From 4caf502908f986807b1e2ed20b5fa3c2b99b0a27 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 20 May 2026 19:06:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=BA=E4=BC=81=E4=B8=9A=20Admin=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E5=BC=95=E5=85=A5=20React=20Router?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 React Router v7 (Declarative mode) 实现 SPA 路由 - 重构 Layout 为 Header + 侧边栏 + 内容区的企业 Admin 布局 - 新增侧边栏菜单组件,支持折叠/展开,状态持久化到 localStorage - 新增示例页面:仪表盘、用户管理、系统设置、404 - 菜单配置与路由统一为单一数据源 (menu.tsx) - Vite code splitting 新增 vendor-router 组 - 更新 DEVELOPMENT.md 和 README.md 文档 --- DEVELOPMENT.md | 60 ++++++-- README.md | 17 ++- bun.lock | 7 + docs/prompts/prompt-apply-review.md | 60 +++++--- docs/prompts/prompt-proposal-review.md | 25 +++- .../refactor-frontend-layout/.openspec.yaml | 2 + .../refactor-frontend-layout/design.md | 95 ++++++++++++ .../refactor-frontend-layout/proposal.md | 36 +++++ .../specs/admin-layout/spec.md | 138 ++++++++++++++++++ .../specs/app-constants/spec.md | 15 ++ .../specs/frontend-routing/spec.md | 57 ++++++++ .../changes/refactor-frontend-layout/tasks.md | 56 +++++++ package.json | 1 + src/web/app.tsx | 89 ++++++----- src/web/components/Sidebar/index.tsx | 42 ++++++ src/web/hooks/use-sidebar-collapsed.ts | 51 +++++++ src/web/main.tsx | 7 +- src/web/menu.tsx | 18 +++ src/web/pages/404/index.tsx | 22 +++ src/web/pages/dashboard/index.tsx | 29 ++++ src/web/pages/settings/index.tsx | 12 ++ src/web/pages/users/index.tsx | 12 ++ src/web/routes.tsx | 17 +++ src/web/styles.css | 62 +++++--- tests/web/App.test.tsx | 52 +++---- tests/web/components/Sidebar/index.test.tsx | 24 +++ tests/web/routes/404.test.tsx | 16 ++ tests/web/routes/dashboard.test.tsx | 22 +++ tests/web/routes/settings.test.tsx | 15 ++ tests/web/routes/users.test.tsx | 15 ++ tests/web/test-utils.tsx | 43 +++++- vite.config.ts | 4 + 32 files changed, 981 insertions(+), 140 deletions(-) create mode 100644 openspec/changes/refactor-frontend-layout/.openspec.yaml create mode 100644 openspec/changes/refactor-frontend-layout/design.md create mode 100644 openspec/changes/refactor-frontend-layout/proposal.md create mode 100644 openspec/changes/refactor-frontend-layout/specs/admin-layout/spec.md create mode 100644 openspec/changes/refactor-frontend-layout/specs/app-constants/spec.md create mode 100644 openspec/changes/refactor-frontend-layout/specs/frontend-routing/spec.md create mode 100644 openspec/changes/refactor-frontend-layout/tasks.md create mode 100644 src/web/components/Sidebar/index.tsx create mode 100644 src/web/hooks/use-sidebar-collapsed.ts create mode 100644 src/web/menu.tsx create mode 100644 src/web/pages/404/index.tsx create mode 100644 src/web/pages/dashboard/index.tsx create mode 100644 src/web/pages/settings/index.tsx create mode 100644 src/web/pages/users/index.tsx create mode 100644 src/web/routes.tsx create mode 100644 tests/web/components/Sidebar/index.test.tsx create mode 100644 tests/web/routes/404.test.tsx create mode 100644 tests/web/routes/dashboard.test.tsx create mode 100644 tests/web/routes/settings.test.tsx create mode 100644 tests/web/routes/users.test.tsx diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b14aeeb..8855a2a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -37,16 +37,31 @@ src/ 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 三步构建流水线 @@ -211,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 组件树与数据流 @@ -222,11 +237,22 @@ 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 展示 - └── ReactQueryDevtools(开发工具,仅开发环境) + └── 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(开发工具,仅开发环境) ``` **Hook 架构**: @@ -237,6 +263,22 @@ hooks/use-theme-preference.ts(浏览器 UI 偏好) ├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode) ├── 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: } +│ ├── { value: "users", label: "用户管理", path: "/users", icon: } +│ └── { value: "settings", label: "系统设置", path: "/settings", icon: } +└── Sidebar 和 Routes 共同引用,保证菜单项与路由同步 ``` ### 2.3 TanStack Query 数据层 diff --git a/README.md b/README.md index 52f12e5..ee99dda 100644 --- a/README.md +++ b/README.md @@ -115,13 +115,23 @@ bun run dev │ │ └── 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/ # 项目文档 @@ -182,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/) | 图表可视化(推荐使用) | diff --git a/bun.lock b/bun.lock index 208940b..a57f429 100644 --- a/bun.lock +++ b/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=="], diff --git a/docs/prompts/prompt-apply-review.md b/docs/prompts/prompt-apply-review.md index a34f3b2..377506a 100644 --- a/docs/prompts/prompt-apply-review.md +++ b/docs/prompts/prompt-apply-review.md @@ -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 补充清单" 是否已清空或已标注保留原因 - "方向待确认清单" 是否已清空或已记录用户决策 - "任务状态问题清单" 和 "测试问题清单" 是否已清空或已标注残留原因 - "代码质量/优化清单" 中哪些已处理,哪些有意延期 diff --git a/docs/prompts/prompt-proposal-review.md b/docs/prompts/prompt-proposal-review.md index 9fa8f1b..4ebb5f2 100644 --- a/docs/prompts/prompt-proposal-review.md +++ b/docs/prompts/prompt-proposal-review.md @@ -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. 收尾 diff --git a/openspec/changes/refactor-frontend-layout/.openspec.yaml b/openspec/changes/refactor-frontend-layout/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/refactor-frontend-layout/design.md b/openspec/changes/refactor-frontend-layout/design.md new file mode 100644 index 0000000..a899a49 --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/design.md @@ -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),避免实验性特性 diff --git a/openspec/changes/refactor-frontend-layout/proposal.md b/openspec/changes/refactor-frontend-layout/proposal.md new file mode 100644 index 0000000..ad369c5 --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/proposal.md @@ -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 已支持) diff --git a/openspec/changes/refactor-frontend-layout/specs/admin-layout/spec.md b/openspec/changes/refactor-frontend-layout/specs/admin-layout/spec.md new file mode 100644 index 0000000..3b192e8 --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/specs/admin-layout/spec.md @@ -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` 类名 diff --git a/openspec/changes/refactor-frontend-layout/specs/app-constants/spec.md b/openspec/changes/refactor-frontend-layout/specs/app-constants/spec.md new file mode 100644 index 0000000..94ab2dc --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/specs/app-constants/spec.md @@ -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"` 读取 diff --git a/openspec/changes/refactor-frontend-layout/specs/frontend-routing/spec.md b/openspec/changes/refactor-frontend-layout/specs/frontend-routing/spec.md new file mode 100644 index 0000000..1257f0d --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/specs/frontend-routing/spec.md @@ -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` 中统一管理所有 `` 定义 + +### 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 中 diff --git a/openspec/changes/refactor-frontend-layout/tasks.md b/openspec/changes/refactor-frontend-layout/tasks.md new file mode 100644 index 0000000..20652b9 --- /dev/null +++ b/openspec/changes/refactor-frontend-layout/tasks.md @@ -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 验证构建成功 diff --git a/package.json b/package.json index c99fc9c..815fe8f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/web/app.tsx b/src/web/app.tsx index ae68998..2836c1d 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,13 +1,17 @@ -import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; -import { Layout, Menu, RadioGroup, Space } from "tdesign-react"; - -import type { HealthResponse } from "../shared/api"; +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" }, @@ -16,12 +20,8 @@ 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; @@ -32,43 +32,40 @@ export function App() { setThemePreference(value); }; + const currentPath = location.pathname; + const currentItem = MENU_ITEMS.find((item) => item.path === currentPath); + const pageTitle = currentItem?.label ?? APP.title; + return ( - -
- - {APP.title} - - } - operations={ -
- ({ label: option.label, value: option.value }))} - theme="button" - value={themePreference} - variant="default-filled" - /> -
- } - /> -
- -
- -

