Version: 0.9.48.dev.260428

后端:
1.新增任务批量状态查询能力,补齐入参归一化、单次上限控制、按当前用户隔离与空结果兼容。
2.QuickTask 从纯文本升级为“正文 + business_card”输出,覆盖 task_record/task_query 两类卡片语义。
3.查询链路新增时间窗边界筛选与异常窗口兜底,SSE/timeline 同步扩展 business_card 事件并持久化。

前端:
1.助手面板接入任务状态 hydration 与增量同步,卡片状态可实时联动(完成/撤销、编辑、删除、同步中)。
2.TaskRecord/TaskQuery 卡片升级为可交互任务卡,并新增对话页任务编辑弹窗与回写闭环。
3.助手路由升级为 /assistant/:id?,支持 URL 驱动会话切换与刷新恢复。

仓库:
同步更新 business card 前端对接说明文档。
This commit is contained in:
Losita
2026-04-28 00:32:33 +08:00
parent 20d8f2acae
commit 495d520b20
19 changed files with 1864 additions and 212 deletions

View File

@@ -25,8 +25,23 @@ export interface TaskQueryCardTaskItem {
is_completed?: boolean
}
export interface TaskQueryCardFilter {
key:
| 'quadrant'
| 'keyword'
| 'deadline_after'
| 'deadline_before'
| 'include_completed'
| 'sort'
label: string
value: string | number | boolean
operator?: 'eq' | 'contains' | 'gte' | 'lt'
display_text: string
}
export interface TaskQueryCardData {
query_summary?: string
query_filters?: TaskQueryCardFilter[]
result_count: number
shown_count: number
has_more?: boolean

View File

@@ -77,6 +77,26 @@ export async function updateTask(payload: TaskUpdatePayload) {
}
}
export interface TaskBatchStatusItem {
id: number
is_completed: boolean
}
export interface TaskBatchStatusResult {
items: TaskBatchStatusItem[]
}
export async function getTaskBatchStatus(ids: number[]) {
if (ids.length === 0) return []
try {
const response = await http.post<ApiResponse<TaskBatchStatusResult>>('/task/batch-status', { ids })
return response.data.data?.items ?? []
} catch (error) {
console.error('Failed to fetch batch status:', error)
return []
}
}
export async function deleteTask(taskId: number) {
try {
const response = await http.delete<ApiResponse<{ task_id: number }>>(
@@ -93,3 +113,4 @@ export async function deleteTask(taskId: number) {
throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试'))
}
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { TaskQueryCardData } from '@/api/schedule_agent'
const props = defineProps<{
@@ -7,6 +8,53 @@ const props = defineProps<{
summary?: string
}>()
// 注入来自 AssistantPanel 的全局任务状态管理
const taskStatusMap = inject<Record<number, {
is_completed: boolean,
syncing: boolean,
is_deleted?: boolean,
title?: string,
priority_group?: number,
deadline_at?: string | null
}>>('taskStatusMap')
const toggleTaskStatus = inject<(id: number) => Promise<void>>('toggleTaskStatus')
const onEditTask = inject<(task: any) => void>('onEditTask')
const onDeleteTask = inject<(id: number) => void>('onDeleteTask')
const isCompleted = (id: number, fallback: boolean = false) => {
return taskStatusMap?.[id]?.is_completed ?? fallback
}
const isSyncing = (id: number) => {
return taskStatusMap?.[id]?.syncing ?? false
}
const isDeleted = (id: number) => {
return taskStatusMap?.[id]?.is_deleted ?? false
}
// 实时获取覆盖后的属性
const getDisplayTitle = (task: any) => {
if (task?.id && taskStatusMap?.[task.id]?.title) {
return taskStatusMap[task.id].title
}
return task?.title || ''
}
const getDisplayPriority = (task: any) => {
if (task?.id && taskStatusMap?.[task.id]?.priority_group) {
return taskStatusMap[task.id].priority_group
}
return task?.priority_group || 2
}
const getDisplayDeadline = (task: any) => {
if (task?.id && taskStatusMap?.[task.id]?.deadline_at !== undefined) {
return taskStatusMap[task.id].deadline_at
}
return task?.deadline_at
}
// 对齐首页象限体系
const quadMeta: any = {
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
@@ -31,11 +79,28 @@ const getTextColor = (group: number = 2) => {
</script>
<template>
<div class="business-card query-results" :style="{ background: getBgStyle(props.data.tasks[0]?.priority_group) }">
<div class="business-card query-results" :style="{ background: getBgStyle(props.data.tasks?.[0] ? getDisplayPriority(props.data.tasks[0]) : 2) }">
<header class="card-header">
<div class="header-left">
<p class="eyebrow">{{ summary || '查询结果' }}</p>
<h3>{{ title || '为您找到以下任务' }}</h3>
<p class="eyebrow">查询结果</p>
<h3 class="card-title">{{ title || '为您找到以下任务' }}</h3>
<div class="filter-tags" v-if="data.query_filters && data.query_filters.length > 0">
<span v-for="f in data.query_filters" :key="f.key" class="filter-tag">
<template v-if="f.key === 'deadline_after' || f.key === 'deadline_before'">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</template>
<template v-else-if="f.key === 'sort'">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</template>
<template v-else>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</template>
{{ f.display_text }}
</span>
</div>
<p v-else-if="summary" class="query-summary-fallback">{{ summary }}</p>
</div>
<div class="count-badge" v-if="data.result_count > 0">
{{ data.result_count }}
@@ -44,26 +109,66 @@ const getTextColor = (group: number = 2) => {
<div class="card-content">
<div v-if="data.tasks && data.tasks.length > 0" class="task-items">
<div v-for="task in data.tasks" :key="task.id" class="task-item">
<div class="item-check">
<div class="check-circle" :style="{ borderColor: getTextColor(task.priority_group) }"></div>
<div
v-for="task in data.tasks"
:key="task.id"
class="task-item"
:class="{
'is-item-completed': isCompleted(task.id, task.is_completed),
'is-syncing': isSyncing(task.id),
'is-deleted': isDeleted(task.id)
}"
@click="!isDeleted(task.id) && onEditTask?.({ ...task, title: getDisplayTitle(task), priority_group: getDisplayPriority(task), deadline_at: getDisplayDeadline(task) })"
>
<div class="item-check" v-if="!isDeleted(task.id)" @click.stop="toggleTaskStatus?.(task.id)">
<div
class="check-circle"
:class="{
'is-checked': isCompleted(task.id, task.is_completed),
'is-syncing': isSyncing(task.id)
}"
:style="{
borderColor: getTextColor(getDisplayPriority(task)),
backgroundColor: isCompleted(task.id, task.is_completed) ? getTextColor(getDisplayPriority(task)) : 'transparent'
}"
>
<svg v-if="isCompleted(task.id, task.is_completed) && !isSyncing(task.id)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>
<div v-if="isSyncing(task.id)" class="sync-spinner"></div>
</div>
</div>
<div class="item-body">
<div class="item-title">{{ task.title }}</div>
<div class="item-meta">
<div class="item-title">{{ isDeleted(task.id) ? '(任务已删除)' : getDisplayTitle(task) }}</div>
<div class="item-meta" v-if="!isDeleted(task.id)">
<span
class="q-pill"
v-if="task.priority_group"
:style="{ color: getTextColor(task.priority_group), background: getTextColor(task.priority_group) + '10' }"
v-if="getDisplayPriority(task)"
:style="{ color: getTextColor(getDisplayPriority(task)), background: getTextColor(getDisplayPriority(task)) + '10' }"
>
Q{{ task.priority_group }} {{ quadMeta[task.priority_group]?.title }}
Q{{ getDisplayPriority(task) }} {{ quadMeta[getDisplayPriority(task)]?.title }}
</span>
<span v-if="task.deadline_at" class="time-pill">
<span v-if="getDisplayDeadline(task)" class="time-pill">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ task.deadline_at }}
{{ getDisplayDeadline(task) }}
</span>
</div>
</div>
<div class="task-actions" v-if="!isDeleted(task.id)">
<button
class="item-action-btn edit-btn"
@click.stop="onEditTask?.({ ...task, title: getDisplayTitle(task), priority_group: getDisplayPriority(task), deadline_at: getDisplayDeadline(task) })"
title="编辑任务"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button
class="item-action-btn delete-btn"
@click.stop="onDeleteTask?.(task.id)"
title="删除任务"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
</button>
</div>
</div>
</div>
@@ -82,78 +187,239 @@ const getTextColor = (group: number = 2) => {
<style scoped>
.business-card {
width: 100%;
max-width: 400px;
border-radius: 28px;
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.02);
overflow: hidden;
transition: all 0.3s;
background: white;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #ffffff;
margin-bottom: 8px;
}
.business-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.12);
}
.card-header {
padding: 24px 24px 16px;
padding: 20px 24px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
}
.header-left {
flex: 1;
}
.eyebrow {
font-size: 11px;
font-weight: 800;
color: rgba(30, 41, 59, 0.5);
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 6px 0;
margin: 0 0 4px 0;
opacity: 0.8;
}
.card-header h3 {
font-size: 20px;
.card-header h3.card-title {
font-size: 18px;
font-weight: 850;
color: #1e293b;
margin: 0;
line-height: 1.2;
letter-spacing: -0.02em;
color: #0f172a;
margin: 0 0 10px 0;
line-height: 1.3;
letter-spacing: -0.01em;
}
.count-badge {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.8);
border-radius: 100px;
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(15, 23, 42, 0.05);
border: 1px solid rgba(15, 23, 42, 0.03);
border-radius: 8px;
font-size: 11px;
font-weight: 700;
color: #475569;
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
transition: all 0.2s;
}
.filter-tag:hover {
background: rgba(15, 23, 42, 0.08);
color: #1e293b;
}
.tag-icon {
opacity: 0.6;
}
.query-summary-fallback {
font-size: 12px;
color: #64748b;
margin: 4px 0 0;
line-height: 1.4;
font-weight: 600;
}
.count-badge {
padding: 4px 10px;
background: #f1f5f9;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #475569;
margin-left: 12px;
flex-shrink: 0;
}
.card-content {
padding: 16px;
}
.task-items {
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.task-item {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0,0,0,0.04);
border-radius: 18px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
padding: 14px 16px;
display: flex;
gap: 14px;
gap: 12px;
align-items: center;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.task-item:hover {
border-color: rgba(15, 23, 42, 0.15);
background: #f8fafc;
transform: scale(1.01);
}
.task-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: all 0.2s;
}
.task-item:hover .task-actions {
opacity: 1;
}
.item-action-btn {
width: 26px;
height: 26px;
border-radius: 7px;
border: none;
background: rgba(15, 23, 42, 0.04);
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.item-action-btn.edit-btn:hover {
background: #dbeafe;
color: #2563eb;
transform: scale(1.1);
}
.item-action-btn.delete-btn:hover {
background: #fee2e2;
color: #f43f5e;
transform: scale(1.1);
}
.task-item.is-deleted {
opacity: 0.5;
filter: grayscale(0.8);
cursor: not-allowed;
pointer-events: none;
}
.task-item.is-syncing {
pointer-events: none;
opacity: 0.85;
background: #f8fafc;
position: relative;
overflow: hidden;
}
.task-item.is-syncing::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6),
transparent
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.item-check {
flex-shrink: 0;
}
.check-circle {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #e2e8f0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.check-circle.is-syncing {
cursor: not-allowed;
opacity: 0.7;
}
.sync-spinner {
width: 10px;
height: 10px;
border: 1.5px solid rgba(0, 0, 0, 0.1);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.is-item-completed .item-title {
text-decoration: line-through;
opacity: 0.5;
}
.item-body {
@@ -164,8 +430,8 @@ const getTextColor = (group: number = 2) => {
.item-title {
font-size: 14px;
font-weight: 700;
color: #122033;
margin-bottom: 4px;
color: #1e293b;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -173,57 +439,63 @@ const getTextColor = (group: number = 2) => {
.item-meta {
display: flex;
gap: 10px;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.q-pill {
font-size: 9px;
font-weight: 800;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 750;
padding: 2px 8px;
border-radius: 6px;
white-space: nowrap;
}
.time-pill {
font-size: 9px;
color: #94a3b8;
font-size: 10px;
color: #64748b;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
font-weight: 600;
}
.btn-more {
width: calc(100% - 32px);
margin: 16px 16px 20px;
padding: 12px;
border: none;
background: rgba(255, 255, 255, 0.6);
border-radius: 14px;
width: 100%;
margin-top: 16px;
padding: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
border-radius: 12px;
font-size: 12px;
font-weight: 800;
color: #475569;
font-weight: 750;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.btn-more:hover {
background: white;
background: #f8fafc;
color: #0f172a;
border-color: #cbd5e1;
}
.empty-state {
padding: 32px 16px;
padding: 40px 24px;
text-align: center;
color: #94a3b8;
}
.empty-icon {
margin-bottom: 8px;
opacity: 0.5;
margin-bottom: 12px;
opacity: 0.4;
display: flex;
justify-content: center;
}
.empty-state p {
font-size: 13px;
font-size: 14px;
font-weight: 600;
margin: 0;
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { TaskRecordCardData, TaskRecordSource } from '@/api/schedule_agent'
const props = defineProps<{
@@ -8,6 +9,62 @@ const props = defineProps<{
summary?: string
}>()
// 注入来自 AssistantPanel 的全局任务状态管理
const taskStatusMap = inject<Record<number, {
is_completed: boolean,
syncing: boolean,
is_deleted?: boolean,
title?: string,
priority_group?: number,
deadline_at?: string | null
}>>('taskStatusMap')
const toggleTaskStatus = inject<(id: number) => Promise<void>>('toggleTaskStatus')
const onEditTask = inject<(task: any) => void>('onEditTask')
const onDeleteTask = inject<(id: number) => void>('onDeleteTask')
const isCompleted = (id: any, fallback: boolean = false) => {
if (!id) return fallback
const nid = Number(id)
return taskStatusMap?.[nid]?.is_completed ?? fallback
}
const isSyncing = (id: any) => {
if (!id) return false
const nid = Number(id)
return taskStatusMap?.[nid]?.syncing ?? false
}
const isDeleted = (id: any) => {
if (!id) return false
const nid = Number(id)
return taskStatusMap?.[nid]?.is_deleted ?? false
}
// 实时获取覆盖后的属性
const displayTitle = computed(() => {
const id = props.data.id ? Number(props.data.id) : null
if (id && taskStatusMap?.[id]?.title) {
return taskStatusMap[id].title
}
return props.data.title
})
const displayPriority = computed(() => {
const id = props.data.id ? Number(props.data.id) : null
if (id && taskStatusMap?.[id]?.priority_group) {
return taskStatusMap[id].priority_group
}
return props.data.priority_group || 2
})
const displayDeadline = computed(() => {
const id = props.data.id ? Number(props.data.id) : null
if (id && taskStatusMap?.[id]?.deadline_at !== undefined) {
return taskStatusMap[id].deadline_at
}
return props.data.deadline_at
})
// 对齐首页象限体系
const quadMeta: any = {
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
@@ -32,32 +89,98 @@ const getTextColor = (group: number = 2) => {
</script>
<template>
<div class="business-card creation-receipt" :style="{ background: getBgStyle(props.data.priority_group) }">
<div
class="business-card creation-receipt"
:class="{ 'is-card-deleted': data.id && isDeleted(data.id) }"
:style="{ background: getBgStyle(displayPriority) }"
>
<div class="receipt-inner">
<div class="receipt-header">
<div class="success-ring" :style="{ background: getTextColor(props.data.priority_group) + '20', color: getTextColor(props.data.priority_group) }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
<div
class="success-ring"
:class="{
'is-completed': isCompleted(data.id),
'is-clickable': data.id && !isDeleted(data.id),
'is-syncing': data.id && isSyncing(data.id)
}"
:style="{
background: isDeleted(data.id) ? 'rgba(100, 116, 139, 0.1)' : (isCompleted(data.id) ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.125)'),
color: isDeleted(data.id) ? '#64748b' : (isCompleted(data.id) ? '#22c55e' : '#ef4448')
}"
@click.stop="data.id && !isDeleted(data.id) && toggleTaskStatus?.(data.id)"
>
<svg v-if="isDeleted(data.id)" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
<svg v-else-if="isSyncing(data.id)" class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<div class="success-msg">
<strong>{{ title || (source === 'quick_note' ? '已帮您记下' : '任务已创建') }}</strong>
<span v-if="data.priority_group">归类至{{ quadMeta[data.priority_group].title }}</span>
<strong v-if="isDeleted(data.id)">任务已删除</strong>
<strong v-else-if="isCompleted(data.id)">任务已完成</strong>
<strong v-else>已帮你记下</strong>
<span v-if="!isDeleted(data.id)">归类至{{ quadMeta[displayPriority]?.title || '重要不紧急' }}</span>
</div>
</div>
<div class="task-info-card">
<div class="task-title">{{ data.title }}</div>
<div
class="task-info-card"
:class="{
'is-item-completed': data.id && isCompleted(data.id),
'is-syncing': data.id && isSyncing(data.id),
'is-deleted': data.id && isDeleted(data.id)
}"
@click="!isDeleted(data.id) && onEditTask?.({ ...data, title: displayTitle, priority_group: displayPriority, deadline_at: displayDeadline })"
>
<div class="task-main-row">
<div class="item-check" v-if="data.id && !isDeleted(data.id)" @click.stop="toggleTaskStatus?.(data.id)">
<div
class="check-circle"
:class="{
'is-checked': isCompleted(data.id),
'is-syncing': isSyncing(data.id)
}"
:style="{
borderColor: getTextColor(displayPriority),
backgroundColor: isCompleted(data.id) ? getTextColor(displayPriority) : 'transparent'
}"
>
<svg v-if="isCompleted(data.id) && !isSyncing(data.id)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>
<div v-if="isSyncing(data.id)" class="sync-spinner"></div>
</div>
</div>
<div class="task-title">{{ displayTitle }}</div>
<div class="task-actions" v-if="data.id && !isDeleted(data.id)">
<!-- 编辑按钮 -->
<button
class="item-action-btn edit-btn"
@click.stop="onEditTask?.({ ...data, title: displayTitle, priority_group: displayPriority, deadline_at: displayDeadline })"
title="编辑任务"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<!-- 删除按钮 -->
<button
class="item-action-btn delete-btn"
@click.stop="onDeleteTask?.(data.id)"
title="删除任务"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
</button>
</div>
</div>
<div class="task-footer">
<span class="task-id" v-if="data.id">ID: {{ data.id }}</span>
<span class="task-time" v-if="data.created_at || data.deadline_at">
{{ data.deadline_at ? '截止' + data.deadline_at : '刚刚创建' }}
<span class="task-time" v-if="data.created_at || displayDeadline">
{{ displayDeadline ? '截止' + displayDeadline : '刚刚创建' }}
</span>
</div>
</div>
<div class="receipt-actions">
<button class="btn-outline">修改详情</button>
<button class="btn-fill" :style="{ background: getTextColor(props.data.priority_group) }">打开查看</button>
</div>
</div>
</div>
</template>
@@ -65,12 +188,18 @@ const getTextColor = (group: number = 2) => {
<style scoped>
.business-card {
width: 100%;
max-width: 400px;
border-radius: 28px;
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.02);
overflow: hidden;
transition: all 0.3s;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 8px;
}
.business-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.12);
}
.receipt-inner {
@@ -82,76 +211,248 @@ const getTextColor = (group: number = 2) => {
.receipt-header {
display: flex;
gap: 14px;
gap: 16px;
align-items: center;
}
.success-ring {
width: 44px;
height: 44px;
border-radius: 50%;
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.success-ring.is-clickable {
cursor: pointer;
}
.success-ring.is-clickable:hover {
transform: scale(1.1) rotate(5deg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.success-ring.is-completed {
background: rgba(34, 197, 94, 0.15) !important;
color: #22c55e !important;
}
.spinner {
animation: rotate 2s linear infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.success-msg {
display: flex;
flex-direction: column;
gap: 2px;
}
.success-msg strong {
font-size: 15px;
font-size: 16px;
font-weight: 850;
color: #0f172a;
letter-spacing: -0.01em;
}
.success-msg span {
font-size: 12px;
color: #64748b;
font-weight: 500;
font-weight: 600;
}
.task-info-card {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0,0,0,0.03);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 20px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.01);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
overflow: hidden;
}
.task-info-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
border-color: rgba(37, 99, 235, 0.2);
}
.task-actions {
margin-left: auto;
display: flex;
gap: 8px;
opacity: 0;
transition: all 0.2s;
}
.task-info-card:hover .task-actions {
opacity: 1;
}
.item-action-btn {
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: rgba(15, 23, 42, 0.05);
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.item-action-btn.edit-btn:hover {
background: #dbeafe;
color: #2563eb;
transform: scale(1.1);
}
.item-action-btn.delete-btn:hover {
background: #fee2e2;
color: #f43f5e;
transform: scale(1.1);
}
.task-info-card.is-deleted {
opacity: 0.6;
filter: grayscale(0.5);
cursor: not-allowed;
pointer-events: none;
}
.is-card-deleted {
opacity: 0.7;
}
.task-info-card.is-syncing {
pointer-events: none;
opacity: 0.8;
position: relative;
overflow: hidden;
}
.task-info-card.is-syncing::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6),
transparent
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.task-main-row {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
.item-check {
padding-top: 2px;
}
.check-circle {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
flex-shrink: 0;
}
.check-circle.is-syncing {
cursor: not-allowed;
opacity: 0.7;
}
.sync-spinner {
width: 10px;
height: 10px;
border: 1.5px solid rgba(0, 0, 0, 0.1);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.is-item-completed .task-title {
text-decoration: line-through;
opacity: 0.5;
}
.task-title {
font-size: 16px;
font-size: 17px;
font-weight: 800;
color: #1e293b;
margin-bottom: 12px;
line-height: 1.4;
margin: 0;
line-height: 1.5;
letter-spacing: -0.01em;
}
.task-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
font-weight: 600;
font-weight: 700;
color: #94a3b8;
border-top: 1px solid #f1f5f9;
padding-top: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
padding-top: 12px;
}
.task-id {
background: #f1f5f9;
padding: 2px 8px;
border-radius: 6px;
color: #64748b;
}
.receipt-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 12px;
}
.btn-outline {
height: 42px;
height: 44px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 12px;
background: #ffffff;
border-radius: 14px;
font-size: 13px;
font-weight: 750;
font-weight: 800;
color: #475569;
cursor: pointer;
transition: all 0.2s;
@@ -159,22 +460,25 @@ const getTextColor = (group: number = 2) => {
.btn-outline:hover {
background: #f8fafc;
color: #0f172a;
border-color: #cbd5e1;
}
.btn-fill {
height: 42px;
height: 44px;
border: none;
border-radius: 12px;
border-radius: 14px;
color: white;
font-size: 13px;
font-weight: 800;
font-weight: 850;
cursor: pointer;
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transition: all 0.2s;
}
.btn-fill:hover {
filter: brightness(1.1);
filter: brightness(1.05);
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import ContextWindowMeter from '@/components/assistant/ContextWindowMeter.vue'
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
@@ -9,6 +10,13 @@ import {
getConversationList,
getConversationMeta,
} from '@/api/agent'
import {
completeTask,
undoCompleteTask,
getTaskBatchStatus,
updateTask,
deleteTask
} from '@/api/task'
import {
getSchedulePreview,
getConversationTimeline,
@@ -33,7 +41,11 @@ import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.
import { formatConversationTime, formatMessageTime } from '@/utils/date'
import { renderMarkdown } from '@/utils/markdown'
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
import type { TimelineBusinessCardPayload } from '@/api/schedule_agent'
import type {
TimelineBusinessCardPayload,
TaskQueryCardData,
TaskRecordCardData
} from '@/api/schedule_agent'
interface StreamDeltaPayload {
content?: string
@@ -74,6 +86,7 @@ interface StreamExtraPayload {
status?: StreamStatusExtraPayload
tool?: StreamToolExtraPayload
confirm?: StreamConfirmPayload
business_card?: TimelineBusinessCardPayload
}
interface StreamEventPayload {
@@ -180,6 +193,8 @@ const props = withDefaults(
)
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const assistantBodyRef = ref<HTMLElement | null>(null)
const messageViewportRef = ref<HTMLElement | null>(null)
@@ -189,7 +204,8 @@ const conversationLoading = ref(true)
const conversationLoadingMore = ref(false)
const chatLoading = ref(false)
const historyExpanded = ref(true)
const selectedConversationId = ref('')
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
const selectedConversationId = ref(isStandaloneMode.value && route.params.id ? (route.params.id as string) : '')
const selectedThinkingMode = ref<ThinkingModeType>('auto')
const selectedExecutionMode = ref<'manual' | 'always'>('manual')
@@ -210,6 +226,18 @@ const confirmOverlayState = reactive<ConfirmOverlayState>({
summary: '',
})
// 任务编辑相关状态(同首页看板)
const taskDialogVisible = ref(false)
const isEditMode = ref(false)
const editingTaskId = ref<number | null>(null)
const saveTaskLoading = ref(false)
const taskForm = reactive({
title: '',
priority_group: 2,
deadline_at: null as Date | null,
urgency_threshold_at: null as Date | null,
})
const conversationPage = ref(1)
const conversationPageSize = 12
const conversationHasMore = ref(false)
@@ -242,6 +270,169 @@ const isFineTuneModalVisible = ref(false)
const fineTuneLoading = ref(false)
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
// 任务状态叠加层,用于实时同步和交互
interface TaskStatusState {
is_completed: boolean
syncing: boolean
is_deleted?: boolean
title?: string
priority_group?: number
deadline_at?: string | null
}
const taskStatusMap = reactive<Record<number, TaskStatusState>>({})
/**
* 切换任务完成状态
* 1. 检查当前是否正在同步,避免重复点击
* 2. 乐观更新 UI 或标记 syncing
* 3. 调用后端接口反转状态
* 4. 失败时回滚并报错
*/
async function toggleTaskStatus(taskId: number) {
const current = taskStatusMap[taskId]
if (current?.syncing || current?.is_deleted) return
const wasCompleted = current?.is_completed ?? false
// 标记同步中
if (!taskStatusMap[taskId]) {
taskStatusMap[taskId] = { is_completed: wasCompleted, syncing: true }
} else {
taskStatusMap[taskId].syncing = true
}
try {
if (wasCompleted) {
await undoCompleteTask(taskId)
} else {
await completeTask(taskId)
}
taskStatusMap[taskId].is_completed = !wasCompleted
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
taskStatusMap[taskId].syncing = false
}
}
async function handleTaskDelete(taskId: number) {
try {
// 1. 弹出确认框,避免误删。
await ElMessageBox.confirm('确定要删除此任务吗?删除后不可恢复。', '确认删除', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
roundButton: true
})
// 2. 标记同步中
if (!taskStatusMap[taskId]) {
taskStatusMap[taskId] = { is_completed: false, syncing: true }
} else {
taskStatusMap[taskId].syncing = true
}
// 3. 调用后端接口删除
await deleteTask(taskId)
// 4. 标记为已删除并停止同步
taskStatusMap[taskId].is_deleted = true
taskStatusMap[taskId].syncing = false
ElMessage.success('已成功删除任务')
} catch (error: any) {
if (error === 'cancel') return
// 失败时恢复状态
if (taskStatusMap[taskId]) {
taskStatusMap[taskId].syncing = false
}
ElMessage.error(error.message || '删除失败')
}
}
/**
* 批量刷新当前时间线中所有卡片任务的真实状态
* 1. 扫描 businessCardEventsMap 中所有已发现的 task id
* 2. 调用 batch-status 接口回填
*/
async function hydrateTaskStatuses(conversationId: string) {
// 确保在 Vue 状态更新后执行
await nextTick()
const messages = conversationMessagesMap[conversationId]
if (!messages || messages.length === 0) return
const ids = new Set<number>()
messages.forEach(msg => {
// 同时也扫描消息本身可能附带的 extra用于 SSE 在线消息)
if (msg.extra?.business_card) {
const card = msg.extra.business_card
if (card.card_type === 'task_query') {
const data = card.data as any
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
} else if (card.card_type === 'task_record') {
const data = card.data as any
if (data.id) ids.add(data.id)
}
}
// 扫描历史恢复的卡片事件
const cardList = businessCardEventsMap[msg.id]
if (cardList) {
cardList.forEach(card => {
if (card.card_type === 'task_query') {
const data = card.data as any
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
} else if (card.card_type === 'task_record') {
const data = card.data as any
if (data.id) ids.add(data.id)
}
})
}
})
if (ids.size === 0) {
return
}
const idList = Array.from(ids)
// 初始化 syncing
idList.forEach(id => {
if (!taskStatusMap[id]) {
taskStatusMap[id] = { is_completed: false, syncing: true }
} else {
taskStatusMap[id].syncing = true
}
})
try {
const items = await getTaskBatchStatus(idList)
items.forEach(item => {
const id = Number(item.id)
if (taskStatusMap[id]) {
// 合并更新,避免丢失已有属性
taskStatusMap[id].is_completed = item.is_completed
taskStatusMap[id].syncing = false
} else {
taskStatusMap[id] = { is_completed: item.is_completed, syncing: false }
}
})
} catch (err) {
console.error('[Hydration] Batch status fetch failed:', err)
} finally {
// 兜底:确保所有 ID 退出 syncing 状态
idList.forEach(id => {
if (taskStatusMap[id]?.syncing) {
taskStatusMap[id].syncing = false
}
})
}
}
provide('taskStatusMap', taskStatusMap)
provide('toggleTaskStatus', toggleTaskStatus)
provide('onEditTask', handleTaskEdit)
provide('onDeleteTask', handleTaskDelete)
const quickActions = [
'帮我梳理今天最重要的三件事',
'把当前任务拆成可执行步骤',
@@ -263,7 +454,7 @@ const shouldAutoFollowMessages = ref(true)
const messageBottomTolerancePx = 24
const isProgrammaticMessageScroll = ref(false)
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
const shouldShowDialogConfirmOverlay = computed(() => confirmOverlayState.visible)
const assistantBodyStyle = computed(() => {
@@ -877,6 +1068,7 @@ function isAssistantTimelineKind(kind: string) {
'schedule_completed',
'interrupt',
'status',
'business_card',
])
return assistantKinds.has(kind)
}
@@ -990,6 +1182,9 @@ function migrateConversationState(fromConversationId: string, toConversationId:
if (selectedConversationId.value === fromConversationId) {
selectedConversationId.value = toConversationId
if (isStandaloneMode.value) {
router.replace({ name: 'assistant', params: { id: toConversationId } })
}
}
}
@@ -1537,9 +1732,16 @@ function scheduleScrollMessagesToBottom(smooth = false, force = false) {
}
async function ensureSelectedConversationAfterListLoad() {
// 1. 根据用户最新要求:进入页面时不自动加载最后一次对话。
// 2. 默认保持 selectedConversationId 为空,从而触发居中的“新会话”看板及动画过渡逻辑。
// 3. 用户若需查看历史,可从左侧列表中手动点击。
// 1. 如果 URL 中显式指定了 ID (Standalone 模式),优先根据 URL 恢复状态
if (isStandaloneMode.value && route.params.id) {
const urlId = route.params.id as string
if (urlId !== selectedConversationId.value) {
await selectConversation(urlId)
return
}
}
// 2. 否则遵循用户最新要求:进入页面时不自动加载最后一次对话,保持新会话状态。
/*
if (!selectedConversationId.value && conversationList.value.length > 0) {
await selectConversation(conversationList.value[0].conversation_id)
@@ -1842,12 +2044,6 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
// 在刷新恢复场景下,我们只需设置状态即可。
}
break
case 'business_card':
if (event.payload?.business_card) {
appendBusinessCardEvent(mid, event.payload.business_card)
}
break
case 'business_card':
if (event.payload?.business_card) {
appendBusinessCardEvent(mid, event.payload.business_card, event.seq)
@@ -1871,6 +2067,7 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
}
if (!forceReload && conversationMessagesMap[conversationId] && unavailableHistoryMap[conversationId] !== true) {
void hydrateTaskStatuses(conversationId)
return
}
@@ -1878,6 +2075,8 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
const events = await getConversationTimeline(conversationId)
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
unavailableHistoryMap[conversationId] = false
// 时间线恢复后立即启动任务状态同步Hydration
void hydrateTaskStatuses(conversationId)
} catch (error) {
console.error('Failed to load timeline:', error)
unavailableHistoryMap[conversationId] = true
@@ -1885,6 +2084,16 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
}
}
// 监听当前会话消息变化,实时触发状态同步
watch(
() => selectedConversationId.value ? conversationMessagesMap[selectedConversationId.value]?.length : 0,
(newLen) => {
if (newLen > 0 && selectedConversationId.value) {
void hydrateTaskStatuses(selectedConversationId.value)
}
}
)
async function ensureConversationMeta(
conversationId: string,
options: EnsureConversationMetaOptions = {},
@@ -1981,6 +2190,12 @@ async function selectConversation(conversationId: string) {
cancelEditUserMessage()
resetConfirmOverlay()
selectedConversationId.value = conversationId
// 仅在 Standalone 模式下将状态同步到 URL实现可刷新/可分享
if (isStandaloneMode.value && route.params.id !== conversationId) {
router.push({ name: 'assistant', params: { id: conversationId || undefined } })
}
await Promise.allSettled([
loadConversationMessages(conversationId),
ensureConversationMeta(conversationId),
@@ -1993,6 +2208,12 @@ function startNewConversation() {
cancelEditUserMessage()
resetConfirmOverlay()
selectedConversationId.value = ''
// 清除 URL 中的 ID
if (isStandaloneMode.value && route.params.id) {
router.push({ name: 'assistant', params: { id: undefined } })
}
messageInput.value = ''
activeStreamingMessageId.value = ''
shouldAutoFollowMessages.value = true
@@ -2113,6 +2334,57 @@ function handleConfirmRejectInputEnter(event: KeyboardEvent) {
void submitConfirmRejectMessage()
}
// 任务管理逻辑(对齐首页)
function handleTaskEdit(task: any) {
isEditMode.value = true
editingTaskId.value = task.id
taskForm.title = task.title
taskForm.priority_group = task.priority_group || 2
taskForm.deadline_at = task.deadline_at || task.deadline ? new Date(task.deadline_at || task.deadline) : null
taskForm.urgency_threshold_at = task.urgency_threshold_at ? new Date(task.urgency_threshold_at) : null
taskDialogVisible.value = true
}
async function handleSaveTask() {
if (!taskForm.title.trim()) {
ElMessage.warning('标题不能为空')
return
}
saveTaskLoading.value = true
try {
if (isEditMode.value && editingTaskId.value) {
const taskId = editingTaskId.value
const updateData = {
task_id: taskId,
title: taskForm.title.trim(),
priority_group: taskForm.priority_group,
deadline_at: taskForm.deadline_at ? (typeof taskForm.deadline_at === 'string' ? taskForm.deadline_at : taskForm.deadline_at.toISOString()) : null,
urgency_threshold_at: taskForm.urgency_threshold_at ? (typeof taskForm.urgency_threshold_at === 'string' ? taskForm.urgency_threshold_at : taskForm.urgency_threshold_at.toISOString()) : null,
}
await updateTask(updateData)
// 同步更新本地状态映射,让所有历史卡片实时联动
if (taskStatusMap[taskId]) {
taskStatusMap[taskId].title = updateData.title
taskStatusMap[taskId].priority_group = updateData.priority_group
// 格式化截止时间用于展示
taskStatusMap[taskId].deadline_at = taskForm.deadline_at
? (taskForm.deadline_at instanceof Date
? taskForm.deadline_at.toLocaleDateString('zh-CN').replace(/\//g, '-')
: String(taskForm.deadline_at).split('T')[0])
: null
}
ElMessage.success('任务详情已更新')
}
taskDialogVisible.value = false
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saveTaskLoading.value = false
}
}
function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
if (!confirmPayload) {
return
@@ -2298,6 +2570,33 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
if (extra.kind === 'business_card' && extra.business_card) {
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
scheduleScrollMessagesToBottom(true)
// SSE 在线接收到新卡片时,也尝试同步一次其状态(主要针对立即生成的任务)
const card = extra.business_card
const ids: number[] = []
if (card.card_type === 'task_query') {
(card.data as TaskQueryCardData).tasks?.forEach(t => { if (t.id) ids.push(t.id) })
} else if (card.card_type === 'task_record') {
const id = (card.data as TaskRecordCardData).id
if (id) ids.push(id)
}
if (ids.length > 0) {
ids.forEach(id => {
if (!taskStatusMap[id]) {
taskStatusMap[id] = { is_completed: false, syncing: true }
}
})
void getTaskBatchStatus(ids).then(items => {
items.forEach(item => {
taskStatusMap[item.id] = { is_completed: item.is_completed, syncing: false }
})
}).finally(() => {
ids.forEach(id => {
if (taskStatusMap[id]?.syncing) taskStatusMap[id].syncing = false
})
})
}
}
}
@@ -2619,6 +2918,19 @@ watch(
},
)
// 监听路由参数 id 的变化,实现前进/后退同步切换对话
watch(
() => route.params.id,
(newId) => {
if (isStandaloneMode.value) {
const targetId = (newId as string) || ''
if (targetId !== selectedConversationId.value) {
selectConversation(targetId)
}
}
}
)
onMounted(async () => {
reasoningTicker = window.setInterval(() => {
@@ -2626,6 +2938,12 @@ onMounted(async () => {
}, 1000)
window.addEventListener('resize', syncHistoryPanelWidthForViewport)
syncHistoryPanelWidthForViewport()
// 如果 URL 中有 ID则立即启动加载不等会话列表返回避免闪烁主页态
if (isStandaloneMode.value && route.params.id) {
void selectConversation(route.params.id as string)
}
await loadConversationListData(true)
syncHistoryPanelWidthForViewport()
})
@@ -2754,7 +3072,7 @@ onBeforeUnmount(() => {
<section
class="assistant-chat dashboard-item-pop"
:class="{ 'assistant-chat--empty': !selectedMessages.length && !chatLoading }"
:class="{ 'assistant-chat--empty': (!selectedConversationId || isDraftConversationId(selectedConversationId)) && !selectedMessages.length && !chatLoading }"
:style="{ '--anim-delay': '0.1s' }"
>
<div class="assistant-chat__spacer-top" />
@@ -2764,11 +3082,13 @@ onBeforeUnmount(() => {
@scroll.passive="handleMessageViewportScroll"
@wheel.passive="handleMessageViewportWheel"
>
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
当前会话的历史消息暂时不可读但你仍然可以继续追问后续刷新后会自动恢复
</div>
<transition name="chat-content-fade">
<div :key="selectedConversationId" class="assistant-messages__inner">
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
当前会话的历史消息暂时不可读但你仍然可以继续追问后续刷新后会自动恢复
</div>
<TransitionGroup v-if="selectedMessages.length" tag="div" name="message-stagger" class="assistant-message-list">
<TransitionGroup v-if="selectedMessages.length" tag="div" name="message-stagger" class="assistant-message-list">
<article
v-for="dm in displayMessages"
:key="dm.id"
@@ -2932,7 +3252,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-else-if="block.type === 'business_card'" class="chat-message__business-card">
<div v-else-if="block.type === 'business_card' && block.businessCard" class="chat-message__business-card">
<BusinessCardRenderer :payload="block.businessCard" />
</div>
@@ -2971,13 +3291,15 @@ onBeforeUnmount(() => {
<span class="chat-message__time">{{ formatMessageTime(dm.createdAt) }}</span>
</div>
</article>
</TransitionGroup>
</TransitionGroup>
</div>
</transition>
</div>
<div class="assistant-chat__interaction-group">
<!-- Welcome Content (Only in empty state) -->
<Transition name="fade-switch">
<div v-if="!selectedMessages.length && !chatLoading" class="assistant-empty">
<div v-if="(!selectedConversationId || isDraftConversationId(selectedConversationId)) && !selectedMessages.length && !chatLoading" class="assistant-empty">
<div class="assistant-empty__halo" />
<div class="assistant-empty__content">
<strong>SmartMate AI 伙伴</strong>
@@ -3192,6 +3514,66 @@ onBeforeUnmount(() => {
@close="closeFineTuneModal"
@saved="handleScheduleSaved"
/>
<!-- 任务编辑弹窗 (对齐首页) -->
<el-dialog
v-model="taskDialogVisible"
:title="isEditMode ? '修改任务详情' : '创建新任务'"
width="440px"
append-to-body
destroy-on-close
class="task-edit-dialog"
>
<div class="task-form">
<div class="form-item">
<label>任务标题</label>
<el-input
v-model="taskForm.title"
placeholder="你想做点什么?"
maxlength="100"
show-word-limit
/>
</div>
<div class="form-item">
<label>优先级象限</label>
<el-radio-group v-model="taskForm.priority_group" class="priority-selector">
<el-radio-button :value="1">重要紧急</el-radio-button>
<el-radio-button :value="2">重要不紧急</el-radio-button>
<el-radio-button :value="3">简单琐碎</el-radio-button>
<el-radio-button :value="4">暂缓处理</el-radio-button>
</el-radio-group>
</div>
<div class="form-row">
<div class="form-item">
<label>截止日期</label>
<el-date-picker
v-model="taskForm.deadline_at"
type="datetime"
placeholder="选个截止时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
:clearable="true"
/>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="taskDialogVisible = false" round>取消</el-button>
<el-button
type="primary"
@click="handleSaveTask"
:loading="saveTaskLoading"
round
>
保存更改
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
@@ -3213,32 +3595,29 @@ onBeforeUnmount(() => {
.fade-switch-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-switch-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.message-stagger-enter-active,
.message-stagger-leave-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.message-stagger-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
.message-stagger-leave-to {
opacity: 0;
transform: translateX(30px) scale(0.95);
}
.message-stagger-leave-active {
position: absolute;
width: 100%;
pointer-events: none;
}
.inner-fade-enter-active,
@@ -3248,13 +3627,42 @@ onBeforeUnmount(() => {
.inner-fade-enter-from {
opacity: 0;
transform: translateY(5px);
}
.inner-fade-leave-to {
opacity: 0;
}
.chat-content-fade-enter-active,
.chat-content-fade-leave-active {
transition:
opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.4s ease;
}
.chat-content-fade-leave-active {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
z-index: 0;
}
.chat-content-fade-enter-active {
z-index: 1;
}
.chat-content-fade-enter-from {
opacity: 0;
filter: blur(8px);
}
.chat-content-fade-leave-to {
opacity: 0;
filter: blur(8px);
}
.assistant-shell {
height: 100%;
min-height: 0;
@@ -3746,12 +4154,10 @@ onBeforeUnmount(() => {
.assistant-chat__spacer-top {
flex: 0;
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.assistant-chat__spacer-bottom {
flex: 0;
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.assistant-chat--empty .assistant-chat__spacer-top {
@@ -3766,10 +4172,18 @@ onBeforeUnmount(() => {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden; /* 关键:防止转场时的水平抖动 */
position: relative;
/* 隐藏滚动条,保持纯净感,仅在非空时显示 */
scrollbar-width: thin;
}
.assistant-messages__inner {
display: flex;
flex-direction: column;
min-height: 100%;
}
.assistant-chat--empty .assistant-messages {
flex: 0;
overflow: hidden;
@@ -5051,6 +5465,129 @@ onBeforeUnmount(() => {
background: rgba(255, 255, 255, 0.9) !important;
}
:global(.task-edit-dialog) {
border-radius: 32px !important;
overflow: hidden !important;
border: none !important;
box-shadow: 0 40px 100px rgba(15, 23, 42, 0.18) !important;
background: #ffffff !important;
}
:global(.task-edit-dialog .el-dialog__header) {
margin: 0 !important;
padding: 40px 40px 10px !important;
border-bottom: none !important;
}
:global(.task-edit-dialog .el-dialog__title) {
font-size: 26px !important;
font-weight: 900 !important;
color: #0f172a !important;
letter-spacing: -0.04em !important;
}
:global(.task-edit-dialog .el-dialog__headerbtn) {
top: 40px !important;
right: 40px !important;
width: 40px !important;
height: 40px !important;
background: #f8fafc !important;
border: none !important;
}
:global(.task-edit-dialog .el-dialog__body) {
padding: 10px 40px 40px !important;
}
:global(.task-edit-dialog .el-dialog__footer) {
padding: 0 40px 40px !important;
}
.task-form {
display: flex;
flex-direction: column;
gap: 32px;
}
.form-item label {
display: block;
font-size: 11px;
font-weight: 900;
color: #cbd5e1;
margin-bottom: 14px;
margin-left: 2px;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.task-form :deep(.el-input__wrapper) {
background: #fcfdfe !important;
box-shadow: none !important;
border: 2px solid #f1f5f9 !important;
border-radius: 20px !important;
padding: 14px 22px !important;
}
.task-form :deep(.el-input__wrapper.is-focus) {
background: #ffffff !important;
border-color: #3b82f6 !important;
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.08) !important;
}
.priority-selector {
display: flex;
width: 100%;
gap: 10px;
background: #f8fafc;
padding: 8px;
border-radius: 24px;
}
.priority-selector :deep(.el-radio-button__inner) {
width: 100% !important;
border: none !important;
background: transparent !important;
font-size: 12px;
font-weight: 800;
color: #94a3b8;
padding: 14px 4px !important;
border-radius: 18px !important;
}
.priority-selector :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: #ffffff !important;
color: #3b82f6 !important;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06) !important;
}
.dialog-footer {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.dialog-footer .el-button {
width: 100%;
margin: 0 !important;
height: 60px;
font-size: 16px;
font-weight: 900;
border-radius: 22px;
}
.dialog-footer .el-button--primary {
background: #0f172a !important; /* Midnight flat style */
color: #ffffff !important;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.2);
}
.dialog-footer .el-button--primary:hover {
background: #1e293b !important;
transform: translateY(-2px);
box-shadow: 0 25px 50px rgba(15, 23, 42, 0.25);
}
:global(.premium-msg-box .el-message-box__header) {
padding-bottom: 8px !important;
}

View File

@@ -38,7 +38,7 @@ const router = createRouter({
},
},
{
path: '/assistant',
path: '/assistant/:id?',
name: 'assistant',
component: AssistantView,
meta: {

View File

@@ -103,6 +103,7 @@ export interface AssistantMessage {
content: string
createdAt: string
reasoning?: string
extra?: any
}
export type ThinkingModeType = 'auto' | 'true' | 'false'