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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
219
docs/功能决策记录/Agent流式协议_前后端对齐_决策记录.md
Normal file
219
docs/功能决策记录/Agent流式协议_前后端对齐_决策记录.md
Normal 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. 复盘结论(上线后补充)
|
||||
- 实际效果:待补充
|
||||
- 与预期偏差:待补充
|
||||
- 后续是否需要二次决策:待补充
|
||||
@@ -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>
|
||||
|
||||
167
frontend/src/components/common/MainSidebar.vue
Normal file
167
frontend/src/components/common/MainSidebar.vue
Normal 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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
624
frontend/src/views/ToolTracePrototypeView.vue
Normal file
624
frontend/src/views/ToolTracePrototypeView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user