欢迎使用 {APP.title}

-

在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):

- {health &&
{JSON.stringify(health, null, 2)}
} -
+ +
+
+ + {APP.title} + {pageTitle}
- +
+ ({ label: option.label, value: option.value }))} + theme="button" + value={themePreference} + variant="default-filled" + /> +
+
+ + + + + + + +
); } - -async function fetchHealth(): Promise { - const response = await fetch("/health"); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; -} diff --git a/src/web/components/Sidebar/index.tsx b/src/web/components/Sidebar/index.tsx new file mode 100644 index 0000000..e801b3c --- /dev/null +++ b/src/web/components/Sidebar/index.tsx @@ -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_ITEMS.map((item) => ( + + {item.label} + + ))} + + ); +} diff --git a/src/web/hooks/use-sidebar-collapsed.ts b/src/web/hooks/use-sidebar-collapsed.ts new file mode 100644 index 0000000..8ca5ff8 --- /dev/null +++ b/src/web/hooks/use-sidebar-collapsed.ts @@ -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(() => 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 { + // 存储不可用时仅使用当前内存状态 + } +} diff --git a/src/web/main.tsx b/src/web/main.tsx index 1baa0bd..5705de5 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -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( - + + + diff --git a/src/web/menu.tsx b/src/web/menu.tsx new file mode 100644 index 0000000..62d0c23 --- /dev/null +++ b/src/web/menu.tsx @@ -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; diff --git a/src/web/pages/404/index.tsx b/src/web/pages/404/index.tsx new file mode 100644 index 0000000..937f956 --- /dev/null +++ b/src/web/pages/404/index.tsx @@ -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 ( + + +

404

+

您访问的页面不存在

+ +
+ ); +} diff --git a/src/web/pages/dashboard/index.tsx b/src/web/pages/dashboard/index.tsx new file mode 100644 index 0000000..1475782 --- /dev/null +++ b/src/web/pages/dashboard/index.tsx @@ -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 ( + +

欢迎使用 {APP.title}

+

在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):

