Compare commits
11 Commits
b469662760
...
43b14a94a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 43b14a94a3 | |||
| 1f0c7608f4 | |||
| 6c4d9affae | |||
| c0384f9a07 | |||
| e3a9a6b47f | |||
| dd2835bb94 | |||
| 09845e0515 | |||
| e0466f9b99 | |||
| eccc3f62d2 | |||
| 91ae52320b | |||
| f4318c7643 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -410,6 +410,7 @@ backend/bin
|
||||
backend/server
|
||||
backend/desktop
|
||||
!src/**/*
|
||||
docs/superpowers
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
|
||||
33
AGENTS.md
33
AGENTS.md
@@ -1 +1,32 @@
|
||||
严格遵守openspec/config.yaml中context声明的项目规范
|
||||
## 项目概览
|
||||
|
||||
- 本项目为 Bun 全栈应用(Alfred·阿福),Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
|
||||
- docs/user/ 记录用户使用方法,docs/development/ 记录开发技术细节
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- 本项目无需考虑向前兼容性
|
||||
|
||||
## 文档入口(按顺序阅读)
|
||||
|
||||
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||
- **其次阅读 docs/development/README.md** 获取完整开发规范、常用命令和质量门禁
|
||||
|
||||
## 全局红线
|
||||
|
||||
- 前端禁止导入 src/server/ 的后端运行时实现
|
||||
- 后端运行时代码禁止直接使用 console.\*,通过 Logger 实例输出
|
||||
- 新增逻辑必须编写完善的测试,不允许跳过任何测试
|
||||
- 每次代码变更必须执行文档影响分析(详见 docs/README.md)
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不轻易引入新依赖
|
||||
|
||||
## Git 规范
|
||||
|
||||
- 提交信息中文,格式"类型: 简短描述",类型:feat/fix/refactor/docs/style/test/chore
|
||||
- 禁止创建 git 操作 task
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 积极使用 subagent 并行独立子任务,节省上下文空间;能并行的步骤明确并行
|
||||
- subagent 仅用于只读收集和分析,禁止用于文件修改、代码生成、git 操作或依赖安装
|
||||
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||
- 优先使用提问工具对用户确认
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- **AdminLayout**(`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects`、`/models`、`/models/providers`。
|
||||
- **WorkbenchLayout**(`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId`、`/workbench/:projectId/chat`。`WorkbenchProjectGate` 从 URL 读 projectId,通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
|
||||
|
||||
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig(effectiveTheme)` 集中构建(含 `cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。主题切换已迁移至设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。
|
||||
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts` 的 `buildThemeConfig({ compact, effectiveTheme })` 集中构建(含 `algorithm` 数组组合 `compactAlgorithm`、`cssVar`、`borderRadius`、`controlHeight`、`components.Layout` 配色);侧边栏折叠。主题与紧凑模式切换在设置页(`/settings`)。Header 显示品牌名、版本号和布局标题。
|
||||
|
||||
`Sidebar`(`src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
|
||||
|
||||
@@ -31,7 +31,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
| 总览 | `/` | `features/dashboard/index.tsx` |
|
||||
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar(状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
|
||||
| 模型管理 | `/models` 和 `/models/providers` | 独立路由页面:`ModelListPage.tsx`(FilterToolbar + ModelTable) + `ProviderListPage.tsx`(FilterToolbar + ProviderTable)。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`)。 |
|
||||
| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。当前包含"主题配置"卡片(Segmented 切换系统/明亮/黑暗),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 持久化,悲观更新策略。 |
|
||||
| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。"主题"卡片使用 antd Form 水平布局,包含主题模式(Radio.Group 按钮风:系统/明亮/黑暗)和紧凑模式(Switch 开关),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 实时保存,`message` toast 反馈。 |
|
||||
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
|
||||
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层(selectedId + modalOpen)+ MaterialSidebar(列表容器)+ MaterialDetailPanel(详情容器)+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
|
||||
| 404 | `*` | `features/not-found/index.tsx` |
|
||||
@@ -68,18 +68,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
|
||||
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks) |
|
||||
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook(组件内使用) |
|
||||
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好管理(localStorage + API 同步) |
|
||||
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题与紧凑模式偏好管理(localStorage + API 同步) |
|
||||
| `use-settings` | `shared/hooks/use-settings.ts` | 平台设置读写(react-query: GET/PUT /api/settings) |
|
||||
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
|
||||
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
|
||||
|
||||
### 共享主题配置
|
||||
|
||||
| 文件 | 导出 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `theme/theme-config.ts` | `buildThemeConfig(effectiveTheme)` — 构建 antd ThemeConfig(algorithm、cssVar、token、components.Layout) |
|
||||
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
||||
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
||||
| 文件 | 导出 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfig(algorithm 数组、cssVar、token、components.Layout),compact 时组合 compactAlgorithm 并降低 controlHeight |
|
||||
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
||||
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
||||
|
||||
### 共享工具函数
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
|
||||
"mcp": {
|
||||
"tdesign-mcp-server": {
|
||||
"enabled": true,
|
||||
"type": "local",
|
||||
"command": ["bunx", "tdesign-mcp-server@latest"]
|
||||
},
|
||||
"antd": {
|
||||
"enabled": true,
|
||||
"type": "local",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
schema: fast-drive
|
||||
|
||||
context: |
|
||||
## 项目概览
|
||||
- 本项目为 Bun 全栈应用(Alfred·阿福),Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
|
||||
- docs/user/ 记录用户使用方法,docs/development/ 记录开发技术细节
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- 本项目无需考虑向前兼容性
|
||||
|
||||
## 文档入口(按顺序阅读)
|
||||
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||
- **其次阅读 docs/development/README.md** 获取完整开发规范、常用命令和质量门禁
|
||||
|
||||
## 全局红线
|
||||
- 前端禁止导入 src/server/ 的后端运行时实现
|
||||
- 后端运行时代码禁止直接使用 console.*,通过 Logger 实例输出
|
||||
- 新增逻辑必须编写完善的测试,不允许跳过任何测试
|
||||
- 每次代码变更必须执行文档影响分析(详见 docs/README.md)
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不轻易引入新依赖
|
||||
|
||||
## Git 规范
|
||||
- 提交信息中文,格式"类型: 简短描述",类型:feat/fix/refactor/docs/style/test/chore
|
||||
- 禁止创建 git 操作 task
|
||||
|
||||
## 工作方式
|
||||
- 积极使用 subagent 并行独立子任务,节省上下文空间;能并行的步骤明确并行
|
||||
- subagent 仅用于只读收集和分析,禁止用于文件修改、代码生成、git 操作或依赖安装
|
||||
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||
- 优先使用提问工具对用户确认
|
||||
|
||||
rules:
|
||||
design:
|
||||
- fast-drive的design.md章节标题和正文使用中文;仅OpenSpec术语、文件名、schema字段名、命令和代码符号保留英文
|
||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||
tasks:
|
||||
- fast-drive的tasks.md分组标题、任务描述和验证说明使用中文;每个任务必须保留OpenSpec CLI可解析的单行checkbox格式
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档
|
||||
@@ -1,150 +0,0 @@
|
||||
name: fast-drive
|
||||
version: 1
|
||||
description: 快速 OpenSpec workflow - design -> tasks -> apply
|
||||
artifacts:
|
||||
- id: design
|
||||
generates: design.md
|
||||
description: 自包含的方案说明和执行计划
|
||||
template: design.md
|
||||
instruction: |
|
||||
创建 design.md,作为本次变更“改什么、为什么改、如何执行”的自包含事实来源。
|
||||
|
||||
本 workflow 不使用 proposal 或 specs artifacts。design.md MUST 保留前序探索和用户讨论中的重要结论,确保后续 apply 阶段即使经历上下文压缩或进入新会话,也能正确继续执行。
|
||||
|
||||
语言规则(强制):
|
||||
|
||||
- fast-drive 的 design.md 使用中文章节标题和中文正文;仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
|
||||
|
||||
- 最终 design.md 不得残留英文模板句子或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
|
||||
|
||||
面向看不到早期对话的人编写。简单变更保持精炼,但必须包含足够细节让执行无歧义。遇到以下情况时增加细节:
|
||||
|
||||
- 跨多个系统、团队、工作流或 artifacts 的横切变更
|
||||
|
||||
- 新增依赖、集成、供应商、工具、策略或外部输入
|
||||
|
||||
- 重要的信息模型、流程模型、数据模型或归属关系变化
|
||||
|
||||
- 涉及安全、隐私、合规、性能、运维或迁移复杂度
|
||||
|
||||
- 执行前需要先做决策才能降低歧义
|
||||
|
||||
- 前序讨论已经确认非显而易见的需求、约束或被否决方案
|
||||
|
||||
必需章节(建议使用以下中文章节标题):
|
||||
|
||||
- **背景**:问题、当前状态、相关参考资料,以及触发本次变更的用户请求
|
||||
|
||||
- **讨论记录**:探索或前序讨论中必须保留的关键点,包括已确认结论、用户偏好、约束和重要的被否决方案
|
||||
|
||||
- **需求**:预期结果、行为/流程/接口/内容变化、连续性要求和验收标准
|
||||
|
||||
- **目标 / 非目标**:本次变更要达成的目标,以及明确不在范围内的内容
|
||||
|
||||
- **执行约束**:必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界
|
||||
|
||||
- **影响范围**:与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项
|
||||
|
||||
- **决策**:关键选择及理由(为什么选 X 而不是 Y)。每个重要决策都要包含考虑过的替代方案,以及未选择它们的原因
|
||||
|
||||
- **执行计划**:主要工作流或待修改 artifacts、集成或交接点、执行顺序,以及必要的发布/落地说明
|
||||
|
||||
- **验证计划**:用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查
|
||||
|
||||
- **风险 / 权衡**:已知限制和可能出错的事项
|
||||
格式:[风险] -> 缓解措施
|
||||
|
||||
- **待解决问题**:执行前仍需解决的决策、假设或未知项。必须区分会阻塞 apply 的问题和非阻塞后续问题。没有未决问题时使用“无”
|
||||
|
||||
可选章节(相关时添加,建议使用中文章节标题):
|
||||
|
||||
- **迁移 / 发布计划**:发布步骤、沟通安排、归属、回滚或连续性策略
|
||||
|
||||
聚焦保留需求、理由、约束和方案。除非某个细节是讨论中明确做出的决策,否则避免逐行或逐步骤展开。
|
||||
|
||||
优先写可长期使用的摘要,而不是聊天记录转写。当具体 artifact 名称、数据/信息形状、示例、相关方、归属和边界场景会影响执行时,必须写清楚。
|
||||
|
||||
不要在 design.md 使用任务 checkbox;checkbox 只属于 tasks.md。
|
||||
|
||||
最终 design.md 不得包含未解决的模板注释、空表格行或占位文本。
|
||||
|
||||
如果信息缺失,写明假设和待解决问题,不要编造隐藏需求。不要依赖未写入文档的聊天上下文。
|
||||
requires: []
|
||||
- id: tasks
|
||||
generates: tasks.md
|
||||
description: 从 design.md 派生的可跟踪执行清单
|
||||
template: tasks.md
|
||||
instruction: |
|
||||
创建 tasks.md,将 design.md 拆解为可执行工作项。
|
||||
|
||||
**重要:必须遵守以下模板中的 checkbox 行格式。** apply 阶段会解析 checkbox 格式跟踪进度。未使用 `- [ ]` 的任务不会被跟踪。
|
||||
|
||||
语言规则(强制):
|
||||
|
||||
- fast-drive 的 tasks.md 使用中文分组标题和中文任务描述;仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
|
||||
|
||||
- 每个可跟踪任务必须保留 OpenSpec CLI 可解析的单行 checkbox 格式,例如 `- [ ] 1.1 任务描述` 或 `- [x] 1.1 已完成任务描述`
|
||||
|
||||
- 最终 tasks.md 不得残留英文模板任务或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
|
||||
|
||||
编写规则:
|
||||
|
||||
- 任务必须从 design.md 派生。不要依赖 proposal.md 或 specs artifacts;任何相关前序讨论都必须已经记录在 design.md 中
|
||||
|
||||
- 相关任务按 `##` 编号标题分组,分组标题使用中文
|
||||
|
||||
- 每个任务 MUST 是单行 checkbox:`- [ ] X.Y 任务描述`
|
||||
|
||||
- 任务粒度应足够小,能在一个会话内完成
|
||||
|
||||
- 按依赖顺序排序(先做必须先完成的事项)
|
||||
|
||||
- 当执行依赖执行约束、影响范围或待解决问题时,从上下文审查任务开始
|
||||
|
||||
- 需要时包含验证任务,覆盖检查、审查、批准、验收、文档、沟通和人工检查
|
||||
|
||||
- 除非仓库、版本控制或发布操作明确属于本次变更范围,否则不要包含这类任务
|
||||
|
||||
- 最终 tasks.md 不得包含未解决的模板注释、空表格行或占位任务文本
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
## 1. 上下文审查
|
||||
|
||||
- [ ] 1.1 阅读 design.md,识别范围、需求、决策、执行约束和待解决问题
|
||||
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
|
||||
|
||||
## 2. 执行
|
||||
|
||||
- [ ] 2.1 执行 design.md 中的第一个具体工作项
|
||||
- [ ] 2.2 执行 design.md 中的下一个具体工作项
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [ ] 3.1 执行“验证计划”中要求的验证
|
||||
- [ ] 3.2 执行项目或 workflow 要求的质量检查
|
||||
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
|
||||
|
||||
## 4. 文档 / 沟通
|
||||
|
||||
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档、runbook、沟通材料或项目参考资料
|
||||
```
|
||||
|
||||
以 design.md 中的范围、需求、决策、执行方向和验证预期为依据。
|
||||
|
||||
每个任务都应可验证:必须能明确判断任务何时完成。
|
||||
requires:
|
||||
- design
|
||||
apply:
|
||||
requires:
|
||||
- design
|
||||
- tasks
|
||||
tracks: tasks.md
|
||||
instruction: |
|
||||
先阅读 design.md,再阅读 tasks.md。
|
||||
同时遵守 workflow context/configuration,例如存在时读取 openspec/config.yaml,以及 design.md 引用的相关项目或 workflow 文档。
|
||||
将 design.md 视为范围、需求、决策、执行约束、执行方向和验证预期的事实来源。
|
||||
按依赖顺序处理待办任务,并在完成后及时标记。
|
||||
只有任务执行完成且必要验证完成后,才能标记任务完成。
|
||||
如果 tasks 与 design.md 冲突、design.md 存在阻塞性待解决问题,或需要澄清,必须暂停。
|
||||
@@ -1,77 +0,0 @@
|
||||
## 背景
|
||||
|
||||
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
|
||||
|
||||
## 讨论记录
|
||||
|
||||
<!-- 记录探索或前序讨论中 apply 阶段必须保留的关键结论 -->
|
||||
|
||||
- 已确认结论:
|
||||
- 用户偏好:
|
||||
- 约束:
|
||||
- 被否决方案:
|
||||
|
||||
## 需求
|
||||
|
||||
<!-- 记录预期结果、行为/流程/接口/内容变化、连续性要求和验收标准 -->
|
||||
|
||||
| 需求 | 验收标准 |
|
||||
| ---- | -------- |
|
||||
| | |
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标:**
|
||||
<!-- 记录本次 design 要达成的目标 -->
|
||||
|
||||
**非目标:**
|
||||
<!-- 记录明确不在范围内的内容 -->
|
||||
|
||||
## 执行约束
|
||||
|
||||
<!-- 记录必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界 -->
|
||||
|
||||
- 依赖限制:
|
||||
- 约束:
|
||||
- 质量门禁:
|
||||
- 相关方:
|
||||
- 文档 / 沟通:
|
||||
- 兼容性 / 连续性:
|
||||
|
||||
## 影响范围
|
||||
|
||||
<!-- 记录与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项 -->
|
||||
|
||||
| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 |
|
||||
| ---- | -------------------- | -------- | ---- |
|
||||
| <!-- 范围 --> | <!-- Artifacts / 参考资料 --> | <!-- 预期变更 --> | <!-- 备注 --> |
|
||||
|
||||
## 决策
|
||||
|
||||
<!-- 记录关键决策、理由和考虑过的替代方案 -->
|
||||
|
||||
| 决策 | 理由 | 已否决替代方案 |
|
||||
| ---- | ---- | ---------------- |
|
||||
| | | |
|
||||
|
||||
## 执行计划
|
||||
|
||||
<!-- 记录主要工作流或待修改 artifacts、集成或交接点、执行顺序,以及必要的发布/落地说明 -->
|
||||
|
||||
## 验证计划
|
||||
|
||||
<!-- 记录用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查 -->
|
||||
|
||||
| 需求 / 风险 | 验证方式 |
|
||||
| ----------- | -------- |
|
||||
| | |
|
||||
|
||||
## 风险 / 权衡
|
||||
|
||||
<!-- 格式:[风险] -> 缓解措施 -->
|
||||
|
||||
## 待解决问题
|
||||
|
||||
| 状态 | 问题 | 所需决策 |
|
||||
| ---- | ---- | -------- |
|
||||
| 无 | 无待解决问题。 | 无需决策 |
|
||||
@@ -1,19 +0,0 @@
|
||||
## 1. 上下文审查
|
||||
|
||||
- [ ] 1.1 阅读 design.md,识别范围、需求、决策、执行约束和待解决问题
|
||||
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
|
||||
|
||||
## 2. 执行
|
||||
|
||||
- [ ] 2.1 执行 design.md 中的第一个具体工作项
|
||||
- [ ] 2.2 执行 design.md 中的下一个具体工作项
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [ ] 3.1 执行“验证计划”中要求的验证
|
||||
- [ ] 3.2 执行项目或 workflow 要求的质量检查
|
||||
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
|
||||
|
||||
## 4. 文档 / 沟通
|
||||
|
||||
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档、runbook、沟通材料或项目参考资料
|
||||
@@ -19,16 +19,17 @@ export function getSettings(raw: Database): SettingsData {
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return { theme: "system" };
|
||||
return { compact: false, theme: "system" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(row.data) as Partial<SettingsData>;
|
||||
return {
|
||||
compact: typeof parsed.compact === "boolean" ? parsed.compact : false,
|
||||
theme: parsed.theme === "dark" || parsed.theme === "light" || parsed.theme === "system" ? parsed.theme : "system",
|
||||
};
|
||||
} catch {
|
||||
return { theme: "system" };
|
||||
return { compact: false, theme: "system" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ export function updateSettings(raw: Database, data: Partial<SettingsData>, _logg
|
||||
.where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings)))
|
||||
.get();
|
||||
|
||||
let currentData: SettingsData = { theme: "system" };
|
||||
let currentData: SettingsData = { compact: false, theme: "system" };
|
||||
if (existing) {
|
||||
try {
|
||||
currentData = JSON.parse(existing.data) as SettingsData;
|
||||
|
||||
@@ -32,6 +32,10 @@ export async function handleUpdateSettings(
|
||||
return jsonResponse(createApiError("theme 仅支持 dark、light、system", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (body.compact !== undefined && typeof body.compact !== "boolean") {
|
||||
return jsonResponse(createApiError("compact 必须为布尔值", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = updateSettings(db, body, logger);
|
||||
logger.info({ data: result }, "设置已更新");
|
||||
return jsonResponse(result, { mode });
|
||||
|
||||
@@ -238,6 +238,7 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface SettingsData {
|
||||
compact?: boolean;
|
||||
theme: ThemePreference;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card, Segmented } from "antd";
|
||||
import { App as AntApp, Card, Form, Radio, Switch } from "antd";
|
||||
|
||||
import type { ThemePreference } from "../../../shared/api";
|
||||
import { useSettings } from "../../shared/hooks/use-settings";
|
||||
import { parseThemePreference, useThemePreference } from "../../shared/hooks/use-theme-preference";
|
||||
|
||||
@@ -9,30 +10,68 @@ const THEME_OPTIONS = [
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
const SAVE_MESSAGE_KEY = "settings-save";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { preference, setPreference } = useThemePreference();
|
||||
const { message } = AntApp.useApp();
|
||||
const { compact, preference, setCompact, setPreference } = useThemePreference();
|
||||
const { isUpdating, updateSettings } = useSettings();
|
||||
|
||||
const handleThemeChange = (value: string) => {
|
||||
const theme = parseThemePreference(value);
|
||||
const handleThemeChange = (value: ThemePreference) => {
|
||||
message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" });
|
||||
updateSettings(
|
||||
{ theme },
|
||||
{ theme: value },
|
||||
{
|
||||
onError: () => {
|
||||
void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreference(theme);
|
||||
setPreference(value);
|
||||
void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleCompactChange = (checked: boolean) => {
|
||||
message.open({ content: "保存中...", duration: 0, key: SAVE_MESSAGE_KEY, type: "loading" });
|
||||
updateSettings(
|
||||
{ compact: checked },
|
||||
{
|
||||
onError: () => {
|
||||
void message.open({ content: "保存失败", duration: 2, key: SAVE_MESSAGE_KEY, type: "error" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setCompact(checked);
|
||||
void message.open({ content: "已保存", duration: 1.5, key: SAVE_MESSAGE_KEY, type: "success" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card extra={isUpdating ? "保存中..." : undefined} title="主题" type="inner">
|
||||
<Segmented
|
||||
block
|
||||
onChange={(value) => handleThemeChange(value)}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
value={preference}
|
||||
/>
|
||||
<Card title="主题" type="inner">
|
||||
<Form
|
||||
className="settings-form"
|
||||
colon={false}
|
||||
disabled={isUpdating}
|
||||
labelAlign="left"
|
||||
labelCol={{ flex: "120px" }}
|
||||
layout="horizontal"
|
||||
>
|
||||
<Form.Item colon={false} help="选择跟随系统将自动适配操作系统的深浅色偏好" label="主题模式">
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
onChange={(e) => handleThemeChange(parseThemePreference(e.target.value))}
|
||||
optionType="button"
|
||||
options={THEME_OPTIONS.map((o) => ({ label: o.label, value: o.value }))}
|
||||
value={preference}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item colon={false} help="开启后控件间距和高度变小,显示更多内容" label="紧凑模式">
|
||||
<Switch checked={compact} onChange={handleCompactChange} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ConsoleOutlet } from "./ConsoleOutlet";
|
||||
const { Content, Header, Sider } = Layout;
|
||||
|
||||
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
|
||||
const { effectiveTheme } = useThemePreference();
|
||||
const { compact, effectiveTheme } = useThemePreference();
|
||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
||||
const { data: meta } = useMeta();
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
|
||||
const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
|
||||
|
||||
return (
|
||||
<XProvider locale={locale} theme={buildThemeConfig(effectiveTheme)}>
|
||||
<XProvider locale={locale} theme={buildThemeConfig({ compact, effectiveTheme })}>
|
||||
<AntApp>
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
|
||||
@@ -6,10 +6,13 @@ export type EffectiveTheme = "dark" | "light";
|
||||
export type ThemePreference = "dark" | "light" | "system";
|
||||
|
||||
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
|
||||
const COMPACT_CHANGE_EVENT = "theme-compact-change";
|
||||
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
export const COMPACT_STORAGE_KEY = "theme.compact";
|
||||
|
||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||
try {
|
||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
@@ -30,6 +33,14 @@ export function readThemePreference(storage: Storage = window.localStorage): The
|
||||
}
|
||||
}
|
||||
|
||||
export function readCompactPreference(storage: Storage = window.localStorage): boolean {
|
||||
try {
|
||||
return storage.getItem(COMPACT_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||
if (preference === "dark" || preference === "light") return preference;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
@@ -37,6 +48,7 @@ export function resolveEffectiveTheme(preference: ThemePreference, systemPrefers
|
||||
|
||||
export function useThemePreference() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [compact, setCompactState] = useState<boolean>(() => readCompactPreference());
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||
|
||||
@@ -52,6 +64,9 @@ export function useThemePreference() {
|
||||
const apiTheme = parseThemePreference(data.theme);
|
||||
setPreferenceState((prev) => (prev !== apiTheme ? apiTheme : prev));
|
||||
writeThemePreference(apiTheme);
|
||||
const apiCompact = typeof data.compact === "boolean" ? data.compact : false;
|
||||
setCompactState((prev) => (prev !== apiCompact ? apiCompact : prev));
|
||||
writeCompactPreference(apiCompact);
|
||||
})
|
||||
.catch(() => {
|
||||
// API 不可用时维持 localStorage 缓存值
|
||||
@@ -78,12 +93,26 @@ export function useThemePreference() {
|
||||
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompactEvent = (event: CustomEvent) => {
|
||||
const next = typeof event.detail === "boolean" ? event.detail : false;
|
||||
setCompactState((prev) => (prev !== next ? next : prev));
|
||||
};
|
||||
window.addEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener);
|
||||
return () => window.removeEventListener(COMPACT_CHANGE_EVENT, handleCompactEvent as EventListener);
|
||||
}, []);
|
||||
|
||||
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference);
|
||||
writeThemePreference(nextPreference);
|
||||
}, []);
|
||||
|
||||
return { effectiveTheme, preference, setPreference };
|
||||
const setCompact = useCallback((nextCompact: boolean) => {
|
||||
setCompactState(nextCompact);
|
||||
writeCompactPreference(nextCompact);
|
||||
}, []);
|
||||
|
||||
return { compact, effectiveTheme, preference, setCompact, setPreference };
|
||||
}
|
||||
|
||||
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||
@@ -98,3 +127,16 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
|
||||
// jsdom 等环境可能不支持 CustomEvent
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCompactPreference(value: boolean, storage: Storage = window.localStorage): void {
|
||||
try {
|
||||
storage.setItem(COMPACT_STORAGE_KEY, String(value));
|
||||
} catch {
|
||||
// 存储不可用时不阻断
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(COMPACT_CHANGE_EVENT, { detail: value }));
|
||||
} catch {
|
||||
// jsdom 等环境兼容
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ import { theme } from "antd";
|
||||
|
||||
import type { EffectiveTheme } from "../hooks/use-theme-preference";
|
||||
|
||||
export function buildThemeConfig(effectiveTheme: EffectiveTheme) {
|
||||
interface BuildThemeConfigOptions {
|
||||
compact?: boolean;
|
||||
effectiveTheme: EffectiveTheme;
|
||||
}
|
||||
|
||||
export function buildThemeConfig({ compact = false, effectiveTheme }: BuildThemeConfigOptions) {
|
||||
const isDark = effectiveTheme === "dark";
|
||||
const algorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
const baseAlgorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
const algorithm = compact ? [baseAlgorithm, theme.compactAlgorithm] : [baseAlgorithm];
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
@@ -27,7 +33,7 @@ export function buildThemeConfig(effectiveTheme: EffectiveTheme) {
|
||||
borderRadius: 10,
|
||||
colorLink: isDark ? "#a3a3a3" : "#0a0a0a",
|
||||
colorPrimary: isDark ? "#525252" : "#0a0a0a",
|
||||
controlHeight: 36,
|
||||
controlHeight: compact ? 28 : 36,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,3 +455,8 @@ body {
|
||||
.markdown-table tbody tr:hover td {
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
/* 设置页表单:最后一项无底边距 */
|
||||
.settings-form .ant-form-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@ describe("设置数据访问层", () => {
|
||||
test("getSettings 无数据时返回默认值", () => {
|
||||
withSettingsDb((db) => {
|
||||
const result = getSettings(db);
|
||||
expect(result).toEqual({ theme: "system" });
|
||||
expect(result).toEqual({ compact: false, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
test("updateSettings 写入并读取", () => {
|
||||
withSettingsDb((db) => {
|
||||
const updated = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||
expect(updated).toEqual({ theme: "dark" });
|
||||
expect(updated).toEqual({ compact: false, theme: "dark" });
|
||||
|
||||
const read = getSettings(db);
|
||||
expect(read).toEqual({ theme: "dark" });
|
||||
expect(read).toEqual({ compact: false, theme: "dark" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("设置数据访问层", () => {
|
||||
withSettingsDb((db) => {
|
||||
updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||
const result = updateSettings(db, { theme: "light" }, createNoopLogger());
|
||||
expect(result).toEqual({ theme: "light" });
|
||||
expect(result).toEqual({ compact: false, theme: "light" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("设置数据访问层", () => {
|
||||
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 'not-json')",
|
||||
);
|
||||
const result = getSettings(db);
|
||||
expect(result).toEqual({ theme: "system" });
|
||||
expect(result).toEqual({ compact: false, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("设置数据访问层", () => {
|
||||
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"unknown\"}')",
|
||||
);
|
||||
const result = getSettings(db);
|
||||
expect(result).toEqual({ theme: "system" });
|
||||
expect(result).toEqual({ compact: false, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,8 +66,8 @@ describe("设置数据访问层", () => {
|
||||
withSettingsDb((db) => {
|
||||
const a = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||
const b = updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||
expect(a).toEqual({ theme: "dark" });
|
||||
expect(b).toEqual({ theme: "dark" });
|
||||
expect(a).toEqual({ compact: false, theme: "dark" });
|
||||
expect(b).toEqual({ compact: false, theme: "dark" });
|
||||
|
||||
const row = db
|
||||
.query("SELECT COUNT(*) as cnt FROM settings WHERE id = 'default' AND deleted_at IS NULL")
|
||||
@@ -75,4 +75,42 @@ describe("设置数据访问层", () => {
|
||||
expect(row.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("getSettings 无 compact 字段时默认 false", () => {
|
||||
withSettingsDb((db) => {
|
||||
db.run(
|
||||
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"dark\"}')",
|
||||
);
|
||||
const result = getSettings(db);
|
||||
expect(result).toEqual({ compact: false, theme: "dark" });
|
||||
});
|
||||
});
|
||||
|
||||
test("updateSettings 写入 compact 并读取", () => {
|
||||
withSettingsDb((db) => {
|
||||
const updated = updateSettings(db, { compact: true }, createNoopLogger());
|
||||
expect(updated).toEqual({ compact: true, theme: "system" });
|
||||
|
||||
const read = getSettings(db);
|
||||
expect(read).toEqual({ compact: true, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
test("updateSettings compact 与 theme 合并", () => {
|
||||
withSettingsDb((db) => {
|
||||
updateSettings(db, { theme: "dark" }, createNoopLogger());
|
||||
const result = updateSettings(db, { compact: true }, createNoopLogger());
|
||||
expect(result).toEqual({ compact: true, theme: "dark" });
|
||||
});
|
||||
});
|
||||
|
||||
test("getSettings compact 为非布尔值时回退 false", () => {
|
||||
withSettingsDb((db) => {
|
||||
db.run(
|
||||
"INSERT INTO settings (id, created_at, updated_at, data) VALUES ('default', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', '{\"theme\":\"dark\",\"compact\":\"yes\"}')",
|
||||
);
|
||||
const result = getSettings(db);
|
||||
expect(result).toEqual({ compact: false, theme: "dark" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +36,8 @@ describe("设置 API 路由", () => {
|
||||
const req = new Request("http://localhost/api/settings");
|
||||
const res = await getSettingsViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { theme: string };
|
||||
expect(body).toEqual({ theme: "system" });
|
||||
const body = (await res.json()) as { compact: boolean; theme: string };
|
||||
expect(body).toEqual({ compact: false, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,14 +50,14 @@ describe("设置 API 路由", () => {
|
||||
});
|
||||
const putRes = await updateSettingsViaHandler(putReq, db);
|
||||
expect(putRes.status).toBe(200);
|
||||
const putBody = (await putRes.json()) as { theme: string };
|
||||
expect(putBody).toEqual({ theme: "dark" });
|
||||
const putBody = (await putRes.json()) as { compact: boolean; theme: string };
|
||||
expect(putBody).toEqual({ compact: false, theme: "dark" });
|
||||
|
||||
const getReq = new Request("http://localhost/api/settings");
|
||||
const getRes = await getSettingsViaHandler(getReq, db);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { theme: string };
|
||||
expect(getBody).toEqual({ theme: "dark" });
|
||||
const getBody = (await getRes.json()) as { compact: boolean; theme: string };
|
||||
expect(getBody).toEqual({ compact: false, theme: "dark" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,8 +77,8 @@ describe("设置 API 路由", () => {
|
||||
});
|
||||
const res2 = await updateSettingsViaHandler(req2, db);
|
||||
expect(res2.status).toBe(200);
|
||||
const body = (await res2.json()) as { theme: string };
|
||||
expect(body).toEqual({ theme: "light" });
|
||||
const body = (await res2.json()) as { compact: boolean; theme: string };
|
||||
expect(body).toEqual({ compact: false, theme: "light" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,8 +139,56 @@ describe("设置 API 路由", () => {
|
||||
});
|
||||
const res = await updateSettingsViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { theme: string };
|
||||
expect(body).toEqual({ theme: "system" });
|
||||
const body = (await res.json()) as { compact: boolean; theme: string };
|
||||
expect(body).toEqual({ compact: false, theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT /api/settings compact 为非布尔值返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/settings", {
|
||||
body: JSON.stringify({ compact: "true" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
const res = await updateSettingsViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT /api/settings compact=true 合法", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/settings", {
|
||||
body: JSON.stringify({ compact: true }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
const res = await updateSettingsViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { compact: boolean; theme: string };
|
||||
expect(body.compact).toBe(true);
|
||||
expect(body.theme).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
test("PUT /api/settings compact 与 theme 合并持久化", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req1 = new Request("http://localhost/api/settings", {
|
||||
body: JSON.stringify({ theme: "dark" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
await updateSettingsViaHandler(req1, db);
|
||||
|
||||
const req2 = new Request("http://localhost/api/settings", {
|
||||
body: JSON.stringify({ compact: true }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
const res2 = await updateSettingsViaHandler(req2, db);
|
||||
expect(res2.status).toBe(200);
|
||||
const body = (await res2.json()) as { compact: boolean; theme: string };
|
||||
expect(body).toEqual({ compact: true, theme: "dark" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ConsoleShell } from "../../../src/web/shared/components/ConsoleShell/Co
|
||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||
|
||||
function mockSettingsResponse(): Response {
|
||||
return jsonResponse({ theme: "system" });
|
||||
return jsonResponse({ compact: false, theme: "system" });
|
||||
}
|
||||
|
||||
describe("ConsoleShell", () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("MaterialCard", () => {
|
||||
selected: false,
|
||||
}),
|
||||
);
|
||||
const item = screen.getByText("测试素材描述").closest(".material-list-item")!;
|
||||
const item = screen.getByText("测试素材描述").closest(".app-sidebar-list-item")!;
|
||||
fireEvent.click(item);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -71,7 +71,7 @@ describe("MaterialCard", () => {
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("选中时包含 material-list-item--selected 类名", () => {
|
||||
test("选中时包含 app-sidebar-list-item--selected 类名", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
@@ -80,11 +80,11 @@ describe("MaterialCard", () => {
|
||||
selected: true,
|
||||
}),
|
||||
);
|
||||
const item = screen.getByText("测试素材描述").closest(".material-list-item--selected");
|
||||
const item = screen.getByText("测试素材描述").closest(".app-sidebar-list-item--selected");
|
||||
expect(item).not.toBeNull();
|
||||
});
|
||||
|
||||
test("未选中时不包含 material-list-item--selected 类名", () => {
|
||||
test("未选中时不包含 app-sidebar-list-item--selected 类名", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
@@ -93,7 +93,7 @@ describe("MaterialCard", () => {
|
||||
selected: false,
|
||||
}),
|
||||
);
|
||||
const item = screen.getByText("测试素材描述").closest(".material-list-item--selected");
|
||||
const item = screen.getByText("测试素材描述").closest(".app-sidebar-list-item--selected");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createElement } from "react";
|
||||
import { SettingsPage } from "../../../../src/web/features/settings/index";
|
||||
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
|
||||
|
||||
function mockSettingsResponse(theme = "system"): Response {
|
||||
return jsonResponse({ theme });
|
||||
function mockSettingsResponse(theme = "system", compact = false): Response {
|
||||
return jsonResponse({ compact, theme });
|
||||
}
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
@@ -21,7 +21,7 @@ describe("SettingsPage", () => {
|
||||
expect(screen.getByText("主题")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染主题 Segmented 选项", () => {
|
||||
test("渲染主题模式 Radio.Group 选项", () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse();
|
||||
return jsonResponse({});
|
||||
@@ -34,7 +34,41 @@ describe("SettingsPage", () => {
|
||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("API 加载中时不显示保存状态", () => {
|
||||
test("渲染紧凑模式标签和开关", () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse();
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderWithProviders(createElement(SettingsPage));
|
||||
|
||||
expect(screen.getByText("紧凑模式")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染水平表单结构", () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse();
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderWithProviders(createElement(SettingsPage));
|
||||
|
||||
const form = document.querySelector(".ant-form");
|
||||
expect(form).not.toBeNull();
|
||||
});
|
||||
|
||||
test("不再使用 Segmented", () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse();
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderWithProviders(createElement(SettingsPage));
|
||||
|
||||
expect(document.querySelector(".ant-segmented")).toBeNull();
|
||||
});
|
||||
|
||||
test("不显示保存状态文本(已迁移到 toast)", () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse();
|
||||
return jsonResponse({});
|
||||
@@ -45,17 +79,17 @@ describe("SettingsPage", () => {
|
||||
expect(screen.queryByText("保存中...")).toBeNull();
|
||||
});
|
||||
|
||||
test("GET /api/settings 获取已保存主题", async () => {
|
||||
test("GET /api/settings 获取已保存主题和紧凑设置", async () => {
|
||||
installFetchMock((call) => {
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse("dark");
|
||||
if (call.url.includes("/api/settings")) return mockSettingsResponse("dark", true);
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderWithProviders(createElement(SettingsPage));
|
||||
|
||||
await waitFor(() => {
|
||||
const segmented = document.querySelector(".ant-segmented");
|
||||
expect(segmented).not.toBeNull();
|
||||
const radioGroup = document.querySelector(".ant-radio-group");
|
||||
expect(radioGroup).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
COMPACT_STORAGE_KEY,
|
||||
getSystemPrefersDark,
|
||||
parseThemePreference,
|
||||
readCompactPreference,
|
||||
readThemePreference,
|
||||
resolveEffectiveTheme,
|
||||
THEME_MEDIA_QUERY,
|
||||
THEME_PREFERENCE_STORAGE_KEY,
|
||||
writeCompactPreference,
|
||||
writeThemePreference,
|
||||
} from "../../../src/web/shared/hooks/use-theme-preference";
|
||||
|
||||
@@ -75,3 +78,60 @@ describe("theme preference 纯逻辑", () => {
|
||||
expect(resolveEffectiveTheme("system", false)).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
function createCompactStorage(initial?: string): Storage {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== undefined) values.set(COMPACT_STORAGE_KEY, initial);
|
||||
return {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("compact preference 纯逻辑", () => {
|
||||
test("读取 compact 默认值 false", () => {
|
||||
const storage = createCompactStorage();
|
||||
expect(readCompactPreference(storage)).toBe(false);
|
||||
});
|
||||
|
||||
test("读取 compact=true", () => {
|
||||
const storage = createCompactStorage("true");
|
||||
expect(readCompactPreference(storage)).toBe(true);
|
||||
});
|
||||
|
||||
test("读取 compact=false", () => {
|
||||
const storage = createCompactStorage("false");
|
||||
expect(readCompactPreference(storage)).toBe(false);
|
||||
});
|
||||
|
||||
test("读取 compact 非法值回退 false", () => {
|
||||
const storage = createCompactStorage("yes");
|
||||
expect(readCompactPreference(storage)).toBe(false);
|
||||
});
|
||||
|
||||
test("写入 compact 值", () => {
|
||||
const storage = createCompactStorage();
|
||||
writeCompactPreference(true, storage);
|
||||
expect(storage.getItem(COMPACT_STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
test("storage 异常时不抛错", () => {
|
||||
const brokenStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
expect(readCompactPreference(brokenStorage)).toBe(false);
|
||||
expect(() => writeCompactPreference(true, brokenStorage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
48
tests/web/shared/theme-config.test.ts
Normal file
48
tests/web/shared/theme-config.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { theme } from "antd";
|
||||
|
||||
import { buildThemeConfig } from "../../../src/web/shared/theme/theme-config";
|
||||
|
||||
describe("buildThemeConfig", () => {
|
||||
test("compact=false 浅色:algorithm 仅 defaultAlgorithm,controlHeight=36", () => {
|
||||
const config = buildThemeConfig({ compact: false, effectiveTheme: "light" });
|
||||
expect(config.algorithm).toEqual([theme.defaultAlgorithm]);
|
||||
expect(config.token.controlHeight).toBe(36);
|
||||
});
|
||||
|
||||
test("compact=false 深色:algorithm 仅 darkAlgorithm,controlHeight=36", () => {
|
||||
const config = buildThemeConfig({ compact: false, effectiveTheme: "dark" });
|
||||
expect(config.algorithm).toEqual([theme.darkAlgorithm]);
|
||||
expect(config.token.controlHeight).toBe(36);
|
||||
});
|
||||
|
||||
test("compact=true 浅色:algorithm 包含 defaultAlgorithm + compactAlgorithm,controlHeight=28", () => {
|
||||
const config = buildThemeConfig({ compact: true, effectiveTheme: "light" });
|
||||
expect(config.algorithm).toEqual([theme.defaultAlgorithm, theme.compactAlgorithm]);
|
||||
expect(config.token.controlHeight).toBe(28);
|
||||
});
|
||||
|
||||
test("compact=true 深色:algorithm 包含 darkAlgorithm + compactAlgorithm,controlHeight=28", () => {
|
||||
const config = buildThemeConfig({ compact: true, effectiveTheme: "dark" });
|
||||
expect(config.algorithm).toEqual([theme.darkAlgorithm, theme.compactAlgorithm]);
|
||||
expect(config.token.controlHeight).toBe(28);
|
||||
});
|
||||
|
||||
test("compact 缺省时等同于 false", () => {
|
||||
const config = buildThemeConfig({ effectiveTheme: "light" });
|
||||
expect(config.algorithm).toEqual([theme.defaultAlgorithm]);
|
||||
expect(config.token.controlHeight).toBe(36);
|
||||
});
|
||||
|
||||
test("深色主题配色保持不变", () => {
|
||||
const config = buildThemeConfig({ compact: false, effectiveTheme: "dark" });
|
||||
expect(config.token.colorPrimary).toBe("#525252");
|
||||
expect(config.token.colorLink).toBe("#a3a3a3");
|
||||
});
|
||||
|
||||
test("浅色主题配色保持不变", () => {
|
||||
const config = buildThemeConfig({ compact: false, effectiveTheme: "light" });
|
||||
expect(config.token.colorPrimary).toBe("#0a0a0a");
|
||||
expect(config.token.colorLink).toBe("#0a0a0a");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user