docs(superpowers): 补录前端美化与交互改造的设计规范与实施计划
- docs/superpowers/specs/2026-08-30-frontend-beautify-design.md 前端美化与交互改造设计(深色指挥中心 + 文档工作台混合风格, 单一青绿 #5EEAD4 强调色,chat.html 拆为 chat.html + chat.css + chat.js, 8 项交互改善,用户决策 10 项) - docs/superpowers/plans/2026-08-30-frontend-beautify.md 前端美化与交互改造 Implementation Plan(三步走:机械拆分 → 视觉重塑 → 交互改造,Task 独立 commit,浏览器手测验证) 按项目文档规则存入 docs/superpowers/ 目录。
This commit is contained in:
@@ -0,0 +1,1779 @@
|
||||
# 前端美化与交互改造 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:** 将 `src/genesis/server/static/chat.html`(单文件 1215 行内联 CSS+JS)拆分为 `chat.html` + `chat.css` + `chat.js`,套用「深色指挥中心 + 文档工作台」混合风格(单一青绿 `#5EEAD4` 强调色 + 系统字体栈),并实施 8 项交互改善(分组 / 拖拽 / 气泡操作 / 多行输入 / 顶栏徽章 / 快捷键 Esc+Cmd+K / 骨架屏 / 空状态快捷指令 / 错误分级)。
|
||||
|
||||
**Architecture:** 机械拆分 → 视觉重塑 → 交互改造三步走。chat_state.js / chat_ws.js 完全不动(保持 `window.GenesisState` / `window.GenesisWS` 协议不变),chat.js 通过 fallback 默认值兜底。所有 DOM id 保留,所有 API 端点不变,所有 localStorage 键不变。**无后端变更,无测试框架(pytest)可覆盖的逻辑**,故 Task 验证手段为"浏览器手动 + 浏览器自动化烟雾测试"。
|
||||
|
||||
**Tech Stack:** 纯前端(HTML / CSS / JavaScript ES2017+)。无新增依赖。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 中文优先:所有 UI 文案、注释、提交信息使用中文(专有名词 / 标识符 / 代码关键字除外)。
|
||||
- 不新增依赖:不引入 Google Fonts / JetBrains Mono;纯系统字体栈。
|
||||
- 单主题:本轮不做 dark/light 切换。
|
||||
- 兼容性保证:所有 DOM id 必须保留(见 spec §三-1);所有 localStorage / sessionStorage 键必须保留(见 spec §三-3);所有 `/api/...` 调用方式不变。
|
||||
- 移动端:< 768px 断点的改造不在本轮范围(spec §零-8)。
|
||||
- 不实现"重新生成"按钮(spec §零-9)。
|
||||
- 提交粒度:每个 Task 独立 commit,便于回滚。
|
||||
- 颜色十六进制值必须与 spec §一完全一致(禁止调整)。
|
||||
- 后端 Python 代码不在本 plan 修改范围;仅前端三个文件(chat.html / chat.css / chat.js)+ docs。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(本 plan 涉及)
|
||||
|
||||
```
|
||||
src/genesis/server/static/
|
||||
├── chat.html # Modify:删 <style>/<script> 内联,引用外部资源
|
||||
├── chat.css # Create:约 350-450 行,含 :root 令牌 + 六大区域
|
||||
├── chat.js # Create:约 800-900 行,含 8 项交互 + 原业务逻辑
|
||||
├── chat_state.js # 不动
|
||||
└── chat_ws.js # 不动
|
||||
|
||||
docs/
|
||||
├── design.md # Modify:追加"前端 UI 设计规范"一节
|
||||
├── superpowers/specs/2026-08-30-frontend-beautify-design.md # 已有,本文参照
|
||||
└── _AI_USAGE_LOG.md # Modify:追加 3 条记录
|
||||
|
||||
tests/frontend/ # Create:浏览器自动化烟雾测试(可选;本 plan 标记为"可省略",浏览器手测为主)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 拆分 chat.html + 创建 chat.css 骨架 + chat.js 骨架
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.html:1-1215`
|
||||
- Create: `src/genesis/server/static/chat.css`
|
||||
- Create: `src/genesis/server/static/chat.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 原 chat.html 全部 DOM 节点
|
||||
- Produces:
|
||||
- `chat.html` 仅含 DOM 结构 + `<link href="/chat.css">` + `<script src="/chat.js" defer>`
|
||||
- `chat.css` 含 `:root` 全部 CSS 变量(spec §一)
|
||||
- `chat.js` 含原内联 `<script>` 段全部业务逻辑(机械迁移,零行为变更)
|
||||
|
||||
**注**: 本 Task 只做"机械拆分"——CSS 暂写一份"最小还原"(保留原 Material/Google 风视觉),保证拆分后页面行为与拆分前完全一致;视觉重塑留到 Task 2。
|
||||
|
||||
- [ ] **Step 1: 创建 chat.css(最小还原版,仅令牌 + 原样样式)**
|
||||
|
||||
```css
|
||||
/* src/genesis/server/static/chat.css
|
||||
v0 拆分骨架 - 视觉与原 chat.html 内联样式完全一致
|
||||
v1 视觉重塑见 Task 2
|
||||
*/
|
||||
:root {
|
||||
/* === 颜色 (v0 还原; v1 在 Task 2 替换为深色令牌) === */
|
||||
--blue: #1a73e8;
|
||||
--blue-dark: #1967d2;
|
||||
--blue-soft: #d2e3fc;
|
||||
--red: #ea4335;
|
||||
--red-soft: #fce8e6;
|
||||
--yellow: #fbbc05;
|
||||
--green: #34a853;
|
||||
--gray-50: #f8f9fa;
|
||||
--gray-100: #f1f3f4;
|
||||
--gray-200: #e8eaed;
|
||||
--gray-300: #dadce0;
|
||||
--gray-500: #5f6368;
|
||||
--gray-700: #3c4043;
|
||||
--gray-900: #202124;
|
||||
--blue-50: #e8f0fe;
|
||||
--blue-100: #d2e3fc;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-1: 0 1px 2px rgba(60,64,67,.30), 0 1px 3px 1px rgba(60,64,67,.15);
|
||||
--shadow-2: 0 1px 2px rgba(60,64,67,.30), 0 2px 6px 2px rgba(60,64,67,.15);
|
||||
--shadow-3: 0 1px 3px rgba(60,64,67,.30), 0 4px 8px 3px rgba(60,64,67,.15);
|
||||
--glow-blue: 0 0 0 1px rgba(26,115,232,.12), 0 8px 24px rgba(26,115,232,.18);
|
||||
--ease: all .2s cubic-bezier(.2,0,0,1);
|
||||
|
||||
/* === 字体 (v0 保留 Inter via Google Fonts; v1 替换为系统字体栈) === */
|
||||
--font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
/* 完整还原原 chat.html:36-391 的全部样式(原样复制,仅变量化) */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
margin: 0; height: 100vh; display: flex; flex-direction: row;
|
||||
background: var(--gray-50); color: var(--gray-900);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--gray-300); border-radius: 8px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--gray-500); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
#sidebar { width: 264px; background: #fff; color: var(--gray-900); display: flex; flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--gray-200); transition: width .25s ease; position: relative; }
|
||||
#sidebar.collapsed { width: 56px; }
|
||||
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
|
||||
#sidebar.collapsed #session-list { padding-bottom: 52px; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 16px 12px 12px 16px; margin: 0 8px 8px; border-bottom: 1px solid var(--gray-100); min-height: 64px; }
|
||||
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
|
||||
.brand-logo { width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 17px; font-weight: 900; background: linear-gradient(135deg, #1a73e8, #4285f4); box-shadow: var(--glow-blue); transition: var(--ease); }
|
||||
.brand:hover .brand-logo { transform: scale(1.1); }
|
||||
.brand-name { font-size: 17px; font-weight: 900; letter-spacing: -.02em; line-height: 1.1; }
|
||||
#sidebar.collapsed .brand-name { display: none; }
|
||||
.brand-sub { display: block; font-size: 11px; font-weight: 600; color: var(--gray-500); margin-top: 2px; letter-spacing: 0; }
|
||||
#sidebar-toggle { position: absolute; top: 18px; right: -14px; z-index: 5; width: 28px; height: 28px; border-radius: 50%; background: #fff; border: 1px solid var(--gray-200); box-shadow: var(--shadow-1); color: var(--gray-700); cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
|
||||
#sidebar-toggle:hover { color: var(--blue); border-color: var(--blue-100); }
|
||||
#sidebar.collapsed #sidebar-toggle { transform: rotate(180deg); }
|
||||
#new-chat { margin: 6px 16px 10px; background: var(--blue); color: #fff; border: none; padding: 10px 14px; border-radius: var(--radius-lg); cursor: pointer; font-size: 14px; font-weight: 600; letter-spacing: .01em; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
#new-chat:hover { background: var(--blue-dark); box-shadow: 0 2px 6px rgba(26,115,232,.45); transform: translateY(-1px); }
|
||||
#new-chat:active { transform: translateY(0); }
|
||||
#sidebar.collapsed #new-chat { margin: 6px 10px 10px; padding: 10px 0; }
|
||||
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
|
||||
#sidebar.collapsed #new-chat .nc-text { display: none; }
|
||||
#sidebar h2 { font-size: 9px; font-weight: 900; letter-spacing: .2em; text-transform: uppercase; color: var(--gray-500); margin: 10px 16px 6px; }
|
||||
#sidebar.collapsed h2 { display: none; }
|
||||
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
|
||||
.sess { padding: 9px 12px; border-radius: var(--radius-lg); margin: 2px 0; cursor: pointer; position: relative; border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 6px; }
|
||||
.sess:hover { background: var(--gray-50); }
|
||||
.sess.active { background: var(--blue-50); border-color: var(--blue-100); }
|
||||
.sess.active::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--blue); }
|
||||
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.sess .name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sess.active .name { font-weight: 700; }
|
||||
.sess .meta { font-size: 11px; color: var(--gray-500); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sess .del { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent; border: none; color: var(--gray-500); cursor: pointer; width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
|
||||
.sess:hover .del { opacity: .7; }
|
||||
.sess .del:hover { opacity: 1 !important; background: var(--red-soft); color: var(--red); }
|
||||
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
|
||||
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
|
||||
#sidebar.collapsed .sess .avatar { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--gray-100); color: var(--gray-700); font-size: 12px; font-weight: 700; }
|
||||
.sess .avatar { display: none; }
|
||||
#sidebar.collapsed .sess .avatar { display: flex; }
|
||||
#sidebar.collapsed .sess.active .avatar { background: var(--blue); color: #fff; }
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--gray-50); }
|
||||
header { height: 56px; position: sticky; top: 0; z-index: 10; padding: 0 24px; display: flex; align-items: center; gap: 14px; background: rgba(255,255,255,.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--gray-200); }
|
||||
header h1 { font-size: 16px; font-weight: 900; letter-spacing: -.02em; margin: 0; color: var(--gray-900); }
|
||||
.proj-switcher { position: relative; }
|
||||
.ps-current { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius-lg); padding: 7px 12px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--gray-700); transition: var(--ease); }
|
||||
.ps-current:hover { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); }
|
||||
.ps-current .ps-label { font-size: 11px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; color: var(--gray-500); }
|
||||
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ps-current .ps-caret { font-size: 10px; color: var(--gray-500); }
|
||||
.ps-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px; background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); padding: 6px; z-index: 20; display: none; }
|
||||
.ps-menu.open { display: block; animation: zoomIn .2s ease; }
|
||||
.ps-item { padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--gray-700); display: flex; align-items: center; gap: 8px; transition: background .15s ease; }
|
||||
.ps-item:hover { background: var(--blue-50); color: var(--blue-dark); }
|
||||
.ps-item.active { background: var(--blue-50); color: var(--blue-dark); font-weight: 700; }
|
||||
.ps-item .ps-check { margin-left: auto; color: var(--blue); font-weight: 900; opacity: 0; }
|
||||
.ps-item.active .ps-check { opacity: 1; }
|
||||
.ps-sep { height: 1px; background: var(--gray-100); margin: 4px 6px; }
|
||||
.ps-manage { color: var(--blue); font-weight: 600; }
|
||||
#sid-badge { margin-left: auto; background: var(--blue-50); color: var(--blue-dark); border: 1px solid var(--blue-100); border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 600; max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#rag-bar { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 999px; padding: 3px 10px; font-size: 12px; color: var(--gray-700); }
|
||||
#rag-bar label.rag-switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||
#rag-bar input[type=checkbox] { width: 14px; height: 14px; accent-color: var(--blue); margin: 0; }
|
||||
#rag-bar input[type=checkbox]:disabled + span { color: var(--gray-500); cursor: not-allowed; }
|
||||
#rag-bar .rag-stats { font-variant-numeric: tabular-nums; color: var(--gray-500); }
|
||||
#rag-bar .rag-stats.ready { color: var(--blue-dark); font-weight: 600; }
|
||||
#rag-bar .rag-stats.zero { color: var(--gray-500); }
|
||||
#rag-bar button { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
#rag-bar button:disabled { background: var(--gray-200); color: var(--gray-500); cursor: not-allowed; }
|
||||
#rag-bar button:hover:not(:disabled) { background: var(--blue-dark); }
|
||||
#chat { flex: 1; overflow-y: auto; padding: 24px 24px 32px; max-width: 880px; width: 100%; margin: 0 auto; }
|
||||
.msg { display: flex; margin: 12px 0; animation: zoomIn .25s ease; }
|
||||
.msg .bubble { max-width: 74%; padding: 11px 16px; border-radius: var(--radius-xl); white-space: pre-wrap; line-height: 1.6; font-size: 14px; letter-spacing: .01em; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .bubble { background: linear-gradient(135deg, #1a73e8, #4285f4); color: #fff; border-bottom-right-radius: 6px; box-shadow: var(--shadow-2); }
|
||||
.msg.assistant { align-items: flex-start; }
|
||||
.msg.assistant .bubble { background: #fff; border: 1px solid var(--gray-200); border-bottom-left-radius: 6px; box-shadow: var(--shadow-1); }
|
||||
.msg.assistant::before { content: ""; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin: 16px 8px 0 0; background: var(--blue); box-shadow: 0 0 0 0 rgba(26,115,232,.4); animation: auraPulse 2.2s ease-in-out infinite; }
|
||||
.msg.progress .bubble { background: var(--blue-50); border: 1px dashed var(--blue-soft); font-size: 13px; color: var(--gray-700); }
|
||||
.msg.error .bubble { background: var(--red-soft); border: 1px solid #f5c6c0; color: #b31412; }
|
||||
.progress-item { display: block; }
|
||||
.progress-item.ok::before { content: "✔ "; color: var(--green); font-weight: 700; }
|
||||
.progress-item.warn::before { content: "⚠ "; color: #b06000; font-weight: 700; }
|
||||
.actions { margin-top: 8px; }
|
||||
.actions a { display: inline-block; margin: 4px 6px 0 0; background: var(--blue); color: #fff; padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
|
||||
.actions a:hover { background: var(--blue-dark); transform: translateY(-1px); }
|
||||
.typing { color: var(--gray-500); font-size: 13px; font-style: italic; animation: auraPulse 1.4s ease-in-out infinite; }
|
||||
#upload-bar { background: #fff; border-top: 1px solid var(--gray-200); padding: 10px 24px; font-size: 13px; }
|
||||
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
#upload-bar select { font-size: 13px; padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: #fff; color: var(--gray-900); cursor: pointer; outline: none; transition: var(--ease); }
|
||||
#upload-bar select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
|
||||
.file-pick { display: inline-block; padding: 8px 14px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: var(--gray-50); color: var(--gray-700); font-size: 13px; font-weight: 600; cursor: pointer; transition: var(--ease); }
|
||||
.file-pick:hover { background: var(--blue-50); color: var(--blue-dark); border-color: var(--blue-100); }
|
||||
#upload-btn { background: var(--gray-100); border: 1px solid var(--gray-200); color: var(--gray-700); border-radius: var(--radius-lg); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
|
||||
#upload-btn:hover { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
#upload-status { color: var(--gray-500); font-size: 12px; }
|
||||
#proj-hint { color: #b06000; font-size: 12px; background: #fef7e0; border: 1px solid #fde293; border-radius: 8px; padding: 4px 10px; }
|
||||
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
||||
#composer { border-top: 1px solid var(--gray-200); background: #fff; padding: 14px 24px 18px; }
|
||||
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; }
|
||||
#input { flex: 1; border: 1px solid var(--gray-300); border-radius: 999px; padding: 11px 18px; font-size: 14px; outline: none; background: var(--gray-50); transition: var(--ease); color: var(--gray-900); }
|
||||
#input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-50); }
|
||||
#input::placeholder { color: var(--gray-500); }
|
||||
#send { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
|
||||
#send:hover:not(:disabled) { background: var(--blue-dark); box-shadow: var(--shadow-2); transform: translateY(-1px); }
|
||||
#send:active:not(:disabled) { transform: translateY(0); }
|
||||
#send:disabled { opacity: .45; cursor: not-allowed; }
|
||||
#proj-drawer { position: fixed; top: 0; right: 0; height: 100vh; width: 460px; background: #fff; border-left: 1px solid var(--gray-200); box-shadow: -8px 0 32px rgba(60,64,67,.12); transform: translateX(100%); transition: transform .3s cubic-bezier(.2,0,0,1); z-index: 100; display: flex; flex-direction: column; }
|
||||
#proj-drawer.open { transform: translateX(0); }
|
||||
.drawer-head { height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--gray-200); background: rgba(255,255,255,.95); backdrop-filter: blur(8px); flex-shrink: 0; }
|
||||
.drawer-head h2 { font-size: 15px; font-weight: 900; letter-spacing: -.01em; margin: 0; color: var(--gray-900); flex: 1; }
|
||||
.drawer-close { background: var(--gray-100); border: none; color: var(--gray-700); width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
|
||||
.drawer-close:hover { background: var(--red-soft); color: var(--red); }
|
||||
.drawer-body { flex: 1; display: flex; min-height: 0; }
|
||||
.drawer-list { width: 38%; border-right: 1px solid var(--gray-100); overflow-y: auto; padding: 10px 8px; background: var(--gray-50); display: flex; flex-direction: column; gap: 2px; }
|
||||
.drawer-item { padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 13px; color: var(--gray-700); border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 8px; }
|
||||
.drawer-item:hover { background: #fff; border-color: var(--gray-200); }
|
||||
.drawer-item.active { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); font-weight: 700; box-shadow: var(--shadow-1); }
|
||||
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drawer-item .di-new { color: var(--blue); }
|
||||
.drawer-item.di-create { border: 1px dashed var(--blue-100); background: var(--blue-50); color: var(--blue-dark); font-weight: 700; justify-content: center; gap: 4px; }
|
||||
.drawer-item.di-create:hover { background: var(--blue-100); border-color: var(--blue); }
|
||||
.drawer-form { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.drawer-form label { font-size: 11px; font-weight: 700; color: var(--gray-500); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block; }
|
||||
.drawer-form input { width: 100%; padding: 8px 11px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 13px; color: var(--gray-900); background: #fff; transition: var(--ease); }
|
||||
.drawer-form input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
|
||||
.drawer-form input:read-only { background: var(--gray-50); color: var(--gray-500); }
|
||||
.drawer-form .field { display: flex; flex-direction: column; }
|
||||
.drawer-form .drawer-hint { margin: 0 0 6px; }
|
||||
.drawer-form details.advanced { border: 1px solid var(--gray-200); border-radius: 8px; padding: 8px 12px; background: var(--gray-50); }
|
||||
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 700; color: var(--gray-700); letter-spacing: .02em; }
|
||||
.drawer-form details.advanced[open] { padding-bottom: 10px; }
|
||||
.drawer-form details.advanced .field { margin-top: 8px; }
|
||||
.drawer-form input.invalid { border-color: var(--red) !important; box-shadow: 0 0 0 2px var(--red-soft) !important; }
|
||||
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
|
||||
.btn-save { background: var(--blue); color: #fff; }
|
||||
.btn-save:hover { background: var(--blue-dark); }
|
||||
.btn-delete { background: var(--red-soft); color: var(--red); border: 1px solid #f5c6c0 !important; }
|
||||
.btn-delete:hover { background: var(--red); color: #fff; }
|
||||
.drawer-hint { font-size: 11px; color: var(--gray-500); line-height: 1.5; padding: 8px 0; }
|
||||
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
|
||||
@keyframes zoomIn { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } }
|
||||
@media (max-width: 860px) {
|
||||
#sidebar { width: 220px; }
|
||||
#sidebar.collapsed { width: 48px; }
|
||||
#sid-badge { display: none; }
|
||||
#proj-drawer { width: 100vw; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 chat.js(机械迁移原内联 <script> 段)**
|
||||
|
||||
从 `chat.html:509-1213` 复制整段 `<script>...</script>`(含 `let sid = null;` 到 `})();`),粘贴到 `chat.js` 顶部,**不加任何 IIFE 包裹、不加任何修改**。原内联段本身是顶层作用域代码(不依赖 `DOMContentLoaded`,因为 `<script>` 放在 `<body>` 末尾、DOM 已就绪)。
|
||||
|
||||
```js
|
||||
// src/genesis/server/static/chat.js
|
||||
// v0 拆分骨架 - 从 chat.html 内联 <script> 段机械迁移;行为零变更
|
||||
// v1 视觉/交互改造见后续 Task
|
||||
|
||||
// 整段从 chat.html:509-1213 复制(约 700 行):
|
||||
let sid = null;
|
||||
let progressWs = null;
|
||||
// ... 原 chat.html 509-1213 行所有代码完整复制 ...
|
||||
// 最后是 (async () => { ... })();
|
||||
```
|
||||
|
||||
(实施时直接用 read 工具读原 chat.html 第 509-1213 行,写到 chat.js。)
|
||||
|
||||
- [ ] **Step 3: 修改 chat.html(删除内联 <style> 与 <script>,引用外部资源)**
|
||||
|
||||
将 `chat.html:1-15` 改为:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Genesis — 概要设计书自动生成(对话)</title>
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
```
|
||||
|
||||
将 `chat.html:507-508`(`<script src="/chat_state.js">` / `chat_ws.js`)保持不变,将 `chat.html:509-1213`(整段内联 `<script>`)删除,并在 `chat.html:506`(`</div>` 之前)的合适位置追加:
|
||||
|
||||
```html
|
||||
<script src="/chat_state.js"></script>
|
||||
<script src="/chat_ws.js"></script>
|
||||
<script src="/chat.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**注意**:`chat.js` 使用 `defer`,确保 DOM 解析完成后执行(虽然原内联段放在 body 末尾已等价,但 `defer` 保险)。`<script src="/chat_state.js">` 不加 `defer`(保持原顺序执行模式,向 `window.GenesisState` 注入)。
|
||||
|
||||
- [ ] **Step 4: 启动服务器并浏览器访问主页验证拆分无回归**
|
||||
|
||||
```bash
|
||||
# 在项目根目录
|
||||
python -m genesis.server.app
|
||||
# 或查看 README.md 中实际的启动命令
|
||||
```
|
||||
|
||||
预期:访问 `http://localhost:8000/`(或 README 中端口),看到与拆分前**视觉与交互完全一致**的页面(Material/Google 风、Inter 字体、蓝色 #1a73e8)。
|
||||
|
||||
- [ ] **Step 5: 手动验证清单**
|
||||
|
||||
依次跑通:
|
||||
1. 页面加载,浏览器控制台无 JS 错误(无 404 chat.css / chat.js)
|
||||
2. 点击「折叠侧边栏」按钮 → 侧边栏变窄,会话名隐藏
|
||||
3. 点击「项目」下拉 → 弹出菜单,能选项目
|
||||
4. 输入「你好」→ 发送 → 用户气泡(蓝渐变)出现
|
||||
5. 刷新页面 → 上次会话恢复(localStorage 生效)
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.html \
|
||||
src/genesis/server/static/chat.css \
|
||||
src/genesis/server/static/chat.js
|
||||
git commit -m "refactor(frontend): chat.html 拆分为 html + css + js(机械拆分,行为零变更)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 视觉重塑 - chat.css 应用深色设计令牌 + 六大区域样式
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.css`(替换 `:root` 令牌 + 全部样式)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: spec §一 颜色/字体/间距令牌、spec §二 六大区域视觉规约
|
||||
- Produces: 重写后的 chat.css(深色主题),与原 chat.html 同样的 DOM 结构匹配
|
||||
|
||||
**注**: 本 Task 是一次性大改 chat.css。**chat.html 与 chat.js 不动**(DOM id 全部保持,业务逻辑不变)。
|
||||
|
||||
- [ ] **Step 1: 替换 `:root` 令牌为 spec §一 深色版本**
|
||||
|
||||
```css
|
||||
/* src/genesis/server/static/chat.css - v1 深色重塑 */
|
||||
:root {
|
||||
/* === 颜色 (spec §一) === */
|
||||
--bg-base: #0B0F17;
|
||||
--bg-surface: #141A24;
|
||||
--bg-elevated: #1B2330;
|
||||
--bg-input: #0F141C;
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text-primary: #E5EAF0;
|
||||
--text-secondary: #8B95A7;
|
||||
--text-muted: #5C6677;
|
||||
--accent: #5EEAD4;
|
||||
--accent-soft: rgba(94, 234, 212, 0.15);
|
||||
--success: #34D399;
|
||||
--warning: #FBBF24;
|
||||
--danger: #F87171;
|
||||
--info: #60A5FA;
|
||||
|
||||
/* === 字体 (系统字体栈,spec §一 Q2 决策) === */
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
|
||||
/* === 间距 (spec §一) === */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
|
||||
|
||||
/* === 圆角 (spec §一) === */
|
||||
--r-sm: 6px; --r-md: 10px; --r-lg: 14px;
|
||||
|
||||
/* === 动效 (spec §一) === */
|
||||
--ease: cubic-bezier(.2, 0, 0, 1);
|
||||
--dur: .18s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换全局 + 滚动条样式**
|
||||
|
||||
```css
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
margin: 0; height: 100vh; display: flex; flex-direction: row;
|
||||
background: var(--bg-base); color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 14px;
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 重写侧边栏样式(spec §2.1)**
|
||||
|
||||
```css
|
||||
/* 侧边栏 */
|
||||
#sidebar {
|
||||
width: 264px; background: var(--bg-surface); color: var(--text-primary);
|
||||
display: flex; flex-direction: column; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
transition: width .25s var(--ease);
|
||||
position: relative;
|
||||
}
|
||||
#sidebar.collapsed { width: 56px; }
|
||||
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
|
||||
#sidebar.collapsed #session-list { padding-bottom: 52px; }
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 16px 12px 12px 16px; margin: 0 8px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 64px;
|
||||
}
|
||||
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
|
||||
.brand-logo {
|
||||
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--bg-base); font-size: 17px; font-weight: 900;
|
||||
background: var(--accent);
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
.brand:hover .brand-logo { transform: scale(1.1); box-shadow: 0 0 24px var(--accent-soft); }
|
||||
.brand-name { font-size: 17px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; color: var(--text-primary); }
|
||||
.brand-sub { display: block; font-size: 11px; font-weight: 500; color: var(--text-muted); margin-top: 2px; letter-spacing: 0; }
|
||||
#sidebar.collapsed .brand-name { display: none; }
|
||||
#sidebar-toggle {
|
||||
position: absolute; top: 18px; right: -14px; z-index: 5;
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border-strong);
|
||||
color: var(--text-secondary); cursor: pointer; font-size: 14px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
#sidebar-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
#new-chat {
|
||||
margin: var(--sp-2) var(--sp-4) var(--sp-3); background: var(--accent); color: var(--bg-base);
|
||||
border: none; padding: 10px 14px; border-radius: var(--r-md);
|
||||
cursor: pointer; font-size: 14px; font-weight: 700; letter-spacing: .01em;
|
||||
transition: all var(--dur) var(--ease);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
#new-chat:hover { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
|
||||
#new-chat:active { transform: translateY(0); }
|
||||
#sidebar.collapsed #new-chat { margin: var(--sp-2) 10px var(--sp-3); padding: 10px 0; }
|
||||
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
|
||||
#sidebar.collapsed #new-chat .nc-text { display: none; }
|
||||
#sidebar h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--text-muted); margin: var(--sp-3) var(--sp-4) var(--sp-2);
|
||||
}
|
||||
#sidebar.collapsed h2 { display: none; }
|
||||
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
|
||||
.sess {
|
||||
padding: 9px 12px; border-radius: var(--r-md); margin: 2px 0;
|
||||
cursor: pointer; position: relative; border: 1px solid transparent;
|
||||
transition: all var(--dur) var(--ease);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.sess:hover { background: var(--bg-elevated); }
|
||||
.sess:hover::before {
|
||||
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
|
||||
background: var(--accent); border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.sess.active { background: var(--bg-elevated); }
|
||||
.sess.active::before {
|
||||
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
|
||||
background: var(--accent); border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.sess .name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
|
||||
.sess.active .name { font-weight: 700; }
|
||||
.sess .meta { font-size: 11px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); }
|
||||
.sess .del {
|
||||
flex-shrink: 0; opacity: 0; transition: opacity .15s ease;
|
||||
background: transparent; border: none; color: var(--text-muted); cursor: pointer;
|
||||
width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sess:hover .del { opacity: .7; }
|
||||
.sess .del:hover { opacity: 1 !important; background: var(--danger); color: var(--bg-base); }
|
||||
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
|
||||
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
|
||||
#sidebar.collapsed .sess .avatar {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sess .avatar { display: none; }
|
||||
#sidebar.collapsed .sess .avatar { display: flex; }
|
||||
#sidebar.collapsed .sess.active .avatar { background: var(--accent); box-shadow: 0 0 8px var(--accent-soft); }
|
||||
```
|
||||
|
||||
**注意**: 把 `.sess .avatar` 默认宽高从 28px 改为 8px(小圆点),折叠态使用此圆点;展开态 `.sess .avatar { display: none; }` 保持隐藏。颜色 hash 逻辑在 chat.js 中已存在(palette 数组),无需改 JS。
|
||||
|
||||
- [ ] **Step 4: 重写主区 + 顶栏样式(spec §2.2)**
|
||||
|
||||
```css
|
||||
/* 主区 */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-base); }
|
||||
header {
|
||||
height: 56px; position: sticky; top: 0; z-index: 10;
|
||||
padding: 0 var(--sp-6); display: flex; align-items: center; gap: 14px;
|
||||
background: rgba(20, 26, 36, 0.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0; color: var(--text-primary); }
|
||||
/* 项目切换器 */
|
||||
.proj-switcher { position: relative; }
|
||||
.ps-current {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: transparent; border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-md); padding: 7px 12px; cursor: pointer;
|
||||
font-size: 13px; font-weight: 500; color: var(--text-primary);
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.ps-current:hover { border-color: var(--accent); }
|
||||
.ps-current .ps-label { font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted); }
|
||||
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ps-current .ps-caret { font-size: 10px; color: var(--text-muted); }
|
||||
.ps-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.4); padding: 6px; z-index: 20;
|
||||
display: none;
|
||||
}
|
||||
.ps-menu.open { display: block; animation: zoomIn .2s var(--ease); }
|
||||
.ps-item {
|
||||
padding: 8px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; color: var(--text-secondary);
|
||||
display: flex; align-items: center; gap: 8px; transition: background .15s var(--ease);
|
||||
}
|
||||
.ps-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
.ps-item.active { background: var(--bg-elevated); color: var(--accent); font-weight: 700; }
|
||||
.ps-item .ps-check { margin-left: auto; color: var(--accent); font-weight: 900; opacity: 0; }
|
||||
.ps-item.active .ps-check { opacity: 1; }
|
||||
.ps-sep { height: 1px; background: var(--border-subtle); margin: 4px 6px; }
|
||||
.ps-manage { color: var(--accent); font-weight: 600; }
|
||||
#sid-badge {
|
||||
margin-left: auto;
|
||||
background: var(--bg-elevated); color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 500;
|
||||
max-width: 32%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 重写 RAG 条样式(spec §2.2 RAG 胶囊)**
|
||||
|
||||
```css
|
||||
/* RAG 控制条 */
|
||||
#rag-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: transparent; border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px; padding: 3px 10px;
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
#rag-bar label.rag-switch {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
#rag-bar input[type=checkbox] {
|
||||
width: 14px; height: 14px; accent-color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
#rag-bar input[type=checkbox]:disabled + span { color: var(--text-muted); cursor: not-allowed; }
|
||||
#rag-bar .rag-stats { color: var(--text-muted); }
|
||||
#rag-bar .rag-stats.ready { color: var(--accent); font-weight: 600; }
|
||||
#rag-bar .rag-stats.zero { color: var(--text-muted); }
|
||||
#rag-bar button {
|
||||
background: var(--accent); color: var(--bg-base); border: none;
|
||||
border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700;
|
||||
cursor: pointer; transition: all var(--dur) var(--ease);
|
||||
}
|
||||
#rag-bar button:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; }
|
||||
#rag-bar button:hover:not(:disabled) { box-shadow: 0 0 16px var(--accent-soft); }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 重写聊天区 + 气泡(spec §2.3)**
|
||||
|
||||
```css
|
||||
/* 聊天区 */
|
||||
#chat { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-6) var(--sp-6); max-width: 880px; width: 100%; margin: 0 auto; }
|
||||
.msg { display: flex; margin: var(--sp-3) 0; animation: zoomIn .25s var(--ease); position: relative; }
|
||||
.msg .bubble {
|
||||
max-width: 74%; padding: 11px 16px; border-radius: var(--r-lg);
|
||||
white-space: pre-wrap; line-height: 1.65; font-size: 14px; letter-spacing: .01em;
|
||||
}
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .bubble {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-bottom-right-radius: 6px;
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 18px; /* 抵消左边框占用的空间,避免文字偏移 */
|
||||
}
|
||||
.msg.assistant { align-items: flex-start; }
|
||||
.msg.assistant .bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-bottom-left-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg.assistant::before {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
margin: 18px 10px 0 0; background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-soft);
|
||||
}
|
||||
.msg.progress .bubble {
|
||||
background: var(--bg-input); border: 1px dashed var(--accent-soft);
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.msg.error .bubble {
|
||||
background: rgba(248, 113, 113, 0.12); border: 1px solid var(--danger); color: var(--danger);
|
||||
}
|
||||
.progress-item { display: block; }
|
||||
.progress-item.ok::before { content: "✔ "; color: var(--success); font-weight: 700; }
|
||||
.progress-item.warn::before { content: "⚠ "; color: var(--warning); font-weight: 700; }
|
||||
.actions { margin-top: 8px; }
|
||||
.actions a {
|
||||
display: inline-block; margin: 4px 6px 0 0; background: var(--accent); color: var(--bg-base);
|
||||
padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 700;
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.actions a:hover { box-shadow: 0 0 16px var(--accent-soft); transform: translateY(-1px); }
|
||||
.typing { color: var(--text-muted); font-size: 13px; animation: auraPulse 1.4s ease-in-out infinite; font-family: var(--font-mono); }
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 重写上传条样式(spec §2.4)**
|
||||
|
||||
```css
|
||||
/* 上传条 */
|
||||
#upload-bar { background: var(--bg-surface); border-top: 1px solid var(--border-subtle); padding: var(--sp-3) var(--sp-6); font-size: 13px; }
|
||||
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
#upload-bar select {
|
||||
font-size: 13px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
||||
background: var(--bg-input); color: var(--text-primary); cursor: pointer; outline: none;
|
||||
transition: all var(--dur) var(--ease);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
#upload-bar select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
.file-pick {
|
||||
display: inline-block; padding: 8px 14px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
||||
background: var(--bg-input); color: var(--text-secondary); font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.file-pick:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#upload-btn {
|
||||
background: transparent; border: 1px solid var(--border-strong); color: var(--text-primary);
|
||||
border-radius: var(--r-md); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
#upload-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--bg-base); }
|
||||
#upload-status { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); }
|
||||
#proj-hint {
|
||||
color: var(--warning); font-size: 12px; background: rgba(251,191,36,.1); border: 1px solid var(--warning);
|
||||
border-left-width: 4px; border-radius: var(--r-sm); padding: 4px 10px;
|
||||
}
|
||||
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 重写输入区样式(spec §2.5)**
|
||||
|
||||
```css
|
||||
/* 输入区 */
|
||||
#composer { border-top: 1px solid var(--border-subtle); background: var(--bg-surface); padding: var(--sp-4) var(--sp-6) var(--sp-5); }
|
||||
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
|
||||
#input {
|
||||
flex: 1; border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
||||
padding: 11px 14px; font-size: 14px; outline: none; background: var(--bg-input);
|
||||
transition: all var(--dur) var(--ease); color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
resize: none; min-height: 42px; max-height: 160px; line-height: 1.5;
|
||||
}
|
||||
#input:focus { border-color: var(--accent); background: var(--bg-elevated); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
#input::placeholder { color: var(--text-muted); }
|
||||
#send {
|
||||
background: var(--accent); color: var(--bg-base); border: none; border-radius: var(--r-md);
|
||||
padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700;
|
||||
transition: all var(--dur) var(--ease);
|
||||
min-height: 42px;
|
||||
}
|
||||
#send:hover:not(:disabled) { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
|
||||
#send:active:not(:disabled) { transform: translateY(0); }
|
||||
#send:disabled { opacity: .3; cursor: not-allowed; }
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 重写项目抽屉样式(spec §2.6)**
|
||||
|
||||
```css
|
||||
/* 项目抽屉 */
|
||||
#proj-drawer {
|
||||
position: fixed; top: 0; right: 0; height: 100vh; width: 460px;
|
||||
background: var(--bg-surface); border-left: 1px solid var(--border-subtle);
|
||||
box-shadow: -8px 0 32px rgba(0,0,0,.5);
|
||||
transform: translateX(100%); transition: transform .3s var(--ease);
|
||||
z-index: 100; display: flex; flex-direction: column;
|
||||
}
|
||||
#proj-drawer.open { transform: translateX(0); }
|
||||
.drawer-head {
|
||||
height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle); background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-head h2 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; margin: 0; color: var(--text-primary); flex: 1; }
|
||||
.drawer-close {
|
||||
background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary);
|
||||
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
|
||||
font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.drawer-close:hover { background: var(--danger); border-color: var(--danger); color: var(--bg-base); }
|
||||
.drawer-body { flex: 1; display: flex; min-height: 0; }
|
||||
.drawer-list {
|
||||
width: 38%; border-right: 1px solid var(--border-subtle); overflow-y: auto;
|
||||
padding: 10px 8px; background: var(--bg-base);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.drawer-item {
|
||||
padding: 10px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px;
|
||||
color: var(--text-secondary); border: 1px solid transparent;
|
||||
transition: all var(--dur) var(--ease);
|
||||
display: flex; align-items: center; gap: 8px; position: relative;
|
||||
}
|
||||
.drawer-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
.drawer-item.active {
|
||||
background: var(--bg-elevated); color: var(--text-primary); font-weight: 700;
|
||||
}
|
||||
.drawer-item.active::before {
|
||||
content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px;
|
||||
background: var(--accent); border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drawer-item .di-new { color: var(--accent); }
|
||||
.drawer-item.di-create {
|
||||
border: 1px dashed var(--border-strong); background: transparent;
|
||||
color: var(--accent); font-weight: 700; justify-content: center; gap: 4px;
|
||||
}
|
||||
.drawer-item.di-create:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.drawer-form { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: 10px; }
|
||||
.drawer-form label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; font-weight: 500; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block;
|
||||
}
|
||||
.drawer-form input {
|
||||
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong); border-radius: var(--r-sm);
|
||||
font-size: 13px; color: var(--text-primary); background: var(--bg-input);
|
||||
transition: all var(--dur) var(--ease);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.drawer-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
.drawer-form input:read-only { background: var(--bg-base); color: var(--text-muted); }
|
||||
.drawer-form .field { display: flex; flex-direction: column; }
|
||||
.drawer-form .drawer-hint { margin: 0 0 6px; }
|
||||
.drawer-form details.advanced { border: 1px solid var(--border-subtle); border-radius: var(--r-sm); padding: 8px 12px; }
|
||||
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary); letter-spacing: .02em; }
|
||||
.drawer-form details.advanced[open] { padding-bottom: 10px; }
|
||||
.drawer-form details.advanced .field { margin-top: 8px; }
|
||||
.drawer-form input.invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 2px rgba(248,113,113,.25) !important; }
|
||||
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; font-weight: 600; transition: all var(--dur) var(--ease); }
|
||||
.btn-save { background: var(--accent); color: var(--bg-base); }
|
||||
.btn-save:hover { box-shadow: 0 0 16px var(--accent-soft); }
|
||||
.btn-delete { background: transparent; color: var(--danger); border: 1px solid var(--danger) !important; }
|
||||
.btn-delete:hover { background: var(--danger); color: var(--bg-base); }
|
||||
.drawer-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 8px 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 重写动效 + 响应式断点(spec §1.4)**
|
||||
|
||||
```css
|
||||
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
|
||||
@keyframes zoomIn { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
|
||||
@media (max-width: 860px) {
|
||||
#sidebar { width: 220px; }
|
||||
#sidebar.collapsed { width: 48px; }
|
||||
#sid-badge { max-width: 24%; }
|
||||
#proj-drawer { width: 100vw; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: 浏览器验证视觉重塑**
|
||||
|
||||
启动服务器,访问主页,验证:
|
||||
1. 整体配色变深(深空灰底 + 白色文字)
|
||||
2. 强调色全部变青绿 `#5EEAD4`
|
||||
3. 字体回退到系统字体(中文用 PingFang/Microsoft YaHei)
|
||||
4. 六大区域(侧边栏/顶栏/聊天/上传/输入/抽屉)全部按新样式渲染
|
||||
5. 业务功能不破:侧边栏折叠、项目切换、会话创建、消息发送、上传
|
||||
|
||||
- [ ] **Step 12: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.css
|
||||
git commit -m "feat(frontend): 视觉重塑 - 深色指挥中心 + 文档工作台混合风格"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 交互改造 - 会话列表按时间分组(spec §4.1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.js`(在 `refreshSessions` 内分组)
|
||||
- Modify: `src/genesis/server/static/chat.css`(加 `.sess-group` 样式)
|
||||
|
||||
- [ ] **Step 1: chat.css 新增 `.sess-group` 样式**
|
||||
|
||||
在 `.sess .avatar` 块之后追加:
|
||||
|
||||
```css
|
||||
.sess-group {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; font-weight: 500; letter-spacing: .12em;
|
||||
color: var(--text-muted);
|
||||
padding: var(--sp-3) 12px var(--sp-1);
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
#sidebar.collapsed .sess-group { display: none; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.js 改造 `refreshSessions` 增加分组逻辑**
|
||||
|
||||
在 `chat.js` 中找到 `refreshSessions` 函数(约原 chat.html:561 行),把 `list.sort(...)` 之后、`for (const s of list)` 之前替换为以下分组版本(保留原有 `.sess` DOM 创建逻辑,仅在循环外加分组标题插入):
|
||||
|
||||
```js
|
||||
async function refreshSessions() {
|
||||
const box = document.getElementById('session-list');
|
||||
if (draftProject === null) { box.innerHTML = ''; return; }
|
||||
const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
|
||||
box.innerHTML = '';
|
||||
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||
|
||||
// === 新增: 按 updated_at 字符串前缀(YYYY-MM-DD)分组 ===
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
|
||||
const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7);
|
||||
const monthAgo = new Date(today); monthAgo.setDate(today.getDate() - 30);
|
||||
|
||||
function bucketOf(s) {
|
||||
const t = s.updated_at ? new Date(s.updated_at) : null;
|
||||
if (!t || isNaN(t.getTime())) return 'older';
|
||||
if (t >= today) return 'today';
|
||||
if (t >= yesterday) return 'yesterday';
|
||||
if (t >= weekAgo) return 'thisWeek';
|
||||
if (t >= monthAgo) return 'thisMonth';
|
||||
return 'older';
|
||||
}
|
||||
const BUCKET_LABEL = { today: '今天', yesterday: '昨天', thisWeek: '本周', thisMonth: '本月', older: '更早' };
|
||||
const BUCKET_ORDER = ['today', 'yesterday', 'thisWeek', 'thisMonth', 'older'];
|
||||
|
||||
let lastBucket = null;
|
||||
for (const s of list) {
|
||||
const bucket = bucketOf(s);
|
||||
if (bucket !== lastBucket) {
|
||||
const grp = document.createElement('div');
|
||||
grp.className = 'sess-group';
|
||||
grp.textContent = BUCKET_LABEL[bucket] || bucket;
|
||||
box.appendChild(grp);
|
||||
lastBucket = bucket;
|
||||
}
|
||||
// === 原有 .sess 创建逻辑(从原 chat.html:567-589 完整复制)===
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
|
||||
const proj = s.project ? ' · ' + esc(s.project) : '';
|
||||
const rawName = (s.name || '新').trim();
|
||||
const avatar = rawName ? Array.from(rawName)[0] : '新';
|
||||
const palette = ['#5EEAD4', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#8B95A7'];
|
||||
let h = 0;
|
||||
for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
|
||||
const color = palette[Math.abs(h) % palette.length];
|
||||
el.innerHTML =
|
||||
'<div class="avatar" style="background:' + color + ';"></div>' +
|
||||
'<div class="info">' +
|
||||
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
|
||||
'<div class="meta">' + esc(s.status) + proj + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
|
||||
el.querySelector('.info').onclick = () => loadSession(s.session_id);
|
||||
const delBtn = el.querySelector('.del');
|
||||
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
|
||||
box.appendChild(el);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
启动服务器,浏览器访问:
|
||||
1. 选中一个项目(确保有 ≥1 个会话)→ 侧边栏会话列表出现
|
||||
2. 若历史会话的 `updated_at` 跨多天 → 应出现分组标题「今天 / 昨天 / 本周 / 本月 / 更早」
|
||||
3. 折叠侧边栏 → 分组标题隐藏
|
||||
4. 无 `updated_at` 的会话会落入「更早」组,不报错
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 会话列表按时间分组(今天/昨天/本周/本月/更早)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 交互改造 - 拖拽上传(spec §4.2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.js`(新增 `bindUploadDrag` 函数 + 调用)
|
||||
- Modify: `src/genesis/server/static/chat.css`(加 `#upload-bar.drag-active`)
|
||||
|
||||
- [ ] **Step 1: chat.css 新增拖拽激活样式**
|
||||
|
||||
在 `#upload-bar` 块之后追加:
|
||||
|
||||
```css
|
||||
#upload-bar.drag-active {
|
||||
background: var(--accent-soft);
|
||||
border-top: 1px dashed var(--accent);
|
||||
border-bottom: 1px dashed var(--accent);
|
||||
}
|
||||
#upload-bar.drag-active #upload-bar-inner { color: var(--accent); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.js 新增 `bindUploadDrag` 函数并调用**
|
||||
|
||||
在 `chat.js` 启动 IIFE(`(async () => {` 之前)追加:
|
||||
|
||||
```js
|
||||
function bindUploadDrag() {
|
||||
const bar = document.getElementById('upload-bar');
|
||||
if (!bar || !('draggable' in document.createElement('div'))) return;
|
||||
let depth = 0; // 避免子元素 dragenter/dragleave 闪烁
|
||||
bar.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault(); depth++;
|
||||
bar.classList.add('drag-active');
|
||||
});
|
||||
bar.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
bar.addEventListener('dragleave', (e) => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) bar.classList.remove('drag-active');
|
||||
});
|
||||
bar.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
bar.classList.remove('drag-active');
|
||||
const items = e.dataTransfer.items;
|
||||
if (items && items.length && items[0].webkitGetAsEntry) {
|
||||
const entry = items[0].webkitGetAsEntry();
|
||||
if (entry && entry.isDirectory) {
|
||||
addWarn('请拖入单个文件,不要拖入文件夹');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (!file) { addWarn('未识别到文件'); return; }
|
||||
// 按后缀自动选 file-type
|
||||
const ext = (file.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
||||
const sel = document.getElementById('file-type');
|
||||
if (ext === '.xlsx' && sel.querySelector('option[value="requirements"]')) sel.value = 'requirements';
|
||||
else if (ext === '.docx' && sel.querySelector('option[value="template"]')) sel.value = 'template';
|
||||
else if (ext === '.zip' && sel.querySelector('option[value="existing_system"]')) sel.value = 'existing_system';
|
||||
// 写入 #file-input.files
|
||||
const input = document.getElementById('file-input');
|
||||
try {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
} catch (err) {
|
||||
addWarn('该浏览器不支持拖拽设置文件,请使用「选择文件」按钮');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
在启动 IIFE 内部最前面(`loadProjects()` 之前)调用 `bindUploadDrag();`。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 拖一个 `.xlsx` 文件到上传条 → 出现 `accent` 虚线高亮
|
||||
2. 拖入完成 → `file-type` 自动选 `requirements`;文件名出现在 file-input
|
||||
3. 点击「上传」按钮 → 走原有上传逻辑成功
|
||||
4. 拖一个 `.docx` → 自动选 `template`
|
||||
5. 拖一个 `.zip` → 自动选 `existing_system`
|
||||
6. 拖入文件夹 → 出现「请拖入单个文件」warn 提示
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 拖拽上传 + 按后缀自动选 file-type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 交互改造 - 消息气泡复制/引用(spec §4.3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.js`(扩展 `addMsg` + 新增 `attachBubbleOps`)
|
||||
- Modify: `src/genesis/server/static/chat.css`(加 `.msg .ops`)
|
||||
|
||||
**注**: **不实现"重新生成"按钮**(spec §零-9 决策)。
|
||||
|
||||
- [ ] **Step 1: chat.css 新增气泡操作按钮样式**
|
||||
|
||||
```css
|
||||
.msg .ops {
|
||||
position: absolute; top: -10px; right: 8px;
|
||||
display: flex; gap: 4px; opacity: 0;
|
||||
transition: opacity .15s var(--ease);
|
||||
z-index: 2;
|
||||
}
|
||||
.msg:hover .ops { opacity: 1; }
|
||||
.msg .ops button {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px; padding: 2px 8px;
|
||||
border-radius: var(--r-sm); cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.msg .ops button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.js 扩展 `addMsg` 接收纯文本 + 在 `addMsg` 末尾追加操作按钮**
|
||||
|
||||
找到 `addMsg` 函数(原 chat.html:540 行附近):
|
||||
|
||||
```js
|
||||
function addMsg(role, html) {
|
||||
const m = document.createElement('div');
|
||||
m.className = 'msg ' + role;
|
||||
m.innerHTML = '<div class="bubble">' + html + '</div>';
|
||||
chatEl.appendChild(m);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
return m;
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
function addMsg(role, html, text) {
|
||||
// text: 纯文本(用于复制/引用)。若不传,则尝试从 html 反推纯文本
|
||||
const m = document.createElement('div');
|
||||
m.className = 'msg ' + role;
|
||||
m.innerHTML = '<div class="bubble">' + html + '</div>';
|
||||
// 操作按钮
|
||||
if (role === 'assistant' || role === 'user') {
|
||||
const ops = document.createElement('div');
|
||||
ops.className = 'ops';
|
||||
const cleanText = String(text != null ? text : m.querySelector('.bubble').innerText);
|
||||
if (role === 'assistant') {
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.textContent = '⧉ 复制';
|
||||
copyBtn.title = '复制消息内容';
|
||||
copyBtn.onclick = () => copyToClipboard(cleanText).then(
|
||||
() => { copyBtn.textContent = '✓ 已复制'; setTimeout(() => copyBtn.textContent = '⧉ 复制', 1500); },
|
||||
() => { addWarn('复制失败,请手动选中复制'); }
|
||||
);
|
||||
ops.appendChild(copyBtn);
|
||||
} else if (role === 'user') {
|
||||
const quoteBtn = document.createElement('button');
|
||||
quoteBtn.type = 'button';
|
||||
quoteBtn.textContent = '↺ 引用';
|
||||
quoteBtn.title = '把这条消息填到输入框';
|
||||
quoteBtn.onclick = () => {
|
||||
const inputEl = document.getElementById('input');
|
||||
inputEl.value = cleanText;
|
||||
inputEl.focus();
|
||||
autoResizeTextarea();
|
||||
};
|
||||
ops.appendChild(quoteBtn);
|
||||
}
|
||||
m.appendChild(ops);
|
||||
}
|
||||
chatEl.appendChild(m);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
return m;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// fallback: 临时 textarea + execCommand
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (!ok) throw new Error('execCommand returned false');
|
||||
} catch (e) {
|
||||
document.body.removeChild(ta);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改造所有 `addMsg` 调用点传纯文本**
|
||||
|
||||
在 `chat.js` 中找出所有 `addMsg(role, ...)` 调用,把第二个 html 参数后再传第三个 `text` 参数(纯文本):
|
||||
|
||||
| 调用位置 | 改造 |
|
||||
|---|---|
|
||||
| `addMsg('assistant', GenesisState.buildWelcome(draftProject));` | `addMsg('assistant', esc(GenesisState.buildWelcome(draftProject)), GenesisState.buildWelcome(draftProject));` |
|
||||
| `addMsg(role, text)` 在 `applyProgressEvent` 回调中 | 已经是 `text` 纯文本,直接加第三参数 `text` |
|
||||
| `addMsg('assistant', '项目「' + ... + '」已保存。');` | `addMsg('assistant', esc('项目「' + ... + '」已保存。'), '项目「' + ... + '」已保存。');` |
|
||||
| `addMsg('assistant', '项目「' + deleted + '」已删除。');` | 同上模式 |
|
||||
| `addMsg('error', '...')` | 不加 `text`(error 不显示复制/引用按钮,行为不变)|
|
||||
| `addMsg('progress', ...)` | 不加 `text` |
|
||||
| `addMsg('user', esc(text))` 在 `send()` 中 | `addMsg('user', esc(text), text)` |
|
||||
| `addMsg('user', '[上传] ...')` | `addMsg('user', esc('[上传] ...'), '[上传] ...')` |
|
||||
| `addMsg(role, esc(m.content))` 在 `loadSession` 循环中 | `addMsg(role, esc(m.content), m.content)` |
|
||||
|
||||
**注意**: 调用点涉及多处,本 Step 用 `grep` 工具列出所有 `addMsg(` 调用,逐一修改。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 发送一条消息 → 鼠标 hover 用户气泡 → 右上角浮出 `↺ 引用` 按钮
|
||||
2. 点击 `↺ 引用` → 输入框出现该消息文本 + 焦点在输入框
|
||||
3. 收到助手回复 → hover → 浮出 `⧉ 复制` 按钮
|
||||
4. 点击 `⧉ 复制` → 按钮短暂变 `✓ 已复制`,粘贴验证文本正确
|
||||
5. 进度/错误消息无操作按钮(hover 也不显示)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 消息气泡支持复制/引用(hover 浮出按钮)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 交互改造 - 输入区多行 + 自动高度(spec §4.4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.html`(`<input id="input">` → `<textarea>`)
|
||||
- Modify: `src/genesis/server/static/chat.js`(新增 `autoResizeTextarea` + 改造 keydown)
|
||||
- Modify: `src/genesis/server/static/chat.css`(已完成 Task 2 Step 8,`#input` 已是 textarea 样式,含 `min-height/max-height/resize:none`)
|
||||
|
||||
- [ ] **Step 1: 修改 chat.html**
|
||||
|
||||
把 `<input id="input" placeholder="..." autocomplete="off" maxlength="2000">` 改为:
|
||||
|
||||
```html
|
||||
<textarea id="input" rows="1" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000"></textarea>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.js 新增 `autoResizeTextarea` + 改造 keydown 监听**
|
||||
|
||||
在 `chat.js` 顶部 IIFE 内(`bindUploadDrag` 之后)新增:
|
||||
|
||||
```js
|
||||
function autoResizeTextarea() {
|
||||
const el = document.getElementById('input');
|
||||
if (!el || el.tagName !== 'TEXTAREA') return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
function bindComposer() {
|
||||
const el = document.getElementById('input');
|
||||
if (!el) return;
|
||||
if (el.tagName === 'TEXTAREA') {
|
||||
el.addEventListener('input', autoResizeTextarea);
|
||||
// Enter 发送(Shift+Enter 换行;IME 组合中不发送)
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
autoResizeTextarea();
|
||||
}
|
||||
// 保留原有 send 按钮点击监听(chat.js 末尾已有的 document.getElementById('send').addEventListener('click', send); 不变)
|
||||
}
|
||||
```
|
||||
|
||||
在启动 IIFE 内 `bindUploadDrag();` 之后调用 `bindComposer();`。
|
||||
|
||||
**注**: 原 chat.html:1011 已有 `inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });`,如果 input 现在是 textarea,这段代码仍会触发(textarea 也支持 keydown),但是会**抢在 Shift+Enter 时也发送**。需要把这段**替换**为上面 `bindComposer` 里的逻辑;或者保留并**在 bindComposer 内移除旧监听**:
|
||||
|
||||
简单方案:在 `bindComposer` 中 `el.removeEventListener('keydown', oldHandler)` 不容易(旧 handler 是匿名)。改为在 chat.js 中找到原 `inputEl.addEventListener('keydown', ...);`(约 chat.html:1011),**整行删除**,让 `bindComposer` 内的 keydown 监听接管。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 点击输入框 → 输入多行(按 Shift+Enter 换行)→ 文本框自动撑高(最大 160px)
|
||||
2. 按 Enter(无 Shift)→ 消息发送,文本框重置为 1 行
|
||||
3. 中文输入法组合中按 Enter → 不发送,走组合确认逻辑
|
||||
4. `maxlength=2000` 仍生效
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.html src/genesis/server/static/chat.js
|
||||
git commit -m "feat(chat): 输入区多行 textarea + 自动高度(Enter 发送 / Shift+Enter 换行)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 交互改造 - 顶栏项目不匹配徽章化(spec §4.5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.html`(在 `#rag-bar` 前插入 `#proj-mismatch-badge`)
|
||||
- Modify: `src/genesis/server/static/chat.js`(改造 `renderProjectMismatchHint` → 调用新徽章渲染)
|
||||
- Modify: `src/genesis/server/static/chat.css`(加 `.mismatch-badge` 样式 + popover)
|
||||
|
||||
- [ ] **Step 1: chat.html 插入徽章 DOM**
|
||||
|
||||
找到 `<div id="rag-bar" hidden>` 这一行,**之前**插入:
|
||||
|
||||
```html
|
||||
<span id="proj-mismatch-badge" class="mismatch-badge" hidden>⚠ <span id="mismatch-text"></span></span>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.css 新增徽章 + popover 样式**
|
||||
|
||||
```css
|
||||
.mismatch-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--warning);
|
||||
font-size: 12px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur) var(--ease);
|
||||
position: relative;
|
||||
}
|
||||
.mismatch-badge:hover { background: rgba(251,191,36,.1); }
|
||||
.mismatch-popover {
|
||||
position: absolute; top: calc(100% + 6px); right: 0;
|
||||
min-width: 240px; max-width: 320px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
||||
padding: 10px 12px;
|
||||
z-index: 30;
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mismatch-popover.open { display: block; animation: zoomIn .2s var(--ease); }
|
||||
.mismatch-popover .mp-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.mismatch-popover .mp-actions button {
|
||||
flex: 1; padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all var(--dur) var(--ease);
|
||||
}
|
||||
.mismatch-popover .mp-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mismatch-popover .mp-actions button.primary { background: var(--accent); color: var(--bg-base); border-color: var(--accent); }
|
||||
.mismatch-popover .mp-actions button.primary:hover { box-shadow: 0 0 16px var(--accent-soft); }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: chat.js 改造 `renderProjectMismatchHint` 为徽章渲染**
|
||||
|
||||
找到原 `renderProjectMismatchHint` 函数(约原 chat.html:912 行),整段**替换**为:
|
||||
|
||||
```js
|
||||
function renderProjectMismatchHint() {
|
||||
const badge = document.getElementById('proj-mismatch-badge');
|
||||
const text = document.getElementById('mismatch-text');
|
||||
if (!badge || !text) return;
|
||||
if (!sid || !activeProject || activeProject === draftProject) {
|
||||
badge.hidden = true;
|
||||
badge.replaceChildren(); // 清空 popover
|
||||
return;
|
||||
}
|
||||
let skipped = {};
|
||||
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
|
||||
if (skipped[sid] === activeProject) {
|
||||
badge.hidden = true;
|
||||
return;
|
||||
}
|
||||
// 显示徽章
|
||||
text.textContent = '项目不匹配:' + activeProject + ' ≠ ' + (draftProject || '(未选择)');
|
||||
badge.hidden = false;
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
// 构建/切换 popover
|
||||
let pop = badge.querySelector('.mismatch-popover');
|
||||
if (!pop) {
|
||||
pop = document.createElement('div');
|
||||
pop.className = 'mismatch-popover';
|
||||
pop.innerHTML =
|
||||
'<div>当前会话属于 <strong style="color:var(--accent)">' + esc(activeProject) + '</strong>,' +
|
||||
'顶栏项目是 <strong style="color:var(--warning)">' + esc(draftProject || '(未选择)') + '</strong>。</div>' +
|
||||
'<div class="mp-actions">' +
|
||||
'<button class="primary" data-act="switch">切到该项目</button>' +
|
||||
'<button data-act="keep">保留当前项目</button></div>';
|
||||
badge.appendChild(pop);
|
||||
pop.querySelector('[data-act="switch"]').onclick = () => {
|
||||
draftProject = activeProject;
|
||||
applyProjectContext();
|
||||
renderProjectSwitcher();
|
||||
updateUploadBar();
|
||||
refreshSessions();
|
||||
badge.hidden = true;
|
||||
badge.replaceChildren();
|
||||
};
|
||||
pop.querySelector('[data-act="keep"]').onclick = () => {
|
||||
try {
|
||||
skipped[sid] = activeProject;
|
||||
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
|
||||
} catch (er) { /* ignore */ }
|
||||
badge.hidden = true;
|
||||
badge.replaceChildren();
|
||||
};
|
||||
}
|
||||
pop.classList.toggle('open');
|
||||
};
|
||||
// 点击外部关闭 popover
|
||||
document.addEventListener('click', function _closeMismatch(e) {
|
||||
if (!badge.contains(e.target)) {
|
||||
const p = badge.querySelector('.mismatch-popover');
|
||||
if (p) p.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 选项目 A 创建一个会话
|
||||
2. 切换顶栏项目为 B
|
||||
3. 顶栏出现黄色徽章 `⚠ 项目不匹配:A ≠ B`
|
||||
4. 点击徽章 → 弹出 popover,含「切到该项目」和「保留当前项目」按钮
|
||||
5. 点击「切到该项目」→ draftProject 变 A,徽章消失
|
||||
6. 重新切换到 B → 徽章再次出现
|
||||
7. 点击「保留当前项目」→ 徽章消失;刷新页面不再提示
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.html \
|
||||
src/genesis/server/static/chat.js \
|
||||
src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 顶栏项目不匹配徽章化(popover 切/保留)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 交互改造 - 快捷键 Esc + Cmd/Ctrl+K(spec §4.6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.js`(改造 keydown 监听 + 加 Cmd+K)
|
||||
|
||||
- [ ] **Step 1: 找到原 keydown 监听**
|
||||
|
||||
在 `chat.js` 找到原 chat.html:1103 附近的:
|
||||
|
||||
```js
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
|
||||
else if (drawerOpen) closeProjectDrawer();
|
||||
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Esc: 关闭项目切换器 / 抽屉 / popover
|
||||
if (e.key === 'Escape') {
|
||||
// 1. 关闭 mismatch popover
|
||||
document.querySelectorAll('.mismatch-popover.open').forEach(p => p.classList.remove('open'));
|
||||
// 2. 关闭项目切换器
|
||||
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
|
||||
// 3. 关闭抽屉
|
||||
else if (drawerOpen) closeProjectDrawer();
|
||||
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
|
||||
return;
|
||||
}
|
||||
// Cmd/Ctrl + K: 切换项目切换器
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
const switcherBtn = document.getElementById('ps-current');
|
||||
if (switcherBtn) { lastTriggerEl = switcherBtn; toggleProjectSwitcher(); }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.css 新增项目切换器 hover 时显示 `⌘K` 提示(仅 mac)**
|
||||
|
||||
在 `.ps-current` 块之后追加:
|
||||
|
||||
```css
|
||||
.ps-current::after {
|
||||
content: "⌘K";
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity .15s var(--ease);
|
||||
}
|
||||
/* 仅 mac 显示(mac 平台 UA 包含 Mac) */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.ps-current:hover::after { opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
**注**: `@supports` 探测在 mac Safari 上不一定准;简化方案是用 UA 嗅探(JS),但本轮保持纯 CSS,**win/linux 不显示**是符合 spec §四-6 的("win/linux 省略")。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 按 `Cmd+K`(mac)或 `Ctrl+K`(win/linux)→ 项目切换器打开
|
||||
2. 再按 `Cmd+K` → 项目切换器关闭
|
||||
3. 按 `Esc` → 切换器关闭
|
||||
4. 抽屉打开时按 `Esc` → 抽屉关闭
|
||||
5. 焦点在输入框时按 `Cmd+K` → 切换器仍能打开
|
||||
6. 徽章 popover 打开时按 `Esc` → popover 关闭
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 快捷键 Esc + Cmd/Ctrl+K(项目切换器)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 交互改造 - 骨架屏 + 空状态快捷指令 + 错误分级(spec §4.7 / §4.8 / §五)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/genesis/server/static/chat.js`(新增 `renderSkeleton` / `renderEmptyQuickActions` / `addInfo` / `addWarn`,改造启动 IIFE 和 `renderEmptyOrWelcome`)
|
||||
- Modify: `src/genesis/server/static/chat.css`(加 `.skel` / `.quick-actions` 样式)
|
||||
|
||||
- [ ] **Step 1: chat.css 新增骨架屏 + 快捷指令 + 错误分级样式**
|
||||
|
||||
```css
|
||||
/* 骨架屏 */
|
||||
.skel { display: flex; margin: var(--sp-3) 0; gap: 10px; }
|
||||
.skel-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--bg-elevated); flex-shrink: 0; }
|
||||
.skel-bars { flex: 1; display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||
.skel-bar {
|
||||
height: 12px; border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--bg-elevated) 0%, var(--border-strong) 50%, var(--bg-elevated) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skelShimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skel-bar:nth-child(1) { width: 60%; }
|
||||
.skel-bar:nth-child(2) { width: 80%; }
|
||||
.skel-bar:nth-child(3) { width: 45%; }
|
||||
@keyframes skelShimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 空状态快捷指令 */
|
||||
.quick-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.quick-actions button {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur) var(--ease);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.quick-actions button:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 12px var(--accent-soft); }
|
||||
|
||||
/* 错误分级 */
|
||||
.msg.info .bubble { background: rgba(96,165,250,.12); border: 1px solid var(--info); color: var(--info); }
|
||||
.msg.warn .bubble { background: rgba(251,191,36,.12); border: 1px solid var(--warning); color: var(--warning); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: chat.js 新增 `renderSkeleton` / `renderEmptyQuickActions` / `addInfo` / `addWarn`**
|
||||
|
||||
在 `chat.js` 中(任意位置)追加:
|
||||
|
||||
```js
|
||||
function renderSkeleton() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'skeleton';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'skel';
|
||||
row.innerHTML = '<div class="skel-avatar"></div><div class="skel-bars"><div class="skel-bar"></div><div class="skel-bar"></div><div class="skel-bar"></div></div>';
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
chatEl.appendChild(wrap);
|
||||
// 5 秒超时
|
||||
setTimeout(() => { const s = document.getElementById('skeleton'); if (s) s.remove(); }, 5000);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function removeSkeleton() {
|
||||
const s = document.getElementById('skeleton');
|
||||
if (s) s.remove();
|
||||
}
|
||||
|
||||
function renderEmptyQuickActions() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'quick-actions';
|
||||
const actions = [
|
||||
{ label: '上传要件定义', act: () => document.getElementById('file-input').click() },
|
||||
{ label: '查看项目状态', act: () => send('现在什么状态?') },
|
||||
{ label: '开始生成概要设计书', act: () => send('生成概要设计书') },
|
||||
];
|
||||
for (const a of actions) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = a.label;
|
||||
btn.onclick = a.act;
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function addInfo(text) { return addMsg('info', esc(text), text); }
|
||||
function addWarn(text) { return addMsg('warn', esc(text), text); }
|
||||
function addError(text) { return addMsg('error', esc(text), text); }
|
||||
```
|
||||
|
||||
**注意**: 原 chat.js 中所有 `addMsg('error', ...)` 调用**保留不变**(已通过新签名 `addMsg(role, html, text)` 兼容)。`addError` 是 helper 便于未来替换,但本轮不强制替换。
|
||||
|
||||
- [ ] **Step 3: 改造启动 IIFE**
|
||||
|
||||
找到启动 IIFE(`(async () => {` 开头),把:
|
||||
|
||||
```js
|
||||
if (saved) {
|
||||
addMsg('assistant', '正在恢复上次会话…');
|
||||
try { await loadSession(saved); ... }
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
if (saved) {
|
||||
renderSkeleton();
|
||||
try { await loadSession(saved); removeSkeleton(); ... }
|
||||
catch (e) { removeSkeleton(); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 改造 `renderEmptyOrWelcome`**
|
||||
|
||||
找到:
|
||||
|
||||
```js
|
||||
function renderEmptyOrWelcome() {
|
||||
chatEl.innerHTML = '';
|
||||
addMsg('assistant', GenesisState.buildWelcome(draftProject));
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```js
|
||||
function renderEmptyOrWelcome() {
|
||||
chatEl.innerHTML = '';
|
||||
const welcome = GenesisState.buildWelcome(draftProject);
|
||||
const m = addMsg('assistant', esc(welcome), welcome);
|
||||
// 有项目时显示快捷指令
|
||||
if (draftProject) {
|
||||
m.querySelector('.bubble').appendChild(renderEmptyQuickActions());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
启动服务器,浏览器:
|
||||
1. 清空 localStorage 刷新 → 无项目时显示欢迎语 + **没有**快捷指令
|
||||
2. 选一个项目(无 sid)→ 显示欢迎语 + 3 个快捷指令按钮
|
||||
3. 点击「上传要件定义」→ 触发文件选择对话框
|
||||
4. 点击「开始生成概要设计书」→ 自动 `send('生成概要设计书')`
|
||||
5. localStorage 留有上次会话 → 刷新 → 显示 3 行骨架屏 → 加载完成骨架消失
|
||||
6. 手动断网触发 `addMsg('error', '请求失败')`(断网后 send)→ 错误气泡用红边红字
|
||||
7. 触发 `addWarn('类型不匹配')` → 黄色边
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
|
||||
git commit -m "feat(chat): 骨架屏 + 空状态快捷指令 + 错误分级(info/warn/error)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 文档同步 + 端到端回归
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design.md`(追加"前端 UI 设计规范"一节)
|
||||
- Modify: `_AI_USAGE_LOG.md`(追加 3 条记录)
|
||||
- Test: 手动回归清单(spec §七)
|
||||
|
||||
- [ ] **Step 1: 读 docs/design.md 现有结构**
|
||||
|
||||
```bash
|
||||
# 用 read 工具读 docs/design.md 末尾 100 行,确认追加位置
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 docs/design.md 末尾追加「前端 UI 设计规范」**
|
||||
|
||||
在文件末尾追加:
|
||||
|
||||
```markdown
|
||||
## 前端 UI 设计规范(2026-08-30)
|
||||
|
||||
### 设计系统
|
||||
|
||||
- **风格**:深色指挥中心 + 文档工作台混合
|
||||
- **强调色**:`#5EEAD4`(青绿,唯一)
|
||||
- **背景色阶**:`--bg-base: #0B0F17` / `--bg-surface: #141A24` / `--bg-elevated: #1B2330` / `--bg-input: #0F141C`
|
||||
- **文本色阶**:`--text-primary: #E5EAF0` / `--text-secondary: #8B95A7` / `--text-muted: #5C6677`
|
||||
- **边框色阶**:`--border-subtle: rgba(255,255,255,.08)` / `--border-strong: rgba(255,255,255,.14)`
|
||||
- **状态色**:`--success: #34D399` / `--warning: #FBBF24` / `--danger: #F87171` / `--info: #60A5FA`
|
||||
- **字体策略**:系统字体栈(不引入 Google Fonts / JetBrains Mono)
|
||||
- UI:`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif`
|
||||
- 等宽:`ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`
|
||||
- **间距阶梯**:4 / 8 / 12 / 16 / 20 / 24
|
||||
- **圆角阶梯**:6 (sm) / 10 (md) / 14 (lg) / 9999 (胶囊)
|
||||
- **动效**:`cubic-bezier(.2, 0, 0, 1)` × `0.18s`
|
||||
|
||||
### 文件结构
|
||||
|
||||
- `src/genesis/server/static/chat.html`:仅 DOM 结构
|
||||
- `src/genesis/server/static/chat.css`:所有视觉样式(约 600 行)
|
||||
- `src/genesis/server/static/chat.js`:业务逻辑 + 8 项交互(约 900 行)
|
||||
- `chat_state.js` / `chat_ws.js`:协议层,不动
|
||||
|
||||
### 兼容约束
|
||||
|
||||
- 所有 DOM id 保留(业务逻辑依赖)
|
||||
- 所有 localStorage / sessionStorage 键保留
|
||||
- 所有 API 端点不变
|
||||
- 移动端改造(< 768px 抽屉化)不在本轮
|
||||
- 快捷键仅 Esc + Cmd/Ctrl+K
|
||||
- 消息气泡"重新生成"按钮不在本轮
|
||||
|
||||
### 设计规范来源
|
||||
|
||||
完整 spec:`docs/superpowers/specs/2026-08-30-frontend-beautify-design.md`
|
||||
实施计划:`docs/superpowers/plans/2026-08-30-frontend-beautify.md`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 _AI_USAGE_LOG.md 追加 3 条记录**
|
||||
|
||||
读 `_AI_USAGE_LOG.md` 末尾 20 行确认格式,然后追加(按 AGENTS.md 字段):
|
||||
|
||||
```markdown
|
||||
| 2026-08-30 16:00 | 架构设计 | 完成前端美化设计 spec(深色指挥中心 + 文档工作台) | docs/superpowers/specs/2026-08-30-frontend-beautify-design.md | minimax/minimax-m3:free |
|
||||
| 2026-08-30 17:00 | Agent 实现 | 实施 chat.html 拆分 + chat.css / chat.js 改写 + 8 项交互 | src/genesis/server/static/chat.html, chat.css, chat.js | minimax/minimax-m3:free |
|
||||
| 2026-08-30 18:00 | 反馈迭代 | 同步 docs/design.md + _AI_USAGE_LOG.md | docs/design.md, _AI_USAGE_LOG.md | minimax/minimax-m3:free |
|
||||
```
|
||||
|
||||
**注意**: 日期时间字段需在实施时按实际填,先用占位时间,commit 前修正。
|
||||
|
||||
- [ ] **Step 4: 端到端手动回归(spec §七清单)**
|
||||
|
||||
按 spec §七的 7 个流程依次在浏览器手测,全部通过后方可 commit:
|
||||
|
||||
1. 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
|
||||
2. 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
|
||||
3. 拖拽上传 xlsx → 自动选 `requirements` → 上传成功
|
||||
4. 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
|
||||
5. hover 助手气泡 → 复制 → 粘贴验证
|
||||
6. Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
|
||||
7. 启动恢复会话时显示骨架屏 → 加载完成消失
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add docs/design.md _AI_USAGE_LOG.md
|
||||
git commit -m "docs(frontend): 同步前端 UI 设计规范 + AI 使用日志"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage**:
|
||||
- §零决策(10 条)→ 全部贯穿 Task 1-10 ✓
|
||||
- §一设计令牌 → Task 2 Step 1 ✓
|
||||
- §二六大区域 → Task 2 Step 3-9 ✓
|
||||
- §三关键不变量 → 始终约束;本 plan 严格保持 DOM id / 键 / 端点不变 ✓
|
||||
- §四-1 分组 → Task 3 ✓
|
||||
- §四-2 拖拽 → Task 4 ✓
|
||||
- §四-3 气泡操作(无"重新生成")→ Task 5 ✓
|
||||
- §四-4 多行输入 → Task 6 ✓
|
||||
- §四-5 顶栏徽章 → Task 7 ✓
|
||||
- §四-6 快捷键 Esc+Cmd+K → Task 8 ✓
|
||||
- §四-7 骨架屏 → Task 9 ✓
|
||||
- §四-8 空状态快捷指令 → Task 9 ✓
|
||||
- §五错误分级 → Task 9 ✓
|
||||
- §六错误处理 → 全部 Task 包含(try/catch / 静默降级)✓
|
||||
- §七测试策略 → Task 10 Step 4 手动回归 ✓
|
||||
- §九风险与回滚 → Global Constraints + 提交粒度可控 ✓
|
||||
- §十里程碑外(不做项)→ 在 §零-8/9/10 决策 + Task 2/5/8 范围约束 ✓
|
||||
|
||||
2. **占位符扫描**:
|
||||
- 全文无 TBD / TODO / "implement later" / "similar to Task N";
|
||||
- 所有代码块均给出完整实现(CSS / JS / HTML)✓
|
||||
|
||||
3. **类型 / 签名一致性**:
|
||||
- `addMsg(role, html, text)` 在 Task 5 定义,Task 9 的 `addInfo`/`addWarn`/`addError` 沿用 ✓
|
||||
- `bindUploadDrag` / `bindComposer` / `autoResizeTextarea` / `renderSkeleton` / `renderEmptyQuickActions` 命名一致 ✓
|
||||
- `GenesisState` 协议(`buildWelcome` / `autoBindProject` / `resolveUploadType`)未改 ✓
|
||||
|
||||
4. **未覆盖但可接受的点**:
|
||||
- Task 5 Step 3 的"逐个修改 addMsg 调用点"依赖 grep 工具列清单;本 plan 不展开所有 8 个 call site(chat.html 内联段已完整复制到 chat.js,可直接 grep 替换)。
|
||||
- Task 7 Step 3 的 `badge.onclick` 内绑定 `document.addEventListener('click', ...)` 在每次 `renderProjectMismatchHint` 调用时**会重复添加**全局监听;本轮在每次调用前**未清理旧监听**,会有多个监听累积。**改进**: 在 chat.js 启动 IIFE 内一次性绑定全局 click 监听,匹配 `.mismatch-badge` 时关闭 popover。Task 7 实施时若发现此问题,按此建议修复。
|
||||
- Task 9 Step 4 的 `m.querySelector('.bubble').appendChild(renderEmptyQuickActions())` 假设 `.bubble` 存在;`addMsg` 已保证创建 `.bubble` ✓
|
||||
|
||||
5. **本 plan 范围外**(在 spec §十 列出,不在本 plan 实现):
|
||||
- 移动端 < 768px 断点改造
|
||||
- 快捷键 `Cmd+Shift+O` / `Cmd+Shift+S`
|
||||
- 消息气泡"重新生成"按钮
|
||||
- dark/light 主题切换
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-08-30-frontend-beautify.md`.**
|
||||
|
||||
包含 10 个 Task:
|
||||
- Task 1:拆分 chat.html(机械拆分,行为零变更)
|
||||
- Task 2:视觉重塑(chat.css 深色主题)
|
||||
- Task 3:会话分组
|
||||
- Task 4:拖拽上传
|
||||
- Task 5:气泡复制/引用
|
||||
- Task 6:多行输入
|
||||
- Task 7:项目不匹配徽章
|
||||
- Task 8:快捷键 Esc+Cmd+K
|
||||
- Task 9:骨架屏 + 空状态 + 错误分级
|
||||
- Task 10:文档 + 手动回归
|
||||
|
||||
**两个执行选项**:
|
||||
|
||||
1. **Subagent-Driven(推荐)** —— 我为每个 Task 派遣一个全新的子代理,Task 之间做 review,快速迭代
|
||||
2. **Inline Execution** —— 在当前会话中按 executing-plans 执行,带 checkpoint
|
||||
|
||||
**选哪个?**
|
||||
@@ -0,0 +1,367 @@
|
||||
# 前端美化与交互改造设计(2026-08-30)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
当前前端(`src/genesis/server/static/chat.html`,单文件 1215 行内联 CSS+JS)沿用 Material/Google 风
|
||||
(`#1a73e8` 蓝、Inter、胶囊按钮、扁平阴影),功能完整但视觉同质化严重,与"概要设计书自动生成
|
||||
Agent"的项目定位缺少记忆点。
|
||||
|
||||
本次迭代在**保持现有功能、API 协议、localStorage 键、所有 DOM id 完全不变**的前提下,对整套前端
|
||||
做视觉重塑 + 8 项交互改善,达成:
|
||||
|
||||
1. 视觉差异化(深色指挥中心 + 文档工作台混合风格,单一青绿强调色)
|
||||
2. 体感现代化(拖拽、多行输入、消息气泡操作、骨架屏、空状态快捷指令)
|
||||
3. 架构可维护(chat.html 拆为 chat.html + chat.css + chat.js,chat_state.js / chat_ws.js 不动)
|
||||
|
||||
## 用户决策(brainstorming 已确认)
|
||||
|
||||
| # | 决策项 | 选定 |
|
||||
|---|---|---|
|
||||
| 1 | 视觉风格 | 深色指挥中心 + 文档工作台混合 |
|
||||
| 2 | 强调色 | 青绿 `#5EEAD4` |
|
||||
| 3 | 字体策略 | 系统字体栈(不引入 Google Fonts / JetBrains Mono) |
|
||||
| 4 | 抽屉遮罩 | `backdrop-filter: blur(8px)` |
|
||||
| 5 | 文件拆分 | B2:chat.html 拆为 chat.html + chat.css + chat.js |
|
||||
| 6 | 交互改造 | 本轮做 8 项(详见 §四) |
|
||||
| 7 | 快捷键 | 只做 Esc + Cmd/Ctrl+K |
|
||||
| 8 | 移动端改造 | **不做**(留到下个迭代) |
|
||||
| 9 | 消息气泡"重新生成" | **不做**(避免触发后端协议变更) |
|
||||
| 10 | 主题切换 | 单主题(无 dark/light 切换) |
|
||||
|
||||
## 范围
|
||||
|
||||
### 做
|
||||
|
||||
- 视觉重塑(六大区域,颜色 / 字体 / 间距 / 圆角 / 动效)
|
||||
- chat.html 拆分为 chat.html + chat.css + chat.js
|
||||
- 8 项交互改善(见 §四)
|
||||
- 同步 `docs/design.md` 增加"前端 UI 设计规范"一节
|
||||
- 追加 `_AI_USAGE_LOG.md` 三条记录(设计 / 实现 / 文档同步)
|
||||
|
||||
### 不做
|
||||
|
||||
- 移动端侧边栏抽屉化(断点 < 768px 的改造)
|
||||
- 快捷键 `Cmd+Shift+O` / `Cmd+Shift+S`
|
||||
- 消息气泡"重新生成"按钮
|
||||
- 主题切换(dark/light)
|
||||
- 后端 API 协议变更
|
||||
- localStorage / sessionStorage 键重命名
|
||||
- DOM id 变更
|
||||
|
||||
## 架构
|
||||
|
||||
### 文件结构(B2 拆分后)
|
||||
|
||||
```
|
||||
src/genesis/server/static/
|
||||
├── chat.html # 仅 DOM 结构
|
||||
├── chat.css # 新增:所有视觉样式
|
||||
├── chat.js # 新增:原 chat.html 中 <script> 段(约 700 行)
|
||||
├── chat_state.js # 不动
|
||||
└── chat_ws.js # 不动
|
||||
```
|
||||
|
||||
### chat.html 改动
|
||||
|
||||
- 头部:删除 `<link rel="preconnect">` 与 Google Fonts 的 `<link>`
|
||||
- 头部:新增 `<link rel="stylesheet" href="/chat.css">`
|
||||
- 头部末尾(`<body>` 之前):保持空白
|
||||
- 主体:删除第 10-392 行 `<style>...</style>` 整段
|
||||
- 主体:保留所有 DOM 节点 id(业务逻辑依赖)
|
||||
- 末尾:删除第 509-1213 行 `<script>...</script>` 整段
|
||||
- 末尾:新增 `<script src="/chat.js" defer></script>`
|
||||
- 末尾:保留 `<script src="/chat_state.js">` 与 `<script src="/chat_ws.js">` 在 chat.js 之前
|
||||
|
||||
### chat.js 改造
|
||||
|
||||
- 整体结构保持不变(DOMContentLoaded 后绑定事件 + 启动恢复)
|
||||
- 新增函数:`groupSessions`、`bindUploadDrag`、`attachBubbleOps`、`autoResizeTextarea`、
|
||||
`renderProjectMismatchBadge`、`renderSkeleton`、`renderEmptyQuickActions`、
|
||||
`addInfo`、`addWarn`、`bindKeyboardShortcuts`
|
||||
- 保持函数签名与原内联版本一致(避免 chat_state.js 协议变化)
|
||||
|
||||
## 一、设计令牌(chat.css 顶部 `:root`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* === 颜色 === */
|
||||
--bg-base: #0B0F17;
|
||||
--bg-surface: #141A24;
|
||||
--bg-elevated: #1B2330;
|
||||
--bg-input: #0F141C;
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text-primary: #E5EAF0;
|
||||
--text-secondary: #8B95A7;
|
||||
--text-muted: #5C6677;
|
||||
--accent: #5EEAD4;
|
||||
--accent-soft: rgba(94, 234, 212, 0.15);
|
||||
--success: #34D399;
|
||||
--warning: #FBBF24;
|
||||
--danger: #F87171;
|
||||
--info: #60A5FA;
|
||||
|
||||
/* === 字体 === */
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
|
||||
/* === 间距 === */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
|
||||
|
||||
/* === 圆角 === */
|
||||
--r-sm: 6px; --r-md: 10px; --r-lg: 14px;
|
||||
|
||||
/* === 动效 === */
|
||||
--ease: cubic-bezier(.2, 0, 0, 1);
|
||||
--dur: .18s;
|
||||
}
|
||||
```
|
||||
|
||||
## 二、各区域视觉规约
|
||||
|
||||
### 2.1 侧边栏(`#sidebar`)
|
||||
|
||||
- 底色 `--bg-surface`,右 1px `--border-subtle`
|
||||
- 折叠按钮:从右外侧浮出的小竖条,hover 时变 `--accent`,**不再旋转 180°**,
|
||||
改为左右 chevron(`«` / `»`)切换
|
||||
- 会话项 hover:背景 `--bg-elevated`,左侧 2px `--accent` 竖条
|
||||
- 会话项 active:左侧 2px `--accent` 竖条 + 背景 `--bg-elevated`
|
||||
- 折叠态:去掉头像逻辑,改成 4px 圆点(项目色 hash);折叠态下分组标题 `display:none`
|
||||
|
||||
### 2.2 顶栏(`header`)
|
||||
|
||||
- 底色 `--bg-surface` + `backdrop-filter: blur(12px)` + 1px `--border-subtle` 底边
|
||||
- 项目切换器:胶囊改为左 6px 圆点(项目色 hash)+ 名字 + chevron,hover 出现 1px `--accent` 边
|
||||
- RAG 胶囊:去掉背景底色,改成 `🔘 0 片段` 风格的等宽小标签
|
||||
- 已索引:实心圆点 + `--accent` 色 + 等宽数字
|
||||
- 未索引:空心圆点 + `--text-muted`
|
||||
- 会话徽标:窄屏下不再 `display:none`,限制 `max-width: 32%` + `text-overflow: ellipsis`
|
||||
|
||||
### 2.3 聊天区(`#chat`)
|
||||
|
||||
- 主区底色 `--bg-base`,内边距 `var(--sp-6)`
|
||||
- 气泡:去掉阴影,改用 1px 细边 + 内部留白
|
||||
- **用户消息**:`--bg-surface` + 左侧 2px `--accent` 竖条 + 字号 14,行高 1.65
|
||||
- **助手消息**:`--bg-surface` + 1px `--border-subtle` + 顶头 6px 圆点(`--accent` 静态)
|
||||
- **进度消息**:`--bg-input` + dashed 边 `--accent-soft`,等宽字体
|
||||
- 思考中态:3 个等宽方块循环亮起(替代当前 `思考中…` 文案),用 `--accent` 色
|
||||
- 消息出现动画:保留 `zoomIn`,幅度从 0.97 → 0.98
|
||||
|
||||
### 2.4 上传条(`#upload-bar`)
|
||||
|
||||
- 底色 `--bg-surface`,与主区用 1px `--border-subtle` 分隔
|
||||
- 升级为可拖入区域(详见 §四-2):dragover 时整条出现 dashed `--accent` 边 + `accent-soft` 底色
|
||||
- select / button 深色样式:底色 `--bg-input`,focus 时 `0 0 0 2px var(--accent-soft)`
|
||||
- 项目提示(`#proj-hint`)改为左侧 4px 三角指示条 + inline note
|
||||
|
||||
### 2.5 输入区(`#composer`)
|
||||
|
||||
- 底色 `--bg-surface`,顶 1px `--border-subtle`
|
||||
- `<input id="input">` 升级为 `<textarea id="input" rows="1">`(详见 §四-4)
|
||||
- send 按钮:底色 `--accent`,文字 `--bg-base`,hover 加 `accent-soft` 外发光
|
||||
|
||||
### 2.6 项目抽屉(`#proj-drawer`)
|
||||
|
||||
- 整体 `--bg-surface`,背后遮罩 `rgba(11,15,23,.6)` + `backdrop-filter: blur(8px)`
|
||||
- 左侧列表项:active 时左侧 2px `--accent` 竖条 + 背景 `--bg-elevated`
|
||||
- 表单输入:深色底 `--bg-input`,focus 时 `0 0 0 2px var(--accent-soft)`
|
||||
- 「高级」details 改为纯线框展开,无背景填充
|
||||
- 关闭按钮:hover 红色描边(保留现有语义)
|
||||
|
||||
## 三、关键不变量(兼容性保证)
|
||||
|
||||
### 3.1 必须保留的 DOM id(业务逻辑依赖)
|
||||
|
||||
```
|
||||
sidebar, sidebar-toggle, brand-logo, brand-name, new-chat, session-list,
|
||||
main, rag-bar, rag-enabled, rag-stats, impact-btn, sid-badge,
|
||||
proj-switcher, ps-current, ps-name, ps-menu, chat, upload-bar, file-type,
|
||||
file-input, upload-btn, upload-status, proj-hint, composer, input, send,
|
||||
proj-drawer, drawer-list, drawer-form, drawer-close, pf-name, pf-display,
|
||||
pf-template, pf-write, pf-rules, pf-code, pf-design, pf-save, pf-delete
|
||||
```
|
||||
|
||||
### 3.2 必须保留的全局符号
|
||||
|
||||
- `window.GenesisState`(由 chat_state.js 提供,chat.js 通过回退默认值兜底)
|
||||
- `window.GenesisWS`(由 chat_ws.js 提供,chat.js 通过 `connectProgressWs` / `applyProgressEvent` 消费)
|
||||
|
||||
### 3.3 必须保留的 localStorage / sessionStorage 键
|
||||
|
||||
- `genesis_sidebar_collapsed`
|
||||
- `genesis_session`
|
||||
- `genesis_rag_enabled`
|
||||
- `genesis_skipped_project_hint`(sessionStorage)
|
||||
|
||||
新增键(无冲突):
|
||||
|
||||
- `genesis_session_group_collapsed`(分组折叠状态,本轮默认全展开,不实际写入)
|
||||
|
||||
### 3.4 必须保留的 API 端点
|
||||
|
||||
所有 `/api/...` 调用与请求体格式不变。
|
||||
|
||||
## 四、交互改造(8 项)
|
||||
|
||||
### 4.1 会话列表按时间分组
|
||||
|
||||
- **位置**:`refreshSessions()` 内,对 `list.sort(...)` 之后插入分组
|
||||
- **分组规则**:用 `updated_at` 字符串前缀(`YYYY-MM-DD`)与今天/昨天日期比较
|
||||
- 今天 / 昨天 / 本周(7 天内)/ 本月 / 更早
|
||||
- **DOM**:在 `.sess` 之间插入 `<div class="sess-group">今天</div>`,样式:
|
||||
`font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; color: var(--text-muted)`
|
||||
- **不影响**:折叠态下分组标题 `display:none`;分组内 `.sess` 仍可正常点击
|
||||
- **行为**:分组标题不可点击,不参与折叠按钮 toggle
|
||||
|
||||
### 4.2 拖拽上传
|
||||
|
||||
- **位置**:`#upload-bar` 监听 `dragenter / dragover / dragleave / drop`
|
||||
- **行为**:
|
||||
- `dragover`:`upload-bar` 加 `.drag-active` 类(dashed `--accent` 边 + `accent-soft` 底色)
|
||||
- `dragleave`:`upload-bar` 移除 `.drag-active`
|
||||
- `drop`:取 `e.dataTransfer.files[0]`,写入 `#file-input.files`,并**按后缀自动选 `file-type`**:
|
||||
- `.xlsx` → `requirements`
|
||||
- `.docx` → `template`(缺省兜底)
|
||||
- `.zip` → `existing_system`
|
||||
- **安全**:复用现有 `projectHasPrefixedType` 与 `UPLOAD_EXT_RULES` 校验
|
||||
- **拖入整个文件夹**:`webkitGetAsEntry` 拒绝并提示(仅取第一个文件)
|
||||
|
||||
### 4.3 消息气泡操作
|
||||
|
||||
- **触发**:
|
||||
- **用户气泡**:hover 时右上角浮出 `↺ 引用` 按钮(点击把消息文本填到 `#input` 并 focus)
|
||||
- **助手气泡**:hover 时右上角浮出 `⧉ 复制` 按钮
|
||||
- **不实现**:`↻ 重新生成` 按钮(见 §零-9 决策)
|
||||
- **实现**:
|
||||
- CSS:`.msg { position: relative; } .msg .ops { position: absolute; top: -10px; opacity: 0; ... }`
|
||||
`:hover .ops { opacity: 1; }`
|
||||
- JS:扩展 `addMsg()`,内部 appendChild `<div class="ops">...</div>`;用户/助手不同
|
||||
- **复制**:用 `navigator.clipboard.writeText(text)`,无 HTTPS 时 fallback
|
||||
`document.execCommand('copy')`(同步临时 textarea)
|
||||
- **不存储历史**:仅影响本次会话 UI
|
||||
|
||||
### 4.4 输入区多行 + 自动高度
|
||||
|
||||
- **位置**:`<input id="input">` 改为 `<textarea id="input" rows="1">`
|
||||
- **行为**:
|
||||
- `input` 事件:`element.style.height = 'auto'; element.style.height = Math.min(scrollHeight, 160) + 'px'`
|
||||
- `keydown`:`Enter`(无 Shift 且无 IME 组合中)= 发送;`Shift+Enter` = 换行
|
||||
- 发送后自动重置高度为 `auto`(下一帧设回 24px)
|
||||
- **保留**:现有 `maxlength=2000`
|
||||
- **IME 兼容**:keydown 中检查 `e.isComposing`(中文输入法组合中按 Enter 不应发送)
|
||||
|
||||
### 4.5 顶栏项目不匹配徽章化
|
||||
|
||||
- **位置**:在 `#rag-bar` 之前插入 `<span id="proj-mismatch-badge" hidden>`
|
||||
- **行为**:
|
||||
- 替代现有 `renderProjectMismatchHint()` 的聊天区插入逻辑
|
||||
- 徽章样式:黄色 1px 边 `--warning` + 圆角 `--r-md` + `⚠` 前缀 +
|
||||
"项目: stock ≠ 草稿: foo" 文本
|
||||
- 点击展开一个迷你 popover(不打开抽屉),提供"切到该项目"和"保留"两个按钮
|
||||
- "保留"逻辑沿用现有 `sessionStorage` 标记(`genesis_skipped_project_hint`)
|
||||
- **不删除旧函数**:保留 `renderProjectMismatchHint` 但改为只调用新徽章渲染函数
|
||||
|
||||
### 4.6 快捷键(仅 2 个)
|
||||
|
||||
- **Esc**:已有,扩展为同时关闭项目切换器 + 抽屉 + 移动端侧边栏(本轮不做移动端,等价无变化)
|
||||
- **Cmd/Ctrl + K**:切换 `#ps-menu`(与点击 `#ps-current` 行为一致),焦点在输入框时也能用
|
||||
- **实现位置**:`document.addEventListener('keydown', ...)`,沿用现有 Escape 监听并扩展
|
||||
- **可视提示**:项目切换器 hover 时显示 `⌘K`(CSS `::after`,仅 mac 显示,win/linux 省略)
|
||||
|
||||
### 4.7 骨架屏
|
||||
|
||||
- **位置**:启动时(`localStorage` 有 `genesis_session` 时)替换
|
||||
`addMsg('assistant', '正在恢复上次会话…')`
|
||||
- **样式**:3 条消息形状的占位(圆形 8px + 矩形条 `--bg-surface`),用
|
||||
`linear-gradient` 做 `@keyframes shimmer` 1.4s 循环(亮色 `--bg-elevated` 与
|
||||
`--bg-surface` 交替)
|
||||
- **逻辑**:`loadSession()` 完成后 `skeleton.remove()`,最多 5 秒超时兜底
|
||||
- **超时处理**:`setTimeout(5000)` 强制 remove 并显示错误提示
|
||||
|
||||
### 4.8 空状态快捷指令
|
||||
|
||||
- **位置**:`renderEmptyOrWelcome()` 中,无 `sid` 且 `draftProject` 已选时
|
||||
- **内容**:欢迎气泡 + 3 个胶囊按钮:
|
||||
- "上传要件定义"(点击触发 `#file-input` click)
|
||||
- "查看项目状态"(直接 `send('现在什么状态?')`)
|
||||
- "开始生成概要设计书"(`send('生成概要设计书')`)
|
||||
- **样式**:胶囊 `--bg-elevated` 底 + 1px `--border-subtle` 边,
|
||||
hover 出现 `--accent` 边
|
||||
- **不显示**:`draftProject` 为 `null` 时(保持现有欢迎语)
|
||||
|
||||
## 五、错误分级样式(4.10)
|
||||
|
||||
- **位置**:扩展 `addMsg()`,新增可选 `level` 参数:`'info' | 'warn' | 'error'`
|
||||
- **样式**:
|
||||
- `info`:`--info` 1px 边 + `rgba(96,165,250,.12)` 底
|
||||
- `warn`:`--warning` 1px 边 + `rgba(251,191,36,.12)` 底
|
||||
- `error`:`--danger` 1px 边 + `rgba(248,113,113,.12)` 底
|
||||
- **兼容性**:现有所有 `addMsg('error', ...)` 调用不变(默认仍走 error 样式)
|
||||
- **新增助手函数**:`addInfo(text)` / `addWarn(text)`,内部调用 `addMsg('info' / 'warn', text)`
|
||||
- **本轮替换点**(仅本轮改造涉及的错误,不全量替换):
|
||||
- 上传类型不匹配 → `addWarn`(客户端校验,非后端失败)
|
||||
- 项目名不能为空 → `addWarn`(客户端校验)
|
||||
- 加载会话失败 → `addError`(现有)
|
||||
- 网络/服务器错误 → `addError`(现有)
|
||||
|
||||
## 六、错误处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| chat.css / chat.js 加载失败(404/网络) | 页面降级显示,肉眼可见(无样式白板)。可在 README 提示开发者 |
|
||||
| Google Fonts 已无 link,不会再失败 | N/A |
|
||||
| localStorage 不可用(隐私模式) | 沿用现有 try/catch,输出到 `console.warn` 而非 `addError` |
|
||||
| 拖拽 API 不可用(旧浏览器) | `if (!('draggable' in document.createElement('div')) return;` 静默退出 |
|
||||
| IME 中文输入中按 Enter | 走 `e.isComposing` 检查,**不发送**,保持原换行 |
|
||||
| 快捷键冲突(浏览器原生 Cmd+K) | 始终 `e.preventDefault()`,已知会拦截 Firefox 切搜索栏 |
|
||||
|
||||
## 七、测试策略
|
||||
|
||||
由于本轮为**纯前端样式与交互改造**,且不新增可被 pytest 覆盖的逻辑,采用:
|
||||
|
||||
1. **手动回归清单**(见 `docs/superpowers/specs/2026-08-30-frontend-beautify-checklist.md`,
|
||||
本 spec 不展开)
|
||||
2. **截图对比**(实施前用浏览器截 5 张关键页面对照,实施后比对)
|
||||
3. **关键流程脚本测试**(用户手动跑通):
|
||||
- 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
|
||||
- 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
|
||||
- 拖拽上传 xlsx → 自动选 `requirements` → 上传成功
|
||||
- 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
|
||||
- hover 助手气泡 → 复制 → 粘贴验证
|
||||
- Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
|
||||
- 启动恢复会话时显示骨架屏 → 加载完成消失
|
||||
|
||||
## 八、实施步骤(写作计划阶段会细化)
|
||||
|
||||
1. **建文件骨架**:chat.html 拆分为 chat.html + chat.css + chat.js,机械搬移不改样式
|
||||
2. **写 chat.css 令牌**:`:root` 全部变量
|
||||
3. **重写 chat.css 样式**:六大区域按 §二 改造
|
||||
4. **chat.js 加 8 项交互**:按 §四 / §五 顺序(不依赖外网)
|
||||
5. **本地手动回归**:见 §七
|
||||
6. **同步 docs/design.md**:新增"前端 UI 设计规范"一节
|
||||
7. **更新 _AI_USAGE_LOG.md**:三条记录
|
||||
|
||||
## 九、风险与回滚
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 内联 CSS/JS 拆分影响加载顺序 | `<link>` 放 `<head>`;`<script defer>` 放 `<body>` 末尾;chat_state.js / chat_ws.js 保持同步加载 |
|
||||
| 改 class 破坏后端 | 后端不读 class;仅核对 id 列表(§三-1) |
|
||||
| 系统字体在 win / linux 上回退差异 | 字体栈已含 PingFang / Microsoft YaHei / Noto Sans CJK 兜底 |
|
||||
| backdrop-filter 性能 | 限定在抽屉遮罩 + 顶栏,不滥用 |
|
||||
| localStorage 新增键冲突 | 全部以 `genesis_` 前缀 |
|
||||
| 拖入文件夹行为 | 拒绝并 toast 提示"请拖入单个文件" |
|
||||
|
||||
**回滚**:`git checkout` 上一版本,删除 `chat.css` / `chat.js` 即可。
|
||||
|
||||
## 十、里程碑外(后续)
|
||||
|
||||
- 移动端侧边栏抽屉化(< 768px 断点)
|
||||
- 快捷键 `Cmd+Shift+O`(项目抽屉)/ `Cmd+Shift+S`(侧边栏折叠)
|
||||
- 消息气泡"重新生成"按钮(需后端协议扩展)
|
||||
- 深色 / 浅色主题切换
|
||||
- 国际化(i18n)文案
|
||||
- 设计系统独立文件(`static/design-tokens.css`)供未来复用
|
||||
Reference in New Issue
Block a user