Files
agent-frontend-web/docs/superpowers/specs/2026-08-08-frontend-dark-theme-sidebar-search-chat-design.md
T

10 KiB
Raw Blame History

Frontend 深色主题 + 会话搜索/分组 + 聊天模块收紧 — Design Spec

日期: 2026-08-08 状态: Approved 范围: UI 调整(仅本地代码,不做 git 推送)

背景

根目录提供两张参考图:

  • image.png — frontend 整体布局:深色主题 + 侧栏"会话列表"标题 + 下方按时间分组的会话项 + 右侧聊天主区;
  • chat.png — 聊天区细节:用户消息右侧气泡、青色主题色、深色背景、紧凑留白。

当前 src/views/frontend/ 已具备 theme.scssConversationSidebar.vueChatWindow.vue 的完整骨架,但:

  1. 深色 --fe-* 变量停留在首版调色(VS Code 暗色 + 青色 accent),与 image.png 期望的"冷感石墨黑 + 蓝紫 accent"仍有差距;
  2. 会话侧栏只有一维列表,没有"今天 / 昨天 / 更早"分组,也没有搜索入口;
  3. 聊天模块的内边距、圆角与 chat.png 的紧凑节奏仍有可收敛空间。

目标 / 非目标

目标

  • :root--fe-* 替换为石墨黑底 + 蓝紫 accent;
  • 会话侧栏顶部新增"搜索会话"输入框(仅样式);
  • 会话按 今天 / 昨天 / 更早 分组,分组支持展开/折叠,且"当前会话所在分组自动展开,其他折叠";
  • 聊天模块气泡、消息组、滚动按钮的圆角与内边距收紧,与 chat.png 节奏对齐。

非目标

  • 不修改浅色主题(body[data-fe-theme="light"].frontend-container[data-theme="light"]);
  • 不修改 WebSocket、流式消息、自动下拉、模型选择、上下文 popover、UserArea、右键菜单、其它视图;
  • 不引入新接口;搜索框不绑定行为;分组折叠状态不持久化。

改动清单

1. src/views/frontend/styles/theme.scss

替换 :root 块下所有 --fe-* 变量值,保持变量名不变。body[data-fe-theme="light"].frontend-container { --el-* }.frontend-container[data-theme="light"] 三个块完全不动。.frontend-el-message-box 与全局 .context-menu 由于使用 --fe-* 变量,自动跟随新调色板,不需要修改。

:root {
  // —— 基础色 ——
  --fe-bg-base: #14171c;          // 容器底色(石墨黑)
  --fe-bg-elevated: #1c1f24;      // 卡片 / hover
  --fe-bg-overlay: #23272e;       // 弹层 / 高亮
  --fe-bg-input: #2a2e35;         // 输入区背景
  --fe-bg-hover: #1f2329;         // 行 hover

  // —— 边框 ——
  --fe-border: #2c3138;
  --fe-border-strong: #4a505a;

  // —— 文本 ——
  --fe-text-primary: #e4e6eb;
  --fe-text-secondary: #9aa0a6;
  --fe-text-muted: #6b7280;
  --fe-text-inverse: #14171c;

  // —— 品牌色 ——
  --fe-accent: #6c8cff;            // 蓝紫 accent
  --fe-accent-hover: #8aa3ff;
  --fe-accent-soft: rgba(108, 140, 255, 0.15);
  --fe-danger: #f07178;
  --fe-danger-soft: rgba(240, 113, 120, 0.1);
  --fe-warning: #e0a050;
  --fe-info: #6cc4d6;
  --fe-info-soft: rgba(108, 196, 214, 0.1);
  --fe-info-border: rgba(108, 196, 214, 0.4);
  --fe-accent-border: rgba(108, 140, 255, 0.4);
  --fe-danger-border: rgba(240, 113, 120, 0.4);

  // —— 布局尺寸 ——
  --fe-sidebar-width: 260px;
  --fe-file-panel-width: 280px;
  --fe-file-preview-width: 600px;

  // —— 阴影 / 圆角 ——
  --fe-radius-sm: 4px;
  --fe-radius-md: 6px;
  --fe-radius-lg: 12px;
  --fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
  --fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.55);
  --fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.65);
}

2. src/views/frontend/components/ConversationSidebar.vue

模板变更

  • sidebar-header 之后新增 <div class="sidebar-search">,内含 <el-input v-model="searchKeyword" placeholder="搜索会话">,前缀图标 Search
  • <el-scrollbar> 内部从 v-for="session in sessionList" 改为 v-for="group in sessionGroups" :key="group.key"
    • 每个 group.items.length === 0 不渲染;
    • <div class="group-header" @click="toggleGroup(group.key)"> 包含 <span class="group-title">{{ group.label }}</span><el-icon class="group-toggle"><ArrowDown v-if="expandedGroups.has(group.key)" /><ArrowRight v-else /></el-icon>
    • <div v-show="expandedGroups.has(group.key)" class="group-items"> 渲染 v-for="session in group.items":复用现有 .conversation-item 结构。

脚本新增

import { Search, ArrowDown, ArrowRight } from '@element-plus/icons-vue'

const searchKeyword = ref('')           // 仅留口子,本次不绑定行为
const expandedGroups = ref(new Set())   // 'today' | 'yesterday' | 'earlier'

function groupOf(session) {
  const ts = parseTimestamp(session?.updateTime)
  if (ts == null) return 'earlier'
  const now = new Date()
  const d = new Date(ts)
  const sameDay = (a, b) =>
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
  if (sameDay(d, now)) return 'today'
  if (sameDay(d, yesterday)) return 'yesterday'
  return 'earlier'
}

