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

271 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend 深色主题 + 会话搜索/分组 + 聊天模块收紧 — Design Spec
**日期:** 2026-08-08
**状态:** Approved
**范围:** UI 调整(仅本地代码,不做 git 推送)
## 背景
根目录提供两张参考图:
- `image.png` — frontend 整体布局:深色主题 + 侧栏"会话列表"标题 + 下方按时间分组的会话项 + 右侧聊天主区;
- `chat.png` — 聊天区细节:用户消息右侧气泡、青色主题色、深色背景、紧凑留白。
当前 `src/views/frontend/` 已具备 `theme.scss``ConversationSidebar.vue``ChatWindow.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-*` 变量,自动跟随新调色板,不需要修改。
```scss
: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` 结构。
#### 脚本新增
```js
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()`
#### 样式新增(仅作用域内)
```scss
.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">`
```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>``watch``onMounted``process-panel``scroll-to-bottom` 全部保持原样;`.scroll-to-bottom` 的颜色/阴影通过 `--fe-bg-elevated` / `--fe-shadow-md` 自动跟随新调色。
## 数据流
1. `ConversationSidebar.fetchSessions()``sessionList` 更新;
2. `sessionGroups`computed)按 `updateTime` 派生今天 / 昨天 / 更早;
3. `syncExpandedWithCurrent()` 决定 `expandedGroups`
4. 模板渲染:分组标题 + 当前展开分组内的会话项。
聊天模块无新增数据流,仅样式收紧。
## 异常处理
- `updateTime` 无法解析 → `parseTimestamp` 返回 `null``groupOf` 落回 `'earlier'`;控制台 `console.warn('无法解析会话时间', session)` 一次;
- 折叠 Set 为空 → `syncExpandedWithCurrent` 总能产出至少一个 key`'today'` 或当前会话所在分组);
- 搜索框无异常路径:用户输入不会改变列表。
## 验证
| 步骤 | 期望 |
|---|---|
| 1 | 启动 dev,深色主题下 frontend 容器底色变 `#14171c`accent 高亮变 `#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 推送;用户在本地合并 / 推送。