Compare commits
3 Commits
e2bf594719
...
4caf502908
| Author | SHA1 | Date | |
|---|---|---|---|
| 4caf502908 | |||
| 5aed73523e | |||
| 4a38d4dac7 |
@@ -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 主题偏好 hook(system/light/dark,localStorage 记忆 + matchMedia 监听)
|
||||
use-sidebar-collapsed.ts 侧边栏折叠状态 hook(localStorage 记忆)
|
||||
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,10 +237,21 @@ main.tsx
|
||||
└── StrictMode
|
||||
└── ErrorBoundary(React 错误边界)
|
||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||
├── App(根组件,Layout + Header + Content 骨架)
|
||||
│ ├── useThemePreference() ── Header 主题模式 RadioGroup(系统/明亮/黑暗,localStorage 记忆 + theme-mode 应用)
|
||||
│ ├── useQuery["health"] ── GET /health(30s 轮询,前后端联调示例)
|
||||
│ └── Content ── 欢迎页 + /health API 响应 JSON 展示
|
||||
└── BrowserRouter(React Router 路由)
|
||||
├── App(根组件,Admin 布局)
|
||||
│ ├── useThemePreference() ── Header 主题模式 RadioGroup(系统/明亮/黑暗)
|
||||
│ ├── useSidebarCollapsed() ── 侧边栏折叠状态(localStorage 记忆)
|
||||
│ ├── Layout
|
||||
│ │ ├── Header(折叠按钮 + 品牌名 + 页标题 + 主题切换)
|
||||
│ │ └── Layout(嵌套)
|
||||
│ │ ├── Aside
|
||||
│ │ │ └── Sidebar(TDesign Menu,菜单项点击导航)
|
||||
│ │ └── Content
|
||||
│ │ └── AppRoutes(路由配置)
|
||||
│ │ ├── / → DashboardPage(欢迎语 + /health 联调)
|
||||
│ │ ├── /users → UsersPage(占位)
|
||||
│ │ ├── /settings → SettingsPage(占位)
|
||||
│ │ └── * → NotFoundPage(404)
|
||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||
```
|
||||
|
||||
@@ -233,8 +261,24 @@ main.tsx
|
||||
hooks/use-theme-preference.ts(浏览器 UI 偏好)
|
||||
├── ThemePreference: system / light / dark(RadioGroup 受控值)
|
||||
├── 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 数据层
|
||||
|
||||
50
README.md
50
README.md
@@ -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/) | 图表可视化(推荐使用) |
|
||||
|
||||
7
bun.lock
7
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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 或文档路径:
|
||||
|
||||
@@ -35,11 +49,12 @@
|
||||
按以下优先级检查:
|
||||
|
||||
| 优先级 | 维度 | 检查点 |
|
||||
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么;apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
|
||||
| P1 | 文档同步性 | 对实际存在的 artifacts 检查:已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档;若影响模块结构、API、实体或用户可见行为,再检查 README 是否同步 |
|
||||
| P2 | 文档要求覆盖 | 对实际存在的 artifacts 检查:文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md`、`design.md`、`specs/*.md`、`tasks.md` |
|
||||
| P3 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
|
||||
| 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 补充清单" 是否已清空或已标注保留原因
|
||||
- "方向待确认清单" 是否已清空或已记录用户决策
|
||||
- "任务状态问题清单" 和 "测试问题清单" 是否已清空或已标注残留原因
|
||||
- "代码质量/优化清单" 中哪些已处理,哪些有意延期
|
||||
|
||||
@@ -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. 收尾
|
||||
|
||||
|
||||
2
openspec/changes/refactor-frontend-layout/.openspec.yaml
Normal file
2
openspec/changes/refactor-frontend-layout/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
95
openspec/changes/refactor-frontend-layout/design.md
Normal file
95
openspec/changes/refactor-frontend-layout/design.md
Normal 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 splitting:react-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 → 使用成熟 API(BrowserRouter/Routes/Route),避免实验性特性
|
||||
36
openspec/changes/refactor-frontend-layout/proposal.md
Normal file
36
openspec/changes/refactor-frontend-layout/proposal.md
Normal 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 已支持)
|
||||
@@ -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 变为 80px,Menu 仅显示图标
|
||||
|
||||
#### Scenario: 侧边栏展开宽度
|
||||
|
||||
- **WHEN** 侧边栏展开
|
||||
- **THEN** Aside 宽度 SHALL 变为 232px,Menu 显示图标和文字
|
||||
|
||||
#### 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` 类名
|
||||
@@ -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"` 读取
|
||||
@@ -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 中
|
||||
56
openspec/changes/refactor-frontend-layout/tasks.md
Normal file
56
openspec/changes/refactor-frontend-layout/tasks.md
Normal 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 验证构建成功
|
||||
@@ -17,7 +17,7 @@ context: |
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
- 优先使用提问工具对用户进行提问
|
||||
- 本项目为 Bun 全栈应用模板,es-toolkit 为后端首选工具库、recharts 为前端首选图表库
|
||||
- 本项目为 Bun 全栈应用模板,README.md记录模板使用方法,DEVELOPMENT.md记录模板使用的技术细节
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
|
||||
61
openspec/specs/app-constants/spec.md
Normal file
61
openspec/specs/app-constants/spec.md
Normal 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"` 读取
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
7
src/shared/app.ts
Normal 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;
|
||||
@@ -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,28 +20,33 @@ 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">
|
||||
<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>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
@@ -44,24 +55,17 @@ export function App() {
|
||||
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>
|
||||
</div>
|
||||
<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>;
|
||||
}
|
||||
|
||||
42
src/web/components/Sidebar/index.tsx
Normal file
42
src/web/components/Sidebar/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
51
src/web/hooks/use-sidebar-collapsed.ts
Normal 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 {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
18
src/web/menu.tsx
Normal file
18
src/web/menu.tsx
Normal 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;
|
||||
22
src/web/pages/404/index.tsx
Normal file
22
src/web/pages/404/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/web/pages/dashboard/index.tsx
Normal file
29
src/web/pages/dashboard/index.tsx
Normal 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>;
|
||||
}
|
||||
12
src/web/pages/settings/index.tsx
Normal file
12
src/web/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/web/pages/users/index.tsx
Normal file
12
src/web/pages/users/index.tsx
Normal 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
17
src/web/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
24
tests/web/components/Sidebar/index.test.tsx
Normal file
24
tests/web/components/Sidebar/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
16
tests/web/routes/404.test.tsx
Normal file
16
tests/web/routes/404.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
22
tests/web/routes/dashboard.test.tsx
Normal file
22
tests/web/routes/dashboard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
15
tests/web/routes/settings.test.tsx
Normal file
15
tests/web/routes/settings.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
15
tests/web/routes/users.test.tsx
Normal file
15
tests/web/routes/users.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)[\\/]/,
|
||||
|
||||
Reference in New Issue
Block a user