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

15 KiB
Raw Permalink Blame History

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 是否声明了测试框架

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 { ... } 块整体替换为以下内容(变量名不变,仅改值):

: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: 校验未触碰其它块
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- 块未改*
cd "D:/数科智联/项目/agent-frontend-web"
grep -n "frontend-container" src/views/frontend/styles/theme.scss

预期:仍有两处 .frontend-container(不含 light 子选择器与 light 修饰),行号与改前一致。

  • Step 4: 启动 dev,目测深色页面底色与 accent 是否变为石墨黑/蓝紫
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"> 之前,插入:

<div class="sidebar-search">
  <el-input
    v-model="searchKeyword"
    placeholder="搜索会话"
    :prefix-icon="Search"
    clearable
  />
</div>
  • Step 2: 把 scrollbar 内的列表改成分组渲染

<div v-for="session in sessionList" ...> 整段替换为:

<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"> 中找到:

.conversation-item {
  ...
  border-radius: 8px;
  ...
}

border-radius: 8px; 改为 border-radius: var(--fe-radius-md);

  • Step 4: 在 <style scoped lang="scss"> 内追加搜索框与分组样式

紧跟在 .sidebar-header { ... } 块之后追加:

.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-262script setup

  • Step 1: 扩展 icon 导入

将:

import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading } from '@element-plus/icons-vue'

改为:

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) 之后插入:

// —— 搜索关键字(仅样式占位,本次不绑定行为) ——
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()

将:

function selectSession(session) {
  currentSession.value = session
  emit('select', session)
}

改为:

function selectSession(session) {
  currentSession.value = session
  emit('select', session)
  syncExpandedWithCurrent()
}
  • Step 5: 在 fetchSessionsfinally 中调用 syncExpandedWithCurrent()

fetchSessions 函数体中 finally 块改为:

} 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()

找到:

if (currentSession.value?.sessionId === session.sessionId) {
  currentSession.value = null
  emit('select', null)
}

在它之后追加:

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,将:

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

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

分别改为:

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

.message-group {
  margin-bottom: 10px;
  ...
}
  • Step 2: 收紧气泡

定位 .message-bubbleuser / 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(如果用户希望记录变更,可手动提交)。