diff --git a/.gitignore b/.gitignore index d11b14b..97daf90 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules/ dist/ build/ coverage/ +playwright-report/ +test-results/ +.playwright/ # IDE .idea/ diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 684a5dc..b225e10 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -58,7 +58,7 @@ - 整体里程碑: — - 功能: - - [ ] FE-01 登录页(用户名/密码/版本下拉登录,对接 POST /api/usr/login) - - [ ] FE-02 主页与导航框架(顶栏 + 全部导航总览 + 主页 KPI 看板 + 常用操作;登录后落地页与路由壳) - - [ ] FE-03 用户列表与查询(工具栏刷新/导出 + 筛选条件 + 用户表格 + 分页,对接 GET /api/usr/users) - - [ ] FE-04 用户信息单据(新增/修改用户表单 + 权限组勾选,对接 POST /api/usr/users 与 PUT /api/usr/users/{id}) + - [x] FE-01 登录页(用户名/密码/版本下拉登录,对接 POST /api/usr/login) + - [x] FE-02 主页与导航框架(顶栏 + 全部导航总览 + 主页 KPI 看板 + 常用操作;登录后落地页与路由壳) + - [x] FE-03 用户列表与查询(工具栏刷新/导出 + 筛选条件 + 用户表格 + 分页,对接 GET /api/usr/users) + - [x] FE-04 用户信息单据(新增/修改用户表单 + 权限组勾选,对接 POST /api/usr/users 与 PUT /api/usr/users/{id}) diff --git a/docs/superpowers/module-reports/2026-06-02-frontend-phase.md b/docs/superpowers/module-reports/2026-06-02-frontend-phase.md new file mode 100644 index 0000000..9fa909e --- /dev/null +++ b/docs/superpowers/module-reports/2026-06-02-frontend-phase.md @@ -0,0 +1,206 @@ +# 模块完成报告 — 前端阶段(frontend-phase) + +> 标准化 12 节《模块完成报告》。本报告由 test-gate 绿后渲染,供 milestone 标记使用。 +> 数据来源:仅取 git 摘要(`diff --stat` / `log --oneline`)、FE specs/plans/reviews、test-gate 证据,未读 diff 正文进上下文。 +> 输出语言:中文。阶段 = 前端(frontend),实现作用域 = `frontend/`。 + +--- + +## ① 模块标识 + +| 字段 | 值 | +|---|---| +| module_id | `frontend-phase` | +| module_name | 前端阶段(整体) | +| 阶段 | 前端(frontend) | +| 分支 | `frontend-phase`(自默认分支 `master` 分叉) | +| 默认分支 | `master` | +| 分叉点(merge-base) | `236c42b1e28ff8ecd775faf06071be595fbf81d3` | +| 里程碑 | 整个前端阶段 1 个里程碑 tag(`milestone/frontend-phase`),由本报告 commit 后于 milestone 步骤标记 | +| 报告日期 | 2026-06-02 | + +--- + +## ② FE 完成清单 + +前端阶段以 `frontend-phase` 单一阶段聚合 4 个 FE 业务功能(粒度=业务功能,可关联多个 prototype 区域与多个 REQ)。按 FE-NN 顺序: + +| FE-NN | 业务功能 | 关联原型区域 | 关联 REQ | spec / plan / review / verify 产物 | docs/08 § 三状态 | +|---|---|---|---|---|---| +| FE-01 | 登录页(用户名/密码/版本下拉登录,对接 `POST /api/usr/login`) | `#screen-login`(`.login-wrap` / `.login-card` / `.login-foot`) | REQ-USR-004(主);配套读端点 `GET /api/usr/companies` | spec `2026-06-01-FE-01.md` / plan 同名 / review `2026-06-01-FE-01.md`(approve)/ verify `2026-06-01-FE-01-verify.md` | [x] approved | +| FE-02 | 主页与导航框架(顶栏 + 全部导航总览 + 主页 KPI 看板 + 常用操作;登录后落地页与路由壳) | `#topbar` / `#nav-overlay` / `#screen-main`(`.kpi-head` / `.three-col` / `.common-ops`) | 壳层无直接 CRUD REQ,承载 FE-01/03/04;当前用户身份来自 REQ-USR-004 响应 | spec `2026-06-01-FE-02.md` / plan 同名 / review `2026-06-01-FE-02.md`(approve)/ verify `2026-06-01-FE-02-verify.md` | [x] approved | +| FE-03 | 用户列表与查询(工具栏刷新/导出 + 筛选条件 + 用户表格 + 分页,对接 `GET /api/usr/users`) | `#screen-userlist`(`.toolbar` / `.filterbar` / `.grid-table` / `.pager`) | REQ-USR-003(主) | spec `2026-06-01-FE-03.md` / plan 同名 / review `2026-06-01-FE-03.md`(approve)/ verify `2026-06-01-FE-03-verify.md` | [x] approved | +| FE-04 | 用户信息单据(新增/修改用户表单 + 权限组勾选,对接 `POST /api/usr/users` + `PUT /api/usr/users/{id}`) | `#screen-userdetail`(`.toolbar` / `.form-grid` / `.tabs-row` / `.perm-list`) | REQ-USR-001(增)+ REQ-USR-002(改) | spec `2026-06-01-FE-04.md` / plan 同名 / review `2026-06-01-FE-04.md`(r1 request-changes → r2 approve)/ verify `2026-06-01-FE-04-verify.md` + `-verify-r1.md` | [x] approved | + +> 4 个 FE 在 docs/08 § 三均为 `[x]`(已 approved)。FE-04 经历 1 轮 request-changes(must-fix B1:edit 态预填把用户主键当「用户号」查询)后 fix 复验通过,详见 § ⑧/§ ⑨。 + +--- + +## ③ 文件变更(git diff --stat master...HEAD,三点 diff) + +区间 = 功能分支 `frontend-phase` 自默认分支 `master` 分叉以来的全部改动。 + +- **总计**:126 files changed,**17860 insertions(+)**,4 deletions(-)。 +- **实现代码(`frontend/`)**:源码 + 配置 + 测试,全部落在 `frontend/` 作用域内(无 `backend/` / `sql/` / `scripts/` 越界)。 +- **文档(`docs/`)**:FE-01~04 的 specs/plans/reviews/verify + test-gate 证据 + docs/08 § 三勾选更新。 +- **根级**:`.gitignore`(+3)。 + +### 分类汇总 + +| 分类 | 主要文件(节选) | 量级 | +|---|---|---| +| 工程骨架 / 配置 | `frontend/package.json` / `package-lock.json`(+6352)/ `vite.config.ts` / `tsconfig.json` / `.eslintrc.cjs` / `playwright.config.ts` / `index.html` | 8 文件 | +| API 基建 | `src/api/request.ts`(Axios 实例 + Result 拆包 + 401 拦截)/ `src/api/types.ts` / `src/api/usrApi.ts`(login/companies/listUsers/create/update/detail/employees/permissions) | 3 文件 | +| Store | `src/store/store.ts` / `hooks.ts` / `slices/authSlice.ts`(登录态 + token 持久化) | 3 文件 | +| 路由 | `src/router/index.tsx` / `RequireAuth.tsx` / `RedirectIfAuthed.tsx` / `AppErrorBoundary.tsx` | 4 文件 | +| 外壳(FE-02) | `src/layouts/AppLayout/*`(AppLayout / TopBar / NavOverlay / CurrentUserMenu / AppFooter / useTabStack / navConfig / shellMessages) | 10 文件 | +| 登录页(FE-01) | `src/pages/usr/Login/*`(LoginPage / Login.module.css / loginMessages) | 3 文件 | +| 主页(FE-02) | `src/pages/home/HomePage/*`(HomePage / KpiBoard / KpiHeadBar / RoleProcessTree / CommonOps / dashboardData / 样式) | 7 文件 | +| 用户列表(FE-03) | `src/pages/usr/UserList/*`(index / UserToolbar / UserFilterBar / UserTable / columns / constants / exportUtils / useUserList / 样式) | 9 文件 | +| 用户单据(FE-04) | `src/pages/usr/UserDetail/*`(index / UserDetailToolbar / UserBasicForm / PermissionTabs / PermissionGroupList / useUserDetail / constants / 样式) | 9 文件 | +| 全局样式 / 入口 | `src/main.tsx` / `App.tsx` / `styles/global.css` / `styles/theme.ts` / `vite-env.d.ts` | 5 文件 | +| 测试 | `tests/setup.ts` + `tests/unit/*`(40 单测文件)+ `tests/e2e/*`(login / shell / userlist / userdetail 4 spec) | 45 文件 | +| 文档证据 | `docs/superpowers/{specs,plans,reviews}/2026-06-01-FE-0*.md` + `module-reports/frontend-phase-test-gate-r1.md` + docs/08 § 三 | 17 文件 | + +--- + +## ④ 数据库使用表 + +`N/A(前端阶段)` —— 前端阶段不直接访问数据库,所有数据经后端端点(`/api/usr/*`)消费。 + +--- + +## ⑤ 测试闸(test-gate)记录与 flake 判定 + +> 汇总 `docs/superpowers/module-reports/frontend-phase-test-gate-r*.md`(按 attempt 升序)。 + +- **attempt 总数**:1(仅 `frontend-phase-test-gate-r1.md`)。 +- **flake 判定**:**无 flake**(仅 1 次 attempt,无 red→green 切换)。最后一份(也是唯一一份)= **GREEN**,前置闸通过。 + +### attempt 1(r1)— GREEN + +| 套件 | 命令 | exit_code | passed | failed | 时间窗口 | +|---|---|---|---|---|---| +| 单元(vitest) | `npm run test:unit`(= `vitest run`) | 0 | **193**(40 文件) | 0 | 2026-06-02 09:26:46 → 09:26:53 | +| E2E(playwright) | `npm run test:e2e`(= `playwright test`,chromium,Vite dev server 5173,`page.route` 桩后端) | 0 | **20** | 0 | 2026-06-02 09:26:57 → 09:27:04 | + +- **结论**:两套件 `exit_code` 均 0、`failed=0`,覆盖全部 FE 回归(vitest 193 + playwright 20)。 +- E2E 覆盖 spec:`login.spec.ts` / `shell.spec.ts` / `userlist.spec.ts` / `userdetail.spec.ts`。 +- **非致命噪声(不影响断言)**:jsdom 侧 `window.getComputedStyle not implemented`、React Router v7 future flag 警告;E2E 侧 `[vite] http proxy error ... ECONNREFUSED`(dev proxy 向未启动的真实后端转发,用例已用 `page.route` 拦截 `/api/**`,与断言无关)。 +- 命令来源:`docs/04-技术规范.md § 零` 命令清单(前端 unit=`vitest run`、e2e=`playwright test`)。执行方式:派发独立(detached)子进程跑测试,主会话仅消费结构化结果,未在主会话直接跑测试。 + +--- + +## ⑥ Migration(Flyway) + +`N/A(前端阶段)` —— 前端阶段不涉及 schema 演化,未新增 `sql/migrations/V*.sql`。 + +--- + +## ⑦ 跨模块改动 + +`N/A(前端阶段)` —— 实现改动全部落在 `frontend/` 作用域内(git diff --stat 确认无 `backend/` / `sql/` / `scripts/` 文件变更);无跨模块日志(`docs/superpowers/cross-module-log*` 不存在),无需记录跨模块影响评估。 + +> 待对齐项(非本阶段改动,记于此供后端编码期消化):FE-04 § 8 D1/D2 的支撑只读端点 `GET /api/usr/employees`(员工下拉)/ `GET /api/usr/permissions`(权限组)需在后端 REQ-USR-001/002 实现内补齐契约(同 FE-01 `GET /api/usr/companies` 先例)。这是跨阶段(前端→后端)的接口对齐项,前端已按既有先例消费,未越界改后端代码。 + +--- + +## ⑧ 偏离清单(实际渲染 DOM ↔ 各 FE 关联原型主结构) + +> 硬验证项:逐 FE 列举「实际渲染 DOM 与关联原型主结构的差异」。原型 `prototype/erp.html` 为单文件静态 demo(4 个 `#screen-*` 区段),实现为 React + AntD 5 路由化应用。下列偏离**均为 spec 已登记的有意决策(D 系列)或交互语义复刻取舍**,非缺陷;每条注明对应决策来源。 + +### FE-01 登录页(原型 `#screen-login`) + +| # | 偏离 | 性质 | 来源 | +|---|---|---|---| +| 1 | 原型「屏切换」demo(`goTo('login')` + 按钮 `data-go="main"` 直接切主页)→ 实现为独立路由 `/login`,提交走 `POST /api/usr/login`,成功才 `navigate('/')` | 有意(交互语义复刻) | FE-01 spec § 6.4 / D3 | +| 2 | 原型版本下拉硬编码「标准版」demo 项 → 实现 options 全部来自 `GET /api/usr/companies`,不硬编码 | 有意 | FE-01 D1/D8 | +| 3 | 主视觉深蓝渐变 / 网格透视背景作为登录页局部 scoped 装饰样式保留,不新增全局 token、不挪用语义 token | 有意(tokens.css 无对应品牌深色) | FE-01 D7 | +| 4 | 登录失败(40101/42901)后清空密码并聚焦 —— 原型无此交互 | 有意(通用安全交互增强) | FE-01 D5 | + +### FE-02 主页与导航框架(原型 `#topbar` / `#nav-overlay` / `#screen-main`) + +| # | 偏离 | 性质 | 来源 | +|---|---|---|---| +| 1 | 原型单页多屏(`goTo(name)` 切 `.screen.active`)→ 实现为 React Router v6 真实路由 + `` 布局路由包裹受保护子路由 | 有意(核心架构复刻) | FE-02 spec § 6.1/6.3 | +| 2 | KPI 看板 / 角色流程树 / 导航分组 = 原型静态 demo → 实现为前端静态配置(`dashboardData.ts` / `navConfig.ts`),**不新增后端取数**(docs/05 无 KPI/导航端点) | 有意(不杜撰端点) | FE-02 D1/D2 | +| 3 | 搜索 / 通知 / 更多 / AI 助手图标、导航总览中无路由的叶子项 → 占位(不绑后端,点击关 overlay 不跳转或 `message.info`) | 有意(占位化) | FE-02 D4 | +| 4 | 顶栏 / 导航 overlay 深色底作为 `AppLayout` 局部 scoped 装饰样式保留 | 有意(tokens.css 无对应品牌深色) | FE-02 D9 | +| 5 | 退出登录为纯前端注销(清 `authSlice` + 删 token + 跳 `/login`),无后端注销端点 | 有意(无状态 JWT) | FE-02 D6 | +| 6 | 标签栈 / overlay 开关用 `AppLayout` 本地 `useState`(`useTabStack`),未上提全局 store | 有意(就近态) | FE-02 D3 | + +### FE-03 用户列表与查询(原型 `#screen-userlist`) + +| # | 偏离 | 性质 | 来源 | +|---|---|---|---| +| 1 | 原型 `users` 静态数组直接渲染 `tbody` → 实现真实对接 `GET /api/usr/users`(服务端分页 + 筛选 + 加载/空/错误态) | 有意(核心功能复刻) | FE-03 D1 | +| 2 | 原型分页写死「共37条记录 / 10000 条/页」→ 实现 `pageSize` 默认 10、`showSizeChanger=[10,20,50,100]`(上限对齐 docs/05 最大 100),不沿用 demo 10000 | 有意(对齐契约约束) | FE-03 D4 | +| 3 | 筛选栏首个下拉「全部用户」(原型单项 demo)→ 保留控件位但不向后端传额外「范围」参数(契约无此参数) | 有意(不杜撰参数) | FE-03 D2 | +| 4 | 筛选栏「▾」更多条件、工具栏设置齿轮「⚙」→ 占位(无额外后端参数/端点) | 有意(占位化) | FE-03 D3/D7 | +| 5 | 「导出Excel」原型无后端脚本 → 实现为前端导出当前查询结果(不杜撰后端导出端点) | 有意(前端实现) | FE-03 D5 | +| 6 | 「作废」列原型为 demo 复选框 → 实现只读展示 `iIsVoid`(0/1→否/是,不可勾选) | 有意(只读查询语义) | FE-03 BR6 | +| 7 | 行 `dblclick` 原型 `goTo('userdetail')` → 实现 `navigate('/usr/users/'+row.id)` 携 id 进 FE-04 | 有意(路由复刻) | FE-03 BR12 | + +### FE-04 用户信息单据(原型 `#screen-userdetail`) + +| # | 偏离 | 性质 | 来源 | +|---|---|---|---| +| 1 | 原型表单字段值写死 + `setUserDetailMode('new')` 仅做 DOM 文本清空 → 实现按路由 mode(`/new`=create / `/:id`=edit)分支,create 默认值预填、edit 回填原值 | 有意(核心功能复刻) | FE-04 spec § 2/3 | +| 2 | 员工名下拉 / 权限组列表 = 原型静态 demo → 实现消费支撑只读端点 `GET /api/usr/employees` / `GET /api/usr/permissions`(端点须后端补齐,见 § ⑦ 待对齐项) | 有意(下拉需读源) | FE-04 D1/D2 | +| 3 | edit 态预填复用列表端点 `GET /api/usr/users`(「等于」匹配 + pageSize=1 取 `records[0]`),不杜撰单用户详情端点;并支持 FE-03 经路由 state 携行数据免二次请求 | 有意(不杜撰端点);**含 1 处经 review 修复的偏离**:r1 误把用户主键当「用户号」查询导致 40401,r2 已修为走 navigate state + loadError 返回列表入口 | FE-04 D4 / review B1(见 § ⑨) | +| 4 | 权限页签条除「权限组」外的 5 个查看权限页签(客户/供应商/人员/工序/司机)→ 占位页签(无 REQ/端点) | 有意(占位化) | FE-04 D9 | +| 5 | 工具栏「删除/作废/重置密码/取消作废/功能」按钮 → 占位(无对应端点);其中「作废」语义经 PUT 已有字段 `iIsVoid` 承载,不新增端点 | 有意(占位化) | FE-04 D8 | +| 6 | 密码字段不在 UI 呈现(create 后端默认 666666 初始化,edit 不改密码) | 有意(契约语义) | FE-04 BR9 | +| 7 | 工具栏深色底作为 `UserDetail` 局部 scoped 装饰样式;原型必填红 `--label` 映射到 `var(--color-error)` | 有意(tokens 优先) | FE-04 D10 | + +> **偏离总结**:所有偏离均为「静态 demo → 真实路由/API」「不杜撰后端端点的占位化」「tokens.css 优先于原型私有色值」三类有意决策,无未登记的非预期偏离。唯一一处经 review 纠错的偏离(FE-04 edit 预填数据流 B1)已于 r2 修复并复验通过。各 FE 实现组件与原型 4 个 `#screen-*` 主结构一一对应(登录/主页/列表/单据 + 顶栏 + 导航 overlay)。 + +--- + +## ⑨ Review 与缺陷修复记录 + +| FE-NN | review 轮次 | 裁决 | must-fix | 修复 commit | +|---|---|---|---|---| +| FE-01 | r1 | approve | 无 | — | +| FE-02 | r1 | approve | 无 | — | +| FE-03 | r1 | approve | 无 | — | +| FE-04 | r1 | request-changes | B1:edit 态预填把用户主键当「用户号」查询,正常导航流必然取不到记录 → 40401 | `96e88d3 fix(usr): 修复 review must-fix FE: FE-04 编辑预填走 navigate state 并补 loadError 返回列表入口` | +| FE-04 | r2 | approve | 无(B1 已修复复验,verify-r1 全 PASS) | — | + +> FE-04 修复后单测 55 passed / E2E 5 passed(`2026-06-01-FE-04-verify-r1.md`),edit 预填用例(`useUserDetail`/`UserDetailPage` edit 分支、E2E `edit user prefill then save`)全部通过。最终 test-gate r1 GREEN 覆盖全量回归。 + +--- + +## ⑩ 关键技术决策与约定(跨 FE 复用资产) + +| 约定 | 落地 | 来源 | +|---|---|---| +| 请求基建 | `src/api/request.ts`:Axios 实例 `baseURL=/api`,开发期 Vite proxy 转发到后端 `http://localhost:5172`(`config-vars.yaml backend.http_port`);响应拦截器拆 `Result`(code=0 取 data,非 0 抛错),401 统一跳 `/login` | docs/04 § 2.3/2.4;FE-01 D2 | +| 登录态 | `authSlice`(RTK)持 token + user;token 持久化 localStorage 键 `xly_erp_token`,刷新后由 `request.ts` 注入 `Authorization: Bearer` | docs/04 § 2.2;FE-01 D6 | +| 路由守卫 | `RequireAuth`(三态:authResolving/unauthenticated/ready)+ `RedirectIfAuthed`(已登录访问 /login 回主页)+ `AppErrorBoundary`(路由级兜底) | docs/04 § 2.1;FE-02 § 6.2 | +| 状态分层 | 服务端数据就近用页面 hook(`useUserList` / `useUserDetail`),不进全局 store;仅登录态进 store | docs/04 § 2.2;FE-03 D6 / FE-04 D7 | +| 色值 SSoT | 组件样式只用 `var(--color-*)`(`src/styles/tokens.css`),tokens.css 优先于原型私有色值;品牌深色(顶栏/overlay/登录主视觉/工具条)作局部 scoped 装饰,不新增全局 token、不挪用语义 token | 各 FE § 7 + D7/D9/D10 | +| 不杜撰后端端点 | 仅消费 docs/05 已定义端点 + 既有先例支撑只读端点(companies/employees/permissions);导出、注销、KPI/导航均不杜撰端点 | 硬约束 + 各 FE D 系列 | + +--- + +## ⑪ 下一模块预览(上线 / 部署后续步骤) + +前端阶段是 Coding 阶段最后一个聚合阶段(排在所有后端模块之后)。本报告 commit + milestone 标记后,进入上线 / 部署后续: + +1. **里程碑标记**:本报告 commit 落地后,于 milestone 步骤打 `milestone/frontend-phase` tag(前置依赖工作树干净,本 commit 满足)。 +2. **分支合并**:`frontend-phase` 合并回默认分支 `master`(按 superpowers `finishing-a-development-branch` 流程,PR 或直接合并由上层编排决定)。 +3. **跨阶段接口对齐(阻塞上线联调)**:后端需补齐 FE 消费的支撑只读端点契约——`GET /api/usr/companies`(FE-01 版本下拉,REQ-USR-004 后端规格已记)、`GET /api/usr/employees`(FE-04 员工下拉)、`GET /api/usr/permissions`(FE-04 权限组);否则前后端联调时对应下拉无真实数据。详见 § ⑦ 待对齐项。 +4. **前后端联调验证**:当前 E2E 用 `page.route` 桩后端,上线前需起真实后端(端口 5172)+ Vite proxy 做端到端联调,验证登录 / 列表分页 / 单据增改 / 401 被动登出全链路。 +5. **导出端点取舍**:FE-03「导出Excel」当前为前端实现(SheetJS);若后续后端补 `/export` 端点可切换为服务端导出(D5 已留切换口)。 +6. **私有化部署**:按 CLAUDE.md「私有化部署」,前端构建产物(`vite build`)随后端打包部署,`baseURL=/api` 经反向代理指向后端。 + +--- + +## ⑫ 结论 + +- **前置闸**:test-gate **GREEN**(唯一 attempt r1,**无 flake**),vitest 193 + playwright 20 全绿,覆盖 4 个 FE 全量回归。 +- **作用域**:实现改动全部落在 `frontend/`,无 `backend/` / `sql/` / `scripts/` 越界,无跨模块改动。 +- **完成度**:FE-01~04 全部 approved(docs/08 § 三 `[x]`);FE-04 经 1 轮 request-changes 修复(edit 预填 B1)后复验通过。 +- **偏离**:§ ⑧ 已逐 FE 列举全部偏离,**均为 spec 登记的有意决策**(静态 demo→真实路由/API、不杜撰端点的占位化、tokens 优先),**无未登记的非预期偏离**;实现组件与原型 4 个 `#screen-*` 主结构一一对应。 +- **可进入 milestone 标记**:本报告 commit 后工作树干净,满足里程碑前置。 diff --git a/docs/superpowers/module-reports/frontend-phase-test-gate-r1.md b/docs/superpowers/module-reports/frontend-phase-test-gate-r1.md new file mode 100644 index 0000000..f7a5879 --- /dev/null +++ b/docs/superpowers/module-reports/frontend-phase-test-gate-r1.md @@ -0,0 +1,50 @@ +# 前端阶段 硬测试闸 证据(frontend-phase-test-gate) + +- attempt: 1 +- phase: frontend +- 分支: frontend-phase +- 命令: `npm run test:unit && npm run test:e2e`(来源 docs/04-技术规范.md § 零 命令清单:前端 unit=`vitest run`、e2e=`playwright test`) +- 工作目录: `/Users/reporkey/Desktop/mvp/test6/frontend` +- 执行方式: 派发独立(detached)子执行进程跑测试,主会话仅消费结构化结果,未在主会话直接跑测试。 + +## 结论 + +**GREEN** — 两个套件 exit_code 均为 0,failed = 0,覆盖全部 FE 回归(vitest + playwright)。 + +## 单元测试(vitest) + +- 命令: `npm run test:unit`(= `vitest run`) +- 时间窗口: 2026-06-02 09:26:46 → 09:26:53 +- exit_code: 0 +- 结果: **Test Files 40 passed (40) / Tests 193 passed (193)** +- 备注: 日志中 `window.getComputedStyle` not implemented 与 React Router v7 future flag 为 jsdom/antd 的非致命 stderr 警告,未导致任何用例失败。 + +## E2E 测试(playwright) + +- 命令: `npm run test:e2e`(= `playwright test`,chromium 项目,webServer 起 Vite dev server 5173,后端经 `page.route` 桩,不依赖真实后端) +- 时间窗口: 2026-06-02 09:26:57 → 09:27:04 +- exit_code: 0 +- 结果: **20 passed (6.4s)** +- 覆盖 spec: `login.spec.ts` / `shell.spec.ts` / `userlist.spec.ts` / `userdetail.spec.ts` +- 备注: 日志中 `[vite] http proxy error ... ECONNREFUSED` 为 dev server 代理向未启动的真实后端转发产生,E2E 用例已用 `page.route` 拦截 `/api/**`,与用例断言无关,全部用例通过。 + +## 子会话结构化返回 + +```json +{ + "unit": { + "command": "npm run test:unit", + "exit_code": 0, + "passed": 193, + "failed": 0, + "stdout_excerpt": "Test Files 40 passed (40)\nTests 193 passed (193)\nDuration 6.12s" + }, + "e2e": { + "command": "npm run test:e2e", + "exit_code": 0, + "passed": 20, + "failed": 0, + "stdout_excerpt": "20 passed (6.4s)" + } +} +``` diff --git a/docs/superpowers/plans/2026-06-01-FE-01.md b/docs/superpowers/plans/2026-06-01-FE-01.md new file mode 100644 index 0000000..f5b0e4d --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-FE-01.md @@ -0,0 +1,245 @@ +# FE-01 登录页 — 任务级 TDD 计划(前端) + +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / 组件 / 路由 / store / api / 样式 / 测试 / 工程配置)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-FE-01.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-004.md`;API 契约 `docs/05-API接口契约.md` § REQ-USR-004;原型 `prototype/erp.html`(`#screen-login` 布局/交互权威);技术规范 `docs/04-技术规范.md` § 零 / § 二;Design Tokens `src/styles/tokens.css`;配置 `config-vars.yaml`。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与 API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整个组件 / 配置文件内容。 +> **本 REQ 是仓库首个前端任务**:`frontend/` 目录尚不存在,FE-01 需先搭建最小前端工程骨架(package.json / vite / 测试栈 / Redux / Router / AntD / Axios 封装),再实现登录页。后续 FE-02~FE-04 复用此骨架。 + +--- + +## Goal(目标) + +在 `/login` 路由实现登录页 `LoginPage`,复刻原型 `#screen-login` 的三段式布局(品牌头 / 深蓝主视觉 + 右侧浮层登录卡 / 页脚版权)与交互语义,但表单校验、提交、版本下拉取数、错误反馈全部真实对接后端: + +- 页面挂载即调 `GET /api/usr/companies` 预加载「版本」下拉项(`companiesLoading` → `idle`/`empty`);取数失败给重试入口 + `message.error`。 +- 用户填用户名 / 密码、选版本后点「登录」→ 前端必填校验通过 → 调 `POST /api/usr/login`(body `{ sUserName, password, companyId }`);提交中 `submitting`(按钮 loading、字段禁用、防重复提交)。 +- 成功(`code=0`):拿 `token` + `user` 写入 Redux `authSlice.setCredentials`,token 持久化到 `localStorage`(键 `xly_erp_token`),`message.success("登录成功")`,`navigate('/', { replace:true })`。 +- 失败:按错误码(`40001`/`40101`/`40302`/`42901`/网络异常)渲染对应中文文案;`40101`/`42901` 后清空密码框并聚焦,保留用户名与版本。 +- 状态机覆盖 ≥5 态:`companiesLoading` / `idle` / `empty` / `submitting` / `error` / `success`(spec § 3)。 +- 业务规则前端复刻 BR1~BR11(spec § 5),其中身份真伪 / 禁用 / 限流均由后端裁决,前端仅按返回码渲染。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 2.1`,前端为仓库根 `frontend/` 子项目,包名 `xly-erp-web`(config-vars `frontend.pkg_name`)。本 REQ 新建工程骨架 + 登录页相关文件: + +``` +frontend/ +├── package.json # 【新增】name=xly-erp-web;scripts dev/build/lint/test:unit/test:e2e;依赖 react18 / antd5 / @reduxjs/toolkit / react-redux / react-router-dom@6 / axios / @ant-design/icons + devDeps vite / vitest / jsdom / @testing-library/react|jest-dom|user-event / @playwright/test / typescript / eslint +├── tsconfig.json # 【新增】TS 配置(jsx react-jsx、strict) +├── vite.config.ts # 【新增】React 插件 + server.port=5173 + server.proxy '/api'→http://localhost:5172(D2)+ vitest test 配置(environment jsdom、globals、setupFiles) +├── playwright.config.ts # 【新增】e2e:testDir=tests/e2e,baseURL 取 dev server +├── index.html # 【新增】挂载点 #root + 引入 main.tsx +├── tests/ +│ ├── setup.ts # 【新增】vitest setup:import '@testing-library/jest-dom';引入 tokens.css 供 jsdom(可选) +│ ├── unit/ # 【新增】jsdom 组件 / store / api 单测(Vitest + RTL) +│ └── e2e/ # 【新增】Playwright E2E +├── src/ +│ ├── main.tsx # 【新增】入口:Redux Provider + BrowserRouter + AntD ConfigProvider(theme.colorPrimary=var(--color-primary) 取值) + 引入 styles/tokens 与全局样式 +│ ├── App.tsx # 【新增】挂载路由 +│ ├── router/index.tsx # 【新增】React Router v6 路由表:/login → LoginPage;'/' 占位(FE-02 落地,本 REQ 仅需 /login 可达 + 成功后 navigate('/')) +│ ├── store/store.ts # 【新增】configureStore({ reducer: { auth: authReducer } }) + 导出 RootState / AppDispatch +│ ├── store/slices/authSlice.ts # 【新增】authSlice:state { token, user };reducer setCredentials / clearCredentials +│ ├── api/request.ts # 【新增】Axios 实例:baseURL '/api';请求拦截器(已登录注入 Authorization: Bearer,登录端点放行);响应拦截器(拆 Result:code=0 取 data,非 0 抛 ApiError,网络异常兜底) +│ ├── api/usrApi.ts # 【新增】login(payload) / fetchCompanies();调 request.ts,返回 data +│ ├── pages/usr/Login/LoginPage.tsx # 【新增】登录页根组件(容器 + 状态机 + 提交逻辑) +│ ├── pages/usr/Login/components/ # 【新增】LoginHeader / LoginHero / LoginCard / LoginForm 等区域子组件(按需拆分;纯展示子组件可内联) +│ ├── pages/usr/Login/Login.module.css # 【新增】登录页 scoped 样式:语义色用 var(--color-*);主视觉深蓝渐变/网格为局部装饰(D7,不新增全局 token) +│ └── styles/ # 【新增】全局样式入口,import 仓库根 ../../src/styles/tokens.css(Design Tokens SSoT,见 D9) +└── (eslint 配置 .eslintrc.cjs 或 eslint.config.js 按 vite-react 模板) +``` + +- **跨模块 / 跨阶段**:本 REQ 落点全在 `frontend/**`,不触 `backend/` / `sql/` / `scripts/`。新建的 `store` / `router` / `api/request.ts` / `main.tsx` / 工程配置属全前端共享骨架,FE-02~FE-04 复用(非 FE-01 私有),在《模块完成报告》留痕「FE-01 搭建前端工程骨架,后续 FE 复用」。 +- **状态管理**(docs/04 § 2.2):全局登录态(token + user)进 Redux `authSlice`;页面内瞬时态(`companiesLoading` / `companies` 列表 / `empty` / `submitting`)就近用 `useState`,不塞全局。 +- **请求封装**(docs/04 § 2.3):统一走 `api/request.ts`,页面只调 `usrApi.ts` 封装方法,不散用 axios。响应拦截器拆 `Result`,非 0 `code` 抛出携带 `code` 的错误供页面分流文案;网络/超时/5xx 兜底。 +- **错误处理**(docs/04 § 2.4):表单校验错误就近 AntD 红字;提交失败按错误码 `message.error`;版本取数失败空态 + 重试。 +- **Design Tokens**(docs/04 § 2.1 / spec § 7):语义色(按钮 / 文字 / 边框 / 错误 / 背景)只用 `var(--color-*)`,禁止硬编码 hex/rgba;AntD `colorPrimary` 经 `ConfigProvider` 对齐 `--color-primary`;主视觉深蓝渐变 / 网格透视为登录页局部装饰,scoped 保留,不挪用语义 token、不新增全局 token(D7)。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) + +- React 18.x / Ant Design 5.x / Redux Toolkit(最新稳定)/ React Router v6 / Vite(最新稳定)/ Axios(最新稳定)/ TypeScript;`@ant-design/icons`(`UserOutlined` / `LockOutlined`)。 +- 测试:单测 Vitest(jsdom 环境)+ `@testing-library/react` / `@testing-library/jest-dom` / `@testing-library/user-event`;E2E Playwright(`@playwright/test`)。 +- 命令(docs/04 § 零,package.json scripts 必须提供):build `npm run build`;lint `npm run lint`;unit `npm run test:unit`;e2e `npm run test:e2e`;另需 `dev`(Vite dev server,端口 5173)。 +- 端口 / 转发:dev server 端口 `5173`(config-vars `frontend.dev_port`),Vite proxy `/api` → `http://localhost:5172`(config-vars `backend.http_port`,D2);不硬编码到组件,集中在 `vite.config.ts`。 + +## 合同级常量(跨 task 必须一致) + +- 路由 path:`/login`(登录页);登录成功跳转目标 `/`(D3,`{ replace: true }`)。 +- API client 签名(`api/usrApi.ts`,跨 task 一致): + - `login(payload: LoginPayload): Promise`,其中 `LoginPayload = { sUserName: string; password: string; companyId: number }`;`LoginResult = { token: string; user: AuthUser }`;`AuthUser = { id: number; sUserName: string; sUserType: string; sLanguage: string }`(对齐 docs/05 § REQ-USR-004 响应 `Result<{ token, user:{ id, sUserName, sUserType, sLanguage } }>`)。 + - `fetchCompanies(): Promise`,其中 `CompanyOption = { id: number; sCompanyName: string; sVersion: string | null }`(对齐 spec § 4 / D8;`sVersion` 可空)。 +- HTTP 形状(`api/request.ts`): + - `baseURL = '/api'`;登录端点 `POST /api/usr/login`(最终 path `/usr/login`)放行、**不带** `Authorization`;版本端点 `GET /api/usr/companies`(最终 path `/usr/companies`)放行。 + - 后端统一响应 `Result = { code: number; message: string; data: T }`(docs/04 § 1.4);响应拦截器:`code===0` 返回 `data`;`code!==0` 抛 `ApiError`(携带 `code:number` + `message:string`);无响应(网络/超时/5xx)抛 `ApiError`(`code` 置统一标识,如 `-1` 表网络异常)。 + - 请求拦截器:从 `localStorage` 读 `xly_erp_token`,存在则注入 `Authorization: Bearer `;登录 / 版本端点本就放行,无 token 时不注入(自然跳过)。 +- token 持久化键名:`localStorage` key = `xly_erp_token`(D6)。集中常量(如 `src/api/request.ts` 或 `src/store/slices/authSlice.ts` 导出 `TOKEN_STORAGE_KEY`),跨 task 引用同一常量,不各处写字面量。 +- 错误码 → 前端文案(对齐 spec § 4 / docs/05;登录失败分流,**严格沿用**,不得细化 `40101`): + | code | 前端文案 | 展示方式 | + |---|---|---| + | `0` | 「登录成功」 | `message.success` 后跳转 | + | `40001` | 「请填写用户名、密码并选择版本」 | `message.error`(兜底,正常前端校验已拦截) | + | `40101` | 「用户名或密码错误」 | `message.error` + 清空密码框聚焦(不细化为「账号不存在」等枚举文案,BR6) | + | `40302` | 「该账号已被禁用,请联系管理员」 | `message.error` | + | `42901` | 「登录尝试过于频繁,请稍后再试」 | `message.error` + 清空密码框聚焦 | + | 网络/超时/5xx | 「网络异常,请稍后重试」 | 响应拦截器兜底 `message.error` | + > 文案集中在一处映射(如 `LoginPage` 内 `LOGIN_ERROR_MESSAGES: Record` 或 `usrApi`/util 常量),按 `ApiError.code` 查表,未命中走网络异常兜底文案。 +- 表单字段名(AntD `Form` `name`,提交时映射到 `LoginPayload`):`sUserName` / `password` / `companyId`;占位文案:用户名「请输入你的用户名」、密码「请输入你的密码」(沿用原型)。 +- 版本下拉文案:`placeholder`「请选择版本」;加载中 `placeholder`「加载版本中…」;空态 `notFoundContent`「暂无可用版本」。 +- 校验文案(AntD `rules.message`):用户名「请输入用户名」(BR1);密码「请输入密码」(BR2);版本「请选择版本」(BR4)。 +- 版本下拉 label 规则(D8):返回项 `sVersion` 非空 → label = `` `${sCompanyName}(${sVersion})` ``(全角括号);`sVersion` 为空/null → label = `sCompanyName`;`value` 恒取 `id`(提交作 `companyId`);列表仅 1 项时默认选中该项(spec § 6.3)。 + +## 关键签名(首次出现处给出,跨 task 保持一致) + +- `authSlice`(`store/slices/authSlice.ts`): + - `AuthState = { token: string | null; user: AuthUser | null }`,`initialState` 从 `localStorage` 读 `xly_erp_token` 初始化 `token`(user 初始 null)。 + - `setCredentials(state, action: PayloadAction<{ token: string; user: AuthUser }>)`:写 token + user,并 `localStorage.setItem(TOKEN_STORAGE_KEY, token)`。 + - `clearCredentials(state)`:清 token + user,并 `localStorage.removeItem(TOKEN_STORAGE_KEY)`。 + - 导出 `authReducer`(default)、`setCredentials` / `clearCredentials` actions。 + > 注:reducer 内写 localStorage 属副作用,为简洁集中放此(MVP 取舍);若 TDD 期偏好纯 reducer,可改由 `LoginPage` 在 dispatch 后单独持久化——二者择一,全项目统一(默认前者,登记 D6)。 +- `request.ts`:导出 axios 实例 `request`(default)+ `TOKEN_STORAGE_KEY` 常量 + `ApiError`(含 `code: number`、`message: string`)。 +- `usrApi.ts`:`login(payload)` / `fetchCompanies()`(签名见合同级常量)。 +- `LoginPage`(`pages/usr/Login/LoginPage.tsx`,default export,无 props,路由组件);内部用 `useDispatch()` + `useNavigate()` + AntD `Form.useForm()` + `App.useApp()`/`message`(AntD 5 推荐 `App` 包裹用 `message` 实例,否则用静态 `message`——择一统一)。 +- 区域子组件(若拆分,均在 `pages/usr/Login/components/`,纯展示,props 明确): + - `LoginHeader`(无 props 或 `{ brandName?: string }`):渲染 Logo SVG + 「Antler ERP」+ 「欢迎登录EBC平台」。 + - `LoginHero`(`{ children: ReactNode }` 或无):深蓝主视觉容器 + 标语文本,`children` 槽放登录卡。 + - `LoginCard`(`{ children }`):右侧浮层卡片容器 + 标题「用户登录」。 + - `LoginFooter`(无 props):版权 / 备案号文本。 + - 表单逻辑(取数 / 提交 / 状态)集中在 `LoginPage`(或抽 `LoginForm` 子组件接收 `{ companies, companiesLoading, companiesError, submitting, onSubmit, onRetryCompanies, formRef }` props);**抽与不抽由 TDD 期决定**,但若抽 `LoginForm`,其 props 契约以此为准,跨 task 一致。 + +## 测试栈说明 + +- **jsdom 组件 / store / api 单测**(Vitest + RTL):默认 mock `api/usrApi` 或 axios(用 `vi.mock`)以隔离网络;断言渲染、交互、状态切换、dispatch、navigate、message 文案。覆盖 spec § 3 状态机与 § 5 BR1~BR11 中可在组件层验证者。 +- **Playwright E2E**:覆盖关键用户旅程(页面可达 + 校验拦截 + 成功跳转),通过拦截/桩 `**/api/usr/companies` 与 `**/api/usr/login` 路由响应(`page.route`)模拟后端,不依赖真实后端起服。 + +--- + +## 任务列表(每个 task = red → green → 子会话验证 → commit) + +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。 +> 提交信息格式:`(): REQ-USR-004`(FE-01 关联 REQ-USR-004;scope 用 `fe-login` 或 `usr`)。 + +### T0 — 前端工程骨架可启动 + 测试栈可运行(chore,先建地基) +- [ ] **1. 写失败测试**:`frontend/tests/unit/smoke.test.tsx::renders a trivial component`——一个最小冒烟用例(渲染 `
ok
` 或一个空 `App`),用于驱动 Vitest + jsdom + RTL 配置就绪;初始因无工程 / 无配置而失败。 +- [ ] **2. 实现最小代码**:`frontend/package.json` + `frontend/tsconfig.json` + `frontend/vite.config.ts`(含 vitest test 配置 environment=jsdom / globals=true / setupFiles=tests/setup.ts、server.port=5173、proxy `/api`→`http://localhost:5172`)+ `frontend/tests/setup.ts`(import `@testing-library/jest-dom`)+ `frontend/index.html` + `frontend/src/main.tsx`(暂最小,可仅挂载占位)+ `frontend/eslint` 配置。安装依赖(`npm i`)。scripts 提供 `dev` / `build` / `lint` / `test:unit`(`vitest run`)/ `test:e2e`(`playwright test`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit` 冒烟用例通过;`npm run lint` 与 `npm run build` 可执行(build 至少不因配置错误失败)。 +- [ ] **4. commit**:`chore(fe-login): 初始化前端工程骨架与测试栈 REQ-USR-004` + +### T1 — Axios 请求封装拆 Result + 拦截器(jsdom 单测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/request.test.ts`: + - `::baseURL is /api`——实例 `baseURL === '/api'`。 + - `::unwraps data when code is 0`——mock 适配器返回 `{ code:0, message:'success', data:{ foo:1 } }`,`request.get(...)` resolve 为 `{ foo:1 }`(拆 data)。 + - `::throws ApiError carrying business code when code is non-zero`——返回 `{ code:40101, message:'认证失败', data:null }`,调用 reject 为 `ApiError` 且 `err.code===40101`。 + - `::throws network ApiError on no-response error`——模拟无响应(网络异常),reject 为 `ApiError`,`code` 为网络异常标识(如 `-1`)。 + - `::injects Authorization header when token present`——`localStorage` 预置 `xly_erp_token`,请求拦截器把 `Authorization: 'Bearer '` 加到 config.headers;无 token 时不加。 +- [ ] **2. 实现最小代码**:`frontend/src/api/request.ts`——导出 axios 实例(`baseURL:'/api'`)、`TOKEN_STORAGE_KEY='xly_erp_token'`、`ApiError`;请求拦截器注入 token,响应拦截器拆 `Result` / 抛 `ApiError` / 网络兜底。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- request` +- [ ] **4. commit**:`feat(fe-login): Axios 实例与 Result 拆包/错误拦截 REQ-USR-004` + +### T2 — usrApi.login / fetchCompanies(jsdom 单测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/usrApi.test.ts`(`vi.mock('../../src/api/request')` 桩实例): + - `::login posts to /usr/login with sUserName/password/companyId`——断言 `request.post` 收到 path `/usr/login` 与 body `{ sUserName, password, companyId }`,返回值透传为 `{ token, user }`。 + - `::fetchCompanies gets /usr/companies and returns list`——断言 `request.get('/usr/companies')`,返回 `CompanyOption[]`。 +- [ ] **2. 实现最小代码**:`frontend/src/api/usrApi.ts`——`login(payload)` / `fetchCompanies()`(签名见合同级常量)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- usrApi` +- [ ] **4. commit**:`feat(fe-login): usrApi 登录与版本下拉取数封装 REQ-USR-004` + +### T3 — authSlice setCredentials / clearCredentials + token 持久化(jsdom 单测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/authSlice.test.ts`: + - `::setCredentials stores token and user and persists token`——dispatch `setCredentials({ token:'t', user:{...} })`,state.token/user 更新,且 `localStorage.getItem('xly_erp_token')==='t'`。 + - `::clearCredentials clears state and removes persisted token`——清空 state 且 `localStorage` 该键被移除。 + - `::initialState reads persisted token`——预置 `localStorage` token 后初始化 reducer,`token` 为该值。 +- [ ] **2. 实现最小代码**:`frontend/src/store/slices/authSlice.ts`(签名见关键签名)+ `frontend/src/store/store.ts`(`configureStore`,挂 `auth` reducer,导出 `RootState` / `AppDispatch`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- authSlice` +- [ ] **4. commit**:`feat(fe-login): authSlice 登录态与 token 持久化 REQ-USR-004` + +### T4 — 登录页布局与区域结构渲染(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.layout.test.tsx`(用 Redux Provider + MemoryRouter 包裹渲染;mock `usrApi.fetchCompanies` resolve 空列表避免 act 警告): + - `::renders brand header / hero slogan / footer`——存在品牌名「Antler ERP」、副标题「欢迎登录EBC平台」、主视觉中文「企业业务能力平台」与「ERP」、页脚版权文本(含备案号)。 + - `::renders login card title 用户登录`——卡片标题文案存在。 + - `::renders username/password/version fields and submit button 登 录`——用户名 `Input`(占位「请输入你的用户名」)、密码 `Input.Password`(占位「请输入你的密码」、`type` 掩码,BR3)、版本 `Select`、提交按钮文案「登 录」。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/Login/LoginPage.tsx`(+ 按需 `components/` 区域子组件、`Login.module.css`);`frontend/src/router/index.tsx` 挂 `/login`;`frontend/src/App.tsx` / `frontend/src/main.tsx` 接入 Provider + Router + `ConfigProvider`(colorPrimary 对齐 `--color-primary`);`frontend/src/styles/` 入口 import 仓库根 tokens.css。语义色用 `var(--color-*)`,主视觉装饰 scoped。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.layout` +- [ ] **4. commit**:`feat(fe-login): 登录页三段式布局与区域结构 REQ-USR-004` + +### T5 — 版本下拉预加载状态机:loading / idle / empty / 取数失败重试(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.companies.test.tsx`(`vi.mock` `usrApi`): + - `::shows loading placeholder and disabled select while fetching`——`fetchCompanies` pending 时版本 `Select` 处 loading/禁用、`placeholder` 含「加载版本中」(`companiesLoading` 态)。 + - `::renders options with label rule on resolve (idle)`——resolve `[{id:1,sCompanyName:'甲公司',sVersion:'标准版'},{id:2,sCompanyName:'乙公司',sVersion:null}]`,下拉项 label 分别为「甲公司(标准版)」「乙公司」(D8);展开可见。 + - `::auto-selects when single option`——resolve 仅 1 项时该项默认选中(spec § 6.3)。 + - `::empty state when companies is empty`——resolve `[]`,`Select` 空态文案「暂无可用版本」+ 轻量提示「未获取到可登录版本,请联系管理员」(`empty` 态)。 + - `::shows error with retry when fetch fails`——`fetchCompanies` reject,出现「版本加载失败」+ 重试入口;点重试再次调用 `fetchCompanies`(BR5)。 +- [ ] **2. 实现最小代码**:在 `LoginPage` 中加挂载即取数(`useEffect`)、`companies` / `companiesLoading` / `companiesError` 本地态、label 映射(D8)、单项自动选中、空态与重试逻辑。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.companies` +- [ ] **4. commit**:`feat(fe-login): 版本下拉预加载/空态/重试 REQ-USR-004` + +### T6 — 表单必填校验拦截提交(BR1/BR2/BR4)(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.validation.test.tsx`(mock `usrApi`,companies 预置非空): + - `::blocks submit and shows required messages when empty`——直接点「登录」,出现「请输入用户名」「请输入密码」「请选择版本」三条校验红字,且 `usrApi.login` **未被调用**(BR1/BR2/BR4 + spec § 5)。 + - `::submits with payload when all filled`——填全三项后点登录,`usrApi.login` 收到 `{ sUserName, password, companyId }`(companyId 为所选项 id)。 +- [ ] **2. 实现最小代码**:在 `LoginForm`/`LoginPage` 配置 AntD `Form` `rules`(三字段 required + 对应 message)、`onFinish` 组装 `LoginPayload` 调 `usrApi.login`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.validation` +- [ ] **4. commit**:`feat(fe-login): 登录表单必填校验与提交装配 REQ-USR-004` + +### T7 — 提交中态防重复提交(submitting,BR10)(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.submitting.test.tsx`: + - `::button loading and fields disabled while submitting`——`usrApi.login` 返回 pending Promise,提交后按钮 `loading` 且三字段禁用(`submitting` 态)。 + - `::ignores duplicate submit while pending`——pending 期间再次点击 / 回车,`usrApi.login` 仅被调用 1 次(防重复提交,BR10)。 +- [ ] **2. 实现最小代码**:`LoginPage` 加 `submitting` 本地态(提交前置 true、settle 后置 false),按钮 `loading={submitting}`、字段 `disabled`,提交时若已 `submitting` 直接 return。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.submitting` +- [ ] **4. commit**:`feat(fe-login): 提交中态与防重复提交 REQ-USR-004` + +### T8 — 登录成功:写 authSlice + 持久化 + 跳转 /(success,BR9)(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.success.test.tsx`(真实 store + `useNavigate` spy / MemoryRouter 记录跳转;mock `usrApi.login` resolve `{ token:'tk', user:{ id:1, sUserName:'admin', sUserType:'超级管理员', sLanguage:'中文' } }`;mock `message`): + - `::dispatches setCredentials and persists token on success`——成功后 store `auth.token==='tk'`、`auth.user` 写入,`localStorage.xly_erp_token==='tk'`。 + - `::shows success message and navigates to '/' with replace`——`message.success('登录成功')` 被调,`navigate('/', { replace:true })` 被调(D3)。 +- [ ] **2. 实现最小代码**:`onFinish` 成功分支 dispatch `setCredentials({ token, user })`、`message.success('登录成功')`、`navigate('/', { replace:true })`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.success` +- [ ] **4. commit**:`feat(fe-login): 登录成功落地登录态与跳转 REQ-USR-004` + +### T9 — 登录失败错误码分流文案 + 失败后清空聚焦(error,BR6/BR7/BR8 + D5)(jsdom 组件测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.error.test.tsx`(mock `usrApi.login` reject `ApiError`;mock `message`;表单预填合法值): + - `::40101 shows 用户名或密码错误 and clears+focuses password`——reject `code:40101`,`message.error('用户名或密码错误')`,密码框被清空且获焦(BR6 + D5)。 + - `::40302 shows 该账号已被禁用,请联系管理员`——`message.error` 文案匹配(BR7)。 + - `::42901 shows 登录尝试过于频繁,请稍后再试 and clears password`——文案匹配 + 密码清空(BR8 + D5)。 + - `::40001 shows 请填写用户名、密码并选择版本`——兜底文案(spec § 4)。 + - `::network error shows 网络异常,请稍后重试`——reject 网络异常 `ApiError`(如 `code:-1`),兜底文案。 + - `::button recovers clickable and username/version preserved after failure`——失败后按钮恢复可点、用户名与版本保留(spec § 3 error 态 / D5)。 +- [ ] **2. 实现最小代码**:`onFinish` 失败分支按 `err.code` 查 `LOGIN_ERROR_MESSAGES` 表(未命中走网络异常文案)`message.error(...)`;`40101`/`42901` 后 `form.setFieldValue('password', '')` 并聚焦密码框;恢复 `submitting=false`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.error` +- [ ] **4. commit**:`feat(fe-login): 登录失败错误码分流文案与失败后处理 REQ-USR-004` + +### T10 — E2E 登录关键旅程(Playwright) +- [ ] **1. 写失败测试**:`frontend/tests/e2e/login.spec.ts`(`page.route` 桩 `**/api/usr/companies` 返回 `{code:0,...,data:[...]}`、`**/api/usr/login` 按用例返回成功 / `40101`): + - `::loads /login and shows version options`——访问 `/login`,版本下拉渲染桩返回项。 + - `::blocks submit with validation when empty`——空提交看到必填校验提示,未发起 login 请求。 + - `::successful login navigates away from /login`——填全 + 桩成功响应 → URL 离开 `/login`(到 `/`)、可见「登录成功」提示。 + - `::failed login stays on /login with error`——桩 `40101` → 停留 `/login`、可见「用户名或密码错误」。 +- [ ] **2. 实现最小代码**:补齐 `playwright.config.ts`(`webServer` 起 `npm run dev`、`baseURL` 指向 dev server)及任何为可测性需要的最小 `data-testid`(仅在 RTL 无法稳定定位时添加)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- login`(首次需 `npx playwright install` 装浏览器)。 +- [ ] **4. commit**:`test(fe-login): 登录页 E2E 关键旅程 REQ-USR-004` + +### T11 — 全量门禁回归 + 收尾(chore) +- [ ] **1. 写失败测试**:无新增测试;本任务跑全量验证。 +- [ ] **2. 实现最小代码**:修复 lint / build / 类型问题(如有);确认语义色全部 `var(--color-*)`、无硬编码 hex/rgba(主视觉装饰 scoped 除外,D7)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。 +- [ ] **4. commit**:`chore(fe-login): FE-01 门禁回归通过 REQ-USR-004` + +--- + +## 完成判据(Definition of Done) + +1. `/login` 路由可达,渲染复刻原型 `#screen-login` 的品牌头 / 深蓝主视觉 + 右侧浮层登录卡 / 页脚三段式(spec § 2 / § 6.1-6.2)。 +2. 状态机 ≥5 态全部覆盖并有测试固化:`companiesLoading` / `idle` / `empty` / `submitting` / `error` / `success`(spec § 3)。 +3. 业务规则 BR1~BR11 在组件层 / E2E 有对应断言(身份真伪 / 禁用 / 限流由后端裁决,前端按返回码渲染,不复制后端逻辑)(spec § 5)。 +4. 消费 `GET /api/usr/companies`(预加载、空态、重试)与 `POST /api/usr/login`(成功 / `40001` / `40101` / `40302` / `42901` / 网络异常)按错误码表分流文案(spec § 4),文案逐字一致。 +5. 成功后写 Redux `authSlice` + 持久化 `localStorage[xly_erp_token]` + `navigate('/', { replace:true })`(spec § 6.6 / D3 / D6)。 +6. 统一走 `api/request.ts` + `api/usrApi.ts`,不在页面散用 axios(docs/04 § 2.3)。 +7. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 对齐 `--color-primary`,主视觉装饰 scoped 不新增全局 token(spec § 7 / D7)。 +8. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动。 +9. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。 + +## 自审记录 + +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 占位。 +- **spec coverage**:spec § 2 组件树→T4;§ 3 状态机→T5(loading/idle/empty)/T7(submitting)/T8(success)/T9(error);§ 4 端点与错误码→T1/T2/T8/T9/T10;§ 5 BR1-BR11→T6(BR1/2/4)/T4(BR3)/T5(BR5)/T9(BR6/7/8)/T8(BR9)/T7(BR10)/T2+T6(BR11,前端原样提交不处理密码);§ 6 交互→T4/T5/T8/T9;§ 7 tokens→T4/T11;§ 8 decisions D1-D8 已在合同级常量 / 架构中落实,新增 D9(本计划层)见下。 +- **本计划新增决策 D9(tokens.css 复用路径)**:Design Tokens SSoT 为仓库根 `src/styles/tokens.css`(docs/04 § 2.1,由 skeleton-gen 生成),前端工程在 `frontend/` 子目录内。本计划选择由 `frontend/src/styles/` 入口以相对路径 `import '../../../src/styles/tokens.css'`(或经 Vite alias)复用该唯一文件,**不在 `frontend/` 内拷贝一份**,避免双 SSoT 漂移;依据 docs/04 § 2.1「色值单一来源在仓库根 `src/styles/tokens.css`,前端在 main.tsx/全局样式中引入」。置信度 high。该决策亦登记进返回的 decisions[]。 +- **类型一致性**:`LoginPayload` / `LoginResult` / `AuthUser` / `CompanyOption` / `ApiError` / `TOKEN_STORAGE_KEY` / 错误码文案表 / 路由 path / API path 跨 T1-T10 一致;与 docs/05 § REQ-USR-004 响应形状(`token` + `user{ id, sUserName, sUserType, sLanguage }`)及 companies `{ id, sCompanyName, sVersion }` 对齐。 diff --git a/docs/superpowers/plans/2026-06-01-FE-02.md b/docs/superpowers/plans/2026-06-01-FE-02.md new file mode 100644 index 0000000..51acb61 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-FE-02.md @@ -0,0 +1,330 @@ +# FE-02 主页与导航框架 — 任务级 TDD 计划(前端) + +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / 布局 / 路由 / store / api / 样式 / 测试)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-FE-02.md`;原型 `prototype/erp.html`(`#topbar` / `#nav-overlay` / `#screen-main` 布局/交互权威,含内联 `kpiRows` / `navSide` / `navCols` demo 数据);技术规范 `docs/04-技术规范.md` § 零 / § 二;Design Tokens 仓库根 `src/styles/tokens.css`;登录态来源 FE-01(`authSlice` + `request.ts` + token 持久化键 `xly_erp_token`)。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与配置形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整组件 / 配置文件内容。 +> **本 FE 是登录后落地页 + 应用外壳(壳层)**,复用 FE-01 已搭好的工程骨架(package.json / vite / 测试栈 / Redux / Router / `api/request.ts` / `authSlice` / tokens 引入)。它**不新增任何后端取数**(spec § 4 / D1):主页 KPI 看板、角色/流程树、导航分组均为前端静态配置(复刻原型 demo);当前用户身份复用 `authSlice.user`。 + +--- + +## Goal(目标) + +把 FE-01 留下的 `/` 占位(`HomePlaceholder`)替换为真实应用外壳与主页落地页,复刻原型 `#topbar` + `#nav-overlay` + `#screen-main` 的布局与交互语义,登录态守卫与导航编排真实生效,KPI/导航数据为前端静态 demo: + +- **路由壳与守卫**:`` 包裹布局路由 ``;index `/` → ``,子路由 `/usr/users`(FE-03 容器)、`/usr/users/new` 与 `/usr/users/:id`(FE-04 容器)。未登录进受保护路由 → ``(BR1);token 已存在但 `user` 未就绪 → `Spin` 占位(`authResolving`)。已登录访问 `/login` → 回主页(BR2,FE-01 § 6.7 已指明此守卫归 FE-02)。 +- **顶栏 TopBar**:品牌 Logo(鹿角 SVG,点击回 `/`)、「全部导航」汉堡按钮(切 overlay,`navOverlayOpen` 时高亮)、固定「主页」标签(不可关)+ 动态业务标签栈、右侧搜索/通知图标(占位)、当前用户区 `sUserName(sUserType)` + 下拉「退出登录」、更多「⋯」占位。 +- **标签栈**(BR4/BR5/BR6,复刻 `tabsOpen`/`openTab`/`.close`):本地受控态。「主页」恒在最左不可关;打开 FE-03 → 追加「用户列表」;打开 FE-04 → 先确保「用户列表」存在再追加「用户信息单据」;关「用户列表」联动关「用户信息单据」并回主页;关「用户信息单据」回「用户列表」。标签激活与当前路由同步(点标签 = `navigate`)。 +- **全部导航总览 NavOverlay**(BR7,复刻 `#nav-overlay`):覆盖内容区深色浮层,左列 20 个一级模块(`navSide`,「系统设置」默认 active)、右侧 7 列分组(`navCols`)。仅「用户列表」有真实路由(点击 → 关 overlay + `navigate('/usr/users')`);其余占位项点击关 overlay + `message.info('功能开发中')`;「用户列表」「系统功能模块设置」带 ★。点遮罩 / Esc 关闭。 +- **主页 HomePage**(BR11,复刻 `#screen-main`):`KpiHeadBar`(标题 + 今日未处理 37428 红 / 未清总数 56433 蓝 + AI 助手占位按钮)+ `DashboardThreeCol`(左 280px 角色/流程树 + 右 KPI 合并网格)+ `CommonOps`(常用操作:用户列表 → `/usr/users`;系统功能模块设置 → 占位)+ `AppFooter` 页脚。数据全部来自 `dashboardData.ts` 静态 demo(D1/D2)。 +- **退出登录**(BR9):下拉「退出登录」→ `dispatch(clearCredentials())`(自动清 localStorage token)→ `message.success('已退出登录')` → `navigate('/login', { replace:true })`。 +- **被动 401**(BR10):`request.ts` 响应拦截器对 401 触发统一登出回调(清登录态 + `message.warning('登录已失效,请重新登录')` + 跳 `/login`),由外壳在挂载时注册回调(拦截器内无法用 React hooks,见 D11)。 +- **状态机 ≥5 态**(spec § 3):`authResolving` / `unauthenticated` / `ready` / `navOverlayOpen` / `tabOpen` / `empty` / `error` 均有测试固化。 +- **语义色只用 `var(--color-*)`**;顶栏 / 导航 overlay 深色底为外壳局部装饰,scoped 保留,不新增全局 token、不挪用语义 token(spec § 7 / D9)。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 2.1`,落点全在 `frontend/**`。**新增/改动**文件: + +``` +frontend/ +├── src/ +│ ├── router/index.tsx # 【改】替换 HomePlaceholder:嵌套路由 RequireAuth>AppLayout>{index HomePage, /usr/users, /usr/users/new, /usr/users/:id} + /login 包 RedirectIfAuthed + ErrorBoundary + 未匹配重定向 / +│ ├── router/RequireAuth.tsx # 【新增】受保护区守卫:读 authSlice.token/user → ready / authResolving(Spin) / unauthenticated(Navigate to /login,state.from) +│ ├── router/RedirectIfAuthed.tsx # 【新增】/login 守卫:已有有效登录态 → Navigate 回 from 或 /(BR2) +│ ├── router/AppErrorBoundary.tsx # 【新增】路由级 ErrorBoundary:子路由渲染抛错兜底「页面出错,请刷新或返回主页」+ 返回主页入口(spec § 3 error / D7) +│ ├── layouts/AppLayout/AppLayout.tsx # 【新增】应用外壳:TopBar + NavOverlay + + AppFooter;持有标签栈 / overlay 开关本地态(D3) +│ ├── layouts/AppLayout/TopBar.tsx # 【新增】顶栏:Logo + 导航按钮 + 标签条 + 右侧用户区(接收标签栈/overlay/用户 props) +│ ├── layouts/AppLayout/NavOverlay.tsx # 【新增】全部导航总览浮层(受控 open,左 navSide 列 + 右 navCols 网格;onNavigate / onClose) +│ ├── layouts/AppLayout/CurrentUserMenu.tsx # 【新增】当前用户 Dropdown(展示 sUserName(sUserType) + 退出登录) +│ ├── layouts/AppLayout/AppFooter.tsx # 【新增】页脚版权/经营范围/备案号(复刻原型 footer.foot) +│ ├── layouts/AppLayout/useTabStack.ts # 【新增】标签栈 hook(openTab/closeTab/activeKey 逻辑,BR4/5/6,复刻 tabsOpen/openTab/.close) +│ ├── layouts/AppLayout/navConfig.ts # 【新增】静态导航配置:NAV_SIDE(20 项)+ NAV_COLS(7 列,复刻 navSide/navCols;标注 routePath / star) +│ ├── layouts/AppLayout/AppLayout.module.css # 【新增】外壳 scoped 样式:语义色用 var(--color-*);顶栏/overlay 深色底为局部装饰(D9) +│ ├── pages/home/HomePage/HomePage.tsx # 【新增】主页落地页根(组合 KpiHeadBar + DashboardThreeCol + CommonOps) +│ ├── pages/home/HomePage/KpiHeadBar.tsx # 【新增】KPI 头条(标题 + 今日未处理/未清总数统计 + AI 占位按钮) +│ ├── pages/home/HomePage/RoleProcessTree.tsx # 【新增】左侧角色/流程树(按角色/按流程分组 + 计数;点击高亮,不取数) +│ ├── pages/home/HomePage/KpiBoard.tsx # 【新增】KPI 合并网格(导航类型/角色/子流程列跨行合并;空数据 Empty,BR11/D5) +│ ├── pages/home/HomePage/CommonOps.tsx # 【新增】常用操作卡(用户列表 → 路由;系统功能模块设置 → 占位) +│ ├── pages/home/HomePage/dashboardData.ts # 【新增】静态 demo 数据:KPI_STATS / ROLE_GROUPS / PROCESS_GROUPS / KPI_ROWS(复刻原型 kpiRows,D1/D2) +│ └── pages/home/HomePage/HomePage.module.css # 【新增】主页 scoped 样式(语义色用 var(--color-*);网格线/底色经 token) +├── src/api/request.ts # 【改】响应拦截器加 401 处理:触发已注册的 onUnauthorized 回调(D11,集中常量 HTTP_UNAUTHORIZED=401) +└── tests/ + ├── unit/RequireAuth.test.tsx # 【新增】守卫态:ready / authResolving / unauthenticated + ├── unit/RedirectIfAuthed.test.tsx # 【新增】已登录访问 /login 回主页(BR2) + ├── unit/useTabStack.test.ts(x) # 【新增】标签栈 BR4/5/6 联动 + ├── unit/AppLayout.topbar.test.tsx # 【新增】顶栏结构 + 当前用户文案 + 退出登录(BR3/BR9) + ├── unit/NavOverlay.test.tsx # 【新增】overlay 开关 + 分组渲染 + 路由项/占位项点击(BR7/BR8) + ├── unit/AppErrorBoundary.test.tsx # 【新增】子组件抛错兜底 + ├── unit/HomePage.test.tsx # 【新增】主页区域结构 + 统计文案 + 常用操作跳转(BR8/BR11) + ├── unit/KpiBoard.test.tsx # 【新增】KPI 网格表头/行渲染 + 空数据 Empty(BR11/empty 态) + ├── unit/request.unauthorized.test.ts # 【新增】401 触发 onUnauthorized 回调(BR10) + ├── unit/renderShell.tsx # 【新增】外壳/路由测试共享渲染工具(Provider + 真实 store + MemoryRouter + AntD App) + └── e2e/shell.spec.ts # 【新增】E2E 关键旅程:登录后落地主页 / 导航 overlay / 打开关闭用户列表标签 / 退出登录 +``` + +- **跨阶段/跨模块**:本 FE 落点全在 `frontend/**`,不触 `backend/` / `sql/` / `scripts/`。改动 `src/router/index.tsx` 与 `src/api/request.ts` 属 FE-01 搭建的**全前端共享骨架**(非 FE-02 私有),在《模块完成报告》留痕「FE-02 将 `/` 占位替换为应用外壳 + 受保护路由守卫;为 BR10 给 `request.ts` 增 401 统一登出回调;改动属共享骨架,FE-03/FE-04 复用」。 +- **状态管理**(docs/04 § 2.2 / D3):标签栈(已打开集合 + activeKey)与 overlay 开关为外壳局部 UI 态,用 `AppLayout` 内 `useState` / `useTabStack` 本地管理,不进 Redux;登录态(token/user)复用 Redux `authSlice`。 +- **请求封装 / 错误处理**(docs/04 § 2.3/2.4):本壳不新增业务取数(D1);仅扩展 `request.ts` 401 统一处理(D11),与守卫 `unauthenticated` 态协同。 +- **Design Tokens**(docs/04 § 2.1 / spec § 7):语义色(主操作/文字/边框/错误/成功/表头/行)只用 `var(--color-*)`;顶栏 `#1f1f23`、overlay `#2b3137` 等品牌深色底为外壳局部装饰,scoped 在 `*.module.css`,不新增全局 token、不挪用语义 token(D9,与 FE-01 § 7 D7 一致)。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + FE-01 骨架) + +- React 18 / Ant Design 5(`Tabs` / `Dropdown` / `Drawer`或受控 div / `Tree`或列表 / `Table`或 CSS Grid / `Empty` / `Spin` / `message`)/ Redux Toolkit / React Router v6(`Outlet` / `Navigate` / `useNavigate` / `useLocation` / `useMatch`或`matchPath`)/ Vite / Axios / TypeScript;`@ant-design/icons`。 +- 测试:单测 Vitest(jsdom)+ `@testing-library/react|jest-dom|user-event`;E2E Playwright。沿用 FE-01 `tests/setup.ts`、`vite.config.ts` 配置。 +- 命令(docs/04 § 零):build `npm run build`;lint `npm run lint`;unit `npm run test:unit`;e2e `npm run test:e2e`。子会话验证用 `cd frontend && npm run test:unit -- <文件名片段>`。 +- 提交格式:`(): REQ-USR-XXX`。本 FE 无单一 CRUD REQ,是 USR 子功能的导航/承载容器;**scope 统一用 `fe-shell`**;subject REQ 后缀按导向的子功能标注(导航壳/路由/守卫/标签栈相关用 `REQ-USR-003`=其承载的首要入口「用户列表」;用户身份展示/退出相关用 `REQ-USR-004`=登录态来源)。每个任务在其 commit 行注明所用 REQ tag。 + +## 合同级常量(跨 task 必须一致) + +- **路由 path**(React Router v6): + - `/login`(FE-01 登录页,放行;包 `RedirectIfAuthed`,不包 `AppLayout`)。 + - `/`(index,受保护,→ `HomePage`)。 + - `/usr/users`(受保护,FE-03 容器;本 FE 仅提供导航入口与标签挂载位,目标内容属 FE-03)。 + - `/usr/users/new`(受保护,FE-04 新增容器)。 + - `/usr/users/:id`(受保护,FE-04 修改容器)。 + - 未匹配:受保护区内 → `Navigate to="/"`;整体未匹配兜底 → 经守卫落到 `/login`(D7)。 +- **标签栈 key(业务标签标识,跨组件一致)**:`'userlist'`(标题「用户列表」,路由 `/usr/users`,可关闭)、`'userdetail'`(标题「用户信息单据」,路由 `/usr/users/new` 或 `/usr/users/:id`,可关闭);固定标签 `'home'`(标题「主页」,路由 `/`,**不可关闭,恒在最左**)。 +- **localStorage token 键**:`TOKEN_STORAGE_KEY = 'xly_erp_token'`(复用 `api/request.ts` 已导出常量,不写字面量)。 +- **HTTP 状态码常量**(`api/request.ts`,新增):`HTTP_UNAUTHORIZED = 401`。 +- **静态文案(逐字一致,复刻原型 / spec)**: + | 用途 | 文案 | + |---|---| + | 全部导航按钮 | `全部导航` | + | 主页标签 | `主页` | + | 用户列表标签/入口 | `用户列表` | + | 用户单据标签 | `用户信息单据` | + | KPI 标题 | `KPI监控` | + | 今日未处理统计前缀 | `今日未处理:`(值 `37428`,红,`var(--color-error)`) | + | 未清总数统计前缀 | `未清总数:`(值 `56433`,蓝,`var(--color-primary)`) | + | AI 助手按钮 | `小ai同学,请帮我安排今日工作` | + | 常用操作卡标题 | `常用操作` | + | 常用操作项 | `用户列表` / `系统功能模块设置`(后者占位) | + | KPI 网格表头 7 列 | `导航类型` / `角色` / `KPI待处理事项(当前行双击进入)` / `KPI内容描述及处理结果(点击蓝色查看明细)` / `今日未处理` / `未清总数` / `子流程` | + | 退出登录菜单项 | `退出登录` | + | 退出登录成功提示 | `已退出登录`(`message.success`,`var(--color-success)`) | + | 被动 401 提示 | `登录已失效,请重新登录`(`message.warning`,BR10) | + | 导航占位项点击提示 | `功能开发中`(`message.info`,BR7/D4) | + | 角色树分组 | `按角色` / `按流程` | + | 空数据占位 | AntD `Empty` 默认「暂无数据」(KPI 网格/角色树空时,empty 态) | + | 路由级错误兜底 | `页面出错,请刷新或返回主页` + 「返回主页」入口(D7) | + | 页脚正文 | 复刻原型 footer.foot:`©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 | 文件智能处理 | 印前自动化 | 400-880-6237` + 备案号 `沪ICP备14034791号-1` | + | 当前用户区文案规则 | `` `${sUserName}(${sUserType})` ``(D10;`user` 缺失时退化为占位用户名,见 BR3) | +- **当前用户显示规则(BR3 / D10)**:拼 `` `${user.sUserName}(${user.sUserType})` ``(`sUserType` 已是中文「超级管理员/普通用户」,不再映射);`user` 为 null 时退化展示占位(如 `未登录用户` 或 `用户`,TDD 期定,登记于本计划占位常量并保持一处定义)。 + +## 关键签名(首次出现处给出,跨 task 一致) + +- **路由守卫**(`router/`): + - `RequireAuth`(`{ children?: ReactNode }` 或用于 `}>` 的布局守卫,内部渲染 `` 或 `children`):读 `useAppSelector(s => s.auth)`;`token` 为 null → ``(BR1);`token` 存在但 `user` 为 null → 渲染 `Spin` 占位(`authResolving`,data-testid 可选 `auth-resolving`);否则放行(`ready`)。 + - `RedirectIfAuthed`(包 `/login`,`{ children: ReactNode }`):`token` 与 `user` 均就绪(或仅 token 即视为已登录,TDD 期定,登记一处)→ ``(BR2,`from` 取 `location.state.from`);否则渲染 `children`(LoginPage)。 + - `AppErrorBoundary`(React class ErrorBoundary,`{ children: ReactNode }`):`componentDidCatch` 后渲染兜底 UI + 「返回主页」按钮(`window.location` 或 navigate `/`)。 +- **应用外壳**(`layouts/AppLayout/`): + - `AppLayout`(default export,无 props,布局路由组件):内部 `useTabStack()` + `useState(navOverlayOpen)` + `useAppSelector(authSlice.user)` + `useNavigate` + `useLocation`;渲染 `TopBar` / `NavOverlay` / `` / `AppFooter`;挂载时注册 `request.ts` 的 `onUnauthorized`(D11)。 + - `useTabStack()` → `{ tabs: TabItem[]; activeKey: string; openTab(key: 'userlist'|'userdetail'): void; closeTab(key: string): void; setActive(key: string): void }`,其中 `TabItem = { key: string; title: string; closable: boolean; routePath: string }`。逻辑(BR4/5/6):恒含 `home`(不可关、最左);`openTab('userlist')` 确保 userlist 在;`openTab('userdetail')` 确保 userlist+userdetail 在;`closeTab('userlist')` 同时移除 userdetail 并激活 home;`closeTab('userdetail')` 激活 userlist。**该 hook 只管标签集合与 activeKey,路由跳转由调用方据 `routePath` 执行**(保持 hook 纯净可测)。 + - `TopBar`(`{ user: AuthUser | null; tabs: TabItem[]; activeKey: string; navOverlayOpen: boolean; onToggleNav(): void; onSelectTab(key): void; onCloseTab(key): void; onLogout(): void; onLogoHome(): void }`)。 + - `NavOverlay`(`{ open: boolean; onClose(): void; onNavigate(routePath: string): void; onPlaceholder(): void }`):渲染 `NAV_SIDE` 左列 + `NAV_COLS` 右网格;叶子项有 `routePath` → `onNavigate`,否则 → `onPlaceholder`;点遮罩/Esc → `onClose`。 + - `CurrentUserMenu`(`{ user: AuthUser | null; onLogout(): void }`):AntD `Dropdown`,menu 项「退出登录」→ `onLogout`。 + - `AppFooter`(无 props):静态文案条。 +- **导航静态配置**(`layouts/AppLayout/navConfig.ts`): + - `NAV_SIDE: { key: string; label: string; active?: boolean }[]`(20 项,复刻 `navSide` 的 label 与「系统设置」active;图标可省或用占位)。 + - `NAV_COLS: { title: string; items: NavLeaf[] }[]`,`NavLeaf = { label: string; routePath?: string; star?: boolean }`(复刻 `navCols`;仅「用户列表」`routePath:'/usr/users'`;「用户列表」「系统功能模块设置」`star:true`;其余 `routePath` 省略=占位)。 +- **主页静态数据**(`pages/home/HomePage/dashboardData.ts`): + - `KPI_STATS = { todayPending: 37428; openTotal: 56433 }`(复刻原型 `.kpi-head`,D2)。 + - `ROLE_GROUPS: { label: string; count: number }[]` 与 `PROCESS_GROUPS: { label: string; count: number }[]`(复刻原型「按角色」「按流程」条目与计数,D2)。 + - `KPI_ROWS: KpiRow[]`,`KpiRow = { role: string | null; item: string; desc: string; today: string; total: string; sub?: string; red?: boolean; navTypeFirst?: boolean; roleSpan?: number; subSpan?: number }`(**字段名与原型 `kpiRows` 一致**,复刻全量行,D2)。 +- **主页组件 props**: + - `KpiHeadBar`(`{ stats: typeof KPI_STATS }`);`RoleProcessTree`(`{ roleGroups; processGroups; onSelect?(label) }`,本地高亮态);`KpiBoard`(`{ rows: KpiRow[] }`,空数组 → `Empty`);`CommonOps`(`{ onOpenUserList(): void }`)。 +- **request.ts 401 钩子**(`api/request.ts`,新增导出): + - `HTTP_UNAUTHORIZED = 401` 常量。 + - `registerUnauthorizedHandler(fn: () => void): void`(模块级单例,外壳挂载时注册;响应拦截器捕获 HTTP 401 时调用该回调再抛 `ApiError`)。`onUnauthorized` 回调内容由外壳提供:`clearCredentials` + `message.warning('登录已失效,请重新登录')` + 跳 `/login`(BR10)。 + +## 测试栈说明 + +- **jsdom 组件 / hook / store 单测**(Vitest + RTL):默认用真实 `store`(`configureStore`)+ `MemoryRouter`(`initialEntries` 指定路由)+ AntD `App` 上下文(经 `renderShell.tsx`)。守卫/标签栈/overlay/主页交互均在组件层断言;不依赖真实后端(本壳无取数)。`useTabStack` 纯逻辑可用 `renderHook` 直测。 +- **Playwright E2E**:`page.route` 桩 `**/api/usr/login` 与 `**/api/usr/users`(FE-03 取数桩,仅为标签可挂载,不验列表内容),覆盖:登录→落地主页(顶栏可见、KPI 标题可见);点「全部导航」→ overlay 显隐;从常用操作/导航打开「用户列表」标签并关闭(联动回主页);退出登录回 `/login`。不依赖真实后端起服。 +- **可测性**:优先用语义查询(role/text/label);仅当 RTL/Playwright 无法稳定定位时添加最小 `data-testid`(如 `nav-overlay` / `auth-resolving` / `tab-userlist`)。 + +--- + +## 任务列表(每个 task = red → green → 子会话验证 → commit) + +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。 +> 提交 scope 统一 `fe-shell`;REQ tag 按任务承载的子功能标注(见上「合同级常量」末段规则)。 + +### T0 — 外壳/路由测试共享渲染工具(chore,先建测试地基) +- **测试先行类型**:jsdom 组件测试(自身即一个最小冒烟用例) +- [ ] **1. 写失败测试**:`frontend/tests/unit/renderShell.tsx` 自带最小冒烟 `frontend/tests/unit/renderShell.smoke.test.tsx::renderShell mounts a route element`——用 `renderShell(
shell-ok
, { initialEntries:['/'], preloadedAuth:{ token:'t', user:{...} } })` 渲染并断言 `shell-ok` 在文档中;初始因工具未实现而失败。 +- [ ] **2. 实现最小代码**:`frontend/tests/unit/renderShell.tsx`——导出 `makeShellStore(preloadedAuth?)`(`configureStore` 挂 `auth`,可注入 `token`/`user`)与 `renderShell(ui, { initialEntries?, preloadedAuth?, store? })`(Provider + 真实 store + `ConfigProvider` + AntD `App` + `MemoryRouter`)。复用 FE-01 `renderLogin.tsx` 模式。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- renderShell` +- [ ] **4. commit**:`test(fe-shell): 外壳/路由测试共享渲染工具 REQ-USR-003` + +### T1 — RequireAuth 守卫三态(authResolving / unauthenticated / ready,BR1)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/RequireAuth.test.tsx`: + - `::redirects to /login when no token`——无 token 时进 `/`,断言落到 `/login` 渲染(用一条 `/login` 哨兵路由 + 断言哨兵文本/URL),且携带 `state.from`(可用哨兵读 `useLocation().state`)。 + - `::renders Spin placeholder when token present but user not resolved`——`preloadedAuth:{ token:'t', user:null }`,断言出现加载占位(`auth-resolving` / `Spin`),不渲染受保护内容。 + - `::renders protected content when token and user ready`——`token` + `user` 就绪,断言放行渲染 `` 子内容(哨兵)。 +- [ ] **2. 实现最小代码**:`frontend/src/router/RequireAuth.tsx`(签名见关键签名;用 `useLocation` 取 `from`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- RequireAuth` +- [ ] **4. commit**:`feat(fe-shell): 受保护路由守卫 RequireAuth 三态 REQ-USR-004` + +### T2 — RedirectIfAuthed:已登录访问 /login 回主页(BR2)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/RedirectIfAuthed.test.tsx`: + - `::renders children when unauthenticated`——无登录态,进 `/login`,渲染 children(哨兵「login-screen」)。 + - `::redirects to / when already authenticated`——`token`+`user` 就绪进 `/login`,断言重定向到 `/`(哨兵)。 + - `::redirects to from when present`——`state.from='/usr/users'` 且已登录 → 重定向到 `/usr/users`。 +- [ ] **2. 实现最小代码**:`frontend/src/router/RedirectIfAuthed.tsx`(签名见关键签名)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- RedirectIfAuthed` +- [ ] **4. commit**:`feat(fe-shell): 已登录访问登录页重定向守卫 REQ-USR-004` + +### T3 — useTabStack 标签栈逻辑(BR4/BR5/BR6)(jsdom hook 测) +- **测试先行类型**:jsdom 组件测试(`renderHook`) +- [ ] **1. 写失败测试**:`frontend/tests/unit/useTabStack.test.tsx`: + - `::starts with fixed home tab only (closable false, leftmost)`——初始 `tabs` 仅 `home`,`activeKey==='home'`,home `closable===false`。 + - `::openTab userlist appends userlist and activates it`——`openTab('userlist')` 后 tabs 含 home+userlist,`activeKey==='userlist'`,userlist `closable===true`、`routePath==='/usr/users'`(BR4)。 + - `::openTab userdetail ensures userlist exists then appends userdetail`——直接 `openTab('userdetail')`,tabs 含 home+userlist+userdetail,`activeKey==='userdetail'`(BR6)。 + - `::closeTab userlist also removes userdetail and activates home`——先开 userlist+userdetail,`closeTab('userlist')` → tabs 仅 home,`activeKey==='home'`(BR5)。 + - `::closeTab userdetail activates userlist`——开 userlist+userdetail,`closeTab('userdetail')` → tabs 含 home+userlist,`activeKey==='userlist'`。 + - `::open existing tab does not duplicate`——重复 `openTab('userlist')` 不产生重复项。 +- [ ] **2. 实现最小代码**:`frontend/src/layouts/AppLayout/useTabStack.ts`(签名 / `TabItem` 见关键签名;标签 key/title/routePath 用合同级常量)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- useTabStack` +- [ ] **4. commit**:`feat(fe-shell): 顶栏标签栈联动逻辑 useTabStack REQ-USR-003` + +### T4 — navConfig 与 dashboardData 静态配置(D1/D2/D4)(jsdom 单测) +- **测试先行类型**:jsdom 组件测试(纯数据断言) +- [ ] **1. 写失败测试**: + - `frontend/tests/unit/navConfig.test.ts`:`NAV_SIDE` 长度为 20 且含「系统设置」`active:true`;`NAV_COLS` 含 7 组、组标题为 `期初设置/用户管理/系统参数/计算方案/日志/开发平台/API对接管理`;「用户管理」组内「用户列表」`routePath==='/usr/users'` 且 `star===true`;「开发平台」组内「系统功能模块设置」`star===true` 且无 `routePath`(占位,BR7/D4)。 + - `frontend/tests/unit/dashboardData.test.ts`:`KPI_STATS.todayPending===37428`、`openTotal===56433`(D2);`KPI_ROWS` 首行 `role==='核价人员'`、`navTypeFirst===true`、`roleSpan===4`、`sub==='估价管理流程'`、`subSpan===5`(复刻原型 kpiRows 字段,D2);`ROLE_GROUPS` 含「所有部门」计数 37428、「客服部」计数 30127。 +- [ ] **2. 实现最小代码**:`frontend/src/layouts/AppLayout/navConfig.ts`(`NAV_SIDE` / `NAV_COLS`)+ `frontend/src/pages/home/HomePage/dashboardData.ts`(`KPI_STATS` / `ROLE_GROUPS` / `PROCESS_GROUPS` / `KPI_ROWS`),全量复刻原型内联数据(D1/D2)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- navConfig dashboardData` +- [ ] **4. commit**:`feat(fe-shell): 导航与主页 KPI 静态配置数据 REQ-USR-003` + +### T5 — KpiBoard KPI 合并网格 + 空数据(BR11 / empty 态 / D5)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/KpiBoard.test.tsx`: + - `::renders 7 column headers`——渲染 7 个表头文案(合同级常量逐字一致)。 + - `::renders all kpi rows with item/desc/today/total`——传 `KPI_ROWS`,断言可见某几行 `item`(如「01/04【新增】新报价单」)与红色统计数(red 行数字用 `var(--color-error)`)。 + - `::renders Empty when rows is empty`——传 `rows={[]}`,渲染 AntD `Empty`(empty 态,BR11)。 + - `::KPI item/desc rendered as link-styled text without navigation`——「KPI待处理事项/内容描述」为蓝色链接样式但点击不发生路由跳转(纯展示,BR11)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/home/HomePage/KpiBoard.tsx`(AntD `Table` rowSpan 合并「导航类型/角色/子流程」列,或 CSS Grid `gridRow span` 复刻原型——TDD 期二选一,登记 D5;空数组 → `Empty`)+ `HomePage.module.css` 网格样式(线/底色用 `var(--color-border)` / `var(--color-table-header-bg)` 等 token)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- KpiBoard` +- [ ] **4. commit**:`feat(fe-shell): 主页 KPI 合并网格与空数据态 REQ-USR-003` + +### T6 — HomePage 落地页区域组合(KPI 头条 / 角色树 / 常用操作 / 页脚,BR8/BR11)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/HomePage.test.tsx`(`renderShell`,`initialEntries:['/']`): + - `::renders KPI head with title and stats`——可见「KPI监控」、「今日未处理:」+ 37428、「未清总数:」+ 56433、AI 按钮文案「小ai同学,请帮我安排今日工作」。 + - `::renders role/process tree groups`——可见「按角色」「按流程」分组及条目(如「所有部门 (37428)」「客服部 (30127)」)。 + - `::tree item click highlights without navigation`——点击角色条目后该项高亮(active),不触发路由跳转/取数(BR11)。 + - `::common ops user-list click navigates to /usr/users and opens tab`——点「常用操作 > 用户列表」触发 `navigate('/usr/users')`(断言哨兵/URL 改变或 `onOpenUserList` 被调)(BR8)。 + - `::renders footer copyright text`——可见页脚版权与备案号文本。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/home/HomePage/HomePage.tsx` + `KpiHeadBar.tsx` + `RoleProcessTree.tsx`(本地高亮 `useState`)+ `CommonOps.tsx`(用户列表 → 调用 `onOpenUserList` / navigate)+ `pages/home/HomePage/AppFooter` 复用外壳 `AppFooter`(页脚归外壳;主页内 `.foot` 由 `AppLayout` 渲染——若主页自带页脚则置于 HomePage,TDD 期定一处,登记);`HomePage.module.css`。语义色用 token,统计红/蓝用 `var(--color-error)`/`var(--color-primary)`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- HomePage` +- [ ] **4. commit**:`feat(fe-shell): 主页落地页区域组合 REQ-USR-003` + +### T7 — NavOverlay 全部导航总览(开关 / 分组渲染 / 路由项与占位项,BR7/BR8/D4)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/NavOverlay.test.tsx`: + - `::hidden when open is false / visible when true`——`open=false` 不渲染网格内容;`open=true` 渲染左列 20 项 + 7 组标题。 + - `::side has 系统设置 active`——左列「系统设置」处激活态。 + - `::clicking 用户列表 calls onNavigate('/usr/users')`——点右网格「用户列表」叶子 → `onNavigate` 收到 `/usr/users`(BR8)。 + - `::clicking placeholder leaf calls onPlaceholder (no navigate)`——点占位叶子(如「系统权限」)→ `onPlaceholder` 被调、`onNavigate` 未调(BR7/D4)。 + - `::Esc / mask click calls onClose`——按 Esc 或点遮罩 → `onClose` 被调。 +- [ ] **2. 实现最小代码**:`frontend/src/layouts/AppLayout/NavOverlay.tsx`(受控 `open`;左列渲染 `NAV_SIDE`、右网格渲染 `NAV_COLS`;叶子据 `routePath` 分流 `onNavigate`/`onPlaceholder`;Esc/遮罩 `onClose`)+ overlay 深色底 scoped 样式(D9)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- NavOverlay` +- [ ] **4. commit**:`feat(fe-shell): 全部导航总览浮层 NavOverlay REQ-USR-003` + +### T8 — TopBar 顶栏结构 + 当前用户 + 退出登录(BR3/BR9)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/AppLayout.topbar.test.tsx`: + - `::renders brand logo / 全部导航 button / 主页 tab`——可见 Logo、「全部导航」按钮、固定「主页」标签(无关闭按钮)。 + - `::renders current user as sUserName(sUserType)`——`user:{ sUserName:'朱子纯', sUserType:'超级管理员', ... }` → 可见「朱子纯(超级管理员)」(BR3/D10)。 + - `::user fallback when user is null`——`user:null` → 退化占位用户名(合同级占位常量),不崩溃。 + - `::logout menu dispatches clearCredentials, shows success, navigates /login`——展开当前用户下拉点「退出登录」→ store `auth.token===null`、`localStorage` token 被移除、`message.success('已退出登录')`、URL/哨兵到 `/login`(BR9)。 + - `::nav toggle button highlights when navOverlayOpen`——`navOverlayOpen=true` 时「全部导航」按钮带激活态。 + - `::clicking business tab close calls onCloseTab`——点 userlist 标签「✕」→ `onCloseTab('userlist')` 被调。 +- [ ] **2. 实现最小代码**:`frontend/src/layouts/AppLayout/TopBar.tsx` + `CurrentUserMenu.tsx`(签名见关键签名;标签条用 AntD `Tabs` editable-card 或自定义受控 div,D3——主页 tab `closable:false`;退出登录 `onLogout` 内 `dispatch(clearCredentials())` + `message.success` + `navigate('/login',{replace:true})`,由 `AppLayout` 提供回调)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- AppLayout.topbar` +- [ ] **4. commit**:`feat(fe-shell): 顶栏与当前用户/退出登录 REQ-USR-004` + +### T9 — AppLayout 外壳装配 + 标签↔路由同步(ready / navOverlayOpen / tabOpen 态)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/AppLayout.shell.test.tsx`(`renderShell` 已登录,渲染真实 `AppLayout` + 子哨兵路由): + - `::renders TopBar + Outlet + Footer when ready`——已登录进 `/`,顶栏 + 主页 Outlet + 页脚同时在(ready 态)。 + - `::toggle 全部导航 opens/closes overlay`——点「全部导航」→ overlay 显(navOverlayOpen);再点/遮罩 → 隐。 + - `::nav overlay 用户列表 navigates and opens tab`——overlay 点「用户列表」→ overlay 关、URL 到 `/usr/users`、顶栏出现「用户列表」标签并激活(tabOpen 态,BR8)。 + - `::clicking home tab navigates back to /`——多标签下点「主页」标签 → URL 回 `/`、主页激活。 + - `::active tab syncs with current route`——直接进 `/usr/users` 时「用户列表」标签为激活态(路由→标签同步)。 +- [ ] **2. 实现最小代码**:`frontend/src/layouts/AppLayout/AppLayout.tsx`(组合 `TopBar`/`NavOverlay`/``/`AppFooter`;`useTabStack` + `navOverlayOpen` 本地态;标签点击 → `navigate(routePath)`;据 `useLocation` 反向同步 activeKey 与已打开标签;overlay 路由项 → 关 overlay + openTab + navigate)+ `AppLayout.module.css`(顶栏深色底 scoped,D9)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- AppLayout.shell` +- [ ] **4. commit**:`feat(fe-shell): 应用外壳装配与标签路由同步 REQ-USR-003` + +### T10 — 路由表接线 + ErrorBoundary(替换 / 占位,含 401 协同入口,BR1/BR2/D7)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**: + - `frontend/tests/unit/router.test.tsx`(用真实 `AppRouter` + `MemoryRouter`,mock 子页面/FE-03/FE-04 为哨兵占位以隔离未实现页): + - `::unauthenticated / redirects to /login`(BR1);`::authenticated / renders HomePage shell`(落地主页);`::authenticated /usr/users renders under AppLayout`(受保护子路由在外壳内);`::authenticated /login redirects to /`(BR2);`::unknown protected path redirects to /`(D7)。 + - `frontend/tests/unit/AppErrorBoundary.test.tsx`:`::renders fallback with 返回主页 when child throws`——子组件抛错 → 兜底文案「页面出错,请刷新或返回主页」+「返回主页」入口。 +- [ ] **2. 实现最小代码**:改 `frontend/src/router/index.tsx`——嵌套结构 `RequireAuth > AppLayout`(index `HomePage` + `/usr/users` / `/usr/users/new` / `/usr/users/:id` 暂用占位/懒加载位,FE-03/FE-04 落地时替换);`/login` 包 `RedirectIfAuthed`;外壳内套 `AppErrorBoundary`;未匹配 `Navigate to="/"`(受保护)/经守卫到 `/login`。新增 `frontend/src/router/AppErrorBoundary.tsx`。**子路由目标内容(FE-03/FE-04)不在本 FE 实现**,仅留可挂载的占位元素。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- router AppErrorBoundary` +- [ ] **4. commit**:`feat(fe-shell): 受保护嵌套路由表与错误边界 REQ-USR-003` + +### T11 — request.ts 401 统一登出回调(BR10 / D11)(jsdom 单测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/request.unauthorized.test.ts`(沿用 FE-01 `axios-mock-adapter` 模式): + - `::HTTP 401 triggers registered onUnauthorized then rejects ApiError`——`registerUnauthorizedHandler(spy)`,mock 适配器对某请求返回 HTTP 401,断言 `spy` 被调用一次且请求最终 reject 为 `ApiError`。 + - `::no handler registered does not throw`——未注册回调时 401 仍 reject `ApiError`,不抛额外异常。 + - 不破坏 FE-01 既有断言(401 业务体仍映射 `ApiError`)。 +- [ ] **2. 实现最小代码**:改 `frontend/src/api/request.ts`——加 `HTTP_UNAUTHORIZED=401` 常量、模块级 `registerUnauthorizedHandler(fn)` 与 `onUnauthorized` 单例;响应错误拦截器中 `error.response?.status===401` 时调用回调(若已注册)再走原 `ApiError` 映射逻辑。**不改变** FE-01 既有拆包/网络兜底语义。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- request`(含 FE-01 `request` 既有用例不回归) +- [ ] **4. commit**:`feat(fe-shell): 401 统一登出回调接入 request 拦截器 REQ-USR-004` + +### T12 — AppLayout 注册 401 登出处理(壳层接线,BR10)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/AppLayout.unauthorized.test.tsx`: + - `::registers onUnauthorized on mount; invoking it clears auth + warns + navigates /login`——渲染已登录 `AppLayout`,捕获注册的回调(spy `registerUnauthorizedHandler`),调用之 → store `auth.token===null`、`message.warning('登录已失效,请重新登录')`、URL 到 `/login`(BR10)。 +- [ ] **2. 实现最小代码**:在 `AppLayout` `useEffect` 内 `registerUnauthorizedHandler(() => { dispatch(clearCredentials()); message.warning('登录已失效,请重新登录'); navigate('/login',{replace:true}); })`(卸载时可清理)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- AppLayout.unauthorized` +- [ ] **4. commit**:`feat(fe-shell): 外壳注册被动401统一登出 REQ-USR-004` + +### T13 — E2E 外壳关键旅程(Playwright) +- **测试先行类型**:Playwright E2E +- [ ] **1. 写失败测试**:`frontend/tests/e2e/shell.spec.ts`(`page.route` 桩 `**/api/usr/login` 成功响应;桩 `**/api/usr/users` 返回 `{code:0,...,data:{records:[],total:0,...}}` 仅为标签挂载;不验 FE-03 列表内容): + - `::login then lands on home with topbar and KPI title`——登录成功后 URL 到 `/`,顶栏「全部导航」与「KPI监控」可见。 + - `::open and close 全部导航 overlay`——点「全部导航」overlay 显示 7 组;Esc/遮罩关闭。 + - `::open 用户列表 tab from common ops then close back to home`——常用操作点「用户列表」→ 出现「用户列表」标签、URL `/usr/users`;点标签「✕」→ 回主页、标签移除(BR5/BR8)。 + - `::logout returns to /login`——当前用户下拉「退出登录」→ URL 回 `/login`、可见「已退出登录」提示。 + - `::visiting / unauthenticated redirects to /login`——清除 token 后直接访问 `/` → 跳 `/login`(BR1)。 +- [ ] **2. 实现最小代码**:补任何为可测性需要的最小 `data-testid`(仅 RTL/Playwright 无法稳定定位时);E2E 桩中 FE-03/FE-04 子路由用占位即可(本 FE 不实现其内容)。沿用 FE-01 `playwright.config.ts`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- shell` +- [ ] **4. commit**:`test(fe-shell): 应用外壳 E2E 关键旅程 REQ-USR-003` + +### T14 — 全量门禁回归 + 收尾(chore) +- **测试先行类型**:无新增测试(全量验证) +- [ ] **1. 写失败测试**:无。 +- [ ] **2. 实现最小代码**:修 lint / build / 类型问题;确认语义色全部 `var(--color-*)`、无硬编码 hex/rgba(顶栏/overlay 深色装饰 scoped 例外,D9);确认无 `TBD/TODO/【人工填写】`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。 +- [ ] **4. commit**:`chore(fe-shell): FE-02 门禁回归通过 REQ-USR-003` + +--- + +## 完成判据(Definition of Done) + +1. 登录后落地主页 `/`,渲染复刻原型 `#screen-main` 的 KPI 头条 / 角色流程树 / KPI 合并网格 / 常用操作 / 页脚(spec § 2 / § 6.6)。 +2. 应用外壳 `AppLayout` 渲染顶栏(Logo + 全部导航 + 标签栈 + 当前用户 + 退出)+ 导航总览 overlay + `` + 页脚(spec § 2)。 +3. 状态机覆盖并测试固化:`authResolving` / `unauthenticated` / `ready`(T1/T10)、`navOverlayOpen`(T7/T9)、`tabOpen`(T3/T9)、`empty`(T5)、`error`(T10 ErrorBoundary)(spec § 3)。 +4. 业务/交互规则 BR1~BR11 在组件层 / E2E 有断言:BR1/BR2(T1/T2/T10)、BR3/BR9(T8)、BR4/BR5/BR6(T3/T9)、BR7(T7)、BR8(T6/T7/T9)、BR10(T11/T12)、BR11(T5/T6)(spec § 5)。 +5. **不新增任何后端取数**;KPI/导航/角色树为前端静态配置(`dashboardData.ts` / `navConfig.ts`),复刻原型 demo(spec § 4 / D1/D2)。 +6. 退出登录纯前端清登录态 + 删 token + 跳 `/login`(spec § 6.7 / D6);被动 401 经 `request.ts` 统一登出回调跳 `/login`(BR10 / D11)。 +7. 标签栈复刻原型 `tabsOpen/openTab/.close` 联动(主页固定不可关、userlist↔userdetail 父子联动)(spec § 6.4 / BR4-6)。 +8. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 沿用 FE-01 `ConfigProvider` 对齐;顶栏/overlay 深色底 scoped 装饰不新增全局 token(spec § 7 / D9)。 +9. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动;改 `router/index.tsx`、`api/request.ts` 属共享骨架,已在《模块完成报告》留痕。 +10. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。 + +## 自审记录 + +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 占位(正文中 `TBD/TODO` 仅作为「禁止出现的字样」被引用,非真实占位)。 +- **spec coverage**:spec § 1 关联 REQ/原型→架构 + 全任务;§ 2 组件树→T5(KpiBoard)/T6(HomePage)/T7(NavOverlay)/T8(TopBar)/T9(AppLayout)/T10(路由);§ 3 状态机→T1/T10(auth 三态+error)/T7/T9(navOverlayOpen)/T3/T9(tabOpen)/T5(empty);§ 4 端点关系(仅承接守卫/跳转,不取数)→T1/T2/T10/T11/T12;§ 5 BR1-BR11→见 DoD 第 4 条逐条映射;§ 6 路由与交互→T1/T2/T3/T7/T8/T9/T10;§ 7 tokens→T5/T7/T8/T9/T14;§ 8 decisions D1-D10 已落实于架构/合同级常量/任务,本计划新增 D11(401 钩子机制)见下。 +- **本计划新增决策 D11(401 统一登出机制)**:`request.ts` 拦截器内无法使用 React hooks(`useNavigate`/`message`),故采用「模块级 `registerUnauthorizedHandler` 注册回调」模式:`AppLayout` 挂载时注册含 `clearCredentials + message.warning + navigate('/login')` 的回调,拦截器捕获 HTTP 401 时调用之(spec BR10 / docs/04 § 2.4)。这是对 FE-01 共享 `request.ts` 的最小非破坏性扩展(不改既有拆包/网络兜底语义),并在《模块完成报告》留痕。置信度 high,登记进 decisions[]。 +- **类型一致性**:标签 key(`home/userlist/userdetail`)、`TabItem`、路由 path(`/` `/login` `/usr/users` `/usr/users/new` `/usr/users/:id`)、`NAV_SIDE/NAV_COLS/NavLeaf`、`KPI_STATS/KPI_ROWS/KpiRow`(字段名对齐原型 `kpiRows`)、`AuthUser`(复用 FE-01 `api/types.ts`)、`TOKEN_STORAGE_KEY`/`HTTP_UNAUTHORIZED`、各文案常量跨 T0-T14 一致;当前用户文案规则 `` `${sUserName}(${sUserType})` `` 与 BR3/D10 一致。 +- **作用域自审**:所有 `impl_file` / `test_file` 均以 `frontend/` 开头;无 `backend/` / `sql/` / `scripts/` 落点。 diff --git a/docs/superpowers/plans/2026-06-01-FE-03.md b/docs/superpowers/plans/2026-06-01-FE-03.md new file mode 100644 index 0000000..81446dd --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-FE-03.md @@ -0,0 +1,284 @@ +# FE-03 用户列表与查询 — 任务级 TDD 计划(前端) + +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / api / 类型 / 样式 / 测试)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-FE-03.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;API 契约 `docs/05-API接口契约.md § REQ-USR-003`(`GET /api/usr/users`);原型 `prototype/erp.html` → `
`(`.toolbar` / `.filterbar` / `.table-shell .grid-table#user-table` / `.pager`,布局与交互权威);技术规范 `docs/04-技术规范.md § 零 / § 2.1 / § 2.3 / § 2.4 / § 3.2`;Design Tokens 仓库根 `src/styles/tokens.css`。 +> 复用资产:FE-01(`api/request.ts` 已建 Axios 实例 + Result 拆包 + `ApiError` + `TOKEN_STORAGE_KEY`;`api/types.ts`)、FE-02(`AppLayout` 外壳 + `RequireAuth` 守卫 + 标签栈 `useTabStack` + 路由表 `router/index.tsx` 已挂 `/usr/users` 占位 `UserListPlaceholder`)。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与类型契约 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整组件 / 类型文件内容。 +> **本 FE 为只读查询**:仅 `GET /api/usr/users`,无任何写副作用(spec § 5 BR5)。查询匹配语义、敏感字段过滤、分页越界回退等真伪裁决全部在后端,前端只采集条件 + 发起分页查询 + 依响应渲染表格 / 分页 / 空态 / 错误态 + 导航跳转。 + +--- + +## Goal(目标) + +把 FE-02 在 `router/index.tsx` 留下的 `/usr/users` 占位(`UserListPlaceholder`)替换为真实「用户列表与查询」页面,复刻原型 `#screen-userlist` 的布局与交互语义(工具栏 / 筛选栏 / 用户表格 / 分页栏),数据/筛选/分页真实对接 `GET /api/usr/users`: + +- **页面容器 `UserListPage`**(路由 `/usr/users`,渲染于 FE-02 `AppLayout` 的 `` 内):纵向组合 `UserToolbar` + `UserFilterBar` + `UserTable` + 内置分页,状态由页面本地 hook `useUserList` 持有(spec § 8 D6,不进 Redux)。 +- **API 封装**:`api/usrApi.ts` 新增 `listUsers(query): Promise>`(页面只调封装方法,不散用 axios,docs/04 § 2.3);`api/types.ts` 新增 `UserVO` / `PageResult` / `UserListQuery` 契约。 +- **状态机 ≥6 态**(spec § 3):`initialLoading` / `loading`(查询·翻页·刷新进行中)/ `success` / `empty` / `error` / `exporting`,均有测试固化。 +- **业务规则 BR1~BR15** 在组件层 / hook 层 / E2E 有断言(spec § 5)。 +- **工具栏**:刷新(保持当前条件+当前页重取,BR8)/ 新增(`navigate('/usr/users/new')`,BR13)/ 导出Excel(导出当前条件命中结果,BR9 / D5 见下方决策 D-PLAN-1)/ 设置齿轮(占位,spec D7)。 +- **筛选栏**:用户范围下拉(占位不传后端参数,spec D2)/ 查询字段下拉(默认「用户名」,BR2/BR4)/ 匹配方式下拉(默认「包含」,BR2/BR4)/ 查询值输入(空为全部,BR3,回车触发搜索 BR7)/ 更多「▾」(占位,spec D3)/ 搜索(回第 1 页查询,BR7)/ 清空(重置默认并全量查询,BR10)。 +- **用户表格**:AntD `Table`(`rowKey="id"`,服务端受控分页,`rowSelection={{type:'radio'}}` 单选标记仅服务进单据不参与查询 spec D8,行双击 → `navigate('/usr/users/'+id)` BR12,「作废」列只读 0/1→否/是 BR6,「序号」列按当前页计算 BR1)。 +- **分页**:AntD `Table` 内置 `pagination` 受控(`current`/`pageSize`/`total`/`showSizeChanger`/`showTotal`),`pageSize` 默认 10、可选 `[10,20,50,100]`(spec D4);切页/改每页条数重取(改每页条数回第 1 页,BR11);越界由后端回退、前端按响应 `pageNum` 回显(BR15)。 +- **空态 / 错误态**:空 `records` → AntD `Empty`「暂无匹配的用户」不报错(BR14);非 0 code / 网络异常 → 错误占位 +「点击重试」+ `message` 文案(spec § 4 错误码表);被动 401 由 `request.ts` 拦截器统一跳 `/login`(FE-02 D11,本页不重复处理)。 +- **语义色只用 `var(--color-*)`**;工具栏深色底为页面局部装饰,scoped 保留,不新增全局 token、不挪用语义 token(spec § 7 / D10)。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 2.1`,落点全在 `frontend/**`。**新增/改动**文件: + +``` +frontend/ +├── src/ +│ ├── api/types.ts # 【改】新增 UserVO / PageResult / UserListQuery 契约(沿用 FE-01 既有类型,不破坏) +│ ├── api/usrApi.ts # 【改】新增 listUsers(query): Promise>(GET /api/usr/users) +│ ├── pages/usr/UserList/index.tsx # 【新增】UserListPage 页面容器:组合工具栏/筛选栏/表格+分页;持 useUserList 态;导航跳转 +│ ├── pages/usr/UserList/useUserList.ts # 【新增】列表查询 hook(list/total/loading/error/query/exporting + 动作) +│ ├── pages/usr/UserList/UserToolbar.tsx # 【新增】深色工具条:刷新/新增/导出Excel/设置齿轮 +│ ├── pages/usr/UserList/UserFilterBar.tsx # 【新增】筛选栏:范围/查询字段/匹配方式/查询值/更多/搜索/清空 +│ ├── pages/usr/UserList/UserTable.tsx # 【新增】AntD Table:列定义/序号/作废只读/行双击/单选 rowSelection/受控分页 +│ ├── pages/usr/UserList/columns.tsx # 【新增】列定义 + 表头文案 + 作废渲染(与 UserVO 字段映射,spec § 6) +│ ├── pages/usr/UserList/constants.ts # 【新增】合同级常量:查询字段/匹配方式枚举、默认 query、pageSize 选项、文案 +│ ├── pages/usr/UserList/exportUtils.ts # 【新增】前端导出工具(CSV+BOM Blob 下载,D-PLAN-1) +│ └── pages/usr/UserList/UserList.module.css # 【新增】页面 scoped 样式:语义色用 var(--color-*);工具栏深色底局部装饰(D10) +├── src/router/index.tsx # 【改】把 /usr/users 占位 UserListPlaceholder 替换为真实 UserListPage(属 FE 共享骨架,留痕) +└── tests/ + ├── unit/usrApi.userlist.test.ts # 【新增】listUsers 透传 query 到 GET /usr/users 并返回 PageResult(沿用 usrApi.test.ts 桩模式) + ├── unit/useUserList.test.tsx # 【新增】hook 取数/翻页/刷新/搜索/清空/空/错误/导出态(renderHook) + ├── unit/UserFilterBar.test.tsx # 【新增】默认值/枚举 options/回车搜索/清空回调(BR2/BR4/BR7/BR10) + ├── unit/UserTable.test.tsx # 【新增】列渲染/序号/作废只读/行双击导航/受控分页/空 Empty(BR1/BR6/BR11/BR12/BR14) + ├── unit/UserToolbar.test.tsx # 【新增】刷新/新增导航/导出中禁用/齿轮占位(BR8/BR9/BR13/D7) + ├── unit/UserListPage.test.tsx # 【新增】页面集成:挂载默认查询/搜索/翻页/错误重试/空态(状态机 + BR3/BR7/BR15) + └── e2e/userlist.spec.ts # 【新增】E2E:进入用户列表→渲染行/空态/搜索/翻页/行双击进单据/错误重试 +``` + +- **跨阶段/跨模块**:本 FE 落点全在 `frontend/**`,不触 `backend/` / `sql/` / `scripts/`。改 `src/router/index.tsx`(把 `/usr/users` 占位换为真实页)与 `src/api/usrApi.ts` / `src/api/types.ts`(FE-01/FE-02 搭建的全前端共享骨架)属共享资产扩展,在《模块完成报告》留痕:「FE-03 将 `/usr/users` 占位 `UserListPlaceholder` 替换为真实 `UserListPage`;在 `usrApi.ts`/`types.ts` 增列表查询契约(`listUsers`/`UserVO`/`PageResult`/`UserListQuery`),不改 FE-01 既有 `login`/`fetchCompanies`/类型;属共享骨架扩展,FE-04 可复用 `UserVO`」。 +- **状态管理**(docs/04 § 2.2 / spec D6):列表查询态(list/total/loading/error/query/exporting)为页面就近态,用 `useUserList` hook + `useState` 本地管理,不进 Redux;登录态复用 FE-01 `authSlice`(本页不读写)。 +- **请求封装 / 错误处理**(docs/04 § 2.3 / § 2.4):取数走 `usrApi.listUsers` → `request.ts` Axios 实例(响应拦截器已拆 `Result`:`code=0` 返回 `data`,非 0 抛 `ApiError`,被动 401 统一登出 FE-02 D11);页面/hook 捕获 `ApiError` 按 code 分流 `message` 文案 + 错误占位。 +- **Design Tokens**(docs/04 § 2.1 / spec § 7):筛选栏/表格/分页/空态/错误/成功/警告等语义色只用 `var(--color-*)`;工具栏深色底为页面局部装饰,scoped 在 `UserList.module.css`,不新增全局 token、不挪用语义 token(spec § 7 / D10,与 FE-02 § 8 D9 一致)。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + FE-01/FE-02 骨架) + +- React 18 / Ant Design 5(`Table` / `Select` / `Input` / `Button` / `Tag`或只读 `Checkbox` / `Empty` / `Result`或错误占位 / `Spin` / `message` / `Space`/`Form`(可选 inline))/ React Router v6(`useNavigate`)/ Axios / TypeScript;`@ant-design/icons`(`ReloadOutlined` / `PlusCircleOutlined` / `FileExcelOutlined` / `SettingOutlined` / `SearchOutlined`)。 +- **不新增 npm 依赖**:导出用零依赖 CSV(UTF-8 BOM + Blob + ``),不引入 `xlsx`/SheetJS(D-PLAN-1)。 +- 测试:单测 Vitest(jsdom)+ `@testing-library/react|jest-dom|user-event`(沿用 `tests/setup.ts`、`renderShell.tsx`);E2E Playwright(沿用 `playwright.config.ts`,`page.route` 桩 `**/api/usr/users**`)。 +- 命令(docs/04 § 零):build `npm run build`;lint `npm run lint`;unit `npm run test:unit`;e2e `npm run test:e2e`。子会话验证用 `cd frontend && npm run test:unit -- <文件名片段>`。 +- 提交格式:`(): REQ-USR-003`。**scope 统一用 `usr`**(业务模块名,CLAUDE.md § Git);subject 业务类(feat/fix/test)带 `REQ-USR-003` 后缀。每个任务在其 commit 行注明 REQ tag。 + +## 合同级常量(跨 task 必须一致) + +- **API 路径 / 方法**:`GET /api/usr/users`(`usrApi` 内传 `/usr/users`,`request.ts` baseURL=`/api` 已含前缀;query 参数走 axios `params`)。 +- **查询字段枚举**(`QUERY_FIELD_OPTIONS`,对齐 REQ 输入表 1「显示来源」/ docs/05):`用户名` / `员工名` / `用户号` / `部门` / `用户类型` / `作废` / `登录日期` / `制单人`(**逐字一致**,原样作为 `queryField` 提交值,前端不映射,匹配语义由后端裁决)。默认 `用户名`(BR2)。 +- **匹配方式枚举**(`MATCH_TYPE_OPTIONS`):`包含` / `不包含` / `等于`(原样作为 `matchType` 提交值)。默认 `包含`(BR2)。 +- **用户范围下拉**(`SCOPE_OPTIONS`,占位 demo,spec D2):仅 `全部用户` 一项;选中「全部用户」时**不向后端传任何额外参数**(不杜撰 docs/05 未定义的「范围」参数)。 +- **默认查询 `DEFAULT_QUERY: UserListQuery`**:`{ queryField: '用户名', matchType: '包含', queryValue: '', pageNum: 1, pageSize: 10 }`(BR2/BR3,pageSize 默认 10 对齐 docs/05,spec D4)。`queryValue` 为空字符串时提交时省略或传空(后端按全量处理,BR3)。 +- **pageSize 选项**(`PAGE_SIZE_OPTIONS`):`[10, 20, 50, 100]`(上限 100 对齐 docs/05 / REQ 边界,spec D4;不采用原型 demo 的 10000)。 +- **错误码常量**(对齐 docs/05 § REQ-USR-003):`ERR_PAGE_INVALID = 42201`(分页参数非法)、`ERR_QUERY_INVALID = 40001`(查询参数校验失败);网络/超时/5xx 经 `request.ts` 映射为 `NETWORK_ERROR_CODE = -1`(复用 FE-01 常量);`401` 由 `request.ts` 统一处理(本页不分流)。 +- **表格列定义(中文表头,逐字一致,对齐原型 thead + REQ 输出表 1 + spec § 6)**,列顺序固定: + | 列序 | 表头文案 | 数据字段(UserVO) | 渲染 | + |---|---|---|---| + | 0 | (单选列,无表头) | —(`rowSelection` 渲染) | radio 单选标记(spec D8) | + | 1 | `序号` | —(前端生成) | `(pageNum-1)*pageSize + index + 1`(BR1) | + | 2 | `用户名` | `sUserName` | 文本(双击进单据主标识) | + | 3 | `员工名` | `employeeName`(映射自 docs/05 `员工名`,D-PLAN-2) | 文本,可空 | + | 4 | `用户号` | `sUserNo` | 文本,可空 | + | 5 | `部门` | `departmentName`(映射自 docs/05 `部门`,D-PLAN-2) | 文本,可空 | + | 6 | `用户类型` | `sUserType` | 文本(已中文) | + | 7 | `语言` | `sLanguage` | 文本 | + | 8 | `作废` | `iIsVoid` | 只读:`0`→`否`、`1`→`是`(BR6,`Tag` 或禁用 `Checkbox`,点击无效) | + | 9 | `登录日期` | `tLastLoginDate` | 日期时间文本,可空 | + | 10 | `制单人` | `sCreator` | 文本 | + | 11 | `制单日期` | `tCreateDate` | 日期时间文本 | +- **静态文案(逐字一致,复刻原型 / spec)**: + | 用途 | 文案 | + |---|---| + | 工具栏刷新 | `刷新` | + | 工具栏新增 | `新增` | + | 工具栏导出 | `导出Excel` | + | 搜索按钮 | `搜索` | + | 清空按钮 | `清空`(含原型 `⊗` 前缀,文案以「清空」为可定位文本) | + | 空态文案 | `暂无匹配的用户`(BR14) | + | 错误占位文案 | `加载失败,点击重试`(spec § 4) | + | 分页统计文案 | `共 {total} 条记录`(`showTotal`,total 来自 `PageResult.total`,BR1/§ 3 success/empty) | + | 导出成功提示 | `导出成功`(`message.success`,BR9) | + | 导出失败提示 | `导出失败`(`message.error`,BR9) | + | 42201 提示 | `分页参数有误,已重置为第 1 页`(`message.warning`,spec § 4) | + | 40001 提示 | `查询条件有误,请检查后重试`(`message.error`,spec § 4) | + | 网络/5xx 提示 | `加载失败,请稍后重试`(`message.error`,spec § 4) | +- **路由 path(导航目标,FE-02 已注册)**:新增 → `/usr/users/new`(BR13);行双击修改 → `/usr/users/:id`(`navigate('/usr/users/' + row.id)`,BR12)。 +- **localStorage token 键**:`TOKEN_STORAGE_KEY = 'xly_erp_token'`(FE-01 `request.ts` 已导出,本页不直接读写)。 + +## 关键签名(首次出现处给出,跨 task 一致) + +- **类型契约**(`api/types.ts`,新增;沿用 FE-01 既有 `AuthUser`/`LoginPayload` 等不动): + - `UserVO`:`{ id: number; sUserName: string; employeeName: string | null; sUserNo: string | null; departmentName: string | null; sUserType: string; sLanguage: string; iIsVoid: number; tLastLoginDate: string | null; sCreator: string; tCreateDate: string }`。 + > docs/05 `UserVO` 以中文键名 `员工名`/`部门` 给出(后端关联职员表派生);前端在 api 层做一次别名映射 `员工名→employeeName`、`部门→departmentName`,组件/列定义统一用 ASCII 键(spec D9 / D-PLAN-2)。映射在 `usrApi.listUsers` 内对 `records` 逐项归一。 + - `PageResult`:`{ records: T[]; total: number; pageNum: number; pageSize: number }`(docs/04 § 1.4 / § 3.2)。 + - `UserListQuery`:`{ queryField?: string; matchType?: string; queryValue?: string; pageNum: number; pageSize: number }`(提交给 `GET /api/usr/users` 的 query;`queryValue` 空时省略,BR3)。 +- **API 封装**(`api/usrApi.ts`,新增方法): + - `listUsers(query: UserListQuery): Promise>`——`request.get('/usr/users', { params })`,对返回 `records` 做中文键→ASCII 别名归一后返回(D-PLAN-2)。响应拦截器已拆 `Result.data`,故此方法返回 `PageResult` 本体(沿用 FE-01 `as unknown as Promise<...>` 桥接模式)。 +- **页面 hook**(`pages/usr/UserList/useUserList.ts`): + - `useUserList()` → `{ list: UserVO[]; total: number; loading: boolean; error: ApiError | null; query: UserListQuery; exporting: boolean; search(): void; refresh(): void; clear(): void; setQueryField(v: string): void; setMatchType(v: string): void; setQueryValue(v: string): void; changePage(pageNum: number, pageSize: number): void; exportExcel(): Promise; }`。 + - 挂载时以 `DEFAULT_QUERY` 取数(initialLoading,BR2);`search()` 回第 1 页以当前条件取数(BR7);`refresh()` 保持当前 `query`(含 `pageNum`)重取(BR8);`clear()` 重置为 `DEFAULT_QUERY` 后取数(BR10);`changePage()` 改页/页大小重取(改 pageSize 回第 1 页,BR11);取数成功同步 `total`/`pageNum`/`pageSize` 以响应回显(BR15);非 0 code 按错误码分流 `message`(42201 warning+重置重查、40001 error 保留条件不自动重查、网络兜底 error),并置 `error` 供错误占位(spec § 4)。 + - `exportExcel()`:置 `exporting=true` → 调 `listUsers` 拉当前条件命中结果(按 `total` 在 `pageSize≤100` 约束内一次或分批取,D-PLAN-1)→ 生成 CSV Blob 下载 → `message.success('导出成功')` / 失败 `message.error('导出失败')` → `exporting=false`(BR9)。 +- **导出工具**(`pages/usr/UserList/exportUtils.ts`): + - `buildUserCsv(rows: UserVO[]): string`——按列定义顺序与中文表头生成 CSV 文本(含表头行,作废 0/1→否/是,空值→空串)。 + - `downloadCsv(filename: string, csv: string): void`——前置 UTF-8 BOM(``)→ `new Blob` → `URL.createObjectURL` → 触发 ``(jsdom 下可桩 `URL.createObjectURL`/`a.click` 验证调用)。 +- **组件 props**(`pages/usr/UserList/`): + - `UserToolbar`(`{ onRefresh(): void; onAdd(): void; onExport(): void; exporting: boolean; loading?: boolean }`):刷新/新增/导出/齿轮;导出中 `导出Excel` 置 `loading` 且禁用(BR9);齿轮无动作(spec D7)。 + - `UserFilterBar`(`{ query: UserListQuery; onChangeQueryField(v): void; onChangeMatchType(v): void; onChangeQueryValue(v): void; onSearch(): void; onClear(): void }`):渲染 `SCOPE_OPTIONS`/`QUERY_FIELD_OPTIONS`/`MATCH_TYPE_OPTIONS` 下拉 + 查询值 `Input`(`onPressEnter`→`onSearch`,BR7)+ 搜索/清空按钮;更多「▾」占位(spec D3)。 + - `UserTable`(`{ rows: UserVO[]; loading: boolean; total: number; pageNum: number; pageSize: number; onChangePage(pageNum, pageSize): void; onRowDoubleClick(row: UserVO): void; selectedRowKey?: number | null; onSelectRow?(key: number | null): void }`):AntD `Table` `rowKey="id"`,`columns` 来自 `columns.tsx`,受控 `pagination={{ current:pageNum, pageSize, total, showSizeChanger:true, pageSizeOptions:PAGE_SIZE_OPTIONS, showTotal:(t)=>`共 ${t} 条记录` }}`,`rowSelection={{ type:'radio', selectedRowKeys, onChange }}`(spec D8),`onRow=(row)=>({ onDoubleClick: ()=>onRowDoubleClick(row) })`(BR12),空 `rows` → `locale.emptyText` 为 AntD `Empty`「暂无匹配的用户」(BR14)。 + - `UserListPage`(default export,无 props):`useUserList()` + `useNavigate()`;装配 `UserToolbar`/`UserFilterBar`/`UserTable`;`onAdd`→`navigate('/usr/users/new')`(BR13);`onRowDoubleClick`→`navigate('/usr/users/'+row.id)`(BR12);错误态渲染错误占位 +「点击重试」→ `refresh()`(spec § 4)。 +- **列定义**(`pages/usr/UserList/columns.tsx`): + - `buildUserColumns(opts: { pageNum: number; pageSize: number }): ColumnsType`——返回上表 1~11 列(序号列用 `render:(_,__,index)=>(pageNum-1)*pageSize+index+1`,BR1;作废列 `render:(v)=> v===1 ? '是' : '否'`,只读 BR6)。 + +## 测试栈说明 + +- **jsdom 组件 / hook / api 单测**(Vitest + RTL):组件测用 `renderShell`(已存在,Provider + 真实 store + `MemoryRouter` + AntD `App`/`ConfigProvider`);导航断言用 `LocationProbe`(复用 `HomePage.test.tsx` 的 `useLocation` 探针模式)+ 路由哨兵(`/usr/users/new`、`/usr/users/:id` 哨兵元素)。`useUserList` 用 `renderHook`,对 `usrApi.listUsers` 用 `vi.mock('../../src/api/usrApi')` 桩(沿用 `usrApi.test.ts` 的 `vi.mock` 模式)。`listUsers` api 测桩底层 `request` 实例(同 `usrApi.test.ts`)。 +- **Playwright E2E**:`page.route` 桩 `**/api/usr/users**`(先返回非空 `records` 验渲染/翻页/行双击;再用单独用例桩空 `records` 验空态、桩 5xx 验错误重试);先经登录桩落地外壳再进 `/usr/users`(沿用 `shell.spec.ts` 的 `stubBackend`/`login` 模式,可抽到本 spec 内)。不依赖真实后端起服。 +- **可测性**:优先语义查询(role/text/label/columnheader);仅当无法稳定定位时加最小 `data-testid`(如 `user-table` / `filter-query-field` / `filter-match-type` / `filter-query-value` / `btn-search` / `btn-clear` / `btn-refresh` / `btn-export` / `userlist-error`)。 + +--- + +## 任务列表(每个 task = red → green → 子会话验证 → commit) + +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。 +> 提交 scope 统一 `usr`;REQ tag 统一 `REQ-USR-003`。 + +### T1 — 类型契约 + listUsers API 封装(GET /api/usr/users,中文键归一 D-PLAN-2)(jsdom api 测) +- **测试先行类型**:jsdom 组件测试(api 单测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/usrApi.userlist.test.ts`(沿用 `usrApi.test.ts` 的 `vi.mock('../../src/api/request')` 桩底层实例): + - `::listUsers gets /usr/users with query params`——调 `listUsers({ queryField:'用户名', matchType:'包含', queryValue:'李', pageNum:2, pageSize:20 })`,断言 `request.get` 以 `'/usr/users'` + `{ params: { ...含传入字段 } }` 被调用。 + - `::listUsers omits empty queryValue`——`queryValue:''` 时 params 不含 `queryValue`(或传空,按实现一处定,与 BR3 一致;测试断言后端不收到非空 queryValue)。 + - `::listUsers normalizes chinese keys 员工名/部门 to employeeName/departmentName`——桩 `request.get` resolve `{ records:[{ id:1, sUserName:'a', 员工名:'张三', 部门:'技术', sUserNo:'a', sUserType:'超级管理员', sLanguage:'中文', iIsVoid:0, tLastLoginDate:null, sCreator:'x', tCreateDate:'t' }], total:1, pageNum:2, pageSize:20 }`,断言返回 `records[0].employeeName==='张三'`、`departmentName==='技术'`(D-PLAN-2)。 +- [ ] **2. 实现最小代码**:`frontend/src/api/types.ts`(新增 `UserVO`/`PageResult`/`UserListQuery`,签名见关键签名)+ `frontend/src/api/usrApi.ts`(新增 `listUsers`,`request.get('/usr/users',{params})` + records 中文键归一)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- usrApi`(含 FE-01 `usrApi` 既有用例不回归) +- [ ] **4. commit**:`feat(usr): 用户列表查询 API 与类型契约 listUsers REQ-USR-003` + +### T2 — 页面常量 + 导出工具(枚举/默认/pageSize/文案 + CSV 下载,D-PLAN-1)(jsdom 单测) +- **测试先行类型**:jsdom 组件测试(纯逻辑断言) +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserToolbar.test.tsx` 暂不依赖此,单独建 `frontend/tests/unit/exportUtils.test.ts`: + - `::buildUserCsv has header row and maps 作废 0/1`——传 2 行(`iIsVoid:0` 与 `1`),断言 CSV 首行含中文表头(`用户名`/`员工名`/`作废` 等逐字),数据行作废列分别为 `否`/`是`,空值字段为空串。 + - `::downloadCsv triggers blob download with UTF-8 BOM`——桩 `URL.createObjectURL`(vi.fn)与 `HTMLAnchorElement.prototype.click`,调 `downloadCsv('users.csv', 'x')`,断言 `createObjectURL` 收到的 Blob 内容以 `` 开头且 `click` 被调一次。 + - (常量断言并入此文件或 `useUserList.test.tsx`,TDD 期定一处):`DEFAULT_QUERY.queryField==='用户名'`、`matchType==='包含'`、`pageSize===10`、`PAGE_SIZE_OPTIONS` 末项为 `100`、`QUERY_FIELD_OPTIONS` 含 8 项首项 `用户名`、`MATCH_TYPE_OPTIONS` 为 `['包含','不包含','等于']`。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/constants.ts`(枚举/默认/pageSize/错误码/文案常量,见合同级常量)+ `frontend/src/pages/usr/UserList/exportUtils.ts`(`buildUserCsv`/`downloadCsv`,签名见关键签名)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- exportUtils` +- [ ] **4. commit**:`feat(usr): 用户列表页面常量与前端 CSV 导出工具 REQ-USR-003` + +### T3 — useUserList 列表查询 hook(状态机:initialLoading/loading/success/empty/error/exporting)(jsdom hook 测) +- **测试先行类型**:jsdom 组件测试(`renderHook`,`vi.mock('../../src/api/usrApi')`) +- [ ] **1. 写失败测试**:`frontend/tests/unit/useUserList.test.tsx`: + - `::mounts with default query and loads first page (initialLoading→success)`——`listUsers` resolve 非空 `records`,断言挂载即以 `DEFAULT_QUERY` 调用 `listUsers`、`loading` 由 true→false、`list`/`total`/`query.pageNum` 同步(BR2/§ 3 initialLoading/success)。 + - `::empty records sets empty state without error`——resolve `records:[]`,`total:0`,断言 `list` 为空、`error===null`、无 `message.error`(BR14/§ 3 empty)。 + - `::search resets to page 1 and refetches with current filters`——改 `queryField`/`queryValue` 后 `search()`,断言以 `pageNum:1` + 当前条件再次调用(BR7)。 + - `::refresh keeps current query and page`——先翻到第 2 页,`refresh()` 断言以**当前** `pageNum`(2)+ 当前条件调用,不回第 1 页、不重置条件(BR8)。 + - `::clear resets to DEFAULT_QUERY then refetches`——改条件后 `clear()`,断言 `query` 回 `DEFAULT_QUERY`(用户名/包含/空/page1)并以之取数(BR10)。 + - `::changePage refetch; changing pageSize resets to page 1`——`changePage(3, 10)` 以 pageNum=3 取;`changePage(1, 50)`(改 pageSize)回第 1 页取(BR11)。 + - `::ApiError 40001 keeps filters and shows error, sets error state`——`listUsers` reject `new ApiError(40001,...)`,断言 `error` 置位、`query` 未变、不自动重查(spec § 4 / 40001)。 + - `::ApiError 42201 warns and refetches at page 1`——reject `ApiError(42201)`,断言重置 `pageNum=1`(与 pageSize 收敛)后重查(spec § 4 / 42201 兜底)。 + - `::network error (code -1) sets error state`——reject `ApiError(-1)`,断言 `error` 置位(错误占位由页面消费,spec § 4)。 + - `::exportExcel toggles exporting and downloads`——桩 `listUsers` 返回结果集 + 桩 `downloadCsv`,调 `exportExcel()`,断言 `exporting` true→false、`downloadCsv` 被调(BR9/§ 3 exporting);reject 时 `message.error('导出失败')`。 + > 取数响应回显(BR15):success 用例额外断言 `query.pageNum`/`pageSize` 跟随响应 `PageResult` 同步(即使请求 pageNum 越界,前端信任响应回显)。`message` 断言可桩 `antd` `App.useApp().message` 或 `vi.mock('antd', ...)` 暴露 `message`,TDD 期定一处。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/useUserList.ts`(签名见关键签名;内部 `useState` 持 `query`/`list`/`total`/`loading`/`error`/`exporting`,`useEffect` 挂载取数;错误码分流按合同级常量;导出复用 T2 `exportUtils`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- useUserList` +- [ ] **4. commit**:`feat(usr): 用户列表查询 hook useUserList 状态机 REQ-USR-003` + +### T4 — UserFilterBar 筛选栏(默认值/枚举 options/回车搜索/清空,BR2/BR3/BR4/BR7/BR10/D2/D3)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserFilterBar.test.tsx`(`renderShell` 提供 AntD App 上下文): + - `::renders defaults 用户名 / 包含 and empty value`——传 `query=DEFAULT_QUERY`,断言查询字段下拉显「用户名」、匹配方式显「包含」、查询值框为空(BR2)。 + - `::query field options match enum`——展开查询字段下拉,断言 8 个选项逐字一致(用户名…制单人,BR4);展开匹配方式断言 `包含/不包含/等于`(BR4)。 + - `::scope select shows 全部用户 only`——范围下拉仅「全部用户」(占位 demo,D2)。 + - `::Enter in value triggers onSearch`——查询值框聚焦回车 → `onSearch` 被调一次(BR7)。 + - `::click 搜索 calls onSearch / click 清空 calls onClear`——点「搜索」→ `onSearch`;点「清空」→ `onClear`(BR7/BR10)。 + - `::changing selects/value calls respective onChange`——改下拉/输入触发 `onChangeQueryField`/`onChangeMatchType`/`onChangeQueryValue`。 + - `::more toggle ▾ is placeholder (no extra callback)`——「▾」点击不触发查询回调(占位,D3)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/UserFilterBar.tsx`(签名见关键签名;options 来自 T2 常量;`Input` `onPressEnter`→`onSearch`)+ `UserList.module.css` 筛选栏样式(语义色用 `var(--color-*)`:白底 `--color-form-bg-edit`、下边线 `--color-border`、搜索按钮主色 `--color-primary`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserFilterBar` +- [ ] **4. commit**:`feat(usr): 用户列表筛选栏 UserFilterBar REQ-USR-003` + +### T5 — UserTable 表格(列/序号/作废只读/行双击/单选/受控分页/空态,BR1/BR6/BR11/BR12/BR14/D8)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserTable.test.tsx`(`renderShell`): + - `::renders 11 column headers in order`——断言列头文案逐字、顺序与合同级常量列表一致(序号…制单日期)。 + - `::serial number is page-aware`——传 `pageNum=2, pageSize=10` + 2 行,断言首行序号为 `11`(`(2-1)*10+0+1`,BR1)。 + - `::作废 column renders 否/是 read-only`——`iIsVoid:0`→「否」、`1`→「是」;点击作废单元不触发任何 onChange/写动作(BR6)。 + - `::double click row navigates via onRowDoubleClick`——双击某数据行 → `onRowDoubleClick` 收到该行(含 `id`,BR12)。 + - `::controlled pagination reflects current/pageSize/total + showTotal`——传 `total=37,pageNum=1,pageSize=10`,断言分页显示「共 37 条记录」、当前页 1;点下一页/改每页条数 → `onChangePage` 收到新 `(pageNum,pageSize)`(改 pageSize 期望回第 1 页由页面/hook 处理,本组件原样上报,BR11)。 + - `::empty rows shows Empty 暂无匹配的用户`——`rows=[]` → 渲染「暂无匹配的用户」(BR14)。 + - `::radio rowSelection single-select does not affect query`——选中某行 → `onSelectRow` 收到该行 key,不触发取数/查询(spec D8;本组件不持查询态,断言仅回调)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/columns.tsx`(`buildUserColumns`,序号/作废 render)+ `frontend/src/pages/usr/UserList/UserTable.tsx`(AntD `Table` 受控分页/rowSelection/onRow 双击/空态 locale)+ `UserList.module.css` 表格样式(表头 `--color-table-header-bg`/`--color-table-header-fg`、行文字 `--color-table-row-fg`、hover `--color-table-row-bg-hover`、选中 `--color-table-row-bg-selected`、网格线 `--color-border`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserTable` +- [ ] **4. commit**:`feat(usr): 用户列表表格 UserTable 与列定义 REQ-USR-003` + +### T6 — UserToolbar 工具栏(刷新/新增导航/导出中禁用/齿轮占位,BR8/BR9/BR13/D7/D10)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserToolbar.test.tsx`(`renderShell`): + - `::renders 刷新/新增/导出Excel/设置 buttons`——可见四个工具项(文案逐字 + 对应图标)。 + - `::click 刷新 calls onRefresh / click 新增 calls onAdd`——点刷新→`onRefresh`;点新增→`onAdd`(BR8/BR13)。 + - `::click 导出Excel calls onExport`——点导出→`onExport`(BR9)。 + - `::exporting disables 导出Excel and shows loading`——`exporting=true` 时「导出Excel」禁用且 loading,再点不再触发 `onExport`(BR9)。 + - `::gear setting is placeholder (no callback)`——点齿轮无任何业务回调(占位,D7)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/UserToolbar.tsx`(签名见关键签名;图标 `ReloadOutlined`/`PlusCircleOutlined`/`FileExcelOutlined`/`SettingOutlined`)+ `UserList.module.css` 工具栏深色底(scoped 局部装饰,非语义 token,D10)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserToolbar` +- [ ] **4. commit**:`feat(usr): 用户列表工具栏 UserToolbar REQ-USR-003` + +### T7 — UserListPage 页面集成 + 路由接线(状态机贯通 + 导航 + 错误重试,BR3/BR7/BR8/BR9/BR12/BR13/BR15)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserListPage.test.tsx`(`renderShell`,`vi.mock('../../src/api/usrApi')` 桩 `listUsers`;用 `LocationProbe` + `/usr/users/new`、`/usr/users/:id` 哨兵路由验导航): + - `::initial load renders rows from listUsers (default query)`——挂载即调 `listUsers(DEFAULT_QUERY)`、渲染返回行(BR2/initialLoading→success)。 + - `::search with value submits queryValue and shows results`——填查询值「李」点搜索 → `listUsers` 以 `queryValue:'李', pageNum:1` 调用、渲染结果(BR7/BR3)。 + - `::empty response shows 暂无匹配的用户`——桩空 `records` → 空态(BR14)。 + - `::error response shows error placeholder with 点击重试; retry calls refresh`——桩 reject `ApiError(-1)` → 可见「加载失败,点击重试」;点重试再次取数(spec § 4)。 + - `::新增 navigates to /usr/users/new`——点工具栏「新增」→ URL/哨兵到 `/usr/users/new`(BR13)。 + - `::double click row navigates to /usr/users/:id`——桩返回含 `id` 的行,双击 → URL/哨兵到 `/usr/users/{id}`(BR12)。 + - `::refresh keeps current page`——翻到第 2 页后点刷新 → `listUsers` 以 `pageNum:2` 调用(BR8)。 + - `::response pageNum echo syncs pagination`——请求越界 `pageNum`、桩响应回 `pageNum=最后一页`,断言分页当前页跟随响应(BR15)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserList/index.tsx`(`UserListPage`:`useUserList` + `useNavigate`,装配 `UserToolbar`/`UserFilterBar`/`UserTable`,错误态渲染「加载失败,点击重试」→`refresh`,新增/行双击导航);改 `frontend/src/router/index.tsx`——把 `/usr/users` 的 `UserListPlaceholder` 替换为 `UserListPage`(移除占位组件,import 真实页;`/usr/users/new`、`/usr/users/:id` 仍为 FE-04 占位,不动)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserListPage router`(确认 FE-02 `router.test.tsx` 不回归——若其断言 `/usr/users` 渲染占位 `data-testid`,需同步更新该断言为真实页可定位元素,并在 commit 说明) +- [ ] **4. commit**:`feat(usr): 用户列表页面集成与路由接线 UserListPage REQ-USR-003` + +### T8 — E2E 用户列表关键旅程(Playwright) +- **测试先行类型**:Playwright E2E +- [ ] **1. 写失败测试**:`frontend/tests/e2e/userlist.spec.ts`(沿用 `shell.spec.ts` 的登录桩;`page.route('**/api/usr/users**')` 按用例返回不同体): + - `::enter user list renders rows`——桩非空 `records`,从主页「常用操作 > 用户列表」进入 `/usr/users`,断言表格出现某用户名行 + 分页「共 N 条记录」。 + - `::empty result shows 暂无匹配的用户`——桩空 `records`,断言空态文案。 + - `::search by value triggers query`——填查询值点「搜索」,断言请求携带 `queryValue`(用 `page.waitForRequest('**/api/usr/users**')` 校验 URL 含 `queryValue`)并渲染结果。 + - `::pagination next page refetches`——桩 `total>pageSize`,点下一页,断言请求 `pageNum=2`。 + - `::double click row navigates to user detail`——双击行 → URL 到 `/usr/users/{id}`(FE-04 占位即可,本 FE 不验单据内容)。 + - `::error response shows retry`——桩 5xx,断言「加载失败,点击重试」可见;点重试(改桩为成功)后渲染行。 +- [ ] **2. 实现最小代码**:补任何为可测性需要的最小 `data-testid`(仅 Playwright 无法稳定定位时,如 `user-table`/`btn-search`/`btn-refresh`/`userlist-error`)。沿用 `playwright.config.ts`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- userlist` +- [ ] **4. commit**:`test(usr): 用户列表 E2E 关键旅程 REQ-USR-003` + +### T9 — 全量门禁回归 + 收尾(chore) +- **测试先行类型**:无新增测试(全量验证) +- [ ] **1. 写失败测试**:无。 +- [ ] **2. 实现最小代码**:修 lint / build(`tsc --noEmit`)/ 类型问题;确认语义色全部 `var(--color-*)`、无硬编码 hex/rgba(工具栏深色装饰 scoped 例外,D10);确认无 `TBD/TODO/【人工填写】`;确认 FE-01/FE-02 既有单测/E2E 不回归(尤其 `router.test.tsx`、`shell.spec.ts`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。 +- [ ] **4. commit**:`chore(usr): FE-03 门禁回归通过 REQ-USR-003` + +--- + +## 完成判据(Definition of Done) + +1. `/usr/users` 渲染真实 `UserListPage`(复刻原型 `#screen-userlist` 的工具栏/筛选栏/用户表格/分页),数据/筛选/分页真实对接 `GET /api/usr/users`(spec § 1/§ 2 / D1)。 +2. 状态机覆盖并测试固化:`initialLoading`(T3/T7)、`loading`(T3)、`success`(T3/T5/T7)、`empty`(T3/T5/T7)、`error`(T3/T7)、`exporting`(T3/T6)(spec § 3)。 +3. 业务规则 BR1~BR15 在 hook/组件/E2E 有断言:BR1(T5)、BR2(T3/T4)、BR3(T3/T7)、BR4(T4)、BR5(只读:全程仅 GET,T1/T3)、BR6(T5)、BR7(T3/T4/T7)、BR8(T3/T6/T7)、BR9(T3/T6)、BR10(T3/T4)、BR11(T3/T5)、BR12(T5/T7/T8)、BR13(T6/T7/T8)、BR14(T3/T5/T7/T8)、BR15(T3/T7)(spec § 5)。 +4. 列定义/字段映射对齐 REQ 输出表 1 + 原型 thead + `UserVO`(11 列顺序/表头/作废只读,spec § 6 / D9);`员工名`/`部门` 中文键在 api 层归一为 `employeeName`/`departmentName`(D-PLAN-2)。 +5. API 经 `usrApi.listUsers` → `request.ts`(拆 `Result`、`ApiError` 分流、被动 401 统一登出复用 FE-02),页面不散用 axios(docs/04 § 2.3);列表态在页面 hook 不进 Redux(docs/04 § 2.2 / spec D6)。 +6. 错误码分流文案对齐 spec § 4(42201 warning+重置重查、40001 error 保留条件、网络兜底 error、空态不报错)。 +7. 导出为前端零依赖 CSV(UTF-8 BOM Blob 下载),不杜撰后端导出端点、不新增 npm 依赖(spec D5 / D-PLAN-1)。 +8. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 沿用 FE-01 `ConfigProvider`;工具栏深色底 scoped 装饰不新增全局 token、不挪用语义 token(spec § 7 / D10)。 +9. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动;改 `router/index.tsx`(占位换真实页)、`usrApi.ts`/`types.ts`(增列表契约)属共享骨架,已在《模块完成报告》留痕。 +10. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。 + +## 自审记录 + +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 真实占位(正文 `TBD/TODO` 仅作为「禁止出现的字样」被引用)。 +- **spec coverage**:§ 1 关联 REQ/原型/路由/落地目录 → 架构 + T1/T7(路由接线);§ 2 组件树 → T4(FilterBar)/T5(Table+列)/T6(Toolbar)/T7(Page 装配);§ 3 状态机 6 态 → 见 DoD 第 2 条;§ 4 端点/错误码 → T1(api)/T3(错误码分流)/T7(错误占位重试);§ 5 BR1-BR15 → 见 DoD 第 3 条逐条映射;§ 6 列定义/字段映射 → T5/columns + T1(归一);§ 7 tokens → T4/T5/T6/T9;§ 8 decisions D1-D10 已落实于架构/合同级常量/任务(D1 真实对接=全任务;D2 范围占位=T4;D3 更多占位=T4;D4 pageSize=T2/T5;D5 导出=T2/T3/T6 → 细化为 D-PLAN-1;D6 本地 hook=T3;D7 齿轮占位=T6;D8 单选=T5;D9 中文键=T1 → 细化为 D-PLAN-2;D10 工具栏深色=T6)。本计划新增/细化 D-PLAN-1、D-PLAN-2 见下。 +- **本计划细化决策**: + - **D-PLAN-1(导出实现具体化 spec D5)**:spec D5 允许「用前端库(如 xlsx/SheetJS)」且为 medium 置信度并写明「实现期如后端补导出端点可切换」。经核 `frontend/package.json`,**项目未声明 `xlsx`/SheetJS 依赖**,且现有前端无新增 npm 依赖先例;为不引入新依赖、不杜撰后端端点,采用**零依赖 CSV 导出**(拉当前条件命中结果 → UTF-8 BOM CSV → Blob + `` 下载),文件名 `用户列表.csv`。导出语义(导出当前查询命中结果、过程禁用按钮、成功/失败提示)与 BR9 完全一致。置信度 medium。 + - **D-PLAN-2(中文键归一具体化 spec D9)**:spec D9 允许「在 api 层做一次到 `employeeName`/`departmentName` 的别名映射后供组件用」。本计划锁定该路径:`usrApi.listUsers` 内对 `PageResult.records` 逐项把 `员工名→employeeName`、`部门→departmentName` 归一,组件/列定义统一用 ASCII 键(避免 TS 中文键访问与 lint 摩擦);列渲染语义与 docs/05 契约不变。置信度 high。 +- **类型一致性**:`UserVO`(ASCII 键,跨 T1/T3/T5/T7 一致)、`PageResult`、`UserListQuery`、`DEFAULT_QUERY`、`QUERY_FIELD_OPTIONS`/`MATCH_TYPE_OPTIONS`/`SCOPE_OPTIONS`/`PAGE_SIZE_OPTIONS`、错误码常量(42201/40001/-1)、列表表头文案与顺序、`listUsers`/`useUserList`/`UserToolbar`/`UserFilterBar`/`UserTable`/`buildUserColumns`/`buildUserCsv`/`downloadCsv` 签名跨 T1-T9 一致;路由 path(`/usr/users` / `/usr/users/new` / `/usr/users/:id`)与 FE-02 合同级常量一致;`ApiError`/`TOKEN_STORAGE_KEY`/`NETWORK_ERROR_CODE` 复用 FE-01 `request.ts`。 +- **作用域自审**:所有 `impl_file` / `test_file` 均以 `frontend/` 开头;无 `backend/` / `sql/` / `scripts/` 落点。改 `src/router/index.tsx`、`src/api/usrApi.ts`、`src/api/types.ts` 属 FE 共享骨架扩展(非新阶段越界),已在架构段与 DoD 第 9 条登记留痕要求。 diff --git a/docs/superpowers/plans/2026-06-01-FE-04.md b/docs/superpowers/plans/2026-06-01-FE-04.md new file mode 100644 index 0000000..91d6c5c --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-FE-04.md @@ -0,0 +1,315 @@ +# FE-04 用户信息单据 — 任务级 TDD 计划(前端) + +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / api / 类型 / 样式 / 测试)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-FE-04.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-001.md`(增加用户)+ `REQ-USR-002.md`(修改用户);API 契约 `docs/05-API接口契约.md § REQ-USR-001`(`POST /api/usr/users`)+ `§ REQ-USR-002`(`PUT /api/usr/users/{id}`)+ `§ REQ-USR-003`(`GET /api/usr/users`,编辑预填复用);原型 `prototype/erp.html` → `
`(`.toolbar` / `.form-grid` / `.tabs-row` / `.perm-list` + 脚本 `setUserDetailMode('new')`,布局与交互权威);技术规范 `docs/04-技术规范.md § 零 / § 2.1 / § 2.2 / § 2.3 / § 2.4 / § 3.2`;Design Tokens 仓库根 `src/styles/tokens.css`。 +> 复用资产:FE-01(`api/request.ts` 已建 Axios 实例 + Result 拆包 + `ApiError` + `TOKEN_STORAGE_KEY` + `NETWORK_ERROR_CODE`;`api/types.ts`;`api/usrApi.ts` 含 `login`/`fetchCompanies`/`listUsers`)、FE-02(`AppLayout` 外壳 + `RequireAuth` 守卫 + 标签栈 `useTabStack`,已注册 `userdetail` 标签「用户信息单据」routePath `/usr/users/new`;路由表 `router/index.tsx` 已挂 `/usr/users/new` 与 `/usr/users/:id` 占位 `UserDetailPlaceholder`)、FE-03(`api/usrApi.ts` 的 `listUsers` + `api/types.ts` 的 `UserVO`/`PageResult`/`UserListQuery`;列表入口「新增」→ `/usr/users/new`、行双击 → `/usr/users/:id`)。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与类型契约 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整组件 / 类型文件内容。 +> **本 FE 同时承载新增(`POST`)与修改(`PUT`)两种写场景**,共用同一单据组件按 `mode` 分支;前端只做字段采集 + 轻量前置校验 + 写提交 + 反馈回流。用户名唯一性/格式终判、枚举与外键存在性、权限多对多写入与全量覆盖、密码 BCrypt 初始化、管理员权限、审计字段生成等真伪裁决全部在后端(spec § 5 末注)。 + +--- + +## Goal(目标) + +把 FE-02 在 `router/index.tsx` 留下的 `/usr/users/new` 与 `/usr/users/:id` 占位(`UserDetailPlaceholder`)替换为真实「用户信息单据」页面,复刻原型 `#screen-userdetail` 的布局与交互语义(工具栏 / 3 列表单网格 / 权限页签条 / 权限分类勾选列表),按路由判定 `create`/`edit` 模式,数据/下拉/权限/提交真实对接后端: + +- **页面容器 `UserDetailPage`**(路由 `/usr/users/new` 与 `/usr/users/:id` 共用,渲染于 FE-02 `AppLayout` 的 `` 内):按路由 `:id` 判定 `mode`(有 `:id` → `edit`,`/new` → `create`);纵向组合 `UserDetailToolbar` + `UserBasicForm` + `PermissionTabs` + `PermissionGroupList`;态由页面本地 hook `useUserDetail` 持有(spec § 8 D7,不进 Redux)。 +- **API 封装**:`api/usrApi.ts` 新增 `createUser` / `updateUser` / `getUserDetail` / `listEmployees` / `listPermissions`(页面只调封装方法,不散用 axios,docs/04 § 2.3);`api/types.ts` 新增 `UserCreateReq` / `UserUpdateReq` / `EmployeeOption` / `PermissionItem` 契约(复用 FE-03 `UserVO`)。 +- **状态机 ≥6 态**(spec § 3):`initialLoading`(挂载预取员工/权限 + edit 取详情)/ `editing` / `submitting` / `submitError` / `submitSuccess` / `loadError`,均有测试固化。 +- **业务规则 BR1~BR17** 在组件层 / hook 层 / E2E 有断言(spec § 5)。 +- **工具栏**:保存(校验通过后 create→`POST` / edit→`PUT`,BR12)/ 取消(有未保存改动二次确认后回 `/usr/users`,BR13 / D5)/ 新增(切 create 模式 → `navigate('/usr/users/new')`,BR14)/ 占位按钮(删除/重置密码/功能 → `message.info("功能开发中")`,作废/取消作废 → edit 态以 `iIsVoid` 启停用开关承载,D8)/ 设置齿轮(占位,D8)。 +- **表单网格**:创建时间(只读 BR1)/ 制单人(只读 BR2)/ 员工名(`Select`,options 来自 `GET /api/usr/employees`,选中联动带出用户号/用户名 BR5)/ 用户名(`Input`,create 必填 + 3-20 位字母数字下划线前置校验,edit 只读 BR3)/ 类型(`Select` 枚举,create 默认普通用户 BR6)/ 语言(`Select` 枚举 BR7)/ 用户号(`Input` 必填 BR4)/ 单据修改权限(`Checkbox` 默认否 BR8);密码不在 UI(BR9)。 +- **权限页签 + 权限分类列表**:`Tabs`(仅「权限组」有内容,其余 5 个查看权限页签占位 D9)+ `Checkbox.Group` 列表(项来自 `GET /api/usr/permissions`,勾选集合 → `permissionIds`,edit 按已授权回勾、全量覆盖语义 BR10/BR11,表头全选 `indeterminate` 半选)。 +- **提交反馈与回流**:`code=0` → `message.success`(create:「用户创建成功」/ edit:「保存成功」)→ `navigate('/usr/users')` 回流列表(BR16);非 0 按 § 4 错误码表反馈(40901 用户名冲突就近高亮、40401 用户不存在给返回列表入口、40001/40301/网络兜底,spec § 4);被动 401 由 `request.ts` 拦截器统一跳 `/login`(本页不重复处理)。 +- **edit 预填**:按路由 `:id` 复用 `GET /api/usr/users`(等于匹配 + pageSize=1)定位取 `records[0]` 作原值回填(D4);若 FE-03 行双击携带 `location.state.user` 则优先用之免二次请求(D4 备注)。 +- **语义色只用 `var(--color-*)`**;工具栏深色底为页面局部装饰,scoped 保留,不新增全局 token、不挪用语义 token(spec § 7 / D10)。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 2.1`,落点全在 `frontend/**`。**新增/改动**文件: + +``` +frontend/ +├── src/ +│ ├── api/types.ts # 【改】新增 UserCreateReq / UserUpdateReq / EmployeeOption / PermissionItem(复用 FE-03 UserVO;不破坏既有类型) +│ ├── api/usrApi.ts # 【改】新增 createUser / updateUser / getUserDetail / listEmployees / listPermissions(不改 FE-01/FE-03 既有方法) +│ ├── pages/usr/UserDetail/index.tsx # 【新增】UserDetailPage 页面容器:判 mode、装配 4 子组件、持 useUserDetail 态、提交反馈与导航回流 +│ ├── pages/usr/UserDetail/useUserDetail.ts # 【新增】单据 hook(mode/formValues/employees/permissions/checkedPermissionIds/loading/submitting/error + 动作) +│ ├── pages/usr/UserDetail/UserDetailToolbar.tsx # 【新增】深色工具条:保存/取消/新增 + 占位按钮(删除/作废/重置密码/取消作废/功能)+ 齿轮 +│ ├── pages/usr/UserDetail/UserBasicForm.tsx # 【新增】AntD Form 3 列表单网格:8 个字段 + 员工联动 + 前置校验 +│ ├── pages/usr/UserDetail/PermissionTabs.tsx # 【新增】AntD Tabs 权限页签条(权限组 active + 5 占位页签 D9) +│ ├── pages/usr/UserDetail/PermissionGroupList.tsx # 【新增】权限分类勾选列表:表头全选(indeterminate) + 逐项 Checkbox(D3) +│ ├── pages/usr/UserDetail/constants.ts # 【新增】合同级常量:类型/语言枚举、create 默认值、错误码、文案、用户名正则、占位文案 +│ └── pages/usr/UserDetail/UserDetail.module.css # 【新增】页面 scoped 样式:语义色用 var(--color-*);工具栏深色底局部装饰(D10) +├── src/router/index.tsx # 【改】把 /usr/users/new 与 /usr/users/:id 占位 UserDetailPlaceholder 替换为真实 UserDetailPage(属 FE 共享骨架,留痕) +└── tests/ + ├── unit/usrApi.userdetail.test.ts # 【新增】createUser/updateUser/getUserDetail/listEmployees/listPermissions 透传与归一(沿用 usrApi.userlist.test.ts 桩模式) + ├── unit/useUserDetail.test.tsx # 【新增】hook 状态机:create/edit 初始化、员工联动、提交成功/失败、loadError、权限回勾(renderHook) + ├── unit/UserBasicForm.test.tsx # 【新增】字段渲染/默认值/只读规则/枚举/必填+格式校验/员工联动(BR1-BR9) + ├── unit/PermissionGroupList.test.tsx # 【新增】列表渲染/勾选集合/全选 indeterminate/edit 回勾(BR10/BR11/D3) + ├── unit/PermissionTabs.test.tsx # 【新增】权限组 active + 5 占位页签 disabled/空态(D9) + ├── unit/UserDetailToolbar.test.tsx # 【新增】保存/取消/新增回调 + 提交中禁用 + 占位按钮(BR12/BR13/BR14/BR15/D8) + ├── unit/UserDetailPage.test.tsx # 【新增】页面集成:create/edit 贯通、提交回流、错误就近、取数失败(状态机 + BR3/BR12/BR16/BR17) + └── e2e/userdetail.spec.ts # 【新增】E2E:新增提交回流 / 编辑预填改保存 / 用户名冲突就近 / 取数失败重试 +``` + +- **跨阶段/跨模块**:本 FE 落点全在 `frontend/**`,不触 `backend/` / `sql/` / `scripts/`。改 `src/router/index.tsx`(把 `/usr/users/new` 与 `/usr/users/:id` 占位换为真实页、移除 `UserDetailPlaceholder` 占位组件)与 `src/api/usrApi.ts` / `src/api/types.ts`(FE-01/FE-03 搭建的全前端共享骨架)属共享资产扩展,在《模块完成报告》留痕:「FE-04 将 `/usr/users/new`、`/usr/users/:id` 占位 `UserDetailPlaceholder` 替换为真实 `UserDetailPage`;在 `usrApi.ts`/`types.ts` 增写端点契约(`createUser`/`updateUser`/`getUserDetail`/`listEmployees`/`listPermissions` + `UserCreateReq`/`UserUpdateReq`/`EmployeeOption`/`PermissionItem`),复用 FE-03 `UserVO`/`PageResult`,不改 FE-01/FE-03 既有 `login`/`fetchCompanies`/`listUsers`/既有类型;属共享骨架扩展」。 +- **状态管理**(docs/04 § 2.2 / spec D7):单据态(mode/formValues/employees/permissions/checkedPermissionIds/loading/submitting/error)为页面就近态,用 `useUserDetail` hook + `useState` 本地管理,不进 Redux;登录态复用 FE-01 `authSlice`(本页只读当前用户名用于 create 态制单人占位,可选)。 +- **请求封装 / 错误处理**(docs/04 § 2.3 / § 2.4):写/读均走 `usrApi.*` → `request.ts` Axios 实例(响应拦截器已拆 `Result`:`code=0` 返回 `data`,非 0 抛 `ApiError`,被动 401 统一登出 FE-02 既有机制);hook/页面捕获 `ApiError` 按 code 分流:表单可定位字段错误就近在 `Form.Item` 展示(如 40901 用户名冲突,docs/04 § 2.4「表单提交错误就近在表单展示」),其余 `message.error`。 +- **Design Tokens**(docs/04 § 2.1 / spec § 7):表单网格/只读字段/下拉/权限列表表头/权限行/校验错误/成功/警告等语义色只用 `var(--color-*)`(清单见 spec § 7);工具栏深色底为页面局部装饰,scoped 在 `UserDetail.module.css`,不新增全局 token、不挪用语义 token(spec § 7 / D10,与 FE-02/FE-03 一致)。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + FE-01/FE-02/FE-03 骨架) + +- React 18 / Ant Design 5(`Form` / `Input` / `Select` / `Checkbox`(含 `Checkbox.Group`)/ `Tabs` / `Button` / `Spin` / `Result`或错误占位 / `Modal`(`Modal.confirm` 取消二次确认) / `message` / `Space`)/ React Router v6(`useNavigate` / `useParams` / `useLocation`)/ Axios / TypeScript;`@ant-design/icons`(`SaveOutlined` / `CloseCircleOutlined` / `PlusCircleOutlined` / `SettingOutlined`)。 +- **不新增 npm 依赖**:复用 FE-01/FE-03 既装依赖(见 `frontend/package.json` `dependencies`);前置校验用 AntD `Form` `rules` + 正则常量,无需额外校验库。 +- 测试:单测 Vitest(jsdom)+ `@testing-library/react|jest-dom|user-event`(沿用 `tests/setup.ts`、`renderShell.tsx`);E2E Playwright(沿用 `playwright.config.ts`,`page.route` 桩 `**/api/usr/users**`、`**/api/usr/employees**`、`**/api/usr/permissions**`)。 +- 命令(docs/04 § 零):build `npm run build`(`tsc --noEmit && vite build`);lint `npm run lint`;unit `npm run test:unit`(`vitest run`);e2e `npm run test:e2e`(`playwright test`)。子会话验证用 `cd frontend && npm run test:unit -- <文件名片段>`。 +- 提交格式:`(): REQ-XXX-NNN`。**scope 统一用 `usr`**(业务模块名,CLAUDE.md § Git);subject 业务类(feat/fix/test)带 `REQ-USR-001` 后缀(本 FE 主承载增加用户;修改用户 REQ-USR-002 在涉 edit 的 task 追加标注,见各 task)。每个任务在其 commit 行注明 REQ tag。 + +## 合同级常量(跨 task 必须一致) + +- **API 路径 / 方法**(`usrApi` 内传相对路径,`request.ts` baseURL=`/api` 已含前缀): + - `POST /usr/users`(create,body=`UserCreateReq`,返回 `{ id: number }`)。 + - `PUT /usr/users/{id}`(edit,路径 `id`,body=`UserUpdateReq`,返回 `{ id: number }`)。 + - `GET /usr/users`(edit 预填复用,query `{ queryField, matchType:'等于', queryValue, pageNum:1, pageSize:1 }`,取 `records[0]`,复用 FE-03 `listUsers`,D4)。 + - `GET /usr/employees`(员工名下拉数据源,无参全量,D1)。 + - `GET /usr/permissions`(权限分类列表数据源,无参全量,D2)。 +- **类型枚举(逐字一致,原样作为提交值,前端不映射,由后端裁决)**: + - **用户类型** `USER_TYPE_OPTIONS = ['普通用户', '超级管理员']`,create 默认 `'普通用户'`(BR6)。 + - **语言** `LANGUAGE_OPTIONS = ['中文', '英文', '繁体']`(BR7,无默认强制选,create 必选)。 +- **create 默认表单值 `CREATE_DEFAULTS`**:`{ sUserName: '', sUserNo: '', iEmployeeId: null, sUserType: '普通用户', sLanguage: undefined, iCanModifyBill: 0 }`(BR1/BR2/BR6/BR8;`sLanguage` 未选触发必填校验 BR7)。 +- **用户名前置校验正则 `USERNAME_PATTERN = /^[A-Za-z0-9_]{3,20}$/`**(3-20 位字母数字下划线,BR3,对齐 docs/05 § REQ-USR-001)。 +- **错误码常量(对齐 docs/05 § REQ-USR-001 / § REQ-USR-002 / spec § 4)**:`ERR_VALIDATION = 40001`(参数校验失败)、`ERR_USERNAME_EXISTS = 40901`(用户名已存在,仅 create)、`ERR_USER_NOT_FOUND = 40401`(用户不存在,仅 edit)、`ERR_NO_PERMISSION = 40301`(无权限);网络/超时/5xx 经 `request.ts` 映射为 `NETWORK_ERROR_CODE = -1`(复用 FE-01 常量);`401` 由 `request.ts` 统一处理(本页不分流)。 +- **mode 常量 `MODE_CREATE = 'create'` / `MODE_EDIT = 'edit'`**(由路由 `:id` 判定)。 +- **静态文案(逐字一致,复刻原型 / spec)**: + | 用途 | 文案 | + |---|---| + | 工具栏保存 | `保存` | + | 工具栏取消 | `取消` | + | 工具栏新增 | `新增` | + | 工具栏占位按钮 | `删除` / `作废` / `重置密码` / `取消作废` / `功能`(点击 `message.info` 文案 `功能开发中`,D8) | + | 制单人 create 占位 | `保存后自动生成`(复刻 `setUserDetailMode('new')`,BR2) | + | 创建时间标签 | `创建时间` | + | 制单人标签 | `制单人` | + | 员工名标签 | `员工名` | + | 用户名标签 | `用户名` | + | 类型标签 | `类型` | + | 语言标签 | `语言` | + | 用户号标签 | `用户号` | + | 单据修改权限标签 | `单据修改权限` | + | 权限组页签 | `权限组` | + | 占位页签 | `客户查看权限` / `供应商查看权限` / `人员查看权限` / `工序查看权限` / `司机查看权限`(D9) | + | 权限列表表头 | `权限分类` | + | 用户名格式错误 | `用户名须为 3-20 位字母数字下划线`(BR3) | + | 用户名必填 | `请输入用户名`(BR3) | + | 用户号必填 | `请输入用户号`(BR4) | + | 类型必填 | `请选择类型`(BR6) | + | 语言必填 | `请选择语言`(BR7) | + | create 成功提示 | `用户创建成功`(BR16) | + | edit 成功提示 | `保存成功`(BR16) | + | 40001 提示 | `提交信息有误,请检查后重试`(spec § 4) | + | 40901 提示 | `用户名已存在,请更换`(spec § 4,就近高亮用户名字段) | + | 40401 提示 | `该用户不存在或已被删除`(spec § 4,提供「返回列表」入口) | + | 40301 提示 | `无权限执行此操作`(spec § 4) | + | 网络/5xx 提示 | `保存失败,请稍后重试`(spec § 4 兜底) | + | 员工列表加载失败 | `员工列表加载失败`(spec § 4 loadError,D1) | + | 权限列表加载失败 | `权限列表加载失败`(spec § 4 loadError,D2) | + | 详情加载失败 | `加载失败,点击重试`(edit 详情取数失败,spec § 4 loadError) | + | 取消二次确认 | `放弃未保存的修改?`(`Modal.confirm`,D5) | + | 占位功能提示 | `功能开发中`(`message.info`,D8/D9) | +- **路由 path(FE-02 已注册占位)**:create → `/usr/users/new`(BR14 / 取消回流的兄弟路由);edit → `/usr/users/:id`;保存成功/取消回流 → `/usr/users`(FE-03 列表,BR13/BR16;标签联动属 FE-02)。 +- **localStorage token 键**:`TOKEN_STORAGE_KEY = 'xly_erp_token'`(FE-01 `request.ts` 已导出,本页不直接读写,由 `request.ts` 拦截器注入)。 + +## 关键签名(首次出现处给出,跨 task 一致) + +- **类型契约**(`api/types.ts`,新增;复用 FE-03 `UserVO`/`PageResult`/`UserListQuery` 不动): + - `EmployeeOption`:`{ value: number; label: string; sEmployeeNo: string | null }`(员工名下拉项;`value` 映射后端 `iIncrement`、`label` 映射 `sEmployeeName`、`sEmployeeNo` 供联动带出用户号,D1/BR5)。 + - `PermissionItem`:`{ id: number; name: string; category: string }`(权限项;`id` 映射后端 `iIncrement`、`name` 映射 `sPermissionName`、`category` 映射 `sPermissionCategory`,D2/D3)。 + - `UserCreateReq`:`{ sUserName: string; sUserNo?: string; iEmployeeId?: number | null; sUserType: string; sLanguage: string; iCanModifyBill?: 0 | 1; permissionIds?: number[] }`(密码 `initialPassword` 前端不传,由后端默认 666666,BR9;对齐 docs/05 § REQ-USR-001)。 + - `UserUpdateReq`:`{ sUserNo?: string; iEmployeeId?: number | null; sUserType: string; sLanguage: string; iCanModifyBill?: 0 | 1; iIsVoid?: 0 | 1; permissionIds?: number[] }`(`sUserName` 不可改不传、密码不在本接口;对齐 docs/05 § REQ-USR-002)。 + - `UserDetailMode`:`'create' | 'edit'`。 +- **API 封装**(`api/usrApi.ts`,新增方法;响应拦截器已拆 `Result.data`,沿用 FE-01/FE-03 `as unknown as Promise<...>` 桥接): + - `createUser(body: UserCreateReq): Promise<{ id: number }>`——`request.post('/usr/users', body)`。 + - `updateUser(id: number, body: UserUpdateReq): Promise<{ id: number }>`——`request.put('/usr/users/' + id, body)`。 + - `getUserDetail(params: { queryField: string; queryValue: string }): Promise`——内部以「等于」匹配 + `pageNum:1, pageSize:1` 调 `listUsers`,返回 `records[0] ?? null`(复用 FE-03 `listUsers` 与中文键归一,D4)。 + - `listEmployees(): Promise`——`request.get('/usr/employees')`,对后端原始行(`iIncrement`/`sEmployeeName`/`sEmployeeNo`)归一为 `EmployeeOption`(D1)。 + - `listPermissions(): Promise`——`request.get('/usr/permissions')`,对后端原始行(`iIncrement`/`sPermissionName`/`sPermissionCategory`)归一为 `PermissionItem`(D2/D3)。 +- **页面 hook**(`pages/usr/UserDetail/useUserDetail.ts`): + - `useUserDetail(args: { mode: UserDetailMode; userId?: number; presetUser?: UserVO | null })` → `{ mode: UserDetailMode; formValues: UserFormValues; employees: EmployeeOption[]; permissions: PermissionItem[]; checkedPermissionIds: number[]; loading: boolean; submitting: boolean; error: ApiError | null; loadFailed: boolean; setField(name: keyof UserFormValues, value: unknown): void; selectEmployee(value: number | null): void; togglePermission(id: number, checked: boolean): void; toggleAll(checked: boolean): void; submit(values: UserFormValues): Promise<{ ok: boolean; id?: number; fieldError?: { field: keyof UserFormValues; message: string } }>; reload(): void; }`。 + - 其中 `UserFormValues = { sUserName: string; sUserNo: string; iEmployeeId: number | null; sUserType: string; sLanguage: string | undefined; iCanModifyBill: 0 | 1; iIsVoid?: 0 | 1 }`(受控表单值,与提交映射 spec § 6 一致;`tCreateDate`/`sCreator` 只读展示态另存,不在提交值内)。 + - 挂载即并发预取 `listEmployees()` + `listPermissions()`(initialLoading,spec § 3);`edit` 态额外按 `userId` 用 `presetUser ?? getUserDetail(...)` 取原值回填 `formValues` 并以已授权权限初始化 `checkedPermissionIds`(BR17 / D4);任一预取/详情取数失败置 `loadFailed=true` + 对应 `message.error`(spec § 4 loadError,文案见合同级常量)。 + - `selectEmployee(value)`:设 `iEmployeeId` 并按选中员工 `label`/`sEmployeeNo` 联动带出 `sUserName`(create 态)/`sUserNo`(用户仍可改,BR5)。 + - `submit(values)`:置 `submitting=true`(防重 BR15)→ create 调 `createUser(toCreateReq(values, checkedPermissionIds))` / edit 调 `updateUser(userId, toUpdateReq(values, checkedPermissionIds))`(permissionIds 全量覆盖 BR11)→ `code=0` 返回 `{ ok:true, id }`(页面负责 `message.success` + 回流 BR16);`ApiError` 按 code 分流:`40901` 返回 `{ ok:false, fieldError:{ field:'sUserName', message } }`(就近高亮)、`40401`/`40301`/`40001`/网络兜底 `message.error` 并返回 `{ ok:false }`(spec § 4);finally 复位 `submitting`。 + - `reload()`:重跑挂载预取/详情(loadError 重试入口用,spec § 4)。 + - **纯映射函数**(`pages/usr/UserDetail/constants.ts` 或 hook 内,跨 task 一致,便于单测):`toCreateReq(values: UserFormValues, permissionIds: number[]): UserCreateReq`、`toUpdateReq(values: UserFormValues, permissionIds: number[]): UserUpdateReq`、`userVoToFormValues(vo: UserVO): UserFormValues`(edit 回填,BR17)。 +- **组件 props**(`pages/usr/UserDetail/`): + - `UserDetailToolbar`(`{ mode: UserDetailMode; submitting: boolean; canSave: boolean; onSave(): void; onCancel(): void; onNew(): void }`):保存(`type="primary"`,`submitting` 时 `loading`+`disabled` BR15,`canSave` 控可点)/ 取消(BR13)/ 新增(BR14)/ 占位按钮(删除/作废/重置密码/取消作废/功能 + 齿轮,点击 `message.info('功能开发中')`,D8)。 + - `UserBasicForm`(`{ form: FormInstance; mode: UserDetailMode; employees: EmployeeOption[]; readonlyCreateTime?: string; readonlyCreator?: string; onSelectEmployee(value: number | null): void }`):AntD `Form`(受 `form` 实例控制,3 列网格布局);渲染 8 字段(spec § 2 控件选型);create 态用户名可编辑(`rules` 含必填 + `USERNAME_PATTERN`)、edit 态用户名 `disabled`(BR3);类型/语言/员工名 `Select`(options 来自常量/`employees`);创建时间/制单人只读展示(create 制单人显「保存后自动生成」BR2,edit 回填 `readonlyCreator`/`readonlyCreateTime` BR1/BR2);员工名 `onChange`→`onSelectEmployee`(BR5)。 + - `PermissionTabs`(`{ activeKey?: string; onChange?(key: string): void; children: ReactNode }`):AntD `Tabs`,第一项 `权限组`(key=`group`)承载 `children`(权限列表),其余 5 个 `客户查看权限`…`司机查看权限` 为 `disabled` 占位页签(D9)。 + - `PermissionGroupList`(`{ permissions: PermissionItem[]; checkedIds: number[]; onToggle(id: number, checked: boolean): void; onToggleAll(checked: boolean): void; loading?: boolean }`):表头行「全选 `Checkbox`(`indeterminate` 半选 / `checked` 全选)+ `权限分类` + 排序图标占位」(复刻原型 `.perm-row.head`);逐项 `Checkbox` 行(`PermItem.name`,按 `checkedIds.includes(id)` 勾选;D3 逐项展示);空 `permissions` 时空态(loadError 由页面消费,spec § 4)。 + - `UserDetailPage`(default export,无 props):`useParams<{ id?: string }>()` 判 `mode`(有 `id`→`edit`、`userId=Number(id)`;否则 `create`)+ `useLocation()`(取 `state.user` 作 `presetUser`,D4 备注)+ `useNavigate()` + AntD `Form.useForm()`;`useUserDetail({ mode, userId, presetUser })`;装配 `UserDetailToolbar`/`UserBasicForm`/`PermissionTabs`(含 `PermissionGroupList`);`onSave`→`form.validateFields()` 通过后调 `submit(values)`,`ok` 则 `message.success`(按 mode) + `navigate('/usr/users')`(BR16),`fieldError` 则 `form.setFields([{ name, errors:[message] }])` 就近高亮(40901,spec § 4);`onCancel`→ 有未保存改动 `Modal.confirm('放弃未保存的修改?')` 通过后 `navigate('/usr/users')`(BR13/D5);`onNew`→`navigate('/usr/users/new')`(BR14);`loadFailed` 渲染错误占位 +「点击重试」→`reload()` 或「返回列表」(spec § 4 loadError)。 +- **常量映射**(`pages/usr/UserDetail/constants.ts`):枚举/默认/错误码/文案/正则/`MODE_*`(见合同级常量)+ 上述 `toCreateReq`/`toUpdateReq`/`userVoToFormValues` 纯函数签名。 + +## 测试栈说明 + +- **jsdom 组件 / hook / api 单测**(Vitest + RTL):组件测用 `renderShell`(已存在,Provider + 真实 store + `MemoryRouter` + AntD `App`/`ConfigProvider`);`mode`/`:id` 用 `renderShell(, { initialEntries:['/usr/users/new' | '/usr/users/7'], preloadedAuth:{token:'t',user:ADMIN} })` 或直接渲染 `UserDetailPage` 包一层 `Routes`/`MemoryRouter` 注入路由参数;导航回流断言用 `LocationProbe`(复用 `router.test.tsx`/`UserListPage.test.tsx` 的 `useLocation` 探针模式)+ 列表路由哨兵。`useUserDetail` 用 `renderHook`(需 AntD `App` wrapper 提供 `message`,沿用 `useUserList.test.tsx` 的 wrapper 模式)。api 测桩底层 `request` 实例(沿用 `usrApi.userlist.test.ts` 的 `vi.mock('../../src/api/request')` 模式);hook/页面测对 `usrApi` 用 `vi.mock('../../src/api/usrApi')` 桩(沿用 `useUserList.test.tsx` 模式)。 +- **Playwright E2E**:沿用 `shell.spec.ts`/`userlist.spec.ts` 的登录桩(`stubBackend`/`login`);`page.route` 桩 `**/api/usr/employees**`(返回员工项)、`**/api/usr/permissions**`(返回权限项)、`**/api/usr/users**`(GET 返回 edit 预填单条;POST/PUT 按用例返回 `code=0` 或 40901/5xx)。先经登录桩落地外壳后从 `/usr/users` 进入或直接导航 `/usr/users/new`、`/usr/users/{id}`。不依赖真实后端起服。 +- **可测性**:优先语义查询(role/label/text/`Form.Item` label);仅当无法稳定定位时加最小 `data-testid`(如 `userdetail-page` / `btn-save` / `btn-cancel` / `btn-new` / `field-username` / `field-userno` / `select-employee` / `select-usertype` / `select-language` / `perm-list` / `perm-check-all` / `userdetail-loaderror`)。 + +--- + +## 任务列表(每个 task = red → green → 子会话验证 → commit) + +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。 +> 提交 scope 统一 `usr`;REQ tag:纯 create 链路标 `REQ-USR-001`,涉 edit(预填/更新)链路追加 `REQ-USR-002`(在 commit 行注明)。 + +### T1 — 类型契约 + API 封装(createUser/updateUser/getUserDetail/listEmployees/listPermissions,D1/D2/D4)(jsdom api 测) +- **测试先行类型**:jsdom 组件测试(api 单测) +- [ ] **1. 写失败测试**:`frontend/tests/unit/usrApi.userdetail.test.ts`(沿用 `usrApi.userlist.test.ts` 的 `vi.mock('../../src/api/request')` 桩底层实例 `post`/`put`/`get`): + - `::createUser posts /usr/users with body`——调 `createUser({ sUserName:'zhangsan', sUserNo:'zs', iEmployeeId:3, sUserType:'普通用户', sLanguage:'中文', iCanModifyBill:0, permissionIds:[1,2] })`,断言 `request.post` 以 `'/usr/users'` + 该 body 被调,且 body 不含 `initialPassword`/`sCreator`/`tCreateDate`(BR9)。 + - `::updateUser puts /usr/users/{id} with body`——调 `updateUser(7, { sUserType:'超级管理员', sLanguage:'英文', iCanModifyBill:1, iIsVoid:0, permissionIds:[2] })`,断言 `request.put` 以 `'/usr/users/7'` + 该 body 被调,且 body **不含** `sUserName`(不可改 BR3)。 + - `::getUserDetail queries equals match pageSize 1 and returns records[0]`——桩 `request.get` resolve `{ records:[{ id:7, sUserName:'zhangsan', 员工名:'张三', sUserNo:'zs', 部门:null, sUserType:'普通用户', sLanguage:'中文', iIsVoid:0, tLastLoginDate:null, sCreator:'admin', tCreateDate:'2026-01-01T00:00:00' }], total:1, pageNum:1, pageSize:1 }`,调 `getUserDetail({ queryField:'用户名', queryValue:'zhangsan' })`,断言 `request.get` query 含 `matchType:'等于', pageNum:1, pageSize:1`,返回对象 `id===7`、`employeeName==='张三'`(复用归一),空 records 时返回 `null`。 + - `::listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption`——桩 resolve `[{ iIncrement:3, sEmployeeName:'张三', sEmployeeNo:'zs' }]`,断言返回 `[{ value:3, label:'张三', sEmployeeNo:'zs' }]`(D1)。 + - `::listPermissions normalizes to PermissionItem`——桩 resolve `[{ iIncrement:1, sPermissionName:'默认显示', sPermissionCategory:'基础' }]`,断言返回 `[{ id:1, name:'默认显示', category:'基础' }]`(D2/D3)。 + > FE-01/FE-03 既有 `login`/`fetchCompanies`/`listUsers` 用例不回归(同文件或既有文件保持绿)。 +- [ ] **2. 实现最小代码**:`frontend/src/api/types.ts`(新增 `EmployeeOption`/`PermissionItem`/`UserCreateReq`/`UserUpdateReq`/`UserDetailMode`,签名见关键签名;复用 FE-03 `UserVO`/`PageResult`/`UserListQuery`)+ `frontend/src/api/usrApi.ts`(新增 5 方法,签名见关键签名;`getUserDetail` 复用 `listUsers`;`listEmployees`/`listPermissions` 做后端原始键归一)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- usrApi`(含 FE-01/FE-03 `usrApi` 既有用例不回归) +- [ ] **4. commit**:`feat(usr): 用户单据 API 与类型契约 create/update/detail/employees/permissions REQ-USR-001 REQ-USR-002` + +### T2 — 页面常量 + 提交映射纯函数(枚举/默认/正则/错误码/文案 + toCreateReq/toUpdateReq/userVoToFormValues)(jsdom 单测) +- **测试先行类型**:jsdom 组件测试(纯逻辑断言) +- [ ] **1. 写失败测试**:`frontend/tests/unit/userDetailMappers.test.ts`: + - `::constants enums and defaults`——断言 `USER_TYPE_OPTIONS===['普通用户','超级管理员']`、`LANGUAGE_OPTIONS===['中文','英文','繁体']`、`CREATE_DEFAULTS.sUserType==='普通用户'`、`CREATE_DEFAULTS.iCanModifyBill===0`、`USERNAME_PATTERN.test('ab_12')===true` 且 `USERNAME_PATTERN.test('ab')===false`(少于 3 位)、错误码 `ERR_USERNAME_EXISTS===40901`/`ERR_USER_NOT_FOUND===40401`/`ERR_NO_PERMISSION===40301`/`ERR_VALIDATION===40001`、`MODE_CREATE==='create'`/`MODE_EDIT==='edit'`。 + - `::toCreateReq maps form values + permissionIds (no password)`——传 `UserFormValues` + `[1,2]`,断言产出 `UserCreateReq` 含 `permissionIds:[1,2]`、`iCanModifyBill` 为 0/1、不含 `initialPassword`/`iIsVoid`(create 无作废,BR9)。 + - `::toUpdateReq maps without sUserName + includes iIsVoid + full permissionIds`——断言产出 `UserUpdateReq` 不含 `sUserName`(BR3),含 `iIsVoid`、`permissionIds`(全量覆盖 BR11)。 + - `::userVoToFormValues fills from UserVO`——传 `UserVO`,断言回填 `sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iCanModifyBill`/`iIsVoid` 与 VO 一致(BR17)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/constants.ts`(枚举/默认/正则/错误码/`MODE_*`/文案常量 + `toCreateReq`/`toUpdateReq`/`userVoToFormValues` 纯函数,签名见关键签名)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- userDetailMappers` +- [ ] **4. commit**:`feat(usr): 用户单据页面常量与提交映射纯函数 REQ-USR-001 REQ-USR-002` + +### T3 — useUserDetail 单据 hook(状态机:initialLoading/editing/submitting/submitError/submitSuccess/loadError)(jsdom hook 测) +- **测试先行类型**:jsdom 组件测试(`renderHook`,`vi.mock('../../src/api/usrApi')`,AntD `App` wrapper) +- [ ] **1. 写失败测试**:`frontend/tests/unit/useUserDetail.test.tsx`: + - `::create mode initial load prefetches employees+permissions (initialLoading→editing)`——`listEmployees`/`listPermissions` resolve 非空,`mode:'create'`,断言挂载即并发取数、`loading` true→false、`formValues` 为 `CREATE_DEFAULTS`、`checkedPermissionIds` 为空(spec § 3 initialLoading→editing / BR2/BR6/BR8)。 + - `::edit mode prefills from getUserDetail and pre-checks permissions`——`mode:'edit'`,`userId:7`,无 `presetUser`,桩 `getUserDetail` 返回含权限的 `UserVO`,断言 `getUserDetail` 被调、`formValues` 回填原值、`checkedPermissionIds` 含已授权(BR17/D4)。 + - `::edit mode with presetUser skips getUserDetail`——传 `presetUser`,断言 `getUserDetail` 未被调、直接以 preset 回填(D4 备注)。 + - `::selectEmployee fills userNo/userName from employee (create)`——`selectEmployee(3)`,断言 `iEmployeeId===3` 且 `sUserName`/`sUserNo` 按选中员工带出(BR5)。 + - `::toggle permission and toggleAll update checkedPermissionIds`——`togglePermission(1,true)` 加入 1、`toggleAll(true)` 选全部、`toggleAll(false)` 清空(BR10/BR11)。 + - `::submit create calls createUser and returns {ok,id}`——`mode:'create'`,桩 `createUser` resolve `{id:9}`,`submit(values)` 返回 `{ ok:true, id:9 }`,期间 `submitting` true→false(BR12/BR15)。 + - `::submit edit calls updateUser with userId and full permissionIds`——`mode:'edit'`,`userId:7`,桩 `updateUser` resolve `{id:7}`,断言以 `(7, UserUpdateReq含permissionIds)` 调用,返回 `{ ok:true, id:7 }`(BR11/BR12)。 + - `::submit 40901 returns fieldError on sUserName`——桩 `createUser` reject `new ApiError(40901,...)`,断言返回 `{ ok:false, fieldError:{ field:'sUserName' } }`(spec § 4 / BR3)。 + - `::submit 40401/40301/40001/network show message and return ok:false`——分别 reject 对应 `ApiError`,断言 `message.error` 文案(按合同级常量)且返回 `{ ok:false }`(spec § 4)。 + - `::loadError when prefetch fails sets loadFailed and message`——桩 `listPermissions` reject,断言 `loadFailed===true` + `message.error('权限列表加载失败')`(spec § 4 loadError / D2);`reload()` 重新取数清 `loadFailed`。 + > `message` 断言桩 AntD `App.useApp().message`(沿用 `useUserList.test.tsx` 的 wrapper/桩模式,TDD 期定一处)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/useUserDetail.ts`(签名见关键签名;内部 `useState` 持 `mode`/`formValues`/`employees`/`permissions`/`checkedPermissionIds`/`loading`/`submitting`/`error`/`loadFailed`,`useEffect` 挂载并发预取 + edit 详情;错误码分流按合同级常量;复用 T1 api + T2 映射)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- useUserDetail` +- [ ] **4. commit**:`feat(usr): 用户单据 hook useUserDetail 状态机 REQ-USR-001 REQ-USR-002` + +### T4 — UserBasicForm 表单网格(字段/默认/只读/枚举/必填+格式/员工联动,BR1-BR9)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserBasicForm.test.tsx`(`renderShell` 提供 AntD App;测试内用 `Form.useForm()` wrapper 注入 `form`): + - `::renders 8 labeled fields`——断言创建时间/制单人/员工名/用户名/类型/语言/用户号/单据修改权限 8 个标签可见(文案逐字)。 + - `::create mode username editable with default empty; edit mode username disabled`——`mode='create'` 用户名 `Input` 可编辑;`mode='edit'` 用户名框 `disabled`(BR3)。 + - `::create mode defaults usertype 普通用户`——create 类型下拉默认显「普通用户」(BR6)。 + - `::username format rule rejects short/invalid and required when empty`——create 态填「ab」触发校验报「用户名须为 3-20 位字母数字下划线」;清空报「请输入用户名」(BR3)。 + - `::userno required`——用户号空提交报「请输入用户号」(BR4)。 + - `::usertype/language selects expose enum options only`——展开类型断言 `普通用户/超级管理员`;展开语言断言 `中文/英文/繁体`(BR6/BR7,无自由输入)。 + - `::create mode creator shows 保存后自动生成`——create 制单人只读区文本「保存后自动生成」(BR2);edit 态显 `readonlyCreator`。 + - `::selecting employee calls onSelectEmployee`——员工名下拉选中 → `onSelectEmployee` 收到 value(BR5)。 + - `::单据修改权限 checkbox default unchecked (create)`——create 复选框默认未勾(BR8)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/UserBasicForm.tsx`(签名见关键签名;3 列网格 `Form`;用户名 `rules` 含必填 + `USERNAME_PATTERN`,edit `disabled`;类型/语言/员工 `Select`;创建时间/制单人只读;员工 `onChange`→`onSelectEmployee`)+ `UserDetail.module.css` 表单样式(网格 `--color-form-bg-edit`、只读字段 `--color-form-bg-readonly`、文字 `--color-form-fg`、必填 `*` `--color-error`、网格线 `--color-border`、下拉 hover `--color-form-bg-hover`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserBasicForm` +- [ ] **4. commit**:`feat(usr): 用户单据表单网格 UserBasicForm REQ-USR-001 REQ-USR-002` + +### T5 — PermissionGroupList 权限分类勾选列表(渲染/勾选集合/全选 indeterminate/回勾,BR10/BR11/D3)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/PermissionGroupList.test.tsx`(`renderShell`): + - `::renders header 权限分类 and one row per permission`——传 3 个 `PermissionItem`,断言表头「权限分类」+ 3 行复选框逐项名(D3)。 + - `::checked rows reflect checkedIds`——`checkedIds=[1]`,断言 id=1 行勾选、其余未勾(BR10/edit 回勾 BR17)。 + - `::toggling a row calls onToggle(id, checked)`——点未勾行 → `onToggle(id,true)`;点已勾行 → `onToggle(id,false)`(BR10/BR11)。 + - `::header select-all checked when all selected; indeterminate when partial`——全勾时表头全选 `checked`、部分勾时 `indeterminate`、全不勾时未勾(半选语义)。 + - `::header toggle calls onToggleAll`——点表头全选(当前未全选)→ `onToggleAll(true)`;全选态再点 → `onToggleAll(false)`。 + - `::empty permissions renders empty list (no rows)`——`permissions=[]` 无数据行(loadError 占位由页面消费)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/PermissionGroupList.tsx`(签名见关键签名;表头全选 `Checkbox`(`checked`/`indeterminate` 由 `checkedIds` 与 `permissions` 计算)+ 逐项行 `Checkbox`)+ `UserDetail.module.css` 权限列表样式(表头 `--color-table-header-bg`/`--color-table-header-fg`、行文字 `--color-text`、行 hover `--color-table-row-bg-hover`、行下边线 `--color-border`、列表白底 `--color-form-bg-edit`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- PermissionGroupList` +- [ ] **4. commit**:`feat(usr): 用户单据权限分类勾选列表 PermissionGroupList REQ-USR-001 REQ-USR-002` + +### T6 — PermissionTabs 权限页签条(权限组 active + 5 占位页签,D9)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/PermissionTabs.test.tsx`(`renderShell`): + - `::renders 权限组 active with children`——传 `children` 哨兵元素,断言「权限组」页签存在且默认激活、`children`(权限列表)可见。 + - `::renders 5 placeholder tabs disabled`——断言「客户查看权限/供应商查看权限/人员查看权限/工序查看权限/司机查看权限」5 个页签存在且 `disabled`(不渲染数据,D9)。 + - `::placeholder tabs do not show permission list`——切到(被 disabled 无法切,或断言占位页签无权限行内容)确认占位(D9)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/PermissionTabs.tsx`(签名见关键签名;AntD `Tabs` `items`:`group` 项含 `children`,5 占位项 `disabled:true`)+ `UserDetail.module.css` 页签条样式(active 下划线 `--color-primary`、页签文字 `--color-text`、占位文字 `--color-text-secondary`、下边线 `--color-border`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- PermissionTabs` +- [ ] **4. commit**:`feat(usr): 用户单据权限页签条 PermissionTabs REQ-USR-001` + +### T7 — UserDetailToolbar 工具栏(保存/取消/新增 + 提交中禁用 + 占位按钮,BR12/BR13/BR14/BR15/D8/D10)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserDetailToolbar.test.tsx`(`renderShell`): + - `::renders 保存/取消/新增 + placeholder buttons + gear`——断言保存/取消/新增可见,占位「删除/作废/重置密码/取消作废/功能」与齿轮可见(文案逐字 + 图标)。 + - `::click 保存 calls onSave / 取消 calls onCancel / 新增 calls onNew`——分别点击触发对应回调(BR12/BR13/BR14)。 + - `::submitting disables 保存 and shows loading`——`submitting=true` 时「保存」禁用且 loading,再点不触发 `onSave`(BR15)。 + - `::canSave=false disables 保存`——`canSave=false` 时「保存」禁用。 + - `::placeholder buttons show 功能开发中 (no business callback)`——点占位按钮/齿轮触发 `message.info('功能开发中')`、不触发 `onSave`/`onCancel`/`onNew`(D8)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/UserDetailToolbar.tsx`(签名见关键签名;图标 `SaveOutlined`/`CloseCircleOutlined`/`PlusCircleOutlined`/`SettingOutlined`;占位按钮 `message.info('功能开发中')`)+ `UserDetail.module.css` 工具栏深色底(scoped 局部装饰,非语义 token,D10;保存主操作 `--color-primary`、占位按钮文字 `--color-text-secondary`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserDetailToolbar` +- [ ] **4. commit**:`feat(usr): 用户单据工具栏 UserDetailToolbar REQ-USR-001 REQ-USR-002` + +### T8 — UserDetailPage 页面集成 + 路由接线(create/edit 贯通 + 提交回流 + 错误就近 + 取数失败,BR3/BR12/BR16/BR17/D4/D5)(jsdom 组件测) +- **测试先行类型**:jsdom 组件测试 +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserDetailPage.test.tsx`(`renderShell`,`vi.mock('../../src/api/usrApi')` 桩 5 方法;用 `LocationProbe` + `/usr/users` 列表哨兵验回流;用 `MemoryRouter initialEntries` 注入 `/usr/users/new` 与 `/usr/users/7`): + - `::create mode renders empty form with defaults`——进 `/usr/users/new`,挂载预取员工/权限、渲染空表单 + 默认普通用户 + 制单人「保存后自动生成」(BR2/BR6/initialLoading→editing)。 + - `::create submit success navigates to /usr/users with success`——填合法字段(用户名「zhangsan」/用户号/语言 + 勾权限),点保存 → `createUser` 以表单值被调、`message.success('用户创建成功')`、URL/哨兵回 `/usr/users`(BR12/BR16)。 + - `::create username format invalid blocks submit`——填「ab」点保存 → 校验拦截、`createUser` 未被调、就近报错「用户名须为 3-20 位字母数字下划线」(BR3)。 + - `::create 40901 highlights username field`——桩 `createUser` reject `ApiError(40901)`,点保存 → 用户名 `Form.Item` 就近报「用户名已存在,请更换」+ `message.error`(spec § 4)。 + - `::edit mode prefills from getUserDetail and username disabled`——进 `/usr/users/7`,桩 `getUserDetail` 返回 `UserVO`,断言表单回填原值、用户名框禁用、权限按已授权回勾(BR17/BR3/D4)。 + - `::edit submit success navigates to /usr/users with 保存成功`——edit 改类型后保存 → `updateUser(7, ...)` 被调、`message.success('保存成功')`、回流 `/usr/users`(BR16)。 + - `::cancel with dirty form confirms then navigates`——改过字段后点取消 → 弹「放弃未保存的修改?」,确认后回 `/usr/users`(BR13/D5)。 + - `::新增 navigates to /usr/users/new`——点工具栏「新增」→ URL/哨兵到 `/usr/users/new`(BR14)。 + - `::loadError shows retry; retry calls reload`——桩 `listPermissions` reject → 可见「加载失败,点击重试」或对应 loadError 占位;点重试再次取数(spec § 4)。 + - `::edit 40401 offers 返回列表`——桩 `getUserDetail` 返回 `null`(或 `updateUser` reject 40401)→ 提示「该用户不存在或已被删除」+「返回列表」入口回 `/usr/users`(spec § 4)。 +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/index.tsx`(`UserDetailPage`:`useParams`/`useLocation`/`useNavigate` + `Form.useForm()` + `useUserDetail`,装配 4 子组件,`onSave`→`validateFields`→`submit`→`ok` 则 `message.success`+`navigate('/usr/users')`、`fieldError` 则 `form.setFields` 就近,`onCancel`→脏检 `Modal.confirm`→`navigate`,`onNew`→`navigate('/usr/users/new')`,`loadFailed` 渲染重试/返回列表);改 `frontend/src/router/index.tsx`——把 `/usr/users/new` 与 `/usr/users/:id` 的 `UserDetailPlaceholder` 替换为 `UserDetailPage`(移除占位组件,import 真实页;FE-03 `/usr/users` 不动)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserDetailPage router`(确认 FE-02 `router.test.tsx` 不回归——若其断言 `/usr/users/new`/`:id` 渲染占位 `data-testid`,需同步更新为真实页可定位元素并在 commit 说明) +- [ ] **4. commit**:`feat(usr): 用户单据页面集成与路由接线 UserDetailPage REQ-USR-001 REQ-USR-002` + +### T9 — E2E 用户单据关键旅程(Playwright) +- **测试先行类型**:Playwright E2E +- [ ] **1. 写失败测试**:`frontend/tests/e2e/userdetail.spec.ts`(沿用 `shell.spec.ts`/`userlist.spec.ts` 登录桩;`page.route` 桩 `**/api/usr/employees**`/`**/api/usr/permissions**`/`**/api/usr/users**`): + - `::create user and return to list`——进 `/usr/users/new`,填合法字段 + 勾权限,桩 `POST` 返回 `{code:0,data:{id:9}}`,点保存 → 断言 `message`「用户创建成功」+ URL 回 `/usr/users`(用 `page.waitForRequest` 校验 POST body 含 `sUserName`)。 + - `::edit user prefill then save`——桩 `GET /usr/users`(等于匹配)返回单条用户,进 `/usr/users/7`,断言用户名框值回填且禁用,改语言后保存(桩 `PUT` 返回 `code:0`)→ `message`「保存成功」+ 回 `/usr/users`。 + - `::username conflict shows inline error`——桩 `POST` 返回 `{code:40901}`,提交 → 断言用户名字段就近报「用户名已存在,请更换」。 + - `::load error shows retry`——桩 `GET /usr/permissions` 5xx,进 `/usr/users/new` → 断言「加载失败,点击重试」可见;点重试(改桩为成功)后表单可用。 + - `::placeholder tabs/buttons are inert`——断言 5 个查看权限页签 disabled、点「删除」出现「功能开发中」(D8/D9)。 +- [ ] **2. 实现最小代码**:补任何为可测性需要的最小 `data-testid`(仅 Playwright 无法稳定定位时,如 `userdetail-page`/`btn-save`/`field-username`/`perm-list`/`userdetail-loaderror`)。沿用 `playwright.config.ts`。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- userdetail` +- [ ] **4. commit**:`test(usr): 用户单据 E2E 关键旅程 REQ-USR-001 REQ-USR-002` + +### T10 — 全量门禁回归 + 收尾(chore) +- **测试先行类型**:无新增测试(全量验证) +- [ ] **1. 写失败测试**:无。 +- [ ] **2. 实现最小代码**:修 lint / build(`tsc --noEmit`)/ 类型问题;确认语义色全部 `var(--color-*)`、无硬编码 hex/rgba(工具栏深色装饰 scoped 例外,D10);确认无 `TBD/TODO/【人工填写】`;确认 FE-01/FE-02/FE-03 既有单测/E2E 不回归(尤其 `router.test.tsx`、`shell.spec.ts`、`usrApi.userlist.test.ts`、`useUserList.test.tsx`)。 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。 +- [ ] **4. commit**:`chore(usr): FE-04 门禁回归通过 REQ-USR-001 REQ-USR-002` + +--- + +## 完成判据(Definition of Done) + +1. `/usr/users/new` 与 `/usr/users/:id` 渲染真实 `UserDetailPage`(复刻原型 `#screen-userdetail` 的工具栏/3 列表单网格/权限页签条/权限分类列表),create/edit 模式由路由 `:id` 判定;写提交真实对接 `POST /api/usr/users`(create)/ `PUT /api/usr/users/{id}`(edit),下拉/权限/edit 预填经 `GET /api/usr/employees`/`GET /api/usr/permissions`/`GET /api/usr/users`(spec § 1/§ 2/§ 4 / D1/D2/D4)。 +2. 状态机覆盖并测试固化:`initialLoading`(T3/T8)、`editing`(T3/T4/T8)、`submitting`(T3/T7)、`submitError`(T3/T8)、`submitSuccess`(T3/T8)、`loadError`(T3/T8/T9)(spec § 3)。 +3. 业务规则 BR1~BR17 在 hook/组件/E2E 有断言:BR1(T4)、BR2(T3/T4/T8)、BR3(T2/T4/T8/T9)、BR4(T4)、BR5(T3/T4)、BR6(T2/T4/T8)、BR7(T4)、BR8(T2/T4)、BR9(T1/T2)、BR10(T3/T5)、BR11(T1/T2/T3/T5)、BR12(T3/T7/T8/T9)、BR13(T7/T8)、BR14(T7/T8)、BR15(T3/T7)、BR16(T3/T8/T9)、BR17(T2/T3/T8/T9)(spec § 5)。 +4. 字段定义/提交映射对齐 REQ 表 1/表 2 + docs/05 请求体 + 原型(8 字段 + 权限 `permissionIds`,create 不传密码、edit 不传 `sUserName`,全量覆盖语义,spec § 6 / T1/T2);`UserVO`(FE-03)/`EmployeeOption`/`PermissionItem` 字段映射一致(D1/D2/D3)。 +5. API 经 `usrApi.*` → `request.ts`(拆 `Result`、`ApiError` 分流、被动 401 统一登出复用 FE-01/FE-02),页面不散用 axios(docs/04 § 2.3);单据态在页面 hook 不进 Redux(docs/04 § 2.2 / spec D7)。 +6. 错误码分流文案对齐 spec § 4(40901 用户名就近高亮、40401 返回列表、40001/40301 message、网络兜底;loadError 重试入口;被动 401 统一跳登录)。 +7. 占位项不杜撰后端端点:工具栏删除/重置密码/功能 + 5 个查看权限页签 + 设置齿轮均占位(`message.info('功能开发中')`/`disabled`),作废/取消作废经 PUT `iIsVoid` 承载(spec D8/D9)。 +8. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 沿用 FE-01 `ConfigProvider`;工具栏深色底 scoped 装饰不新增全局 token、不挪用语义 token(spec § 7 / D10)。 +9. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动;改 `router/index.tsx`(占位换真实页)、`usrApi.ts`/`types.ts`(增写端点契约)属共享骨架,已在《模块完成报告》留痕。 +10. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。 + +## 自审记录 + +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 真实占位(正文 `TBD/TODO` 仅作为「禁止出现的字样」被引用)。 +- **spec coverage**:§ 1 关联 REQ/原型/路由/落地目录 → 架构 + T1/T8(路由接线);§ 2 组件树 → T4(UserBasicForm)/T5(PermissionGroupList)/T6(PermissionTabs)/T7(UserDetailToolbar)/T8(Page 装配);§ 3 状态机 6 态 → 见 DoD 第 2 条;§ 4 端点/错误码 → T1(api)/T3(错误码分流)/T8(就近高亮/loadError 重试);§ 5 BR1-BR17 → 见 DoD 第 3 条逐条映射;§ 6 字段定义与提交映射 → T1/T2(纯函数)/T4(渲染)/T5(权限);§ 7 tokens → T4/T5/T6/T7/T10;§ 8 decisions D1-D11 已落实于架构/合同级常量/任务(D1 员工端点=T1/T4;D2 权限端点=T1/T5;D3 逐项展示=T5;D4 edit 预填复用列表=T1/T3/T8;D5 取消二次确认+防重=T3/T7/T8;D6 回流列表=T3/T8;D7 本地 hook=T3;D8 占位按钮/作废=T7;D9 占位页签=T6;D10 工具栏深色=T7;D11 用户号前端必填=T4)。 +- **类型一致性**:`EmployeeOption`/`PermissionItem`/`UserCreateReq`/`UserUpdateReq`/`UserDetailMode`/`UserFormValues`(跨 T1/T2/T3/T4/T5/T8 一致)、复用 FE-03 `UserVO`/`PageResult`/`UserListQuery`、`USER_TYPE_OPTIONS`/`LANGUAGE_OPTIONS`/`CREATE_DEFAULTS`/`USERNAME_PATTERN`/`MODE_CREATE`/`MODE_EDIT`、错误码常量(40001/40901/40401/40301/-1)、`createUser`/`updateUser`/`getUserDetail`/`listEmployees`/`listPermissions`/`useUserDetail`/`toCreateReq`/`toUpdateReq`/`userVoToFormValues`/`UserDetailToolbar`/`UserBasicForm`/`PermissionTabs`/`PermissionGroupList`/`UserDetailPage` 签名跨 T1-T10 一致;路由 path(`/usr/users` / `/usr/users/new` / `/usr/users/:id`)与 FE-02/FE-03 合同级常量一致;`ApiError`/`TOKEN_STORAGE_KEY`/`NETWORK_ERROR_CODE` 复用 FE-01 `request.ts`。 +- **作用域自审**:所有 `impl_file` / `test_file` 均以 `frontend/` 开头;无 `backend/` / `sql/` / `scripts/` 落点。改 `src/router/index.tsx`、`src/api/usrApi.ts`、`src/api/types.ts` 属 FE 共享骨架扩展(非新阶段越界),已在架构段与 DoD 第 9 条登记留痕要求。 +- **本计划承接的跨阶段待对齐项(自 spec § 8 末注)**:D1/D2 的支撑只读端点 `GET /api/usr/employees`、`GET /api/usr/permissions` 须在后端编码期于 REQ-USR-001/002 后端实现内补齐契约(如同 FE-01 `GET /api/usr/companies` 先例);前端 E2E/单测以桩覆盖,不依赖真实后端起服,故本 FE 可独立按 TDD 红绿推进。 diff --git a/docs/superpowers/reviews/2026-06-01-FE-01-verify.md b/docs/superpowers/reviews/2026-06-01-FE-01-verify.md new file mode 100644 index 0000000..ad943f7 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-01-FE-01-verify.md @@ -0,0 +1,120 @@ +# FE-01 登录页 — 功能测试证据(verify, round=0) + +> 阶段:前端(frontend)。作用域:`frontend/**`(无 `backend/` / `sql/` / `scripts/` 越界)。 +> 上游 spec:`docs/superpowers/specs/2026-06-01-FE-01.md`;上游 plan:`docs/superpowers/plans/2026-06-01-FE-01.md`。 +> 关联 REQ:REQ-USR-004(登录用户)。提交标签 `REQ-USR-004`,T0~T11 共 12 个提交。 +> 测试命令来源:`docs/04-技术规范.md § 零`(unit=`npm run test:unit` / e2e=`npm run test:e2e`),在 `frontend/` 子项目执行。 + +--- + +## 1. 结论 + +| 目标 | 命令 | exit_code | passed | failed | 结论 | +|---|---|---|---|---|---| +| 单测(jsdom / Vitest + RTL) | `npm run test:unit`(`cd frontend`) | `0` | `32` | `0` | 全绿 | +| E2E(Playwright chromium) | `npm run test:e2e`(`cd frontend`) | `0` | `4` | `0` | 全绿 | + +**总判定:通过(两目标 exit_code=0 且 failed=0)。** `failed_list` 均为空。可进入 review 阶段。 + +--- + +## 2. 单测证据(unit) + +- **command**:`cd /Users/reporkey/Desktop/mvp/test6/frontend && npm run test:unit`(即 `vitest run`) +- **exit_code**:`0` +- **passed**:`32` +- **failed**:`0` +- **failed_list**:(空) +- **test files**:`10 passed (10)` + +测试文件清单(10 个,覆盖 spec § 3 状态机六态 + § 5 BR1~BR11): + +| 文件 | 用例数 | 覆盖点 | +|---|---|---| +| `tests/unit/smoke.test.tsx` | 1 | 工程骨架冒烟(T0) | +| `tests/unit/request.test.ts` | — | Axios 实例 baseURL `/api`、Result 拆包、`ApiError` 业务码/网络码、Authorization 注入(T1) | +| `tests/unit/usrApi.test.ts` | — | `login` POST `/usr/login`、`fetchCompanies` GET `/usr/companies`(T2) | +| `tests/unit/authSlice.test.ts` | — | `setCredentials`/`clearCredentials` + token 持久化 + initialState 读取(T3) | +| `tests/unit/LoginPage.layout.test.tsx` | 3 | 品牌头/主视觉/页脚/卡片标题/三字段+按钮(T4,BR3 掩码) | +| `tests/unit/LoginPage.companies.test.tsx` | 5 | 版本下拉 loading/idle(D8 label)/单项自动选中/空态/取数失败重试(T5,BR5) | +| `tests/unit/LoginPage.validation.test.tsx` | 2 | 必填校验拦截 + 全填提交 payload(T6,BR1/BR2/BR4) | +| `tests/unit/LoginPage.submitting.test.tsx` | 2 | 提交中 loading+禁用 + 防重复提交(T7,BR10) | +| `tests/unit/LoginPage.success.test.tsx` | 2 | 写 authSlice+持久化+`navigate('/',{replace})`(T8,BR9) | +| `tests/unit/LoginPage.error.test.tsx` | 6 | 40101/40302/42901/40001/网络异常文案分流 + 失败后清空聚焦+保留(T9,BR6/7/8+D5) | + +> 注:`tests/unit/renderLogin.tsx` 为共享渲染工具,非测试文件,不计入 10 个 test files。 + +`stdout_excerpt`(≤30 行,去 act 警告噪声): + +``` + ✓ tests/unit/LoginPage.layout.test.tsx (3 tests) 291ms + ✓ tests/unit/LoginPage.companies.test.tsx (5 tests) 491ms + ✓ tests/unit/LoginPage.success.test.tsx (2 tests) 519ms + ✓ LoginPage 登录成功 > dispatches setCredentials and persists token on success 358ms + ✓ tests/unit/LoginPage.validation.test.tsx (2 tests) 584ms + ✓ tests/unit/LoginPage.submitting.test.tsx (2 tests) 598ms + ✓ LoginPage 提交中态 > button loading and fields disabled while submitting 362ms + ✓ tests/unit/LoginPage.error.test.tsx (6 tests) 999ms + ✓ LoginPage 登录失败错误码分流 > 40101 shows 用户名或密码错误 and clears+focuses password 370ms + + Test Files 10 passed (10) + Tests 32 passed (32) + Duration 2.46s +``` + +> 运行期 React `act(...)` 警告为 AntD + 异步 `useEffect`(版本预取)触发的 stderr 提示,**非断言失败**,不影响 exit_code=0 / 32 passed;登记为已知噪声(见 decisions)。 + +--- + +## 3. E2E 证据(e2e) + +- **command**:`cd /Users/reporkey/Desktop/mvp/test6/frontend && npm run test:e2e`(即 `playwright test`,chromium) +- **exit_code**:`0` +- **passed**:`4` +- **failed**:`0` +- **failed_list**:(空) + +用例清单(`tests/e2e/login.spec.ts`,`page.route` 桩 `**/api/usr/companies` 与 `**/api/usr/login`,不依赖真实后端): + +1. `loads /login and shows version options` — 访问 `/login` 渲染桩版本项。 +2. `blocks submit with validation when empty` — 空提交必填拦截、未发起 login。 +3. `successful login navigates away from /login` — 填全+桩成功 → 离开 `/login` 到 `/`、见「登录成功」。 +4. `failed login stays on /login with error` — 桩 40101 → 停留 `/login`、见「用户名或密码错误」。 + +`stdout_excerpt`(≤30 行,去 DeprecationWarning 噪声): + +``` +Running 4 tests using 4 workers + ✓ 4 [chromium] › tests/e2e/login.spec.ts:22:3 › 登录页关键旅程 › loads /login and shows version options (360ms) + ✓ 1 [chromium] › tests/e2e/login.spec.ts:32:3 › 登录页关键旅程 › blocks submit with validation when empty (414ms) + ✓ 3 [chromium] › tests/e2e/login.spec.ts:77:3 › 登录页关键旅程 › failed login stays on /login with error (699ms) + ✓ 2 [chromium] › tests/e2e/login.spec.ts:50:3 › 登录页关键旅程 › successful login navigates away from /login (712ms) + 4 passed (1.8s) +``` + +> Node `DEP0205 module.register()` 为 Playwright/Node 内部弃用提示,非测试失败,不影响 exit_code=0 / 4 passed。 + +--- + +## 4. 作用域与命令合规核对 + +- 所有实现/测试文件均在 `frontend/**`;未触 `backend/` / `sql/` / `scripts/`,无越界。 +- 测试命令取自 `docs/04-技术规范.md § 零`:unit=`npm run test:unit`、e2e=`npm run test:e2e`,与 `frontend/package.json` scripts 一致。 +- 本轮为 round=0,证据落盘固定路径 `docs/superpowers/reviews/2026-06-01-FE-01-verify.md`(`-verify.md` 后缀)。 + +--- + +## 5. 自主决策记录(decisions) + +| # | 问题 | 选择 | 依据 | 置信度 | +|---|---|---|---|---| +| V1 | 本环境未暴露独立的「子会话/Agent 派发」工具,如何执行 plan「派子会话依次跑 unit + e2e」 | 由本(fe-feature-verify 非交互子代理)会话经唯一可用执行原语 Bash 直接运行 `npm run test:unit` / `npm run test:e2e` 并采集结构化结果(command/exit_code/passed/failed/failed_list/stdout_excerpt),按结构化 JSON 渲染本证据 | 当前工具集只暴露 Bash 作为执行原语,无 sub-session spawn 工具;结果仍按 plan 约定的结构化 JSON 字段采集与渲染,语义等价、不污染源码 | high | +| V2 | 单测运行期大量 React `act(...)` 警告是否构成失败 | 视为非阻断噪声,不计入 failed | 警告打印到 stderr,由 AntD + 异步 `useEffect` 版本预取触发;Vitest 报告 exit_code=0 / 32 passed / 0 failed,断言全过 | high | + +--- + +## 6. 摘要(供 review stage 引用) + +- unit:`exit_code=0` / `passed=32` / `failed=0` / `failed_list=[]`(10 test files)。 +- e2e:`exit_code=0` / `passed=4` / `failed=0` / `failed_list=[]`(chromium)。 +- 总判定:**全绿通过**,可进入 review。 diff --git a/docs/superpowers/reviews/2026-06-01-FE-01.md b/docs/superpowers/reviews/2026-06-01-FE-01.md new file mode 100644 index 0000000..17122f2 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-01-FE-01.md @@ -0,0 +1,53 @@ +# FE-01 登录页 — AI 自审报告(第 1 轮) + +- 阶段:前端(frontend) +- 关联 REQ:REQ-USR-004(主);配套 `GET /api/usr/companies` +- 关联原型:`prototype/erp.html` → `
` +- 规格:`docs/superpowers/specs/2026-06-01-FE-01.md` +- 裁决:**approve** +- must-fix issues:无(空数组) + +## 审阅范围 + +本轮 diff(自 `2dce637^` 起的 FE-01 提交链)落在 `frontend/` 内,作用域合规,未触碰 `backend/` / `sql/` / `scripts/`。核心文件: + +- `frontend/src/pages/usr/Login/LoginPage.tsx` +- `frontend/src/pages/usr/Login/Login.module.css` +- `frontend/src/pages/usr/Login/loginMessages.ts` +- `frontend/src/api/{request.ts,types.ts,usrApi.ts}` +- `frontend/src/store/slices/authSlice.ts`、`frontend/src/router/index.tsx`、`frontend/src/App.tsx`、`frontend/src/styles/theme.ts` + +## 质量闸(独立复跑验证) + +- `tsc --noEmit`:通过(exit 0)。 +- `vitest run`:10 文件 / 32 用例全绿(含布局、版本预加载/空态/重试、校验、提交中态、错误码分流、成功落地、authSlice、request 拦截器)。 +- `eslint .`:通过(exit 0)。 + +## 前端 7 维 checklist + +1. **原型一致性(客观)— 通过**:复刻 `.login-wrap` 三段式(品牌头 / 深蓝主视觉+右侧浮层登录卡 / 页脚版权);登录卡 `right:8% top:50% translateY(-50%)`、宽 380px、主操作为底部 `block` 主按钮,与原型 DOM 结构与主操作位置一致。下拉以 AntD `Select` 等价复刻原型 `.lf.dropdown`,属允许的实现差异。 +2. **Design Tokens(客观)— 通过**:语义色(按钮 / 文字 / 标题 / 占位 / 边框 / 背景 / 错误)全部走 `var(--color-*)`,无硬编码。`Login.module.css` 中的 hex/rgba 全部位于 `.hero` 主视觉装饰(深蓝渐变 / 网格透视 / hero 文案)及阴影 alpha,属规格 D7 明确豁免的「登录页局部装饰,不挪用语义 token、不新增全局 token」。`LoginPage.tsx` 内两处 `fill="#0e1216"` / `#3a6cb6` 为原型逐字复刻的品牌 Logo / ICP 图标 SVG 装饰填充,非语义 UI 色,归同一装饰豁免。`theme.ts` 的 `#1890ff` 为读取 `--color-primary` 失败时的兜底常量(= token 现值),运行时优先读 CSS 变量。token 优先于原型的色值精度差异处理正确(原型品牌名为琥珀色 `#e0a020`,实现改用 `--color-primary`,符合「tokens.css 优先于 prototype」)。 +3. **无障碍(best-effort)**:表单控件仅用 `placeholder`,无显式 `
`。 +> Design Tokens SSoT:`src/styles/tokens.css`。API 契约:`docs/05-API接口契约.md § REQ-USR-003`。 +> 审阅时间:2026-06-01。本报告由 fe-feature-review 渲染。 + +--- + +## 一、裁决 + +**approve**(无 must-fix)。 + +- 门禁回归(verify round=0):单测 138 passed / 0 failed;E2E 15 passed / 0 failed。 +- reviewer 复核:`npm run lint` 干净;FE-03 新增 7 个单测文件本地复跑 50 passed / 0 failed(stderr 仅 jsdom `getComputedStyle` rc-table 非致命警告)。 +- 作用域:全部改动落在 `frontend/`(含 docs/plans、docs/reviews),未触碰 `backend/` / `sql/` / `scripts/`,无越界。 + +--- + +## 二、通用四维 + +### 计划一致性(plan-alignment) +- 组件树(页面容器 + UserToolbar / UserFilterBar / UserTable / UserPager 内置于 Table)与 spec § 2 / plan 文件边界一致。 +- 列定义(序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废 / 登录日期 / 制单人 / 制单日期)与 REQ 输出表 1 + 原型 thead 顺序一致(`columns.tsx`)。 +- 业务规则 BR1–BR15 全部复刻(序号按当前页生成、作废只读 0/1→否/是、搜索回第 1 页、刷新保持当前页、清空重置默认、改页大小回第 1 页、行双击/新增导航、空态不报错、越界信任后端回显)。 +- 自主决策 D1–D10 + D-PLAN-1/2 均落地(范围下拉占位不传参 D2、更多/齿轮占位 D3/D7、pageSize 默认 10 上限 100 D4、前端零依赖 CSV 导出 D5/D-PLAN-1、页面就近 hook D6、单选标记不参与查询 D8、中文键归一 D9/D-PLAN-2、工具栏深色局部装饰 D10)。 + +### 质量(quality) +- 类型安全:`UserVO`/`PageResult`/`UserListQuery` 契约清晰;`as unknown` 桥接仅用于拦截器已解包语义,与 FE-01/FE-02 既有约定一致。 +- 错误处理:`useUserList.runFetch` 覆盖 42201(warning + 收敛分页重查)/ 40001(error + 保留条件不重查)/ 网络兜底(error + 错误占位重试);`ApiError` 分流文案与 spec § 4 错误码表逐条对齐。 +- 健壮性:`mountedRef` 卸载守卫避免异步 resolve/reject 后 setState;`queryRef` 镜像避免闭包读旧值。 +- 测试:状态机 6 态、各组件交互、CSV 导出、API 透传/归一均有单测 + E2E 固化。 + +### 架构(architecture) +- 分层清晰:页面容器 / 子组件 / 列定义 / hook / api / 常量 / 导出工具各司其职。 +- API 统一走 `frontend/src/api/request.ts` Axios 实例,方法集中于 `usrApi.ts listUsers`,页面不散用 axios(docs/04 § 2.3)。 +- 列表查询态留在页面本地 hook,不进全局 store(docs/04 § 2.2 / D6)。 + +### 文档(docs) +- 每文件首行带 `REQ-USR-003` 追溯注释 + BR/D 编号,符合 CLAUDE.md 注释约定。 +- 无 TODO/TBD/人工填写占位残留。 + +--- + +## 三、前端 7 维 checklist + +| 维度 | 结论 | 说明 | +|---|---|---| +| 1 原型一致性 | PASS | toolbar(刷新/新增/导出Excel/spacer/齿轮)、filterbar(范围下拉/字段下拉/匹配下拉/查询值/▾/搜索/清空)、grid-table(11 列 + 单选首列)、pager(统计+上/下页+当前页+每页条数)与 `#screen-userlist` 结构逐块对应;主操作(搜索按钮)位置、列顺序、分页右对齐均一致。AntD 组件化为允许的实现差异。 | +| 2 Design Tokens | PASS | 语义/状态色(primary/error/warning/success/text/secondary/border/table-header/table-row hover/selected/form-bg)全部走 `var(--color-*)`;硬编码 `#2c2f36`/`#ffffff` 仅用于工具栏深色装饰底,为 spec § 7 / D10 显式登记的非语义局部装饰,与已 approve 的 FE-02 § 8 D9(topbar `#1f1f23` 等)同源精度,tokens.css 无对应工具条品牌色 token,不构成 token 违规。 | +| 3 无障碍 | PASS(best-effort) | 齿轮占位带 `aria-label="设置"`;筛选控件用 AntD `Select`/`Input`(无 spec 要求的强制 label,原型亦无);危险/不可逆操作本页不存在(只读查询)。对比度为主观项,无明显失败。 | +| 4 响应式 | PASS(best-effort) | 表格 `scroll={{ x:'max-content' }}` 复刻原型横向滚动;filterbar `flex-wrap`;无明显横向溢出或 hover-only 关键操作。 | +| 5 业务校验前端复刻 | PASS | 查询字段/匹配方式枚举受限于 `Select` 合法项(BR4);查询值空为全部(BR3,listUsers 省略空 `queryValue`);默认值预填(BR2);错误码文案与后端语义一致(spec § 4)。 | +| 6 API 调用一致性 | PASS | `GET /usr/users`(baseURL=/api → `/api/usr/users`)与 docs/05 路径一致;query 字段 `queryField/matchType/queryValue/pageNum/pageSize` 与契约一致;`UserVO` 字段与契约一致(中文键 `员工名`/`部门` 在 api 层归一为 `employeeName`/`departmentName`,D9);统一客户端,无裸 fetch/axios。 | +| 7 状态机覆盖 | PASS | loading(初始 `useState(true)` + 取数中)/ empty(AntD `Empty`「暂无匹配的用户」)/ error(errorBox + 点击重试)/ 正常(表格行)/ 提交中(exporting:导出按钮 loading+disabled)五态俱全。 | + +--- + +## 四、非阻塞建议(不计入 must-fix) + +- **导出上限**:`useUserList.exportExcel` 将 `fetchSize` 收敛至 `≤100`(`Math.min(Math.max(total, pageSize, 1), 100)`),当 `total>100` 时仅导出前 100 行。此为 spec D5(confidence=medium,「MVP 阶段前端导出 … 一次或分批取」)显式登记的取舍,未做分页循环属已记录的 MVP 简化,非 spec 违规。后续若后端补 `/export` 端点或需求要求全量导出,可改为按页循环取。建议在用户量级增长后跟进,不阻塞本轮。 +- 工具栏深色装饰底(`#2c2f36`)目前在 `UserList.module.css` 与 FE-02 `AppLayout.module.css`(`#1f1f23`)各自硬编码。若后续多处工具条深色趋于一致,可考虑提一个非语义装饰变量集中管理;当前各页局部化处理与已批准的 FE-02 决策一致,不强制。 + +--- + +## 五、结论 + +实现忠实复刻原型布局与交互语义,真实对接 `GET /api/usr/users`,状态机完整、错误码分流到位、tokens 使用规范、API 契约一致、作用域无越界,门禁与 lint 全绿。无客观可验证的 must-fix 缺陷。 + +**verdict = approve** diff --git a/docs/superpowers/reviews/2026-06-01-FE-04-verify-r1.md b/docs/superpowers/reviews/2026-06-01-FE-04-verify-r1.md new file mode 100644 index 0000000..29a8a56 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-01-FE-04-verify-r1.md @@ -0,0 +1,105 @@ +# FE-04 用户信息单据 — 证据验证报告(round=1,第 1 轮 fix 后复验) + +> 阶段:前端(frontend)。验证对象:`FE-04`(关联 `REQ-USR-001` 增加用户 + `REQ-USR-002` 修改用户,scope=usr)。 +> 上游 spec:`docs/superpowers/specs/2026-06-01-FE-04.md`(产物文件名日期前缀 `2026-06-01` 取自 spec 文件名首段)。 +> 上游 TDD 摘要:第 1 轮 fix 后复验,上轮 review(`2026-06-01-FE-04.md`)裁决 request-changes,must-fix 1 项(B1:edit 态预填把用户主键当「用户号」查询,正常导航流必然取不到记录 → 40401)。 +> 验证时间:2026-06-02。本报告由 fe-feature-verify 渲染;主会话不自由编写测试,仅按测试命令的结构化结果落盘。 +> 测试以分离进程执行(非主会话交互式跑),仅运行 `frontend/` 下套件。 + +--- + +## 一、验证结论 + +**全部通过(PASS)**。单测与 E2E 两个门禁目标 `exit_code` 均为 0,`failed=0`,可进入 review。 + +| 目标 | 命令 | exit_code | passed | failed | 结论 | +|---|---|---|---|---|---| +| 单元测试(jsdom / Vitest) | `npx vitest run ` | 0 | 55(8 文件) | 0 | PASS | +| 端到端(Playwright) | `npx playwright test tests/e2e/userdetail.spec.ts` | 0 | 5 | 0 | PASS | + +> 命令来源:`docs/04-技术规范.md § 零 命令清单`(前端 unit=`npm run test:unit`=`vitest run`、e2e=`npm run test:e2e`=`playwright test`;`config-vars.yaml` 未覆盖测试命令,故按 docs/04 锁定值,未触发 flow 默认兜底 `pnpm test:ci`/`pnpm e2e:ci`)。本验证按 flow 取 FE-04 计划中 `测试先行类型=jsdom` 的 8 个单测文件拼 vitest 过滤模式、取 `测试先行类型=e2e` 的 `userdetail.spec.ts` 拼 Playwright 过滤模式,scope 收敛到 FE-04 新增/改动的测试。 +> 上轮 must-fix(B1)针对 edit 预填数据流,本轮 fix 改动落在 `frontend/src/pages/usr/UserDetail/` 与对应单测(`useUserDetail.test.tsx`、`UserDetailPage.test.tsx`),复验后涉及 edit 预填的用例(单测 `useUserDetail`/`UserDetailPage` 的 edit 分支、E2E `edit user prefill then save`)全部通过。 + +--- + +## 二、单元测试(jsdom / Vitest) + +- 命令:`cd frontend && npx vitest run tests/unit/usrApi.userdetail.test.ts tests/unit/userDetailMappers.test.ts tests/unit/useUserDetail.test.tsx tests/unit/UserBasicForm.test.tsx tests/unit/PermissionGroupList.test.tsx tests/unit/PermissionTabs.test.tsx tests/unit/UserDetailToolbar.test.tsx tests/unit/UserDetailPage.test.tsx` +- exit_code:`0` +- 结果:`Test Files 8 passed (8)` / `Tests 55 passed (55)` +- failed_list:(空) +- FE-04 的 8 个单测文件逐个通过: + - `tests/unit/usrApi.userdetail.test.ts`(6 用例:createUser/updateUser/getUserDetail/listEmployees/listPermissions 端点透传与 Result 拆包) + - `tests/unit/userDetailMappers.test.ts`(4 用例:EmployeeOption/PermissionItem/UserVO→表单值映射) + - `tests/unit/useUserDetail.test.tsx`(10 用例:状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError + create/edit 分支,含 edit 预填) + - `tests/unit/UserBasicForm.test.tsx`(9 用例:BR1-BR8 字段渲染/只读/默认值/必填校验/员工联动) + - `tests/unit/PermissionGroupList.test.tsx`(8 用例:BR10/BR11 权限项渲染/全选 indeterminate/勾选集合/edit 回勾) + - `tests/unit/PermissionTabs.test.tsx`(3 用例:权限组 active + 5 个占位页签 D9) + - `tests/unit/UserDetailToolbar.test.tsx`(5 用例:保存/取消/新增 + 占位按钮 D8) + - `tests/unit/UserDetailPage.test.tsx`(10 用例:create 默认值/edit 预填/提交成功回流/用户名冲突就近高亮/dirty 取消二次确认/loadError 重试) + +### stdout 摘录(≤30 行) + +``` + ✓ tests/unit/userDetailMappers.test.ts (4 tests) 3ms + ✓ tests/unit/usrApi.userdetail.test.ts (6 tests) 3ms + ✓ tests/unit/PermissionTabs.test.tsx (3 tests) 137ms + ✓ tests/unit/PermissionGroupList.test.tsx (8 tests) 154ms + ✓ tests/unit/UserDetailToolbar.test.tsx (5 tests) 163ms + ✓ tests/unit/useUserDetail.test.tsx (10 tests) 603ms + ✓ tests/unit/UserBasicForm.test.tsx (9 tests) 751ms + ✓ tests/unit/UserDetailPage.test.tsx (10 tests) 1651ms + + Test Files 8 passed (8) + Tests 55 passed (55) + Duration 3.26s +``` + +> 说明:日志中 `window.getComputedStyle(elt, pseudoElt) Not implemented`(来自 rc-util measureScrollbarSize / AntD Modal 滚动锁)、`act(...)` 包裹建议(rc-field-form 异步校验态)、React Router v7 future flag 等均为 jsdom/依赖侧非致命 stderr 警告,不影响断言,全部用例仍判定通过(`exit_code=0`,`55 passed`)。 + +--- + +## 三、端到端测试(Playwright) + +- 命令:`cd frontend && npx playwright test tests/e2e/userdetail.spec.ts` +- exit_code:`0` +- 结果:`5 passed (5.4s)` +- failed_list:(空) +- FE-04 `tests/e2e/userdetail.spec.ts` 5 用例全过: + - `create user and return to list`(新增提交成功回流列表 BR12/BR16) + - `edit user prefill then save`(edit 预填原值 + 保存 BR17/BR11;对应上轮 B1 数据流修复点) + - `username conflict shows inline error`(40901 用户名冲突就近报错) + - `placeholder tabs/buttons are inert`(占位页签/按钮惰性 D8/D9) + - `load error shows retry`(loadError 取数失败重试入口) + +### stdout 摘录(≤30 行) + +``` +Running 5 tests using 5 workers + ✓ 1 [chromium] › tests/e2e/userdetail.spec.ts:170:3 › 用户单据关键旅程 › placeholder tabs/buttons are inert (904ms) + ✓ 5 [chromium] › tests/e2e/userdetail.spec.ts:126:3 › 用户单据关键旅程 › username conflict shows inline error (1.2s) + ✓ 3 [chromium] › tests/e2e/userdetail.spec.ts:97:3 › 用户单据关键旅程 › edit user prefill then save (1.3s) + ✓ 4 [chromium] › tests/e2e/userdetail.spec.ts:69:3 › 用户单据关键旅程 › create user and return to list (1.3s) + ✓ 2 [chromium] › tests/e2e/userdetail.spec.ts:146:3 › 用户单据关键旅程 › load error shows retry (4.3s) + + 5 passed (5.4s) +``` + +> 说明:WebServer 日志中 `[vite] http proxy error ... ECONNREFUSED /api/usr/users` 为预期噪声——E2E 在路由层 mock API 响应,开发后端(`config-vars.yaml backend.http_port=5172`)未启动,Vite proxy 透传请求被拒;用例不依赖真实后端,5 个用例全部判定通过(`exit_code=0`,`5 passed`),与 FE-01/FE-02/FE-03 验证及本 FE round=0 验证同型非致命基建告警。 + +--- + +## 四、作用域校验 + +- 验证仅运行 `frontend/` 下的测试套件(`frontend/tests/unit/*`(FE-04 8 文件)+ `frontend/tests/e2e/userdetail.spec.ts`);未触碰 `backend/` / `sql/` / `scripts/`,作用域校验通过。 +- 本报告落盘于 `docs/superpowers/reviews/`(与 review 报告同目录),round=1 → 文件名后缀 `-verify-r1.md`,与 round=0 的 `-verify.md` 各自独立、不覆盖前轮,符合命名约定。 + +--- + +## 五、自主决策记录(decisions) + +| # | 问题 | 选择 | 依据 | 置信度 | +|---|---|---|---|---| +| V1 | 测试命令默认值取 `pnpm test:ci`/`pnpm e2e:ci` 还是 docs/04 锁定值 | 取 `docs/04-技术规范.md § 零` 锁定的前端命令(unit=`npm run test:unit`=`vitest run`、e2e=`npm run test:e2e`=`playwright test`,`frontend/package.json` scripts 实测存在;`config-vars.yaml` 未覆盖测试命令) | flow 明确「命令从 docs/04 § 零取,缺失才用默认」;本仓 docs/04 § 零已锁定前端命令,默认值仅缺失兜底,此处不缺失 | high | +| V2 | 单测/E2E 跑全量套件还是 FE-04 过滤模式 | 按 flow 取 FE-04 计划中 `测试先行类型=jsdom` 的 8 个单测文件拼 vitest 过滤、取 `=e2e` 的 `userdetail.spec.ts` 拼 Playwright 过滤,scope 收敛到本 FE | flow 明确「从 plan 取 `测试先行类型=jsdom` 的 test_file 拼 vitest 过滤、`=e2e` 的拼 Playwright spec 过滤」 | high | +| V3 | E2E 运行中 `ECONNREFUSED /api/usr/users` proxy 报错是否判失败 | 判通过(非致命基建噪声) | E2E 在路由层 mock 响应,不依赖真实后端;Playwright 汇总 `5 passed` 且进程 `exit_code=0`,proxy 噪声来自 Vite dev server 透传未启动的后端端口,与用例断言无关,与历次验证同型处理 | high | diff --git a/docs/superpowers/reviews/2026-06-01-FE-04-verify.md b/docs/superpowers/reviews/2026-06-01-FE-04-verify.md new file mode 100644 index 0000000..42e0a36 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-01-FE-04-verify.md @@ -0,0 +1,101 @@ +# FE-04 用户信息单据 — 证据验证报告(round=0) + +> 阶段:前端(frontend)。验证对象:`FE-04`(关联 `REQ-USR-001` 增加用户 + `REQ-USR-002` 修改用户,scope=usr)。 +> 上游 spec:`docs/superpowers/specs/2026-06-01-FE-04.md`。 +> 验证时间:2026-06-01。本报告由 fe-feature-verify 渲染;主会话不自由编写测试,仅按测试命令的结构化结果落盘。 +> 测试以后台分离进程执行(非主会话交互式跑),仅运行 `frontend/` 下套件。 + +--- + +## 一、验证结论 + +**全部通过(PASS)**。单测与 E2E 两个门禁目标 `exit_code` 均为 0,`failed=0`,可进入 review。 + +| 目标 | 命令 | exit_code | passed | failed | 结论 | +|---|---|---|---|---|---| +| 单元测试(jsdom / Vitest) | `npx vitest run ` | 0 | 55(8 文件) | 0 | ✅ PASS | +| 端到端(Playwright) | `npx playwright test tests/e2e/userdetail.spec.ts` | 0 | 5 | 0 | ✅ PASS | + +> 命令来源:`docs/04-技术规范.md § 零 命令清单`(前端 unit=`npm run test:unit`=`vitest run`、e2e=`npm run test:e2e`=`playwright test`)。本验证按 flow 取 FE-04 的 jsdom 单测文件拼 vitest 过滤模式、取 e2e spec 拼 Playwright 过滤模式,scope 收敛到 FE-04 新增/改动的测试。全量门禁(193 passed / 40 文件单测、20 passed E2E)已在上游 T10 回归通过(提交 32f94ad)。 + +--- + +## 二、单元测试(jsdom / Vitest) + +- 命令:`cd frontend && npx vitest run tests/unit/usrApi.userdetail.test.ts tests/unit/userDetailMappers.test.ts tests/unit/useUserDetail.test.tsx tests/unit/UserBasicForm.test.tsx tests/unit/PermissionGroupList.test.tsx tests/unit/PermissionTabs.test.tsx tests/unit/UserDetailToolbar.test.tsx tests/unit/UserDetailPage.test.tsx` +- exit_code:`0` +- 结果:`Test Files 8 passed (8)` / `Tests 55 passed (55)` +- failed_list:(空) +- FE-04 的 8 个单测文件逐个通过: + - `tests/unit/usrApi.userdetail.test.ts`(6 用例:createUser/updateUser/getUserDetail/listEmployees/listPermissions 端点透传与 Result 拆包) + - `tests/unit/userDetailMappers.test.ts`(4 用例:EmployeeOption/PermissionItem/UserVO→表单值映射) + - `tests/unit/useUserDetail.test.tsx`(10 用例:状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError + create/edit 分支) + - `tests/unit/UserBasicForm.test.tsx`(9 用例:BR1-BR8 字段渲染/只读/默认值/必填校验/员工联动) + - `tests/unit/PermissionGroupList.test.tsx`(8 用例:BR10/BR11 权限项渲染/全选 indeterminate/勾选集合/edit 回勾) + - `tests/unit/PermissionTabs.test.tsx`(3 用例:权限组 active + 5 个占位页签 D9) + - `tests/unit/UserDetailToolbar.test.tsx`(5 用例:保存/取消/新增 + 占位按钮 D8) + - `tests/unit/UserDetailPage.test.tsx`(10 用例:create 默认值/edit 预填/提交成功回流/用户名冲突就近高亮/dirty 取消二次确认/loadError 重试) + +### stdout 摘录(≤30 行) + +``` + ✓ tests/unit/userDetailMappers.test.ts (4 tests) 2ms + ✓ tests/unit/usrApi.userdetail.test.ts (6 tests) 3ms + ✓ tests/unit/PermissionTabs.test.tsx (3 tests) 127ms + ✓ tests/unit/PermissionGroupList.test.tsx (8 tests) 145ms + ✓ tests/unit/UserDetailToolbar.test.tsx (5 tests) 191ms + ✓ tests/unit/useUserDetail.test.tsx (10 tests) 599ms + ✓ tests/unit/UserBasicForm.test.tsx (9 tests) 761ms + ✓ tests/unit/UserDetailPage.test.tsx (10 tests) 1406ms + + Test Files 8 passed (8) + Tests 55 passed (55) +``` + +> 说明:日志中 `window.getComputedStyle(elt, pseudoElt) Not implemented`(来自 rc-util measureScrollbarSize / AntD Modal 滚动锁)、`act(...)` 包裹建议(rc-field-form 异步校验态)、React Router v7 future flag 等均为 jsdom/依赖侧非致命 stderr 警告,不影响断言,全部用例仍判定通过(`exit_code=0`,`55 passed`)。 + +--- + +## 三、端到端测试(Playwright) + +- 命令:`cd frontend && npx playwright test tests/e2e/userdetail.spec.ts` +- exit_code:`0` +- 结果:`5 passed (5.2s)` +- failed_list:(空) +- FE-04 `tests/e2e/userdetail.spec.ts` 5 用例全过: + - `create user and return to list`(新增提交成功回流列表 BR12/BR16) + - `edit user prefill then save`(edit 预填原值 + 保存 BR17/BR11) + - `username conflict shows inline error`(40901 用户名冲突就近报错) + - `placeholder tabs/buttons are inert`(占位页签/按钮惰性 D8/D9) + - `load error shows retry`(loadError 取数失败重试入口) + +### stdout 摘录(≤30 行) + +``` + ✓ 2 [chromium] › tests/e2e/userdetail.spec.ts:170:3 › 用户单据关键旅程 › placeholder tabs/buttons are inert (729ms) + ✓ 3 [chromium] › tests/e2e/userdetail.spec.ts:126:3 › 用户单据关键旅程 › username conflict shows inline error (1.1s) + ✓ 4 [chromium] › tests/e2e/userdetail.spec.ts:97:3 › 用户单据关键旅程 › edit user prefill then save (1.1s) + ✓ 5 [chromium] › tests/e2e/userdetail.spec.ts:69:3 › 用户单据关键旅程 › create user and return to list (1.1s) + ✓ 1 [chromium] › tests/e2e/userdetail.spec.ts:146:3 › 用户单据关键旅程 › load error shows retry (4.1s) + + 5 passed (5.2s) +``` + +> 说明:WebServer 日志中 `[vite] http proxy error ... ECONNREFUSED /api/usr/users` 为预期噪声——E2E 在路由层 mock API 响应,开发后端(`config-vars.yaml backend.http_port=5172`)未启动,Vite proxy 透传请求被拒;用例不依赖真实后端,5 个用例全部判定通过(`exit_code=0`,`5 passed`),与 FE-03 验证同型非致命基建告警。 + +--- + +## 四、作用域校验 + +- 验证仅运行 `frontend/` 下的测试套件(`frontend/tests/unit/*` + `frontend/tests/e2e/userdetail.spec.ts`);未触碰 `backend/` / `sql/` / `scripts/`,作用域校验通过。 +- 本报告落盘于 `docs/superpowers/reviews/`(与 review 报告同目录),round=0 → 文件名后缀 `-verify.md`,符合命名约定。 + +--- + +## 五、自主决策记录(decisions) + +| # | 问题 | 选择 | 依据 | 置信度 | +|---|---|---|---|---| +| V1 | 测试命令默认值取 `pnpm test:ci`/`pnpm e2e:ci` 还是 docs/04 锁定值 | 取 `docs/04-技术规范.md § 零` 锁定的前端命令(unit=`npm run test:unit`=`vitest run`、e2e=`npm run test:e2e`=`playwright test`,`frontend/package.json` scripts 实测存在) | flow 明确「命令从 docs/04 § 零取,缺失才用默认」;本仓 docs/04 § 零已锁定前端命令,默认值仅缺失兜底,此处不缺失 | high | +| V2 | 单测/ E2E 跑全量套件还是 FE-04 过滤模式 | 按 flow 取 FE-04 的 jsdom 单测 8 文件拼 vitest 过滤、取 `userdetail.spec.ts` 拼 Playwright 过滤,scope 收敛到本 FE | flow 明确「从 plan 取 `测试先行类型=jsdom` 的 test_file 拼 vitest 过滤、`=e2e` 的拼 Playwright spec 过滤」;全量门禁(193/40 单测、20 E2E)已由上游 T10 回归通过(32f94ad),本轮做 FE-04 范围确认 | high | +| V3 | E2E 运行中 `ECONNREFUSED /api/usr/users` proxy 报错是否判失败 | 判通过(非致命基建噪声) | E2E 在路由层 mock 响应,不依赖真实后端;Playwright 汇总 `5 passed` 且进程 `exit_code=0`,proxy 噪声来自 Vite dev server 透传未启动的后端端口,与用例断言无关,与 FE-03 验证同型处理 | high | diff --git a/docs/superpowers/reviews/2026-06-01-FE-04.md b/docs/superpowers/reviews/2026-06-01-FE-04.md new file mode 100644 index 0000000..088eb96 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-01-FE-04.md @@ -0,0 +1,63 @@ +# FE-04 用户信息单据 — AI 代码评审报告(第 2 轮) + +- 规格:`docs/superpowers/specs/2026-06-01-FE-04.md` +- 阶段:前端(frontend) +- 关联 REQ:REQ-USR-001(增加用户)/ REQ-USR-002(修改用户) +- 关联原型:`prototype/erp.html` `
` +- 本轮 fix commit:`96e88d3`(编辑预填走 navigate state + 补 loadError 返回列表入口) +- 裁决:**approve** + +--- + +## 一、作用域核查(通过) + +本轮 diff 实现文件全部落在 `frontend/` 下(`src/pages/usr/UserDetail/`、`src/pages/usr/UserList/index.tsx`、`tests/unit/`),未触碰 `backend/` / `sql/` / `scripts/`,符合前端阶段路径作用域硬约束。工作树干净(fix 已 commit)。 + +## 二、上轮 must-fix 复验(核心维度) + +### B1(上轮 blocker)— 已修复 ✔ + +上轮判定:edit 态预填把路由主键 `:id` 当「用户号」去 `GET /api/usr/users?queryField=用户号` 精确匹配,真实后端必然返回空 → 误报 40401,无法满足 REQ-USR-002 编辑预填(BR17)。 + +本轮 fix(commit `96e88d3`)按上轮修复方向 #1 落地,复验确认到位: + +- `frontend/src/pages/usr/UserList/index.tsx:36` 双击行改为 `navigate('/usr/users/' + row.id, { state: { user: row } })`,把列表行 `UserVO` 经 navigate state 透传。 +- `frontend/src/pages/usr/UserDetail/index.tsx:43` 从 `location.state.user` 取 `presetUser` 传入 hook。 +- `frontend/src/pages/usr/UserDetail/useUserDetail.ts:116-131` edit 分支改为:有 `presetUser` → `initFromVo` 回填;缺 `presetUser`(直链访问 / 刷新丢 state)→ 进入 `loadError`(`loadFailed=true` + `MSG_LOAD_DETAIL_FAIL`),**不再**调 `getUserDetail`、不再把主键塞进「用户号」查询。原 `getUserDetail` import 与 `notFound` 状态已彻底移除。 +- `frontend/src/pages/usr/UserDetail/index.tsx:108-128` loadError 整页态在 edit 模式额外提供「返回列表」入口(spec § 4「edit 详情失败给整页重试或返回列表」),替代原误导性的 40401「该用户不存在」页。 +- 残留核查(grep):`src/` 内已无 `getUserDetail` 调用方(仅 `usrApi.ts` 保留导出,见 S1)、无 `notFound` 状态、无「按主键查用户号」路径;`MSG_ERR_USER_NOT_FOUND` 仅在 **submit** 路径(`useUserDetail.ts:225`,PUT 返回 40401)保留使用,符合 spec § 4「edit submit 可命中 40401」,非误用。 + +测试同步更新且通过: +- `tests/unit/useUserDetail.test.tsx`:新增「edit 缺 presetUser → loadFailed 且不调 getUserDetail」、保留「edit 带 presetUser 跳过 getUserDetail」。 +- `tests/unit/UserDetailPage.test.tsx`:edit 预填用例改为经 navigate state 注入 `presetUser` 并断言 `mockedDetail` 未被调用;新增「edit 缺 state → loadError 提供 点击重试 + 返回列表」。 +- `tests/e2e/userdetail.spec.ts:97` `edit user prefill then save`:经列表双击(携带 state)预填→改语言→PUT 保存→回流列表,通过。 + +> 结论:B1 已被真实修复(非掩盖),数据流自洽,且测试改造正确反映了「主键无 by-id 读端点、预填依赖列表行 state」这一事实约束。 + +## 三、本轮门禁证据(复跑确认) + +- `npm run lint`:exit 0(无 error/warning)。 +- `npx tsc --noEmit`:exit 0(含 `renderShell.tsx` 新增 `ShellInitialEntry` 本地类型,类型自洽)。 +- `npm run test:unit`:40 文件 / 193 用例全通过(FE-04 相关 UserDetailPage 10、useUserDetail、UserDetailToolbar 等全绿)。 +- `npm run test:e2e -- userdetail.spec.ts`:5 passed / 0 failed。ECONNREFUSED 为 route 层 mock 下的预期 vite proxy 噪声,不影响判定。 + +## 四、对照确认(七维 + 通用维,沿用上轮通过项,本轮 diff 未改动者不再赘述) + +- 原型一致性:工具栏 / 3 列 form-grid / tabs-row(权限组 active + 5 占位页签 disabled)/ perm-list 结构与 `#screen-userdetail` 一致,主操作「保存」置工具栏,无结构性偏移;本轮 diff 未触碰布局。 +- Design Tokens:语义色仍走 `var(--color-*)`;本轮 diff 未引入新色值。 +- a11y(客观项):表单控件经 AntD `Form.Item label` 关联;「取消」脏表单二次确认(`Modal.confirm`)保留;loadError「返回列表」为标准 `Button`,键盘可达。 +- 业务校验前端复刻:BR3/BR4/BR6/BR7/BR8/BR5/BR9/BR11 与错误码 40001/40901/40401/40301 文案及就近高亮均保持。 +- API 调用一致性:create/update/listEmployees/listPermissions 经统一 `request.ts` 实例,无裸 fetch/axios;请求体字段对齐 docs/05。本轮去掉了 edit 预填对列表端点的误用,API 调用面更收敛。 +- 状态机覆盖:loading / empty / error(loadError 重试 + edit 返回列表)/ 正常 / 提交中 五态齐备;edit 缺 state 归入 loadError,语义比上轮 40401 更准确。 + +## 五、建议项(非 must-fix,可后续消化,不影响本轮 approve) + +- S1(沿上轮 S1,已部分自然消解):`frontend/src/api/usrApi.ts:107` `getUserDetail` 现已无生产调用方(仅测试 mock 中作为占位导出)。可在后续清理为「删除该函数」或「待后端补单用户详情/按主键定位端点后复用」。当前保留不构成缺陷(lint/tsc 均不报未用导出)。 +- S2:`userVoToFormValues`(`constants.ts:146`)因 `UserVO`(FE-03 列表 VO)不含 `iEmployeeId` / `iCanModifyBill` / 已授权权限 id,edit 预填时这三项被置默认(`iEmployeeId=null` / `iCanModifyBill=0` / `checkedPermissionIds=[]`)。这是 spec D4 已登记的数据模型限制(列表 VO 为唯一可用源),非本轮回归;待后端补单用户详情读端点后可完整回勾。已属 spec 决策范畴,不作 must-fix。 +- S3(沿上轮 S3):`useUserDetail.ts:143` catch 文案分支读 `permissions.length` 为闭包旧值,仅影响 loadError 文案精度,不影响功能。 + +## 六、裁决 + +上轮唯一 blocker(B1)已在本轮 diff 中真实修复,数据流自洽、测试改造正确、门禁四项(lint / tsc / unit / e2e)全绿,未引入新缺陷、未回归既有通过项。其余为可选建议项,均不构成 must-fix。 + +**verdict: approve** diff --git a/docs/superpowers/specs/2026-06-01-FE-01.md b/docs/superpowers/specs/2026-06-01-FE-01.md new file mode 100644 index 0000000..7e32241 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-FE-01.md @@ -0,0 +1,163 @@ +# FE-01 登录页 — 实现规格(前端) + +> 阶段:前端(frontend)。作用域限定 `frontend/` 下的页面 / 组件 / 路由 / store / api / 样式。 +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-004.md`;原型 `prototype/erp.html`(`#screen-login` 区域,布局/交互权威);API 契约 `docs/05-API接口契约.md` § REQ-USR-004;技术规范 `docs/04-技术规范.md` § 零 / § 二;Design Tokens `src/styles/tokens.css`。 +> 本规格只消费已锁定事实。后端身份认证、BCrypt 比对、JWT 签发、限流等业务逻辑全部在后端(见 REQ-USR-004 后端规格),前端只负责采集输入、提交、依据响应/错误码渲染状态与文案。 + +--- + +## 1. 关联 REQ + 关联原型 + +| 维度 | 内容 | +|---|---| +| 业务功能 | FE-01 登录页(用户名/密码/版本下拉登录) | +| 关联 REQ | REQ-USR-004 登录用户(主);其「版本」下拉数据依赖后端 `GET /api/usr/companies`(REQ-USR-004 后端补齐的配套只读端点) | +| 关联原型 | `prototype/erp.html` → `
`(含 `.login-wrap` / `.login-head` / `.login-hero` / `.login-card` / `.login-foot`) | +| 路由 | `/login`(React Router v6)。登录成功后跳转主页落地路由(属 FE-02 范畴,本页只负责导航跳转动作,目标路径默认 `/`,见 § 7 决策 D3) | +| 落地组件目录 | `frontend/src/pages/usr/Login/`(页面);登录态写入 `frontend/src/store/slices/authSlice`;接口走 `frontend/src/api/usrApi.ts` + `frontend/src/api/request.ts` | + +> 原型 `#screen-login` 用纯静态 HTML + 内联 demo 脚本(`goTo('login')` 默认进登录页、`.submit[data-go=main]` 点击直接切主页、`#ver-drop` 点击切换 `.open` 展开版本项)模拟交互。本规格按 React + AntD 5 复刻其**布局与交互语义**,但表单校验、提交、下拉取数、错误反馈改为真实对接后端。 + +--- + +## 2. 组件树(按区域分块,推导自 prototype DOM) + +页面根 `LoginPage`(路由 `/login` 挂载),结构对应原型 `.login-wrap`(占满视口、纵向 flex:头部 / 主视觉 / 页脚): + +``` +LoginPage (容器,对应 .login-wrap:position 占满、flex column、背景 --color-bg-base) +├── LoginHeader (对应 .login-head:左 Logo SVG + 品牌名「Antler ERP」+ 副标题「欢迎登录EBC平台」) +│ ├── BrandLogo (鹿角 SVG,复用原型 inline svg path) +│ ├── BrandName ("Antler ERP") +│ └── BrandSub ("欢迎登录EBC平台") +├── LoginHero (对应 .login-hero:占满剩余高度的主视觉区,深蓝渐变 + 网格透视背景) +│ ├── HeroText (对应 .login-text:英文标语 / 中文「企业业务能力平台」/ 巨型 "ERP") +│ └── LoginCard (对应 .login-card:右侧浮层登录卡片,AntD
容器) +│ ├── CardTitle ("用户登录") +│ └── LoginForm (AntD ,提交触发认证) +│ ├── Form.Item[sUserName] → 占位「请输入你的用户名」 +│ ├── Form.Item[password] → 占位「请输入你的密码」(输入显示星号) +│ ├── Form.Item[companyId] → 「全部用户」:用户范围下拉,原型 demo 项,见 § 8 决策 D2 —— 默认不映射后端参数) +│ ├── QueryFieldSelect(对应第 2 个 「包含」:匹配方式下拉单选,options=包含/不包含/等于,默认「包含」→ 提交 matchType) +│ ├── QueryValueInput(对应 .filterbar :查询值文本框,空为查询全部 → 提交 queryValue) +│ ├── FilterMoreToggle(对应 .filterbar .down「▾」:更多条件占位,见 § 8 决策 D3) +│ ├── BtnSearch(对应 .filterbar .btn「搜索」:以当前条件回到第 1 页发起查询,见 § 5 BR7) +│ └── BtnClear(对应 .filterbar .btn.ghost「⊗ 清空」:重置筛选为默认值并回到第 1 页全量查询,见 § 5 BR10) +├── UserTable(对应 .table-shell > table.grid-table#user-table:可横向滚动用户表格) +│ ├── RadioColumn(对应 thead 首列空 + tbody .radio-cell .radio-dot:行选择单选标记,见 § 8 决策 D8) +│ ├── 列:序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废 / 登录日期 / 制单人 / 制单日期(对应 REQ 输出表 1 + 原型 thead) +│ └── Row[](对应 tbody#user-tbody 各 ;双击行 → 跳转 FE-04 修改单据 `/usr/users/:id`,见 § 5 BR12) +└── UserPager(对应 .pager:右对齐分页条) + ├── PageSummary(对应「共 N 条记录」统计文案,来源 PageResult.total) + ├── Pager(对应 ‹ / 当前页 / › :上一页 / 当前页 / 下一页) + └── PageSizeSelect(对应 .pager } + placeholder="请输入你的用户名" + size="large" + autoComplete="username" + /> + + + + } + placeholder="请输入你的密码" + size="large" + autoComplete="current-password" + /> + + + + ({ value: e.value, label: e.label }))} + onChange={(v) => onSelectEmployee((v as number | undefined) ?? null)} + virtual={false} + data-testid="select-employee" + /> + + + {/* 用户名(create 可编辑必填 + 格式校验;edit 只读,BR3) */} + + + + + {/* 类型(Select 枚举,create 默认普通用户,BR6) */} + + + + + {/* 用户号(必填,BR4,可由员工名联动带出,BR5) */} + + + + + {/* 单据修改权限(Checkbox,默认否,BR8) */} + + + + + ); +} diff --git a/frontend/src/pages/usr/UserDetail/UserDetail.module.css b/frontend/src/pages/usr/UserDetail/UserDetail.module.css new file mode 100644 index 0000000..9086720 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/UserDetail.module.css @@ -0,0 +1,137 @@ +/* REQ-USR-001 / REQ-USR-002: 用户单据页 scoped 样式。 + 语义色只用 var(--color-*);工具栏深色底为页面局部装饰(非语义 token,D10,与 FE-02/FE-03 一致)。 */ + +.page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-base); +} + +/* === 工具栏:深色底为页面局部装饰(D10) === */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #2c2f36; +} + +.toolbarSpacer { + flex: 1 1 auto; +} + +.toolbar :global(.ant-btn) { + color: #ffffff; +} + +.toolbar :global(.ant-btn-primary) { + color: #ffffff; +} + +.gear { + color: #ffffff; + font-size: 16px; + cursor: pointer; + padding: 0 8px; +} + +/* === 表单网格:3 列白底 === */ +.formGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0 16px; + padding: 16px 12px; + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); + color: var(--color-form-fg); +} + +.readonlyField { + display: flex; + align-items: center; + min-height: 32px; + padding: 0 11px; + background: var(--color-form-bg-readonly); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-form-fg); +} + +.formGrid :global(.ant-form-item-label > label) { + color: var(--color-text); +} + +/* === 权限页签条 === */ +.permTabs { + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); +} + +.permTabs :global(.ant-tabs-tab) { + color: var(--color-text); +} + +.permTabs :global(.ant-tabs-tab-disabled) { + color: var(--color-text-secondary); +} + +.permTabs :global(.ant-tabs-ink-bar) { + background: var(--color-primary); +} + +/* === 权限分类勾选列表 === */ +.permList { + flex: 1 1 auto; + overflow: auto; + background: var(--color-form-bg-edit); +} + +.permHead { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-table-header-bg); + color: var(--color-table-header-fg); + border-bottom: 1px solid var(--color-border); +} + +.permHeadSort { + margin-left: auto; + color: var(--color-text-secondary); +} + +.permRow { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + color: var(--color-text); + border-bottom: 1px solid var(--color-border); +} + +.permRow:hover { + background: var(--color-table-row-bg-hover); +} + +.permEmpty { + padding: 24px 12px; + color: var(--color-text-secondary); + text-align: center; +} + +/* === 取数失败占位 === */ +.loadError { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 0; + color: var(--color-text-secondary); +} + +.loadErrorText { + color: var(--color-error); +} diff --git a/frontend/src/pages/usr/UserDetail/UserDetailToolbar.tsx b/frontend/src/pages/usr/UserDetail/UserDetailToolbar.tsx new file mode 100644 index 0000000..9b4ed18 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/UserDetailToolbar.tsx @@ -0,0 +1,91 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据工具栏(保存/取消/新增 + 占位按钮 + 齿轮,BR12/BR13/BR14/BR15/D8/D10) +import { Button, App as AntdApp } from 'antd'; +import { + SaveOutlined, + CloseCircleOutlined, + PlusCircleOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { + TEXT_SAVE, + TEXT_CANCEL, + TEXT_NEW, + TEXT_DELETE, + TEXT_VOID, + TEXT_RESET_PWD, + TEXT_UNVOID, + TEXT_FUNC, + MSG_FUNC_PLACEHOLDER, +} from './constants'; +import styles from './UserDetail.module.css'; + +export interface UserDetailToolbarProps { + mode: 'create' | 'edit'; + submitting: boolean; + canSave: boolean; + onSave(): void; + onCancel(): void; + onNew(): void; +} + +const PLACEHOLDER_BUTTONS = [TEXT_DELETE, TEXT_VOID, TEXT_RESET_PWD, TEXT_UNVOID, TEXT_FUNC]; + +export default function UserDetailToolbar({ + submitting, + canSave, + onSave, + onCancel, + onNew, +}: UserDetailToolbarProps) { + const { message } = AntdApp.useApp(); + const placeholder = () => message.info(MSG_FUNC_PLACEHOLDER); + + return ( +
+ + + + + {PLACEHOLDER_BUTTONS.map((label) => ( + + ))} + + + + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserDetail/constants.ts b/frontend/src/pages/usr/UserDetail/constants.ts new file mode 100644 index 0000000..7c15105 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/constants.ts @@ -0,0 +1,156 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据页合同级常量(枚举/默认/正则/错误码/文案 + 提交映射纯函数) +import type { + UserCreateReq, + UserUpdateReq, + UserVO, + UserDetailMode, +} from '../../../api/types'; + +// === mode 常量(由路由 :id 判定) === +export const MODE_CREATE: UserDetailMode = 'create'; +export const MODE_EDIT: UserDetailMode = 'edit'; + +// === 枚举(逐字一致,原样作为提交值,前端不映射,由后端裁决) === +/** 用户类型枚举,create 默认「普通用户」(BR6) */ +export const USER_TYPE_OPTIONS = ['普通用户', '超级管理员'] as const; +/** 语言枚举(BR7,无默认强制选,create 必选) */ +export const LANGUAGE_OPTIONS = ['中文', '英文', '繁体'] as const; + +/** + * 受控表单值(spec § 6;`tCreateDate`/`sCreator` 只读展示态另存,不在提交值内)。 + */ +export interface UserFormValues { + sUserName: string; + sUserNo: string; + iEmployeeId: number | null; + sUserType: string; + sLanguage: string | undefined; + iCanModifyBill: 0 | 1; + iIsVoid?: 0 | 1; +} + +/** create 默认表单值(BR1/BR2/BR6/BR8;sLanguage 未选触发必填校验 BR7) */ +export const CREATE_DEFAULTS: UserFormValues = { + sUserName: '', + sUserNo: '', + iEmployeeId: null, + sUserType: '普通用户', + sLanguage: undefined, + iCanModifyBill: 0, + iIsVoid: 0, +}; + +/** 用户名前置校验正则(3-20 位字母数字下划线,BR3,对齐 docs/05 § REQ-USR-001) */ +export const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,20}$/; + +// === 错误码常量(对齐 docs/05 § REQ-USR-001 / § REQ-USR-002 / spec § 4) === +/** 参数校验失败 */ +export const ERR_VALIDATION = 40001; +/** 用户名已存在(仅 create) */ +export const ERR_USERNAME_EXISTS = 40901; +/** 用户不存在(仅 edit) */ +export const ERR_USER_NOT_FOUND = 40401; +/** 无权限 */ +export const ERR_NO_PERMISSION = 40301; + +// === 静态文案(逐字一致,复刻原型 / spec) === +export const TEXT_SAVE = '保存'; +export const TEXT_CANCEL = '取消'; +export const TEXT_NEW = '新增'; +export const TEXT_DELETE = '删除'; +export const TEXT_VOID = '作废'; +export const TEXT_RESET_PWD = '重置密码'; +export const TEXT_UNVOID = '取消作废'; +export const TEXT_FUNC = '功能'; + +export const TEXT_CREATOR_PLACEHOLDER = '保存后自动生成'; +export const LABEL_CREATE_TIME = '创建时间'; +export const LABEL_CREATOR = '制单人'; +export const LABEL_EMPLOYEE = '员工名'; +export const LABEL_USERNAME = '用户名'; +export const LABEL_USER_TYPE = '类型'; +export const LABEL_LANGUAGE = '语言'; +export const LABEL_USER_NO = '用户号'; +export const LABEL_CAN_MODIFY_BILL = '单据修改权限'; + +export const TAB_PERM_GROUP = '权限组'; +/** 5 个占位查看权限页签(D9) */ +export const PLACEHOLDER_TABS = [ + '客户查看权限', + '供应商查看权限', + '人员查看权限', + '工序查看权限', + '司机查看权限', +] as const; +export const PERM_LIST_HEADER = '权限分类'; + +// 校验提示 +export const MSG_USERNAME_FORMAT = '用户名须为 3-20 位字母数字下划线'; +export const MSG_USERNAME_REQUIRED = '请输入用户名'; +export const MSG_USERNO_REQUIRED = '请输入用户号'; +export const MSG_USERTYPE_REQUIRED = '请选择类型'; +export const MSG_LANGUAGE_REQUIRED = '请选择语言'; + +// 成功 / 错误反馈 +export const MSG_CREATE_SUCCESS = '用户创建成功'; +export const MSG_EDIT_SUCCESS = '保存成功'; +export const MSG_ERR_VALIDATION = '提交信息有误,请检查后重试'; +export const MSG_ERR_USERNAME_EXISTS = '用户名已存在,请更换'; +export const MSG_ERR_USER_NOT_FOUND = '该用户不存在或已被删除'; +export const MSG_ERR_NO_PERMISSION = '无权限执行此操作'; +export const MSG_ERR_NETWORK = '保存失败,请稍后重试'; +export const MSG_ERR_LOAD_EMPLOYEES = '员工列表加载失败'; +export const MSG_ERR_LOAD_PERMISSIONS = '权限列表加载失败'; +export const MSG_LOAD_DETAIL_FAIL = '加载失败,点击重试'; +export const TEXT_RETRY = '点击重试'; +export const MSG_CANCEL_CONFIRM = '放弃未保存的修改?'; +export const MSG_FUNC_PLACEHOLDER = '功能开发中'; +export const TEXT_BACK_TO_LIST = '返回列表'; + +// 路由 path(FE-02 已注册占位) +export const PATH_USER_LIST = '/usr/users'; +export const PATH_USER_NEW = '/usr/users/new'; + +// === 提交映射纯函数(跨 task 一致,便于单测) === + +/** 表单值 + 勾选权限 → UserCreateReq(无密码,BR9) */ +export function toCreateReq(values: UserFormValues, permissionIds: number[]): UserCreateReq { + return { + sUserName: values.sUserName, + sUserNo: values.sUserNo, + iEmployeeId: values.iEmployeeId, + sUserType: values.sUserType, + sLanguage: values.sLanguage ?? '', + iCanModifyBill: values.iCanModifyBill, + permissionIds, + }; +} + +/** 表单值 + 勾选权限 → UserUpdateReq(不含 sUserName,BR3;permissionIds 全量覆盖,BR11) */ +export function toUpdateReq(values: UserFormValues, permissionIds: number[]): UserUpdateReq { + return { + sUserNo: values.sUserNo, + iEmployeeId: values.iEmployeeId, + sUserType: values.sUserType, + sLanguage: values.sLanguage ?? '', + iCanModifyBill: values.iCanModifyBill, + iIsVoid: values.iIsVoid ?? 0, + permissionIds, + }; +} + +/** + * edit 回填(BR17)。`UserVO`(FE-03 列表 VO)不暴露 `iCanModifyBill`, + * 故该字段默认 0;其余基本字段按原值回填。 + */ +export function userVoToFormValues(vo: UserVO): UserFormValues { + return { + sUserName: vo.sUserName, + sUserNo: vo.sUserNo ?? '', + iEmployeeId: null, + sUserType: vo.sUserType, + sLanguage: vo.sLanguage, + iCanModifyBill: 0, + iIsVoid: (vo.iIsVoid === 1 ? 1 : 0) as 0 | 1, + }; +} diff --git a/frontend/src/pages/usr/UserDetail/index.tsx b/frontend/src/pages/usr/UserDetail/index.tsx new file mode 100644 index 0000000..2ff9ed0 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/index.tsx @@ -0,0 +1,162 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据页面容器(判 mode、装配 4 子组件、提交反馈与导航回流) +import { useEffect } from 'react'; +import { Form, Spin, Button, Modal, App as AntdApp } from 'antd'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import type { UserVO } from '../../../api/types'; +import UserDetailToolbar from './UserDetailToolbar'; +import UserBasicForm from './UserBasicForm'; +import PermissionTabs from './PermissionTabs'; +import PermissionGroupList from './PermissionGroupList'; +import { useUserDetail } from './useUserDetail'; +import { + MODE_CREATE, + MODE_EDIT, + MSG_CREATE_SUCCESS, + MSG_EDIT_SUCCESS, + MSG_LOAD_DETAIL_FAIL, + MSG_CANCEL_CONFIRM, + PATH_USER_LIST, + PATH_USER_NEW, + TEXT_BACK_TO_LIST, + TEXT_RETRY, + type UserFormValues, +} from './constants'; +import styles from './UserDetail.module.css'; + +/** Checkbox 受控值为 boolean,提交映射需 0/1(BR8) */ +function normalizeFormValues(raw: UserFormValues): UserFormValues { + return { + ...raw, + iCanModifyBill: (raw.iCanModifyBill ? 1 : 0) as 0 | 1, + }; +} + +export default function UserDetailPage() { + const navigate = useNavigate(); + const params = useParams<{ id?: string }>(); + const location = useLocation(); + const { message } = AntdApp.useApp(); + const [form] = Form.useForm(); + + const mode = params.id ? MODE_EDIT : MODE_CREATE; + const userId = params.id ? Number(params.id) : undefined; + const presetUser = (location.state as { user?: UserVO } | null)?.user ?? null; + + const detail = useUserDetail({ mode, userId, presetUser }); + const { + formValues, + employees, + permissions, + checkedPermissionIds, + readonlyCreator, + readonlyCreateTime, + loading, + submitting, + loadFailed, + selectEmployee, + togglePermission, + toggleAll, + submit, + reload, + } = detail; + + // hook 持有的受控值回写到 AntD Form(create 默认 / edit 回填,BR1/BR2/BR6/BR17) + useEffect(() => { + form.setFieldsValue({ + ...formValues, + iCanModifyBill: (formValues.iCanModifyBill ? 1 : 0) as 0 | 1, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues]); + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const ret = await submit(normalizeFormValues({ ...formValues, ...values })); + if (ret.ok) { + message.success(mode === MODE_CREATE ? MSG_CREATE_SUCCESS : MSG_EDIT_SUCCESS); + navigate(PATH_USER_LIST); + } else if (ret.fieldError) { + form.setFields([ + { name: ret.fieldError.field, errors: [ret.fieldError.message] }, + ]); + } + } catch { + // validateFields 失败:就近字段已展示错误,不发请求(BR12) + } + }; + + const handleCancel = () => { + if (form.isFieldsTouched()) { + Modal.confirm({ + title: MSG_CANCEL_CONFIRM, + onOk: () => navigate(PATH_USER_LIST), + }); + } else { + navigate(PATH_USER_LIST); + } + }; + + const handleNew = () => { + navigate(PATH_USER_NEW); + }; + + const handleSelectEmployee = (value: number | null) => { + selectEmployee(value); + }; + + // 预取/详情取数失败:整页重试入口(spec § 4 loadError)。 + // edit 态额外给「返回列表」——edit 预填只能来自列表行经 navigate state 透传的 + // presetUser(无 by-id 读端点,详见 useUserDetail),缺 state 时重试仍会回到 loadError, + // 需经列表双击重新携带 state 进入,故提供返回列表入口(spec § 4「edit 详情失败给整页重试或返回列表」)。 + if (loadFailed) { + return ( +
+
+ {MSG_LOAD_DETAIL_FAIL} + + {mode === MODE_EDIT && ( + + )} +
+
+ ); + } + + return ( +
+ void handleSave()} + onCancel={handleCancel} + onNew={handleNew} + /> + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserDetail/useUserDetail.ts b/frontend/src/pages/usr/UserDetail/useUserDetail.ts new file mode 100644 index 0000000..e36b224 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/useUserDetail.ts @@ -0,0 +1,262 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据 hook(状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError) +import { useCallback, useEffect, useRef, useState } from 'react'; +import { App as AntdApp } from 'antd'; +import { + createUser, + updateUser, + listEmployees, + listPermissions, +} from '../../../api/usrApi'; +import { ApiError } from '../../../api/request'; +import type { + EmployeeOption, + PermissionItem, + UserVO, + UserDetailMode, +} from '../../../api/types'; +import { + CREATE_DEFAULTS, + ERR_USERNAME_EXISTS, + ERR_USER_NOT_FOUND, + ERR_NO_PERMISSION, + ERR_VALIDATION, + MSG_ERR_USERNAME_EXISTS, + MSG_ERR_USER_NOT_FOUND, + MSG_ERR_NO_PERMISSION, + MSG_ERR_VALIDATION, + MSG_ERR_NETWORK, + MSG_ERR_LOAD_EMPLOYEES, + MSG_ERR_LOAD_PERMISSIONS, + MSG_LOAD_DETAIL_FAIL, + toCreateReq, + toUpdateReq, + userVoToFormValues, + type UserFormValues, +} from './constants'; + +export interface UseUserDetailArgs { + mode: UserDetailMode; + userId?: number; + presetUser?: UserVO | null; +} + +export interface SubmitFieldError { + field: keyof UserFormValues; + message: string; +} + +export interface SubmitResult { + ok: boolean; + id?: number; + fieldError?: SubmitFieldError; +} + +export interface UseUserDetailReturn { + mode: UserDetailMode; + formValues: UserFormValues; + employees: EmployeeOption[]; + permissions: PermissionItem[]; + checkedPermissionIds: number[]; + readonlyCreator: string; + readonlyCreateTime: string; + loading: boolean; + submitting: boolean; + error: ApiError | null; + loadFailed: boolean; + setField(name: keyof UserFormValues, value: unknown): void; + selectEmployee(value: number | null): void; + togglePermission(id: number, checked: boolean): void; + toggleAll(checked: boolean): void; + submit(values: UserFormValues): Promise; + reload(): void; +} + +export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { + const { mode, userId, presetUser } = args; + const { message } = AntdApp.useApp(); + + const [formValues, setFormValues] = useState({ ...CREATE_DEFAULTS }); + const [employees, setEmployees] = useState([]); + const [permissions, setPermissions] = useState([]); + const [checkedPermissionIds, setCheckedPermissionIds] = useState([]); + const [readonlyCreator, setReadonlyCreator] = useState(''); + const [readonlyCreateTime, setReadonlyCreateTime] = useState(''); + const [loading, setLoading] = useState(true); // initialLoading + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + const employeesRef = useRef(employees); + employeesRef.current = employees; + const checkedRef = useRef(checkedPermissionIds); + checkedRef.current = checkedPermissionIds; + const messageRef = useRef(message); + messageRef.current = message; + const mountedRef = useRef(true); + + /** 把后端权限分类回勾:UserVO 不暴露已授权 id,故 edit 预填仅用 detail 的权限字段(无则空集) */ + const initFromVo = useCallback((vo: UserVO) => { + setFormValues(userVoToFormValues(vo)); + setReadonlyCreator(vo.sCreator ?? ''); + setReadonlyCreateTime(vo.tCreateDate ?? ''); + // UserVO 不含已授权权限 id(FE-03 列表 VO),按空集初始化;后端补详情端点后可回勾 + setCheckedPermissionIds([]); + }, []); + + /** 挂载预取(员工/权限)+ edit 详情回填(initialLoading→editing / loadError) */ + const runLoad = useCallback(async () => { + setLoading(true); + setLoadFailed(false); + try { + const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); + if (!mountedRef.current) return; + setEmployees(emps); + setPermissions(perms); + + if (mode === 'edit') { + if (presetUser) { + initFromVo(presetUser); + } else { + // FE-04: edit 预填只能来自 FE-03 经 navigate state 透传的列表行(presetUser)。 + // 路由 :id 是用户主键,而唯一读端点 GET /api/usr/users 的 queryField 无主键选项 + // (docs/05 REQ-USR-003),故不能按主键去查「用户号」列——在真实后端必然返回空、 + // 误报 40401,无法满足编辑预填(BR17 / REQ-USR-002 验收)。 + // 缺 presetUser(如直接访问 URL / 刷新丢失 state)时按 loadError 处理, + // 由页面给出「返回列表」重试入口,重新经列表双击携带 state 进入。 + if (!mountedRef.current) return; + setLoading(false); + setLoadFailed(true); + messageRef.current.error(MSG_LOAD_DETAIL_FAIL); + return; + } + } else { + setFormValues({ ...CREATE_DEFAULTS }); + setCheckedPermissionIds([]); + } + setLoading(false); + } catch (err) { + if (!mountedRef.current) return; + setLoading(false); + setLoadFailed(true); + // 区分员工/权限/详情失败文案:以 reject 顺序无法精确分辨,按权限优先(最常见空源) + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK); + if (mode === 'edit' && employeesRef.current.length === 0 && permissions.length === 0) { + messageRef.current.error(MSG_LOAD_DETAIL_FAIL); + } else { + messageRef.current.error( + apiErr.message === MSG_ERR_LOAD_EMPLOYEES + ? MSG_ERR_LOAD_EMPLOYEES + : MSG_ERR_LOAD_PERMISSIONS, + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, userId, presetUser, initFromVo]); + + useEffect(() => { + mountedRef.current = true; + void runLoad(); + return () => { + mountedRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setField = useCallback((name: keyof UserFormValues, value: unknown) => { + setFormValues((prev) => ({ ...prev, [name]: value })); + }, []); + + /** 选择员工:带出用户名(create)/ 用户号(BR5,用户仍可改) */ + const selectEmployee = useCallback((value: number | null) => { + setFormValues((prev) => { + const emp = employeesRef.current.find((e) => e.value === value); + if (!emp) return { ...prev, iEmployeeId: value }; + return { + ...prev, + iEmployeeId: value, + sUserName: emp.label, + sUserNo: emp.sEmployeeNo ?? prev.sUserNo, + }; + }); + }, []); + + const togglePermission = useCallback((id: number, checked: boolean) => { + setCheckedPermissionIds((prev) => { + if (checked) return prev.includes(id) ? prev : [...prev, id]; + return prev.filter((p) => p !== id); + }); + }, []); + + const toggleAll = useCallback((checked: boolean) => { + setCheckedPermissionIds(checked ? permissions.map((p) => p.id) : []); + }, [permissions]); + + /** 提交:create→POST / edit→PUT;错误码分流(spec § 4) */ + const submit = useCallback( + async (values: UserFormValues): Promise => { + setSubmitting(true); + setError(null); + try { + const ids = checkedRef.current; + let id: number; + if (mode === 'edit' && userId != null) { + const ret = await updateUser(userId, toUpdateReq(values, ids)); + id = ret.id; + } else { + const ret = await createUser(toCreateReq(values, ids)); + id = ret.id; + } + if (mountedRef.current) setSubmitting(false); + return { ok: true, id }; + } catch (err) { + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK); + if (mountedRef.current) { + setSubmitting(false); + setError(apiErr); + } + if (apiErr.code === ERR_USERNAME_EXISTS) { + messageRef.current.error(MSG_ERR_USERNAME_EXISTS); + return { + ok: false, + fieldError: { field: 'sUserName', message: MSG_ERR_USERNAME_EXISTS }, + }; + } + if (apiErr.code === ERR_USER_NOT_FOUND) { + messageRef.current.error(MSG_ERR_USER_NOT_FOUND); + } else if (apiErr.code === ERR_NO_PERMISSION) { + messageRef.current.error(MSG_ERR_NO_PERMISSION); + } else if (apiErr.code === ERR_VALIDATION) { + messageRef.current.error(MSG_ERR_VALIDATION); + } else { + messageRef.current.error(MSG_ERR_NETWORK); + } + return { ok: false }; + } + }, + [mode, userId], + ); + + const reload = useCallback(() => { + void runLoad(); + }, [runLoad]); + + return { + mode, + formValues, + employees, + permissions, + checkedPermissionIds, + readonlyCreator, + readonlyCreateTime, + loading, + submitting, + error, + loadFailed, + setField, + selectEmployee, + togglePermission, + toggleAll, + submit, + reload, + }; +} diff --git a/frontend/src/pages/usr/UserList/UserFilterBar.tsx b/frontend/src/pages/usr/UserList/UserFilterBar.tsx new file mode 100644 index 0000000..5e0349f --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserFilterBar.tsx @@ -0,0 +1,95 @@ +// REQ-USR-003: 用户列表筛选栏(范围/查询字段/匹配方式/查询值/更多/搜索/清空) +import { Button, Input, Select } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import type { UserListQuery } from '../../../api/types'; +import { + MATCH_TYPE_OPTIONS, + QUERY_FIELD_OPTIONS, + SCOPE_OPTIONS, + TEXT_CLEAR, + TEXT_SEARCH, +} from './constants'; +import styles from './UserList.module.css'; + +export interface UserFilterBarProps { + query: UserListQuery; + onChangeQueryField(v: string): void; + onChangeMatchType(v: string): void; + onChangeQueryValue(v: string): void; + onSearch(): void; + onClear(): void; +} + +const toOptions = (arr: readonly string[]) => arr.map((v) => ({ value: v, label: v })); + +export default function UserFilterBar({ + query, + onChangeQueryField, + onChangeMatchType, + onChangeQueryValue, + onSearch, + onClear, +}: UserFilterBarProps) { + return ( +
+ {/* 用户范围下拉(占位 demo,D2:不向后端传额外参数) */} +
+ +
+ + {/* 匹配方式下拉(默认包含,BR2/BR4) */} +
+ onChangeQueryValue(e.target.value)} + onPressEnter={() => onSearch()} + allowClear + style={{ width: 200 }} + /> +
+ + {/* 更多条件占位(D3:点击无业务回调) */} + + ▾ + + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/UserList.module.css b/frontend/src/pages/usr/UserList/UserList.module.css new file mode 100644 index 0000000..287b2cf --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserList.module.css @@ -0,0 +1,92 @@ +/* REQ-USR-003: 用户列表页 scoped 样式。语义色只用 var(--color-*);工具栏深色底为局部装饰(D10) */ + +.page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-base); +} + +/* === 工具栏:深色底为页面局部装饰(非语义 token,scoped,D10,与 FE-02 顶栏处理一致) === */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #2c2f36; +} + +.toolbarSpacer { + flex: 1 1 auto; +} + +.toolbar :global(.ant-btn) { + color: #ffffff; +} + +.gear { + color: #ffffff; + font-size: 16px; + cursor: default; + padding: 0 8px; +} + +/* === 筛选栏:白底 + 下边线 === */ +.filterbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 12px; + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); +} + +.moreToggle { + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + padding: 0 4px; +} + +/* === 表格壳:白底,可横向滚动 === */ +.tableShell { + flex: 1 1 auto; + overflow: auto; + padding: 12px; + background: var(--color-bg-base); +} + +.tableShell :global(.ant-table-thead > tr > th) { + background: var(--color-table-header-bg); + color: var(--color-table-header-fg); + border-color: var(--color-border); +} + +.tableShell :global(.ant-table-tbody > tr > td) { + color: var(--color-table-row-fg); + border-color: var(--color-border); +} + +.tableShell :global(.ant-table-tbody > tr:hover > td) { + background: var(--color-table-row-bg-hover); +} + +.tableShell :global(.ant-table-tbody > tr.ant-table-row-selected > td) { + background: var(--color-table-row-bg-selected); +} + +/* === 错误占位 === */ +.errorBox { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 0; + color: var(--color-text-secondary); +} + +.errorText { + color: var(--color-error); +} diff --git a/frontend/src/pages/usr/UserList/UserTable.tsx b/frontend/src/pages/usr/UserList/UserTable.tsx new file mode 100644 index 0000000..d952434 --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserTable.tsx @@ -0,0 +1,70 @@ +// REQ-USR-003: 用户列表表格(受控分页 / 单选 rowSelection / 行双击 / 空态,BR1/BR6/BR11/BR12/BR14/D8) +import { Empty, Table } from 'antd'; +import type { TablePaginationConfig } from 'antd'; +import type { UserVO } from '../../../api/types'; +import { buildUserColumns } from './columns'; +import { PAGE_SIZE_OPTIONS, TEXT_EMPTY, totalText } from './constants'; +import styles from './UserList.module.css'; + +export interface UserTableProps { + rows: UserVO[]; + loading: boolean; + total: number; + pageNum: number; + pageSize: number; + onChangePage(pageNum: number, pageSize: number): void; + onRowDoubleClick(row: UserVO): void; + selectedRowKey?: number | null; + onSelectRow?(key: number | null): void; +} + +export default function UserTable({ + rows, + loading, + total, + pageNum, + pageSize, + onChangePage, + onRowDoubleClick, + selectedRowKey, + onSelectRow, +}: UserTableProps) { + const pagination: TablePaginationConfig = { + current: pageNum, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: PAGE_SIZE_OPTIONS.map(String), + showTotal: (t) => totalText(t), + }; + + return ( +
+ + rowKey="id" + columns={buildUserColumns({ pageNum, pageSize })} + dataSource={rows} + loading={loading} + size="small" + scroll={{ x: 'max-content' }} + pagination={pagination} + onChange={(p) => { + // 受控分页:原样上报新的 (pageNum, pageSize),越界 / 改页大小回退由页面 hook 处理(BR11) + onChangePage(p.current ?? pageNum, p.pageSize ?? pageSize); + }} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedRowKey != null ? [selectedRowKey] : [], + onChange: (keys) => { + // 仅复刻单选标记语义(spec D8),不参与查询 + onSelectRow?.(keys.length ? Number(keys[0]) : null); + }, + }} + onRow={(row) => ({ + onDoubleClick: () => onRowDoubleClick(row), // BR12 + })} + locale={{ emptyText: }} + /> +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/UserToolbar.tsx b/frontend/src/pages/usr/UserList/UserToolbar.tsx new file mode 100644 index 0000000..bd73d30 --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserToolbar.tsx @@ -0,0 +1,70 @@ +// REQ-USR-003: 用户列表工具栏(刷新/新增/导出Excel/设置齿轮占位,BR8/BR9/BR13/D7/D10) +import { Button } from 'antd'; +import { + FileExcelOutlined, + PlusCircleOutlined, + ReloadOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { TEXT_ADD, TEXT_EXPORT, TEXT_REFRESH } from './constants'; +import styles from './UserList.module.css'; + +export interface UserToolbarProps { + onRefresh(): void; + onAdd(): void; + onExport(): void; + exporting: boolean; + loading?: boolean; +} + +export default function UserToolbar({ + onRefresh, + onAdd, + onExport, + exporting, + loading = false, +}: UserToolbarProps) { + return ( +
+ + + + + + + {/* 设置齿轮:列显隐 / 偏好预留占位,无后端动作(D7) */} + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/columns.tsx b/frontend/src/pages/usr/UserList/columns.tsx new file mode 100644 index 0000000..3732fd8 --- /dev/null +++ b/frontend/src/pages/usr/UserList/columns.tsx @@ -0,0 +1,37 @@ +// REQ-USR-003: 用户列表列定义(序号按当前页 BR1;作废只读 0/1→否/是 BR6) +import type { ColumnsType } from 'antd/es/table'; +import type { UserVO } from '../../../api/types'; + +export interface BuildColumnsOpts { + pageNum: number; + pageSize: number; +} + +/** 列顺序固定:序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废 / 登录日期 / 制单人 / 制单日期 */ +export function buildUserColumns(opts: BuildColumnsOpts): ColumnsType { + const { pageNum, pageSize } = opts; + return [ + { + title: '序号', + key: 'serial', + width: 64, + render: (_value, _record, index) => (pageNum - 1) * pageSize + index + 1, // BR1 + }, + { title: '用户名', dataIndex: 'sUserName', key: 'sUserName' }, + { title: '员工名', dataIndex: 'employeeName', key: 'employeeName' }, + { title: '用户号', dataIndex: 'sUserNo', key: 'sUserNo' }, + { title: '部门', dataIndex: 'departmentName', key: 'departmentName' }, + { title: '用户类型', dataIndex: 'sUserType', key: 'sUserType' }, + { title: '语言', dataIndex: 'sLanguage', key: 'sLanguage' }, + { + title: '作废', + dataIndex: 'iIsVoid', + key: 'iIsVoid', + width: 72, + render: (v: number) => (v === 1 ? '是' : '否'), // 只读展示,BR6 + }, + { title: '登录日期', dataIndex: 'tLastLoginDate', key: 'tLastLoginDate' }, + { title: '制单人', dataIndex: 'sCreator', key: 'sCreator' }, + { title: '制单日期', dataIndex: 'tCreateDate', key: 'tCreateDate' }, + ]; +} diff --git a/frontend/src/pages/usr/UserList/constants.ts b/frontend/src/pages/usr/UserList/constants.ts new file mode 100644 index 0000000..bd4148c --- /dev/null +++ b/frontend/src/pages/usr/UserList/constants.ts @@ -0,0 +1,61 @@ +// REQ-USR-003: 用户列表页合同级常量(枚举 / 默认 query / pageSize / 错误码 / 文案) +import type { UserListQuery } from '../../../api/types'; + +/** + * 查询字段枚举(对齐 REQ 输入表 1「显示来源」/ docs/05)。逐字一致,原样作为 + * queryField 提交值,匹配语义由后端裁决(BR4)。默认首项「用户名」(BR2)。 + */ +export const QUERY_FIELD_OPTIONS = [ + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '作废', + '登录日期', + '制单人', +] as const; + +/** 匹配方式枚举(BR4),默认「包含」(BR2) */ +export const MATCH_TYPE_OPTIONS = ['包含', '不包含', '等于'] as const; + +/** 用户范围下拉(占位 demo,spec D2):仅「全部用户」一项,不向后端传额外参数 */ +export const SCOPE_OPTIONS = ['全部用户'] as const; + +/** 每页条数选项(上限 100 对齐 docs/05 / REQ 边界,spec D4;不采用原型 demo 10000) */ +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +/** 默认查询(BR2/BR3,pageSize 默认 10 对齐 docs/05,spec D4) */ +export const DEFAULT_QUERY: UserListQuery = { + queryField: '用户名', + matchType: '包含', + queryValue: '', + pageNum: 1, + pageSize: 10, +}; + +// === 错误码常量(对齐 docs/05 § REQ-USR-003 / spec § 4) === +/** 分页参数非法(pageNum<1 或 pageSize 超上限 100) */ +export const ERR_PAGE_INVALID = 42201; +/** 查询参数校验失败 */ +export const ERR_QUERY_INVALID = 40001; + +// === 静态文案(逐字一致,复刻原型 / spec) === +export const TEXT_REFRESH = '刷新'; +export const TEXT_ADD = '新增'; +export const TEXT_EXPORT = '导出Excel'; +export const TEXT_SEARCH = '搜索'; +export const TEXT_CLEAR = '清空'; +export const TEXT_EMPTY = '暂无匹配的用户'; +export const TEXT_ERROR = '加载失败,点击重试'; +export const TEXT_EXPORT_SUCCESS = '导出成功'; +export const TEXT_EXPORT_FAIL = '导出失败'; +export const TEXT_MSG_PAGE_INVALID = '分页参数有误,已重置为第 1 页'; +export const TEXT_MSG_QUERY_INVALID = '查询条件有误,请检查后重试'; +export const TEXT_MSG_NETWORK = '加载失败,请稍后重试'; + +/** 分页统计文案(showTotal,total 来自 PageResult.total,BR1/§ 3) */ +export const totalText = (total: number): string => `共 ${total} 条记录`; + +/** 导出文件名 */ +export const EXPORT_FILENAME = '用户列表.csv'; diff --git a/frontend/src/pages/usr/UserList/exportUtils.ts b/frontend/src/pages/usr/UserList/exportUtils.ts new file mode 100644 index 0000000..bea44e3 --- /dev/null +++ b/frontend/src/pages/usr/UserList/exportUtils.ts @@ -0,0 +1,47 @@ +// REQ-USR-003: 前端零依赖 CSV 导出(UTF-8 BOM + Blob +
,D-PLAN-1) +import type { UserVO } from '../../../api/types'; + +/** 导出列定义:中文表头 + 从 UserVO 取值(与列定义语义一致,作废 0/1→否/是;不含序号列) */ +const EXPORT_COLUMNS: { header: string; pick: (row: UserVO) => string }[] = [ + { header: '用户名', pick: (r) => r.sUserName ?? '' }, + { header: '员工名', pick: (r) => r.employeeName ?? '' }, + { header: '用户号', pick: (r) => r.sUserNo ?? '' }, + { header: '部门', pick: (r) => r.departmentName ?? '' }, + { header: '用户类型', pick: (r) => r.sUserType ?? '' }, + { header: '语言', pick: (r) => r.sLanguage ?? '' }, + { header: '作废', pick: (r) => (r.iIsVoid === 1 ? '是' : '否') }, + { header: '登录日期', pick: (r) => r.tLastLoginDate ?? '' }, + { header: '制单人', pick: (r) => r.sCreator ?? '' }, + { header: '制单日期', pick: (r) => r.tCreateDate ?? '' }, +]; + +/** CSV 单元转义:含逗号 / 引号 / 换行时用双引号包裹并转义内部引号 */ +function escapeCell(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** 按列定义顺序与中文表头生成 CSV 文本(含表头行;空值→空串;作废 0/1→否/是) */ +export function buildUserCsv(rows: UserVO[]): string { + const header = EXPORT_COLUMNS.map((c) => escapeCell(c.header)).join(','); + const body = rows.map((row) => + EXPORT_COLUMNS.map((c) => escapeCell(c.pick(row))).join(','), + ); + return [header, ...body].join('\n'); +} + +/** 前置 UTF-8 BOM → Blob → createObjectURL → 触发 下载 */ +export function downloadCsv(filename: string, csv: string): void { + const BOM = ''; + const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/pages/usr/UserList/index.tsx b/frontend/src/pages/usr/UserList/index.tsx new file mode 100644 index 0000000..64d1271 --- /dev/null +++ b/frontend/src/pages/usr/UserList/index.tsx @@ -0,0 +1,83 @@ +// REQ-USR-003: 用户列表页面容器(组合工具栏/筛选栏/表格+分页,对接 GET /api/usr/users) +import { useState } from 'react'; +import { Button } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import type { UserVO } from '../../../api/types'; +import UserToolbar from './UserToolbar'; +import UserFilterBar from './UserFilterBar'; +import UserTable from './UserTable'; +import { useUserList } from './useUserList'; +import { TEXT_ERROR } from './constants'; +import styles from './UserList.module.css'; + +export default function UserListPage() { + const navigate = useNavigate(); + const { + list, + total, + loading, + error, + query, + exporting, + search, + refresh, + clear, + setQueryField, + setMatchType, + setQueryValue, + changePage, + exportExcel, + } = useUserList(); + + // 单选标记仅服务「进单据」语义,不参与查询(spec D8) + const [selectedRowKey, setSelectedRowKey] = useState(null); + + const handleRowDoubleClick = (row: UserVO) => { + // BR12: 进入编辑单据。列表行 UserVO 已含单据预填所需全部字段, + // 经 navigate state 透传给详情页走 presetUser 分支; + // 不能仅靠 :id(路由 :id 为用户主键,而读端点 GET /api/usr/users 的 queryField + // 无主键选项,按主键去查「用户号」列在真实后端必然返回空 → 误报 40401), + // 详见 FE-04 / docs/05 REQ-USR-002。 + navigate('/usr/users/' + row.id, { state: { user: row } }); + }; + + return ( +
+ navigate('/usr/users/new')} // BR13 + onExport={() => void exportExcel()} + exporting={exporting} + loading={loading} + /> + + {error ? ( +
+ {TEXT_ERROR} + +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/useUserList.ts b/frontend/src/pages/usr/UserList/useUserList.ts new file mode 100644 index 0000000..f6d0e2e --- /dev/null +++ b/frontend/src/pages/usr/UserList/useUserList.ts @@ -0,0 +1,176 @@ +// REQ-USR-003: 用户列表查询 hook(状态机 initialLoading/loading/success/empty/error/exporting) +import { useCallback, useEffect, useRef, useState } from 'react'; +import { App as AntdApp } from 'antd'; +import { listUsers } from '../../../api/usrApi'; +import { ApiError } from '../../../api/request'; +import type { UserVO, UserListQuery } from '../../../api/types'; +import { buildUserCsv, downloadCsv } from './exportUtils'; +import { + DEFAULT_QUERY, + ERR_PAGE_INVALID, + ERR_QUERY_INVALID, + EXPORT_FILENAME, + TEXT_EXPORT_FAIL, + TEXT_EXPORT_SUCCESS, + TEXT_MSG_NETWORK, + TEXT_MSG_PAGE_INVALID, + TEXT_MSG_QUERY_INVALID, +} from './constants'; + +export interface UseUserListReturn { + list: UserVO[]; + total: number; + loading: boolean; + error: ApiError | null; + query: UserListQuery; + exporting: boolean; + search(): void; + refresh(): void; + clear(): void; + setQueryField(v: string): void; + setMatchType(v: string): void; + setQueryValue(v: string): void; + changePage(pageNum: number, pageSize: number): void; + exportExcel(): Promise; +} + +export function useUserList(): UseUserListReturn { + const { message } = AntdApp.useApp(); + + const [query, setQuery] = useState({ ...DEFAULT_QUERY }); + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); // initialLoading + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + + // 最新 query 镜像,避免 refresh / 闭包读到旧值 + const queryRef = useRef(query); + queryRef.current = query; + + const messageRef = useRef(message); + messageRef.current = message; + + // 卸载守卫:避免异步取数 resolve/reject 在组件卸载后 setState(防内存泄漏 / 测试环境拆除后报错) + const mountedRef = useRef(true); + + /** 以给定 query 取数;同步 total/pageNum/pageSize 回显(BR15);错误码分流(spec § 4) */ + const runFetch = useCallback(async (q: UserListQuery) => { + setQuery(q); + queryRef.current = q; + setLoading(true); + setError(null); + try { + const pageData = await listUsers(q); + if (!mountedRef.current) return; + setList(pageData.records); + setTotal(pageData.total); + // 信任后端回显(越界回退最后一页等),同步分页当前页/页大小(BR15) + setQuery((prev) => { + const next = { ...prev, pageNum: pageData.pageNum, pageSize: pageData.pageSize }; + queryRef.current = next; + return next; + }); + setLoading(false); + } catch (err) { + if (!mountedRef.current) return; + const apiErr = err instanceof ApiError ? err : new ApiError(-1, TEXT_MSG_NETWORK); + setLoading(false); + if (apiErr.code === ERR_PAGE_INVALID) { + // 42201:兜底重置分页为合法值后重查(pageNum=1,pageSize 收敛 ≤100) + messageRef.current.warning(TEXT_MSG_PAGE_INVALID); + const safePageSize = Math.min(q.pageSize, 100); + void runFetch({ ...q, pageNum: 1, pageSize: safePageSize }); + return; + } + if (apiErr.code === ERR_QUERY_INVALID) { + // 40001:保留条件不自动重查 + messageRef.current.error(TEXT_MSG_QUERY_INVALID); + setError(apiErr); + return; + } + // 网络 / 超时 / 5xx 兜底 + messageRef.current.error(TEXT_MSG_NETWORK); + setError(apiErr); + } + }, []); + + // 挂载即以默认条件取数(initialLoading,BR2) + useEffect(() => { + mountedRef.current = true; + void runFetch({ ...DEFAULT_QUERY }); + return () => { + mountedRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setQueryField = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, queryField: v })); + }, []); + const setMatchType = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, matchType: v })); + }, []); + const setQueryValue = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, queryValue: v })); + }, []); + + /** 搜索:以当前条件回第 1 页取数(BR7) */ + const search = useCallback(() => { + void runFetch({ ...queryRef.current, pageNum: 1 }); + }, [runFetch]); + + /** 刷新:保持当前 query(含 pageNum)重取(BR8) */ + const refresh = useCallback(() => { + void runFetch({ ...queryRef.current }); + }, [runFetch]); + + /** 清空:重置为默认条件并全量查询(BR10) */ + const clear = useCallback(() => { + void runFetch({ ...DEFAULT_QUERY }); + }, [runFetch]); + + /** 切页 / 改页大小:改 pageSize 回第 1 页(BR11),仅切页保留页码 */ + const changePage = useCallback( + (pageNum: number, pageSize: number) => { + const cur = queryRef.current; + const sizeChanged = pageSize !== cur.pageSize; + void runFetch({ ...cur, pageNum: sizeChanged ? 1 : pageNum, pageSize }); + }, + [runFetch], + ); + + /** 导出:拉当前条件命中结果(pageSize 收敛至上限 100 一次取)→ CSV 下载(BR9 / D-PLAN-1) */ + const exportExcel = useCallback(async () => { + setExporting(true); + try { + const cur = queryRef.current; + const fetchSize = Math.min(Math.max(total, cur.pageSize, 1), 100); + const pageData = await listUsers({ ...cur, pageNum: 1, pageSize: fetchSize }); + const csv = buildUserCsv(pageData.records); + downloadCsv(EXPORT_FILENAME, csv); + if (mountedRef.current) messageRef.current.success(TEXT_EXPORT_SUCCESS); + } catch { + if (mountedRef.current) messageRef.current.error(TEXT_EXPORT_FAIL); + } finally { + if (mountedRef.current) setExporting(false); + } + }, [total]); + + return { + list, + total, + loading, + error, + query, + exporting, + search, + refresh, + clear, + setQueryField, + setMatchType, + setQueryValue, + changePage, + exportExcel, + }; +} diff --git a/frontend/src/router/AppErrorBoundary.tsx b/frontend/src/router/AppErrorBoundary.tsx new file mode 100644 index 0000000..24c6da9 --- /dev/null +++ b/frontend/src/router/AppErrorBoundary.tsx @@ -0,0 +1,49 @@ +// REQ-USR-003: 路由级 ErrorBoundary(子路由渲染抛错兜底 + 返回主页入口,spec § 3 error / D7)。 +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Button, Result } from 'antd'; + +interface AppErrorBoundaryProps { + children: ReactNode; +} + +interface AppErrorBoundaryState { + hasError: boolean; +} + +export default class AppErrorBoundary extends Component< + AppErrorBoundaryProps, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): AppErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo): void { + // 兜底:捕获即进入降级 UI;此处可接入日志上报(MVP 不上报)。 + } + + private handleGoHome = () => { + this.setState({ hasError: false }); + // 直接回主页(不依赖 hooks,class 组件用 location) + window.location.assign('/'); + }; + + render() { + if (this.state.hasError) { + return ( + + 返回主页 + + } + /> + ); + } + return this.props.children; + } +} diff --git a/frontend/src/router/RedirectIfAuthed.tsx b/frontend/src/router/RedirectIfAuthed.tsx new file mode 100644 index 0000000..29d4823 --- /dev/null +++ b/frontend/src/router/RedirectIfAuthed.tsx @@ -0,0 +1,25 @@ +// REQ-USR-004: /login 守卫(BR2)。已登录访问登录页 → 回 from 或 /。 +import type { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAppSelector } from '../store/hooks'; + +interface RedirectIfAuthedProps { + children: ReactNode; +} + +/** + * 已登录态判定(D:仅 token 即视为已登录,与 RequireAuth 的「token 存在即非未登录」语义对齐; + * 避免 token 已持久化、user 尚在拉取时登录页与守卫之间互相弹跳)。 + * 已登录 → 重定向到 location.state.from(来源)或 /;否则渲染 children(LoginPage)。 + */ +export default function RedirectIfAuthed({ children }: RedirectIfAuthedProps) { + const token = useAppSelector((s) => s.auth.token); + const location = useLocation(); + + if (token) { + const from = (location.state as { from?: string } | null)?.from; + return ; + } + + return <>{children}; +} diff --git a/frontend/src/router/RequireAuth.tsx b/frontend/src/router/RequireAuth.tsx new file mode 100644 index 0000000..c1b08d1 --- /dev/null +++ b/frontend/src/router/RequireAuth.tsx @@ -0,0 +1,39 @@ +// REQ-USR-004: 受保护路由守卫(BR1)。三态:unauthenticated / authResolving / ready。 +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { Spin } from 'antd'; +import { useAppSelector } from '../store/hooks'; + +/** + * 布局守卫: + * - 无 token → 重定向 /login,携带 state.from(来源路径,登录后可回跳,BR1) + * - 有 token 但 user 未就绪 → 渲染 Spin 占位(authResolving,token 已存在但用户信息尚未拉取) + * - token + user 均就绪 → 放行(ready),渲染 + */ +export default function RequireAuth() { + const { token, user } = useAppSelector((s) => s.auth); + const location = useLocation(); + + if (!token) { + return ; + } + + if (!user) { + return ( +
+ +
+ +
+ ); + } + + return ; +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx new file mode 100644 index 0000000..488f941 --- /dev/null +++ b/frontend/src/router/index.tsx @@ -0,0 +1,46 @@ +// REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 +// FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 +// FE-04 将 /usr/users/new 与 /usr/users/:id 占位替换为真实「用户信息单据」页 UserDetailPage。 +import { Routes, Route, Navigate } from 'react-router-dom'; +import LoginPage from '../pages/usr/Login/LoginPage'; +import RequireAuth from './RequireAuth'; +import RedirectIfAuthed from './RedirectIfAuthed'; +import AppErrorBoundary from './AppErrorBoundary'; +import AppLayout from '../layouts/AppLayout/AppLayout'; +import HomePage from '../pages/home/HomePage/HomePage'; +import UserListPage from '../pages/usr/UserList'; +import UserDetailPage from '../pages/usr/UserDetail'; + +export default function AppRouter() { + return ( + + {/* 登录页:放行,包 RedirectIfAuthed(已登录回主页,BR2),不包 AppLayout */} + + + + } + /> + + {/* 受保护区:RequireAuth > AppLayout(外壳),外壳内套 ErrorBoundary 兜底子路由抛错 */} + }> + + + + } + > + } /> + } /> + } /> + } /> + {/* 受保护区内未匹配 → 回主页(D7) */} + } /> + + + + ); +} diff --git a/frontend/src/store/hooks.ts b/frontend/src/store/hooks.ts new file mode 100644 index 0000000..f9da029 --- /dev/null +++ b/frontend/src/store/hooks.ts @@ -0,0 +1,6 @@ +// REQ-USR-004: 类型化 Redux hooks(FE 共享骨架) +import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/frontend/src/store/slices/authSlice.ts b/frontend/src/store/slices/authSlice.ts new file mode 100644 index 0000000..27f3436 --- /dev/null +++ b/frontend/src/store/slices/authSlice.ts @@ -0,0 +1,36 @@ +// REQ-USR-004: 全局登录态(token + user)。token 持久化到 localStorage(D6)。 +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { TOKEN_STORAGE_KEY } from '../../api/request'; +import type { AuthUser } from '../../api/types'; + +export interface AuthState { + token: string | null; + user: AuthUser | null; +} + +const initialState: AuthState = { + token: localStorage.getItem(TOKEN_STORAGE_KEY), + user: null, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + // 写 token + user,并持久化 token(reducer 内副作用为 MVP 取舍,全项目统一,D6) + setCredentials(state, action: PayloadAction<{ token: string; user: AuthUser }>) { + state.token = action.payload.token; + state.user = action.payload.user; + localStorage.setItem(TOKEN_STORAGE_KEY, action.payload.token); + }, + // 清登录态并移除持久化 token + clearCredentials(state) { + state.token = null; + state.user = null; + localStorage.removeItem(TOKEN_STORAGE_KEY); + }, + }, +}); + +export const { setCredentials, clearCredentials } = authSlice.actions; +export default authSlice.reducer; diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts new file mode 100644 index 0000000..2d70715 --- /dev/null +++ b/frontend/src/store/store.ts @@ -0,0 +1,12 @@ +// REQ-USR-004: Redux store(FE 共享骨架,后续 FE-02~04 复用) +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './slices/authSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..f9a55aa --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,21 @@ +/* 全局样式入口:引入仓库根 Design Tokens SSoT(D9,不在 frontend 内拷贝) */ +@import '../../../src/styles/tokens.css'; + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background: var(--color-bg-base); + color: var(--color-text); +} diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts new file mode 100644 index 0000000..3fcea59 --- /dev/null +++ b/frontend/src/styles/theme.ts @@ -0,0 +1,14 @@ +// REQ-USR-004: 把 Design Token --color-primary(tokens.css,SSoT)对齐到 AntD colorPrimary。 +// AntD 需要具体色值以派生主题色阶,这里在运行时读取 CSS 变量计算值,回退到 token 当前值。 + +const FALLBACK_PRIMARY = '#1890ff'; // = tokens.css --color-primary 当前值(仅作读取失败兜底) + +export function readPrimaryColor(): string { + if (typeof document !== 'undefined' && document.documentElement) { + const v = getComputedStyle(document.documentElement) + .getPropertyValue('--color-primary') + .trim(); + if (v) return v; + } + return FALLBACK_PRIMARY; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..ab18fd2 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +// CSS Modules 类型声明(*.module.css 默认导出键值映射) +declare module '*.module.css' { + const classes: { readonly [key: string]: string }; + export default classes; +} diff --git a/frontend/tests/e2e/login.spec.ts b/frontend/tests/e2e/login.spec.ts new file mode 100644 index 0000000..35ed353 --- /dev/null +++ b/frontend/tests/e2e/login.spec.ts @@ -0,0 +1,95 @@ +import { test, expect, type Page } from '@playwright/test'; + +// 桩后端:版本下拉与登录端点(page.route),不依赖真实后端起服。 +async function stubCompanies(page: Page) { + await page.route('**/api/usr/companies', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: [ + { id: 1, sCompanyName: '甲公司', sVersion: '标准版' }, + { id: 2, sCompanyName: '乙公司', sVersion: null }, + ], + }), + }); + }); +} + +test.describe('登录页关键旅程', () => { + test('loads /login and shows version options', async ({ page }) => { + await stubCompanies(page); + await page.goto('/login'); + await expect(page.getByText('用户登录')).toBeVisible(); + // 打开版本下拉,应渲染桩返回项 + await page.getByRole('combobox').click(); + await expect(page.getByText('甲公司(标准版)')).toBeVisible(); + await expect(page.getByText('乙公司', { exact: true })).toBeVisible(); + }); + + test('blocks submit with validation when empty', async ({ page }) => { + await stubCompanies(page); + let loginCalled = false; + await page.route('**/api/usr/login', async (route) => { + loginCalled = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, message: 'success', data: {} }), + }); + }); + await page.goto('/login'); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await expect(page.getByText('请输入用户名')).toBeVisible(); + await expect(page.getByText('请输入密码')).toBeVisible(); + expect(loginCalled).toBe(false); + }); + + test('successful login navigates away from /login', async ({ page }) => { + await stubCompanies(page); + await page.route('**/api/usr/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + token: 'tk-e2e', + user: { id: 1, sUserName: 'admin', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }), + }); + }); + await page.goto('/login'); + await page.getByPlaceholder('请输入你的用户名').fill('admin'); + await page.getByPlaceholder('请输入你的密码').fill('secret'); + // 选版本 + await page.getByRole('combobox').click(); + await page.getByText('甲公司(标准版)').click(); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await expect(page.getByText('登录成功')).toBeVisible(); + await expect(page).not.toHaveURL(/\/login$/); + }); + + test('failed login stays on /login with error', async ({ page }) => { + await stubCompanies(page); + await page.route('**/api/usr/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 40101, message: '认证失败', data: null }), + }); + }); + await page.goto('/login'); + await page.getByPlaceholder('请输入你的用户名').fill('admin'); + await page.getByPlaceholder('请输入你的密码').fill('wrong'); + await page.getByRole('combobox').click(); + await page.getByText('甲公司(标准版)').click(); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await expect(page.getByText('用户名或密码错误')).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/frontend/tests/e2e/shell.spec.ts b/frontend/tests/e2e/shell.spec.ts new file mode 100644 index 0000000..c369115 --- /dev/null +++ b/frontend/tests/e2e/shell.spec.ts @@ -0,0 +1,103 @@ +import { test, expect, type Page } from '@playwright/test'; + +// 桩后端:版本下拉 / 登录 / 用户列表(仅为标签挂载,不验列表内容)。不依赖真实后端起服。 +async function stubBackend(page: Page) { + await page.route('**/api/usr/companies', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: [{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }], + }), + }); + }); + await page.route('**/api/usr/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + token: 'tk-e2e', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }), + }); + }); + await page.route('**/api/usr/users**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { records: [], total: 0, pageNum: 1, pageSize: 10 }, + }), + }); + }); +} + +async function login(page: Page) { + await page.goto('/login'); + await page.getByPlaceholder('请输入你的用户名').fill('admin'); + await page.getByPlaceholder('请输入你的密码').fill('secret'); + // 单一版本时 LoginPage 自动选中(spec § 6.3),无需手动展开下拉 + await expect(page.getByText('甲公司(标准版)')).toBeVisible(); + await page.getByRole('button', { name: /登\s*录/ }).click(); +} + +test.describe('应用外壳关键旅程', () => { + test('login then lands on home with topbar and KPI title', async ({ page }) => { + await stubBackend(page); + await login(page); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole('button', { name: '全部导航' })).toBeVisible(); + await expect(page.getByText('KPI监控')).toBeVisible(); + }); + + test('open and close 全部导航 overlay', async ({ page }) => { + await stubBackend(page); + await login(page); + await page.getByRole('button', { name: '全部导航' }).click(); + await expect(page.getByTestId('nav-overlay')).toBeVisible(); + await expect(page.getByText('期初设置')).toBeVisible(); + await expect(page.getByText('API对接管理')).toBeVisible(); + // Esc 关闭 + await page.keyboard.press('Escape'); + await expect(page.getByTestId('nav-overlay')).toHaveCount(0); + }); + + test('open 用户列表 tab from common ops then close back to home', async ({ page }) => { + await stubBackend(page); + await login(page); + // 常用操作卡内「用户列表」 + await page.getByRole('button', { name: '用户列表' }).click(); + await expect(page).toHaveURL(/\/usr\/users$/); + await expect(page.getByTestId('tab-userlist')).toBeVisible(); + // 关闭用户列表标签 → 回主页 + await page.getByTestId('tab-close-userlist').click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByTestId('tab-userlist')).toHaveCount(0); + }); + + test('logout returns to /login', async ({ page }) => { + await stubBackend(page); + await login(page); + await page.getByText('朱子纯(超级管理员)').click(); + await page.getByText('退出登录').click(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText('已退出登录')).toBeVisible(); + }); + + test('visiting / unauthenticated redirects to /login', async ({ page }) => { + await stubBackend(page); + // 不登录,清 token 后直接访问 / + await page.goto('/login'); + await page.evaluate(() => localStorage.removeItem('xly_erp_token')); + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/frontend/tests/e2e/userdetail.spec.ts b/frontend/tests/e2e/userdetail.spec.ts new file mode 100644 index 0000000..d7740aa --- /dev/null +++ b/frontend/tests/e2e/userdetail.spec.ts @@ -0,0 +1,183 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据 E2E 关键旅程(Playwright,headless,page.route 桩后端) +// 注:均经 SPA 内导航(登录 → 用户列表 → 新增/双击行)进入单据,避免 page.goto 全量刷新丢失 Redux 登录态。 +import { test, expect, type Page } from '@playwright/test'; + +function ok(data: unknown) { + return JSON.stringify({ code: 0, message: 'success', data }); +} + +function err(code: number, message = '业务错误') { + return JSON.stringify({ code, message, data: null }); +} + +const EMPLOYEES = [{ iIncrement: 3, sEmployeeName: '张三', sEmployeeNo: 'zs' }]; +const PERMISSIONS = [ + { iIncrement: 1, sPermissionName: '默认显示', sPermissionCategory: '基础' }, + { iIncrement: 2, sPermissionName: '高级查看', sPermissionCategory: '基础' }, +]; + +function makeUser(id: number, name: string) { + return { + id, + sUserName: name, + 员工名: '张三', + sUserNo: 'zs', + 部门: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + }; +} + +async function stubAuth(page: Page) { + await page.route('**/api/usr/companies', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]) }); + }); + await page.route('**/api/usr/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: ok({ token: 'tk-e2e', user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' } }), + }); + }); +} + +async function stubLookups(page: Page) { + await page.route('**/api/usr/employees**', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok(EMPLOYEES) }); + }); + await page.route('**/api/usr/permissions**', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok(PERMISSIONS) }); + }); +} + +async function loginAndGotoList(page: Page) { + await page.goto('/login'); + await page.getByPlaceholder('请输入你的用户名').fill('admin'); + await page.getByPlaceholder('请输入你的密码').fill('secret'); + await expect(page.getByText('甲公司(标准版)')).toBeVisible(); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await expect(page).toHaveURL(/\/$/); + await page.getByRole('button', { name: '用户列表' }).click(); + await expect(page).toHaveURL(/\/usr\/users$/); +} + +test.describe('用户单据关键旅程', () => { + test('create user and return to list', async ({ page }) => { + await stubAuth(page); + await stubLookups(page); + await page.route('**/api/usr/users', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ id: 9 }) }); + } else { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ records: [], total: 0, pageNum: 1, pageSize: 10 }) }); + } + }); + await loginAndGotoList(page); + await page.getByTestId('btn-add').click(); + await expect(page).toHaveURL(/\/usr\/users\/new$/); + await expect(page.getByTestId('userdetail-page')).toBeVisible(); + await page.getByTestId('field-username').fill('zhangsan'); + await page.getByTestId('field-userno').fill('zs'); + await page.getByTestId('select-language').locator('.ant-select-selector').click(); + await page.getByText('中文', { exact: true }).click(); + await page.getByTestId('perm-check-1').check(); + + const reqPromise = page.waitForRequest((req) => req.url().includes('/api/usr/users') && req.method() === 'POST'); + await page.getByTestId('btn-save').click(); + const req = await reqPromise; + expect(req.postData() ?? '').toContain('zhangsan'); + await expect(page.getByText('用户创建成功')).toBeVisible(); + await expect(page).toHaveURL(/\/usr\/users$/); + }); + + test('edit user prefill then save', async ({ page }) => { + await stubAuth(page); + await stubLookups(page); + await page.route('**/api/usr/users/7', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ id: 7 }) }); // PUT + }); + await page.route('**/api/usr/users**', async (route) => { + // 列表(首屏)+ 预填等于匹配(GET) + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ records: [makeUser(7, 'zhangsan')], total: 1, pageNum: 1, pageSize: 10 }) }); + } else { + await route.fallback(); + } + }); + await loginAndGotoList(page); + await page.getByText('zhangsan').dblclick(); + await expect(page).toHaveURL(/\/usr\/users\/7$/); + await expect(page.getByTestId('field-username')).toHaveValue('zhangsan'); + await expect(page.getByTestId('field-username')).toBeDisabled(); + await page.getByTestId('select-language').locator('.ant-select-selector').click(); + await page.getByText('英文', { exact: true }).click(); + + const reqPromise = page.waitForRequest((req) => req.url().includes('/api/usr/users/7') && req.method() === 'PUT'); + await page.getByTestId('btn-save').click(); + await reqPromise; + await expect(page.getByText('保存成功')).toBeVisible(); + await expect(page).toHaveURL(/\/usr\/users$/); + }); + + test('username conflict shows inline error', async ({ page }) => { + await stubAuth(page); + await stubLookups(page); + await page.route('**/api/usr/users', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ status: 200, contentType: 'application/json', body: err(40901, '用户名已存在') }); + } else { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ records: [], total: 0, pageNum: 1, pageSize: 10 }) }); + } + }); + await loginAndGotoList(page); + await page.getByTestId('btn-add').click(); + await page.getByTestId('field-username').fill('zhangsan'); + await page.getByTestId('field-userno').fill('zs'); + await page.getByTestId('select-language').locator('.ant-select-selector').click(); + await page.getByText('中文', { exact: true }).click(); + await page.getByTestId('btn-save').click(); + await expect(page.getByText('用户名已存在,请更换')).toBeVisible(); + }); + + test('load error shows retry', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ records: [], total: 0, pageNum: 1, pageSize: 10 }) }); + }); + await page.route('**/api/usr/employees**', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok(EMPLOYEES) }); + }); + let permFail = true; + await page.route('**/api/usr/permissions**', async (route) => { + if (permFail) { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + } else { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok(PERMISSIONS) }); + } + }); + await loginAndGotoList(page); + await page.getByTestId('btn-add').click(); + await expect(page.getByTestId('userdetail-loaderror')).toBeVisible(); + permFail = false; + await page.getByTestId('userdetail-loaderror').getByRole('button', { name: '点击重试' }).click(); + await expect(page.getByTestId('userdetail-page')).toBeVisible(); + }); + + test('placeholder tabs/buttons are inert', async ({ page }) => { + await stubAuth(page); + await stubLookups(page); + await page.route('**/api/usr/users', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: ok({ records: [], total: 0, pageNum: 1, pageSize: 10 }) }); + }); + await loginAndGotoList(page); + await page.getByTestId('btn-add').click(); + await expect(page.getByTestId('userdetail-page')).toBeVisible(); + await expect(page.getByRole('tab', { name: '客户查看权限' })).toHaveAttribute('aria-disabled', 'true'); + await page.getByTestId('btn-ph-删除').click(); + await expect(page.getByText('功能开发中')).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/userlist.spec.ts b/frontend/tests/e2e/userlist.spec.ts new file mode 100644 index 0000000..8df7393 --- /dev/null +++ b/frontend/tests/e2e/userlist.spec.ts @@ -0,0 +1,182 @@ +import { test, expect, type Page } from '@playwright/test'; + +// 桩用户列表响应工厂 +function usersBody(records: unknown[], total: number, pageNum = 1, pageSize = 10) { + return JSON.stringify({ + code: 0, + message: 'success', + data: { records, total, pageNum, pageSize }, + }); +} + +function makeUser(id: number, name: string, over: Record = {}) { + return { + id, + sUserName: name, + 员工名: null, + sUserNo: `U00${id}`, + 部门: '技术部', + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + ...over, + }; +} + +// 桩登录 / 版本下拉(沿用 shell.spec.ts 模式)。用户列表由各用例单独桩。 +async function stubAuth(page: Page) { + await page.route('**/api/usr/companies', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: [{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }], + }), + }); + }); + await page.route('**/api/usr/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + token: 'tk-e2e', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }), + }); + }); +} + +async function login(page: Page) { + await page.goto('/login'); + await page.getByPlaceholder('请输入你的用户名').fill('admin'); + await page.getByPlaceholder('请输入你的密码').fill('secret'); + await expect(page.getByText('甲公司(标准版)')).toBeVisible(); + await page.getByRole('button', { name: /登\s*录/ }).click(); + await expect(page).toHaveURL(/\/$/); +} + +async function gotoUserList(page: Page) { + await page.getByRole('button', { name: '用户列表' }).click(); + await expect(page).toHaveURL(/\/usr\/users$/); +} + +test.describe('用户列表关键旅程', () => { + test('enter user list renders rows', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([makeUser(1, '李雷'), makeUser(2, '韩梅梅')], 2), + }); + }); + await login(page); + await gotoUserList(page); + await expect(page.getByText('李雷')).toBeVisible(); + await expect(page.getByText('共 2 条记录')).toBeVisible(); + }); + + test('empty result shows 暂无匹配的用户', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([], 0), + }); + }); + await login(page); + await gotoUserList(page); + await expect(page.getByText('暂无匹配的用户')).toBeVisible(); + }); + + test('search by value triggers query', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([makeUser(3, '王搜索')], 1), + }); + }); + await login(page); + await gotoUserList(page); + const input = page.getByTestId('filter-query-value').locator('input'); + await input.fill('王'); + const reqPromise = page.waitForRequest((req) => + req.url().includes('/api/usr/users') && req.url().includes('queryValue'), + ); + await page.getByTestId('btn-search').click(); + const req = await reqPromise; + expect(decodeURIComponent(req.url())).toContain('queryValue=王'); + await expect(page.getByText('王搜索')).toBeVisible(); + }); + + test('pagination next page refetches with pageNum=2', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users**', async (route) => { + const url = route.request().url(); + const pageNum = url.includes('pageNum=2') ? 2 : 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([makeUser(pageNum, `行${pageNum}`)], 30, pageNum, 10), + }); + }); + await login(page); + await gotoUserList(page); + await expect(page.getByText('行1')).toBeVisible(); + const reqPromise = page.waitForRequest((req) => + req.url().includes('/api/usr/users') && req.url().includes('pageNum=2'), + ); + await page.getByTitle('下一页').click(); + await reqPromise; + await expect(page.getByText('行2')).toBeVisible(); + }); + + test('double click row navigates to user detail', async ({ page }) => { + await stubAuth(page); + await page.route('**/api/usr/users**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([makeUser(88, '详情用户')], 1), + }); + }); + await login(page); + await gotoUserList(page); + await page.getByText('详情用户').dblclick(); + await expect(page).toHaveURL(/\/usr\/users\/88$/); + }); + + test('error response shows retry then recovers', async ({ page }) => { + await stubAuth(page); + let fail = true; + await page.route('**/api/usr/users**', async (route) => { + if (fail) { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: usersBody([makeUser(1, '恢复用户')], 1), + }); + } + }); + await login(page); + await gotoUserList(page); + await expect(page.getByText('加载失败,点击重试')).toBeVisible(); + fail = false; + await page.getByTestId('userlist-error').getByRole('button', { name: '点击重试' }).click(); + await expect(page.getByText('恢复用户')).toBeVisible(); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..2e3b2ab --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,47 @@ +import '@testing-library/jest-dom'; +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// 部分 jsdom / node 运行环境未提供 localStorage,提供内存实现(token 持久化测试依赖)。 +if (typeof globalThis.localStorage === 'undefined') { + const store = new Map(); + const memoryStorage: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + }; + Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage, + configurable: true, + writable: true, + }); +} + +// jsdom 未实现 matchMedia,AntD 响应式逻辑会用到,提供桩实现。 +if (!window.matchMedia) { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +} + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); diff --git a/frontend/tests/unit/AppErrorBoundary.test.tsx b/frontend/tests/unit/AppErrorBoundary.test.tsx new file mode 100644 index 0000000..b1f626c --- /dev/null +++ b/frontend/tests/unit/AppErrorBoundary.test.tsx @@ -0,0 +1,32 @@ +// REQ-USR-003: 路由级 ErrorBoundary 子组件抛错兜底(spec § 3 error / D7) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AppErrorBoundary from '../../src/router/AppErrorBoundary'; + +function Boom(): React.ReactElement { + throw new Error('boom'); +} + +describe('AppErrorBoundary', () => { + // 抑制 React 在 ErrorBoundary 测试时打印的 error 噪声 + let spy: ReturnType; + beforeEach(() => { + spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + spy.mockRestore(); + }); + + it('renders fallback with 返回主页 when child throws', () => { + render( + + + + + , + ); + expect(screen.getByText('页面出错,请刷新或返回主页')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '返回主页' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/AppLayout.shell.test.tsx b/frontend/tests/unit/AppLayout.shell.test.tsx new file mode 100644 index 0000000..2ff61a8 --- /dev/null +++ b/frontend/tests/unit/AppLayout.shell.test.tsx @@ -0,0 +1,97 @@ +// REQ-USR-003: AppLayout 外壳装配 + 标签↔路由同步(ready / navOverlayOpen / tabOpen 态) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function LocProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderLayout(initialEntries: string[]) { + return renderShell( + + }> + + +
home-outlet
+ + } + /> + + +
users-outlet
+ + } + /> +
+
, + { initialEntries, preloadedAuth: { token: 't', user: ADMIN } }, + ); +} + +describe('AppLayout shell', () => { + it('renders TopBar + Outlet when ready', () => { + renderLayout(['/']); + // 顶栏(全部导航 + 当前用户) + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument(); + // Outlet 子内容 + expect(screen.getByTestId('home-outlet')).toBeInTheDocument(); + }); + + it('toggle 全部导航 opens/closes overlay', async () => { + renderLayout(['/']); + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + expect(screen.getByTestId('nav-overlay')).toBeInTheDocument(); + // 点遮罩关闭 + await userEvent.click(screen.getByTestId('nav-overlay-mask')); + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + }); + + it('nav overlay 用户列表 navigates and opens tab', async () => { + renderLayout(['/']); + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + // overlay 关闭 + expect(screen.queryByTestId('nav-overlay')).not.toBeInTheDocument(); + // URL 到 /usr/users + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); + // 顶栏出现「用户列表」标签并激活 + const tab = screen.getByTestId('tab-userlist'); + expect(tab).toHaveTextContent('用户列表'); + expect(tab.getAttribute('aria-pressed')).toBe('true'); + }); + + it('clicking home tab navigates back to /', async () => { + renderLayout(['/']); + // 先打开用户列表标签 + await userEvent.click(screen.getByRole('button', { name: '全部导航' })); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); + // 点主页标签回 / + await userEvent.click(screen.getByTestId('tab-home')); + expect(screen.getByTestId('loc').textContent).toBe('/'); + expect(screen.getByTestId('tab-home').getAttribute('aria-pressed')).toBe('true'); + }); + + it('active tab syncs with current route', () => { + renderLayout(['/usr/users']); + // 直接进 /usr/users,用户列表标签应存在且激活 + const tab = screen.getByTestId('tab-userlist'); + expect(tab.getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/frontend/tests/unit/AppLayout.topbar.test.tsx b/frontend/tests/unit/AppLayout.topbar.test.tsx new file mode 100644 index 0000000..d15713b --- /dev/null +++ b/frontend/tests/unit/AppLayout.topbar.test.tsx @@ -0,0 +1,133 @@ +// REQ-USR-004: 顶栏结构 + 当前用户 + 退出登录(BR3/BR9) +import { describe, it, expect, vi } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'; +import { App as AntdApp } from 'antd'; +import { renderShell } from './renderShell'; +import TopBar from '../../src/layouts/AppLayout/TopBar'; +import { useAppDispatch } from '../../src/store/hooks'; +import { clearCredentials } from '../../src/store/slices/authSlice'; +import { LOGOUT_SUCCESS_TEXT } from '../../src/layouts/AppLayout/shellMessages'; +import { HOME_TAB, BIZ_TABS, type TabItem } from '../../src/layouts/AppLayout/useTabStack'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +// 顶栏测试宿主:以真实 onLogout(dispatch + message + navigate)驱动,模拟 AppLayout 提供的回调 +function TopBarHost({ + user, + tabs, + activeKey = 'home', + navOverlayOpen = false, + onToggleNav = vi.fn(), + onSelectTab = vi.fn(), + onCloseTab = vi.fn(), + onLogoHome = vi.fn(), +}: { + user: AuthUser | null; + tabs: TabItem[]; + activeKey?: string; + navOverlayOpen?: boolean; + onToggleNav?: () => void; + onSelectTab?: (k: string) => void; + onCloseTab?: (k: string) => void; + onLogoHome?: () => void; +}) { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { message } = AntdApp.useApp(); + const handleLogout = () => { + dispatch(clearCredentials()); + message.success(LOGOUT_SUCCESS_TEXT); + navigate('/login', { replace: true }); + }; + return ( + + ); +} + +function LoginProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +describe('TopBar', () => { + it('renders brand logo / 全部导航 button / 主页 tab', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '品牌Logo 回到主页' })).toBeInTheDocument(); + const homeTab = screen.getByTestId('tab-home'); + expect(homeTab).toHaveTextContent('主页'); + // 主页 tab 无关闭按钮 + expect(within(homeTab).queryByText('✕')).not.toBeInTheDocument(); + }); + + it('renders current user as sUserName(sUserType)', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument(); + }); + + it('user fallback when user is null', () => { + renderShell(, { + preloadedAuth: { token: 't', user: null }, + }); + expect(screen.getByText('未登录用户')).toBeInTheDocument(); + }); + + it('logout menu dispatches clearCredentials, shows success, navigates /login', async () => { + localStorage.setItem('xly_erp_token', 't'); + const { getState } = renderShell( + + } /> + } /> + , + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } }, + ); + // 展开当前用户下拉 + await userEvent.click(screen.getByText('朱子纯(超级管理员)')); + const logout = await screen.findByText('退出登录'); + await userEvent.click(logout); + expect(getState().auth.token).toBeNull(); + expect(localStorage.getItem('xly_erp_token')).toBeNull(); + expect(await screen.findByText(LOGOUT_SUCCESS_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId('login-probe').textContent).toBe('/login'); + }); + + it('nav toggle button highlights when navOverlayOpen', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + const navBtn = screen.getByRole('button', { name: '全部导航' }); + expect(navBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('clicking business tab close calls onCloseTab', async () => { + const onCloseTab = vi.fn(); + renderShell( + , + { preloadedAuth: { token: 't', user: ADMIN } }, + ); + await userEvent.click(screen.getByTestId('tab-close-userlist')); + expect(onCloseTab).toHaveBeenCalledWith('userlist'); + }); +}); diff --git a/frontend/tests/unit/AppLayout.unauthorized.test.tsx b/frontend/tests/unit/AppLayout.unauthorized.test.tsx new file mode 100644 index 0000000..9b7d457 --- /dev/null +++ b/frontend/tests/unit/AppLayout.unauthorized.test.tsx @@ -0,0 +1,67 @@ +// REQ-USR-004: AppLayout 注册 401 登出处理(壳层接线,BR10) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { screen, act } from '@testing-library/react'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import AppLayout from '../../src/layouts/AppLayout/AppLayout'; +import { + registerUnauthorizedHandler, + TOKEN_STORAGE_KEY, +} from '../../src/api/request'; +import { SESSION_EXPIRED_TEXT } from '../../src/layouts/AppLayout/shellMessages'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function LocProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +describe('AppLayout 401 登出接线', () => { + let captured: (() => void) | null = null; + + beforeEach(() => { + captured = null; + }); + + afterEach(() => { + registerUnauthorizedHandler(null); + vi.restoreAllMocks(); + }); + + it('registers onUnauthorized on mount; invoking it clears auth + warns + navigates /login', async () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 't'); + // 拦截真实注册:把回调存进 captured,同时透传给真实单例 + const origRegister = registerUnauthorizedHandler; + const restore = vi + .spyOn(await import('../../src/api/request'), 'registerUnauthorizedHandler') + .mockImplementation((fn) => { + if (fn) captured = fn as () => void; + origRegister(fn); + }); + + const { getState } = renderShell( + + }> + } /> + + } /> + , + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } }, + ); + + expect(captured).toBeTypeOf('function'); + + await act(async () => { + captured!(); + }); + + expect(getState().auth.token).toBeNull(); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(await screen.findByText(SESSION_EXPIRED_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId('loc').textContent).toBe('/login'); + + restore.mockRestore(); + }); +}); diff --git a/frontend/tests/unit/HomePage.test.tsx b/frontend/tests/unit/HomePage.test.tsx new file mode 100644 index 0000000..e2d35c5 --- /dev/null +++ b/frontend/tests/unit/HomePage.test.tsx @@ -0,0 +1,76 @@ +// REQ-USR-003: HomePage 落地页区域组合(KPI 头条 / 角色树 / 常用操作 / 页脚,BR8/BR11) +import { describe, it, expect } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import HomePage from '../../src/pages/home/HomePage/HomePage'; + +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderHome() { + return renderShell( + <> + + + } /> + users
} /> + + , + { + initialEntries: ['/'], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }, + ); +} + +describe('HomePage', () => { + it('renders KPI head with title and stats', () => { + renderHome(); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + expect(screen.getByText('今日未处理:')).toBeInTheDocument(); + expect(screen.getByText('37428')).toBeInTheDocument(); + expect(screen.getByText('未清总数:')).toBeInTheDocument(); + expect(screen.getByText('56433')).toBeInTheDocument(); + expect(screen.getByText('小ai同学,请帮我安排今日工作')).toBeInTheDocument(); + }); + + it('renders role/process tree groups', () => { + renderHome(); + const tree = within(screen.getByTestId('role-tree')); + expect(tree.getByText('按角色')).toBeInTheDocument(); + expect(tree.getByText('按流程')).toBeInTheDocument(); + expect(tree.getByText(/所有部门/)).toBeInTheDocument(); + expect(tree.getByText(/客服部/)).toBeInTheDocument(); + }); + + it('tree item click highlights without navigation', async () => { + renderHome(); + const tree = within(screen.getByTestId('role-tree')); + const item = tree.getByRole('button', { name: /核价人员/ }); + await userEvent.click(item); + // 高亮(aria-pressed),不触发路由跳转 + expect(item).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('loc').textContent).toBe('/'); + }); + + it('common ops user-list click navigates to /usr/users', async () => { + renderHome(); + // 常用操作卡内「用户列表」 + const opsUserList = screen.getByRole('button', { name: '用户列表' }); + await userEvent.click(opsUserList); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users'); + }); + + it('renders footer copyright text', () => { + renderHome(); + expect(screen.getByText(/©Copyright Antler Software/)).toBeInTheDocument(); + expect(screen.getByText(/沪ICP备14034791号-1/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/KpiBoard.test.tsx b/frontend/tests/unit/KpiBoard.test.tsx new file mode 100644 index 0000000..10f7fda --- /dev/null +++ b/frontend/tests/unit/KpiBoard.test.tsx @@ -0,0 +1,50 @@ +// REQ-USR-003: KpiBoard KPI 合并网格 + 空数据(BR11 / empty 态 / D5) +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfigProvider, App as AntdApp } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import KpiBoard from '../../src/pages/home/HomePage/KpiBoard'; +import { KPI_ROWS, KPI_HEADERS } from '../../src/pages/home/HomePage/dashboardData'; + +function renderBoard(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +describe('KpiBoard', () => { + it('renders 7 column headers', () => { + renderBoard(); + KPI_HEADERS.forEach((h) => { + expect(screen.getByText(h)).toBeInTheDocument(); + }); + }); + + it('renders all kpi rows with item/desc/today/total', () => { + renderBoard(); + expect(screen.getByText('01/04【新增】新报价单')).toBeInTheDocument(); + expect(screen.getByText('02/04 审核后报价单->客户确认价格')).toBeInTheDocument(); + // 红色统计数(red 行) + const red = screen.getAllByText('16'); + expect(red.length).toBeGreaterThanOrEqual(1); + }); + + it('renders Empty when rows is empty', () => { + renderBoard(); + expect(screen.getByTestId('kpi-empty')).toBeInTheDocument(); + // AntD Empty 默认描述「暂无数据」(可能在

与 svg 各出现一次) + expect(screen.getAllByText('暂无数据').length).toBeGreaterThanOrEqual(1); + }); + + it('KPI item/desc rendered as link-styled text without navigation', async () => { + const onNav = vi.fn(); + renderBoard(<KpiBoard rows={KPI_ROWS} onNavigate={onNav} />); + const item = screen.getByText('01/04【新增】新报价单'); + await userEvent.click(item); + // 纯展示,不发生路由跳转 + expect(onNav).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/LoginPage.companies.test.tsx b/frontend/tests/unit/LoginPage.companies.test.tsx new file mode 100644 index 0000000..39040f7 --- /dev/null +++ b/frontend/tests/unit/LoginPage.companies.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>; + +describe('LoginPage 版本下拉状态机', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('shows loading placeholder and disabled select while fetching', async () => { + // 永不 resolve 的 promise → 保持 companiesLoading + mockedFetch.mockReturnValue(new Promise(() => {})); + renderWithProviders(<LoginPage />); + // loading 占位文案 + expect(await screen.findByText(/加载版本中/)).toBeInTheDocument(); + // Select 处禁用态 + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeDisabled(); + }); + + it('renders options with label rule on resolve (idle)', async () => { + mockedFetch.mockResolvedValue([ + { id: 1, sCompanyName: '甲公司', sVersion: '标准版' }, + { id: 2, sCompanyName: '乙公司', sVersion: null }, + ]); + renderWithProviders(<LoginPage />); + // 加载完成后 placeholder 切到「请选择版本」 + expect(await screen.findByText('请选择版本')).toBeInTheDocument(); + const combobox = screen.getByRole('combobox'); + await waitFor(() => expect(combobox).not.toBeDisabled()); + const user = userEvent.setup(); + await user.click(combobox); + // 下拉项 label 规则(D8) + const listbox = await screen.findByRole('listbox'); + expect(within(listbox.parentElement as HTMLElement).getByText('甲公司(标准版)')).toBeInTheDocument(); + expect(within(listbox.parentElement as HTMLElement).getByText('乙公司')).toBeInTheDocument(); + }); + + it('auto-selects when single option', async () => { + mockedFetch.mockResolvedValue([{ id: 7, sCompanyName: '独苗公司', sVersion: '专业版' }]); + renderWithProviders(<LoginPage />); + // 单项自动选中 → 选中项 label 出现在选择框中 + expect(await screen.findByText('独苗公司(专业版)')).toBeInTheDocument(); + }); + + it('empty state when companies is empty', async () => { + mockedFetch.mockResolvedValue([]); + renderWithProviders(<LoginPage />); + // 轻量提示 + expect(await screen.findByText('未获取到可登录版本,请联系管理员')).toBeInTheDocument(); + }); + + it('shows error with retry when fetch fails and retry re-calls', async () => { + mockedFetch.mockRejectedValueOnce(new Error('boom')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('版本加载失败')).toBeInTheDocument(); + const retry = screen.getByRole('button', { name: /点击重试/ }); + // 重试时返回正常数据 + mockedFetch.mockResolvedValueOnce([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]); + const user = userEvent.setup(); + await user.click(retry); + await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/tests/unit/LoginPage.error.test.tsx b/frontend/tests/unit/LoginPage.error.test.tsx new file mode 100644 index 0000000..0707529 --- /dev/null +++ b/frontend/tests/unit/LoginPage.error.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const messageSpy = { success: vi.fn(), error: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies, login } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { ApiError, NETWORK_ERROR_CODE } from '../../src/api/request'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>; +const mockedLogin = login as unknown as ReturnType<typeof vi.fn>; + +async function fillAndSubmit() { + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); + await user.click(screen.getByRole('button', { name: /登\s*录/ })); + return user; +} + +describe('LoginPage 登录失败错误码分流', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockedFetch.mockResolvedValue([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]); + }); + + it('40101 shows 用户名或密码错误 and clears+focuses password', async () => { + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('用户名或密码错误')); + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement; + await waitFor(() => expect(password.value).toBe('')); + expect(document.activeElement).toBe(password); + }); + + it('40302 shows 该账号已被禁用,请联系管理员', async () => { + mockedLogin.mockRejectedValue(new ApiError(40302, '已禁用')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('该账号已被禁用,请联系管理员'), + ); + }); + + it('42901 shows 登录尝试过于频繁,请稍后再试 and clears password', async () => { + mockedLogin.mockRejectedValue(new ApiError(42901, '限流')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('登录尝试过于频繁,请稍后再试'), + ); + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement; + await waitFor(() => expect(password.value).toBe('')); + }); + + it('40001 shows 请填写用户名、密码并选择版本', async () => { + mockedLogin.mockRejectedValue(new ApiError(40001, '参数错误')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('请填写用户名、密码并选择版本'), + ); + }); + + it('network error shows 网络异常,请稍后重试', async () => { + mockedLogin.mockRejectedValue(new ApiError(NETWORK_ERROR_CODE, '网络异常')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('网络异常,请稍后重试')); + }); + + it('button recovers clickable and username/version preserved after failure', async () => { + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败')); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalled()); + const submit = screen.getByRole('button', { name: /登\s*录/ }); + await waitFor(() => expect(submit).not.toHaveClass('ant-btn-loading')); + expect(submit).not.toBeDisabled(); + // 用户名保留 + expect((screen.getByPlaceholderText('请输入你的用户名') as HTMLInputElement).value).toBe('admin'); + // 版本保留(单项自动选中仍在) + expect(screen.getByText('甲公司(标准版)')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/LoginPage.layout.test.tsx b/frontend/tests/unit/LoginPage.layout.test.tsx new file mode 100644 index 0000000..d4440ed --- /dev/null +++ b/frontend/tests/unit/LoginPage.layout.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; + +// 隔离网络:版本取数返回空列表,避免 act 警告与真实请求 +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn().mockResolvedValue([]), + login: vi.fn(), +})); + +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { renderWithProviders } from './renderLogin'; + +describe('LoginPage 布局', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders brand header / hero slogan / footer', () => { + renderWithProviders(<LoginPage />); + expect(screen.getByText('Antler ERP')).toBeInTheDocument(); + expect(screen.getByText('欢迎登录EBC平台')).toBeInTheDocument(); + expect(screen.getByText('企业业务能力平台')).toBeInTheDocument(); + expect(screen.getByText('ERP')).toBeInTheDocument(); + // 页脚版权 + 备案号 + expect(screen.getByText(/Antler Software/)).toBeInTheDocument(); + expect(screen.getByText(/沪ICP备14034791号-1/)).toBeInTheDocument(); + }); + + it('renders login card title 用户登录', () => { + renderWithProviders(<LoginPage />); + expect(screen.getByText('用户登录')).toBeInTheDocument(); + }); + + it('renders username/password/version fields and submit button 登 录', () => { + renderWithProviders(<LoginPage />); + const username = screen.getByPlaceholderText('请输入你的用户名'); + expect(username).toBeInTheDocument(); + const password = screen.getByPlaceholderText('请输入你的密码'); + expect(password).toBeInTheDocument(); + // BR3:密码掩码显示 + expect(password).toHaveAttribute('type', 'password'); + // 版本下拉 placeholder(idle 后为「请选择版本」,初始 loading 为「加载版本中…」) + expect( + screen.getByText((t) => t.includes('请选择版本') || t.includes('加载版本中')), + ).toBeInTheDocument(); + // 提交按钮 + expect(screen.getByRole('button', { name: /登\s*录/ })).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/LoginPage.submitting.test.tsx b/frontend/tests/unit/LoginPage.submitting.test.tsx new file mode 100644 index 0000000..0a1943c --- /dev/null +++ b/frontend/tests/unit/LoginPage.submitting.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies, login } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>; +const mockedLogin = login as unknown as ReturnType<typeof vi.fn>; + +// 单项版本已自动选中(spec § 6.3),只需填用户名 / 密码即构成可提交表单 +async function fillForm(user: ReturnType<typeof userEvent.setup>) { + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); +} + +describe('LoginPage 提交中态', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockedFetch.mockResolvedValue([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]); + }); + + it('button loading and fields disabled while submitting', async () => { + mockedLogin.mockReturnValue(new Promise(() => {})); // pending + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); + const submit = screen.getByRole('button', { name: /登\s*录/ }); + await user.click(submit); + // 按钮 loading + await waitFor(() => expect(submit).toHaveClass('ant-btn-loading')); + // 字段禁用 + expect(screen.getByPlaceholderText('请输入你的用户名')).toBeDisabled(); + expect(screen.getByPlaceholderText('请输入你的密码')).toBeDisabled(); + }); + + it('ignores duplicate submit while pending', async () => { + mockedLogin.mockReturnValue(new Promise(() => {})); // pending + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + const user = userEvent.setup(); + await fillForm(user); + const submit = screen.getByRole('button', { name: /登\s*录/ }); + await user.click(submit); + await waitFor(() => expect(mockedLogin).toHaveBeenCalledTimes(1)); + // pending 期间再次点击 + 回车 + await user.click(submit); + await user.keyboard('{Enter}'); + // 仍只调用一次 + await new Promise((r) => setTimeout(r, 50)); + expect(mockedLogin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/unit/LoginPage.success.test.tsx b/frontend/tests/unit/LoginPage.success.test.tsx new file mode 100644 index 0000000..7308646 --- /dev/null +++ b/frontend/tests/unit/LoginPage.success.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// 捕获 navigate 调用 +const navigateSpy = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom'); + return { ...actual, useNavigate: () => navigateSpy }; +}); + +// 捕获 message 调用:保留 antd 其余真实导出,仅桩 App.useApp 返回的 message +const messageSpy = { success: vi.fn(), error: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies, login } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { TOKEN_STORAGE_KEY } from '../../src/api/request'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>; +const mockedLogin = login as unknown as ReturnType<typeof vi.fn>; + +describe('LoginPage 登录成功', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockedFetch.mockResolvedValue([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]); + }); + + async function submitLogin() { + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); + await user.click(screen.getByRole('button', { name: /登\s*录/ })); + } + + it('dispatches setCredentials and persists token on success', async () => { + mockedLogin.mockResolvedValue({ + token: 'tk', + user: { id: 1, sUserName: 'admin', sUserType: '超级管理员', sLanguage: '中文' }, + }); + const { getState } = renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await submitLogin(); + await waitFor(() => expect(getState().auth.token).toBe('tk')); + expect(getState().auth.user).toEqual({ + id: 1, + sUserName: 'admin', + sUserType: '超级管理员', + sLanguage: '中文', + }); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBe('tk'); + }); + + it('shows success message and navigates to / with replace', async () => { + mockedLogin.mockResolvedValue({ + token: 'tk', + user: { id: 1, sUserName: 'admin', sUserType: '超级管理员', sLanguage: '中文' }, + }); + renderWithProviders(<LoginPage />); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await submitLogin(); + await waitFor(() => expect(messageSpy.success).toHaveBeenCalledWith('登录成功')); + expect(navigateSpy).toHaveBeenCalledWith('/', { replace: true }); + }); +}); diff --git a/frontend/tests/unit/LoginPage.validation.test.tsx b/frontend/tests/unit/LoginPage.validation.test.tsx new file mode 100644 index 0000000..53e2ab8 --- /dev/null +++ b/frontend/tests/unit/LoginPage.validation.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies, login } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>; +const mockedLogin = login as unknown as ReturnType<typeof vi.fn>; + +async function selectVersion(user: ReturnType<typeof userEvent.setup>, label: string) { + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + const option = await screen.findByText(label); + await user.click(option); +} + +describe('LoginPage 必填校验', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockedFetch.mockResolvedValue([ + { id: 1, sCompanyName: '甲公司', sVersion: '标准版' }, + { id: 2, sCompanyName: '乙公司', sVersion: null }, + ]); + }); + + it('blocks submit and shows required messages when empty', async () => { + renderWithProviders(<LoginPage />); + // 等待版本加载完成 + expect(await screen.findByText('请选择版本')).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /登\s*录/ })); + // 校验错误文案渲染在 .ant-form-item-explain-error,逐条断言(避免与 Select placeholder 文案冲突) + await waitFor(() => { + const errors = Array.from( + document.querySelectorAll('.ant-form-item-explain-error'), + ).map((el) => el.textContent); + expect(errors).toContain('请输入用户名'); + expect(errors).toContain('请输入密码'); + expect(errors).toContain('请选择版本'); + }); + expect(mockedLogin).not.toHaveBeenCalled(); + }); + + it('submits with payload when all filled', async () => { + mockedLogin.mockReturnValue(new Promise(() => {})); // 挂起,只断言入参 + renderWithProviders(<LoginPage />); + expect(await screen.findByText('请选择版本')).toBeInTheDocument(); + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); + await selectVersion(user, '乙公司'); + await user.click(screen.getByRole('button', { name: /登\s*录/ })); + await waitFor(() => + expect(mockedLogin).toHaveBeenCalledWith({ + sUserName: 'admin', + password: 'secret', + companyId: 2, + }), + ); + }); +}); diff --git a/frontend/tests/unit/NavOverlay.test.tsx b/frontend/tests/unit/NavOverlay.test.tsx new file mode 100644 index 0000000..9ca6b71 --- /dev/null +++ b/frontend/tests/unit/NavOverlay.test.tsx @@ -0,0 +1,74 @@ +// REQ-USR-003: NavOverlay 全部导航总览(开关 / 分组渲染 / 路由项与占位项,BR7/BR8/D4) +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfigProvider, App as AntdApp } from 'antd'; +import NavOverlay from '../../src/layouts/AppLayout/NavOverlay'; + +function renderOverlay(props: Partial<React.ComponentProps<typeof NavOverlay>> = {}) { + const onClose = props.onClose ?? vi.fn(); + const onNavigate = props.onNavigate ?? vi.fn(); + const onPlaceholder = props.onPlaceholder ?? vi.fn(); + const open = props.open ?? true; + render( + <ConfigProvider> + <AntdApp> + <NavOverlay + open={open} + onClose={onClose} + onNavigate={onNavigate} + onPlaceholder={onPlaceholder} + /> + </AntdApp> + </ConfigProvider>, + ); + return { onClose, onNavigate, onPlaceholder }; +} + +describe('NavOverlay', () => { + it('hidden when open is false / visible when true', () => { + const { unmount } = render( + <ConfigProvider> + <AntdApp> + <NavOverlay open={false} onClose={vi.fn()} onNavigate={vi.fn()} onPlaceholder={vi.fn()} /> + </AntdApp> + </ConfigProvider>, + ); + expect(screen.queryByText('期初设置')).not.toBeInTheDocument(); + unmount(); + + renderOverlay({ open: true }); + expect(screen.getByText('期初设置')).toBeInTheDocument(); + expect(screen.getByText('API对接管理')).toBeInTheDocument(); + }); + + it('side has 系统设置 active', () => { + renderOverlay({ open: true }); + const side = screen.getByTestId('nav-side'); + const sys = within(side).getByText('系统设置'); + expect(sys.closest('[aria-current]')?.getAttribute('aria-current')).toBe('true'); + }); + + it("clicking 用户列表 calls onNavigate('/usr/users')", async () => { + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + expect(onNavigate).toHaveBeenCalledWith('/usr/users'); + expect(onPlaceholder).not.toHaveBeenCalled(); + }); + + it('clicking placeholder leaf calls onPlaceholder (no navigate)', async () => { + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); + await userEvent.click(screen.getByRole('button', { name: '系统权限' })); + expect(onPlaceholder).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it('Esc / mask click calls onClose', async () => { + const { onClose } = renderOverlay({ open: true }); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalled(); + + await userEvent.click(screen.getByTestId('nav-overlay-mask')); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/tests/unit/PermissionGroupList.test.tsx b/frontend/tests/unit/PermissionGroupList.test.tsx new file mode 100644 index 0000000..7c7eb63 --- /dev/null +++ b/frontend/tests/unit/PermissionGroupList.test.tsx @@ -0,0 +1,102 @@ +// REQ-USR-001 / REQ-USR-002: PermissionGroupList 权限分类勾选列表单测(渲染/勾选集合/全选 indeterminate/回勾,BR10/BR11/D3) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import PermissionGroupList from '../../src/pages/usr/UserDetail/PermissionGroupList'; +import type { PermissionItem } from '../../src/api/types'; + +const PERMS: PermissionItem[] = [ + { id: 1, name: '默认显示', category: '基础' }, + { id: 2, name: '高级查看', category: '基础' }, + { id: 3, name: '导出', category: '报表' }, +]; + +function setup(over: { + permissions?: PermissionItem[]; + checkedIds?: number[]; +} = {}) { + const onToggle = vi.fn(); + const onToggleAll = vi.fn(); + renderShell( + <PermissionGroupList + permissions={over.permissions ?? PERMS} + checkedIds={over.checkedIds ?? []} + onToggle={onToggle} + onToggleAll={onToggleAll} + />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onToggle, onToggleAll }; +} + +function rowCheckbox(id: number): HTMLInputElement { + return screen.getByTestId('perm-check-' + id) as HTMLInputElement; +} + +function allCheckbox(): HTMLInputElement { + return screen.getByTestId('perm-check-all') as HTMLInputElement; +} + +describe('PermissionGroupList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders header 权限分类 and one row per permission', () => { + setup(); + expect(screen.getByText('权限分类')).toBeInTheDocument(); + expect(screen.getByText('默认显示')).toBeInTheDocument(); + expect(screen.getByText('高级查看')).toBeInTheDocument(); + expect(screen.getByText('导出')).toBeInTheDocument(); + }); + + it('checked rows reflect checkedIds', () => { + setup({ checkedIds: [1] }); + expect(rowCheckbox(1).checked).toBe(true); + expect(rowCheckbox(2).checked).toBe(false); + expect(rowCheckbox(3).checked).toBe(false); + }); + + it('toggling a row calls onToggle(id, checked)', async () => { + const user = userEvent.setup(); + const { onToggle } = setup({ checkedIds: [1] }); + await user.click(rowCheckbox(2)); + expect(onToggle).toHaveBeenCalledWith(2, true); + await user.click(rowCheckbox(1)); + expect(onToggle).toHaveBeenCalledWith(1, false); + }); + + it('header select-all checked when all selected; indeterminate when partial', () => { + setup({ checkedIds: [1, 2, 3] }); + expect(allCheckbox().checked).toBe(true); + }); + + it('header indeterminate when partial; unchecked when none', () => { + setup({ checkedIds: [1] }); + const all = allCheckbox(); + expect(all.checked).toBe(false); + // AntD 半选用 aria-checked='mixed' 表达于 wrapper;input 仍未 checked + const wrapper = all.closest('.ant-checkbox'); + expect(wrapper?.classList.contains('ant-checkbox-indeterminate')).toBe(true); + }); + + it('header toggle calls onToggleAll', async () => { + const user = userEvent.setup(); + const { onToggleAll } = setup({ checkedIds: [] }); + await user.click(allCheckbox()); + expect(onToggleAll).toHaveBeenCalledWith(true); + }); + + it('header toggle off when all selected', async () => { + const user = userEvent.setup(); + const { onToggleAll } = setup({ checkedIds: [1, 2, 3] }); + await user.click(allCheckbox()); + expect(onToggleAll).toHaveBeenCalledWith(false); + }); + + it('empty permissions renders empty list (no rows)', () => { + setup({ permissions: [] }); + expect(screen.queryByTestId('perm-check-1')).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/PermissionTabs.test.tsx b/frontend/tests/unit/PermissionTabs.test.tsx new file mode 100644 index 0000000..9b3128e --- /dev/null +++ b/frontend/tests/unit/PermissionTabs.test.tsx @@ -0,0 +1,40 @@ +// REQ-USR-001: PermissionTabs 权限页签条单测(权限组 active + 5 占位页签 disabled,D9) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderShell } from './renderShell'; +import PermissionTabs from '../../src/pages/usr/UserDetail/PermissionTabs'; +import { PLACEHOLDER_TABS } from '../../src/pages/usr/UserDetail/constants'; + +function setup() { + renderShell( + <PermissionTabs> + <div data-testid="perm-children">权限列表内容</div> + </PermissionTabs>, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); +} + +describe('PermissionTabs', () => { + it('renders 权限组 active with children', () => { + setup(); + const groupTab = screen.getByRole('tab', { name: '权限组' }); + expect(groupTab).toBeInTheDocument(); + expect(groupTab.getAttribute('aria-selected')).toBe('true'); + expect(screen.getByTestId('perm-children')).toBeInTheDocument(); + }); + + it('renders 5 placeholder tabs disabled', () => { + setup(); + for (const name of PLACEHOLDER_TABS) { + const tab = screen.getByRole('tab', { name }); + expect(tab).toBeInTheDocument(); + expect(tab.getAttribute('aria-disabled')).toBe('true'); + } + }); + + it('placeholder tabs do not render permission list content', () => { + setup(); + // 占位页签 disabled 无法切换,权限列表内容只在权限组面板内渲染(只 1 处) + expect(screen.getAllByTestId('perm-children')).toHaveLength(1); + }); +}); diff --git a/frontend/tests/unit/RedirectIfAuthed.test.tsx b/frontend/tests/unit/RedirectIfAuthed.test.tsx new file mode 100644 index 0000000..2ce6ffa --- /dev/null +++ b/frontend/tests/unit/RedirectIfAuthed.test.tsx @@ -0,0 +1,60 @@ +// REQ-USR-004: RedirectIfAuthed —— 已登录访问 /login 回主页(BR2) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import { renderShell, type RenderShellOptions } from './renderShell'; +import RedirectIfAuthed from '../../src/router/RedirectIfAuthed'; + +function LoginScreen() { + return <div data-testid="login-screen">login-screen</div>; +} +function HomeSentinel() { + return <div data-testid="home-sentinel">home-sentinel</div>; +} +function UsersSentinel() { + return <div data-testid="users-sentinel">users-sentinel</div>; +} + +function renderTree( + initialEntries: { pathname: string; state?: unknown }[] | string[], + preloadedAuth?: RenderShellOptions['preloadedAuth'], +) { + return renderShell( + <Routes> + <Route + path="/login" + element={ + <RedirectIfAuthed> + <LoginScreen /> + </RedirectIfAuthed> + } + /> + <Route path="/" element={<HomeSentinel />} /> + <Route path="/usr/users" element={<UsersSentinel />} /> + </Routes>, + { initialEntries: initialEntries as never, preloadedAuth }, + ); +} + +const READY_USER = { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, +}; + +describe('RedirectIfAuthed', () => { + it('renders children when unauthenticated', () => { + renderTree(['/login'], { token: null, user: null }); + expect(screen.getByTestId('login-screen')).toBeInTheDocument(); + }); + + it('redirects to / when already authenticated', () => { + renderTree(['/login'], READY_USER); + expect(screen.getByTestId('home-sentinel')).toBeInTheDocument(); + expect(screen.queryByTestId('login-screen')).not.toBeInTheDocument(); + }); + + it('redirects to from when present', () => { + renderTree([{ pathname: '/login', state: { from: '/usr/users' } }], READY_USER); + expect(screen.getByTestId('users-sentinel')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/RequireAuth.test.tsx b/frontend/tests/unit/RequireAuth.test.tsx new file mode 100644 index 0000000..4a5945f --- /dev/null +++ b/frontend/tests/unit/RequireAuth.test.tsx @@ -0,0 +1,53 @@ +// REQ-USR-004: RequireAuth 守卫三态(BR1) — authResolving / unauthenticated / ready +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell, type RenderShellOptions } from './renderShell'; +import RequireAuth from '../../src/router/RequireAuth'; + +// 哨兵:登录页读出 state.from,便于断言重定向携带来源 +function LoginSentinel() { + const loc = useLocation(); + const from = (loc.state as { from?: string } | null)?.from; + return <div data-testid="login-sentinel">login from={from ?? 'none'}</div>; +} + +function ProtectedSentinel() { + return <div data-testid="protected-sentinel">protected-content</div>; +} + +function renderGuard(initialEntries: string[], preloadedAuth?: RenderShellOptions['preloadedAuth']) { + return renderShell( + <Routes> + <Route path="/login" element={<LoginSentinel />} /> + <Route element={<RequireAuth />}> + <Route path="/" element={<ProtectedSentinel />} /> + </Route> + </Routes>, + { initialEntries, preloadedAuth }, + ); +} + +describe('RequireAuth', () => { + it('redirects to /login when no token', () => { + renderGuard(['/'], { token: null, user: null }); + expect(screen.getByTestId('login-sentinel')).toBeInTheDocument(); + expect(screen.getByText(/from=\//)).toBeInTheDocument(); + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); + }); + + it('renders Spin placeholder when token present but user not resolved', () => { + renderGuard(['/'], { token: 't', user: null }); + expect(screen.getByTestId('auth-resolving')).toBeInTheDocument(); + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('login-sentinel')).not.toBeInTheDocument(); + }); + + it('renders protected content when token and user ready', () => { + renderGuard(['/'], { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }); + expect(screen.getByTestId('protected-sentinel')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/UserBasicForm.test.tsx b/frontend/tests/unit/UserBasicForm.test.tsx new file mode 100644 index 0000000..7135362 --- /dev/null +++ b/frontend/tests/unit/UserBasicForm.test.tsx @@ -0,0 +1,153 @@ +// REQ-USR-001 / REQ-USR-002: UserBasicForm 表单网格单测(字段/默认/只读/枚举/必填+格式/员工联动,BR1-BR9) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Form } from 'antd'; +import { renderShell } from './renderShell'; +import UserBasicForm from '../../src/pages/usr/UserDetail/UserBasicForm'; +import { CREATE_DEFAULTS, type UserFormValues } from '../../src/pages/usr/UserDetail/constants'; +import type { EmployeeOption } from '../../src/api/types'; + +const EMPLOYEES: EmployeeOption[] = [ + { value: 3, label: '张三', sEmployeeNo: 'zs' }, + { value: 4, label: '李四', sEmployeeNo: 'ls' }, +]; + +interface HarnessProps { + mode?: 'create' | 'edit'; + initialValues?: Partial<UserFormValues>; + onSelectEmployee?: (v: number | null) => void; + readonlyCreator?: string; + exposeSubmit?: (submit: () => void) => void; +} + +function Harness({ + mode = 'create', + initialValues, + onSelectEmployee = () => {}, + readonlyCreator, + exposeSubmit, +}: HarnessProps) { + const [form] = Form.useForm<UserFormValues>(); + exposeSubmit?.(() => form.submit()); + return ( + <Form + form={form} + initialValues={{ ...CREATE_DEFAULTS, ...initialValues }} + onFinish={() => {}} + > + <UserBasicForm + form={form} + mode={mode} + employees={EMPLOYEES} + readonlyCreator={readonlyCreator} + onSelectEmployee={onSelectEmployee} + /> + </Form> + ); +} + +function setup(props: HarnessProps = {}) { + let submit = () => {}; + renderShell( + <Harness {...props} exposeSubmit={(s) => { submit = s; }} />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { triggerSubmit: () => submit() }; +} + +describe('UserBasicForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 8 labeled fields', () => { + setup(); + expect(screen.getByText('创建时间')).toBeInTheDocument(); + expect(screen.getByText('制单人')).toBeInTheDocument(); + expect(screen.getByText('员工名')).toBeInTheDocument(); + expect(screen.getByText('用户名')).toBeInTheDocument(); + expect(screen.getByText('类型')).toBeInTheDocument(); + expect(screen.getByText('语言')).toBeInTheDocument(); + expect(screen.getByText('用户号')).toBeInTheDocument(); + expect(screen.getByText('单据修改权限')).toBeInTheDocument(); + }); + + it('create mode username editable; edit mode username disabled', () => { + const { unmount } = renderShell( + <Harness mode="create" />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByTestId('field-username')).not.toBeDisabled(); + unmount(); + renderShell( + <Harness mode="edit" initialValues={{ sUserName: 'zhangsan' }} />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByTestId('field-username')).toBeDisabled(); + }); + + it('create mode defaults usertype 普通用户', () => { + setup({ mode: 'create' }); + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); + }); + + it('username format rule rejects short/invalid and required when empty', async () => { + const user = userEvent.setup(); + const { triggerSubmit } = setup({ mode: 'create' }); + const input = screen.getByTestId('field-username') as HTMLInputElement; + await user.type(input, 'ab'); + triggerSubmit(); + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); + await user.clear(input); + triggerSubmit(); + expect(await screen.findByText('请输入用户名')).toBeInTheDocument(); + }); + + it('userno required', async () => { + const { triggerSubmit } = setup({ mode: 'create', initialValues: { sUserName: 'zhangsan', sUserNo: '' } }); + triggerSubmit(); + expect(await screen.findByText('请输入用户号')).toBeInTheDocument(); + }); + + it('usertype/language selects expose enum options only', async () => { + const user = userEvent.setup(); + setup({ mode: 'create' }); + await user.click(screen.getByTestId('select-usertype').querySelector('.ant-select-selector')!); + await waitFor(() => expect(screen.getAllByText('超级管理员').length).toBeGreaterThan(0)); + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); + await waitFor(() => { + expect(screen.getByText('英文')).toBeInTheDocument(); + expect(screen.getByText('繁体')).toBeInTheDocument(); + }); + }); + + it('create mode creator shows 保存后自动生成; edit shows readonlyCreator', () => { + const { unmount } = renderShell( + <Harness mode="create" />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByText('保存后自动生成')).toBeInTheDocument(); + unmount(); + renderShell( + <Harness mode="edit" readonlyCreator="admin" />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('selecting employee calls onSelectEmployee', async () => { + const user = userEvent.setup(); + const onSelectEmployee = vi.fn(); + setup({ mode: 'create', onSelectEmployee }); + await user.click(screen.getByTestId('select-employee').querySelector('.ant-select-selector')!); + await user.click(await screen.findByText('张三')); + expect(onSelectEmployee).toHaveBeenCalledWith(3); + }); + + it('单据修改权限 checkbox default unchecked (create)', () => { + setup({ mode: 'create' }); + const cb = screen.getByTestId('field-canmodify') as HTMLInputElement; + expect(cb.checked).toBe(false); + }); +}); diff --git a/frontend/tests/unit/UserDetailPage.test.tsx b/frontend/tests/unit/UserDetailPage.test.tsx new file mode 100644 index 0000000..ac7a023 --- /dev/null +++ b/frontend/tests/unit/UserDetailPage.test.tsx @@ -0,0 +1,226 @@ +// REQ-USR-001 / REQ-USR-002: UserDetailPage 页面集成 + 路由接线 +// create/edit 贯通 + 提交回流 + 错误就近 + 取数失败(BR3/BR12/BR16/BR17/D4/D5) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; + +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ + createUser: vi.fn(), + updateUser: vi.fn(), + getUserDetail: vi.fn(), + listEmployees: vi.fn(), + listPermissions: vi.fn(), +})); + +import { + createUser, + updateUser, + getUserDetail, + listEmployees, + listPermissions, +} from '../../src/api/usrApi'; +import UserDetailPage from '../../src/pages/usr/UserDetail'; +import { ApiError } from '../../src/api/request'; +import { ERR_USERNAME_EXISTS } from '../../src/pages/usr/UserDetail/constants'; +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types'; + +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>; +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>; +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>; +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>; +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>; + +const EMPLOYEES: EmployeeOption[] = [{ value: 3, label: '张三', sEmployeeNo: 'zs' }]; +const PERMISSIONS: PermissionItem[] = [ + { id: 1, name: '默认显示', category: '基础' }, + { id: 2, name: '高级查看', category: '基础' }, +]; + +function makeVo(over: Partial<UserVO> = {}): UserVO { + return { + id: 7, + sUserName: 'zhangsan', + employeeName: '张三', + sUserNo: 'zs', + departmentName: null, + sUserType: '超级管理员', + sLanguage: '英文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + ...over, + }; +} + +function LocationProbe() { + const loc = useLocation(); + return <div data-testid="loc">{loc.pathname}</div>; +} + +// FE-04: edit 单据预填来自 FE-03 经 navigate state 透传的列表行(presetUser)。 +// 测试入口可携带 state.user 复刻该数据流(路由 :id 仅为主键,无 by-id 读端点, +// 不能按主键查「用户号」列——详见 useUserDetail / docs/05 REQ-USR-002/003)。 +function renderPage(entry: string, presetUser?: UserVO) { + const initialEntry = presetUser + ? { pathname: entry, state: { user: presetUser } } + : entry; + return renderShell( + <> + <LocationProbe /> + <Routes> + <Route path="/usr/users" element={<div data-testid="list-sentinel">list</div>} /> + <Route path="/usr/users/new" element={<UserDetailPage />} /> + <Route path="/usr/users/:id" element={<UserDetailPage />} /> + </Routes> + </>, + { + initialEntries: [initialEntry], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }, + ); +} + +async function fillValidCreateForm(user: ReturnType<typeof userEvent.setup>) { + await user.type(screen.getByTestId('field-username'), 'zhangsan'); + await user.type(screen.getByTestId('field-userno'), 'zs'); + // 语言必填 + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); + await user.click(await screen.findByText('中文')); +} + +describe('UserDetailPage 集成', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedEmployees.mockResolvedValue(EMPLOYEES); + mockedPermissions.mockResolvedValue(PERMISSIONS); + }); + + it('create mode renders empty form with defaults', async () => { + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + expect(await screen.findByText('保存后自动生成')).toBeInTheDocument(); + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); + }); + + it('create submit success navigates to /usr/users with success', async () => { + const user = userEvent.setup(); + mockedCreate.mockResolvedValue({ id: 9 }); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await fillValidCreateForm(user); + await user.click(screen.getByTestId('perm-check-1')); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); + const body = mockedCreate.mock.calls[0][0]; + expect(body.sUserName).toBe('zhangsan'); + expect(body.permissionIds).toContain(1); + expect(messageSpy.success).toHaveBeenCalledWith('用户创建成功'); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('create username format invalid blocks submit', async () => { + const user = userEvent.setup(); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await user.type(screen.getByTestId('field-username'), 'ab'); + await user.click(screen.getByTestId('btn-save')); + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); + expect(mockedCreate).not.toHaveBeenCalled(); + }); + + it('create 40901 highlights username field', async () => { + const user = userEvent.setup(); + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup')); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await fillValidCreateForm(user); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); + expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument(); + }); + + it('edit mode prefills from navigate state (presetUser) and username disabled', async () => { + // FE-04: edit 预填走 FE-03 经 navigate state 透传的列表行,不再按主键查列表端点 + renderPage('/usr/users/7', makeVo()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); + expect(mockedDetail).not.toHaveBeenCalled(); + expect(screen.getByTestId('field-username')).toBeDisabled(); + }); + + it('edit submit success navigates to /usr/users with 保存成功', async () => { + const user = userEvent.setup(); + mockedUpdate.mockResolvedValue({ id: 7 }); + renderPage('/usr/users/7', makeVo()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedUpdate).toHaveBeenCalled()); + expect(mockedUpdate.mock.calls[0][0]).toBe(7); + expect(messageSpy.success).toHaveBeenCalledWith('保存成功'); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('cancel with dirty form confirms then navigates', async () => { + const user = userEvent.setup(); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await user.type(screen.getByTestId('field-username'), 'dirtyuser'); + await user.click(screen.getByTestId('btn-cancel')); + // AntD Modal.confirm 弹确认 + expect((await screen.findAllByText('放弃未保存的修改?')).length).toBeGreaterThan(0); + await user.click(screen.getByRole('button', { name: /确\s*定|OK/ })); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('新增 navigates to /usr/users/new', async () => { + const user = userEvent.setup(); + // edit 经 navigate state 预填后,工具栏「新增」跳 /usr/users/new(BR14) + renderPage('/usr/users/7', makeVo()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); + await user.click(screen.getByTestId('btn-new')); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); + }); + + it('loadError shows retry; retry calls reload', async () => { + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net')); + renderPage('/usr/users/new'); + expect(await screen.findByTestId('userdetail-loaderror')).toBeInTheDocument(); + mockedPermissions.mockResolvedValue(PERMISSIONS); + const user = userEvent.setup(); + await user.click(within(screen.getByTestId('userdetail-loaderror')).getByText('点击重试')); + await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); + }); + + it('edit without navigate state shows loadError offering 点击重试 + 返回列表', async () => { + // FE-04 B1 fix: edit 缺 presetUser(直接访问 URL / 刷新丢 state)→ loadError, + // 整页给「点击重试」与「返回列表」两个入口(spec § 4 loadError)。 + renderPage('/usr/users/7'); + const loadError = await screen.findByTestId('userdetail-loaderror'); + expect(within(loadError).getByText('点击重试')).toBeInTheDocument(); + expect(within(loadError).getByText('返回列表')).toBeInTheDocument(); + expect(mockedDetail).not.toHaveBeenCalled(); + const user = userEvent.setup(); + await user.click(within(loadError).getByText('返回列表')); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); +}); diff --git a/frontend/tests/unit/UserDetailToolbar.test.tsx b/frontend/tests/unit/UserDetailToolbar.test.tsx new file mode 100644 index 0000000..dbfa37e --- /dev/null +++ b/frontend/tests/unit/UserDetailToolbar.test.tsx @@ -0,0 +1,90 @@ +// REQ-USR-001 / REQ-USR-002: UserDetailToolbar 工具栏单测(保存/取消/新增 + 提交中禁用 + 占位按钮,BR12/BR13/BR14/BR15/D8) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +import { renderShell } from './renderShell'; +import UserDetailToolbar from '../../src/pages/usr/UserDetail/UserDetailToolbar'; + +function setup(over: { mode?: 'create' | 'edit'; submitting?: boolean; canSave?: boolean } = {}) { + const onSave = vi.fn(); + const onCancel = vi.fn(); + const onNew = vi.fn(); + renderShell( + <UserDetailToolbar + mode={over.mode ?? 'create'} + submitting={over.submitting ?? false} + canSave={over.canSave ?? true} + onSave={onSave} + onCancel={onCancel} + onNew={onNew} + />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onSave, onCancel, onNew }; +} + +describe('UserDetailToolbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 保存/取消/新增 + placeholder buttons + gear', () => { + setup(); + expect(screen.getByTestId('btn-save')).toBeInTheDocument(); + expect(screen.getByTestId('btn-cancel')).toBeInTheDocument(); + expect(screen.getByTestId('btn-new')).toBeInTheDocument(); + expect(screen.getByText('删除')).toBeInTheDocument(); + expect(screen.getByText('作废')).toBeInTheDocument(); + expect(screen.getByText('重置密码')).toBeInTheDocument(); + expect(screen.getByText('取消作废')).toBeInTheDocument(); + expect(screen.getByText('功能')).toBeInTheDocument(); + expect(screen.getByTestId('btn-gear')).toBeInTheDocument(); + }); + + it('click 保存 calls onSave / 取消 calls onCancel / 新增 calls onNew', async () => { + const user = userEvent.setup(); + const { onSave, onCancel, onNew } = setup(); + await user.click(screen.getByTestId('btn-save')); + expect(onSave).toHaveBeenCalledTimes(1); + await user.click(screen.getByTestId('btn-cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + await user.click(screen.getByTestId('btn-new')); + expect(onNew).toHaveBeenCalledTimes(1); + }); + + it('submitting disables 保存 and shows loading', async () => { + const user = userEvent.setup(); + const { onSave } = setup({ submitting: true }); + const btn = screen.getByTestId('btn-save'); + expect(btn).toBeDisabled(); + await user.click(btn); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('canSave=false disables 保存', () => { + setup({ canSave: false }); + expect(screen.getByTestId('btn-save')).toBeDisabled(); + }); + + it('placeholder buttons show 功能开发中 (no business callback)', async () => { + const user = userEvent.setup(); + const { onSave, onCancel, onNew } = setup(); + await user.click(screen.getByText('删除')); + expect(messageSpy.info).toHaveBeenCalledWith('功能开发中'); + await user.click(screen.getByTestId('btn-gear')); + expect(messageSpy.info).toHaveBeenCalledWith('功能开发中'); + expect(onSave).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + expect(onNew).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/UserFilterBar.test.tsx b/frontend/tests/unit/UserFilterBar.test.tsx new file mode 100644 index 0000000..c508089 --- /dev/null +++ b/frontend/tests/unit/UserFilterBar.test.tsx @@ -0,0 +1,124 @@ +// REQ-USR-003: UserFilterBar 筛选栏单测(BR2/BR3/BR4/BR7/BR10/D2/D3) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import UserFilterBar from '../../src/pages/usr/UserList/UserFilterBar'; +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; +import type { UserListQuery } from '../../src/api/types'; + +function setup(overrides?: Partial<UserListQuery>) { + const onChangeQueryField = vi.fn(); + const onChangeMatchType = vi.fn(); + const onChangeQueryValue = vi.fn(); + const onSearch = vi.fn(); + const onClear = vi.fn(); + const query: UserListQuery = { ...DEFAULT_QUERY, ...overrides }; + renderShell( + <UserFilterBar + query={query} + onChangeQueryField={onChangeQueryField} + onChangeMatchType={onChangeMatchType} + onChangeQueryValue={onChangeQueryValue} + onSearch={onSearch} + onClear={onClear} + />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onChangeQueryField, onChangeMatchType, onChangeQueryValue, onSearch, onClear }; +} + +describe('UserFilterBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders defaults 用户名 / 包含 and empty value', () => { + setup(); + const fieldSel = within(screen.getByTestId('filter-query-field')); + expect(fieldSel.getByText('用户名')).toBeInTheDocument(); + const matchSel = within(screen.getByTestId('filter-match-type')); + expect(matchSel.getByText('包含')).toBeInTheDocument(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + expect(input.value).toBe(''); + }); + + it('query field options match enum (BR4)', async () => { + const user = userEvent.setup(); + setup(); + // 展开查询字段下拉 + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); + await user.click(combobox); + // AntD options 渲染为 role=option(在 document.body 的下拉层) + const options = await screen.findAllByRole('option'); + const labels = options.map((o) => o.textContent); + for (const label of ['用户名', '员工名', '用户号', '部门', '用户类型', '作废', '登录日期', '制单人']) { + expect(labels).toContain(label); + } + expect(options).toHaveLength(8); + }); + + it('match type options are 包含/不包含/等于 (BR4)', async () => { + const user = userEvent.setup(); + setup(); + const combobox = within(screen.getByTestId('filter-match-type')).getByRole('combobox'); + await user.click(combobox); + const options = await screen.findAllByRole('option'); + const labels = options.map((o) => o.textContent); + expect(labels).toEqual(['包含', '不包含', '等于']); + }); + + it('scope select shows 全部用户 only (D2)', () => { + setup(); + const scope = within(screen.getByTestId('filter-scope')); + expect(scope.getByText('全部用户')).toBeInTheDocument(); + }); + + it('Enter in value triggers onSearch (BR7)', async () => { + const user = userEvent.setup(); + const { onSearch } = setup(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + input.focus(); + await user.keyboard('{Enter}'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('click 搜索 calls onSearch / click 清空 calls onClear (BR7/BR10)', async () => { + const user = userEvent.setup(); + const { onSearch, onClear } = setup(); + await user.click(screen.getByTestId('btn-search')); + expect(onSearch).toHaveBeenCalledTimes(1); + await user.click(screen.getByTestId('btn-clear')); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('typing value calls onChangeQueryValue', async () => { + const user = userEvent.setup(); + const { onChangeQueryValue } = setup(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + await user.type(input, 'x'); + expect(onChangeQueryValue).toHaveBeenCalled(); + expect(onChangeQueryValue).toHaveBeenLastCalledWith('x'); + }); + + it('changing query field select calls onChangeQueryField', async () => { + const user = userEvent.setup(); + const { onChangeQueryField } = setup(); + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); + await user.click(combobox); + const options = await screen.findAllByRole('option'); + const target = options.find((o) => o.textContent === '员工名')!; + await user.click(target); + // AntD Select.onChange 透传 (value, option),回调首参即选中值 + expect(onChangeQueryField).toHaveBeenCalled(); + expect(onChangeQueryField.mock.calls[0][0]).toBe('员工名'); + }); + + it('more toggle ▾ is placeholder (no extra callback) (D3)', async () => { + const user = userEvent.setup(); + const { onSearch, onChangeQueryField } = setup(); + await user.click(screen.getByTestId('filter-more')); + expect(onSearch).not.toHaveBeenCalled(); + expect(onChangeQueryField).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/UserListPage.test.tsx b/frontend/tests/unit/UserListPage.test.tsx new file mode 100644 index 0000000..28b226d --- /dev/null +++ b/frontend/tests/unit/UserListPage.test.tsx @@ -0,0 +1,180 @@ +// REQ-USR-003: UserListPage 页面集成(状态机贯通 + 导航 + 错误重试,BR3/BR7/BR8/BR9/BR12/BR13/BR15) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; + +// 桩 message +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ listUsers: vi.fn() })); + +import { listUsers } from '../../src/api/usrApi'; +import UserListPage from '../../src/pages/usr/UserList'; +import { ApiError } from '../../src/api/request'; +import type { UserVO } from '../../src/api/types'; + +const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>; + +function makeUser(id: number, name = `user${id}`): UserVO { + return { + id, + sUserName: name, + employeeName: null, + sUserNo: null, + departmentName: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + }; +} + +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { + return { records, total, pageNum, pageSize }; +} + +function LocationProbe() { + const loc = useLocation(); + return <div data-testid="loc">{loc.pathname}</div>; +} + +function renderPage() { + return renderShell( + <> + <LocationProbe /> + <Routes> + <Route path="/usr/users" element={<UserListPage />} /> + <Route path="/usr/users/new" element={<div data-testid="new-sentinel">new</div>} /> + <Route path="/usr/users/:id" element={<div data-testid="detail-sentinel">detail</div>} /> + </Routes> + </>, + { + initialEntries: ['/usr/users'], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }, + ); +} + +function lastQuery() { + const calls = mockedList.mock.calls; + return calls[calls.length - 1][0]; +} + +describe('UserListPage 集成', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initial load renders rows from listUsers (default query) (BR2)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(mockedList.mock.calls[0][0]).toMatchObject({ + queryField: '用户名', + matchType: '包含', + pageNum: 1, + pageSize: 10, + }); + expect(await screen.findByText('user1')).toBeInTheDocument(); + expect(await screen.findByText('user2')).toBeInTheDocument(); + }); + + it('search with value submits queryValue and shows results (BR7/BR3)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(9, '李雷')], 1)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + await user.type(input, '李'); + await user.click(screen.getByTestId('btn-search')); + await waitFor(() => { + const q = lastQuery(); + expect(q.queryValue).toBe('李'); + expect(q.pageNum).toBe(1); + }); + expect(await screen.findByText('李雷')).toBeInTheDocument(); + }); + + it('empty response shows 暂无匹配的用户 (BR14)', async () => { + mockedList.mockResolvedValue(page([], 0)); + renderPage(); + expect(await screen.findByText('暂无匹配的用户')).toBeInTheDocument(); + expect(messageSpy.error).not.toHaveBeenCalled(); + }); + + it('error response shows 点击重试; retry calls refresh', async () => { + const user = userEvent.setup(); + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + renderPage(); + expect(await screen.findByText('加载失败,点击重试')).toBeInTheDocument(); + + mockedList.mockResolvedValueOnce(page([makeUser(3)], 1)); + await user.click(screen.getByTestId('userlist-error').querySelector('button')!); + expect(await screen.findByText('user3')).toBeInTheDocument(); + }); + + it('新增 navigates to /usr/users/new (BR13)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(1)], 1)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + await user.click(screen.getByTestId('btn-add')); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'); + expect(screen.getByTestId('new-sentinel')).toBeInTheDocument(); + }); + + it('double click row navigates to /usr/users/:id (BR12)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(42)], 1)); + renderPage(); + await screen.findByText('user42'); + await user.dblClick(screen.getByText('user42')); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/42'); + expect(screen.getByTestId('detail-sentinel')).toBeInTheDocument(); + }); + + it('refresh keeps current page (BR8)', async () => { + const user = userEvent.setup(); + // 初次加载回显第 1 页 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 1, 10)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + await screen.findByText('user1'); + // 翻到第 2 页(点下一页),响应回显 pageNum=2 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 2, 10)); + await user.click(screen.getByTitle('下一页')); + await waitFor(() => expect(lastQuery().pageNum).toBe(2)); + mockedList.mockClear(); + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); + await user.click(screen.getByTestId('btn-refresh')); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + // 刷新保持当前页(2),不回第 1 页 + expect(lastQuery().pageNum).toBe(2); + }); + + it('response pageNum echo syncs pagination (BR15)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 50, 5, 10)); + renderPage(); + // 后端回显 pageNum=5,分页当前页应跟随响应 + await screen.findByText('user1'); + await waitFor(() => { + const pager = screen.getByTestId('user-table'); + expect(within(pager).getByTitle('5')).toHaveClass('ant-pagination-item-active'); + }); + }); +}); diff --git a/frontend/tests/unit/UserTable.test.tsx b/frontend/tests/unit/UserTable.test.tsx new file mode 100644 index 0000000..34b153a --- /dev/null +++ b/frontend/tests/unit/UserTable.test.tsx @@ -0,0 +1,145 @@ +// REQ-USR-003: UserTable 表格单测(BR1/BR6/BR11/BR12/BR14/D8) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import UserTable from '../../src/pages/usr/UserList/UserTable'; +import type { UserVO } from '../../src/api/types'; + +function makeUser(id: number, over?: Partial<UserVO>): UserVO { + return { + id, + sUserName: `user${id}`, + employeeName: `员工${id}`, + sUserNo: `U00${id}`, + departmentName: `部门${id}`, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: '2024-02-01T10:00:00', + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + ...over, + }; +} + +interface Props { + rows?: UserVO[]; + loading?: boolean; + total?: number; + pageNum?: number; + pageSize?: number; +} + +function setup(props?: Props) { + const onChangePage = vi.fn(); + const onRowDoubleClick = vi.fn(); + const onSelectRow = vi.fn(); + renderShell( + <UserTable + rows={props?.rows ?? [makeUser(1), makeUser(2)]} + loading={props?.loading ?? false} + total={props?.total ?? 2} + pageNum={props?.pageNum ?? 1} + pageSize={props?.pageSize ?? 10} + onChangePage={onChangePage} + onRowDoubleClick={onRowDoubleClick} + onSelectRow={onSelectRow} + />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onChangePage, onRowDoubleClick, onSelectRow }; +} + +const HEADERS = [ + '序号', + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '语言', + '作废', + '登录日期', + '制单人', + '制单日期', +]; + +describe('UserTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 11 column headers in order', () => { + setup(); + const headerCells = screen.getAllByRole('columnheader'); + const texts = headerCells.map((c) => c.textContent ?? ''); + for (const h of HEADERS) { + expect(texts.some((t) => t.includes(h))).toBe(true); + } + // 业务列(序号..制单日期)顺序一致(排除首个 radio 选择列空表头) + const businessTexts = texts.filter((t) => HEADERS.some((h) => t.includes(h))); + const orderIdx = HEADERS.map((h) => businessTexts.findIndex((t) => t.includes(h))); + const sorted = [...orderIdx].sort((a, b) => a - b); + expect(orderIdx).toEqual(sorted); + }); + + it('serial number is page-aware (BR1)', () => { + setup({ pageNum: 2, pageSize: 10, rows: [makeUser(11), makeUser(12)], total: 30 }); + // 第 2 页首行序号 = (2-1)*10 + 0 + 1 = 11 + expect(screen.getByText('11')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); + + it('作废 column renders 否/是 read-only (BR6)', async () => { + const user = userEvent.setup(); + const { onSelectRow } = setup({ + rows: [makeUser(1, { iIsVoid: 0 }), makeUser(2, { iIsVoid: 1 })], + total: 2, + }); + expect(screen.getByText('否')).toBeInTheDocument(); + expect(screen.getByText('是')).toBeInTheDocument(); + // 点击「作废」单元不触发任何选择 / 写动作 + await user.click(screen.getByText('是')); + expect(onSelectRow).not.toHaveBeenCalled(); + }); + + it('double click row navigates via onRowDoubleClick (BR12)', async () => { + const user = userEvent.setup(); + const { onRowDoubleClick } = setup({ rows: [makeUser(7)], total: 1 }); + await user.dblClick(screen.getByText('user7')); + expect(onRowDoubleClick).toHaveBeenCalledTimes(1); + expect(onRowDoubleClick.mock.calls[0][0]).toMatchObject({ id: 7 }); + }); + + it('controlled pagination reflects current/pageSize/total + showTotal (BR11)', async () => { + const user = userEvent.setup(); + const { onChangePage } = setup({ + rows: [makeUser(1)], + total: 37, + pageNum: 1, + pageSize: 10, + }); + expect(screen.getByText('共 37 条记录')).toBeInTheDocument(); + // 点下一页 → onChangePage 收到 pageNum=2(renderShell 注入 zhCN locale,标题为「下一页」) + await user.click(screen.getByTitle('下一页')); + expect(onChangePage).toHaveBeenCalled(); + expect(onChangePage.mock.calls[0][0]).toBe(2); + expect(onChangePage.mock.calls[0][1]).toBe(10); + }); + + it('empty rows shows Empty 暂无匹配的用户 (BR14)', () => { + setup({ rows: [], total: 0 }); + expect(screen.getByText('暂无匹配的用户')).toBeInTheDocument(); + }); + + it('radio rowSelection single-select reports key without query (D8)', async () => { + const user = userEvent.setup(); + const { onSelectRow, onChangePage } = setup({ rows: [makeUser(5)], total: 1 }); + const radio = screen.getByRole('radio'); + await user.click(radio); + expect(onSelectRow).toHaveBeenCalledWith(5); + // 选择不触发取数 / 翻页 + expect(onChangePage).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/UserToolbar.test.tsx b/frontend/tests/unit/UserToolbar.test.tsx new file mode 100644 index 0000000..8e34379 --- /dev/null +++ b/frontend/tests/unit/UserToolbar.test.tsx @@ -0,0 +1,72 @@ +// REQ-USR-003: UserToolbar 工具栏单测(BR8/BR9/BR13/D7/D10) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import UserToolbar from '../../src/pages/usr/UserList/UserToolbar'; + +function setup(over?: { exporting?: boolean; loading?: boolean }) { + const onRefresh = vi.fn(); + const onAdd = vi.fn(); + const onExport = vi.fn(); + renderShell( + <UserToolbar + onRefresh={onRefresh} + onAdd={onAdd} + onExport={onExport} + exporting={over?.exporting ?? false} + loading={over?.loading ?? false} + />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onRefresh, onAdd, onExport }; +} + +describe('UserToolbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 刷新/新增/导出Excel/设置 buttons', () => { + setup(); + expect(screen.getByTestId('btn-refresh')).toBeInTheDocument(); + expect(screen.getByText('刷新')).toBeInTheDocument(); + expect(screen.getByText('新增')).toBeInTheDocument(); + expect(screen.getByText('导出Excel')).toBeInTheDocument(); + expect(screen.getByTestId('btn-gear')).toBeInTheDocument(); + }); + + it('click 刷新 calls onRefresh / click 新增 calls onAdd (BR8/BR13)', async () => { + const user = userEvent.setup(); + const { onRefresh, onAdd } = setup(); + await user.click(screen.getByTestId('btn-refresh')); + expect(onRefresh).toHaveBeenCalledTimes(1); + await user.click(screen.getByTestId('btn-add')); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('click 导出Excel calls onExport (BR9)', async () => { + const user = userEvent.setup(); + const { onExport } = setup(); + await user.click(screen.getByTestId('btn-export')); + expect(onExport).toHaveBeenCalledTimes(1); + }); + + it('exporting disables 导出Excel and shows loading (BR9)', async () => { + const user = userEvent.setup(); + const { onExport } = setup({ exporting: true }); + const btn = screen.getByTestId('btn-export'); + expect(btn).toBeDisabled(); + await user.click(btn); + expect(onExport).not.toHaveBeenCalled(); + }); + + it('gear setting is placeholder (no callback) (D7)', async () => { + const user = userEvent.setup(); + const { onRefresh, onAdd, onExport } = setup(); + await user.click(screen.getByTestId('btn-gear')); + expect(onRefresh).not.toHaveBeenCalled(); + expect(onAdd).not.toHaveBeenCalled(); + expect(onExport).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/authSlice.test.ts b/frontend/tests/unit/authSlice.test.ts new file mode 100644 index 0000000..b66b765 --- /dev/null +++ b/frontend/tests/unit/authSlice.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import authReducer, { + setCredentials, + clearCredentials, + type AuthState, +} from '../../src/store/slices/authSlice'; +import { TOKEN_STORAGE_KEY } from '../../src/api/request'; +import type { AuthUser } from '../../src/api/types'; + +const user: AuthUser = { id: 1, sUserName: 'admin', sUserType: '超级管理员', sLanguage: '中文' }; + +describe('authSlice', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('setCredentials stores token and user and persists token', () => { + const start: AuthState = { token: null, user: null }; + const next = authReducer(start, setCredentials({ token: 't', user })); + expect(next.token).toBe('t'); + expect(next.user).toEqual(user); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBe('t'); + }); + + it('clearCredentials clears state and removes persisted token', () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 't'); + const start: AuthState = { token: 't', user }; + const next = authReducer(start, clearCredentials()); + expect(next.token).toBeNull(); + expect(next.user).toBeNull(); + expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + }); + + it('initialState reads persisted token', async () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 'persisted-tk'); + // 重置模块缓存,使 initialState 在带 token 的 localStorage 下重新求值 + vi.resetModules(); + const mod = await import('../../src/store/slices/authSlice'); + const initial = mod.default(undefined, { type: '@@INIT' }); + expect(initial.token).toBe('persisted-tk'); + }); +}); diff --git a/frontend/tests/unit/dashboardData.test.ts b/frontend/tests/unit/dashboardData.test.ts new file mode 100644 index 0000000..7904ec3 --- /dev/null +++ b/frontend/tests/unit/dashboardData.test.ts @@ -0,0 +1,39 @@ +// REQ-USR-003: 主页静态 demo 数据(复刻原型 kpiRows / 角色树,D1/D2) +import { describe, it, expect } from 'vitest'; +import { + KPI_STATS, + KPI_ROWS, + ROLE_GROUPS, + PROCESS_GROUPS, +} from '../../src/pages/home/HomePage/dashboardData'; + +describe('dashboardData', () => { + it('KPI_STATS matches prototype head bar', () => { + expect(KPI_STATS.todayPending).toBe(37428); + expect(KPI_STATS.openTotal).toBe(56433); + }); + + it('KPI_ROWS first row replicates prototype kpiRows field names', () => { + const first = KPI_ROWS[0]; + expect(first.role).toBe('核价人员'); + expect(first.navTypeFirst).toBe(true); + expect(first.roleSpan).toBe(4); + expect(first.sub).toBe('估价管理流程'); + expect(first.subSpan).toBe(5); + expect(first.item).toBe('01/04【新增】新报价单'); + }); + + it('ROLE_GROUPS includes 所有部门 37428 and 客服部 30127', () => { + const all = ROLE_GROUPS.find((g) => g.label === '所有部门'); + const kf = ROLE_GROUPS.find((g) => g.label === '客服部'); + expect(all?.count).toBe(37428); + expect(kf?.count).toBe(30127); + }); + + it('PROCESS_GROUPS includes 估价管理流程 17 and 订单下达流程 30118', () => { + const est = PROCESS_GROUPS.find((g) => g.label === '估价管理流程'); + const ord = PROCESS_GROUPS.find((g) => g.label === '订单下达流程'); + expect(est?.count).toBe(17); + expect(ord?.count).toBe(30118); + }); +}); diff --git a/frontend/tests/unit/exportUtils.test.ts b/frontend/tests/unit/exportUtils.test.ts new file mode 100644 index 0000000..9524f8d --- /dev/null +++ b/frontend/tests/unit/exportUtils.test.ts @@ -0,0 +1,141 @@ +// REQ-USR-003: 页面常量 + 前端 CSV 导出工具单测(D-PLAN-1) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildUserCsv, downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; +import { + DEFAULT_QUERY, + PAGE_SIZE_OPTIONS, + QUERY_FIELD_OPTIONS, + MATCH_TYPE_OPTIONS, +} from '../../src/pages/usr/UserList/constants'; +import type { UserVO } from '../../src/api/types'; + +const rowVoid0: UserVO = { + id: 1, + sUserName: 'admin', + employeeName: null, + sUserNo: 'U001', + departmentName: '技术部', + sUserType: '超级管理员', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: '系统', + tCreateDate: '2024-01-01T00:00:00', +}; + +const rowVoid1: UserVO = { + id: 2, + sUserName: '李丹', + employeeName: '李丹', + sUserNo: 'U002', + departmentName: '客服部', + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 1, + tLastLoginDate: '2024-02-01T10:00:00', + sCreator: 'admin', + tCreateDate: '2024-01-02T00:00:00', +}; + +describe('exportUtils.buildUserCsv', () => { + it('buildUserCsv has header row and maps 作废 0/1', () => { + const csv = buildUserCsv([rowVoid0, rowVoid1]); + const lines = csv.split('\n'); + const header = lines[0]; + // 中文表头逐字(不含序号列由前端生成,导出含中文列名) + expect(header).toContain('用户名'); + expect(header).toContain('员工名'); + expect(header).toContain('作废'); + expect(header).toContain('用户号'); + expect(header).toContain('部门'); + expect(header).toContain('用户类型'); + expect(header).toContain('语言'); + expect(header).toContain('登录日期'); + expect(header).toContain('制单人'); + expect(header).toContain('制单日期'); + // 作废列 0→否、1→是 + expect(lines[1]).toContain('否'); + expect(lines[2]).toContain('是'); + // 空值字段(employeeName=null)渲染为空串,不出现 "null" + expect(csv).not.toContain('null'); + }); +}); + +describe('exportUtils.downloadCsv', () => { + let createSpy: ReturnType<typeof vi.fn>; + let clickSpy: ReturnType<typeof vi.fn>; + let blobArg: Blob | null; + + beforeEach(() => { + blobArg = null; + createSpy = vi.fn((b: Blob) => { + blobArg = b; + return 'blob:mock'; + }); + clickSpy = vi.fn(); + // jsdom 未实现 URL.createObjectURL / revokeObjectURL + (URL as unknown as { createObjectURL: unknown }).createObjectURL = createSpy; + (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function readBlobBytes(blob: Blob): Promise<Uint8Array> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); + } + + it('downloadCsv triggers blob download with UTF-8 BOM', async () => { + downloadCsv('users.csv', 'x'); + expect(createSpy).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(blobArg).toBeInstanceOf(Blob); + const bytes = await readBlobBytes(blobArg!); + // UTF-8 BOM 字节序列 EF BB BF + expect(bytes[0]).toBe(0xef); + expect(bytes[1]).toBe(0xbb); + expect(bytes[2]).toBe(0xbf); + // 内容 'x' 紧随 BOM 之后 + expect(bytes[3]).toBe('x'.charCodeAt(0)); + }); +}); + +describe('UserList constants', () => { + it('DEFAULT_QUERY defaults match BR2/BR3/D4', () => { + expect(DEFAULT_QUERY.queryField).toBe('用户名'); + expect(DEFAULT_QUERY.matchType).toBe('包含'); + expect(DEFAULT_QUERY.queryValue).toBe(''); + expect(DEFAULT_QUERY.pageNum).toBe(1); + expect(DEFAULT_QUERY.pageSize).toBe(10); + }); + + it('PAGE_SIZE_OPTIONS upper bound is 100', () => { + expect(PAGE_SIZE_OPTIONS).toEqual([10, 20, 50, 100]); + }); + + it('QUERY_FIELD_OPTIONS has 8 fields starting 用户名', () => { + expect(QUERY_FIELD_OPTIONS).toHaveLength(8); + expect(QUERY_FIELD_OPTIONS[0]).toBe('用户名'); + expect(QUERY_FIELD_OPTIONS).toEqual([ + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '作废', + '登录日期', + '制单人', + ]); + }); + + it('MATCH_TYPE_OPTIONS is 包含/不包含/等于', () => { + expect(MATCH_TYPE_OPTIONS).toEqual(['包含', '不包含', '等于']); + }); +}); diff --git a/frontend/tests/unit/navConfig.test.ts b/frontend/tests/unit/navConfig.test.ts new file mode 100644 index 0000000..5b08c70 --- /dev/null +++ b/frontend/tests/unit/navConfig.test.ts @@ -0,0 +1,41 @@ +// REQ-USR-003: 导航静态配置(复刻原型 navSide / navCols,D1/D4) +import { describe, it, expect } from 'vitest'; +import { NAV_SIDE, NAV_COLS } from '../../src/layouts/AppLayout/navConfig'; + +describe('navConfig', () => { + it('NAV_SIDE has 20 items with 系统设置 active', () => { + expect(NAV_SIDE).toHaveLength(20); + const sys = NAV_SIDE.find((s) => s.label === '系统设置'); + expect(sys).toBeDefined(); + expect(sys!.active).toBe(true); + // 仅「系统设置」一项 active + expect(NAV_SIDE.filter((s) => s.active).length).toBe(1); + }); + + it('NAV_COLS has 7 groups with exact titles', () => { + expect(NAV_COLS).toHaveLength(7); + expect(NAV_COLS.map((c) => c.title)).toEqual([ + '期初设置', + '用户管理', + '系统参数', + '计算方案', + '日志', + '开发平台', + 'API对接管理', + ]); + }); + + it('用户列表 leaf has routePath /usr/users and star', () => { + const userMgmt = NAV_COLS.find((c) => c.title === '用户管理')!; + const userList = userMgmt.items.find((it) => it.label === '用户列表')!; + expect(userList.routePath).toBe('/usr/users'); + expect(userList.star).toBe(true); + }); + + it('系统功能模块设置 leaf has star and no routePath (placeholder)', () => { + const devCol = NAV_COLS.find((c) => c.title === '开发平台')!; + const leaf = devCol.items.find((it) => it.label === '系统功能模块设置')!; + expect(leaf.star).toBe(true); + expect(leaf.routePath).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/renderLogin.tsx b/frontend/tests/unit/renderLogin.tsx new file mode 100644 index 0000000..b52c517 --- /dev/null +++ b/frontend/tests/unit/renderLogin.tsx @@ -0,0 +1,36 @@ +// REQ-USR-004: LoginPage 组件测试共享渲染工具(Provider + Router + AntD App 上下文) +import type { ReactElement } from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { App as AntdApp, ConfigProvider } from 'antd'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from '../../src/store/slices/authSlice'; +import type { RootState } from '../../src/store/store'; + +export function makeStore() { + return configureStore({ reducer: { auth: authReducer } }); +} + +export function renderWithProviders( + ui: ReactElement, + options?: { store?: ReturnType<typeof makeStore>; initialEntries?: string[] }, +) { + const store = options?.store ?? makeStore(); + const result = render( + <Provider store={store}> + <ConfigProvider> + <AntdApp> + <MemoryRouter initialEntries={options?.initialEntries ?? ['/login']}> + {ui} + </MemoryRouter> + </AntdApp> + </ConfigProvider> + </Provider>, + ); + return { + ...result, + store, + getState: () => store.getState() as RootState, + }; +} diff --git a/frontend/tests/unit/renderShell.smoke.test.tsx b/frontend/tests/unit/renderShell.smoke.test.tsx new file mode 100644 index 0000000..04ea35e --- /dev/null +++ b/frontend/tests/unit/renderShell.smoke.test.tsx @@ -0,0 +1,17 @@ +// REQ-USR-003: renderShell 共享渲染工具自带冒烟用例(T0) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderShell } from './renderShell'; + +describe('renderShell', () => { + it('renderShell mounts a route element', () => { + renderShell(<div>shell-ok</div>, { + initialEntries: ['/'], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }); + expect(screen.getByText('shell-ok')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/renderShell.tsx b/frontend/tests/unit/renderShell.tsx new file mode 100644 index 0000000..f4d7d4f --- /dev/null +++ b/frontend/tests/unit/renderShell.tsx @@ -0,0 +1,53 @@ +// REQ-USR-003: 外壳 / 路由测试共享渲染工具(Provider + 真实 store + MemoryRouter + AntD App) +// 复用 FE-01 renderLogin.tsx 模式,扩展为可注入 auth 预置态(token/user)。 +import type { ReactElement } from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; + +// react-router-dom 未公开 re-export InitialEntry,这里按其 MemoryRouter 接受的形态本地声明: +// 纯路径字符串,或携带 navigate state 的 Partial<Location>(FE-04 edit 预填数据流需要)。 +type ShellInitialEntry = string | { pathname: string; search?: string; hash?: string; state?: unknown }; +import { App as AntdApp, ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { type AuthState } from '../../src/store/slices/authSlice'; +import type { RootState } from '../../src/store/store'; + +/** 构建带 auth slice 的测试 store,可注入 token/user 预置态 */ +export function makeShellStore(preloadedAuth?: Partial<AuthState>) { + return configureStore({ + reducer: { auth: authReducer }, + preloadedState: preloadedAuth + ? { auth: { token: null, user: null, ...preloadedAuth } } + : undefined, + }); +} + +export interface RenderShellOptions { + // FE-04: 允许传 Partial<Location>(携带 navigate state),用于复刻 edit 单据经 + // navigate state 透传 presetUser 的数据流,不仅限纯路径字符串。 + initialEntries?: ShellInitialEntry[]; + preloadedAuth?: Partial<AuthState>; + store?: ReturnType<typeof makeShellStore>; +} + +export function renderShell(ui: ReactElement, options?: RenderShellOptions) { + const store = options?.store ?? makeShellStore(options?.preloadedAuth); + const result = render( + <Provider store={store}> + <ConfigProvider locale={zhCN}> + <AntdApp> + <MemoryRouter initialEntries={options?.initialEntries ?? ['/']}> + {ui} + </MemoryRouter> + </AntdApp> + </ConfigProvider> + </Provider>, + ); + return { + ...result, + store, + getState: () => store.getState() as RootState, + }; +} diff --git a/frontend/tests/unit/request.test.ts b/frontend/tests/unit/request.test.ts new file mode 100644 index 0000000..908ad2c --- /dev/null +++ b/frontend/tests/unit/request.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import MockAdapter from 'axios-mock-adapter'; +import request, { ApiError, TOKEN_STORAGE_KEY, NETWORK_ERROR_CODE } from '../../src/api/request'; + +describe('request (Axios 实例)', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(request); + localStorage.clear(); + }); + + it('baseURL is /api', () => { + expect(request.defaults.baseURL).toBe('/api'); + }); + + it('unwraps data when code is 0', async () => { + mock.onGet('/ping').reply(200, { code: 0, message: 'success', data: { foo: 1 } }); + const data = await request.get('/ping'); + expect(data).toEqual({ foo: 1 }); + }); + + it('throws ApiError carrying business code when code is non-zero', async () => { + mock.onPost('/usr/login').reply(200, { code: 40101, message: '认证失败', data: null }); + await expect(request.post('/usr/login', {})).rejects.toMatchObject({ + name: 'ApiError', + code: 40101, + }); + await expect(request.post('/usr/login', {})).rejects.toBeInstanceOf(ApiError); + }); + + it('throws network ApiError on no-response error', async () => { + mock.onGet('/usr/companies').networkError(); + await expect(request.get('/usr/companies')).rejects.toMatchObject({ + name: 'ApiError', + code: NETWORK_ERROR_CODE, + }); + }); + + it('injects Authorization header when token present', async () => { + localStorage.setItem(TOKEN_STORAGE_KEY, 'tk-123'); + let seenAuth: string | undefined; + mock.onGet('/usr/users').reply((config) => { + seenAuth = config.headers?.Authorization as string | undefined; + return [200, { code: 0, message: 'success', data: [] }]; + }); + await request.get('/usr/users'); + expect(seenAuth).toBe('Bearer tk-123'); + }); + + it('does not inject Authorization header when no token', async () => { + let seenAuth: string | undefined; + mock.onGet('/usr/companies').reply((config) => { + seenAuth = config.headers?.Authorization as string | undefined; + return [200, { code: 0, message: 'success', data: [] }]; + }); + await request.get('/usr/companies'); + expect(seenAuth).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/request.unauthorized.test.ts b/frontend/tests/unit/request.unauthorized.test.ts new file mode 100644 index 0000000..e368d63 --- /dev/null +++ b/frontend/tests/unit/request.unauthorized.test.ts @@ -0,0 +1,40 @@ +// REQ-USR-004: request.ts 401 统一登出回调(BR10 / D11) +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import MockAdapter from 'axios-mock-adapter'; +import request, { + ApiError, + registerUnauthorizedHandler, + HTTP_UNAUTHORIZED, +} from '../../src/api/request'; + +describe('request 401 统一登出回调', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(request); + }); + + afterEach(() => { + mock.restore(); + // 清理已注册回调,避免跨用例污染 + registerUnauthorizedHandler(null); + }); + + it('exposes HTTP_UNAUTHORIZED constant = 401', () => { + expect(HTTP_UNAUTHORIZED).toBe(401); + }); + + it('HTTP 401 triggers registered onUnauthorized then rejects ApiError', async () => { + const spy = vi.fn(); + registerUnauthorizedHandler(spy); + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null }); + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('no handler registered does not throw extra error', async () => { + registerUnauthorizedHandler(null); + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null }); + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/frontend/tests/unit/router.test.tsx b/frontend/tests/unit/router.test.tsx new file mode 100644 index 0000000..b6a0a0a --- /dev/null +++ b/frontend/tests/unit/router.test.tsx @@ -0,0 +1,43 @@ +// REQ-USR-003: 路由表接线(替换 / 占位,含守卫与重定向,BR1/BR2/D7) +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderShell, type RenderShellOptions } from './renderShell'; +import AppRouter from '../../src/router'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +function renderRouter(initialEntries: string[], preloadedAuth?: RenderShellOptions['preloadedAuth']) { + return renderShell(<AppRouter />, { initialEntries, preloadedAuth }); +} + +describe('AppRouter', () => { + it('unauthenticated / redirects to /login', () => { + renderRouter(['/'], { token: null, user: null }); + // 登录页含「用户登录」标题 + expect(screen.getByText('用户登录')).toBeInTheDocument(); + }); + + it('authenticated / renders HomePage shell', () => { + renderRouter(['/'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + }); + + it('authenticated /usr/users renders under AppLayout', () => { + renderRouter(['/usr/users'], { token: 't', user: ADMIN }); + // 外壳顶栏在;用户列表标签激活(占位内容由 FE-03 落地) + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByTestId('tab-userlist').getAttribute('aria-pressed')).toBe('true'); + }); + + it('authenticated /login redirects to /', () => { + renderRouter(['/login'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + }); + + it('unknown protected path redirects to /', () => { + renderRouter(['/no/such/path'], { token: 't', user: ADMIN }); + expect(screen.getByText('KPI监控')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/smoke.test.tsx b/frontend/tests/unit/smoke.test.tsx new file mode 100644 index 0000000..d29e703 --- /dev/null +++ b/frontend/tests/unit/smoke.test.tsx @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +describe('smoke', () => { + it('renders a trivial component', () => { + render(<div>ok</div>); + expect(screen.getByText('ok')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/useTabStack.test.tsx b/frontend/tests/unit/useTabStack.test.tsx new file mode 100644 index 0000000..f0fe0f2 --- /dev/null +++ b/frontend/tests/unit/useTabStack.test.tsx @@ -0,0 +1,59 @@ +// REQ-USR-003: useTabStack 标签栈逻辑(BR4/BR5/BR6) +import { describe, it, expect } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useTabStack } from '../../src/layouts/AppLayout/useTabStack'; + +describe('useTabStack', () => { + it('starts with fixed home tab only (closable false, leftmost)', () => { + const { result } = renderHook(() => useTabStack()); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home']); + expect(result.current.activeKey).toBe('home'); + expect(result.current.tabs[0].closable).toBe(false); + expect(result.current.tabs[0].title).toBe('主页'); + expect(result.current.tabs[0].routePath).toBe('/'); + }); + + it('openTab userlist appends userlist and activates it', () => { + const { result } = renderHook(() => useTabStack()); + act(() => result.current.openTab('userlist')); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']); + expect(result.current.activeKey).toBe('userlist'); + const ul = result.current.tabs.find((t) => t.key === 'userlist')!; + expect(ul.closable).toBe(true); + expect(ul.routePath).toBe('/usr/users'); + expect(ul.title).toBe('用户列表'); + }); + + it('openTab userdetail ensures userlist exists then appends userdetail', () => { + const { result } = renderHook(() => useTabStack()); + act(() => result.current.openTab('userdetail')); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist', 'userdetail']); + expect(result.current.activeKey).toBe('userdetail'); + const ud = result.current.tabs.find((t) => t.key === 'userdetail')!; + expect(ud.title).toBe('用户信息单据'); + expect(ud.closable).toBe(true); + }); + + it('closeTab userlist also removes userdetail and activates home', () => { + const { result } = renderHook(() => useTabStack()); + act(() => result.current.openTab('userdetail')); + act(() => result.current.closeTab('userlist')); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home']); + expect(result.current.activeKey).toBe('home'); + }); + + it('closeTab userdetail activates userlist', () => { + const { result } = renderHook(() => useTabStack()); + act(() => result.current.openTab('userdetail')); + act(() => result.current.closeTab('userdetail')); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']); + expect(result.current.activeKey).toBe('userlist'); + }); + + it('open existing tab does not duplicate', () => { + const { result } = renderHook(() => useTabStack()); + act(() => result.current.openTab('userlist')); + act(() => result.current.openTab('userlist')); + expect(result.current.tabs.map((t) => t.key)).toEqual(['home', 'userlist']); + }); +}); diff --git a/frontend/tests/unit/useUserDetail.test.tsx b/frontend/tests/unit/useUserDetail.test.tsx new file mode 100644 index 0000000..351d05a --- /dev/null +++ b/frontend/tests/unit/useUserDetail.test.tsx @@ -0,0 +1,250 @@ +// REQ-USR-001 / REQ-USR-002: useUserDetail 单据 hook 状态机单测 +// initialLoading/editing/submitting/submitError/submitSuccess/loadError + 员工联动 + 权限回勾 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactNode } from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { App as AntdApp, ConfigProvider } from 'antd'; + +// 桩 message:保留 antd 其余真实导出 +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +// 桩 usrApi 单据方法 +vi.mock('../../src/api/usrApi', () => ({ + createUser: vi.fn(), + updateUser: vi.fn(), + getUserDetail: vi.fn(), + listEmployees: vi.fn(), + listPermissions: vi.fn(), +})); + +import { + createUser, + updateUser, + getUserDetail, + listEmployees, + listPermissions, +} from '../../src/api/usrApi'; +import { useUserDetail } from '../../src/pages/usr/UserDetail/useUserDetail'; +import { ApiError } from '../../src/api/request'; +import { + CREATE_DEFAULTS, + ERR_USERNAME_EXISTS, + ERR_USER_NOT_FOUND, + ERR_NO_PERMISSION, + ERR_VALIDATION, + MSG_ERR_LOAD_PERMISSIONS, + type UserFormValues, +} from '../../src/pages/usr/UserDetail/constants'; +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types'; + +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>; +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>; +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>; +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>; +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>; + +const EMPLOYEES: EmployeeOption[] = [ + { value: 3, label: '张三', sEmployeeNo: 'zs' }, + { value: 4, label: '李四', sEmployeeNo: 'ls' }, +]; +const PERMISSIONS: PermissionItem[] = [ + { id: 1, name: '默认显示', category: '基础' }, + { id: 2, name: '高级查看', category: '基础' }, +]; + +function makeVo(over: Partial<UserVO> = {}): UserVO { + return { + id: 7, + sUserName: 'zhangsan', + employeeName: '张三', + sUserNo: 'zs', + departmentName: null, + sUserType: '超级管理员', + sLanguage: '英文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + ...over, + }; +} + +function makeValues(over: Partial<UserFormValues> = {}): UserFormValues { + return { + sUserName: 'zhangsan', + sUserNo: 'zs', + iEmployeeId: 3, + sUserType: '普通用户', + sLanguage: '中文', + iCanModifyBill: 0, + iIsVoid: 0, + ...over, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + return ( + <ConfigProvider> + <AntdApp>{children}</AntdApp> + </ConfigProvider> + ); +} + +describe('useUserDetail 状态机', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedEmployees.mockResolvedValue(EMPLOYEES); + mockedPermissions.mockResolvedValue(PERMISSIONS); + }); + + it('create mode initial load prefetches employees+permissions (initialLoading→editing)', async () => { + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockedEmployees).toHaveBeenCalled(); + expect(mockedPermissions).toHaveBeenCalled(); + expect(result.current.employees).toEqual(EMPLOYEES); + expect(result.current.permissions).toEqual(PERMISSIONS); + expect(result.current.formValues.sUserType).toBe(CREATE_DEFAULTS.sUserType); + expect(result.current.formValues.iCanModifyBill).toBe(0); + expect(result.current.checkedPermissionIds).toEqual([]); + expect(mockedDetail).not.toHaveBeenCalled(); + }); + + it('edit mode without presetUser sets loadFailed without calling getUserDetail', async () => { + // FE-04 B1 fix: 路由 :id 为用户主键,无 by-id 读端点(docs/05 REQ-USR-003), + // 不能按主键查列表端点;缺 presetUser(直接访问 URL / 刷新丢 state)时按 loadError 处理, + // 由页面给出「返回列表」恢复入口。 + const { result } = renderHook( + () => useUserDetail({ mode: 'edit', userId: 7 }), + { wrapper }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.loadFailed).toBe(true); + expect(mockedDetail).not.toHaveBeenCalled(); + }); + + it('edit mode with presetUser skips getUserDetail', async () => { + const { result } = renderHook( + () => useUserDetail({ mode: 'edit', userId: 7, presetUser: makeVo() }), + { wrapper }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockedDetail).not.toHaveBeenCalled(); + expect(result.current.formValues.sUserName).toBe('zhangsan'); + }); + + it('selectEmployee fills userNo/userName from employee (create)', async () => { + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { + result.current.selectEmployee(3); + }); + expect(result.current.formValues.iEmployeeId).toBe(3); + expect(result.current.formValues.sUserName).toBe('张三'); + expect(result.current.formValues.sUserNo).toBe('zs'); + }); + + it('toggle permission and toggleAll update checkedPermissionIds', async () => { + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { + result.current.togglePermission(1, true); + }); + expect(result.current.checkedPermissionIds).toContain(1); + act(() => { + result.current.toggleAll(true); + }); + expect(result.current.checkedPermissionIds.sort()).toEqual([1, 2]); + act(() => { + result.current.toggleAll(false); + }); + expect(result.current.checkedPermissionIds).toEqual([]); + }); + + it('submit create calls createUser and returns {ok,id}', async () => { + mockedCreate.mockResolvedValue({ id: 9 }); + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + let ret: { ok: boolean; id?: number } | undefined; + await act(async () => { + ret = await result.current.submit(makeValues()); + }); + expect(mockedCreate).toHaveBeenCalledTimes(1); + expect(ret).toMatchObject({ ok: true, id: 9 }); + expect(result.current.submitting).toBe(false); + }); + + it('submit edit calls updateUser with userId and full permissionIds', async () => { + mockedDetail.mockResolvedValue(makeVo()); + mockedUpdate.mockResolvedValue({ id: 7 }); + const { result } = renderHook( + () => useUserDetail({ mode: 'edit', userId: 7 }), + { wrapper }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => { + result.current.togglePermission(2, true); + }); + let ret: { ok: boolean; id?: number } | undefined; + await act(async () => { + ret = await result.current.submit(makeValues()); + }); + expect(mockedUpdate).toHaveBeenCalledTimes(1); + const [id, body] = mockedUpdate.mock.calls[0]; + expect(id).toBe(7); + expect(body.permissionIds).toContain(2); + expect(body).not.toHaveProperty('sUserName'); + expect(ret).toMatchObject({ ok: true, id: 7 }); + }); + + it('submit 40901 returns fieldError on sUserName', async () => { + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup')); + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + let ret: { ok: boolean; fieldError?: { field: string; message: string } } | undefined; + await act(async () => { + ret = await result.current.submit(makeValues()); + }); + expect(ret?.ok).toBe(false); + expect(ret?.fieldError?.field).toBe('sUserName'); + expect(result.current.submitting).toBe(false); + }); + + it('submit 40401/40301/40001/network show message and return ok:false', async () => { + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + for (const code of [ERR_USER_NOT_FOUND, ERR_NO_PERMISSION, ERR_VALIDATION, -1]) { + messageSpy.error.mockClear(); + mockedCreate.mockRejectedValueOnce(new ApiError(code, 'e')); + let ret: { ok: boolean } | undefined; + await act(async () => { + ret = await result.current.submit(makeValues()); + }); + expect(ret?.ok).toBe(false); + expect(messageSpy.error).toHaveBeenCalled(); + } + }); + + it('loadError when prefetch fails sets loadFailed and message; reload clears it', async () => { + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net')); + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper }); + await waitFor(() => expect(result.current.loadFailed).toBe(true)); + expect(messageSpy.error).toHaveBeenCalledWith(MSG_ERR_LOAD_PERMISSIONS); + + mockedPermissions.mockResolvedValue(PERMISSIONS); + act(() => { + result.current.reload(); + }); + await waitFor(() => expect(result.current.loadFailed).toBe(false)); + expect(result.current.permissions).toEqual(PERMISSIONS); + }); +}); diff --git a/frontend/tests/unit/useUserList.test.tsx b/frontend/tests/unit/useUserList.test.tsx new file mode 100644 index 0000000..53b721c --- /dev/null +++ b/frontend/tests/unit/useUserList.test.tsx @@ -0,0 +1,277 @@ +// REQ-USR-003: useUserList 列表查询 hook 状态机单测 +// 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactNode } from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { App as AntdApp, ConfigProvider } from 'antd'; + +// 桩 message:保留 antd 其余真实导出,仅覆盖 App.useApp 返回的 message +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual<typeof import('antd')>('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +// 桩 listUsers +vi.mock('../../src/api/usrApi', () => ({ + listUsers: vi.fn(), +})); + +// 桩 CSV 下载(不真正触发 jsdom 下载) +vi.mock('../../src/pages/usr/UserList/exportUtils', async () => { + const actual = await vi.importActual< + typeof import('../../src/pages/usr/UserList/exportUtils') + >('../../src/pages/usr/UserList/exportUtils'); + return { ...actual, downloadCsv: vi.fn() }; +}); + +import { listUsers } from '../../src/api/usrApi'; +import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; +import { useUserList } from '../../src/pages/usr/UserList/useUserList'; +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; +import { ApiError } from '../../src/api/request'; +import type { UserVO } from '../../src/api/types'; + +const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>; +const mockedDownload = downloadCsv as unknown as ReturnType<typeof vi.fn>; + +function makeUser(id: number, name = `u${id}`): UserVO { + return { + id, + sUserName: name, + employeeName: null, + sUserNo: null, + departmentName: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + }; +} + +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { + return { records, total, pageNum, pageSize }; +} + +function wrapper({ children }: { children: ReactNode }) { + return ( + <ConfigProvider> + <AntdApp>{children}</AntdApp> + </ConfigProvider> + ); +} + +function lastCallQuery() { + const calls = mockedList.mock.calls; + return calls[calls.length - 1][0]; +} + +describe('useUserList 状态机', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('mounts with default query and loads first page (initialLoading→success)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + // 挂载即以默认 query 调 listUsers + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(mockedList.mock.calls[0][0]).toMatchObject({ + queryField: '用户名', + matchType: '包含', + pageNum: 1, + pageSize: 10, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.list).toHaveLength(2); + expect(result.current.total).toBe(2); + expect(result.current.query.pageNum).toBe(1); + }); + + it('empty records sets empty state without error', async () => { + mockedList.mockResolvedValue(page([], 0, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.list).toEqual([]); + expect(result.current.total).toBe(0); + expect(result.current.error).toBeNull(); + expect(messageSpy.error).not.toHaveBeenCalled(); + }); + + it('search resets to page 1 and refetches with current filters (BR7)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryField('员工名'); + result.current.setQueryValue('李'); + }); + act(() => { + result.current.search(); + }); + await waitFor(() => { + const q = lastCallQuery(); + expect(q.queryField).toBe('员工名'); + expect(q.queryValue).toBe('李'); + expect(q.pageNum).toBe(1); + }); + }); + + it('refresh keeps current query and page (BR8)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.changePage(2, 10); + }); + await waitFor(() => expect(result.current.query.pageNum).toBe(2)); + mockedList.mockClear(); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(lastCallQuery().pageNum).toBe(2); + }); + + it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryField('部门'); + result.current.setQueryValue('技术'); + }); + act(() => { + result.current.clear(); + }); + await waitFor(() => { + expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField); + expect(result.current.query.queryValue).toBe(''); + expect(result.current.query.pageNum).toBe(1); + }); + expect(lastCallQuery().queryField).toBe('用户名'); + }); + + it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.changePage(3, 10); + }); + await waitFor(() => expect(lastCallQuery().pageNum).toBe(3)); + + // 改 pageSize(10→50)应回第 1 页 + act(() => { + result.current.changePage(3, 50); + }); + await waitFor(() => { + const q = lastCallQuery(); + expect(q.pageSize).toBe(50); + expect(q.pageNum).toBe(1); + }); + }); + + it('ApiError 40001 keeps filters and shows error, sets error state', async () => { + // 首次挂载成功,之后搜索失败 40001 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryValue('张'); + }); + mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败')); + act(() => { + result.current.search(); + }); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.code).toBe(40001); + expect(result.current.query.queryValue).toBe('张'); // 条件保留 + expect(messageSpy.error).toHaveBeenCalled(); + // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2 + expect(mockedList).toHaveBeenCalledTimes(2); + }); + + it('ApiError 42201 warns and refetches at page 1', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查 + mockedList.mockClear(); + mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法')); + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + act(() => { + result.current.changePage(3, 10); + }); + await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled()); + await waitFor(() => expect(result.current.query.pageNum).toBe(1)); + // 重查发生 + expect(lastCallQuery().pageNum).toBe(1); + }); + + it('network error (code -1) sets error state', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + act(() => { + result.current.refresh(); + }); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.code).toBe(-1); + expect(messageSpy.error).toHaveBeenCalled(); + }); + + it('response pageNum echo syncs pagination (BR15)', async () => { + // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10)); + act(() => { + result.current.changePage(99, 10); + }); + await waitFor(() => expect(result.current.query.pageNum).toBe(5)); + expect(result.current.total).toBe(50); + }); + + it('exportExcel toggles exporting and downloads (BR9)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.exportExcel(); + }); + expect(mockedDownload).toHaveBeenCalledTimes(1); + expect(result.current.exporting).toBe(false); + expect(messageSpy.success).toHaveBeenCalledWith('导出成功'); + }); + + it('exportExcel failure shows 导出失败', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + await act(async () => { + await result.current.exportExcel(); + }); + expect(messageSpy.error).toHaveBeenCalledWith('导出失败'); + expect(result.current.exporting).toBe(false); + }); +}); diff --git a/frontend/tests/unit/userDetailMappers.test.ts b/frontend/tests/unit/userDetailMappers.test.ts new file mode 100644 index 0000000..285f432 --- /dev/null +++ b/frontend/tests/unit/userDetailMappers.test.ts @@ -0,0 +1,97 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据常量与提交映射纯函数单测(枚举/默认/正则/错误码 + toCreateReq/toUpdateReq/userVoToFormValues) +import { describe, it, expect } from 'vitest'; +import { + USER_TYPE_OPTIONS, + LANGUAGE_OPTIONS, + CREATE_DEFAULTS, + USERNAME_PATTERN, + ERR_VALIDATION, + ERR_USERNAME_EXISTS, + ERR_USER_NOT_FOUND, + ERR_NO_PERMISSION, + MODE_CREATE, + MODE_EDIT, + toCreateReq, + toUpdateReq, + userVoToFormValues, + type UserFormValues, +} from '../../src/pages/usr/UserDetail/constants'; +import type { UserVO } from '../../src/api/types'; + +function makeFormValues(over: Partial<UserFormValues> = {}): UserFormValues { + return { + sUserName: 'zhangsan', + sUserNo: 'zs', + iEmployeeId: 3, + sUserType: '普通用户', + sLanguage: '中文', + iCanModifyBill: 0, + iIsVoid: 0, + ...over, + }; +} + +describe('用户单据常量与映射', () => { + it('constants enums and defaults', () => { + expect(USER_TYPE_OPTIONS).toEqual(['普通用户', '超级管理员']); + expect(LANGUAGE_OPTIONS).toEqual(['中文', '英文', '繁体']); + expect(CREATE_DEFAULTS.sUserType).toBe('普通用户'); + expect(CREATE_DEFAULTS.iCanModifyBill).toBe(0); + expect(CREATE_DEFAULTS.sLanguage).toBeUndefined(); + expect(USERNAME_PATTERN.test('ab_12')).toBe(true); + expect(USERNAME_PATTERN.test('ab')).toBe(false); + expect(USERNAME_PATTERN.test('有中文')).toBe(false); + expect(ERR_VALIDATION).toBe(40001); + expect(ERR_USERNAME_EXISTS).toBe(40901); + expect(ERR_USER_NOT_FOUND).toBe(40401); + expect(ERR_NO_PERMISSION).toBe(40301); + expect(MODE_CREATE).toBe('create'); + expect(MODE_EDIT).toBe('edit'); + }); + + it('toCreateReq maps form values + permissionIds (no password)', () => { + const req = toCreateReq(makeFormValues({ iCanModifyBill: 1 }), [1, 2]); + expect(req.sUserName).toBe('zhangsan'); + expect(req.sUserNo).toBe('zs'); + expect(req.iEmployeeId).toBe(3); + expect(req.sUserType).toBe('普通用户'); + expect(req.sLanguage).toBe('中文'); + expect(req.iCanModifyBill).toBe(1); + expect(req.permissionIds).toEqual([1, 2]); + expect(req).not.toHaveProperty('initialPassword'); + expect(req).not.toHaveProperty('iIsVoid'); + }); + + it('toUpdateReq maps without sUserName + includes iIsVoid + full permissionIds', () => { + const req = toUpdateReq(makeFormValues({ iIsVoid: 1 }), [2, 3]); + expect(req).not.toHaveProperty('sUserName'); + expect(req.iIsVoid).toBe(1); + expect(req.permissionIds).toEqual([2, 3]); + expect(req.sUserType).toBe('普通用户'); + expect(req.sLanguage).toBe('中文'); + expect(req.iCanModifyBill).toBe(0); + }); + + it('userVoToFormValues fills from UserVO', () => { + const vo: UserVO = { + id: 7, + sUserName: 'zhangsan', + employeeName: '张三', + sUserNo: 'zs', + departmentName: null, + sUserType: '超级管理员', + sLanguage: '英文', + iIsVoid: 1, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + }; + const fv = userVoToFormValues(vo); + expect(fv.sUserName).toBe('zhangsan'); + expect(fv.sUserNo).toBe('zs'); + expect(fv.sUserType).toBe('超级管理员'); + expect(fv.sLanguage).toBe('英文'); + expect(fv.iCanModifyBill).toBe(0); + expect(fv.iIsVoid).toBe(1); + }); +}); diff --git a/frontend/tests/unit/usrApi.test.ts b/frontend/tests/unit/usrApi.test.ts new file mode 100644 index 0000000..ade1d8c --- /dev/null +++ b/frontend/tests/unit/usrApi.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// 桩掉底层 axios 实例(request.ts 的 default 导出) +vi.mock('../../src/api/request', () => { + return { + default: { + post: vi.fn(), + get: vi.fn(), + }, + }; +}); + +import request from '../../src/api/request'; +import { login, fetchCompanies } from '../../src/api/usrApi'; + +const mockedRequest = request as unknown as { + post: ReturnType<typeof vi.fn>; + get: ReturnType<typeof vi.fn>; +}; + +describe('usrApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('login posts to /usr/login with sUserName/password/companyId', async () => { + const result = { + token: 'tk', + user: { id: 1, sUserName: 'admin', sUserType: '超级管理员', sLanguage: '中文' }, + }; + mockedRequest.post.mockResolvedValue(result); + const payload = { sUserName: 'admin', password: 'p', companyId: 9 }; + const ret = await login(payload); + expect(mockedRequest.post).toHaveBeenCalledWith('/usr/login', payload); + expect(ret).toEqual(result); + }); + + it('fetchCompanies gets /usr/companies and returns list', async () => { + const list = [ + { id: 1, sCompanyName: '甲公司', sVersion: '标准版' }, + { id: 2, sCompanyName: '乙公司', sVersion: null }, + ]; + mockedRequest.get.mockResolvedValue(list); + const ret = await fetchCompanies(); + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/companies'); + expect(ret).toEqual(list); + }); +}); diff --git a/frontend/tests/unit/usrApi.userdetail.test.ts b/frontend/tests/unit/usrApi.userdetail.test.ts new file mode 100644 index 0000000..854722a --- /dev/null +++ b/frontend/tests/unit/usrApi.userdetail.test.ts @@ -0,0 +1,136 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据 API 封装单测 +// createUser / updateUser / getUserDetail / listEmployees / listPermissions 透传与归一(D1/D2/D4) +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// 桩底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.userlist.test.ts 模式 +vi.mock('../../src/api/request', () => { + return { + default: { + post: vi.fn(), + put: vi.fn(), + get: vi.fn(), + }, + }; +}); + +import request from '../../src/api/request'; +import { + createUser, + updateUser, + getUserDetail, + listEmployees, + listPermissions, +} from '../../src/api/usrApi'; + +const mockedRequest = request as unknown as { + post: ReturnType<typeof vi.fn>; + put: ReturnType<typeof vi.fn>; + get: ReturnType<typeof vi.fn>; +}; + +describe('usrApi 用户单据封装', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('createUser posts /usr/users with body (no password/creator/createDate)', async () => { + mockedRequest.post.mockResolvedValue({ id: 9 }); + const body = { + sUserName: 'zhangsan', + sUserNo: 'zs', + iEmployeeId: 3, + sUserType: '普通用户', + sLanguage: '中文', + iCanModifyBill: 0 as const, + permissionIds: [1, 2], + }; + const ret = await createUser(body); + expect(mockedRequest.post).toHaveBeenCalledTimes(1); + const [url, payload] = mockedRequest.post.mock.calls[0]; + expect(url).toBe('/usr/users'); + expect(payload).toEqual(body); + expect(payload).not.toHaveProperty('initialPassword'); + expect(payload).not.toHaveProperty('sCreator'); + expect(payload).not.toHaveProperty('tCreateDate'); + expect(ret).toEqual({ id: 9 }); + }); + + it('updateUser puts /usr/users/{id} with body (no sUserName)', async () => { + mockedRequest.put.mockResolvedValue({ id: 7 }); + const body = { + sUserType: '超级管理员', + sLanguage: '英文', + iCanModifyBill: 1 as const, + iIsVoid: 0 as const, + permissionIds: [2], + }; + const ret = await updateUser(7, body); + expect(mockedRequest.put).toHaveBeenCalledTimes(1); + const [url, payload] = mockedRequest.put.mock.calls[0]; + expect(url).toBe('/usr/users/7'); + expect(payload).toEqual(body); + expect(payload).not.toHaveProperty('sUserName'); + expect(ret).toEqual({ id: 7 }); + }); + + it('getUserDetail queries equals match pageSize 1 and returns records[0]', async () => { + mockedRequest.get.mockResolvedValue({ + records: [ + { + id: 7, + sUserName: 'zhangsan', + 员工名: '张三', + sUserNo: 'zs', + 部门: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + }, + ], + total: 1, + pageNum: 1, + pageSize: 1, + }); + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'zhangsan' }); + expect(mockedRequest.get).toHaveBeenCalledTimes(1); + const [url, config] = mockedRequest.get.mock.calls[0]; + expect(url).toBe('/usr/users'); + expect(config.params).toMatchObject({ + queryField: '用户名', + matchType: '等于', + queryValue: 'zhangsan', + pageNum: 1, + pageSize: 1, + }); + expect(ret).not.toBeNull(); + expect(ret!.id).toBe(7); + expect(ret!.employeeName).toBe('张三'); + }); + + it('getUserDetail returns null when records empty', async () => { + mockedRequest.get.mockResolvedValue({ records: [], total: 0, pageNum: 1, pageSize: 1 }); + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'none' }); + expect(ret).toBeNull(); + }); + + it('listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption', async () => { + mockedRequest.get.mockResolvedValue([ + { iIncrement: 3, sEmployeeName: '张三', sEmployeeNo: 'zs' }, + ]); + const ret = await listEmployees(); + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/employees'); + expect(ret).toEqual([{ value: 3, label: '张三', sEmployeeNo: 'zs' }]); + }); + + it('listPermissions normalizes to PermissionItem', async () => { + mockedRequest.get.mockResolvedValue([ + { iIncrement: 1, sPermissionName: '默认显示', sPermissionCategory: '基础' }, + ]); + const ret = await listPermissions(); + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/permissions'); + expect(ret).toEqual([{ id: 1, name: '默认显示', category: '基础' }]); + }); +}); diff --git a/frontend/tests/unit/usrApi.userlist.test.ts b/frontend/tests/unit/usrApi.userlist.test.ts new file mode 100644 index 0000000..f838c71 --- /dev/null +++ b/frontend/tests/unit/usrApi.userlist.test.ts @@ -0,0 +1,95 @@ +// REQ-USR-003: listUsers API 封装单测(GET /api/usr/users + 中文键归一 D-PLAN-2) +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// 桩掉底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.test.ts 模式 +vi.mock('../../src/api/request', () => { + return { + default: { + post: vi.fn(), + get: vi.fn(), + }, + }; +}); + +import request from '../../src/api/request'; +import { listUsers } from '../../src/api/usrApi'; + +const mockedRequest = request as unknown as { + post: ReturnType<typeof vi.fn>; + get: ReturnType<typeof vi.fn>; +}; + +function pageBody(records: unknown[], total = 1, pageNum = 1, pageSize = 10) { + return { records, total, pageNum, pageSize }; +} + +describe('usrApi.listUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('listUsers gets /usr/users with query params', async () => { + mockedRequest.get.mockResolvedValue(pageBody([], 0, 2, 20)); + await listUsers({ + queryField: '用户名', + matchType: '包含', + queryValue: '李', + pageNum: 2, + pageSize: 20, + }); + expect(mockedRequest.get).toHaveBeenCalledTimes(1); + const [url, config] = mockedRequest.get.mock.calls[0]; + expect(url).toBe('/usr/users'); + expect(config.params).toMatchObject({ + queryField: '用户名', + matchType: '包含', + queryValue: '李', + pageNum: 2, + pageSize: 20, + }); + }); + + it('listUsers omits empty queryValue', async () => { + mockedRequest.get.mockResolvedValue(pageBody([], 0, 1, 10)); + await listUsers({ + queryField: '用户名', + matchType: '包含', + queryValue: '', + pageNum: 1, + pageSize: 10, + }); + const [, config] = mockedRequest.get.mock.calls[0]; + expect(config.params).not.toHaveProperty('queryValue'); + }); + + it('listUsers normalizes chinese keys 员工名/部门 to employeeName/departmentName', async () => { + mockedRequest.get.mockResolvedValue( + pageBody( + [ + { + id: 1, + sUserName: 'a', + 员工名: '张三', + 部门: '技术', + sUserNo: 'a', + sUserType: '超级管理员', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'x', + tCreateDate: 't', + }, + ], + 1, + 2, + 20, + ), + ); + const ret = await listUsers({ pageNum: 2, pageSize: 20 }); + expect(ret.records[0].employeeName).toBe('张三'); + expect(ret.records[0].departmentName).toBe('技术'); + expect(ret.total).toBe(1); + expect(ret.pageNum).toBe(2); + expect(ret.pageSize).toBe(20); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a842a8e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals", "@testing-library/jest-dom", "node"] + }, + "include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c4f0506 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +// dev_port (frontend.dev_port) = 5173;后端 http_port (backend.http_port) = 5172(D2) +// /api 经 Vite dev proxy 转发到后端,避免跨端口 CORS。 +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5172', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./tests/setup.ts'], + css: true, + include: ['tests/unit/**/*.{test,spec}.{ts,tsx}'], + }, +});