Version: 0.9.30.dev.260419

后端:
1. 工具事件推送从通用 EmitStatus 重构为 EmitToolCallStart / EmitToolCallResult 结构化双事件
- newAgent/node/execute.go:executeToolCall / executePendingTool 两处调用路径分离为 Start + Result 两步推送;blocked 场景显式传入状态码,不再复用通用状态
- 新增工具事件摘要生成链:resolveToolEventResultStatus(结果状态映射)、buildToolEventResultSummary / tryExtractToolResultSummaryCN(JSON→中文结论提炼)、buildToolCallStartSummary /
buildToolArgumentsPreviewCN(参数白名单中文标签)、resolveToolDisplayNameCN(17 个工具中文名映射)、formatToolArgValueByKeyCN / formatToolArgValueCN(参数值格式化)
- newAgent/stream/emitter.go:EmitStatus / EmitToolCallStart / EmitToolCallResult 统一收敛到 emitExtraOnly,不再回写 reasoning_content;EmitToolCallResult 新增 status 参数

前端:
1. 全局侧边栏统一提升到 App.vue
- App.vue 新增 MainSidebar + smartflow-layout 双栏布局,三个页面共享导航;AssistantView / DashboardView / ScheduleView 移除各自内联 sidebar 定义、路由跳转、拖拽逻辑及全部样式
2. Assistant 消息流从纯文本重构为结构化 block 时间线
- AssistantPanel 新增 ToolTraceEvent / StatusTraceEvent / DisplayAssistantBlock 类型;handleStreamExtraEvent 按 extra.kind 分发四类事件;getDisplayAssistantBlocks 按 seq 排序统一渲染 tool/status/reasoning/content 五种 block
- 模板层改为 TransitionGroup 动态渲染;工具卡片左侧彩色状态条 + 可展开详情;状态行显示节点阶段中文文案;兼容旧协议 tool_* 状态码归并
- SSE 协议切换到 extra-only:shouldSuppressReasoningDeltaByExtraKind 抑制 status/tool 事件的 reasoning 累积;会话/首页加载锁定 800ms 最少时间保证骨架屏动画
3. 设计系统从渐变毛玻璃迁移到 Flat Modern 扁平化
- 全局色板统一 #3b82f6 / #f8fafc / #f1f5f9,替代旧蓝紫渐变;confirm 卡片琥珀色顶条、用户气泡蓝底白字、工具卡片状态色条
- 新增 dashboard-item-pop / board-item-pop / message-stagger / fade-switch / task-detail 等入场动画;WeekPlanningBoard 格子弹簧动画按行列错峰;TaskClassSidebar 详情展开 max-height 过渡 + 角标旋转
4. 路由新增 /prototype/tool-trace 原型页(ToolTracePrototypeView)
This commit is contained in:
Losita
2026-04-19 00:21:44 +08:00
parent 6760e50e4b
commit 146b94fd50
15 changed files with 3162 additions and 1639 deletions

View File

@@ -1368,15 +1368,16 @@ func executeToolCall(
return fmt.Errorf("工具调用缺少工具名称")
}
// 推送工具调用状态,让前端知道当前在做什么
if err := emitter.EmitStatus(
// 推送工具调用开始事件(结构化)
if err := emitter.EmitToolCallStart(
executeStatusBlockID,
executeStageName,
"tool_call",
fmt.Sprintf("正在调用工具:%s", toolName),
toolName,
buildToolCallStartSummary(toolName, toolCall.Arguments),
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
); err != nil {
return fmt.Errorf("工具调用状态推送失败: %w", err)
return fmt.Errorf("工具调用开始事件推送失败: %w", err)
}
// 1. 校验依赖。
@@ -1417,11 +1418,13 @@ func executeToolCall(
toolName,
flowState.AllowReorder,
)
_ = emitter.EmitStatus(
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
"tool_blocked",
toolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
)
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
@@ -1448,6 +1451,15 @@ func executeToolCall(
afterDigest,
flattenForLog(result),
)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
toolName,
resolveToolEventResultStatus(result),
buildToolEventResultSummary(result),
buildToolArgumentsPreviewCN(toolCall.Arguments),
false,
)
// 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
@@ -1511,15 +1523,16 @@ func executePendingTool(
return fmt.Errorf("解析工具参数失败: %w", err)
}
// 2. 推送状态
if err := emitter.EmitStatus(
// 2. 推送工具调用开始事件(结构化)
if err := emitter.EmitToolCallStart(
executeStatusBlockID,
executeStageName,
"tool_call",
fmt.Sprintf("正在执行工具:%s", pending.ToolName),
pending.ToolName,
buildToolCallStartSummary(pending.ToolName, args),
buildToolArgumentsPreviewCN(args),
false,
); err != nil {
return fmt.Errorf("工具调用状态推送失败: %w", err)
return fmt.Errorf("工具调用开始事件推送失败: %w", err)
}
// 3. 校验依赖:写工具必须持有有效的日程状态。
@@ -1531,11 +1544,13 @@ func executePendingTool(
// 3.1 顺序护栏在确认执行路径同样生效,避免绕过前置约束。
if shouldBlockMinContextSwitch(flowState, pending.ToolName) {
blockedResult := "已拒绝执行 min_context_switch当前未授权打乱顺序。如需使用该工具请先由用户明确说明“允许打乱顺序”。"
_ = emitter.EmitStatus(
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
"tool_blocked",
pending.ToolName,
"blocked",
blockedResult,
buildToolArgumentsPreviewCN(args),
false,
)
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
@@ -1564,6 +1579,15 @@ func executePendingTool(
afterDigest,
flattenForLog(result),
)
_ = emitter.EmitToolCallResult(
executeStatusBlockID,
executeStageName,
pending.ToolName,
resolveToolEventResultStatus(result),
buildToolEventResultSummary(result),
buildToolArgumentsPreviewCN(args),
false,
)
// 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
@@ -1717,3 +1741,338 @@ func flattenForLog(text string) string {
text = strings.ReplaceAll(text, "\r", " ")
return strings.TrimSpace(text)
}
// resolveToolEventResultStatus 将工具返回文本映射为前端可识别的结果状态。
//
// 职责边界:
// 1. 只做轻量字符串规则判断,不做业务语义推理;
// 2. 默认归类为 done只有明显失败关键字才判定 failed
// 3. blocked 场景在调用侧显式传入,不由这里推断。
func resolveToolEventResultStatus(result string) string {
normalized := strings.TrimSpace(result)
if normalized == "" {
return "done"
}
if strings.Contains(normalized, "失败") {
return "failed"
}
lower := strings.ToLower(normalized)
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") {
return "failed"
}
return "done"
}
// buildToolEventResultSummary 生成用于前端工具行的结果摘要。
//
// 职责边界:
// 1. 优先从 JSON 结果提炼中文结论,避免前端直接看到原始字段;
// 2. 提炼失败时回退到“压平 + 截断”,保证仍有可读摘要;
// 3. 空结果给出固定兜底文案,避免前端出现空白行。
func buildToolEventResultSummary(result string) string {
flat := flattenForLog(result)
if flat == "" {
return "工具已执行完成。"
}
// 1. 工具很多返回 JSON直接截断 JSON 会把字段名原样暴露到前端,阅读体验差。
// 2. 这里优先做结构化提炼,转成一句中文结论(成功/失败/关键结果)。
// 3. 仅在无法提炼时才回退到原文截断,保证不会丢失可读信息。
if summary, ok := tryExtractToolResultSummaryCN(flat); ok {
return summary
}
runes := []rune(flat)
if len(runes) <= 48 {
return flat
}
return string(runes[:48]) + "..."
}
func tryExtractToolResultSummaryCN(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return "", false
}
toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool"))
toolName := resolveToolDisplayNameCN(toolRaw)
if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" {
return truncateToolSummaryCN(fmt.Sprintf("%s失败%s", toolName, errText)), true
}
if success, exists := payload["success"]; exists {
if ok, isBool := success.(bool); isBool && !ok {
reason := strings.TrimSpace(readStringAnyFromMap(payload, "reason", "message"))
if reason != "" {
return truncateToolSummaryCN(fmt.Sprintf("%s失败%s", toolName, reason)), true
}
return truncateToolSummaryCN(fmt.Sprintf("%s执行失败。", toolName)), true
}
}
if message := strings.TrimSpace(readStringAnyFromMap(payload, "result", "message", "reason")); message != "" {
return truncateToolSummaryCN(message), true
}
pending, hasPending := readIntAnyFromMap(payload, "pending_count")
completed, hasCompleted := readIntAnyFromMap(payload, "completed_count")
if hasPending || hasCompleted {
skipped, _ := readIntAnyFromMap(payload, "skipped_count")
return fmt.Sprintf("队列状态:待处理 %d已完成 %d已跳过 %d。", pending, completed, skipped), true
}
if hasHead, exists := payload["has_head"]; exists {
if b, isBool := hasHead.(bool); isBool {
if b {
return "已取到当前队首任务。", true
}
return "当前队列没有可处理任务。", true
}
}
if _, ok := payload["slot_candidates"]; ok {
if total, exists := readIntAnyFromMap(payload, "total"); exists {
return fmt.Sprintf("已找到 %d 个可用时段。", total), true
}
}
if toolRaw != "" {
return fmt.Sprintf("已完成「%s」。", toolName), true
}
return "", false
}
func truncateToolSummaryCN(text string) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) <= 48 {
return string(runes)
}
return string(runes[:48]) + "..."
}
// buildToolCallStartSummary 生成“工具开始调用”的中文摘要。
//
// 职责边界:
// 1. 摘要面向前端展示,避免直接暴露参数字段名;
// 2. 只做轻量信息拼接,不做业务语义推断;
// 3. 仅展示少量关键参数,避免消息过长抢占正文注意力。
func buildToolCallStartSummary(toolName string, args map[string]any) string {
displayName := resolveToolDisplayNameCN(toolName)
argSummary := buildToolArgumentsPreviewCN(args)
if argSummary == "" {
return fmt.Sprintf("已调用工具:%s。", displayName)
}
return fmt.Sprintf("已调用工具:%s%s。", displayName, argSummary)
}
// buildToolArgumentsPreviewCN 把工具参数转换为中文可读摘要。
//
// 职责边界:
// 1. 只输出白名单字段的中文标签,避免把原始参数键直接透出给前端;
// 2. 默认最多展示 2 组参数,防止工具行过长;
// 3. 无可展示参数时返回空字符串,由上层决定是否展示。
func buildToolArgumentsPreviewCN(args map[string]any) string {
if len(args) <= 0 {
return ""
}
type argPair struct {
Key string
Label string
}
orderedPairs := []argPair{
{Key: "title", Label: "任务标题"},
{Key: "task_name", Label: "任务名称"},
{Key: "deadline_at", Label: "截止时间"},
{Key: "new_day", Label: "目标天"},
{Key: "new_slot_start", Label: "目标开始节次"},
{Key: "day", Label: "天"},
{Key: "day_start", Label: "起始天"},
{Key: "day_end", Label: "结束天"},
{Key: "day_scope", Label: "日期范围"},
{Key: "day_of_week", Label: "星期"},
{Key: "week", Label: "周"},
{Key: "week_from", Label: "起始周"},
{Key: "week_to", Label: "结束周"},
{Key: "week_filter", Label: "周筛选"},
{Key: "slot_start", Label: "开始节次"},
{Key: "slot_end", Label: "结束节次"},
{Key: "slot_type", Label: "时段类型"},
{Key: "slot_types", Label: "时段类型"},
{Key: "task_id", Label: "任务编号"},
{Key: "task_ids", Label: "任务列表"},
{Key: "task_item_id", Label: "任务条目"},
{Key: "task_item_ids", Label: "任务条目列表"},
{Key: "query", Label: "搜索词"},
{Key: "keyword", Label: "关键词"},
{Key: "top_k", Label: "返回数量"},
{Key: "url", Label: "链接"},
{Key: "reason", Label: "原因"},
{Key: "limit", Label: "数量"},
}
items := make([]string, 0, 2)
for _, pair := range orderedPairs {
rawValue, exists := args[pair.Key]
if !exists {
continue
}
valueText := formatToolArgValueByKeyCN(pair.Key, rawValue)
if valueText == "" {
continue
}
items = append(items, fmt.Sprintf("%s%s", pair.Label, valueText))
if len(items) >= 2 {
break
}
}
return strings.Join(items, "")
}
// resolveToolDisplayNameCN 返回工具中文展示名。
func resolveToolDisplayNameCN(toolName string) string {
name := strings.TrimSpace(toolName)
if name == "" {
return "未知工具"
}
displayNameMap := map[string]string{
"get_overview": "查看总览",
"query_range": "查询时段详情",
"queue_status": "查看任务队列",
"queue_pop_head": "获取队首任务",
"queue_apply_head_move": "调整队首任务时段",
"queue_skip_head": "跳过队首任务",
"query_target_tasks": "查询目标任务",
"query_available_slots": "查询可用时间段",
"get_task_info": "查看任务详情",
"quick_note_create": "创建提醒任务",
"query_tasks": "查询任务列表",
"web_search": "网页搜索",
"web_fetch": "网页抓取",
"move": "移动任务",
"place": "放置任务",
"swap": "交换任务",
"batch_move": "批量移动任务",
"spread_even": "均匀分散任务",
"min_context_switch": "减少上下文切换",
"unplace": "移除任务安排",
}
if label, ok := displayNameMap[name]; ok {
return label
}
return name
}
func formatToolArgValueByKeyCN(key string, value any) string {
switch key {
case "day_scope":
scope := strings.ToLower(strings.TrimSpace(formatToolArgValueCN(value)))
switch scope {
case "workday":
return "工作日"
case "weekend":
return "周末"
case "all":
return "全部日期"
default:
return scope
}
case "day_of_week":
weekdays := parseAnyToIntSlice(value)
if len(weekdays) <= 0 {
return formatToolArgValueCN(value)
}
labels := make([]string, 0, len(weekdays))
for _, day := range weekdays {
labels = append(labels, fmt.Sprintf("周%d", day))
if len(labels) >= 4 {
break
}
}
return strings.Join(labels, "、")
case "task_ids", "task_item_ids", "week_filter":
values := parseAnyToIntSlice(value)
if len(values) <= 0 {
return formatToolArgValueCN(value)
}
items := make([]string, 0, len(values))
for _, current := range values {
items = append(items, strconv.Itoa(current))
if len(items) >= 4 {
break
}
}
return strings.Join(items, "、")
case "url":
return truncateToolSummaryCN(formatToolArgValueCN(value))
case "reason", "title", "task_name", "query", "keyword":
return truncateToolSummaryCN(formatToolArgValueCN(value))
default:
return formatToolArgValueCN(value)
}
}
// formatToolArgValueCN 把参数值格式化为中文可读字符串。
func formatToolArgValueCN(value any) string {
switch v := value.(type) {
case string:
text := strings.TrimSpace(v)
if text == "" {
return ""
}
return text
case int:
return strconv.Itoa(v)
case int8:
return strconv.Itoa(int(v))
case int16:
return strconv.Itoa(int(v))
case int32:
return strconv.Itoa(int(v))
case int64:
return strconv.Itoa(int(v))
case float32:
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
case float64:
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
case bool:
if v {
return "是"
}
return "否"
case []any:
values := make([]string, 0, len(v))
for _, item := range v {
text := formatToolArgValueCN(item)
if text == "" {
continue
}
values = append(values, text)
if len(values) >= 3 {
break
}
}
return strings.Join(values, "、")
default:
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" || text == "<nil>" || text == "map[]" {
return ""
}
return text
}
}

