271 lines
10 KiB
Markdown
271 lines
10 KiB
Markdown
# 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 推送;用户在本地合并 / 推送。 |