@@ -28,7 +28,7 @@ body {
margin: 0;
}
-.smartflow-layout {
+.smartmate-layout {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
@@ -41,7 +41,7 @@ body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
-.smartflow-content {
+.smartmate-content {
flex: 1;
min-width: 0;
min-height: 0;
diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts
index 2ea90d2..49bd392 100644
--- a/frontend/src/api/schedule_agent.ts
+++ b/frontend/src/api/schedule_agent.ts
@@ -90,7 +90,7 @@ export async function applyBatchIntoSchedule(
items,
}, {
headers: {
- 'Idempotency-Key': idempotencyKey
+ 'X-Idempotency-Key': idempotencyKey
}
})
} catch (error) {
diff --git a/frontend/src/components/assistant/ScheduleFineTuneModal.vue b/frontend/src/components/assistant/ScheduleFineTuneModal.vue
index 64a0834..6db3267 100644
--- a/frontend/src/components/assistant/ScheduleFineTuneModal.vue
+++ b/frontend/src/components/assistant/ScheduleFineTuneModal.vue
@@ -86,6 +86,9 @@ async function handleSaveToState() {
}
}
+// 每个预览会话维持一个稳定的幂等键,避免重试或延迟导致的重复落库
+const officialSaveIdempotencyKey = ref(crypto.randomUUID())
+
/**
* 正式保存到数据库 (MySQL)
*/
@@ -122,13 +125,16 @@ async function handleOfficialSave() {
}
})
- const idempotencyKey = crypto.randomUUID()
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
- applyBatchIntoSchedule(classId, groupItems, idempotencyKey)
+ applyBatchIntoSchedule(classId, groupItems, officialSaveIdempotencyKey.value)
)
await Promise.all(promises)
ElMessage.success('日程已正式保存到数据库')
+
+ // 保存成功后刷新幂等键,虽然通常弹窗会关闭,但这是为了逻辑严密
+ officialSaveIdempotencyKey.value = crypto.randomUUID()
+
emit('saved')
emit('close')
} catch (error: any) {
diff --git a/frontend/src/components/assistant/ScheduleResultCard.vue b/frontend/src/components/assistant/ScheduleResultCard.vue
index d98f78e..12f6ccd 100644
--- a/frontend/src/components/assistant/ScheduleResultCard.vue
+++ b/frontend/src/components/assistant/ScheduleResultCard.vue
@@ -41,30 +41,49 @@ const emit = defineEmits<{
gap: 16px;
padding: 16px;
background: #ffffff;
- border: 1px solid rgba(15, 23, 42, 0.08);
- border-radius: 16px;
+ border: 1px solid rgba(15, 23, 42, 0.06);
+ border-radius: 20px;
cursor: pointer;
- transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
- box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
- margin: 8px 0;
+ transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+ margin: 12px 0;
+ position: relative;
+ overflow: hidden;
+ /* 弹出动画 */
+ animation: schedule-card-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+
+@keyframes schedule-card-pop {
+ 0% {
+ opacity: 0;
+ transform: scale(0.9) translateY(10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
}
.schedule-result-card:hover {
- transform: translateY(-2px);
border-color: #3b82f6;
- box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.15);
+ background: #fcfdfe;
+ box-shadow: 0 4px 20px rgba(59, 130, 246, 0.04);
}
.schedule-result-card__icon {
- width: 44px;
- height: 44px;
+ width: 48px;
+ height: 48px;
display: flex;
align-items: center;
justify-content: center;
- background: #eff6ff;
+ background: linear-gradient(135deg, #eff6ff 0%, #dbebff 100%);
color: #3b82f6;
- border-radius: 12px;
+ border-radius: 14px;
flex-shrink: 0;
+ transition: transform 0.3s ease;
+}
+
+.schedule-result-card:hover .schedule-result-card__icon {
+ transform: rotate(-5deg) scale(1.05);
}
.schedule-result-card__content {
@@ -75,23 +94,33 @@ const emit = defineEmits<{
.schedule-result-card__summary {
margin: 0 0 4px;
font-size: 15px;
- font-weight: 700;
- color: #1e293b;
+ font-weight: 850;
+ color: #0f172a;
+ letter-spacing: -0.01em;
}
.schedule-result-card__detail {
margin: 0;
font-size: 13px;
+ font-weight: 500;
color: #64748b;
}
.schedule-result-card__arrow {
- color: #cbd5e1;
- transition: transform 0.3s;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8fafc;
+ border-radius: 10px;
+ color: #94a3b8;
+ transition: all 0.3s;
}
.schedule-result-card:hover .schedule-result-card__arrow {
transform: translateX(4px);
- color: #3b82f6;
+ background: #3b82f6;
+ color: #ffffff;
}
diff --git a/frontend/src/components/assistant/TaskClassPlanningPicker.vue b/frontend/src/components/assistant/TaskClassPlanningPicker.vue
index f9f93aa..93883b3 100644
--- a/frontend/src/components/assistant/TaskClassPlanningPicker.vue
+++ b/frontend/src/components/assistant/TaskClassPlanningPicker.vue
@@ -352,33 +352,57 @@ function formatDateLabel(value: string) {
.assistant-planning__panel {
display: grid;
gap: 14px;
+ /* 弹出动画 */
+ animation: planning-panel-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+
+@keyframes planning-panel-pop {
+ 0% {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
.assistant-planning__panel-header strong {
display: block;
- color: #1f2937;
- font-size: 15px;
+ color: #0f172a;
+ font-size: 16px;
+ font-weight: 850;
+ letter-spacing: -0.01em;
}
.assistant-planning__panel-header p {
- margin: 6px 0 0;
- color: #6b7280;
+ margin: 4px 0 0;
+ color: #64748b;
font-size: 12px;
- line-height: 1.5;
+ line-height: 1.6;
}
.assistant-planning__loading,
.assistant-planning__list {
display: grid;
- gap: 8px;
- max-height: 260px;
+ gap: 6px;
+ max-height: 300px;
overflow-y: auto;
+ padding: 2px;
+ scrollbar-width: thin;
}
.assistant-planning__loading-item {
- height: 62px;
- border-radius: 16px;
- background: linear-gradient(90deg, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.72), rgba(241, 245, 249, 0.9));
+ height: 60px;
+ border-radius: 12px;
+ background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite linear;
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
}
.assistant-planning__item {
@@ -387,66 +411,89 @@ function formatDateLabel(value: string) {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
- padding: 12px 14px;
- border: 1px solid rgba(15, 23, 42, 0.08);
- border-radius: 16px;
- background: #ffffff;
+ padding: 10px 14px;
+ border: 1px solid rgba(15, 23, 42, 0.04);
+ border-radius: 14px;
+ background: #f8fafc;
text-align: left;
- transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.assistant-planning__item:hover {
- border-color: rgba(57, 86, 178, 0.24);
- background: #fafcff;
+ border-color: rgba(59, 130, 246, 0.2);
+ background: #ffffff;
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
+ transform: translateY(-1px);
}
.assistant-planning__item--selected {
- border-color: rgba(57, 86, 178, 0.28);
- background: #f5f8ff;
+ border-color: #3b82f6;
+ background: #ffffff;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.06);
}
.assistant-planning__item-check {
- width: 16px;
- height: 16px;
- border-radius: 999px;
- border: 1.5px solid rgba(148, 163, 184, 0.8);
+ width: 18px;
+ height: 18px;
+ border-radius: 6px;
+ border: 2px solid #e2e8f0;
background: #ffffff;
+ transition: all 0.2s;
}
.assistant-planning__item-check--selected {
- border-color: #3357c2;
- background: radial-gradient(circle at center, #3357c2 0 45%, transparent 46%);
+ border-color: #3b82f6;
+ background: #3b82f6;
+ position: relative;
+}
+
+.assistant-planning__item-check--selected::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 6px;
+ height: 3px;
+ border-left: 2px solid #ffffff;
+ border-bottom: 2px solid #ffffff;
+ transform: translate(-50%, -65%) rotate(-45deg);
}
.assistant-planning__item-body {
min-width: 0;
display: grid;
- gap: 4px;
}
.assistant-planning__item-body strong {
- color: #1f2430;
- font-size: 13px;
+ color: #1e293b;
+ font-size: 14px;
+ font-weight: 700;
}
.assistant-planning__item-body small {
- color: #64748b;
- font-size: 12px;
+ color: #94a3b8;
+ font-size: 11px;
+ font-weight: 500;
+ margin-top: 2px;
}
.assistant-planning__item-slots {
- color: #475569;
+ color: #64748b;
font-size: 12px;
- font-weight: 600;
+ font-weight: 700;
+ background: #f1f5f9;
+ padding: 2px 8px;
+ border-radius: 6px;
}
.assistant-planning__empty {
- padding: 18px 16px;
- border-radius: 16px;
+ padding: 30px 20px;
+ text-align: center;
+ border-radius: 12px;
background: #f8fafc;
- color: #64748b;
+ color: #94a3b8;
font-size: 13px;
- line-height: 1.6;
}
.assistant-planning__panel-actions {
@@ -465,20 +512,31 @@ function formatDateLabel(value: string) {
}
.assistant-planning__panel-button--ghost {
- background: #ffffff;
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.assistant-planning__panel-button--ghost:hover {
+ background: #e2e8f0;
color: #475569;
}
.assistant-planning__panel-button--primary {
- border-color: transparent;
- background: #3357c2;
+ background: #3b82f6;
color: #ffffff;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
+}
+
+.assistant-planning__panel-button--primary:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 6px 15px rgba(59, 130, 246, 0.25);
}
:global(.assistant-planning-popover) {
- padding: 14px;
- border-radius: 20px;
- border: 1px solid rgba(203, 213, 225, 0.78);
- box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
+ padding: 18px !important;
+ border-radius: 20px !important;
+ border: 1px solid rgba(15, 23, 42, 0.08) !important;
+ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12) !important;
}
diff --git a/frontend/src/components/common/MainSidebar.vue b/frontend/src/components/common/MainSidebar.vue
index 5b1d170..0209a2a 100644
--- a/frontend/src/components/common/MainSidebar.vue
+++ b/frontend/src/components/common/MainSidebar.vue
@@ -12,7 +12,6 @@ interface SidebarItem {
const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
- { key: 'task', label: '任务', short: '任' },
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
]
diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue
index ff1d03b..32a9607 100644
--- a/frontend/src/components/dashboard/AssistantPanel.vue
+++ b/frontend/src/components/dashboard/AssistantPanel.vue
@@ -230,6 +230,7 @@ const conversationContextStatsReadyMap = reactive
>({})
const conversationListItemRevealMap = reactive>({})
const scheduleResultMap = reactive>({})
const isFineTuneModalVisible = ref(false)
+const fineTuneLoading = ref(false)
const activeFineTuneData = ref(null)
const quickActions = [
@@ -1301,9 +1302,14 @@ function scheduleScrollMessagesToBottom(smooth = false, force = false) {
}
async function ensureSelectedConversationAfterListLoad() {
+ // 1. 根据用户最新要求:进入页面时不自动加载最后一次对话。
+ // 2. 默认保持 selectedConversationId 为空,从而触发居中的“新会话”看板及动画过渡逻辑。
+ // 3. 用户若需查看历史,可从左侧列表中手动点击。
+ /*
if (!selectedConversationId.value && conversationList.value.length > 0) {
await selectConversation(conversationList.value[0].conversation_id)
}
+ */
}
// loadConversationListData 负责按页读取会话列表,并驱动首屏选中与懒加载状态。
@@ -1601,16 +1607,16 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
break
case 'schedule_completed':
- // 标记该消息需要排程卡片
- // 详情通过 schedule_completed 事件触发的 getSchedulePreview 异步填充
- void (async () => {
- try {
- const preview = await getSchedulePreview(conversationId)
- scheduleResultMap[mid] = preview
- } catch {
- // 吞掉,可能是过期的预览
- }
- })()
+ // 1. 标记该消息需要排程卡片。
+ // 2. 改造点:不在此处立即进行 getSchedulePreview 的异步拉取,
+ // 避免后端还未完成落库、或者并发过高导致的 'schedule plan preview not found' 404 捕获。
+ // 3. 这里先存入占位标志,真正的拉取推迟到用户“点击卡片”时。
+ scheduleResultMap[mid] = {
+ summary: '智能编排方案已就绪',
+ conversation_id: conversationId,
+ hybrid_entries: [],
+ is_placeholder: true, // 内部临时标记
+ } as any
break
}
}
@@ -1761,8 +1767,34 @@ function isManualThinkingEnabled(mode: ThinkingModeType) {
return mode === 'true'
}
-function openFineTuneModal(data: SchedulePreviewData) {
- activeFineTuneData.value = data
+async function openFineTuneModal(data: SchedulePreviewData) {
+ // 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。
+ if ((data as any).is_placeholder) {
+ if (fineTuneLoading.value) return
+
+ fineTuneLoading.value = true
+ try {
+ const realData = await getSchedulePreview(selectedConversationId.value)
+ // 成功后覆盖占位符,下次点击无需再查
+ activeFineTuneData.value = realData
+
+ // 这里的逻辑主要是为了同步界面上的 card 状态(如果是合并消息,对应的 id 为 dm.id)
+ for (const mid of Object.keys(scheduleResultMap)) {
+ if ((scheduleResultMap[mid] as any).is_placeholder) {
+ scheduleResultMap[mid] = realData
+ }
+ }
+ } catch (error: any) {
+ console.error('Lazy load schedule failed:', error)
+ ElMessage.warning('编排方案正在生成中,请稍候再试...')
+ return
+ } finally {
+ fineTuneLoading.value = false
+ }
+ } else {
+ activeFineTuneData.value = data
+ }
+
isFineTuneModalVisible.value = true
}
@@ -1965,6 +1997,9 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
return
}
+ // 结构化 extra 事件(如卡片、工具调用、状态变更)处理逻辑。
+ // 注意:为了避免请求爆炸,不再每个事件都刷新统计信息。仅在里程碑事件(如卡片生成)处精准刷新。
+
if (extra.kind === 'confirm_request') {
// 1. 记录“confirm 到来前是否已存在可见正文/思考”。
// 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。
@@ -1995,6 +2030,9 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
buildToolDetail(extra.tool),
`${extra.tool.name || ''}`,
)
+ if (extra.tool.status === 'done') {
+ void loadConversationContextStats(selectedConversationId.value, true)
+ }
return
}
@@ -2022,16 +2060,18 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
}
if (extra.kind === 'schedule_completed') {
- // 异步拉取详细排程方案
- void (async () => {
- try {
- const preview = await getSchedulePreview(selectedConversationId.value)
- scheduleResultMap[assistantMessage.id] = preview
- scheduleScrollMessagesToBottom(true)
- } catch (error: any) {
- ElMessage.error(error.message || '获取排程方案失败')
- }
- })()
+ // 1. 每当“排程卡片”这种重量级里程碑出现时,刷新统计信息,让用户感知到上下文变动。
+ void loadConversationContextStats(selectedConversationId.value, true)
+
+ // 2. 收到编排完成事件,仅在前端打上占位标记,展示展示卡片。
+ // 不再并发执行异步 fetch,防止后端落库延迟导致的 NotFound。
+ scheduleResultMap[assistantMessage.id] = {
+ summary: '智能编排方案已就绪',
+ conversation_id: selectedConversationId.value,
+ hybrid_entries: [],
+ is_placeholder: true,
+ } as any
+ scheduleScrollMessagesToBottom(true)
}
}
@@ -2069,6 +2109,8 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
}
activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
+ // 整个 SSE 流结束信号
+ void loadConversationContextStats(selectedConversationId.value, true)
return
}
@@ -2127,6 +2169,8 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
}
activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
+ // 单条消息结束标志
+ void loadConversationContextStats(selectedConversationId.value, true)
}
if (!shouldSuppressVisibleDelta) {
@@ -2202,6 +2246,9 @@ async function streamAssistantReply(
: '暂未收到回复正文,请稍后重试。'
}
+ // 4. 传输彻底结束后,做最后一次上下文统计更新,确保最终的 Token 用量胶囊是准确的。
+ void loadConversationContextStats(actualConversationId, true)
+
if (shouldSyncCurrentConversationMeta) {
await syncNewConversationTitleAfterFirstReply(actualConversationId)
}
@@ -2383,14 +2430,7 @@ onBeforeUnmount(() => {
-