View File

@@ -191,70 +191,55 @@ func (e *ChunkEmitter) EmitPseudoAssistantText(ctx context.Context, blockID, sta
// EmitStatus 输出一条阶段状态事件。
//
// 当前兼容策略
// 1. extra 用 status 表达结构化语义
// 2. reasoning_content 里同时放一份可读降级文本,保证旧前端也能看到
// 协议约束
// 1. 状态事件只通过 extra 传递,不再写入 reasoning_content
// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role
func (e *ChunkEmitter) EmitStatus(blockID, stage, code, summary string, includeRole bool) error {
if e == nil || e.emit == nil {
return nil
}
text := buildStageReasoningText(stage, summary)
payload, err := ToOpenAIReasoningChunkWithExtra(
e.RequestID,
e.ModelName,
e.Created,
text,
includeRole,
NewStatusExtra(blockID, stage, code, summary),
)
if err != nil {
return err
}
if payload == "" {
return nil
}
return e.emit(payload)
_ = includeRole
return e.emitExtraOnly(NewStatusExtra(blockID, stage, code, summary))
}
// EmitToolCallStart 输出一次工具调用开始事件。
//
// 协议约束:
// 1. 工具调用开始事件只走 extra.tool不回写 reasoning_content
// 2. includeRole 保留是为了兼容旧签名,当前结构化事件路径不依赖 role。
func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argumentsPreview string, includeRole bool) error {
if e == nil || e.emit == nil {
return nil
}
text := buildToolCallReasoningText(toolName, summary, argumentsPreview)
payload, err := ToOpenAIReasoningChunkWithExtra(
e.RequestID,
e.ModelName,
e.Created,
text,
includeRole,
NewToolCallExtra(blockID, stage, toolName, "start", summary, argumentsPreview),
)
if err != nil {
return err
}
if payload == "" {
return nil
}
return e.emit(payload)
_ = includeRole
return e.emitExtraOnly(NewToolCallExtra(blockID, stage, toolName, "start", summary, argumentsPreview))
}
// EmitToolCallResult 输出一次工具调用结果事件。
func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, summary, argumentsPreview string, includeRole bool) error {
//
// 协议约束:
// 1. status 由调用方明确传入(如 done/blocked/failed
// 2. 结果事件只走 extra.tool不回写 reasoning_content。
func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, status, summary, argumentsPreview string, includeRole bool) error {
if e == nil || e.emit == nil {
return nil
}
_ = includeRole
return e.emitExtraOnly(NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview))
}
text := buildToolResultReasoningText(toolName, summary)
payload, err := ToOpenAIReasoningChunkWithExtra(
// emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。
func (e *ChunkEmitter) emitExtraOnly(extra *OpenAIChunkExtra) error {
if e == nil || e.emit == nil {
return nil
}
payload, err := ToOpenAIStreamWithExtra(
nil,
e.RequestID,
e.ModelName,
e.Created,
text,
includeRole,
NewToolResultExtra(blockID, stage, toolName, "done", summary, argumentsPreview),
false,
extra,
)
if err != nil {
return err

View File

@@ -0,0 +1,219 @@
# Agent 流式协议前后端对齐 决策记录
## 1. 基本信息
- 记录编号FDR-009
- 功能名称Agent Chat SSE 协议前后端对齐(去伪思考化)
- 记录日期2026-04-18
- 决策状态:提议(评审后执行)
- 负责人SmartFlow 团队
- 关联需求 / Issue
- 工具调用信息前端可视化(折叠式、通俗文案)
- 降低“深度思考”误导(当前为伪装块)
## 2. 背景与问题
- 业务背景:
- 目前聊天页已经在做流式展示、确认卡片、会话管理;
- 计划将“工具调用过程”以专属样式内联展示。
- 现状问题:
1. 后端阶段状态/工具状态,会通过 `reasoning_content` 回传给前端,形成“看起来像深度思考”的内容;
2. 前端目前只消费 `extra.kind=confirm_request`,其他 `extra` 结构化事件并未真正用于渲染;
3. 用户感知层面会误以为模型正在“深度思考”,但其中大量内容其实是流程状态(非真实思考)。
- 不做此决策的后果:
1. 前端即使先移除“深度思考框”,也会连同状态可见性一起丢失;
2. 前后端协议继续漂移,工具可视化落地会重复返工;
3. 后续“真流式 speak”与“工具事件流”会相互干扰排查困难。
## 3. 决策目标
- 目标 1统一 SSE 协议语义边界:`reasoning_content` 仅承载真实思考文本,不再承载阶段/工具状态文案。
- 目标 2`extra.kind` 作为结构化事件主通道,前端据此渲染工具/状态专属 UI。
- 目标 3明确迁移顺序先后端协议就位再前端切换展示最后清理兼容层。
- 非目标(本次不解决):
- 不在本轮实现“每一次 speak 都改成 chat 节点流式消息头”;
- 不在本轮重构全部历史会话数据存储格式。
## 4. 备选方案
### 方案 A先改前端先隐藏深度思考区
- 描述:前端先把思考区关闭或默认不展示,后端暂不改。
- 优点:
- 见效快UI 即刻变“干净”。
- 缺点:
- 只是遮罩,不是协议治理;
- 状态可见性与思考内容耦合,后续仍要返工。
- 复杂度 / 成本:低(短期)/ 高(长期返工)
### 方案 B先改后端协议再改前端渲染采纳
- 描述:后端先把状态/工具事件改为结构化主通道,前端再切换消费逻辑与样式。
- 优点:
- 语义边界清晰,长期维护成本低;
- 前端可直接做“折叠式工具行”,不再依赖伪思考文本;
- 可通过双写/开关平滑迁移,风险可控。
- 缺点:
- 首轮需要前后端并行协作与联调。
- 复杂度 / 成本:中
### 方案 C前后端同一轮同时硬切
- 描述:单次发布同时切后端协议和前端展示,不保留兼容层。
- 优点:
- 路径最短,代码最“干净”。
- 缺点:
- 回归风险高,灰度与回滚空间小;
- 一旦线上混部或缓存命中旧逻辑,容易出现空白块/重复块。
- 复杂度 / 成本:中(开发)/ 高(上线风险)
## 5. 最终决策
- 采纳方案:方案 B先后端协议再前端渲染最后清理兼容层
- 关键理由:
1. 先解决协议语义,再做视觉层改造,避免 UI 层“治标不治本”;
2. 与“工具调用可视化”目标一致,能直接对接折叠式工具行;
3. 具备可灰度、可回滚的工程路径。
## 6. 影响范围
- 涉及模块:
- 后端:`backend/newAgent/stream``backend/newAgent/node`
- 前端:`frontend/src/components/dashboard/AssistantPanel.vue`
- 数据与存储影响:
- 无数据库结构变更;
- 仅影响实时 SSE 事件解释方式。
- 接口 / 协议影响:
- `POST /api/v1/agent/chat` 的 SSE chunk 语义调整(见第 7/8 节)。
- 监控与日志影响:
- 需新增“事件类型计数”与“前端解析命中率”观测。
## 7. 前后端接口现状AS-IS
### 7.1 SSE 外层协议(当前)
- 后端已定义 OpenAI 兼容壳 + `extra` 扩展,代码位置:
- `backend/newAgent/stream/openai.go`
- `backend/newAgent/stream/emitter.go`
- `extra.kind` 已有枚举:`status``tool_call``tool_result``confirm_request` 等。
### 7.2 当前关键现象
1. 后端 `EmitStatus` 会把阶段文案同时写入:
- `extra.kind=status`
- `choices[0].delta.reasoning_content`(降级文本)
2. 执行节点多数工具过程仍通过 `EmitStatus(code=tool_call/tool_blocked)` 推送;
3. 前端 `processSseBlock` 当前只显式处理:
- `parsed.extra?.kind === 'confirm_request'`
4. 结果大量状态文案作为“reasoning”显示形成伪“深度思考”体验。
### 7.3 当前事件样例(简化)
```json
{
"choices": [
{
"delta": {
"reasoning_content": "阶段execute\n正在调用工具queue_status"
}
}
],
"extra": {
"kind": "status",
"status": {
"code": "tool_call",
"summary": "正在调用工具queue_status"
}
}
}
```
## 8. 目标协议TO-BE
### 8.1 总体原则
1. `reasoning_content`:只承载真实模型思考文本;
2. `extra.kind`:承载流程状态和工具事件(前端主消费通道);
3. 前端渲染:默认不把状态事件塞入“深度思考区”,而是进入工具/状态专属样式。
### 8.2 目标事件约定
| 事件类型 | `extra.kind` | 前端默认表现 | 是否进入 reasoning 区 |
|---|---|---|---|
| 阶段状态 | `status` | 轻量状态行 / 提示条 | 否 |
| 工具调用开始 | `tool_call` | 折叠工具行(默认摘要) | 否 |
| 工具调用结果 | `tool_result` | 更新同一工具行状态与详情 | 否 |
| 待确认 | `confirm_request` | 确认卡片 | 否 |
| 真思考 | `reasoning_text` | 思考区(可折叠) | 是 |
| 正文输出 | `assistant_text` | 正文区 | 否 |
### 8.3 目标工具事件样例(简化)
```json
{
"choices": [],
"extra": {
"kind": "tool_call",
"stage": "execute",
"tool": {
"name": "queue_status",
"status": "start",
"summary": "已调用工具:查看任务队列",
"arguments_preview": "默认参数"
}
}
}
```
```json
{
"choices": [],
"extra": {
"kind": "tool_result",
"stage": "execute",
"tool": {
"name": "queue_status",
"status": "done",
"summary": "待处理 3 项,已完成 1 项"
}
}
}
```
## 9. 实施计划(先后端、再前端)
### 里程碑 1后端协议对齐先做
- 目标:状态/工具事件走结构化主通道,不再依赖伪 reasoning 文本。
- 开工清单(后端):
1. `execute` 工具链路改用 `EmitToolCallStart/EmitToolCallResult`
2. `EmitStatus` 增加策略开关:支持“仅 extra不回写 reasoning_content”模式
3. `tool_blocked` 统一归类到工具事件(或结构化 status避免文本拼接歧义
4. 输出契约补充到 `backend/newAgent/ARCHITECTURE.md`
### 里程碑 2前端事件消费切换
- 目标:前端按 `extra.kind` 渲染,不再把流程状态当“深度思考”。
- 开工清单(前端):
1. `processSseBlock` 增加 `status/tool_call/tool_result` 解析;
2. 新增“折叠式工具行”状态机(默认摘要、展开详情);
3. 深度思考区默认只接收真 `reasoning_text`
4. 确认卡片逻辑保持兼容。
### 里程碑 3兼容层收敛
- 目标:确认稳定后去除伪思考降级写法。
- 开工清单:
1. 关闭后端状态写入 `reasoning_content`
2. 清理前端旧降级路径;
3. 补齐回归用例与文档。
## 10. 风险与应对
- 风险 1前端未及时消费 `extra.kind`,导致状态缺失。
- 应对策略:后端先双写一段窗口期(可配置开关),灰度切换。
- 风险 2工具事件顺序乱序导致 UI 折叠状态错位。
- 应对策略:使用 `block_id + tool.name + stage` 做关联键,按到达顺序幂等更新。
- 风险 3历史会话与新会话混合展示不一致。
- 应对策略:仅对新流式消息生效,历史消息维持只读展示。
## 11. 验证与回滚
- 验证方式:
1. 后端单测:事件序列与 payload 结构校验;
2. 前端联调:`tool_call -> tool_result -> summary` 顺序回放;
3. 手工场景:普通问答 / 工具调用 / confirm / tool_blocked。
- 成功判定标准:
1. 状态类文本不再出现在“深度思考区”;
2. 工具事件能稳定渲染为折叠行;
3. confirm 卡片行为不回归。
- 回滚方案:
- 后端切回“状态写 reasoning_content + 旧前端渲染”兼容模式;
- 前端保留旧路径开关,必要时回退版本。
## 12. 后续计划
- 后续优化项 1把 speak 也统一到更细粒度真流式事件头(下一轮决策)。
- 后续优化项 2工具详情文案模板化通俗中文 + 可本地化)。
- 后续优化项 3工具事件接入埋点点击展开率、阅读停留、错误率
## 13. 复盘结论(上线后补充)
- 实际效果:待补充
- 与预期偏差:待补充
- 后续是否需要二次决策:待补充

View File

@@ -1,3 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import MainSidebar from '@/components/common/MainSidebar.vue'
const route = useRoute()
const showLayout = computed(() => {
return ['dashboard', 'assistant', 'schedule'].includes(route.name as string)
})
</script>
<template>
<router-view />
<div v-if="showLayout" class="smartflow-layout">
<MainSidebar />
<div class="smartflow-content">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
</div>
<router-view v-else />
</template>
<style>
/* Reset base styles */
body {
margin: 0;
}
.smartflow-layout {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
padding: 10px;
background: #f8fafc; /* Unified Flat Modern background */
display: flex;
gap: 10px;
align-items: stretch;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.smartflow-content {
flex: 1;
min-width: 0;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to?: '/dashboard' | '/assistant' | '/schedule'
}
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' },
]
const route = useRoute()
const router = useRouter()
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) return 'ai'
if (route.path.startsWith('/schedule')) return 'calendar'
return 'home'
})
const activeSidebarIndex = computed(() => {
return sidebarItems.findIndex(item => item.key === activeSidebarKey.value)
})
const activeIndicatorStyle = computed(() => {
return {
transform: `translateY(${activeSidebarIndex.value * 72}px)`
}
})
function handleSidebarNavigate(item: SidebarItem) {
if (item.to) {
if (route.path !== item.to) void router.push(item.to)
return
}
ElMessage.info(`${item.label} 页面正在开发中`)
}
</script>
<template>
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<div class="dashboard-sidebar__nav-indicator" :style="activeIndicatorStyle" />
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
</template>
<style scoped>
.dashboard-sidebar {
width: 78px;
height: 100%;
border-radius: 24px;
background: #0f172a;
padding: 24px 12px;
display: flex;
flex-direction: column;
gap: 32px;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.dashboard-sidebar__brand {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
font-size: 20px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.3);
}
.dashboard-sidebar__nav { position: relative; display: grid; gap: 12px; align-content: start; }
.dashboard-sidebar__nav-indicator {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 60px;
background: rgba(255, 255, 255, 0.08);
border-radius: 16px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 0;
}
.dashboard-sidebar__nav-item {
width: 100%;
height: 60px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.45);
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
position: relative;
z-index: 1;
transition: color 0.2s;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
font-weight: 700;
font-size: 14px;
transition: all 0.2s;
}
.dashboard-sidebar__nav-item:hover { color: #f8fafc; }
.dashboard-sidebar__nav-item small { font-size: 10px; font-weight: 500; opacity: 0.8; }
.dashboard-sidebar__nav-item--active { color: #fff; }
.dashboard-sidebar__nav-item--active span { background: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); }
.dashboard-sidebar__settings {
width: 48px;
height: 48px;
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
margin-top: auto;
}
.dashboard-sidebar__settings:hover { background: rgba(255, 255, 255, 0.1); color: #f8fafc; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -32,35 +32,37 @@ const visibleTasks = computed(() => props.tasks)
<span class="quadrant-card__count">{{ count }}</span>
</header>
<div v-if="loading" class="quadrant-card__skeleton">
<div v-for="index in 3" :key="index" class="quadrant-card__skeleton-item" />
</div>
<transition name="fade-switch" mode="out-in">
<div v-if="loading" key="loading" class="quadrant-card__skeleton">
<div v-for="index in 3" :key="index" class="quadrant-card__skeleton-item" />
</div>
<div v-else-if="visibleTasks.length === 0" class="quadrant-card__empty">
{{ emptyText }}
</div>
<div v-else-if="visibleTasks.length === 0" key="empty" class="quadrant-card__empty">
{{ emptyText }}
</div>
<div v-else class="quadrant-list">
<button
v-for="task in visibleTasks"
:key="task.id"
type="button"
class="quadrant-item"
:class="{ 'quadrant-item--completed': task.is_completed }"
@click="emit('toggle', task)"
>
<span class="quadrant-item__check">
{{ task.is_completed ? '✓' : '' }}
</span>
<span class="quadrant-item__content">
<strong>{{ task.title }}</strong>
<small>{{ formatDeadline(task.deadline) }}</small>
</span>
<span class="quadrant-item__status">
{{ task.is_completed ? '已完成' : '待处理' }}
</span>
</button>
</div>
<TransitionGroup v-else tag="div" name="list-stagger" class="quadrant-list" key="list">
<button
v-for="task in visibleTasks"
:key="task.id"
type="button"
class="quadrant-item"
:class="{ 'quadrant-item--completed': task.is_completed }"
@click="emit('toggle', task)"
>
<span class="quadrant-item__check">
{{ task.is_completed ? '✓' : '' }}
</span>
<span class="quadrant-item__content">
<strong>{{ task.title }}</strong>
<small>{{ formatDeadline(task.deadline) }}</small>
</span>
<span class="quadrant-item__status">
{{ task.is_completed ? '已完成' : '待处理' }}
</span>
</button>
</TransitionGroup>
</transition>
</section>
</template>
@@ -259,4 +261,38 @@ const visibleTasks = computed(() => props.tasks)
background-position: -200% 0;
}
}
.fade-switch-enter-active,
.fade-switch-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-switch-enter-from {
opacity: 0;
transform: translateY(4px);
}
.fade-switch-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.list-stagger-enter-active,
.list-stagger-leave-active {
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.list-stagger-enter-from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
.list-stagger-leave-to {
opacity: 0;
transform: translateX(12px) scale(0.98);
}
.list-stagger-leave-active {
position: absolute;
}
</style>

View File

@@ -125,28 +125,30 @@ const renderSlots = computed<RenderSlot[]>(() =>
<span class="timeline-card__caption">按时间顺序展示课程与任务安排</span>
</header>
<div v-if="loading" class="timeline-skeleton">
<div v-for="slot in slotBlueprint" :key="slot.key" class="timeline-skeleton__item" />
</div>
<transition name="fade-switch" mode="out-in">
<div v-if="loading" key="loading" class="timeline-skeleton">
<div v-for="slot in slotBlueprint" :key="slot.key" class="timeline-skeleton__item" />
</div>
<div v-else class="timeline-grid">
<template v-for="slot in renderSlots" :key="slot.key">
<article
v-if="slot.kind === 'event'"
class="timeline-event"
:class="`timeline-event--${slot.tone}`"
>
<span class="timeline-event__time">{{ slot.timeText }}</span>
<strong class="timeline-event__title">{{ slot.title }}</strong>
<span class="timeline-event__location">{{ slot.locationText }}</span>
</article>
<div v-else key="content" class="timeline-grid">
<template v-for="slot in renderSlots" :key="slot.key">
<article
v-if="slot.kind === 'event'"
class="timeline-event"
:class="`timeline-event--${slot.tone}`"
>
<span class="timeline-event__time">{{ slot.timeText }}</span>
<strong class="timeline-event__title">{{ slot.title }}</strong>
<span class="timeline-event__location">{{ slot.locationText }}</span>
</article>
<article v-else class="timeline-placeholder timeline-placeholder--pause">
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
</article>
</template>
</div>
<article v-else class="timeline-placeholder timeline-placeholder--pause">
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
</article>
</template>
</div>
</transition>
</section>
</template>
@@ -203,7 +205,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
.timeline-skeleton__item {
min-width: 0;
min-height: 124px;
border-radius: 20px;
border-radius: 14px;
}
.timeline-event {
@@ -211,7 +213,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid rgba(17, 24, 39, 0.06);
border: 1px solid transparent;
position: relative;
overflow: hidden;
}
@@ -222,107 +224,142 @@ const renderSlots = computed<RenderSlot[]>(() =>
left: 0;
top: 0;
bottom: 0;
width: 5px;
opacity: 0.92;
width: 4px;
}
.timeline-event__time {
font-size: 12px;
font-weight: 700;
color: #295b9b;
color: #64748b;
}
.timeline-event__title {
margin-top: 12px;
font-size: 15px;
line-height: 1.35;
color: #172033;
color: #0f172a;
}
.timeline-event__location {
margin-top: 14px;
font-size: 12px;
color: #5f6980;
color: #64748b;
}
.timeline-event--course {
background: linear-gradient(180deg, #ecf4ff 0%, #e4eefc 100%);
background: #eff6ff;
}
.timeline-event--course::before {
background: #1669c1;
background: #3b82f6;
}
.timeline-event--course .timeline-event__title,
.timeline-event--course .timeline-event__time {
color: #1d4ed8;
}
.timeline-event--sky {
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
background: #f0f9ff;
}
.timeline-event--sky::before {
background: #c8d6e8;
background: #0ea5e9;
}
.timeline-event--sky .timeline-event__title,
.timeline-event--sky .timeline-event__time {
color: #0369a1;
}
.timeline-event--violet {
background: linear-gradient(180deg, #eef0ff 0%, #e6e8ff 100%);
background: #f5f3ff;
}
.timeline-event--violet::before {
background: #676cff;
background: #8b5cf6;
}
.timeline-event--violet .timeline-event__title,
.timeline-event--violet .timeline-event__time {
color: #6d28d9;
}
.timeline-event--mint {
background: linear-gradient(180deg, #e6f2ff 0%, #dceaff 100%);
background: #ecfdf5;
}
.timeline-event--mint::before {
background: #2f7de1;
background: #10b981;
}
.timeline-event--mint .timeline-event__title,
.timeline-event--mint .timeline-event__time {
color: #047857;
}
.timeline-event--emerald {
background: linear-gradient(180deg, #e6f8f1 0%, #def5ec 100%);
background: #dcfce7;
}
.timeline-event--emerald::before {
background: #27b482;
background: #22c55e;
}
.timeline-event--emerald .timeline-event__title,
.timeline-event--emerald .timeline-event__time {
color: #15803d;
}
.timeline-event--amber {
background: linear-gradient(180deg, #fff5db 0%, #fff0cb 100%);
background: #fffbeb;
}
.timeline-event--amber::before {
background: #f59e0b;
}
.timeline-event--amber .timeline-event__title,
.timeline-event--amber .timeline-event__time {
color: #b45309;
}
.timeline-event--cyan {
background: linear-gradient(180deg, #e1f7ff 0%, #d6f2fb 100%);
background: #cffafe;
}
.timeline-event--cyan::before {
background: #57b8ea;
background: #06b6d4;
}
.timeline-event--cyan .timeline-event__title,
.timeline-event--cyan .timeline-event__time {
color: #0e7490;
}
.timeline-event--neutral {
background: linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.05);
}
.timeline-event--neutral::before {
background: #c8d6e8;
background: #94a3b8;
}
.timeline-placeholder {
border: 1px dashed rgba(120, 144, 171, 0.28);
background: rgba(255, 255, 255, 0.55);
border: 1px dashed rgba(15, 23, 42, 0.15);
background: #ffffff;
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
padding: 14px 12px;
text-align: center;
color: #8a96a8;
color: #64748b;
}
.timeline-placeholder--pause {
background: linear-gradient(180deg, #f5f9ff 0%, #eef4fb 100%);
background: #f8fafc;
}
.timeline-placeholder__title {
@@ -359,6 +396,20 @@ const renderSlots = computed<RenderSlot[]>(() =>
}
}
.fade-switch-enter-active,
.fade-switch-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-switch-enter-from,
.fade-switch-leave-to {
opacity: 0;
transform: translateY(4px);
}
.fade-switch-leave-to {
transform: translateY(-4px);
}
@media (max-width: 1320px) {
.timeline-grid,
.timeline-skeleton {

View File

@@ -68,9 +68,6 @@ function resolveDetailPanelStyle(items: TaskClassDetail['items']) {
)
const finalHeight = Math.min(preferredHeight, maxHeightByItemCount, maxHeightByContainer)
// 1. 条目少时让卡片自然长高,避免只有两三条时还出现大块留白。
// 2. 条目超过“当前屏幕可安全展示的最大条数”后,立即锁住高度并进入内部滚动。
// 3. 这样像 8 条 task_item 这类中等长度列表会稳定触发滚动,不会再因为估算过大而失效。
return {
maxHeight: `${finalHeight}px`,
}
@@ -171,39 +168,41 @@ watch(
</span>
</button>
<div
v-if="isExpanded(taskClass.id)"
class="task-class-card__detail"
:style="expandedTaskClassDetail ? resolveDetailPanelStyle(expandedTaskClassDetail.items) : undefined"
>
<div v-if="detailLoading" class="task-class-card__detail-loading">正在载入任务块</div>
<transition name="task-detail">
<div
v-if="isExpanded(taskClass.id)"
class="task-class-card__detail"
:style="expandedTaskClassDetail ? resolveDetailPanelStyle(expandedTaskClassDetail.items) : { maxHeight: '60px' }"
>
<div v-if="detailLoading" class="task-class-card__detail-loading">正在载入任务块</div>
<div v-else-if="expandedTaskClassDetail" class="task-class-card__detail-list">
<div
v-for="item in expandedTaskClassDetail.items"
:key="item.order"
class="task-class-card__detail-item"
>
<span class="task-class-card__detail-order">{{ item.order }}</span>
<span class="task-class-card__detail-text">{{ item.content }}</span>
<span
class="task-class-card__detail-status"
:class="{ 'task-class-card__detail-status--arranged': item.embedded_time }"
<div v-else-if="expandedTaskClassDetail" class="task-class-card__detail-list">
<div
v-for="item in expandedTaskClassDetail.items"
:key="item.order"
class="task-class-card__detail-item"
>
{{ formatEmbeddedTime(item.embedded_time) }}
</span>
<button
type="button"
class="task-class-card__detail-delete"
aria-label="删除任务块"
:disabled="typeof item.id !== 'number'"
@click="typeof item.id === 'number' && emit('deleteItem', item.id)"
>
×
</button>
<span class="task-class-card__detail-order">{{ item.order }}</span>
<span class="task-class-card__detail-text">{{ item.content }}</span>
<span
class="task-class-card__detail-status"
:class="{ 'task-class-card__detail-status--arranged': item.embedded_time }"
>
{{ formatEmbeddedTime(item.embedded_time) }}
</span>
<button
type="button"
class="task-class-card__detail-delete"
aria-label="删除任务块"
:disabled="typeof item.id !== 'number'"
@click="typeof item.id === 'number' && emit('deleteItem', item.id)"
>
×
</button>
</div>
</div>
</div>
</div>
</transition>
</article>
<button type="button" class="task-class-sidebar__create" @click="emit('create')">
@@ -221,14 +220,33 @@ watch(
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid rgba(196, 209, 227, 0.55);
background: linear-gradient(180deg, rgba(251, 253, 255, 0.96), rgba(247, 250, 254, 0.98));
border-right: 1px solid rgba(15, 23, 42, 0.05);
background: #ffffff;
overflow: hidden;
}
/* --- 全局精致滚动条 --- */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.08);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.15);
}
.task-class-sidebar__header {
padding: 16px 24px 14px;
border-bottom: 1px solid rgba(214, 223, 238, 0.68);
padding: 20px 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.05);
display: grid;
gap: 12px;
min-width: 0;
@@ -261,7 +279,7 @@ watch(
width: 16px;
height: 16px;
display: inline-flex;
color: #165fd0;
color: #3b82f6;
}
.task-class-sidebar__count {
@@ -275,21 +293,21 @@ watch(
.task-class-sidebar__mode {
height: 34px;
border: 1px solid rgba(25, 95, 213, 0.18);
border: 1px solid rgba(59, 130, 246, 0.18);
border-radius: 12px;
background: #f6f9ff;
color: #1d64d2;
background: #f8fafc;
color: #3b82f6;
font-size: 12px;
font-weight: 700;
justify-self: start;
padding: 0 14px;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
transition: all 0.2s;
}
.task-class-sidebar__mode:hover {
border-color: rgba(25, 95, 213, 0.34);
background: #edf4ff;
border-color: rgba(59, 130, 246, 0.34);
background: #eff6ff;
}
.task-class-sidebar__list,
@@ -300,7 +318,6 @@ watch(
padding: 24px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
scrollbar-gutter: stable;
}
@@ -308,25 +325,25 @@ watch(
.task-class-sidebar__skeleton-item {
flex: 0 0 auto;
height: 120px;
border-radius: 24px;
background: linear-gradient(90deg, rgba(234, 239, 246, 0.9), rgba(248, 251, 255, 1), rgba(234, 239, 246, 0.9));
background-size: 200% 100%;
border-radius: 20px;
background: rgba(15, 23, 42, 0.03);
animation: task-class-skeleton 1.25s linear infinite;
}
.task-class-card {
flex: 0 0 auto;
min-width: 0;
border-radius: 24px;
border: 1px solid rgba(216, 225, 238, 0.9);
background: linear-gradient(180deg, #fdfefe 0%, #f8fbff 100%);
box-shadow: 0 10px 22px rgba(19, 51, 107, 0.04);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.06);
background: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.02);
overflow: hidden;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.task-class-card--selected {
border-color: rgba(28, 98, 206, 0.28);
box-shadow: 0 14px 24px rgba(22, 95, 208, 0.08);
border-color: #3b82f6;
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.1);
}
.task-class-card__summary {
@@ -345,8 +362,8 @@ watch(
}
.task-class-card__summary:hover .task-class-card__corner {
background: #edf4ff;
color: #2067d5;
background: #eff6ff;
color: #3b82f6;
}
.task-class-card__selector {
@@ -359,8 +376,8 @@ watch(
}
.task-class-card__selector--active {
border-color: #1e66d4;
background: #1e66d4;
border-color: #3b82f6;
background: #3b82f6;
box-shadow: inset 0 0 0 3px #ffffff;
}
@@ -391,9 +408,17 @@ watch(
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(246, 249, 253, 0.9);
color: #1e66d4;
transition: background-color 0.16s ease, color 0.16s ease;
background: #f8fafc;
color: #3b82f6;
transition: all 0.2s;
}
.task-class-card__corner svg {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.task-class-card--expanded .task-class-card__corner svg {
transform: rotate(45deg);
}
.task-class-card__detail {
@@ -405,8 +430,20 @@ watch(
overflow-x: hidden;
scrollbar-gutter: stable;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(114, 130, 157, 0.65) transparent;
}
.task-detail-enter-active,
.task-detail-leave-active {
transition: max-height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), padding 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
overflow: hidden;
}
.task-detail-enter-from,
.task-detail-leave-to {
max-height: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
opacity: 0;
}
.task-class-card__detail-loading {
@@ -415,19 +452,6 @@ watch(
font-size: 13px;
}
.task-class-card__detail::-webkit-scrollbar {
width: 8px;
}
.task-class-card__detail::-webkit-scrollbar-track {
background: transparent;
}
.task-class-card__detail::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(114, 130, 157, 0.55);
}
.task-class-card__detail-list {
display: grid;
gap: 6px;
@@ -482,7 +506,7 @@ watch(
height: 24px;
border: none;
border-radius: 999px;
background: #bb3326;
background: #ef4444;
color: #ffffff;
font-size: 16px;
line-height: 1;
@@ -497,23 +521,23 @@ watch(
.task-class-sidebar__create {
flex: 0 0 auto;
min-width: 0;
min-height: 108px;
border: 1px dashed rgba(204, 216, 232, 0.92);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(249, 251, 255, 0.98));
color: #b1bccd;
min-height: 96px;
border: 1.5px dashed rgba(15, 23, 42, 0.1);
border-radius: 20px;
background: transparent;
color: #94a3b8;
display: grid;
justify-items: center;
align-content: center;
gap: 10px;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
transition: all 0.2s;
}
.task-class-sidebar__create:hover {
border-color: rgba(25, 95, 213, 0.22);
background: #f7fbff;
color: #6d7f99;
border-color: #3b82f6;
background: #f8fafc;
color: #3b82f6;
}
.task-class-sidebar__create-icon {
@@ -529,13 +553,8 @@ watch(
}
@keyframes task-class-skeleton {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 1520px) {
@@ -545,102 +564,18 @@ watch(
padding-left: 18px;
padding-right: 18px;
}
.task-class-card__summary {
padding: 16px 16px 16px 15px;
min-height: 84px;
}
}
@media (max-width: 1380px) {
.task-class-sidebar {
border-right: none;
border-bottom: 1px solid rgba(196, 209, 227, 0.55);
}
}
@media (max-width: 1180px) {
.task-class-card__detail-item {
grid-template-columns: 28px minmax(0, 1fr) 24px;
align-items: start;
}
.task-class-card__detail-status {
grid-column: 2;
justify-self: start;
}
.task-class-card__detail-delete {
grid-column: 3;
grid-row: 1 / span 2;
align-self: center;
border-bottom: 1px solid rgba(15, 23, 42, 0.05);
}
}
@media (max-height: 900px) {
.task-class-sidebar__header {
padding-top: 12px;
padding-bottom: 12px;
gap: 10px;
}
.task-class-sidebar__header { padding: 12px 18px; }
.task-class-sidebar__list,
.task-class-sidebar__skeleton {
padding-top: 16px;
padding-bottom: 16px;
gap: 10px;
}
.task-class-card {
border-radius: 20px;
}
.task-class-card__summary {
padding: 14px 14px 14px 13px;
min-height: 76px;
}
.task-class-card__content {
gap: 6px;
}
.task-class-card__content strong {
font-size: 15px;
}
.task-class-sidebar__create {
min-height: 88px;
}
}
@media (max-height: 820px) {
.task-class-sidebar__header,
.task-class-sidebar__list,
.task-class-sidebar__skeleton {
padding-left: 14px;
padding-right: 14px;
}
.task-class-card__summary {
padding: 12px;
min-height: 72px;
}
.task-class-card__corner {
width: 40px;
height: 40px;
}
.task-class-card__content strong {
font-size: 14px;
}
.task-class-card__content span,
.task-class-card__detail-text,
.task-class-card__detail-status {
font-size: 12px;
}
.task-class-sidebar__skeleton { padding: 16px 18px; }
}
</style>

View File

@@ -281,11 +281,12 @@ function handlePreviewDragEnd() {
<article
v-for="header in weekHeaders"
:key="`${header.dayOfWeek}-${slot.order}`"
:key="`${weekData?.week ?? 0}-${header.dayOfWeek}-${slot.order}`"
class="planning-board__cell"
:class="[
`planning-board__cell--${resolveEventTone(resolveEvent(header.dayOfWeek, slot.order))}`,
{
'board-item-pop': resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty',
'planning-board__cell--selectable': scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty',
'planning-board__cell--selected': resolveEvent(header.dayOfWeek, slot.order) && isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id),
'planning-board__cell--draggable': isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order)),
@@ -293,6 +294,7 @@ function handlePreviewDragEnd() {
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
},
]"
:style="{ '--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's' }"
:draggable="isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order))"
@dragstart="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
@dragover="handlePreviewDragOver(header.dayOfWeek, slot.order, $event)"
@@ -347,29 +349,29 @@ function handlePreviewDragEnd() {
<style scoped>
.planning-board {
--planning-grid-padding-x: 24px;
--planning-grid-padding-y: 28px;
--planning-grid-gap-x: 12px;
--planning-grid-padding-x: 20px;
--planning-grid-padding-y: 20px;
--planning-grid-gap-x: 10px;
--planning-grid-gap-y: 10px;
--planning-time-column-width: 74px;
--planning-time-column-width: 68px;
--planning-day-column-min: 96px;
--planning-cell-height: clamp(72px, 9.2vh, 112px);
min-width: 0;
min-height: 0;
border-radius: 28px;
border: 1px solid rgba(214, 223, 236, 0.82);
background: linear-gradient(180deg, rgba(252, 253, 255, 0.98), rgba(248, 251, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.05);
background: #ffffff;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.planning-board__header {
padding: 18px 28px 16px;
border-bottom: 1px solid rgba(221, 229, 240, 0.86);
color: #1f2b42;
font-size: 18px;
padding: 18px 24px 16px;
border-bottom: 1px solid rgba(15, 23, 42, 0.05);
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.planning-board__grid {
@@ -391,7 +393,7 @@ function handlePreviewDragEnd() {
display: grid;
justify-items: center;
gap: 4px;
color: #8ca0bd;
color: #64748b;
}
.planning-board__day-head span {
@@ -409,13 +411,14 @@ function handlePreviewDragEnd() {
display: grid;
align-content: center;
justify-items: end;
color: #9aacbf;
padding-right: 8px;
color: #94a3b8;
padding-right: 12px;
}
.planning-board__time-cell strong {
font-size: 15px;
color: #8da0bc;
font-size: 14px;
color: #64748b;
font-weight: 700;
}
.planning-board__time-cell small {
@@ -428,14 +431,15 @@ function handlePreviewDragEnd() {
.planning-board__cell {
position: relative;
min-height: var(--planning-cell-height);
border-radius: 22px;
border: 1px solid rgba(228, 234, 243, 0.92);
padding: 18px 14px;
border-radius: 14px;
border: 1px solid transparent;
padding: 14px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.planning-board__cell-main {
@@ -445,34 +449,34 @@ function handlePreviewDragEnd() {
}
.planning-board__cell-main strong {
color: #7387a3;
font-size: 15px;
line-height: 1.35;
color: #334155;
font-size: 14px;
line-height: 1.4;
font-weight: 700;
min-width: 0;
overflow-wrap: anywhere;
}
.planning-board__cell-main span {
color: #9badc5;
color: #64748b;
font-size: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.planning-board__cell--course {
background: #acd6f4;
background: #e0f2fe;
}
.planning-board__cell--course-embedded {
background: linear-gradient(180deg, rgba(121, 187, 239, 0.96) 0%, rgba(88, 161, 225, 0.96) 100%);
background: #b9e6fe;
align-items: stretch;
padding: 9px;
padding: 8px;
}
.planning-board__cell--course .planning-board__cell-main strong,
.planning-board__cell--course .planning-board__cell-main span {
color: #2576cc;
color: #0284c7;
}
.planning-board__embedded-shell {
@@ -498,7 +502,7 @@ function handlePreviewDragEnd() {
.planning-board__embedded-course {
padding: 6px 4px;
color: #ffffff;
color: #0369a1;
}
.planning-board__embedded-course strong,
@@ -522,13 +526,12 @@ function handlePreviewDragEnd() {
.planning-board__embedded-task {
padding: 6px 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 18px rgba(31, 82, 145, 0.14);
border-radius: 10px;
background: #ffffff;
}
.planning-board__embedded-task strong {
color: #1f5db3;
color: #0369a1;
font-size: 11px;
line-height: 1.24;
font-weight: 800;
@@ -544,48 +547,59 @@ function handlePreviewDragEnd() {
}
.planning-board__cell--amber {
background: #ffe58b;
background: #fef3c7;
}
.planning-board__cell--amber .planning-board__cell-main strong,
.planning-board__cell--amber .planning-board__cell-main span {
color: #7d6917;
color: #d97706;
}
.planning-board__cell--mint {
background: #d7f7a7;
background: #dcfce7;
}
.planning-board__cell--mint .planning-board__cell-main strong,
.planning-board__cell--mint .planning-board__cell-main span,
.planning-board__cell--emerald .planning-board__cell-main strong,
.planning-board__cell--emerald .planning-board__cell-main span {
color: #72a91d;
color: #059669;
}
.planning-board__cell--emerald {
background: #d3f3ac;
background: #d1fae5;
}
.planning-board__cell--rose {
background: #f6dfe2;
background: #fee2e2;
}
.planning-board__cell--rose .planning-board__cell-main strong,
.planning-board__cell--rose .planning-board__cell-main span {
color: #e6696e;
color: #e11d48;
}
.planning-board__cell--violet {
background: #e9dcfb;
background: #f3e8ff;
}
.planning-board__cell--violet .planning-board__cell-main strong,
.planning-board__cell--violet .planning-board__cell-main span {
color: #7c3aed;
}
.planning-board__cell--sky {
background: #d8ecfb;
background: #e0f2fe;
}
.planning-board__cell--sky .planning-board__cell-main strong,
.planning-board__cell--sky .planning-board__cell-main span {
color: #0284c7;
}
.planning-board__cell--empty {
background: #f8fbff;
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.05);
}
.planning-board__cell--selectable {
@@ -593,12 +607,16 @@ function handlePreviewDragEnd() {
}
.planning-board__cell--selected {
box-shadow: inset 0 0 0 2px rgba(32, 102, 212, 0.52);
box-shadow: inset 0 0 0 2px #3b82f6;
}
.planning-board__cell--draggable {
cursor: grab;
}
.planning-board__cell--draggable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
}
.planning-board__cell--dragging {
opacity: 0.42;
@@ -606,8 +624,8 @@ function handlePreviewDragEnd() {
.planning-board__cell--dragover {
box-shadow:
inset 0 0 0 2px rgba(20, 92, 192, 0.58),
0 0 0 4px rgba(33, 109, 215, 0.1);
inset 0 0 0 2px #2563eb,
0 0 0 4px rgba(59, 130, 246, 0.15);
}
.planning-board__checkbox {
@@ -623,11 +641,23 @@ function handlePreviewDragEnd() {
}
.planning-board__checkbox--active {
border-color: #1e66d4;
background: #1e66d4;
border-color: #3b82f6;
background: #3b82f6;
box-shadow: inset 0 0 0 3px #ffffff;
}
@keyframes board-item-spring {
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.board-item-pop {
animation: board-item-spring 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--anim-delay, 0s);
transform-origin: center center;
}
@media (max-width: 1560px) {
.planning-board__grid {
--planning-time-column-width: 64px;

View File

@@ -5,6 +5,7 @@ import AuthView from '@/views/AuthView.vue'
import AssistantView from '@/views/AssistantView.vue'
import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue'
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
const router = createRouter({
history: createWebHistory(),
@@ -45,6 +46,11 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/prototype/tool-trace',
name: 'tool-trace-prototype',
component: ToolTracePrototypeView,
},
],
})

View File

@@ -1,197 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to?: '/dashboard' | '/assistant' | '/schedule'
}
const router = useRouter()
const route = useRoute()
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' },
]
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
function handleSidebarNavigate(item: SidebarItem) {
// 1. 和首页保持相同行为:已接通路由直接跳转,未接通入口给出明确提示。
// 2. 同路由不重复 push避免产生冗余导航记录。
// 3. 这样可保证两个页面的侧栏交互预期完全一致。
if (item.to) {
if (route.path !== item.to) {
void router.push(item.to)
}
return
}
ElMessage.info(`${item.label} 页面正在开发中`)
}
</script>
<template>
<main class="assistant-view">
<section class="assistant-view__layout">
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
<AssistantPanel class="assistant-view__panel" view-mode="standalone" :initial-history-width="248" />
</section>
</main>
<AssistantPanel class="assistant-view__panel" view-mode="standalone" :initial-history-width="248" />
</template>
<style scoped>
.assistant-view {
box-sizing: border-box;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
padding: 10px;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(22, 92, 168, 0.1), transparent 30%),
linear-gradient(180deg, #f8fbff 0%, #eef3f9 100%);
}
.assistant-view__layout {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
.dashboard-sidebar {
height: 100%;
border-radius: 26px;
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
padding: 16px 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
.dashboard-sidebar__brand,
.dashboard-sidebar__settings {
width: 50px;
height: 50px;
border: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dashboard-sidebar__nav {
display: grid;
gap: 12px;
align-content: start;
}
.dashboard-sidebar__nav-item {
width: 54px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.74);
padding: 10px 8px;
display: grid;
justify-items: center;
gap: 5px;
cursor: pointer;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
}
.dashboard-sidebar__nav-item small {
font-size: 10px;
}
.dashboard-sidebar__nav-item--active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.assistant-view__panel {
min-width: 0;
min-height: 0;
height: 100%;
border-radius: 18px;
}
@media (max-width: 980px) {
.assistant-view__layout {
height: auto;
grid-template-columns: 1fr;
}
.dashboard-sidebar {
height: auto;
grid-template-columns: auto 1fr auto;
grid-template-rows: none;
align-items: center;
}
.dashboard-sidebar__nav {
grid-auto-flow: column;
justify-content: center;
}
border-radius: 24px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
@media (max-width: 720px) {
.assistant-view {
.assistant-view__panel {
height: auto;
min-height: 100vh;
min-height: 100svh;
padding: 8px;
overflow: visible;
}
}
</style>

View File

@@ -15,9 +15,9 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const pageLoading = ref(false)
const taskLoading = ref(false)
const scheduleLoading = ref(false)
const pageLoading = ref(true)
const taskLoading = ref(true)
const scheduleLoading = ref(true)
const createTaskLoading = ref(false)
const logoutLoading = ref(false)
const createTaskDialogVisible = ref(false)
@@ -31,8 +31,6 @@ const dashboardMainScale = ref(1)
const tasks = ref<TaskItem[]>([])
const todayEvents = ref<TodayEvent[]>([])
const sidebarWidth = ref(78)
const taskForm = reactive<{
title: string
priority_group: number
@@ -43,29 +41,6 @@ const taskForm = reactive<{
deadline_at: null,
})
interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai'
label: string
short: string
to?: '/dashboard' | '/assistant' | '/schedule'
}
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' },
]
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
const quadrantOrder = [1, 2, 3, 4] as const
@@ -101,48 +76,26 @@ const quadrantMeta: Record<
const pageTitleDate = computed(() => formatHeaderDate(new Date()))
const greetingName = computed(() => authStore.lastUsername || 'SmartFlow 用户')
const layoutStyle = computed(() => ({
'--dashboard-sidebar-width': `${sidebarWidth.value}px`,
}))
const dashboardMainScaleStyle = computed(() => ({
'--dashboard-main-scale': `${dashboardMainScale.value}`,
}))
const groupedTasks = computed(() => {
const groups: Record<number, TaskItem[]> = {
1: [],
2: [],
3: [],
4: [],
}
const groups: Record<number, TaskItem[]> = { 1: [], 2: [], 3: [], 4: [] }
for (const task of tasks.value) {
if (groups[task.priority_group]) {
groups[task.priority_group].push(task)
}
if (groups[task.priority_group]) groups[task.priority_group].push(task)
}
for (const key of Object.keys(groups)) {
groups[Number(key)].sort((left, right) => {
if (left.is_completed !== right.is_completed) {
return left.is_completed ? 1 : -1
}
if (left.is_completed !== right.is_completed) return left.is_completed ? 1 : -1
return left.id - right.id
})
}
return groups
})
async function loadTasksData() {
taskLoading.value = true
try {
tasks.value = await getTasks()
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '任务加载失败')
} finally {
taskLoading.value = false
}
try { tasks.value = await getTasks() }
catch (error) { ElMessage.warning(error instanceof Error ? error.message : '任务加载失败') }
finally { taskLoading.value = false }
}
async function loadScheduleData() {
@@ -150,16 +103,18 @@ async function loadScheduleData() {
try {
const schedules = await getTodaySchedule()
todayEvents.value = schedules.flatMap((item) => item.events).sort((left, right) => left.order - right.order)
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '今日日程加载失败')
} finally {
scheduleLoading.value = false
}
} catch (error) { ElMessage.warning(error instanceof Error ? error.message : '今日日程加载失败') }
finally { scheduleLoading.value = false }
}
async function loadDashboardData() {
pageLoading.value = true
await Promise.allSettled([loadTasksData(), loadScheduleData()])
// 锁死最少加载时间,确保骨架屏平稳滑入定型后,再进行内外数据的交叉溶解
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
pageLoading.value = false
}
@@ -172,14 +127,11 @@ async function handleTaskToggle(task: TaskItem) {
ElMessage.success('任务已恢复为未完成')
return
}
const result = await completeTask(task.id)
task.is_completed = result.is_completed
task.status = result.status
ElMessage.success(result.already_completed ? '任务已经是完成状态' : '任务已标记为完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务更新失败')
}
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '任务更新失败') }
}
function openCreateTaskDialog() {
@@ -190,11 +142,7 @@ function openCreateTaskDialog() {
}
async function handleCreateTask() {
if (!taskForm.title.trim()) {
ElMessage.warning('请先填写任务标题')
return
}
if (!taskForm.title.trim()) { ElMessage.warning('请先填写任务标题'); return }
createTaskLoading.value = true
try {
const created = await createTask({
@@ -202,120 +150,34 @@ async function handleCreateTask() {
priority_group: taskForm.priority_group,
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
})
tasks.value.unshift({
id: created.id,
user_id: 0,
title: created.title,
priority_group: created.priority_group,
status: created.status,
deadline: created.deadline_at ?? '',
is_completed: false,
})
tasks.value.unshift({ id: created.id, user_id: 0, title: created.title, priority_group: created.priority_group, status: created.status, deadline: created.deadline_at ?? '', is_completed: false })
createTaskDialogVisible.value = false
ElMessage.success('任务已添加')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '创建任务失败')
} finally {
createTaskLoading.value = false
}
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '创建任务失败') }
finally { createTaskLoading.value = false }
}
async function handleLogout() {
logoutLoading.value = true
try {
await authStore.logout()
ElMessage.success('已安全退出登录')
} catch (error) {
ElMessage.warning(error instanceof Error ? `${error.message},本地登录态已清除` : '退出接口异常,本地登录态已清除')
} finally {
logoutLoading.value = false
await router.push('/auth')
}
try { await authStore.logout(); ElMessage.success('已安全退出登录') }
catch (error) { ElMessage.warning(error instanceof Error ? `${error.message},本地登录态已清除` : '退出接口异常,本地登录态已清除') }
finally { logoutLoading.value = false; await router.push('/auth') }
}
function handleCourseImportEntry() {
ElMessage.info('课表导入入口已预留,下一步我可以继续把导入流程页接出来')
}
function handleSidebarNavigate(item: SidebarItem) {
// 1. 已接通路由的入口直接跳转,避免侧栏按钮成为“仅装饰”元素。
// 2. 未接通的入口先给出明确提示,防止用户误以为点击失效。
// 3. 同路由不重复 push避免产生无意义导航与日志噪音。
if (item.to) {
if (route.path !== item.to) {
void router.push(item.to)
}
return
}
ElMessage.info(`${item.label} 页面正在开发中`)
}
function clampSidebarWidth(nextWidth: number) {
return Math.min(110, Math.max(68, nextWidth))
}
function startResize(type: 'sidebar', event: PointerEvent) {
const layout = dashboardLayoutRef.value
if (!layout || window.innerWidth <= 1380) {
return
}
const rect = layout.getBoundingClientRect()
const startX = event.clientX
const startSidebarWidth = sidebarWidth.value
// 1. 拖拽时先记录容器宽度和起始位置,避免每次 move 都重复读布局造成抖动。
// 2. 主区域需要保留最小宽度,防止侧栏被拖得过宽后正文不可读。
// 3. 结束时统一解绑事件,避免指针松开后仍残留拖拽状态。
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX
const splitterTotalWidth = 10
const minMainWidth = 560
const nextSidebarWidth = clampSidebarWidth(startSidebarWidth + deltaX)
const maxSidebarWidth = rect.width - splitterTotalWidth - minMainWidth
sidebarWidth.value = Math.min(nextSidebarWidth, Math.max(68, maxSidebarWidth))
}
const stopResize = () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopResize)
document.body.classList.remove('dashboard-resizing')
}
document.body.classList.add('dashboard-resizing')
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopResize)
}
function handleCourseImportEntry() { ElMessage.info('课表导入入口已预留') }
function syncDashboardMainScale() {
const main = dashboardMainRef.value
const inner = dashboardMainInnerRef.value
const topbar = dashboardTopbarRef.value
const content = dashboardContentRef.value
if (!main || !inner || !topbar || !content || window.innerWidth <= 980) {
dashboardMainScale.value = 1
return
}
// 1. 先回到 1:1确保拿到未缩放状态下的真实高度。
// 2. 自然高度按“顶部栏高度 + 内容 scrollHeight + 栅格间距”计算,避免被 1fr 约束低估。
// 3. 仅在桌面端启用缩放,小屏仍走原生滚动布局。
if (!main || !inner || !topbar || !content || window.innerWidth <= 980) { dashboardMainScale.value = 1; return }
dashboardMainScale.value = 1
window.requestAnimationFrame(() => {
const availableHeight = main.clientHeight
const gridGap = 10
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
if (!availableHeight || !naturalHeight) {
dashboardMainScale.value = 1
return
}
// 预留适中安全边距,优先保证底部卡片完整可见,避免再次出现裁切。
if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return }
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.98)
dashboardMainScale.value = Number(nextScale.toFixed(4))
})
@@ -329,62 +191,19 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.body.classList.remove('dashboard-resizing')
window.removeEventListener('resize', syncDashboardMainScale)
})
watch(
[() => tasks.value.length, () => todayEvents.value.length, pageLoading],
async () => {
await nextTick()
syncDashboardMainScale()
},
{ flush: 'post' },
)
watch(
sidebarWidth,
async () => {
await nextTick()
syncDashboardMainScale()
},
{ flush: 'post' },
)
watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], async () => {
await nextTick()
syncDashboardMainScale()
}, { flush: 'post' })
</script>
<template>
<main class="dashboard-page">
<div ref="dashboardLayoutRef" class="dashboard-layout" :style="layoutStyle">
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
<div
class="dashboard-splitter"
role="separator"
aria-label="调整侧边导航宽度"
@pointerdown.prevent="startResize('sidebar', $event)"
>
<span class="dashboard-splitter__line" />
</div>
<section ref="dashboardMainRef" class="dashboard-main">
<div ref="dashboardMainInnerRef" class="dashboard-main__scaled" :style="dashboardMainScaleStyle">
<header ref="dashboardTopbarRef" class="dashboard-topbar glass-panel">
<section ref="dashboardMainRef" class="dashboard-main">
<div ref="dashboardMainInnerRef" class="dashboard-main__scaled" :style="{ '--dashboard-main-scale': dashboardMainScale }">
<header ref="dashboardTopbarRef" class="dashboard-topbar glass-panel dashboard-item-pop" :style="{ '--anim-delay': '0s' }">
<div>
<div class="dashboard-topbar__brandline">
<strong>AI 智慧日程系统</strong>
@@ -393,14 +212,7 @@ watch(
</div>
<div class="dashboard-topbar__actions">
<button
type="button"
class="dashboard-topbar__logout"
:disabled="logoutLoading"
@click="handleLogout"
>
{{ logoutLoading ? '退出中…' : '登出' }}
</button>
<button type="button" class="dashboard-topbar__logout" :disabled="logoutLoading" @click="handleLogout">{{ logoutLoading ? '退出中...' : '登出' }}</button>
<div class="dashboard-topbar__profile">
<strong>{{ greetingName }}</strong>
<span>{{ greetingName.slice(0, 1).toUpperCase() }}</span>
@@ -409,18 +221,18 @@ watch(
</header>
<div ref="dashboardContentRef" class="dashboard-content page-shell">
<TodayTimeline :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<TodayTimeline class="dashboard-item-pop" :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<div class="dashboard-actions">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">
添加任务
</button>
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
</div>
<section class="dashboard-quadrants">
<TaskQuadrantCard
v-for="group in quadrantOrder"
v-for="(group, index) in quadrantOrder"
:key="group"
class="dashboard-item-pop"
:style="{ '--anim-delay': (0.12 + index * 0.04) + 's' }"
:title="quadrantMeta[group].title"
:caption="quadrantMeta[group].caption"
:tone="quadrantMeta[group].tone"
@@ -432,14 +244,12 @@ watch(
/>
</section>
<section class="dashboard-import glass-panel">
<section class="dashboard-import glass-panel dashboard-item-pop" :style="{ '--anim-delay': '0.28s' }">
<div class="dashboard-import__content">
<p class="dashboard-import__eyebrow">课程导入</p>
<h2>导入课表</h2>
<p>导入课表后可以在安排日程时避开上课时间后续我会继续把导入流程页接完整</p>
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">
开始导入
</button>
<p>导入课表后可以在安排日程时避开上课时间</p>
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">开始导入</button>
</div>
<div class="dashboard-import__shape">
<span class="dashboard-import__shape-ring" />
@@ -450,20 +260,11 @@ watch(
</div>
</section>
</div>
<el-dialog
v-model="createTaskDialogVisible"
title="添加任务"
width="460px"
align-center
class="dashboard-dialog"
>
<el-dialog v-model="createTaskDialogVisible" title="添加任务" width="460px" align-center class="dashboard-dialog">
<el-form label-position="top">
<el-form-item label="任务标题">
<el-input v-model="taskForm.title" maxlength="255" placeholder="例如:完成数据库复习" />
</el-form-item>
<el-form-item label="优先级象限">
<el-select v-model="taskForm.priority_group" class="dashboard-dialog__select">
<el-option :value="1" label="1 - 重要且紧急" />
@@ -472,142 +273,39 @@ watch(
<el-option :value="4" label="4 - 不简单不重要" />
</el-select>
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="taskForm.deadline_at"
type="datetime"
placeholder="可选,不设置也可以"
class="dashboard-dialog__select"
/>
<el-date-picker v-model="taskForm.deadline_at" type="datetime" placeholder="可选" class="dashboard-dialog__select" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createTaskDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createTaskLoading" @click="handleCreateTask">保存任务</el-button>
</template>
</el-dialog>
</main>
</template>
<style scoped>
.dashboard-page {
height: 100vh;
padding: 10px;
overflow: hidden;
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); }
@keyframes dashboard-item-spring {
0% { opacity: 0; transform: scale(0.9) translateY(20px); }
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.dashboard-layout {
--dashboard-sidebar-width: 78px;
height: calc(100vh - 20px);
display: grid;
grid-template-columns:
var(--dashboard-sidebar-width)
10px
minmax(0, 1fr);
gap: 8px;
align-items: stretch;
.dashboard-item-pop {
animation: dashboard-item-spring 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--anim-delay, 0s);
transform-origin: center center;
}
.dashboard-sidebar {
height: 100%;
border-radius: 26px;
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
padding: 16px 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
.dashboard-sidebar__brand,
.dashboard-sidebar__settings {
width: 50px;
height: 50px;
border: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dashboard-sidebar__nav {
display: grid;
gap: 12px;
align-content: start;
}
.dashboard-sidebar__nav-item {
width: 54px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.74);
padding: 10px 8px;
display: grid;
justify-items: center;
gap: 5px;
cursor: pointer;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
}
.dashboard-sidebar__nav-item small {
font-size: 10px;
}
.dashboard-sidebar__nav-item--active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.dashboard-splitter {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
touch-action: none;
}
.dashboard-splitter__line {
width: 4px;
height: 64px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(145, 163, 188, 0.24), rgba(88, 124, 177, 0.4), rgba(145, 163, 188, 0.24));
transition:
background-color 0.18s ease,
transform 0.18s ease;
}
.dashboard-splitter:hover .dashboard-splitter__line {
transform: scaleX(1.15);
background: linear-gradient(180deg, rgba(104, 140, 194, 0.34), rgba(42, 108, 214, 0.62), rgba(104, 140, 194, 0.34));
}
.dashboard-main {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.dashboard-main { min-width: 0; min-height: 0; overflow: hidden; height: 100%; }
.dashboard-main__scaled {
--dashboard-main-scale: 1;
min-width: 0;
min-height: 0;
width: calc(100% / var(--dashboard-main-scale));
height: calc(100% / var(--dashboard-main-scale));
display: grid;
@@ -618,263 +316,53 @@ watch(
}
.dashboard-topbar {
border-radius: 24px;
padding: 18px 22px;
border-radius: 20px;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid rgba(17, 24, 39, 0.08);
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.03);
}
.dashboard-topbar__brandline {
display: flex;
align-items: center;
gap: 14px;
}
.dashboard-topbar__brandline { display: flex; align-items: center; gap: 14px; }
.dashboard-topbar__brandline strong { font-size: 18px; color: #14233a; }
.dashboard-topbar__brandline span { color: #677588; font-size: 13px; }
.dashboard-topbar__brandline strong {
font-size: 18px;
color: #14233a;
}
.dashboard-topbar__actions { display: flex; align-items: center; gap: 14px; }
.dashboard-topbar__logout { min-width: 88px; height: 38px; border-radius: 13px; border: 1px solid rgba(28, 98, 205, 0.22); background: #f9fbff; color: #1d63cf; cursor: pointer; }
.dashboard-topbar__profile { display: flex; align-items: center; gap: 10px; }
.dashboard-topbar__profile strong { font-size: 13px; }
.dashboard-topbar__profile span { width: 38px; height: 38px; border-radius: 999px; background: #eef3fb; color: #314156; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
.dashboard-topbar__brandline span {
color: #677588;
font-size: 13px;
}
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; }
.dashboard-actions { display: flex; justify-content: flex-end; }
.dashboard-actions__primary { height: 42px; padding: 0 20px; border: none; border-radius: 15px; background: #3b82f6; color: #fff; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); }
.dashboard-topbar__actions {
display: flex;
align-items: center;
gap: 14px;
}
.dashboard-topbar__logout {
min-width: 88px;
height: 38px;
border-radius: 13px;
border: 1px solid rgba(28, 98, 205, 0.22);
background: #f9fbff;
color: #1d63cf;
cursor: pointer;
}
.dashboard-topbar__profile {
display: flex;
align-items: center;
gap: 10px;
}
.dashboard-topbar__profile strong {
font-size: 13px;
}
.dashboard-topbar__profile span {
width: 38px;
height: 38px;
border-radius: 999px;
background: #eef3fb;
color: #314156;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
}
.dashboard-content {
width: 100%;
max-width: none;
min-height: 0;
min-width: 0;
display: grid;
gap: 14px;
overflow-y: hidden;
overflow-x: hidden;
padding-right: 0;
align-content: start;
}
.dashboard-actions {
display: flex;
justify-content: flex-end;
}
.dashboard-actions__primary {
height: 42px;
padding: 0 20px;
border: none;
border-radius: 15px;
background: linear-gradient(180deg, #246ff1 0%, #1a5dc8 100%);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.dashboard-quadrants {
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.dashboard-import {
border-radius: 26px;
padding: 28px 30px;
min-height: 240px;
background: linear-gradient(135deg, #0f5ca9 0%, #0b4b89 100%);
color: #fff;
border-radius: 24px;
padding: 32px;
min-height: 220px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
display: flex;
justify-content: space-between;
gap: 20px;
gap: 24px;
position: relative;
overflow: hidden;
position: relative;
}
.dashboard-import__content {
position: relative;
z-index: 1;
max-width: 460px;
padding-bottom: 22px;
}
.dashboard-import__content { position: relative; z-index: 1; max-width: 460px; }
.dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; }
.dashboard-import h2 { margin: 0; font-size: 32px; color: #0f172a; font-weight: 800; }
.dashboard-import p { margin: 14px 0 24px; color: #64748b; font-size: 14px; }
.dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; }
.dashboard-import__eyebrow {
margin: 0 0 8px;
opacity: 0.76;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
font-weight: 700;
}
.dashboard-import h2 {
margin: 0;
font-size: 38px;
line-height: 1.08;
letter-spacing: -0.04em;
}
.dashboard-import p {
margin: 12px 0 22px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.7;
font-size: 14px;
}
.dashboard-import__button {
height: 46px;
padding: 0 20px;
border: none;
border-radius: 15px;
background: #fff;
color: #0d55a0;
font-weight: 800;
cursor: pointer;
}
.dashboard-import__shape {
position: relative;
width: 250px;
min-width: 250px;
display: grid;
place-items: center;
}
.dashboard-import__shape-ring,
.dashboard-import__shape-core {
position: absolute;
border-radius: 999px;
border: 14px solid rgba(255, 255, 255, 0.92);
}
.dashboard-import__shape-ring {
width: 168px;
height: 168px;
right: 22px;
bottom: 10px;
}
.dashboard-import__shape-core {
width: 96px;
height: 96px;
right: 0;
bottom: 2px;
}
.dashboard-dialog :deep(.el-dialog) {
border-radius: 24px;
}
.dashboard-dialog__select {
width: 100%;
}
@media (max-width: 1380px) {
.dashboard-layout {
height: calc(100vh - 20px);
grid-template-columns: 78px minmax(0, 1fr);
}
.dashboard-splitter {
display: none;
}
}
@media (max-width: 980px) {
.dashboard-layout {
height: auto;
grid-template-columns: 1fr;
}
.dashboard-sidebar {
height: auto;
grid-template-columns: auto 1fr auto;
grid-template-rows: none;
align-items: center;
}
.dashboard-sidebar__nav {
grid-auto-flow: column;
justify-content: center;
}
.dashboard-main {
grid-column: auto;
}
.dashboard-main__scaled {
width: 100%;
height: 100%;
transform: none;
}
.dashboard-content {
overflow-y: auto;
}
.dashboard-quadrants {
grid-template-columns: 1fr;
}
.dashboard-import {
flex-direction: column;
}
}
@media (max-width: 720px) {
.dashboard-page {
height: auto;
padding: 8px;
overflow: visible;
}
.dashboard-topbar {
flex-direction: column;
align-items: flex-start;
}
.dashboard-topbar__actions,
.dashboard-topbar__brandline {
width: 100%;
justify-content: space-between;
}
}
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
.dashboard-import__shape-ring { position: absolute; inset: 0; border: 40px solid #3b82f6; border-radius: 50%; }
.dashboard-import__shape-core { position: absolute; inset: 80px; background: #3b82f6; border-radius: 50%; }
</style>

View File

@@ -116,13 +116,6 @@ if (typeof window !== 'undefined') {
const router = useRouter()
const route = useRoute()
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' },
]
const taskClassLoading = ref(false)
const taskClassDetailLoading = ref(false)
const weekLoading = ref(false)
@@ -155,16 +148,6 @@ const MAX_SCHEDULE_WEEK = 24
let weekRequestSequence = 0
let activeWeekRequestSequence = 0
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
return 'ai'
}
if (route.path.startsWith('/schedule')) {
return 'calendar'
}
return 'home'
})
const effectiveSelectedTaskClassIds = computed(() => {
if (taskClassMultiSelectMode.value) {
return selectedTaskClassIds.value
@@ -944,28 +927,8 @@ onMounted(async () => {
</script>
<template>
<main class="schedule-page">
<div class="schedule-layout">
<aside class="dashboard-sidebar">
<div class="dashboard-sidebar__brand">S</div>
<nav class="dashboard-sidebar__nav">
<button
v-for="item in sidebarItems"
:key="item.key"
type="button"
class="dashboard-sidebar__nav-item"
:class="{ 'dashboard-sidebar__nav-item--active': item.key === activeSidebarKey }"
@click="handleSidebarNavigate(item)"
>
<span>{{ item.short }}</span>
<small>{{ item.label }}</small>
</button>
</nav>
<button type="button" class="dashboard-sidebar__settings"></button>
</aside>
<section class="schedule-shell">
<header class="schedule-topbar">
<section class="schedule-shell">
<header class="schedule-topbar">
<div class="schedule-topbar__brand">
<span class="schedule-topbar__brand-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1084,104 +1047,26 @@ onMounted(async () => {
</section>
</div>
</section>
</div>
<CreateTaskClassDialog
v-model="createDialogVisible"
:loading="createDialogLoading"
@submit="handleCreateTaskClass"
/>
</main>
<CreateTaskClassDialog
v-model="createDialogVisible"
:loading="createDialogLoading"
@submit="handleCreateTaskClass"
/>
</template>
<style scoped>
.schedule-page {
height: 100vh;
padding: 10px;
overflow: hidden;
background: linear-gradient(180deg, #f6f9fd 0%, #eff4fb 100%);
}
.schedule-layout {
height: calc(100vh - 20px);
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
min-height: 0;
}
.dashboard-sidebar {
height: 100%;
border-radius: 26px;
background: linear-gradient(180deg, #165ca8 0%, #104d8f 100%);
padding: 16px 12px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
}
.dashboard-sidebar__brand,
.dashboard-sidebar__settings {
width: 50px;
height: 50px;
border: none;
border-radius: 16px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dashboard-sidebar__nav {
display: grid;
gap: 12px;
align-content: start;
}
.dashboard-sidebar__nav-item {
width: 54px;
border: none;
border-radius: 16px;
background: transparent;
color: rgba(255, 255, 255, 0.74);
padding: 10px 8px;
display: grid;
justify-items: center;
gap: 5px;
cursor: pointer;
}
.dashboard-sidebar__nav-item span {
width: 32px;
height: 32px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
}
.dashboard-sidebar__nav-item small {
font-size: 10px;
}
.dashboard-sidebar__nav-item--active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.schedule-shell {
min-width: 0;
min-height: 0;
border-radius: 28px;
border: 1px solid rgba(215, 224, 237, 0.84);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.98));
height: 100%;
border-radius: 20px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.schedule-topbar {
@@ -1202,14 +1087,15 @@ onMounted(async () => {
}
.schedule-topbar__brand-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: linear-gradient(180deg, #1b64cf 0%, #0f56b7 100%);
width: 42px;
height: 42px;
border-radius: 12px;
background: #3b82f6;
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
}
.schedule-topbar__brand strong {
@@ -1222,8 +1108,8 @@ onMounted(async () => {
.schedule-topbar__meta {
display: grid;
justify-items: end;
gap: 6px;
color: #8493aa;
gap: 4px;
color: #64748b;
min-width: 0;
text-align: right;
}
@@ -1279,36 +1165,40 @@ onMounted(async () => {
.schedule-board__toolbar-button,
.schedule-board__footer-button {
height: 36px;
height: 38px;
border-radius: 10px;
border: 1px solid transparent;
padding: 0 18px;
padding: 0 16px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
transition: all 0.2s ease;
}
.schedule-board__toolbar-button--primary,
.schedule-board__footer-button--primary {
background: linear-gradient(180deg, #1d64d1 0%, #1157bd 100%);
background: #3b82f6;
color: #ffffff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
}
.schedule-board__toolbar-button--primary:hover,
.schedule-board__footer-button--primary:hover {
background: linear-gradient(180deg, #1757b8 0%, #0f4ea9 100%);
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
}
.schedule-board__toolbar-button--ghost {
border-color: rgba(27, 96, 208, 0.22);
border-color: #e2e8f0;
background: #ffffff;
color: #1e66d4;
color: #475569;
}
.schedule-board__toolbar-button--ghost:hover {
border-color: rgba(27, 96, 208, 0.38);
background: #f2f7ff;
border-color: #cbd5e1;
background: #f8fafc;
color: #0f172a;
}
.schedule-board__footer {
@@ -1318,8 +1208,14 @@ onMounted(async () => {
}
.schedule-board__footer-button--danger {
background: #bb3326;
background: #ef4444;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
}
.schedule-board__footer-button--danger:hover {
background: #dc2626;
transform: translateY(-1px);
}
.schedule-board__footer-button--danger:disabled,
@@ -1411,17 +1307,5 @@ onMounted(async () => {
}
}
@media (max-width: 1180px) {
.schedule-layout {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
display: none;
}
.schedule-main {
grid-template-columns: 1fr;
}
}
@media (max-width: 1440px) { .schedule-main { grid-template-columns: 320px 1fr; } }
</style>

View File

@@ -0,0 +1,624 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
interface BaselineLine {
id: string
atMs: number
text: string
}
type ToolLineState = 'called' | 'create' | 'blocked'
interface EnhancedLine {
id: string
atMs: number
type: 'text' | 'tool'
text?: string
summary?: string
detail?: string
state?: ToolLineState
toolName?: string
}
const baselineScript: BaselineLine[] = [
{ id: 'base-1', atMs: 0, text: '我先看一下当前任务队列,再把晚间学习任务挪开。' },
{ id: 'base-2', atMs: 680, text: '我已把队首任务调到周六晚间,不影响你今天课程。' },
{ id: 'base-3', atMs: 1360, text: '另外已帮你记录“周日报告提交”的提醒。' },
{ id: 'base-4', atMs: 1980, text: '如果你还要我打乱同类任务顺序,需要你先明确授权。' },
]
const enhancedScript: EnhancedLine[] = [
{ id: 'enh-1', atMs: 0, type: 'text', text: '我先看一下当前任务队列,再把晚间学习任务挪开。' },
{
id: 'enh-2',
atMs: 260,
type: 'tool',
state: 'called',
toolName: 'queue_status',
summary: '已调用工具:查看任务队列',
detail: '现在还有 3 项待处理、已完成 1 项。我会先处理当前队首任务。',
},
{
id: 'enh-3',
atMs: 620,
type: 'tool',
state: 'called',
toolName: 'queue_pop_head',
summary: '已调用工具:取出当前要处理的任务',
detail: '已取到“英语听力练习”,准备尝试挪到周六晚间。',
},
{
id: 'enh-4',
atMs: 980,
type: 'tool',
state: 'called',
toolName: 'queue_apply_head_move',
summary: '已调用工具:调整任务到新时段',
detail: '调整成功,晚间冲突已解除,待处理数量减少到 2 项。',
},
{ id: 'enh-5', atMs: 1360, type: 'text', text: '我已把队首任务调到周六晚间,不影响你今天课程。' },
{
id: 'enh-6',
atMs: 1730,
type: 'tool',
state: 'create',
toolName: 'quick_note_create',
summary: '已创建:周日报告提交提醒',
detail: '提醒已经记下,优先级是“重要不紧急”。',
},
{
id: 'enh-7',
atMs: 2100,
type: 'tool',
state: 'blocked',
toolName: 'min_context_switch',
summary: '已拦截:打乱顺序的操作',
detail: '你还没授权“允许打乱顺序”,所以这一步先不执行。',
},
{ id: 'enh-8', atMs: 2460, type: 'text', text: '如果你还要我打乱同类任务顺序,需要你先明确授权。' },
]
const baselineLines = ref<BaselineLine[]>([])
const enhancedLines = ref<EnhancedLine[]>([])
const isReplaying = ref(false)
const replayRound = ref(0)
const expandedToolLineMap = reactive<Record<string, boolean>>({})
const timerHandles = new Set<number>()
function clearReplayTimers() {
for (const handle of timerHandles) {
window.clearTimeout(handle)
}
timerHandles.clear()
}
function resetExpandedToolLineState() {
for (const key of Object.keys(expandedToolLineMap)) {
delete expandedToolLineMap[key]
}
}
function replay() {
clearReplayTimers()
replayRound.value += 1
baselineLines.value = []
enhancedLines.value = []
resetExpandedToolLineState()
isReplaying.value = true
for (const line of baselineScript) {
const handle = window.setTimeout(() => {
baselineLines.value = [...baselineLines.value, line]
}, line.atMs)
timerHandles.add(handle)
}
for (const line of enhancedScript) {
const handle = window.setTimeout(() => {
enhancedLines.value = [...enhancedLines.value, line]
}, line.atMs)
timerHandles.add(handle)
}
const doneDelay = Math.max(
...baselineScript.map((item) => item.atMs),
...enhancedScript.map((item) => item.atMs),
) + 220
const doneHandle = window.setTimeout(() => {
isReplaying.value = false
timerHandles.delete(doneHandle)
}, doneDelay)
timerHandles.add(doneHandle)
}
function toolStateLabel(state?: ToolLineState) {
if (state === 'called') {
return '已调用'
}
if (state === 'create') {
return '已创建'
}
if (state === 'blocked') {
return '已拦截'
}
return '工具'
}
function isToolLineExpanded(lineId: string) {
return expandedToolLineMap[lineId] === true
}
function toggleToolLineExpanded(lineId: string) {
expandedToolLineMap[lineId] = !expandedToolLineMap[lineId]
}
onMounted(() => {
replay()
})
onBeforeUnmount(() => {
clearReplayTimers()
})
</script>
<template>
<main class="tool-compare-proto" :key="replayRound">
<header class="tool-compare-proto__top">
<div>
<p class="tool-compare-proto__eyebrow">工具调用可视化原型对比模式</p>
<h1>同一份聊天左边原样右边折叠式工具提示</h1>
<p>目标默认简洁只在你展开时看具体细节</p>
</div>
<div class="tool-compare-proto__actions">
<button type="button" class="tool-compare-proto__btn tool-compare-proto__btn--primary" @click="replay">
{{ isReplaying ? '重播中...' : '重播对比' }}
</button>
<a class="tool-compare-proto__btn tool-compare-proto__btn--ghost" href="/assistant">返回聊天页</a>
</div>
</header>
<section class="tool-compare-proto__grid">
<article class="tool-panel">
<header class="tool-panel__header">
<p class="tool-panel__tag">当前样式</p>
<h2>聊天页基线无工具提示</h2>
</header>
<div class="tool-panel__body">
<div class="tool-message tool-message--user">
<div class="tool-message__bubble">帮我把今天任务重新安排一下别影响晚上的课程顺便记一下周日报告</div>
</div>
<div class="tool-message tool-message--assistant">
<div class="tool-message__meta">
<span>Assistant</span>
<small>{{ isReplaying ? '流式中' : '完成' }}</small>
</div>
<div class="tool-message__bubble tool-message__bubble--assistant">
<p v-for="line in baselineLines" :key="line.id" class="proto-line">{{ line.text }}</p>
<p v-if="baselineLines.length <= 0" class="proto-line proto-line--placeholder">正在生成回复...</p>
</div>
</div>
</div>
</article>
<article class="tool-panel">
<header class="tool-panel__header">
<p class="tool-panel__tag">提案样式</p>
<h2>折叠式工具提示扳手图标</h2>
</header>
<div class="tool-panel__body">
<div class="tool-message tool-message--user">
<div class="tool-message__bubble">帮我把今天任务重新安排一下别影响晚上的课程顺便记一下周日报告</div>
</div>
<div class="tool-message tool-message--assistant">
<div class="tool-message__meta">
<span>Assistant</span>
<small>{{ isReplaying ? '流式中' : '完成' }}</small>
</div>
<div class="tool-message__bubble tool-message__bubble--assistant">
<template v-for="line in enhancedLines" :key="line.id">
<p v-if="line.type === 'text'" class="proto-line">{{ line.text }}</p>
<div
v-else
class="proto-tool"
:class="{
'proto-tool--called': line.state === 'called',
'proto-tool--create': line.state === 'create',
'proto-tool--blocked': line.state === 'blocked',
}"
>
<button type="button" class="proto-tool__head" @click="toggleToolLineExpanded(line.id)">
<span class="proto-tool__icon" aria-hidden="true">
<!-- 扳手图标 -->
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</span>
<span class="proto-tool__summary">{{ line.summary }}</span>
<em class="proto-tool__badge">{{ toolStateLabel(line.state) }}</em>
<span class="proto-tool__chevron" :class="{ 'proto-tool__chevron--expanded': isToolLineExpanded(line.id) }" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</span>
</button>
<p v-if="isToolLineExpanded(line.id)" class="proto-tool__detail">
{{ line.detail }}
</p>
</div>
</template>
<p v-if="enhancedLines.length <= 0" class="proto-line proto-line--placeholder">正在生成回复...</p>
</div>
</div>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.tool-compare-proto {
min-height: 100vh;
padding: 24px 20px 48px;
background:
radial-gradient(circle at 10% 10%, rgba(59, 130, 246, 0.05), transparent 40%),
radial-gradient(circle at 90% 90%, rgba(16, 185, 129, 0.05), transparent 40%),
#f8fafc;
color: #0f172a;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.tool-compare-proto__top {
width: min(1200px, 100%);
margin: 0 auto;
border-radius: 20px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
padding: 24px 32px;
display: flex;
gap: 20px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -2px rgba(0, 0, 0, 0.02);
}
.tool-compare-proto__eyebrow {
margin: 0;
color: #3b82f6;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-compare-proto__top h1 {
margin: 8px 0 6px;
font-size: clamp(24px, 2.5vw, 32px);
font-weight: 800;
letter-spacing: -0.02em;
color: #1e293b;
}
.tool-compare-proto__top p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.tool-compare-proto__actions {
display: flex;
gap: 12px;
}
.tool-compare-proto__btn {
border: 1px solid transparent;
border-radius: 12px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.tool-compare-proto__btn--primary {
background: #0f172a;
color: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);
}
.tool-compare-proto__btn--primary:hover {
background: #1e293b;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
}
.tool-compare-proto__btn--ghost {
background: #ffffff;
color: #475569;
border-color: #e2e8f0;
}
.tool-compare-proto__btn--ghost:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #1e293b;
}
.tool-compare-proto__grid {
width: min(1200px, 100%);
margin: 32px auto 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.tool-panel {
border-radius: 20px;
border: 1px solid #e2e8f0;
background: #ffffff;
min-height: 500px;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.tool-panel:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px -8px rgba(0,0,0,0.08);
}
.tool-panel__header {
padding: 20px 24px;
border-bottom: 1px solid #f1f5f9;
}
.tool-panel__tag {
margin: 0;
color: #3b82f6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-panel__header h2 {
margin: 4px 0 0;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.tool-panel__body {
padding: 24px;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.tool-message {
display: flex;
flex-direction: column;
}
.tool-message--user {
align-items: flex-end;
}
.tool-message__meta {
margin-bottom: 8px;
display: flex;
gap: 8px;
align-items: center;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
}
.tool-message__bubble {
max-width: 90%;
padding: 12px 16px;
border-radius: 16px;
border: 1px solid #e2e8f0;
background: #ffffff;
font-size: 14px;
line-height: 1.6;
color: #334155;
}
.tool-message--user .tool-message__bubble {
color: #ffffff;
border: none;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-bottom-right-radius: 4px;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15);
}
.tool-message__bubble--assistant {
background: #f8fafc;
border-color: #f1f5f9;
border-bottom-left-radius: 4px;
}
.proto-line {
margin: 0;
opacity: 0;
animation: line-fade-in 0.4s ease-out forwards;
}
.proto-line + .proto-line,
.proto-line + .proto-tool,
.proto-tool + .proto-line,
.proto-tool + .proto-tool {
margin-top: 10px;
}
.proto-line--placeholder {
color: #94a3b8;
font-style: italic;
}
.proto-tool {
font-size: 13px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #f8fafc;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
}
.proto-tool:hover {
border-color: #cbd5e1;
background: #f1f5f9;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
/* 状态修饰符:左侧装饰条与特定背景色 */
.proto-tool--called {
border-left: 4px solid #3b82f6;
}
.proto-tool--create {
border-left: 4px solid #10b981;
}
.proto-tool--blocked {
border-left: 4px solid #f43f5e;
}
.proto-tool__head {
width: 100%;
border: none;
background: transparent;
color: #1e293b;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
text-align: left;
cursor: pointer;
outline: none;
}
.proto-tool__icon {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
background: #ffffff;
border: 1px solid #e2e8f0;
flex: 0 0 24px;
transition: all 0.2s;
}
.proto-tool:hover .proto-tool__icon {
border-color: #cbd5e1;
color: #334155;
}
.proto-tool__summary {
flex: 1;
min-width: 0;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-tool__badge {
font-style: normal;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
border-radius: 6px;
padding: 2px 8px;
line-height: normal;
}
/* 根据状态调整 badge */
.proto-tool--called .proto-tool__badge {
background: #dbeafe;
color: #1e40af;
}
.proto-tool--create .proto-tool__badge {
background: #d1fae5;
color: #065f46;
}
.proto-tool--blocked .proto-tool__badge {
background: #ffe4e6;
color: #9f1239;
}
.proto-tool__chevron {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: #94a3b8;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.proto-tool__chevron--expanded {
transform: rotate(90deg);
}
.proto-tool__detail {
margin: 0;
padding: 0 16px 12px 46px;
color: #475569;
font-size: 13px;
line-height: 1.6;
border-top: 1px solid transparent;
animation: detail-slide-down 0.2s ease-out;
}
@keyframes detail-slide-down {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes line-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
.tool-compare-proto__grid {
grid-template-columns: 1fr;
}
}
</style>