+ {health &&
{JSON.stringify(health, null, 2)}
} +
+ ); +} + +async function fetchHealth(): Promise { + const response = await fetch("/health"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/src/web/pages/settings/index.tsx b/src/web/pages/settings/index.tsx new file mode 100644 index 0000000..2557c9e --- /dev/null +++ b/src/web/pages/settings/index.tsx @@ -0,0 +1,12 @@ +import { Card, Space } from "tdesign-react"; + +export function SettingsPage() { + return ( + +

系统设置

+ +

页面建设中...

+
+
+ ); +} diff --git a/src/web/pages/users/index.tsx b/src/web/pages/users/index.tsx new file mode 100644 index 0000000..28fac96 --- /dev/null +++ b/src/web/pages/users/index.tsx @@ -0,0 +1,12 @@ +import { Card, Space } from "tdesign-react"; + +export function UsersPage() { + return ( + +

用户管理

+ +

页面建设中...

+
+
+ ); +} diff --git a/src/web/routes.tsx b/src/web/routes.tsx new file mode 100644 index 0000000..ab9eaf3 --- /dev/null +++ b/src/web/routes.tsx @@ -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 ( + + } path="/" /> + } path="/users" /> + } path="/settings" /> + } path="*" /> + + ); +} diff --git a/src/web/styles.css b/src/web/styles.css index a309ef4..01a3d07 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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 { diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index f95e273..e4f6952 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -1,43 +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" }, @@ -45,11 +16,26 @@ describe("App", () => { }); }) as unknown as typeof fetch; - renderApp(); + renderWithProviders(createElement(App)); 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); + }); }); diff --git a/tests/web/components/Sidebar/index.test.tsx b/tests/web/components/Sidebar/index.test.tsx new file mode 100644 index 0000000..3436c1b --- /dev/null +++ b/tests/web/components/Sidebar/index.test.tsx @@ -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(); + }); +}); diff --git a/tests/web/routes/404.test.tsx b/tests/web/routes/404.test.tsx new file mode 100644 index 0000000..3047c49 --- /dev/null +++ b/tests/web/routes/404.test.tsx @@ -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(); + }); +}); diff --git a/tests/web/routes/dashboard.test.tsx b/tests/web/routes/dashboard.test.tsx new file mode 100644 index 0000000..932d704 --- /dev/null +++ b/tests/web/routes/dashboard.test.tsx @@ -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(); + }); +}); diff --git a/tests/web/routes/settings.test.tsx b/tests/web/routes/settings.test.tsx new file mode 100644 index 0000000..b5b38bb --- /dev/null +++ b/tests/web/routes/settings.test.tsx @@ -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(); + }); +}); diff --git a/tests/web/routes/users.test.tsx b/tests/web/routes/users.test.tsx new file mode 100644 index 0000000..5ac3402 --- /dev/null +++ b/tests/web/routes/users.test.tsx @@ -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(); + }); +}); diff --git a/tests/web/test-utils.tsx b/tests/web/test-utils.tsx index 2baa56f..d337be6 100644 --- a/tests/web/test-utils.tsx +++ b/tests/web/test-utils.tsx @@ -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) => { diff --git a/vite.config.ts b/vite.config.ts index 33e72c8..d38855c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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)[\\/]/,