Compare commits

..

3 Commits

Author SHA1 Message Date
4caf502908 feat: 重构前端为企业 Admin 后台布局,引入 React Router 路由
- 引入 React Router v7 (Declarative mode) 实现 SPA 路由
- 重构 Layout 为 Header + 侧边栏 + 内容区的企业 Admin 布局
- 新增侧边栏菜单组件,支持折叠/展开,状态持久化到 localStorage
- 新增示例页面:仪表盘、用户管理、系统设置、404
- 菜单配置与路由统一为单一数据源 (menu.tsx)
- Vite code splitting 新增 vendor-router 组
- 更新 DEVELOPMENT.md 和 README.md 文档
2026-05-20 19:06:14 +08:00
5aed73523e feat: 新增应用全局常量 APP,消除硬编码散落
- 新增 src/shared/app.ts,定义应用元信息(name、title、subtitle、description、version)
- 后端 3 处硬编码改为引用 APP.name
- 前端 3 处硬编码改为引用 APP.title/APP.description
- localStorage key 从 my-app.theme.preference 改为 theme.preference
- 构建脚本可执行文件名改为引用 APP.name
- 更新 README.md 和 DEVELOPMENT.md 文档
- 新增 openspec/specs/app-constants/spec.md 规范文档
2026-05-20 15:54:15 +08:00
4a38d4dac7 refactor: 模板占位符从 {{app-name}} 改为 my-app 2026-05-20 14:15:14 +08:00
41 changed files with 1096 additions and 169 deletions

View File

