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

503 lines
15 KiB
Markdown
Raw 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 深色主题 + 会话搜索/分组 + 聊天收紧 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 frontend 页面调整为:石墨黑 + 蓝紫 accent 的深色主题、会话侧栏顶部"搜索会话"输入框、按 `今天 / 昨天 / 更早` 分组且"当前会话所在分组自动展开、其他折叠"、聊天模块气泡节奏收紧对齐 chat.png。本次仅修改本地代码,不做 git 推送。
**Architecture:** 三个文件局部改动,不新增组件、不引入新依赖。`theme.scss` 替换 `:root` 变量值;`ConversationSidebar.vue` 在 header 与 scrollbar 之间插入搜索框,scrollbar 内部从一维列表改为分组结构,添加 `groupOf / parseTimestamp / sessionGroups / expandedGroups / syncExpandedWithCurrent` 等本地逻辑;`ChatWindow.vue` 仅调整气泡相关 SCSS。所有改动通过 `--fe-*` 变量串联,浅色主题不受影响。
**Tech Stack:** Vue 3.5 + Composition API + Element Plus 2.13 + Sasssass-embedded 1.97)。测试使用 Vitest(需在 Task 1 中确认是否已配置)。
---
## File Structure
| 文件 | 操作 | 职责 |
|---|---|---|
| `src/views/frontend/styles/theme.scss` | 改 | 仅替换 `:root``--fe-*` 值;其它块保持原样 |
| `src/views/frontend/components/ConversationSidebar.vue` | 改 | header 下新增搜索框;scrollbar 内分组渲染;新增本地状态/方法;新增分组标题样式 |
| `src/views/frontend/components/ChatWindow.vue` | 改 | 仅 `<style scoped>` 内调整 `padding / margin-bottom / border-radius` |
无新增文件;不新增组件抽象。
---
## Task 1: 摸底测试栈与现有测试位置
**Files:**
- Inspect: `package.json`, `vitest.config.*`, `tests/``src/**/*.test.{js,ts}`
- [ ] **Step 1: 检查 package.json 是否声明了测试框架**
```bash
cd "D:/数科智联/项目/agent-frontend-web"
grep -E "vitest|jest|@vue/test-utils" package.json
```
预期:很可能没有测试框架;如果 `npm ls vitest` 无输出,跳过 vitest 步骤(前端为视觉改动,以手动 dev 验证为主)。记录结论到 Task 1 末尾的"测试结论"。
- [ ] **Step 2: 若无测试框架,标注结论并跳过所有 TDD 步骤**
在执行 Task 2~4 时,把"Write the failing test / Run it to verify it fails"两步替换为"手动 dev 验证(启动 `npm run dev` 在浏览器查看)"。
测试结论:当前仓库未配置前端单元测试;本次采用手动 dev 验证(步骤在 Task 5 统一列出)。
---
## Task 2: 替换 `theme.scss` 深色调色板
**Files:**
- Modify: `src/views/frontend/styles/theme.scss:3-46``:root` 块)
- [ ] **Step 1: 替换 `:root` 变量值**
`src/views/frontend/styles/theme.scss``:root { ... }` 块整体替换为以下内容(变量名不变,仅改值):
```scss
:root {
// —— 基础色 ——
--fe-bg-base: #14171c;
--fe-bg-elevated: #1c1f24;
--fe-bg-overlay: #23272e;
--fe-bg-input: #2a2e35;
--fe-bg-hover: #1f2329;
// —— 边框 ——
--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;
--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);
}
```
- [ ] **Step 2: 校验未触碰其它块**
```bash
cd "D:/数科智联/项目/agent-frontend-web"
sed -n '47,90p' src/views/frontend/styles/theme.scss
```
预期:`body[data-fe-theme="light"]` 块保持 `body[data-fe-theme="light"] { --fe-bg-base: #ffffff; ... }` 原样输出。
- [ ] **Step 3: 校验 `.frontend-container` 与浅色 el-* 块未改**
```bash
cd "D:/数科智联/项目/agent-frontend-web"
grep -n "frontend-container" src/views/frontend/styles/theme.scss
```
预期:仍有两处 `.frontend-container`(不含 light 子选择器与 light 修饰),行号与改前一致。
- [ ] **Step 4: 启动 dev,目测深色页面底色与 accent 是否变为石墨黑/蓝紫**
```bash
cd "D:/数科智联/项目/agent-frontend-web"
npm run dev
```
打开 `/frontend` 路由,深色主题下容器底色变 `#14171c`、accent 高亮变 `#6c8cff`;切到浅色主题,颜色与之前完全一致。
---
## Task 3: `ConversationSidebar.vue` —— 模板新增搜索框 + 分组渲染
**Files:**
- Modify: `src/views/frontend/components/ConversationSidebar.vue:10-59`(模板)、`:372-`.conversation-item 圆角)
- [ ] **Step 1: 在 `sidebar-header` 之后插入搜索框**
`<div class="sidebar-header">...</div>` 之后、`<el-scrollbar class="sidebar-scrollbar">` 之前,插入:
```html
<div class="sidebar-search">
<el-input
v-model="searchKeyword"
placeholder="搜索会话"
:prefix-icon="Search"
clearable
/>
</div>
```
- [ ] **Step 2: 把 scrollbar 内的列表改成分组渲染**
`<div v-for="session in sessionList" ...>` 整段替换为:
```html
<template v-for="group in sessionGroups" :key="group.key">
<template v-if="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>
<div v-show="expandedGroups.has(group.key)" class="group-items">
<div
v-for="session in group.items"
:key="session.sessionId"
class="conversation-item"
:class="{ active: currentSession?.sessionId === session.sessionId, 'has-new-message': session.hasNewMessage }"
@click="selectSession(session)"
@contextmenu.prevent="handleRightClick($event, session)"
>
<div class="conv-dot"></div>
<div class="conv-info">
<span class="conv-name">{{ session.title || session.name }}</span>
<span class="conv-time">{{ formatTimeAgo(session.updateTime) }}</span>
</div>
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
</div>
</div>
</template>
</template>
```
空列表提示 `<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>` 保持不变。
- [ ] **Step 3: 把 `.conversation-item` 的固定 8px 圆角改为变量**
`<style scoped lang="scss">` 中找到:
```scss
.conversation-item {
...
border-radius: 8px;
...
}
```
`border-radius: 8px;` 改为 `border-radius: var(--fe-radius-md);`
- [ ] **Step 4: 在 `<style scoped lang="scss">` 内追加搜索框与分组样式**
紧跟在 `.sidebar-header { ... }` 块之后追加:
```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; }
```
- [ ] **Step 5: dev 验证(任务3**
启动 dev,进入 `/frontend`,目测:
- header 下方出现搜索框;
- 列表按 `今天 / 昨天 / 更早` 三段渲染;
- 分组标题为蓝紫色,左侧 ▾/▸ 图标。
---
## Task 4: `ConversationSidebar.vue` —— 脚本新增分组逻辑
**Files:**
- Modify: `src/views/frontend/components/ConversationSidebar.vue:68-262`script setup
- [ ] **Step 1: 扩展 icon 导入**
将:
```js
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading } from '@element-plus/icons-vue'
```
改为:
```js
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading, Search, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
```
- [ ] **Step 2: 扩展 import,引入 computed**
`import { ref, onMounted, onUnmounted } from 'vue'` 改为 `import { ref, computed, onMounted, onUnmounted } from 'vue'`
- [ ] **Step 3: 新增 state 与 helper,放在 `currentSession` 之后**
`const currentSession = ref(null)` 之后插入:
```js
// —— 搜索关键字(仅样式占位,本次不绑定行为) ——
const searchKeyword = ref('')
// —— 分组折叠状态:'today' | 'yesterday' | 'earlier' ——
const expandedGroups = ref(new Set())
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
}
function groupOf(session) {
const ts = parseTimestamp(session?.updateTime)
if (ts == null) {
console.warn('[ConversationSidebar] 无法解析会话时间', session)
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'
}
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'])
}
}
```
- [ ] **Step 4: 在 `selectSession` 末尾追加 `syncExpandedWithCurrent()`**
将:
```js
function selectSession(session) {
currentSession.value = session
emit('select', session)
}
```
改为:
```js
function selectSession(session) {
currentSession.value = session
emit('select', session)
syncExpandedWithCurrent()
}
```
- [ ] **Step 5: 在 `fetchSessions` 的 `finally` 中调用 `syncExpandedWithCurrent()`**
`fetchSessions` 函数体中 `finally` 块改为:
```js
} finally {
// 强制最小 loading 时长,避免接口返回过快导致 icon 一闪而过
const elapsed = Date.now() - start
const remain = 300 - elapsed
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
loading.value = false
syncExpandedWithCurrent()
emit('ready')
}
```
- [ ] **Step 6: 在 `handleDeleteSession` 删除条目后追加 `syncExpandedWithCurrent()`**
找到:
```js
if (currentSession.value?.sessionId === session.sessionId) {
currentSession.value = null
emit('select', null)
}
```
在它之后追加:
```js
syncExpandedWithCurrent()
```
- [ ] **Step 7: dev 验证(任务4**
启动 dev,进入 `/frontend`
- 列表加载完成后默认 `今天` 展开、昨天 / 更早折叠;
- 点击任一会话 → 仅其所在分组展开;
- 点击分组标题 → 切换展开 / 折叠。
---
## Task 5: `ChatWindow.vue` —— 气泡节奏收紧
**Files:**
- Modify: `src/views/frontend/components/ChatWindow.vue``<style scoped lang="scss">` 段)
- [ ] **Step 1: 收紧容器与消息组**
`<style scoped lang="scss">` 中定位 `.chat-window``.message-group`,将:
```scss
.chat-window {
...
padding: 16px 20px;
...
}
.message-group {
margin-bottom: 12px;
...
}
```
分别改为:
```scss
.chat-window {
...
padding: 12px 16px;
...
}
.message-group {
margin-bottom: 10px;
...
}
```
- [ ] **Step 2: 收紧气泡**
定位 `.message-bubble`user / assistant 共享样式),将 `padding: 10px 14px;` 改为 `padding: 9px 13px;`;将 `border-radius: 12px;` 改为 `border-radius: 10px;`
- [ ] **Step 3: dev 验证(任务5**
启动 dev,进入 `/frontend`,选择一个会话发消息,目测:
- 用户 / 助手气泡圆角 10px
- 气泡内边距 9 / 13px
- 消息组间距 10px
- 整体节奏与 `chat.png` 一致;
- 发送消息后流式追加仍自动滚到底。
---
## Task 6: 端到端冒烟清单(合并验证)
**Files:** 无新增;仅执行 dev 自检。
- [ ] **Step 1: 深色主题**
进入 `/frontend` 默认深色:
- 容器底色 `#14171c`、卡片背景 `#1c1f24`
- ChatHeader、ConversationSidebar、ChatWindow、InputArea 颜色一致;
- accent 高亮 `#6c8cff`(搜索框聚焦边框、分组标题、新建会话图标等)。
- [ ] **Step 2: 浅色主题未变**
切到浅色主题:底色 `#ffffff`、accent `#1890ff`;与改动前完全一致。
- [ ] **Step 3: 上下文 popover / 模型选择 / ElMessageBox**
点击 ChatHeader 切换模型、点击 InputArea 右下角上下文指示器、删除会话触发确认弹窗:新深色系跟随;浅色下保持原样。
- [ ] **Step 4: 侧栏分组 + 搜索框**
- 列表加载默认 `今天` 展开;
- 点击分组标题切换展开 / 折叠;
- 选中任一会话 → 仅其所在分组展开;
- 删除当前会话后折叠状态回退到 `今天`(或当前会话所在分组);
- 搜索框聚焦高亮、placeholder "搜索会话"、输入不改变列表;
- 右键菜单仍可重命名 / 删除会话。
- [ ] **Step 5: 聊天模块**
- 用户 / 助手气泡圆角 10px、内边距 9/13px
- 消息组间距 10px
- 流式追加自动滚到底;
- 上拉后向下箭头按钮仍可点击回到底部;
- 处理详情折叠面板(thinking / tool)展开 / 折叠正常。
- [ ] **Step 6: 不做 git 推送**
本次仅修改本地文件,不运行 `git push` / `git commit`(如果用户希望记录变更,可手动提交)。