function parseTimestamp(t) {
  if (!t) return null
  if (typeof t === 'number' && String(t).length === 13) return t
  if (typeof t === 'number' || /^\d{14}$/.test(t)) {
    const s = String(t)
    return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}`).getTime()
  }
  if (typeof t === 'string' && t.includes(' ')) {
    const iso = t.replace(' ', 'T').replace(/\.(\d+)?$/, '')
    const ms = new Date(iso).getTime()
    return Number.isNaN(ms) ? null : ms
  }
  const ms = new Date(t).getTime()
  return Number.isNaN(ms) ? null : ms
}

const sessionGroups = computed(() => {
  const groups = { today: [], yesterday: [], earlier: [] }
  for (const s of sessionList.value) {
    groups[groupOf(s)].push(s)
  }
  return [
    { key: 'today', label: '今天', items: groups.today },
    { key: 'yesterday', label: '昨天', items: groups.yesterday },
    { key: 'earlier', label: '更早', items: groups.earlier }
  ]
})

function toggleGroup(key) {
  const next = new Set(expandedGroups.value)
  if (next.has(key)) next.delete(key); else next.add(key)
  expandedGroups.value = next
}

function syncExpandedWithCurrent() {
  if (currentSession.value) {
    expandedGroups.value = new Set([groupOf(currentSession.value)])
  } else {
    expandedGroups.value = new Set(['today'])
  }
}

调用点:

  • fetchSessions finally 中调用 syncExpandedWithCurrent()(含首屏无会话、列表为空、列表非空三种情况);
  • selectSession(session) 末尾追加 syncExpandedWithCurrent()
  • handleDeleteSession 移除条目后调用 syncExpandedWithCurrent()

样式新增(仅作用域内)

.sidebar-search {
  padding: 8px 12px 0;
  :deep(.el-input__wrapper) {
    background: var(--fe-bg-elevated);
    box-shadow: 0 0 0 1px var(--fe-border) inset;
    border-radius: var(--fe-radius-md);
  }
  :deep(.el-input__inner) {
    color: var(--fe-text-primary);
    &::placeholder { color: var(--fe-text-muted); }
  }
  :deep(.el-input__prefix .el-icon) { color: var(--fe-text-muted); }
}

.group-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px 6px;
  cursor: pointer;
  user-select: none;
  .group-title {
    font-size: 12px;
    font-weight: 600;
    color: var(--fe-accent);
    letter-spacing: 0.5px;
  }
  .group-toggle {
    color: var(--fe-text-secondary);
    font-size: 12px;
  }
  &:hover .group-toggle { color: var(--fe-text-primary); }
}

.group-items { padding: 0 8px; }

保留现有 .conversation-item 圆角 8px → 改为 var(--fe-radius-md),与 --fe-radius-md 同步到 6px。

3. src/views/frontend/components/ChatWindow.vue

仅改 <style scoped lang="scss">

.chat-window {
  // padding: 16px 20px;
  padding: 12px 16px;
}

.message-group {
  // margin-bottom: 12px;
  margin-bottom: 10px;
}

.message-bubble {
  // padding: 10px 14px;
  padding: 9px 13px;
  // border-radius: 12px;
  border-radius: 10px;
}

模板、<script setup>watchonMountedprocess-panelscroll-to-bottom 全部保持原样;.scroll-to-bottom 的颜色/阴影通过 --fe-bg-elevated / --fe-shadow-md 自动跟随新调色。

数据流

  1. ConversationSidebar.fetchSessions()sessionList 更新;
  2. sessionGroupscomputed)按 updateTime 派生今天 / 昨天 / 更早;
  3. syncExpandedWithCurrent() 决定 expandedGroups
  4. 模板渲染:分组标题 + 当前展开分组内的会话项。

聊天模块无新增数据流,仅样式收紧。

异常处理

  • updateTime 无法解析 → parseTimestamp 返回 nullgroupOf 落回 'earlier';控制台 console.warn('无法解析会话时间', session) 一次;
  • 折叠 Set 为空 → syncExpandedWithCurrent 总能产出至少一个 key'today' 或当前会话所在分组);
  • 搜索框无异常路径:用户输入不会改变列表。

验证

步骤 期望
1 启动 dev,深色主题下 frontend 容器底色变 #14171caccent 高亮变 #6c8cff
2 切到浅色主题:颜色与之前完全一致(#ffffff / #1890ff
3 上下文 popover / ChatHeader 模型选择 / ElMessageBox 弹窗主题一致(新色系跟随)
4 列表加载完成后默认 今天 展开、昨天 / 更早折叠
5 点击任一会话 → 仅其所在分组展开
6 点击分组标题 → 切换展开 / 折叠
7 搜索框:聚焦高亮、placeholder "搜索会话"、输入无任何列表变化
8 聊天区:用户 / 助手气泡圆角 10px、内边距 9/13px;消息间距 10px;与 chat.png 节奏一致
9 发消息后流式追加仍自动滚到底;不回归
10 删会话后折叠状态保持 今天(或当前会话所在分组)

风险

  • 颜色变量变更可能与前端测试中硬编码颜色(如 ChatWindow.vue 内的 color="#67C23A" 等 icon 颜色)冲突;本次只动 --fe-* 全局变量,硬编码 icon 颜色保留原值;
  • parseTimestamp 仅识别现有 3 种格式(毫秒 / 14 位 / 空格分隔),与 ChatWindow.formatTime 保持一致;其它格式落回 "更早" 不报错;
  • 不做 git 推送;用户在本地合并 / 推送。