@@ -1,6 +1,6 @@
{{app-name}} 开发文档
my-app 开发文档
本文档面向 `{{app-name}}` 项目的开发者,介绍项目结构、前后端架构、构建流程、测试、代码规范等内容。
本文档面向 `my-app` 项目的开发者,介绍项目结构、前后端架构、构建流程、测试、代码规范等内容。
用户使用说明请参阅 [README.md](README.md)。
@@ -34,18 +34,34 @@ src/
health.ts GET /health
shared/
api.ts 前后端共享 TypeScript 类型
app.ts 应用全局常量name、title、subtitle、description、version
web/ React 前端(通过 Vite 构建)
index.html HTML 入口
app.tsx 根组件(Layout + Header + Content/health 联调示例展示
main.tsx 入口QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools + TDesign CSS 导入)
app.tsx 根组件(Admin 布局Header + Sidebar + Content
main.tsx 入口(BrowserRouter + QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools + TDesign CSS 导入)
routes.tsx 路由配置(定义所有页面路由)
styles.css 全局样式与自定义 CSS 变量
css.d.ts CSS 模块类型声明
pages/ 页面组件
dashboard/
index.tsx 仪表盘页(欢迎语 + /health 联调示例)
users/
index.tsx 用户管理页(占位)
settings/
index.tsx 系统设置页(占位)
404/
index.tsx 404 页面
components/ UI 组件
ErrorBoundary.tsx React 错误边界,捕获渲染异常并展示降级 UI
Sidebar/
index.tsx 侧边栏菜单组件TDesign Menu + 折叠控制)
hooks/ React hooks
use-theme-preference.ts 主题偏好 hooksystem/light/darklocalStorage 记忆 + matchMedia 监听)
use-sidebar-collapsed.ts 侧边栏折叠状态 hooklocalStorage 记忆)
utils/ 前端工具函数
time.ts 时间处理formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours
menu.tsx 菜单配置(路由与菜单项统一数据源)
routes.tsx 路由配置(定义所有页面路由)
scripts/
dev.ts 双进程开发服务Bun API server + Vite dev server
build.ts Vite → codegen → Bun compile 三步构建流水线
@@ -161,6 +177,7 @@ export function handleHealth(mode: RuntimeMode): Response;
### 1.5 类型定义规范
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象name、title、subtitle、description、version前后端及构建脚本共同引用
- 前端不得 `import src/server/` 下的任何文件
- **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string`
- API 响应类型(`ApiErrorResponse``HealthResponse`)定义在 shared 中
@@ -209,9 +226,9 @@ server:
| 语言 | TypeScript | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动刷新 |
| 路由 | 无(单页面应用) | 不引入 React Router |
| 路由 | React Router v7 (Declarative mode) | SPA 路由与页面导航 |
**不引入的依赖**React Router单页面场景不需要状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够)
**不引入的依赖**状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够)
### 2.2 组件树与数据流
@@ -220,11 +237,22 @@ main.tsx
└── StrictMode
└── ErrorBoundaryReact 错误边界)
└── QueryClientProviderTanStack Query 全局挂载)
── App根组件Layout + Header + Content 骨架
├── useThemePreference() ── Header 主题模式 RadioGroup系统/明亮/黑暗localStorage 记忆 + theme-mode 应用
│ ├── useQuery["health"] ── GET /health30s 轮询,前后端联调示例
── Content ── 欢迎页 + /health API 响应 JSON 展示
└── ReactQueryDevtools开发工具仅开发环境
── BrowserRouterReact Router 路由
├── App根组件Admin 布局
│ ├── useThemePreference() ── Header 主题模式 RadioGroup系统/明亮/黑暗
── useSidebarCollapsed() ── 侧边栏折叠状态localStorage 记忆)
│ ├── Layout
│ │ ├── Header折叠按钮 + 品牌名 + 页标题 + 主题切换)
│ │ └── Layout嵌套
│ │ ├── Aside
│ │ │ └── SidebarTDesign Menu菜单项点击导航
│ │ └── Content
│ │ └── AppRoutes路由配置
│ │ ├── / → DashboardPage欢迎语 + /health 联调)
│ │ ├── /users → UsersPage占位
│ │ ├── /settings → SettingsPage占位
│ │ └── * → NotFoundPage404
└── ReactQueryDevtools开发工具仅开发环境
```
**Hook 架构**
@@ -233,8 +261,24 @@ main.tsx
hooks/use-theme-preference.ts浏览器 UI 偏好)
├── ThemePreference: system / light / darkRadioGroup 受控值)
├── EffectiveTheme: light / dark写入 document.documentElement theme-mode
├── localStorage key: {{app-name}}.theme.preference同一浏览器记忆
├── localStorage key: theme.preference同一浏览器记忆
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
hooks/use-sidebar-collapsed.ts侧边栏折叠状态
├── collapsed: boolean折叠状态
├── localStorage key: sidebar.collapsed同一浏览器记忆
└── toggleCollapsed()(切换折叠状态)
```
**菜单配置**
```
utils/menu-config.ts路由与菜单统一数据源
├── MENU_ITEMS: MenuItemConfig[](菜单项配置数组)
│ ├── { value: "dashboard", label: "仪表盘", path: "/", icon: <DashboardIcon /> }
│ ├── { value: "users", label: "用户管理", path: "/users", icon: <UserIcon /> }
│ └── { value: "settings", label: "系统设置", path: "/settings", icon: <SettingIcon /> }
└── Sidebar 和 Routes 共同引用,保证菜单项与路由同步
```
### 2.3 TanStack Query 数据层

View File

@@ -1,4 +1,4 @@
# {{app-name}}
# my-app
(替换为你的项目介绍)
@@ -25,23 +25,23 @@ cd my-project
rm -rf .git && git init
```
### 2. 替换占位符 `{{app-name}}`
### 2. 配置应用信息
在整个项目中全局搜索替换 `{{app-name}}` 为你的项目名称(如 `my-app`)。以下为所有出现位置
编辑 `src/shared/app.ts`,修改应用元信息
| # | 文件 | 说明 |
| --- | --------------------------------------- | ------------------------------------------ |
| 1 | `package.json` | `name` 字段 |
| 2 | `scripts/build.ts` | 可执行文件路径 |
| 3 | `src/server/config.ts` | CLI 帮助文本 |
| 4 | `src/server/helpers.ts` | `createHealthResponse()``service` 字段 |
| 5 | `src/server/server.ts` | 服务启动日志消息 |
| 6 | `src/web/index.html` | `<title>``<meta name="description">` |
| 7 | `src/web/app.tsx` | Header 中的品牌名和欢迎标题 |
| 8 | `src/web/hooks/use-theme-preference.ts` | `localStorage` 键名 |
| 9 | `tests/web/App.test.tsx` | 测试中的品牌名断言 |
```typescript
export const APP = {
name: "your-app", // 机器标识kebab-case
title: "Your App", // 人类可读标题
subtitle: "你的副标题", // 副标题
description: "应用描述", // SEO meta 描述
version: "0.1.0", // 版本号
} as const;
```
> **提示**:可直接在编辑器中全局搜索 `{{app-name}}`,一次性替换
同时修改 `package.json``name` 字段保持一致
> **注意**localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。
### 3. 清理 OpenSpec 历史
@@ -111,16 +111,27 @@ bun run dev
│ │ └── routes/ # API 路由处理器
│ │ └── health.ts # 健康检查端点
│ ├── shared/
│ │ ── api.ts # 前后端共享 TypeScript 类型定义
│ │ ── api.ts # 前后端共享 TypeScript 类型定义
│ │ └── app.ts # 应用全局常量name、title、version 等)
│ └── web/ # 前端代码
│ ├── index.html # HTML 入口
│ ├── main.tsx # React 入口QueryClient + ErrorBoundary
│ ├── app.tsx # 根组件(Layout + 主题切换 + /health 展示
│ ├── main.tsx # React 入口(BrowserRouter + QueryClient + ErrorBoundary
│ ├── app.tsx # 根组件(Admin 布局Header + Sidebar + Content
│ ├── routes.tsx # 路由配置
│ ├── styles.css # 全局样式
│ ├── css.d.ts # CSS 模块类型声明
│ ├── pages/ # 页面组件
│ │ ├── dashboard/ # 仪表盘页
│ │ ├── users/ # 用户管理页
│ │ ├── settings/ # 系统设置页
│ │ └── 404/ # 404 页面
│ ├── components/ # UI 组件
│ │ ├── ErrorBoundary.tsx
│ │ └── Sidebar/ # 侧边栏组件
│ ├── hooks/ # React Hooks
── utils/ # 前端工具函数
── utils/ # 前端工具函数
│ ├── menu.tsx # 菜单配置
│ └── routes.tsx # 路由配置
├── tests/ # 测试文件(镜像 src 目录结构)
├── openspec/ # OpenSpec 规格与变更管理
└── docs/ # 项目文档
@@ -181,6 +192,7 @@ bun run dev custom-config.yaml
| 技术 | 说明 |
| --------------------------------------------------- | ------------------------ |
| [React 19](https://react.dev/) | UI 组件框架 |
| [React Router](https://reactrouter.com/) | SPA 路由与页面导航 |
| [TDesign React](https://tdesign.tencent.com/react/) | 企业级 UI 组件库 |
| [@tanstack/react-query](https://tanstack.com/query) | 服务端状态管理与数据获取 |
| [Recharts](https://recharts.org/) | 图表可视化(推荐使用) |

View File

@@ -9,6 +9,7 @@
"es-toolkit": "^1.46.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
@@ -429,6 +430,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
@@ -889,6 +892,8 @@
"react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-router": ["react-router@7.15.1", "https://registry.npmmirror.com/react-router/-/react-router-7.15.1.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="],
"react-transition-group": ["react-transition-group@4.4.5", "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
@@ -931,6 +936,8 @@
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],

View File

@@ -4,6 +4,7 @@
- 先审查再修复;未经用户确认,不修改代码或变更文档
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- 禁止同步或修改 `openspec/specs/` 下的主规范——主规范同步属于 archive 阶段,不在此提示词范围内;本次 change 目录下的 `specs/*.md`、代码、测试、README 等均可修改
- 优先使用当前会话中的实现说明、测试结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为代码已经存在就自动以代码为准;先判断差异属于"文档要求未实现"、"测试后新增修补"还是"意外偏离/回归"
- 每批代码或文档修改执行前用提问工具获得用户确认
@@ -12,14 +13,27 @@
## 1. 收集
并行读取:
读取约束
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的实现说明、apply 过程中的偏离、测试失败、手动修补原因、待确认事项
- 与本次变更相关的代码和测试文件;优先依据 `git diff --name-only``git diff --name-only --cached``tasks.md`、Impact、失败测试栈定位若工作区已干净再结合文档和代码模块反推
- 最近一次相关测试命令、测试结果、失败信息和修补后的验证结果
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
- 不原样输出文件内容,仅在步骤 2 输出审查结论
分步收集:
a) 先并行读取核心入口和配置,确定范围:
- 本次 change 的 `proposal.md`
- `openspec/config.yaml`
- 与本次改动相关的 README、架构文档以及现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、手动修补涉及的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
b) 从 `proposal.md` 提取 `Capabilities` / `Modified Capabilities`,确定 proposal 已声明的 spec 列表
c) 并行获取改动范围:`git diff --name-only``git diff --name-only --cached`;若工作区已干净,再结合文档和代码模块反推
d) 对比 git diff 涉及的模块与 proposal 已声明的 spec识别 apply 阶段新增改动触及但 proposal 未覆盖的现有 spec合并为完整 spec 读取列表;相关性来源还包括:手动修补涉及的受影响能力、`design.md` Impact 中提到的模块、相关代码对应的现有能力
e) 并行读取完整 spec 列表和其余 artifacts`design.md``tasks.md`、相关源码、测试文件、README、架构文档
f) 收集当前会话中与本次变更相关的实现说明、apply 过程中的偏离、测试失败、手动修补原因、待确认事项
若当前上下文无法明确 change 或文档路径:
@@ -34,12 +48,13 @@
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | 文档同步性 | 对实际存在的 artifacts 检查已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档若影响模块结构、API、实体或用户可见行为再检查 README 是否同步 |
| P2 | 文档要求覆盖 | 对实际存在的 artifacts 检查文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md``design.md``specs/*.md``tasks.md` |
| P3 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | 文档同步性 | 对本次 change 目录下实际存在的 artifacts 检查已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档若影响模块结构、API、实体或用户可见行为再检查 README 是否同步 |
| P2 | Spec 覆盖完整性 | 对实际代码改动范围与 proposal 中定义的 `Capabilities` / `Modified Capabilities`,识别 apply 阶段新增的功能是否触及了更多现有 spec若触及列入补充清单在本次 change 的 specs 中新增对应的 spec 文件,并更新 `proposal.md``Modified Capabilities` |
| P3 | 文档要求覆盖 | 对实际存在的 artifacts 检查文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md``design.md``specs/*.md``tasks.md` |
| P4 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
分析时区分三类差异:
@@ -58,7 +73,7 @@
- 文档要求但未落地的功能、场景、异常处理或验证步骤
- apply 完成后新增的代码修补、边界处理、接口调整、行为变化未同步到文档
- `tasks.md` 标记完成,但代码、测试或文档未闭环
- `Modified Capabilities` 应更新但未更新的现有 spec
- `Modified Capabilities` 在本次 change 的 specs 中是否已更新(注意:仅更新 change 目录下的 specs不动 `openspec/specs/`apply 阶段新增功能触及的未覆盖 spec 是否已补充到本次 change 的 `specs/` 目录
- 代码存在明显的重复、复杂度过高、命名不清、错误处理薄弱、测试质量不足等问题
输出审查结果:
@@ -68,10 +83,11 @@
3. **未覆盖清单**:文档要求但未在代码中实现或未充分验证的内容
4. **需回写文档清单**:代码和测试中已确认、但文档未体现的实现、修补或约束变化
5. **方向待确认清单**:代码与文档不一致,且无法判断应以哪边为准的事项
6. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
7. **测试问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的测试问题
8. **代码质量/优化清单**可优化的实现问题和建议
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
6. **Spec 补充清单**apply 阶段新增功能触及但 proposal 未覆盖的现有 spec列出需新增的 spec 文件名、对应的主 spec 路径、需描述的变更内容
7. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
8. **测试问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的测试问题
9. **代码质量/优化清单**:可优化的实现问题和建议
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
@@ -82,7 +98,8 @@
再整理完整修复方案,按类别列出:
- 代码或测试补充:补实现、补异常处理、补回归测试、修复掩盖错误的测试
- 文档回写:同步 `proposal.md``design.md``tasks.md``specs/*.md`、README 中遗漏或过时的内容
- 文档回写:同步本次 change 目录下的 `proposal.md``design.md``tasks.md``specs/*.md`、README 中遗漏或过时的内容(禁止同步到 `openspec/specs/`
- Spec 补充:将 apply 阶段新增功能触及的现有 spec 复制到本次 change 的 `specs/` 目录并更新,同步更新 `proposal.md``Modified Capabilities`
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
- 代码质量优化:在不改变目标行为的前提下优化结构、复用、命名或可维护性
@@ -112,13 +129,16 @@
若修改了文档:
- 确认实际存在的变更文档之间保持一致;在 `spec-driven`重点检查 `proposal.md``design.md``tasks.md``specs/*.md`
- 若 apply 后新增修补改变了能力边界或行为约束,同步更新 `Capabilities` / `Modified Capabilities`
- 确认本次 change 目录下的变更文档之间保持一致;重点检查 `proposal.md``design.md``tasks.md``specs/*.md`
- 若 apply 后新增修补改变了能力边界或行为约束,同步更新本次 change 的 `Capabilities` / `Modified Capabilities`
- 若"Spec 补充清单"中有需新增的 spec先从 `openspec/specs/` 复制对应的原 spec 到本次 change 的 `specs/` 目录,再基于实际改动更新其内容;禁止修改 `openspec/specs/` 下的原文件
- 禁止将本次 change 的 specs 同步到 `openspec/specs/`,该操作属于 archive 阶段
执行后重新读取所有被修改的代码、测试和文档,并复核:
- "未覆盖清单" 是否已清空或已标注保留原因
- "需回写文档清单" 是否已清空
- "Spec 补充清单" 是否已清空或已标注保留原因
- "方向待确认清单" 是否已清空或已记录用户决策
- "任务状态问题清单" 和 "测试问题清单" 是否已清空或已标注残留原因
- "代码质量/优化清单" 中哪些已处理,哪些有意延期

View File

@@ -10,13 +10,21 @@
## 1. 收集
并行读取:
读取约束
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的讨论、澄清、边界约束、非目标、待确认事项
- 与本次变更直接相关的源码、测试、README、架构文档
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
- 不原样输出文件内容,仅在步骤 2 输出审查结论
分步收集:
a) 先并行读取核心入口和配置,确定范围:
- 本次 change 的 `proposal.md`
- `openspec/config.yaml`
- 现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、讨论中提到的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
b) 从 `proposal.md` 提取 `Capabilities` / `Modified Capabilities`,确定需要读取的 spec 列表;相关性来源还包括:讨论中提到的受影响能力、`design.md` Impact 中提到的模块、相关代码对应的现有能力
c) 并行读取已确定的 spec 和其余 artifacts`design.md``tasks.md`、相关源码、测试、README、架构文档
若当前上下文无法明确 change 或文档路径:
@@ -48,8 +56,7 @@
- 讨论中已确定但文档未记录的内容
- 文档基于错误现状做出的设计或任务拆分
- 文档之间相互冲突的目标、方案、约束、任务
- `proposal -> specs -> design -> tasks` 链路中的断点
- `Modified Capabilities` 应更新但未更新的现有 spec
- `Modified Capabilities` 在本次 change 的 specs 中是否已更新(注意:仅更新 change 目录下的 specs不动 `openspec/specs/`
输出审查结果:
@@ -90,7 +97,9 @@
- "讨论遗漏清单" 是否已清空或已标注保留原因
- "现实性问题清单" 是否已清空或已标注为预期变更
- "文档冲突清单" 和 "OpenSpec 规范问题清单" 是否已清空
- "文档冲突清单" 是否已清空
- "OpenSpec 规范问题清单" 是否已清空
- "待澄清清单" 是否已清空或已记录用户决策
## 5. 收尾

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-20

View File

@@ -0,0 +1,95 @@
## Context
当前前端为单页面应用,`app.tsx` 直接渲染 Header + Content无路由、无侧边栏。技术栈为 React 19 + TDesign React + TanStack Query + Vite。后端使用 Bun.serve已支持 SPA fallback无扩展名路径返回 index.html
目标:重构为经典企业 Admin 后台布局,引入路由,支持多页面导航。
约束:
- 前端样式开发优先使用 TDesign 组件和 CSS tokens禁止内联 style、硬编码色值
- 新增代码需编写完善测试
- 后端无需改动
## Goals / Non-Goals
**Goals:**
- 引入 React Router v7 (Declarative mode) 实现 SPA 路由
- 实现企业 Admin 后台布局Header + 侧边栏 + 内容区
- 侧边栏支持折叠/展开,状态持久化到 localStorage
- 路由定义与菜单配置统一为单一数据源
- 提供示例页面仪表盘、用户管理、系统设置、404
**Non-Goals:**
- 不实现路由守卫/权限控制(预留接口但不启用)
- 不实现懒加载(页面少,暂不需要)
- 不实现面包屑导航
- 不实现用户认证
## Decisions
### 1. 路由方案React Router v7 Declarative mode
**选择**`react-router` v7使用 Declarative mode`BrowserRouter` + `Routes` + `Route`
**理由**
- v7 统一了 `react-router``react-router-dom`,单包导入
- Declarative mode 满足当前需求,无需 framework mode 的文件系统路由
- 社区成熟,文档完善
**替代方案**
- TanStack Router类型安全强但较新、API 变动风险
- 自实现:零依赖但功能有限,扩展成本高
### 2. Routes 定义位置:独立文件
**选择**:抽离为 `src/web/routes.tsx``app.tsx` 引用
**理由**
- 路由增删时改动范围小,不污染 app.tsx
- 便于后续扩展路由守卫、懒加载
### 3. 侧边栏折叠按钮位置Header 左侧
**选择**:折叠按钮放在 Header 左侧Sidebar 不使用 Menu 的 operations prop
**理由**
- Sidebar 折叠变窄后,底部按钮变小且易被忽略
- Header 上的按钮始终可见且易触达
- 符合 Ant Design Pro 等主流企业后台习惯
### 4. 页面标题来源:复用 menu label
**选择**Header 页面标题直接使用 `menu.tsx` 中的 `label` 字段
**理由**
- 简单直接,侧边栏标签即页面标题
- 如需差异化,后续加 `title` 字段即可
### 5. Layout 嵌套:保留嵌套结构
**选择**`Layout > (Header + Layout > (Aside + Content))`
**理由**
- 为 Footer 预留位置,将来加 Footer 无需重构
- 符合 TDesign 官方组合导航布局示例
### 6. Vite code splittingreact-router 单独分组
**选择**:新增 `vendor-router` 组,包含 `react-router`
**理由**
- 路由库独立于 React 核心,更新节奏不同
- 便于缓存隔离
### 7. 菜单与路由数据源menu.tsx
**选择**`src/web/menu.tsx` 定义菜单项配置Sidebar 和 Routes 共同引用
**理由**
- 单一数据源,保证菜单项和路由同步
- 便于类型安全(`as const`
## Risks / Trade-offs
- **路由与菜单配置一致性**:需人工保证 `menu.tsx``routes.tsx` 中 Route 定义一致 → 测试覆盖路由跳转和菜单点击
- **CSS 类名迁移影响**`.dashboard-*``.app-*` 可能影响测试选择器 → 全局搜索确认影响范围,更新测试
- **React Router v7 新版本风险**v7 较新,可能存在未发现的 bug → 使用成熟 APIBrowserRouter/Routes/Route避免实验性特性

View File

@@ -0,0 +1,36 @@
## Why
当前前端为单页面应用,仅有 Header + Content 的简单布局,无路由、无侧边栏,页面结构单一,无法支撑企业级 Admin 后台的多页面导航需求。作为 Bun 全栈应用模板,需要提供更完善的前端布局范例,便于使用者在此基础上扩展业务页面。
## What Changes
- 引入 React Router v7 (Declarative mode) 实现前端 SPA 路由
- 重构 Layout 为经典企业 Admin 后台布局Header + 侧边栏 + 内容区
- 新增侧边栏菜单组件,支持折叠/展开,折叠状态持久化到 localStorage
- 新增多个示例页面仪表盘、用户管理、系统设置、404
- 将路由定义与菜单配置统一为单一数据源,保证两者同步
- 新增 `react-router` 依赖Vite code splitting 单独分组
**BREAKING**: 原 `app.tsx` 的 Content 内容迁移至 Dashboard 页面CSS 类名 `.dashboard-*` 变更为 `.app-*`
## Capabilities
### New Capabilities
- `frontend-routing`: 前端 SPA 路由能力,基于 React Router v7支持多页面导航、路由匹配、404 处理
- `admin-layout`: 企业 Admin 后台布局能力Header + 侧边栏 + 内容区,侧边栏可折叠
### Modified Capabilities
- `app-constants`: 新增 localStorage key `"sidebar.collapsed"` 用于持久化侧边栏折叠状态
## Impact
- 新增依赖:`react-router`
- 新增目录:`src/web/pages/``src/web/components/Sidebar/`
- 新增文件:`src/web/routes.tsx``src/web/menu.tsx``src/web/hooks/use-sidebar-collapsed.ts`
- 修改文件:`src/web/app.tsx`(重构 Layout`src/web/main.tsx`+ BrowserRouter`src/web/styles.css`(布局样式重写)
- 修改配置:`vite.config.ts`code splitting 新增 vendor-router 组)
- 更新文档:`DEVELOPMENT.md``README.md`
- 新增测试Sidebar 组件测试、各页面测试、test-utils 增强
- 后端无需改动SPA fallback 已支持)

View File

@@ -0,0 +1,138 @@
## ADDED Requirements
### Requirement: 企业 Admin 后台布局
系统 SHALL 实现 Header + 侧边栏 + 内容区的企业 Admin 后台布局,使用 TDesign Layout 组件。
#### Scenario: 布局结构
- **WHEN** 用户访问应用
- **THEN** 系统 SHALL 渲染包含 Header、Aside、Content 的 Layout 结构
#### Scenario: Layout 嵌套结构
- **WHEN** 布局渲染
- **THEN** 系统 SHALL 使用嵌套 Layout 结构:`Layout > (Header + Layout > (Aside + Content))`
### Requirement: Header 布局
Header SHALL 包含折叠按钮、页面标题、主题切换控件。
#### Scenario: Header 左侧折叠按钮
- **WHEN** Header 渲染
- **THEN** 系统 SHALL 在左侧显示侧边栏折叠/展开按钮
#### Scenario: Header 页面标题
- **WHEN** Header 渲染
- **THEN** 系统 SHALL 显示当前路由对应的页面标题(从 menu 获取)
#### Scenario: Header 右侧主题切换
- **WHEN** Header 渲染
- **THEN** 系统 SHALL 在右侧显示主题切换 RadioGroup系统/明亮/黑暗)
### Requirement: 侧边栏菜单
系统 SHALL 提供侧边栏菜单组件Sidebar使用 TDesign Menu 组件,支持折叠/展开。
#### Scenario: 侧边栏渲染菜单项
- **WHEN** 侧边栏渲染
- **THEN** 系统 SHALL 根据 `menu.tsx` 渲染所有菜单项
#### Scenario: 菜单项点击导航
- **WHEN** 用户点击菜单项
- **THEN** 系统 SHALL 使用 React Router 的 `navigate` 跳转到对应路径
#### Scenario: 菜单项激活状态
- **WHEN** 当前路由与菜单项路径匹配
- **THEN** 系统 SHALL 高亮显示该菜单项
### Requirement: 侧边栏折叠
侧边栏 SHALL 支持折叠/展开,折叠状态持久化到 localStorage。
#### Scenario: 侧边栏折叠交互
- **WHEN** 用户点击 Header 左侧的折叠按钮
- **THEN** 系统 SHALL 切换侧边栏折叠状态
#### Scenario: 侧边栏折叠宽度
- **WHEN** 侧边栏折叠
- **THEN** Aside 宽度 SHALL 变为 80pxMenu 仅显示图标
#### Scenario: 侧边栏展开宽度
- **WHEN** 侧边栏展开
- **THEN** Aside 宽度 SHALL 变为 232pxMenu 显示图标和文字
#### Scenario: 折叠状态持久化
- **WHEN** 用户切换侧边栏折叠状态
- **THEN** 系统 SHALL 将状态存储到 localStorage key `"sidebar.collapsed"`
#### Scenario: 折叠状态恢复
- **WHEN** 应用初始化
- **THEN** 系统 SHALL 从 localStorage 读取 `"sidebar.collapsed"` 并恢复折叠状态
### Requirement: 侧边栏主题跟随全局
侧边栏 Menu 的主题 SHALL 跟随全局主题设置,不单独设置 `theme` prop。
#### Scenario: 侧边栏跟随全局主题
- **WHEN** 全局主题切换为 dark
- **THEN** 侧边栏 SHALL 自动应用 dark 主题样式
### Requirement: 菜单配置单一数据源
系统 SHALL 在 `src/web/menu.tsx` 定义菜单项配置,包含 `value``label``path``icon` 字段。
#### Scenario: 菜单配置类型安全
- **WHEN** 定义菜单配置
- **THEN** 系统 SHALL 使用 `as const` 保证字面量类型推断
#### Scenario: Sidebar 引用菜单配置
- **WHEN** Sidebar 渲染
- **THEN** 系统 SHALL 遍历 `MENU_ITEMS` 渲染 MenuItem
### Requirement: 示例页面
系统 SHALL 提供示例页面组件Dashboard、Users、Settings、NotFound。
#### Scenario: Dashboard 页面
- **WHEN** 用户访问 `/`
- **THEN** 系统 SHALL 渲染 Dashboard 页面(包含原 app.tsx 的欢迎语和 /health 展示)
#### Scenario: Users 页面占位
- **WHEN** 用户访问 `/users`
- **THEN** 系统 SHALL 渲染用户管理占位页面
#### Scenario: Settings 页面占位
- **WHEN** 用户访问 `/settings`
- **THEN** 系统 SHALL 渲染系统设置占位页面
#### Scenario: NotFound 页面
- **WHEN** 用户访问未定义路径
- **THEN** 系统 SHALL 渲染 404 页面,提供返回首页按钮
### Requirement: CSS 类名迁移
`.dashboard-*` CSS 类名 SHALL 变更为 `.app-*`
#### Scenario: 布局类名
- **WHEN** 样式应用
- **THEN** 系统 SHALL 使用 `.app-layout``.app-header``.app-content``.app-sidebar` 类名

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 侧边栏折叠状态 localStorage key
侧边栏折叠状态存储 key SHALL 为 `"sidebar.collapsed"`,不包含应用名前缀。
#### Scenario: 折叠状态持久化
- **WHEN** 用户切换侧边栏折叠状态
- **THEN** 系统 SHALL 将状态存储到 localStorage key `"sidebar.collapsed"`
#### Scenario: 折叠状态读取
- **WHEN** 应用初始化时读取侧边栏折叠状态
- **THEN** 系统 SHALL 从 localStorage key `"sidebar.collapsed"` 读取

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: 前端 SPA 路由
系统 SHALL 使用 React Router v7 (Declarative mode) 实现前端 SPA 路由,支持多页面导航。
#### Scenario: 应用启动时初始化路由
- **WHEN** 应用启动
- **THEN** 系统 SHALL 在 `main.tsx` 中使用 `BrowserRouter` 包裹根组件
#### Scenario: 路由匹配渲染对应页面
- **WHEN** 用户访问路径 `/`
- **THEN** 系统 SHALL 渲染 Dashboard 页面
#### Scenario: 用户管理页面路由
- **WHEN** 用户访问路径 `/users`
- **THEN** 系统 SHALL 渲染用户管理页面
#### Scenario: 系统设置页面路由
- **WHEN** 用户访问路径 `/settings`
- **THEN** 系统 SHALL 渲染系统设置页面
#### Scenario: 未知路径返回 404 页面
- **WHEN** 用户访问未定义的路径(如 `/unknown`
- **THEN** 系统 SHALL 渲染 NotFound 页面
### Requirement: 路由定义独立文件
路由定义 SHALL 抽离为独立文件 `src/web/routes.tsx``app.tsx` 引用该文件。
#### Scenario: 路由配置集中管理
- **WHEN** 开发者需要新增或修改路由
- **THEN** 系统 SHALL 在 `routes.tsx` 中统一管理所有 `<Route>` 定义
### Requirement: 路由守卫预留
系统 SHALL 预留路由守卫接口(`ProtectedRoute` 组件),但暂不实现认证逻辑。
#### Scenario: 路由守卫组件存在
- **WHEN** 需要实现认证保护
- **THEN** 系统 SHALL 提供 `ProtectedRoute` wrapper 组件供 Routes 使用
### Requirement: Vite code splitting 包含路由库
`vite.config.ts` 的 code splitting 配置 SHALL 新增 `vendor-router` 组,包含 `react-router`
#### Scenario: 路由库独立分包
- **WHEN** 执行生产构建
- **THEN** `react-router` SHALL 被打包到独立的 `vendor-router` chunk 中

View File

@@ -0,0 +1,56 @@
## 1. 依赖与配置
- [x] 1.1 安装 react-router 依赖
- [x] 1.2 更新 vite.config.ts code splitting 配置,新增 vendor-router 组
## 2. 工具与 Hook
- [x] 2.1 创建 src/web/menu.tsx定义菜单项配置
- [x] 2.2 创建 src/web/hooks/use-sidebar-collapsed.ts实现侧边栏折叠状态 hook
## 3. 页面组件
- [x] 3.1 创建 src/web/pages/dashboard/index.tsx迁移原 app.tsx 内容区逻辑
- [x] 3.2 创建 src/web/pages/users/index.tsx实现用户管理占位页面
- [x] 3.3 创建 src/web/pages/settings/index.tsx实现系统设置占位页面
- [x] 3.4 创建 src/web/pages/404/index.tsx实现 404 页面
## 4. 侧边栏组件
- [x] 4.1 创建 src/web/components/Sidebar/index.tsx实现侧边栏菜单组件
## 5. 路由配置
- [x] 5.1 创建 src/web/routes.tsx定义所有路由
## 6. 根组件重构
- [x] 6.1 重构 src/web/app.tsx实现 Header + Aside + Content 布局
- [x] 6.2 修改 src/web/main.tsx添加 BrowserRouter 包裹
## 7. 样式更新
- [x] 7.1 更新 src/web/styles.css迁移 .dashboard-* 为 .app-*,新增布局样式
## 8. 测试工具增强
- [x] 8.1 增强 tests/web/test-utils.tsx提供 renderWithProviders 函数
## 9. 组件测试
- [x] 9.1 创建 tests/web/components/Sidebar/index.test.tsx
- [x] 9.2 创建 tests/web/routes/dashboard.test.tsx
- [x] 9.3 创建 tests/web/routes/users.test.tsx
- [x] 9.4 创建 tests/web/routes/settings.test.tsx
- [x] 9.5 创建 tests/web/routes/404.test.tsx
- [x] 9.6 更新 tests/web/App.test.tsx 适配 BrowserRouter
## 10. 文档更新
- [x] 10.1 更新 DEVELOPMENT.md更新技术栈表格、组件树、目录结构说明
- [x] 10.2 更新 README.md更新技术栈表格、项目结构说明
## 11. 质量保障
- [x] 11.1 运行 bun run check 确保类型检查、lint、测试通过
- [x] 11.2 运行 bun run build 验证构建成功

View File

@@ -17,7 +17,7 @@ context: |
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
- 本项目为 Bun 全栈应用模板,es-toolkit 为后端首选工具库、recharts 为前端首选图表库
- 本项目为 Bun 全栈应用模板,README.md记录模板使用方法DEVELOPMENT.md记录模板使用的技术细节
rules:
proposal:

View File

@@ -0,0 +1,61 @@
## Purpose
定义应用全局常量作为应用元信息name、title、subtitle、description、version的唯一真实来源供前后端及构建脚本统一引用消除硬编码散落。
## Requirements
### Requirement: 应用元信息唯一来源
系统 SHALL 在 `src/shared/app.ts` 中定义应用全局常量 `APP`,包含以下字段:
- `name`机器标识kebab-case 格式)
- `title`:人类可读标题
- `subtitle`:副标题
- `description`:应用描述(用于 SEO meta
- `version`:语义版本号
`APP` SHALL 使用 `as const` 声明,保证字面量类型推断。
#### Scenario: 后端引用应用名称
- **WHEN** 后端代码需要应用名称(如 CLI 帮助文本、health 响应、启动日志)
- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.name`
#### Scenario: 前端引用应用标题
- **WHEN** 前端代码需要应用标题(如 Header logo、欢迎文本
- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.title`
#### Scenario: 构建脚本引用应用名称
- **WHEN** 构建脚本需要确定可执行文件名
- **THEN** 系统 SHALL 从 `src/shared/app.ts` 导入 `APP.name`
### Requirement: 前端 HTML 元信息动态设置
系统 SHALL 在 React 应用挂载时动态设置 HTML 元信息:
- `document.title` SHALL 设置为 `APP.title`
- `<meta name="description">` 内容 SHALL 设置为 `APP.description`
#### Scenario: 页面标题显示应用名称
- **WHEN** 用户访问应用
- **THEN** 浏览器标签页标题 SHALL 显示 `APP.title`
#### Scenario: meta description 设置
- **WHEN** 搜索引擎爬取页面
- **THEN** meta description SHALL 包含 `APP.description`
### Requirement: localStorage key 语义化命名
主题偏好存储 key SHALL 为 `"theme.preference"`,不包含应用名前缀。
#### Scenario: 主题偏好持久化
- **WHEN** 用户选择主题偏好system/light/dark
- **THEN** 系统 SHALL 将偏好值存储到 localStorage key `"theme.preference"`
#### Scenario: 主题偏好读取
- **WHEN** 应用初始化时读取用户主题偏好
- **THEN** 系统 SHALL 从 localStorage key `"theme.preference"` 读取

View File

@@ -1,5 +1,5 @@
{
"name": "{{app-name}}",
"name": "my-app",
"type": "module",
"private": true,
"scripts": {
@@ -48,6 +48,7 @@
"es-toolkit": "^1.46.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9"

View File

@@ -2,10 +2,12 @@ import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { APP } from "../src/shared/app";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const distWebDir = join(projectRoot, "dist/web");
const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, "dist/{{app-name}}");
const executablePath = join(projectRoot, `dist/${APP.name}`);
async function build() {
try {

View File

@@ -1,3 +1,5 @@
import { APP } from "../shared/app";
export interface ServerConfig {
host: string;
port: number;
@@ -43,7 +45,7 @@ export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPa
if (argv.length === 0) return {};
const firstArg = argv[0];
if (firstArg === "--help" || firstArg === "-h") {
console.log("用法: {{app-name}} [config.yaml]");
console.log(`用法: ${APP.name} [config.yaml]`);
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
process.exit(0);
}

View File

@@ -1,5 +1,7 @@
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
import { APP } from "../shared/app";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
@@ -18,7 +20,7 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
export function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "{{app-name}}",
service: APP.name,
timestamp: new Date().toISOString(),
};
}

View File

@@ -2,6 +2,7 @@ import type { RuntimeMode } from "../shared/api";
import type { ServerConfig } from "./config";
import type { StaticAssets } from "./static";
import { APP } from "../shared/app";
import { createApiError, jsonResponse } from "./helpers";
import { handleHealth } from "./routes/health";
import { serveStaticAsset } from "./static";
@@ -32,7 +33,7 @@ export function startServer(options: StartServerOptions) {
},
});
console.log(`{{app-name}} listening on ${server.url}`);
console.log(`${APP.name} listening on ${server.url}`);
return server;
}

7
src/shared/app.ts Normal file
View File

@@ -0,0 +1,7 @@
export const APP = {
description: "基于 Bun + React + TDesign 的全栈开发框架",
name: "my-app",
subtitle: "Bun 全栈应用",
title: "My App",
version: "0.1.0",
} as const;

View File

@@ -1,11 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { Layout, Menu, RadioGroup, Space } from "tdesign-react";
import type { HealthResponse } from "../shared/api";
import { useEffect } from "react";
import { useLocation } from "react-router";
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
import { Button, Layout, RadioGroup } from "tdesign-react";
import { APP } from "../shared/app";
import { Sidebar } from "./components/Sidebar";
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
import { MENU_ITEMS } from "./menu";
import { AppRoutes } from "./routes";
const { Aside, Content, Header } = Layout;
const { Content, Header } = Layout;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
@@ -14,54 +20,52 @@ const THEME_OPTIONS = [
export function App() {
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { data: health } = useQuery({
queryFn: fetchHealth,
queryKey: ["health"],
refetchInterval: 30000,
staleTime: 5000,
});
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
const location = useLocation();
useEffect(() => {
document.title = APP.title;
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
}, []);
const handleThemeChange = (value: ThemePreference) => {
setThemePreference(value);
};
const currentPath = location.pathname;
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
const pageTitle = currentItem?.label ?? APP.title;
return (
<Layout className="dashboard">
<Header>
<Menu.HeadMenu
logo={
<span className="dashboard-brand">
<span className="dashboard-logo">{"{{app-name}}"}</span>
</span>
}
operations={
<div className="dashboard-header-controls">
<RadioGroup
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
theme="button"
value={themePreference}
variant="default-filled"
/>
</div>
}
/>
</Header>
<Content>
<div className="dashboard-content">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<h2>使 {"{{app-name}}"}</h2>
<p> /health API </p>
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
</Space>
<Layout className="app-layout">
<Header className="app-header">
<div className="app-header-left">
<Button className="app-sidebar-toggle" onClick={toggleCollapsed} shape="square" variant="text">
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Button>
<span className="app-brand">{APP.title}</span>
<span className="app-page-title">{pageTitle}</span>
</div>
</Content>
<div className="app-header-right">
<RadioGroup
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
theme="button"
value={themePreference}
variant="default-filled"
/>
</div>
</Header>
<Layout>
<Aside className="app-sidebar" width={collapsed ? "80px" : "232px"}>
<Sidebar collapsed={collapsed} />
</Aside>
<Layout>
<Content className="app-content">
<AppRoutes />
</Content>
</Layout>
</Layout>
</Layout>
);
}
async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<HealthResponse>;
}

View File

@@ -0,0 +1,42 @@
import { useLocation, useNavigate } from "react-router";
import { Menu } from "tdesign-react";
import { MENU_ITEMS } from "../../menu";
const { MenuItem } = Menu;
interface SidebarProps {
collapsed: boolean;
}
export function Sidebar({ collapsed }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
const activeValue = currentItem?.value ?? "";
const handleMenuChange = (value: number | string) => {
const item = MENU_ITEMS.find((item) => item.value === value);
if (item) {
void navigate(item.path);
}
};
return (
<Menu
className="app-sidebar-menu"
collapsed={collapsed}
onChange={handleMenuChange}
value={activeValue}
width={collapsed ? "80px" : "232px"}
>
{MENU_ITEMS.map((item) => (
<MenuItem icon={item.icon} key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</Menu>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
export function applyInitialSidebarCollapsed() {
const collapsed = readSidebarCollapsed();
applySidebarCollapsed(collapsed);
}
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
root.setAttribute("data-sidebar-collapsed", String(collapsed));
}
export function parseSidebarCollapsed(value: unknown): boolean {
return value === "true";
}
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
try {
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
} catch {
return false;
}
}
export function useSidebarCollapsed() {
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
useEffect(() => {
applySidebarCollapsed(collapsed);
}, [collapsed]);
const setCollapsed = (nextCollapsed: boolean) => {
setCollapsedState(nextCollapsed);
writeSidebarCollapsed(nextCollapsed);
};
const toggleCollapsed = () => {
setCollapsed(!collapsed);
};
return { collapsed, setCollapsed, toggleCollapsed };
}
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
try {
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
} catch {
// 存储不可用时仅使用当前内存状态
}
}

View File

@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
export const THEME_PREFERENCE_STORAGE_KEY = "{{app-name}}.theme.preference";
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
export function applyInitialThemePreference() {

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{app-name}}" />
<title>{{app-name}}</title>
<meta name="description" content="" />
<title>App</title>
</head>
<body>
<div id="root"></div>

View File

@@ -2,9 +2,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./app";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
import "tdesign-react/dist/reset.css";
import "tdesign-react/dist/tdesign.min.css";
@@ -28,12 +30,15 @@ if (!rootElement) {
}
applyInitialThemePreference();
applyInitialSidebarCollapsed();
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>

18
src/web/menu.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { ReactElement } from "react";
import type { MenuValue } from "tdesign-react";
import { createElement } from "react";
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
export interface MenuItemConfig {
icon: ReactElement;
label: string;
path: string;
value: MenuValue;
}
export const MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
] as const;

View File

@@ -0,0 +1,22 @@
import { useNavigate } from "react-router";
import { ErrorCircleIcon } from "tdesign-icons-react";
import { Button, Space } from "tdesign-react";
export function NotFoundPage() {
const navigate = useNavigate();
const handleGoHome = () => {
void navigate("/");
};
return (
<Space align="center" className="not-found-page" direction="vertical" size="large">
<ErrorCircleIcon size="64px" style={{ color: "var(--td-warning-color)" }} />
<h1>404</h1>
<p>访</p>
<Button onClick={handleGoHome} theme="primary">
</Button>
</Space>
);
}

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { Space } from "tdesign-react";
import type { HealthResponse } from "../../../shared/api";
import { APP } from "../../../shared/app";
export function DashboardPage() {
const { data: health } = useQuery({
queryFn: fetchHealth,
queryKey: ["health"],
refetchInterval: 30000,
staleTime: 5000,
});
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<h2>使 {APP.title}</h2>
<p> /health API </p>
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
</Space>
);
}
async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<HealthResponse>;
}

View File

@@ -0,0 +1,12 @@
import { Card, Space } from "tdesign-react";
export function SettingsPage() {
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<h2></h2>
<Card>
<p>...</p>
</Card>
</Space>
);
}

View File

@@ -0,0 +1,12 @@
import { Card, Space } from "tdesign-react";
export function UsersPage() {
return (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<h2></h2>
<Card>
<p>...</p>
</Card>
</Space>
);
}

17
src/web/routes.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Route, Routes } from "react-router";
import { NotFoundPage } from "./pages/404";
import { DashboardPage } from "./pages/dashboard";
import { SettingsPage } from "./pages/settings";
import { UsersPage } from "./pages/users";
export function AppRoutes() {
return (
<Routes>
<Route element={<DashboardPage />} path="/" />
<Route element={<UsersPage />} path="/users" />
<Route element={<SettingsPage />} path="/settings" />
<Route element={<NotFoundPage />} path="*" />
</Routes>
);
}

View File

@@ -2,40 +2,66 @@
--td-brand-color: var(--td-brand-color-7);
}
.dashboard {
.app-layout {
min-height: 100vh;
background: var(--td-bg-color-page);
width: 100%;
}
.dashboard-content {
box-sizing: border-box;
max-width: 1400px;
margin: 0 auto;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
width: 100%;
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--td-comp-paddingLR-l);
background: var(--td-bg-color-container);
border-bottom: 1px solid var(--td-component-border);
height: 64px;
}
.dashboard-brand {
.app-header-left {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: var(--td-comp-margin-s);
line-height: 1.2;
align-items: center;
gap: var(--td-comp-margin-l);
}
.dashboard-logo {
.app-header-right {
display: inline-flex;
align-items: center;
gap: var(--td-comp-margin-s);
}
.app-sidebar-toggle {
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-s);
}
.app-brand {
margin: 0;
color: var(--td-text-color-primary);
font-size: calc(var(--td-font-size-title-large) + 6px);
font-weight: 700;
}
.dashboard-header-controls {
display: inline-flex;
align-items: center;
gap: var(--td-comp-margin-s);
margin-right: var(--td-comp-margin-xxl);
.app-page-title {
color: var(--td-text-color-secondary);
font-size: var(--td-font-size-title-medium);
font-weight: 500;
}
.app-sidebar {
background: var(--td-bg-color-container);
border-right: 1px solid var(--td-component-border);
height: calc(100vh - 64px);
overflow: hidden;
}
.app-sidebar-menu {
height: 100%;
}
.app-content {
box-sizing: border-box;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
min-height: calc(100vh - 64px);
}
.health-response {

View File

@@ -1,42 +1,14 @@
/* eslint-disable @typescript-eslint/require-await */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement, StrictMode } from "react";
import { createElement } from "react";
import { APP } from "../../src/shared/app";
import { App } from "../../src/web/app";
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
}
function renderApp() {
const queryClient = createTestQueryClient();
return render(
createElement(
StrictMode,
null,
createElement(
ErrorBoundary,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(App)),
),
),
);
}
import { renderWithProviders } from "./test-utils";
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
// mock /health fetch 避免网络错误
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
@@ -44,11 +16,26 @@ describe("App", () => {
});
}) as unknown as typeof fetch;
renderApp();
renderWithProviders(createElement(App));
expect(screen.getByText("{{app-name}}")).not.toBeNull();
expect(screen.getByText(APP.title)).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
test("渲染侧边栏菜单项", () => {
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,24 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { Sidebar } from "../../../../src/web/components/Sidebar";
import { renderWithProviders } from "../../test-utils";
describe("Sidebar", () => {
test("渲染菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
test("折叠状态下仍渲染菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: true }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { NotFoundPage } from "../../../src/web/pages/404";
import { renderWithProviders } from "../test-utils";
describe("NotFoundPage", () => {
test("渲染 404 页面", () => {
renderWithProviders(createElement(NotFoundPage));
expect(screen.getByText("404")).not.toBeNull();
expect(screen.getByText("您访问的页面不存在")).not.toBeNull();
expect(screen.getByText("返回首页")).not.toBeNull();
});
});

View File

@@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/require-await */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { DashboardPage } from "../../../src/web/pages/dashboard";
import { renderWithProviders } from "../test-utils";
describe("DashboardPage", () => {
test("渲染欢迎信息", () => {
window.fetch = (async () => {
return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
}) as unknown as typeof fetch;
renderWithProviders(createElement(DashboardPage));
expect(screen.getByText(/欢迎使用/)).not.toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { SettingsPage } from "../../../src/web/pages/settings";
import { renderWithProviders } from "../test-utils";
describe("SettingsPage", () => {
test("渲染系统设置页面", () => {
renderWithProviders(createElement(SettingsPage));
expect(screen.getByText("系统设置")).not.toBeNull();
expect(screen.getByText("页面建设中...")).not.toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { UsersPage } from "../../../src/web/pages/users";
import { renderWithProviders } from "../test-utils";
describe("UsersPage", () => {
test("渲染用户管理页面", () => {
renderWithProviders(createElement(UsersPage));
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("页面建设中...")).not.toBeNull();
});
});

View File

@@ -1,7 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { mock } from "bun:test";
import { createElement, StrictMode } from "react";
import { MemoryRouter } from "react-router";
// Note: jsdom and polyfills are now set up in tests/setup.ts
// This file only contains component-specific mocks
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
// Mock recharts BEFORE any component imports
void mock.module("recharts", () => ({
@@ -15,6 +18,42 @@ void mock.module("recharts", () => ({
YAxis: () => null,
}));
export interface RenderWithProvidersOptions {
initialRoute?: string;
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
const queryClient = createTestQueryClient();
const initialRoute = options?.initialRoute ?? "/";
return render(
createElement(
StrictMode,
null,
createElement(
ErrorBoundary,
null,
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(MemoryRouter, { initialEntries: [initialRoute] }, ui),
),
),
),
);
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
}
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {

View File

@@ -13,6 +13,10 @@ export default defineConfig({
name: "vendor-react",
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
},
{
name: "vendor-router",
test: /[\\/]node_modules[\\/](react-router)[\\/]/,
},
{
name: "vendor-tdesign",
test: /[\\/]node_modules[\\/](tdesign-react|tdesign-icons-react)[\\/]/,