Commit 3545663a7113500e02edf252cc4a4bdf80531531
1 parent
6f679058
docs(plan:FE-04): 任务级 TDD 计划
Showing
1 changed file
with
315 additions
and
0 deletions
docs/superpowers/plans/2026-06-01-FE-04.md
0 → 100644
| 1 | +# FE-04 用户信息单据 — 任务级 TDD 计划(前端) | ||
| 2 | + | ||
| 3 | +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / api / 类型 / 样式 / 测试)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。 | ||
| 4 | +> 上游 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` → `<section id="screen-userdetail">`(`.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`。 | ||
| 5 | +> 复用资产: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`)。 | ||
| 6 | +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与类型契约 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整组件 / 类型文件内容。 | ||
| 7 | +> **本 FE 同时承载新增(`POST`)与修改(`PUT`)两种写场景**,共用同一单据组件按 `mode` 分支;前端只做字段采集 + 轻量前置校验 + 写提交 + 反馈回流。用户名唯一性/格式终判、枚举与外键存在性、权限多对多写入与全量覆盖、密码 BCrypt 初始化、管理员权限、审计字段生成等真伪裁决全部在后端(spec § 5 末注)。 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +## Goal(目标) | ||
| 12 | + | ||
| 13 | +把 FE-02 在 `router/index.tsx` 留下的 `/usr/users/new` 与 `/usr/users/:id` 占位(`UserDetailPlaceholder`)替换为真实「用户信息单据」页面,复刻原型 `#screen-userdetail` 的布局与交互语义(工具栏 / 3 列表单网格 / 权限页签条 / 权限分类勾选列表),按路由判定 `create`/`edit` 模式,数据/下拉/权限/提交真实对接后端: | ||
| 14 | + | ||
| 15 | +- **页面容器 `UserDetailPage`**(路由 `/usr/users/new` 与 `/usr/users/:id` 共用,渲染于 FE-02 `AppLayout` 的 `<Outlet/>` 内):按路由 `:id` 判定 `mode`(有 `:id` → `edit`,`/new` → `create`);纵向组合 `UserDetailToolbar` + `UserBasicForm` + `PermissionTabs` + `PermissionGroupList`;态由页面本地 hook `useUserDetail` 持有(spec § 8 D7,不进 Redux)。 | ||
| 16 | +- **API 封装**:`api/usrApi.ts` 新增 `createUser` / `updateUser` / `getUserDetail` / `listEmployees` / `listPermissions`(页面只调封装方法,不散用 axios,docs/04 § 2.3);`api/types.ts` 新增 `UserCreateReq` / `UserUpdateReq` / `EmployeeOption` / `PermissionItem` 契约(复用 FE-03 `UserVO`)。 | ||
| 17 | +- **状态机 ≥6 态**(spec § 3):`initialLoading`(挂载预取员工/权限 + edit 取详情)/ `editing` / `submitting` / `submitError` / `submitSuccess` / `loadError`,均有测试固化。 | ||
| 18 | +- **业务规则 BR1~BR17** 在组件层 / hook 层 / E2E 有断言(spec § 5)。 | ||
| 19 | +- **工具栏**:保存(校验通过后 create→`POST` / edit→`PUT`,BR12)/ 取消(有未保存改动二次确认后回 `/usr/users`,BR13 / D5)/ 新增(切 create 模式 → `navigate('/usr/users/new')`,BR14)/ 占位按钮(删除/重置密码/功能 → `message.info("功能开发中")`,作废/取消作废 → edit 态以 `iIsVoid` 启停用开关承载,D8)/ 设置齿轮(占位,D8)。 | ||
| 20 | +- **表单网格**:创建时间(只读 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)。 | ||
| 21 | +- **权限页签 + 权限分类列表**:`Tabs`(仅「权限组」有内容,其余 5 个查看权限页签占位 D9)+ `Checkbox.Group` 列表(项来自 `GET /api/usr/permissions`,勾选集合 → `permissionIds`,edit 按已授权回勾、全量覆盖语义 BR10/BR11,表头全选 `indeterminate` 半选)。 | ||
| 22 | +- **提交反馈与回流**:`code=0` → `message.success`(create:「用户创建成功」/ edit:「保存成功」)→ `navigate('/usr/users')` 回流列表(BR16);非 0 按 § 4 错误码表反馈(40901 用户名冲突就近高亮、40401 用户不存在给返回列表入口、40001/40301/网络兜底,spec § 4);被动 401 由 `request.ts` 拦截器统一跳 `/login`(本页不重复处理)。 | ||
| 23 | +- **edit 预填**:按路由 `:id` 复用 `GET /api/usr/users`(等于匹配 + pageSize=1)定位取 `records[0]` 作原值回填(D4);若 FE-03 行双击携带 `location.state.user` 则优先用之免二次请求(D4 备注)。 | ||
| 24 | +- **语义色只用 `var(--color-*)`**;工具栏深色底为页面局部装饰,scoped 保留,不新增全局 token、不挪用语义 token(spec § 7 / D10)。 | ||
| 25 | + | ||
| 26 | +## Architecture(架构 / 分层) | ||
| 27 | + | ||
| 28 | +遵循 `docs/04 § 2.1`,落点全在 `frontend/**`。**新增/改动**文件: | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +frontend/ | ||
| 32 | +├── src/ | ||
| 33 | +│ ├── api/types.ts # 【改】新增 UserCreateReq / UserUpdateReq / EmployeeOption / PermissionItem(复用 FE-03 UserVO;不破坏既有类型) | ||
| 34 | +│ ├── api/usrApi.ts # 【改】新增 createUser / updateUser / getUserDetail / listEmployees / listPermissions(不改 FE-01/FE-03 既有方法) | ||
| 35 | +│ ├── pages/usr/UserDetail/index.tsx # 【新增】UserDetailPage 页面容器:判 mode、装配 4 子组件、持 useUserDetail 态、提交反馈与导航回流 | ||
| 36 | +│ ├── pages/usr/UserDetail/useUserDetail.ts # 【新增】单据 hook(mode/formValues/employees/permissions/checkedPermissionIds/loading/submitting/error + 动作) | ||
| 37 | +│ ├── pages/usr/UserDetail/UserDetailToolbar.tsx # 【新增】深色工具条:保存/取消/新增 + 占位按钮(删除/作废/重置密码/取消作废/功能)+ 齿轮 | ||
| 38 | +│ ├── pages/usr/UserDetail/UserBasicForm.tsx # 【新增】AntD Form 3 列表单网格:8 个字段 + 员工联动 + 前置校验 | ||
| 39 | +│ ├── pages/usr/UserDetail/PermissionTabs.tsx # 【新增】AntD Tabs 权限页签条(权限组 active + 5 占位页签 D9) | ||
| 40 | +│ ├── pages/usr/UserDetail/PermissionGroupList.tsx # 【新增】权限分类勾选列表:表头全选(indeterminate) + 逐项 Checkbox(D3) | ||
| 41 | +│ ├── pages/usr/UserDetail/constants.ts # 【新增】合同级常量:类型/语言枚举、create 默认值、错误码、文案、用户名正则、占位文案 | ||
| 42 | +│ └── pages/usr/UserDetail/UserDetail.module.css # 【新增】页面 scoped 样式:语义色用 var(--color-*);工具栏深色底局部装饰(D10) | ||
| 43 | +├── src/router/index.tsx # 【改】把 /usr/users/new 与 /usr/users/:id 占位 UserDetailPlaceholder 替换为真实 UserDetailPage(属 FE 共享骨架,留痕) | ||
| 44 | +└── tests/ | ||
| 45 | + ├── unit/usrApi.userdetail.test.ts # 【新增】createUser/updateUser/getUserDetail/listEmployees/listPermissions 透传与归一(沿用 usrApi.userlist.test.ts 桩模式) | ||
| 46 | + ├── unit/useUserDetail.test.tsx # 【新增】hook 状态机:create/edit 初始化、员工联动、提交成功/失败、loadError、权限回勾(renderHook) | ||
| 47 | + ├── unit/UserBasicForm.test.tsx # 【新增】字段渲染/默认值/只读规则/枚举/必填+格式校验/员工联动(BR1-BR9) | ||
| 48 | + ├── unit/PermissionGroupList.test.tsx # 【新增】列表渲染/勾选集合/全选 indeterminate/edit 回勾(BR10/BR11/D3) | ||
| 49 | + ├── unit/PermissionTabs.test.tsx # 【新增】权限组 active + 5 占位页签 disabled/空态(D9) | ||
| 50 | + ├── unit/UserDetailToolbar.test.tsx # 【新增】保存/取消/新增回调 + 提交中禁用 + 占位按钮(BR12/BR13/BR14/BR15/D8) | ||
| 51 | + ├── unit/UserDetailPage.test.tsx # 【新增】页面集成:create/edit 贯通、提交回流、错误就近、取数失败(状态机 + BR3/BR12/BR16/BR17) | ||
| 52 | + └── e2e/userdetail.spec.ts # 【新增】E2E:新增提交回流 / 编辑预填改保存 / 用户名冲突就近 / 取数失败重试 | ||
| 53 | +``` | ||
| 54 | + | ||
| 55 | +- **跨阶段/跨模块**:本 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`/既有类型;属共享骨架扩展」。 | ||
| 56 | +- **状态管理**(docs/04 § 2.2 / spec D7):单据态(mode/formValues/employees/permissions/checkedPermissionIds/loading/submitting/error)为页面就近态,用 `useUserDetail` hook + `useState` 本地管理,不进 Redux;登录态复用 FE-01 `authSlice`(本页只读当前用户名用于 create 态制单人占位,可选)。 | ||
| 57 | +- **请求封装 / 错误处理**(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`。 | ||
| 58 | +- **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 一致)。 | ||
| 59 | + | ||
| 60 | +## Tech Stack(技术栈,源自 docs/04 § 零 + FE-01/FE-02/FE-03 骨架) | ||
| 61 | + | ||
| 62 | +- 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`)。 | ||
| 63 | +- **不新增 npm 依赖**:复用 FE-01/FE-03 既装依赖(见 `frontend/package.json` `dependencies`);前置校验用 AntD `Form` `rules` + 正则常量,无需额外校验库。 | ||
| 64 | +- 测试:单测 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**`)。 | ||
| 65 | +- 命令(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 -- <文件名片段>`。 | ||
| 66 | +- 提交格式:`<type>(<scope>): <subject> 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。 | ||
| 67 | + | ||
| 68 | +## 合同级常量(跨 task 必须一致) | ||
| 69 | + | ||
| 70 | +- **API 路径 / 方法**(`usrApi` 内传相对路径,`request.ts` baseURL=`/api` 已含前缀): | ||
| 71 | + - `POST /usr/users`(create,body=`UserCreateReq`,返回 `{ id: number }`)。 | ||
| 72 | + - `PUT /usr/users/{id}`(edit,路径 `id`,body=`UserUpdateReq`,返回 `{ id: number }`)。 | ||
| 73 | + - `GET /usr/users`(edit 预填复用,query `{ queryField, matchType:'等于', queryValue, pageNum:1, pageSize:1 }`,取 `records[0]`,复用 FE-03 `listUsers`,D4)。 | ||
| 74 | + - `GET /usr/employees`(员工名下拉数据源,无参全量,D1)。 | ||
| 75 | + - `GET /usr/permissions`(权限分类列表数据源,无参全量,D2)。 | ||
| 76 | +- **类型枚举(逐字一致,原样作为提交值,前端不映射,由后端裁决)**: | ||
| 77 | + - **用户类型** `USER_TYPE_OPTIONS = ['普通用户', '超级管理员']`,create 默认 `'普通用户'`(BR6)。 | ||
| 78 | + - **语言** `LANGUAGE_OPTIONS = ['中文', '英文', '繁体']`(BR7,无默认强制选,create 必选)。 | ||
| 79 | +- **create 默认表单值 `CREATE_DEFAULTS`**:`{ sUserName: '', sUserNo: '', iEmployeeId: null, sUserType: '普通用户', sLanguage: undefined, iCanModifyBill: 0 }`(BR1/BR2/BR6/BR8;`sLanguage` 未选触发必填校验 BR7)。 | ||
| 80 | +- **用户名前置校验正则 `USERNAME_PATTERN = /^[A-Za-z0-9_]{3,20}$/`**(3-20 位字母数字下划线,BR3,对齐 docs/05 § REQ-USR-001)。 | ||
| 81 | +- **错误码常量(对齐 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` 统一处理(本页不分流)。 | ||
| 82 | +- **mode 常量 `MODE_CREATE = 'create'` / `MODE_EDIT = 'edit'`**(由路由 `:id` 判定)。 | ||
| 83 | +- **静态文案(逐字一致,复刻原型 / spec)**: | ||
| 84 | + | 用途 | 文案 | | ||
| 85 | + |---|---| | ||
| 86 | + | 工具栏保存 | `保存` | | ||
| 87 | + | 工具栏取消 | `取消` | | ||
| 88 | + | 工具栏新增 | `新增` | | ||
| 89 | + | 工具栏占位按钮 | `删除` / `作废` / `重置密码` / `取消作废` / `功能`(点击 `message.info` 文案 `功能开发中`,D8) | | ||
| 90 | + | 制单人 create 占位 | `保存后自动生成`(复刻 `setUserDetailMode('new')`,BR2) | | ||
| 91 | + | 创建时间标签 | `创建时间` | | ||
| 92 | + | 制单人标签 | `制单人` | | ||
| 93 | + | 员工名标签 | `员工名` | | ||
| 94 | + | 用户名标签 | `用户名` | | ||
| 95 | + | 类型标签 | `类型` | | ||
| 96 | + | 语言标签 | `语言` | | ||
| 97 | + | 用户号标签 | `用户号` | | ||
| 98 | + | 单据修改权限标签 | `单据修改权限` | | ||
| 99 | + | 权限组页签 | `权限组` | | ||
| 100 | + | 占位页签 | `客户查看权限` / `供应商查看权限` / `人员查看权限` / `工序查看权限` / `司机查看权限`(D9) | | ||
| 101 | + | 权限列表表头 | `权限分类` | | ||
| 102 | + | 用户名格式错误 | `用户名须为 3-20 位字母数字下划线`(BR3) | | ||
| 103 | + | 用户名必填 | `请输入用户名`(BR3) | | ||
| 104 | + | 用户号必填 | `请输入用户号`(BR4) | | ||
| 105 | + | 类型必填 | `请选择类型`(BR6) | | ||
| 106 | + | 语言必填 | `请选择语言`(BR7) | | ||
| 107 | + | create 成功提示 | `用户创建成功`(BR16) | | ||
| 108 | + | edit 成功提示 | `保存成功`(BR16) | | ||
| 109 | + | 40001 提示 | `提交信息有误,请检查后重试`(spec § 4) | | ||
| 110 | + | 40901 提示 | `用户名已存在,请更换`(spec § 4,就近高亮用户名字段) | | ||
| 111 | + | 40401 提示 | `该用户不存在或已被删除`(spec § 4,提供「返回列表」入口) | | ||
| 112 | + | 40301 提示 | `无权限执行此操作`(spec § 4) | | ||
| 113 | + | 网络/5xx 提示 | `保存失败,请稍后重试`(spec § 4 兜底) | | ||
| 114 | + | 员工列表加载失败 | `员工列表加载失败`(spec § 4 loadError,D1) | | ||
| 115 | + | 权限列表加载失败 | `权限列表加载失败`(spec § 4 loadError,D2) | | ||
| 116 | + | 详情加载失败 | `加载失败,点击重试`(edit 详情取数失败,spec § 4 loadError) | | ||
| 117 | + | 取消二次确认 | `放弃未保存的修改?`(`Modal.confirm`,D5) | | ||
| 118 | + | 占位功能提示 | `功能开发中`(`message.info`,D8/D9) | | ||
| 119 | +- **路由 path(FE-02 已注册占位)**:create → `/usr/users/new`(BR14 / 取消回流的兄弟路由);edit → `/usr/users/:id`;保存成功/取消回流 → `/usr/users`(FE-03 列表,BR13/BR16;标签联动属 FE-02)。 | ||
| 120 | +- **localStorage token 键**:`TOKEN_STORAGE_KEY = 'xly_erp_token'`(FE-01 `request.ts` 已导出,本页不直接读写,由 `request.ts` 拦截器注入)。 | ||
| 121 | + | ||
| 122 | +## 关键签名(首次出现处给出,跨 task 一致) | ||
| 123 | + | ||
| 124 | +- **类型契约**(`api/types.ts`,新增;复用 FE-03 `UserVO`/`PageResult`/`UserListQuery` 不动): | ||
| 125 | + - `EmployeeOption`:`{ value: number; label: string; sEmployeeNo: string | null }`(员工名下拉项;`value` 映射后端 `iIncrement`、`label` 映射 `sEmployeeName`、`sEmployeeNo` 供联动带出用户号,D1/BR5)。 | ||
| 126 | + - `PermissionItem`:`{ id: number; name: string; category: string }`(权限项;`id` 映射后端 `iIncrement`、`name` 映射 `sPermissionName`、`category` 映射 `sPermissionCategory`,D2/D3)。 | ||
| 127 | + - `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)。 | ||
| 128 | + - `UserUpdateReq`:`{ sUserNo?: string; iEmployeeId?: number | null; sUserType: string; sLanguage: string; iCanModifyBill?: 0 | 1; iIsVoid?: 0 | 1; permissionIds?: number[] }`(`sUserName` 不可改不传、密码不在本接口;对齐 docs/05 § REQ-USR-002)。 | ||
| 129 | + - `UserDetailMode`:`'create' | 'edit'`。 | ||
| 130 | +- **API 封装**(`api/usrApi.ts`,新增方法;响应拦截器已拆 `Result.data`,沿用 FE-01/FE-03 `as unknown as Promise<...>` 桥接): | ||
| 131 | + - `createUser(body: UserCreateReq): Promise<{ id: number }>`——`request.post('/usr/users', body)`。 | ||
| 132 | + - `updateUser(id: number, body: UserUpdateReq): Promise<{ id: number }>`——`request.put('/usr/users/' + id, body)`。 | ||
| 133 | + - `getUserDetail(params: { queryField: string; queryValue: string }): Promise<UserVO | null>`——内部以「等于」匹配 + `pageNum:1, pageSize:1` 调 `listUsers`,返回 `records[0] ?? null`(复用 FE-03 `listUsers` 与中文键归一,D4)。 | ||
| 134 | + - `listEmployees(): Promise<EmployeeOption[]>`——`request.get('/usr/employees')`,对后端原始行(`iIncrement`/`sEmployeeName`/`sEmployeeNo`)归一为 `EmployeeOption`(D1)。 | ||
| 135 | + - `listPermissions(): Promise<PermissionItem[]>`——`request.get('/usr/permissions')`,对后端原始行(`iIncrement`/`sPermissionName`/`sPermissionCategory`)归一为 `PermissionItem`(D2/D3)。 | ||
| 136 | +- **页面 hook**(`pages/usr/UserDetail/useUserDetail.ts`): | ||
| 137 | + - `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; }`。 | ||
| 138 | + - 其中 `UserFormValues = { sUserName: string; sUserNo: string; iEmployeeId: number | null; sUserType: string; sLanguage: string | undefined; iCanModifyBill: 0 | 1; iIsVoid?: 0 | 1 }`(受控表单值,与提交映射 spec § 6 一致;`tCreateDate`/`sCreator` 只读展示态另存,不在提交值内)。 | ||
| 139 | + - 挂载即并发预取 `listEmployees()` + `listPermissions()`(initialLoading,spec § 3);`edit` 态额外按 `userId` 用 `presetUser ?? getUserDetail(...)` 取原值回填 `formValues` 并以已授权权限初始化 `checkedPermissionIds`(BR17 / D4);任一预取/详情取数失败置 `loadFailed=true` + 对应 `message.error`(spec § 4 loadError,文案见合同级常量)。 | ||
| 140 | + - `selectEmployee(value)`:设 `iEmployeeId` 并按选中员工 `label`/`sEmployeeNo` 联动带出 `sUserName`(create 态)/`sUserNo`(用户仍可改,BR5)。 | ||
| 141 | + - `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`。 | ||
| 142 | + - `reload()`:重跑挂载预取/详情(loadError 重试入口用,spec § 4)。 | ||
| 143 | + - **纯映射函数**(`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)。 | ||
| 144 | +- **组件 props**(`pages/usr/UserDetail/`): | ||
| 145 | + - `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)。 | ||
| 146 | + - `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)。 | ||
| 147 | + - `PermissionTabs`(`{ activeKey?: string; onChange?(key: string): void; children: ReactNode }`):AntD `Tabs`,第一项 `权限组`(key=`group`)承载 `children`(权限列表),其余 5 个 `客户查看权限`…`司机查看权限` 为 `disabled` 占位页签(D9)。 | ||
| 148 | + - `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)。 | ||
| 149 | + - `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)。 | ||
| 150 | +- **常量映射**(`pages/usr/UserDetail/constants.ts`):枚举/默认/错误码/文案/正则/`MODE_*`(见合同级常量)+ 上述 `toCreateReq`/`toUpdateReq`/`userVoToFormValues` 纯函数签名。 | ||
| 151 | + | ||
| 152 | +## 测试栈说明 | ||
| 153 | + | ||
| 154 | +- **jsdom 组件 / hook / api 单测**(Vitest + RTL):组件测用 `renderShell`(已存在,Provider + 真实 store + `MemoryRouter` + AntD `App`/`ConfigProvider`);`mode`/`:id` 用 `renderShell(<AppRouter/>, { 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` 模式)。 | ||
| 155 | +- **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}`。不依赖真实后端起服。 | ||
| 156 | +- **可测性**:优先语义查询(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`)。 | ||
| 157 | + | ||
| 158 | +--- | ||
| 159 | + | ||
| 160 | +## 任务列表(每个 task = red → green → 子会话验证 → commit) | ||
| 161 | + | ||
| 162 | +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。 | ||
| 163 | +> 提交 scope 统一 `usr`;REQ tag:纯 create 链路标 `REQ-USR-001`,涉 edit(预填/更新)链路追加 `REQ-USR-002`(在 commit 行注明)。 | ||
| 164 | + | ||
| 165 | +### T1 — 类型契约 + API 封装(createUser/updateUser/getUserDetail/listEmployees/listPermissions,D1/D2/D4)(jsdom api 测) | ||
| 166 | +- **测试先行类型**:jsdom 组件测试(api 单测) | ||
| 167 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/usrApi.userdetail.test.ts`(沿用 `usrApi.userlist.test.ts` 的 `vi.mock('../../src/api/request')` 桩底层实例 `post`/`put`/`get`): | ||
| 168 | + - `::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)。 | ||
| 169 | + - `::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)。 | ||
| 170 | + - `::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`。 | ||
| 171 | + - `::listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption`——桩 resolve `[{ iIncrement:3, sEmployeeName:'张三', sEmployeeNo:'zs' }]`,断言返回 `[{ value:3, label:'张三', sEmployeeNo:'zs' }]`(D1)。 | ||
| 172 | + - `::listPermissions normalizes to PermissionItem`——桩 resolve `[{ iIncrement:1, sPermissionName:'默认显示', sPermissionCategory:'基础' }]`,断言返回 `[{ id:1, name:'默认显示', category:'基础' }]`(D2/D3)。 | ||
| 173 | + > FE-01/FE-03 既有 `login`/`fetchCompanies`/`listUsers` 用例不回归(同文件或既有文件保持绿)。 | ||
| 174 | +- [ ] **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` 做后端原始键归一)。 | ||
| 175 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- usrApi`(含 FE-01/FE-03 `usrApi` 既有用例不回归) | ||
| 176 | +- [ ] **4. commit**:`feat(usr): 用户单据 API 与类型契约 create/update/detail/employees/permissions REQ-USR-001 REQ-USR-002` | ||
| 177 | + | ||
| 178 | +### T2 — 页面常量 + 提交映射纯函数(枚举/默认/正则/错误码/文案 + toCreateReq/toUpdateReq/userVoToFormValues)(jsdom 单测) | ||
| 179 | +- **测试先行类型**:jsdom 组件测试(纯逻辑断言) | ||
| 180 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/userDetailMappers.test.ts`: | ||
| 181 | + - `::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'`。 | ||
| 182 | + - `::toCreateReq maps form values + permissionIds (no password)`——传 `UserFormValues` + `[1,2]`,断言产出 `UserCreateReq` 含 `permissionIds:[1,2]`、`iCanModifyBill` 为 0/1、不含 `initialPassword`/`iIsVoid`(create 无作废,BR9)。 | ||
| 183 | + - `::toUpdateReq maps without sUserName + includes iIsVoid + full permissionIds`——断言产出 `UserUpdateReq` 不含 `sUserName`(BR3),含 `iIsVoid`、`permissionIds`(全量覆盖 BR11)。 | ||
| 184 | + - `::userVoToFormValues fills from UserVO`——传 `UserVO`,断言回填 `sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iCanModifyBill`/`iIsVoid` 与 VO 一致(BR17)。 | ||
| 185 | +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/constants.ts`(枚举/默认/正则/错误码/`MODE_*`/文案常量 + `toCreateReq`/`toUpdateReq`/`userVoToFormValues` 纯函数,签名见关键签名)。 | ||
| 186 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- userDetailMappers` | ||
| 187 | +- [ ] **4. commit**:`feat(usr): 用户单据页面常量与提交映射纯函数 REQ-USR-001 REQ-USR-002` | ||
| 188 | + | ||
| 189 | +### T3 — useUserDetail 单据 hook(状态机:initialLoading/editing/submitting/submitError/submitSuccess/loadError)(jsdom hook 测) | ||
| 190 | +- **测试先行类型**:jsdom 组件测试(`renderHook`,`vi.mock('../../src/api/usrApi')`,AntD `App` wrapper) | ||
| 191 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/useUserDetail.test.tsx`: | ||
| 192 | + - `::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)。 | ||
| 193 | + - `::edit mode prefills from getUserDetail and pre-checks permissions`——`mode:'edit'`,`userId:7`,无 `presetUser`,桩 `getUserDetail` 返回含权限的 `UserVO`,断言 `getUserDetail` 被调、`formValues` 回填原值、`checkedPermissionIds` 含已授权(BR17/D4)。 | ||
| 194 | + - `::edit mode with presetUser skips getUserDetail`——传 `presetUser`,断言 `getUserDetail` 未被调、直接以 preset 回填(D4 备注)。 | ||
| 195 | + - `::selectEmployee fills userNo/userName from employee (create)`——`selectEmployee(3)`,断言 `iEmployeeId===3` 且 `sUserName`/`sUserNo` 按选中员工带出(BR5)。 | ||
| 196 | + - `::toggle permission and toggleAll update checkedPermissionIds`——`togglePermission(1,true)` 加入 1、`toggleAll(true)` 选全部、`toggleAll(false)` 清空(BR10/BR11)。 | ||
| 197 | + - `::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)。 | ||
| 198 | + - `::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)。 | ||
| 199 | + - `::submit 40901 returns fieldError on sUserName`——桩 `createUser` reject `new ApiError(40901,...)`,断言返回 `{ ok:false, fieldError:{ field:'sUserName' } }`(spec § 4 / BR3)。 | ||
| 200 | + - `::submit 40401/40301/40001/network show message and return ok:false`——分别 reject 对应 `ApiError`,断言 `message.error` 文案(按合同级常量)且返回 `{ ok:false }`(spec § 4)。 | ||
| 201 | + - `::loadError when prefetch fails sets loadFailed and message`——桩 `listPermissions` reject,断言 `loadFailed===true` + `message.error('权限列表加载失败')`(spec § 4 loadError / D2);`reload()` 重新取数清 `loadFailed`。 | ||
| 202 | + > `message` 断言桩 AntD `App.useApp().message`(沿用 `useUserList.test.tsx` 的 wrapper/桩模式,TDD 期定一处)。 | ||
| 203 | +- [ ] **2. 实现最小代码**:`frontend/src/pages/usr/UserDetail/useUserDetail.ts`(签名见关键签名;内部 `useState` 持 `mode`/`formValues`/`employees`/`permissions`/`checkedPermissionIds`/`loading`/`submitting`/`error`/`loadFailed`,`useEffect` 挂载并发预取 + edit 详情;错误码分流按合同级常量;复用 T1 api + T2 映射)。 | ||
| 204 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- useUserDetail` | ||
| 205 | +- [ ] **4. commit**:`feat(usr): 用户单据 hook useUserDetail 状态机 REQ-USR-001 REQ-USR-002` | ||
| 206 | + | ||
| 207 | +### T4 — UserBasicForm 表单网格(字段/默认/只读/枚举/必填+格式/员工联动,BR1-BR9)(jsdom 组件测) | ||
| 208 | +- **测试先行类型**:jsdom 组件测试 | ||
| 209 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserBasicForm.test.tsx`(`renderShell` 提供 AntD App;测试内用 `Form.useForm()` wrapper 注入 `form`): | ||
| 210 | + - `::renders 8 labeled fields`——断言创建时间/制单人/员工名/用户名/类型/语言/用户号/单据修改权限 8 个标签可见(文案逐字)。 | ||
| 211 | + - `::create mode username editable with default empty; edit mode username disabled`——`mode='create'` 用户名 `Input` 可编辑;`mode='edit'` 用户名框 `disabled`(BR3)。 | ||
| 212 | + - `::create mode defaults usertype 普通用户`——create 类型下拉默认显「普通用户」(BR6)。 | ||
| 213 | + - `::username format rule rejects short/invalid and required when empty`——create 态填「ab」触发校验报「用户名须为 3-20 位字母数字下划线」;清空报「请输入用户名」(BR3)。 | ||
| 214 | + - `::userno required`——用户号空提交报「请输入用户号」(BR4)。 | ||
| 215 | + - `::usertype/language selects expose enum options only`——展开类型断言 `普通用户/超级管理员`;展开语言断言 `中文/英文/繁体`(BR6/BR7,无自由输入)。 | ||
| 216 | + - `::create mode creator shows 保存后自动生成`——create 制单人只读区文本「保存后自动生成」(BR2);edit 态显 `readonlyCreator`。 | ||
| 217 | + - `::selecting employee calls onSelectEmployee`——员工名下拉选中 → `onSelectEmployee` 收到 value(BR5)。 | ||
| 218 | + - `::单据修改权限 checkbox default unchecked (create)`——create 复选框默认未勾(BR8)。 | ||
| 219 | +- [ ] **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`)。 | ||
| 220 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserBasicForm` | ||
| 221 | +- [ ] **4. commit**:`feat(usr): 用户单据表单网格 UserBasicForm REQ-USR-001 REQ-USR-002` | ||
| 222 | + | ||
| 223 | +### T5 — PermissionGroupList 权限分类勾选列表(渲染/勾选集合/全选 indeterminate/回勾,BR10/BR11/D3)(jsdom 组件测) | ||
| 224 | +- **测试先行类型**:jsdom 组件测试 | ||
| 225 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/PermissionGroupList.test.tsx`(`renderShell`): | ||
| 226 | + - `::renders header 权限分类 and one row per permission`——传 3 个 `PermissionItem`,断言表头「权限分类」+ 3 行复选框逐项名(D3)。 | ||
| 227 | + - `::checked rows reflect checkedIds`——`checkedIds=[1]`,断言 id=1 行勾选、其余未勾(BR10/edit 回勾 BR17)。 | ||
| 228 | + - `::toggling a row calls onToggle(id, checked)`——点未勾行 → `onToggle(id,true)`;点已勾行 → `onToggle(id,false)`(BR10/BR11)。 | ||
| 229 | + - `::header select-all checked when all selected; indeterminate when partial`——全勾时表头全选 `checked`、部分勾时 `indeterminate`、全不勾时未勾(半选语义)。 | ||
| 230 | + - `::header toggle calls onToggleAll`——点表头全选(当前未全选)→ `onToggleAll(true)`;全选态再点 → `onToggleAll(false)`。 | ||
| 231 | + - `::empty permissions renders empty list (no rows)`——`permissions=[]` 无数据行(loadError 占位由页面消费)。 | ||
| 232 | +- [ ] **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`)。 | ||
| 233 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- PermissionGroupList` | ||
| 234 | +- [ ] **4. commit**:`feat(usr): 用户单据权限分类勾选列表 PermissionGroupList REQ-USR-001 REQ-USR-002` | ||
| 235 | + | ||
| 236 | +### T6 — PermissionTabs 权限页签条(权限组 active + 5 占位页签,D9)(jsdom 组件测) | ||
| 237 | +- **测试先行类型**:jsdom 组件测试 | ||
| 238 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/PermissionTabs.test.tsx`(`renderShell`): | ||
| 239 | + - `::renders 权限组 active with children`——传 `children` 哨兵元素,断言「权限组」页签存在且默认激活、`children`(权限列表)可见。 | ||
| 240 | + - `::renders 5 placeholder tabs disabled`——断言「客户查看权限/供应商查看权限/人员查看权限/工序查看权限/司机查看权限」5 个页签存在且 `disabled`(不渲染数据,D9)。 | ||
| 241 | + - `::placeholder tabs do not show permission list`——切到(被 disabled 无法切,或断言占位页签无权限行内容)确认占位(D9)。 | ||
| 242 | +- [ ] **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`)。 | ||
| 243 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- PermissionTabs` | ||
| 244 | +- [ ] **4. commit**:`feat(usr): 用户单据权限页签条 PermissionTabs REQ-USR-001` | ||
| 245 | + | ||
| 246 | +### T7 — UserDetailToolbar 工具栏(保存/取消/新增 + 提交中禁用 + 占位按钮,BR12/BR13/BR14/BR15/D8/D10)(jsdom 组件测) | ||
| 247 | +- **测试先行类型**:jsdom 组件测试 | ||
| 248 | +- [ ] **1. 写失败测试**:`frontend/tests/unit/UserDetailToolbar.test.tsx`(`renderShell`): | ||
| 249 | + - `::renders 保存/取消/新增 + placeholder buttons + gear`——断言保存/取消/新增可见,占位「删除/作废/重置密码/取消作废/功能」与齿轮可见(文案逐字 + 图标)。 | ||
| 250 | + - `::click 保存 calls onSave / 取消 calls onCancel / 新增 calls onNew`——分别点击触发对应回调(BR12/BR13/BR14)。 | ||
| 251 | + - `::submitting disables 保存 and shows loading`——`submitting=true` 时「保存」禁用且 loading,再点不触发 `onSave`(BR15)。 | ||
| 252 | + - `::canSave=false disables 保存`——`canSave=false` 时「保存」禁用。 | ||
| 253 | + - `::placeholder buttons show 功能开发中 (no business callback)`——点占位按钮/齿轮触发 `message.info('功能开发中')`、不触发 `onSave`/`onCancel`/`onNew`(D8)。 | ||
| 254 | +- [ ] **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`)。 | ||
| 255 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserDetailToolbar` | ||
| 256 | +- [ ] **4. commit**:`feat(usr): 用户单据工具栏 UserDetailToolbar REQ-USR-001 REQ-USR-002` | ||
| 257 | + | ||
| 258 | +### T8 — UserDetailPage 页面集成 + 路由接线(create/edit 贯通 + 提交回流 + 错误就近 + 取数失败,BR3/BR12/BR16/BR17/D4/D5)(jsdom 组件测) | ||
| 259 | +- **测试先行类型**:jsdom 组件测试 | ||
| 260 | +- [ ] **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`): | ||
| 261 | + - `::create mode renders empty form with defaults`——进 `/usr/users/new`,挂载预取员工/权限、渲染空表单 + 默认普通用户 + 制单人「保存后自动生成」(BR2/BR6/initialLoading→editing)。 | ||
| 262 | + - `::create submit success navigates to /usr/users with success`——填合法字段(用户名「zhangsan」/用户号/语言 + 勾权限),点保存 → `createUser` 以表单值被调、`message.success('用户创建成功')`、URL/哨兵回 `/usr/users`(BR12/BR16)。 | ||
| 263 | + - `::create username format invalid blocks submit`——填「ab」点保存 → 校验拦截、`createUser` 未被调、就近报错「用户名须为 3-20 位字母数字下划线」(BR3)。 | ||
| 264 | + - `::create 40901 highlights username field`——桩 `createUser` reject `ApiError(40901)`,点保存 → 用户名 `Form.Item` 就近报「用户名已存在,请更换」+ `message.error`(spec § 4)。 | ||
| 265 | + - `::edit mode prefills from getUserDetail and username disabled`——进 `/usr/users/7`,桩 `getUserDetail` 返回 `UserVO`,断言表单回填原值、用户名框禁用、权限按已授权回勾(BR17/BR3/D4)。 | ||
| 266 | + - `::edit submit success navigates to /usr/users with 保存成功`——edit 改类型后保存 → `updateUser(7, ...)` 被调、`message.success('保存成功')`、回流 `/usr/users`(BR16)。 | ||
| 267 | + - `::cancel with dirty form confirms then navigates`——改过字段后点取消 → 弹「放弃未保存的修改?」,确认后回 `/usr/users`(BR13/D5)。 | ||
| 268 | + - `::新增 navigates to /usr/users/new`——点工具栏「新增」→ URL/哨兵到 `/usr/users/new`(BR14)。 | ||
| 269 | + - `::loadError shows retry; retry calls reload`——桩 `listPermissions` reject → 可见「加载失败,点击重试」或对应 loadError 占位;点重试再次取数(spec § 4)。 | ||
| 270 | + - `::edit 40401 offers 返回列表`——桩 `getUserDetail` 返回 `null`(或 `updateUser` reject 40401)→ 提示「该用户不存在或已被删除」+「返回列表」入口回 `/usr/users`(spec § 4)。 | ||
| 271 | +- [ ] **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` 不动)。 | ||
| 272 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- UserDetailPage router`(确认 FE-02 `router.test.tsx` 不回归——若其断言 `/usr/users/new`/`:id` 渲染占位 `data-testid`,需同步更新为真实页可定位元素并在 commit 说明) | ||
| 273 | +- [ ] **4. commit**:`feat(usr): 用户单据页面集成与路由接线 UserDetailPage REQ-USR-001 REQ-USR-002` | ||
| 274 | + | ||
| 275 | +### T9 — E2E 用户单据关键旅程(Playwright) | ||
| 276 | +- **测试先行类型**:Playwright E2E | ||
| 277 | +- [ ] **1. 写失败测试**:`frontend/tests/e2e/userdetail.spec.ts`(沿用 `shell.spec.ts`/`userlist.spec.ts` 登录桩;`page.route` 桩 `**/api/usr/employees**`/`**/api/usr/permissions**`/`**/api/usr/users**`): | ||
| 278 | + - `::create user and return to list`——进 `/usr/users/new`,填合法字段 + 勾权限,桩 `POST` 返回 `{code:0,data:{id:9}}`,点保存 → 断言 `message`「用户创建成功」+ URL 回 `/usr/users`(用 `page.waitForRequest` 校验 POST body 含 `sUserName`)。 | ||
| 279 | + - `::edit user prefill then save`——桩 `GET /usr/users`(等于匹配)返回单条用户,进 `/usr/users/7`,断言用户名框值回填且禁用,改语言后保存(桩 `PUT` 返回 `code:0`)→ `message`「保存成功」+ 回 `/usr/users`。 | ||
| 280 | + - `::username conflict shows inline error`——桩 `POST` 返回 `{code:40901}`,提交 → 断言用户名字段就近报「用户名已存在,请更换」。 | ||
| 281 | + - `::load error shows retry`——桩 `GET /usr/permissions` 5xx,进 `/usr/users/new` → 断言「加载失败,点击重试」可见;点重试(改桩为成功)后表单可用。 | ||
| 282 | + - `::placeholder tabs/buttons are inert`——断言 5 个查看权限页签 disabled、点「删除」出现「功能开发中」(D8/D9)。 | ||
| 283 | +- [ ] **2. 实现最小代码**:补任何为可测性需要的最小 `data-testid`(仅 Playwright 无法稳定定位时,如 `userdetail-page`/`btn-save`/`field-username`/`perm-list`/`userdetail-loaderror`)。沿用 `playwright.config.ts`。 | ||
| 284 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- userdetail` | ||
| 285 | +- [ ] **4. commit**:`test(usr): 用户单据 E2E 关键旅程 REQ-USR-001 REQ-USR-002` | ||
| 286 | + | ||
| 287 | +### T10 — 全量门禁回归 + 收尾(chore) | ||
| 288 | +- **测试先行类型**:无新增测试(全量验证) | ||
| 289 | +- [ ] **1. 写失败测试**:无。 | ||
| 290 | +- [ ] **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`)。 | ||
| 291 | +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。 | ||
| 292 | +- [ ] **4. commit**:`chore(usr): FE-04 门禁回归通过 REQ-USR-001 REQ-USR-002` | ||
| 293 | + | ||
| 294 | +--- | ||
| 295 | + | ||
| 296 | +## 完成判据(Definition of Done) | ||
| 297 | + | ||
| 298 | +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)。 | ||
| 299 | +2. 状态机覆盖并测试固化:`initialLoading`(T3/T8)、`editing`(T3/T4/T8)、`submitting`(T3/T7)、`submitError`(T3/T8)、`submitSuccess`(T3/T8)、`loadError`(T3/T8/T9)(spec § 3)。 | ||
| 300 | +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)。 | ||
| 301 | +4. 字段定义/提交映射对齐 REQ 表 1/表 2 + docs/05 请求体 + 原型(8 字段 + 权限 `permissionIds`,create 不传密码、edit 不传 `sUserName`,全量覆盖语义,spec § 6 / T1/T2);`UserVO`(FE-03)/`EmployeeOption`/`PermissionItem` 字段映射一致(D1/D2/D3)。 | ||
| 302 | +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)。 | ||
| 303 | +6. 错误码分流文案对齐 spec § 4(40901 用户名就近高亮、40401 返回列表、40001/40301 message、网络兜底;loadError 重试入口;被动 401 统一跳登录)。 | ||
| 304 | +7. 占位项不杜撰后端端点:工具栏删除/重置密码/功能 + 5 个查看权限页签 + 设置齿轮均占位(`message.info('功能开发中')`/`disabled`),作废/取消作废经 PUT `iIsVoid` 承载(spec D8/D9)。 | ||
| 305 | +8. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 沿用 FE-01 `ConfigProvider`;工具栏深色底 scoped 装饰不新增全局 token、不挪用语义 token(spec § 7 / D10)。 | ||
| 306 | +9. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动;改 `router/index.tsx`(占位换真实页)、`usrApi.ts`/`types.ts`(增写端点契约)属共享骨架,已在《模块完成报告》留痕。 | ||
| 307 | +10. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。 | ||
| 308 | + | ||
| 309 | +## 自审记录 | ||
| 310 | + | ||
| 311 | +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 真实占位(正文 `TBD/TODO` 仅作为「禁止出现的字样」被引用)。 | ||
| 312 | +- **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)。 | ||
| 313 | +- **类型一致性**:`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`。 | ||
| 314 | +- **作用域自审**:所有 `impl_file` / `test_file` 均以 `frontend/` 开头;无 `backend/` / `sql/` / `scripts/` 落点。改 `src/router/index.tsx`、`src/api/usrApi.ts`、`src/api/types.ts` 属 FE 共享骨架扩展(非新阶段越界),已在架构段与 DoD 第 9 条登记留痕要求。 | ||
| 315 | +- **本计划承接的跨阶段待对齐项(自 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 红绿推进。 |