Commit 8427fb705ae6130c8d0e1d7b8e70718525e85133

Authored by zichun
1 parent 3d923ac9

docs(plan:FE-01): 任务级 TDD 计划

docs/superpowers/plans/2026-06-01-FE-01.md 0 → 100644
  1 +# FE-01 登录页 — 任务级 TDD 计划(前端)
  2 +
  3 +> 阶段:前端(frontend)。作用域:`frontend/**`(页面 / 组件 / 路由 / store / api / 样式 / 测试 / 工程配置)。**禁止**写 `backend/**` / `sql/**` / `scripts/**`。
  4 +> 上游 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`。
  5 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / props 与 API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整个组件 / 配置文件内容。
  6 +> **本 REQ 是仓库首个前端任务**:`frontend/` 目录尚不存在,FE-01 需先搭建最小前端工程骨架(package.json / vite / 测试栈 / Redux / Router / AntD / Axios 封装),再实现登录页。后续 FE-02~FE-04 复用此骨架。
  7 +
  8 +---
  9 +
  10 +## Goal(目标)
  11 +
  12 +在 `/login` 路由实现登录页 `LoginPage`,复刻原型 `#screen-login` 的三段式布局(品牌头 / 深蓝主视觉 + 右侧浮层登录卡 / 页脚版权)与交互语义,但表单校验、提交、版本下拉取数、错误反馈全部真实对接后端:
  13 +
  14 +- 页面挂载即调 `GET /api/usr/companies` 预加载「版本」下拉项(`companiesLoading` → `idle`/`empty`);取数失败给重试入口 + `message.error`。
  15 +- 用户填用户名 / 密码、选版本后点「登录」→ 前端必填校验通过 → 调 `POST /api/usr/login`(body `{ sUserName, password, companyId }`);提交中 `submitting`(按钮 loading、字段禁用、防重复提交)。
  16 +- 成功(`code=0`):拿 `token` + `user` 写入 Redux `authSlice.setCredentials`,token 持久化到 `localStorage`(键 `xly_erp_token`),`message.success("登录成功")`,`navigate('/', { replace:true })`。
  17 +- 失败:按错误码(`40001`/`40101`/`40302`/`42901`/网络异常)渲染对应中文文案;`40101`/`42901` 后清空密码框并聚焦,保留用户名与版本。
  18 +- 状态机覆盖 ≥5 态:`companiesLoading` / `idle` / `empty` / `submitting` / `error` / `success`(spec § 3)。
  19 +- 业务规则前端复刻 BR1~BR11(spec § 5),其中身份真伪 / 禁用 / 限流均由后端裁决,前端仅按返回码渲染。
  20 +
  21 +## Architecture(架构 / 分层)
  22 +
  23 +遵循 `docs/04 § 2.1`,前端为仓库根 `frontend/` 子项目,包名 `xly-erp-web`(config-vars `frontend.pkg_name`)。本 REQ 新建工程骨架 + 登录页相关文件:
  24 +
  25 +```
  26 +frontend/
  27 +├── 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
  28 +├── tsconfig.json # 【新增】TS 配置(jsx react-jsx、strict)
  29 +├── vite.config.ts # 【新增】React 插件 + server.port=5173 + server.proxy '/api'→http://localhost:5172(D2)+ vitest test 配置(environment jsdom、globals、setupFiles)
  30 +├── playwright.config.ts # 【新增】e2e:testDir=tests/e2e,baseURL 取 dev server
  31 +├── index.html # 【新增】挂载点 #root + 引入 main.tsx
  32 +├── tests/
  33 +│ ├── setup.ts # 【新增】vitest setup:import '@testing-library/jest-dom';引入 tokens.css 供 jsdom(可选)
  34 +│ ├── unit/ # 【新增】jsdom 组件 / store / api 单测(Vitest + RTL)
  35 +│ └── e2e/ # 【新增】Playwright E2E
  36 +├── src/
  37 +│ ├── main.tsx # 【新增】入口:Redux Provider + BrowserRouter + AntD ConfigProvider(theme.colorPrimary=var(--color-primary) 取值) + 引入 styles/tokens 与全局样式
  38 +│ ├── App.tsx # 【新增】挂载路由 <AppRouter/>
  39 +│ ├── router/index.tsx # 【新增】React Router v6 路由表:/login → LoginPage;'/' 占位(FE-02 落地,本 REQ 仅需 /login 可达 + 成功后 navigate('/'))
  40 +│ ├── store/store.ts # 【新增】configureStore({ reducer: { auth: authReducer } }) + 导出 RootState / AppDispatch
  41 +│ ├── store/slices/authSlice.ts # 【新增】authSlice:state { token, user };reducer setCredentials / clearCredentials
  42 +│ ├── api/request.ts # 【新增】Axios 实例:baseURL '/api';请求拦截器(已登录注入 Authorization: Bearer,登录端点放行);响应拦截器(拆 Result:code=0 取 data,非 0 抛 ApiError,网络异常兜底)
  43 +│ ├── api/usrApi.ts # 【新增】login(payload) / fetchCompanies();调 request.ts,返回 data
  44 +│ ├── pages/usr/Login/LoginPage.tsx # 【新增】登录页根组件(容器 + 状态机 + 提交逻辑)
  45 +│ ├── pages/usr/Login/components/ # 【新增】LoginHeader / LoginHero / LoginCard / LoginForm 等区域子组件(按需拆分;纯展示子组件可内联)
  46 +│ ├── pages/usr/Login/Login.module.css # 【新增】登录页 scoped 样式:语义色用 var(--color-*);主视觉深蓝渐变/网格为局部装饰(D7,不新增全局 token)
  47 +│ └── styles/ # 【新增】全局样式入口,import 仓库根 ../../src/styles/tokens.css(Design Tokens SSoT,见 D9)
  48 +└── (eslint 配置 .eslintrc.cjs 或 eslint.config.js 按 vite-react 模板)
  49 +```
  50 +
  51 +- **跨模块 / 跨阶段**:本 REQ 落点全在 `frontend/**`,不触 `backend/` / `sql/` / `scripts/`。新建的 `store` / `router` / `api/request.ts` / `main.tsx` / 工程配置属全前端共享骨架,FE-02~FE-04 复用(非 FE-01 私有),在《模块完成报告》留痕「FE-01 搭建前端工程骨架,后续 FE 复用」。
  52 +- **状态管理**(docs/04 § 2.2):全局登录态(token + user)进 Redux `authSlice`;页面内瞬时态(`companiesLoading` / `companies` 列表 / `empty` / `submitting`)就近用 `useState`,不塞全局。
  53 +- **请求封装**(docs/04 § 2.3):统一走 `api/request.ts`,页面只调 `usrApi.ts` 封装方法,不散用 axios。响应拦截器拆 `Result`,非 0 `code` 抛出携带 `code` 的错误供页面分流文案;网络/超时/5xx 兜底。
  54 +- **错误处理**(docs/04 § 2.4):表单校验错误就近 AntD 红字;提交失败按错误码 `message.error`;版本取数失败空态 + 重试。
  55 +- **Design Tokens**(docs/04 § 2.1 / spec § 7):语义色(按钮 / 文字 / 边框 / 错误 / 背景)只用 `var(--color-*)`,禁止硬编码 hex/rgba;AntD `colorPrimary` 经 `ConfigProvider` 对齐 `--color-primary`;主视觉深蓝渐变 / 网格透视为登录页局部装饰,scoped 保留,不挪用语义 token、不新增全局 token(D7)。
  56 +
  57 +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars)
  58 +
  59 +- React 18.x / Ant Design 5.x / Redux Toolkit(最新稳定)/ React Router v6 / Vite(最新稳定)/ Axios(最新稳定)/ TypeScript;`@ant-design/icons`(`UserOutlined` / `LockOutlined`)。
  60 +- 测试:单测 Vitest(jsdom 环境)+ `@testing-library/react` / `@testing-library/jest-dom` / `@testing-library/user-event`;E2E Playwright(`@playwright/test`)。
  61 +- 命令(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)。
  62 +- 端口 / 转发:dev server 端口 `5173`(config-vars `frontend.dev_port`),Vite proxy `/api` → `http://localhost:5172`(config-vars `backend.http_port`,D2);不硬编码到组件,集中在 `vite.config.ts`。
  63 +
  64 +## 合同级常量(跨 task 必须一致)
  65 +
  66 +- 路由 path:`/login`(登录页);登录成功跳转目标 `/`(D3,`{ replace: true }`)。
  67 +- API client 签名(`api/usrApi.ts`,跨 task 一致):
  68 + - `login(payload: LoginPayload): Promise<LoginResult>`,其中 `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 } }>`)。
  69 + - `fetchCompanies(): Promise<CompanyOption[]>`,其中 `CompanyOption = { id: number; sCompanyName: string; sVersion: string | null }`(对齐 spec § 4 / D8;`sVersion` 可空)。
  70 +- HTTP 形状(`api/request.ts`):
  71 + - `baseURL = '/api'`;登录端点 `POST /api/usr/login`(最终 path `/usr/login`)放行、**不带** `Authorization`;版本端点 `GET /api/usr/companies`(最终 path `/usr/companies`)放行。
  72 + - 后端统一响应 `Result<T> = { code: number; message: string; data: T }`(docs/04 § 1.4);响应拦截器:`code===0` 返回 `data`;`code!==0` 抛 `ApiError`(携带 `code:number` + `message:string`);无响应(网络/超时/5xx)抛 `ApiError`(`code` 置统一标识,如 `-1` 表网络异常)。
  73 + - 请求拦截器:从 `localStorage` 读 `xly_erp_token`,存在则注入 `Authorization: Bearer <token>`;登录 / 版本端点本就放行,无 token 时不注入(自然跳过)。
  74 +- token 持久化键名:`localStorage` key = `xly_erp_token`(D6)。集中常量(如 `src/api/request.ts` 或 `src/store/slices/authSlice.ts` 导出 `TOKEN_STORAGE_KEY`),跨 task 引用同一常量,不各处写字面量。
  75 +- 错误码 → 前端文案(对齐 spec § 4 / docs/05;登录失败分流,**严格沿用**,不得细化 `40101`):
  76 + | code | 前端文案 | 展示方式 |
  77 + |---|---|---|
  78 + | `0` | 「登录成功」 | `message.success` 后跳转 |
  79 + | `40001` | 「请填写用户名、密码并选择版本」 | `message.error`(兜底,正常前端校验已拦截) |
  80 + | `40101` | 「用户名或密码错误」 | `message.error` + 清空密码框聚焦(不细化为「账号不存在」等枚举文案,BR6) |
  81 + | `40302` | 「该账号已被禁用,请联系管理员」 | `message.error` |
  82 + | `42901` | 「登录尝试过于频繁,请稍后再试」 | `message.error` + 清空密码框聚焦 |
  83 + | 网络/超时/5xx | 「网络异常,请稍后重试」 | 响应拦截器兜底 `message.error` |
  84 + > 文案集中在一处映射(如 `LoginPage` 内 `LOGIN_ERROR_MESSAGES: Record<number, string>` 或 `usrApi`/util 常量),按 `ApiError.code` 查表,未命中走网络异常兜底文案。
  85 +- 表单字段名(AntD `Form` `name`,提交时映射到 `LoginPayload`):`sUserName` / `password` / `companyId`;占位文案:用户名「请输入你的用户名」、密码「请输入你的密码」(沿用原型)。
  86 +- 版本下拉文案:`placeholder`「请选择版本」;加载中 `placeholder`「加载版本中…」;空态 `notFoundContent`「暂无可用版本」。
  87 +- 校验文案(AntD `rules.message`):用户名「请输入用户名」(BR1);密码「请输入密码」(BR2);版本「请选择版本」(BR4)。
  88 +- 版本下拉 label 规则(D8):返回项 `sVersion` 非空 → label = `` `${sCompanyName}(${sVersion})` ``(全角括号);`sVersion` 为空/null → label = `sCompanyName`;`value` 恒取 `id`(提交作 `companyId`);列表仅 1 项时默认选中该项(spec § 6.3)。
  89 +
  90 +## 关键签名(首次出现处给出,跨 task 保持一致)
  91 +
  92 +- `authSlice`(`store/slices/authSlice.ts`):
  93 + - `AuthState = { token: string | null; user: AuthUser | null }`,`initialState` 从 `localStorage` 读 `xly_erp_token` 初始化 `token`(user 初始 null)。
  94 + - `setCredentials(state, action: PayloadAction<{ token: string; user: AuthUser }>)`:写 token + user,并 `localStorage.setItem(TOKEN_STORAGE_KEY, token)`。
  95 + - `clearCredentials(state)`:清 token + user,并 `localStorage.removeItem(TOKEN_STORAGE_KEY)`。
  96 + - 导出 `authReducer`(default)、`setCredentials` / `clearCredentials` actions。
  97 + > 注:reducer 内写 localStorage 属副作用,为简洁集中放此(MVP 取舍);若 TDD 期偏好纯 reducer,可改由 `LoginPage` 在 dispatch 后单独持久化——二者择一,全项目统一(默认前者,登记 D6)。
  98 +- `request.ts`:导出 axios 实例 `request`(default)+ `TOKEN_STORAGE_KEY` 常量 + `ApiError`(含 `code: number`、`message: string`)。
  99 +- `usrApi.ts`:`login(payload)` / `fetchCompanies()`(签名见合同级常量)。
  100 +- `LoginPage`(`pages/usr/Login/LoginPage.tsx`,default export,无 props,路由组件);内部用 `useDispatch<AppDispatch>()` + `useNavigate()` + AntD `Form.useForm()` + `App.useApp()`/`message`(AntD 5 推荐 `App` 包裹用 `message` 实例,否则用静态 `message`——择一统一)。
  101 +- 区域子组件(若拆分,均在 `pages/usr/Login/components/`,纯展示,props 明确):
  102 + - `LoginHeader`(无 props 或 `{ brandName?: string }`):渲染 Logo SVG + 「Antler ERP」+ 「欢迎登录EBC平台」。
  103 + - `LoginHero`(`{ children: ReactNode }` 或无):深蓝主视觉容器 + 标语文本,`children` 槽放登录卡。
  104 + - `LoginCard`(`{ children }`):右侧浮层卡片容器 + 标题「用户登录」。
  105 + - `LoginFooter`(无 props):版权 / 备案号文本。
  106 + - 表单逻辑(取数 / 提交 / 状态)集中在 `LoginPage`(或抽 `LoginForm` 子组件接收 `{ companies, companiesLoading, companiesError, submitting, onSubmit, onRetryCompanies, formRef }` props);**抽与不抽由 TDD 期决定**,但若抽 `LoginForm`,其 props 契约以此为准,跨 task 一致。
  107 +
  108 +## 测试栈说明
  109 +
  110 +- **jsdom 组件 / store / api 单测**(Vitest + RTL):默认 mock `api/usrApi` 或 axios(用 `vi.mock`)以隔离网络;断言渲染、交互、状态切换、dispatch、navigate、message 文案。覆盖 spec § 3 状态机与 § 5 BR1~BR11 中可在组件层验证者。
  111 +- **Playwright E2E**:覆盖关键用户旅程(页面可达 + 校验拦截 + 成功跳转),通过拦截/桩 `**/api/usr/companies` 与 `**/api/usr/login` 路由响应(`page.route`)模拟后端,不依赖真实后端起服。
  112 +
  113 +---
  114 +
  115 +## 任务列表(每个 task = red → green → 子会话验证 → commit)
  116 +
  117 +> 硬护栏:以下每个 `impl_file` / `test_file` 均以 `frontend/` 开头;无任何 `backend/` / `sql/` / `scripts/` 落点。
  118 +> 提交信息格式:`<type>(<scope>): <subject> REQ-USR-004`(FE-01 关联 REQ-USR-004;scope 用 `fe-login` 或 `usr`)。
  119 +
  120 +### T0 — 前端工程骨架可启动 + 测试栈可运行(chore,先建地基)
  121 +- [ ] **1. 写失败测试**:`frontend/tests/unit/smoke.test.tsx::renders a trivial component`——一个最小冒烟用例(渲染 `<div>ok</div>` 或一个空 `App`),用于驱动 Vitest + jsdom + RTL 配置就绪;初始因无工程 / 无配置而失败。
  122 +- [ ] **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`)。
  123 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit` 冒烟用例通过;`npm run lint` 与 `npm run build` 可执行(build 至少不因配置错误失败)。
  124 +- [ ] **4. commit**:`chore(fe-login): 初始化前端工程骨架与测试栈 REQ-USR-004`
  125 +
  126 +### T1 — Axios 请求封装拆 Result + 拦截器(jsdom 单测)
  127 +- [ ] **1. 写失败测试**:`frontend/tests/unit/request.test.ts`:
  128 + - `::baseURL is /api`——实例 `baseURL === '/api'`。
  129 + - `::unwraps data when code is 0`——mock 适配器返回 `{ code:0, message:'success', data:{ foo:1 } }`,`request.get(...)` resolve 为 `{ foo:1 }`(拆 data)。
  130 + - `::throws ApiError carrying business code when code is non-zero`——返回 `{ code:40101, message:'认证失败', data:null }`,调用 reject 为 `ApiError` 且 `err.code===40101`。
  131 + - `::throws network ApiError on no-response error`——模拟无响应(网络异常),reject 为 `ApiError`,`code` 为网络异常标识(如 `-1`)。
  132 + - `::injects Authorization header when token present`——`localStorage` 预置 `xly_erp_token`,请求拦截器把 `Authorization: 'Bearer <token>'` 加到 config.headers;无 token 时不加。
  133 +- [ ] **2. 实现最小代码**:`frontend/src/api/request.ts`——导出 axios 实例(`baseURL:'/api'`)、`TOKEN_STORAGE_KEY='xly_erp_token'`、`ApiError`;请求拦截器注入 token,响应拦截器拆 `Result` / 抛 `ApiError` / 网络兜底。
  134 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- request`
  135 +- [ ] **4. commit**:`feat(fe-login): Axios 实例与 Result 拆包/错误拦截 REQ-USR-004`
  136 +
  137 +### T2 — usrApi.login / fetchCompanies(jsdom 单测)
  138 +- [ ] **1. 写失败测试**:`frontend/tests/unit/usrApi.test.ts`(`vi.mock('../../src/api/request')` 桩实例):
  139 + - `::login posts to /usr/login with sUserName/password/companyId`——断言 `request.post` 收到 path `/usr/login` 与 body `{ sUserName, password, companyId }`,返回值透传为 `{ token, user }`。
  140 + - `::fetchCompanies gets /usr/companies and returns list`——断言 `request.get('/usr/companies')`,返回 `CompanyOption[]`。
  141 +- [ ] **2. 实现最小代码**:`frontend/src/api/usrApi.ts`——`login(payload)` / `fetchCompanies()`(签名见合同级常量)。
  142 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- usrApi`
  143 +- [ ] **4. commit**:`feat(fe-login): usrApi 登录与版本下拉取数封装 REQ-USR-004`
  144 +
  145 +### T3 — authSlice setCredentials / clearCredentials + token 持久化(jsdom 单测)
  146 +- [ ] **1. 写失败测试**:`frontend/tests/unit/authSlice.test.ts`:
  147 + - `::setCredentials stores token and user and persists token`——dispatch `setCredentials({ token:'t', user:{...} })`,state.token/user 更新,且 `localStorage.getItem('xly_erp_token')==='t'`。
  148 + - `::clearCredentials clears state and removes persisted token`——清空 state 且 `localStorage` 该键被移除。
  149 + - `::initialState reads persisted token`——预置 `localStorage` token 后初始化 reducer,`token` 为该值。
  150 +- [ ] **2. 实现最小代码**:`frontend/src/store/slices/authSlice.ts`(签名见关键签名)+ `frontend/src/store/store.ts`(`configureStore`,挂 `auth` reducer,导出 `RootState` / `AppDispatch`)。
  151 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- authSlice`
  152 +- [ ] **4. commit**:`feat(fe-login): authSlice 登录态与 token 持久化 REQ-USR-004`
  153 +
  154 +### T4 — 登录页布局与区域结构渲染(jsdom 组件测)
  155 +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.layout.test.tsx`(用 Redux Provider + MemoryRouter 包裹渲染;mock `usrApi.fetchCompanies` resolve 空列表避免 act 警告):
  156 + - `::renders brand header / hero slogan / footer`——存在品牌名「Antler ERP」、副标题「欢迎登录EBC平台」、主视觉中文「企业业务能力平台」与「ERP」、页脚版权文本(含备案号)。
  157 + - `::renders login card title 用户登录`——卡片标题文案存在。
  158 + - `::renders username/password/version fields and submit button 登 录`——用户名 `Input`(占位「请输入你的用户名」)、密码 `Input.Password`(占位「请输入你的密码」、`type` 掩码,BR3)、版本 `Select`、提交按钮文案「登 录」。
  159 +- [ ] **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。
  160 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.layout`
  161 +- [ ] **4. commit**:`feat(fe-login): 登录页三段式布局与区域结构 REQ-USR-004`
  162 +
  163 +### T5 — 版本下拉预加载状态机:loading / idle / empty / 取数失败重试(jsdom 组件测)
  164 +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.companies.test.tsx`(`vi.mock` `usrApi`):
  165 + - `::shows loading placeholder and disabled select while fetching`——`fetchCompanies` pending 时版本 `Select` 处 loading/禁用、`placeholder` 含「加载版本中」(`companiesLoading` 态)。
  166 + - `::renders options with label rule on resolve (idle)`——resolve `[{id:1,sCompanyName:'甲公司',sVersion:'标准版'},{id:2,sCompanyName:'乙公司',sVersion:null}]`,下拉项 label 分别为「甲公司(标准版)」「乙公司」(D8);展开可见。
  167 + - `::auto-selects when single option`——resolve 仅 1 项时该项默认选中(spec § 6.3)。
  168 + - `::empty state when companies is empty`——resolve `[]`,`Select` 空态文案「暂无可用版本」+ 轻量提示「未获取到可登录版本,请联系管理员」(`empty` 态)。
  169 + - `::shows error with retry when fetch fails`——`fetchCompanies` reject,出现「版本加载失败」+ 重试入口;点重试再次调用 `fetchCompanies`(BR5)。
  170 +- [ ] **2. 实现最小代码**:在 `LoginPage` 中加挂载即取数(`useEffect`)、`companies` / `companiesLoading` / `companiesError` 本地态、label 映射(D8)、单项自动选中、空态与重试逻辑。
  171 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.companies`
  172 +- [ ] **4. commit**:`feat(fe-login): 版本下拉预加载/空态/重试 REQ-USR-004`
  173 +
  174 +### T6 — 表单必填校验拦截提交(BR1/BR2/BR4)(jsdom 组件测)
  175 +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.validation.test.tsx`(mock `usrApi`,companies 预置非空):
  176 + - `::blocks submit and shows required messages when empty`——直接点「登录」,出现「请输入用户名」「请输入密码」「请选择版本」三条校验红字,且 `usrApi.login` **未被调用**(BR1/BR2/BR4 + spec § 5)。
  177 + - `::submits with payload when all filled`——填全三项后点登录,`usrApi.login` 收到 `{ sUserName, password, companyId }`(companyId 为所选项 id)。
  178 +- [ ] **2. 实现最小代码**:在 `LoginForm`/`LoginPage` 配置 AntD `Form` `rules`(三字段 required + 对应 message)、`onFinish` 组装 `LoginPayload` 调 `usrApi.login`。
  179 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.validation`
  180 +- [ ] **4. commit**:`feat(fe-login): 登录表单必填校验与提交装配 REQ-USR-004`
  181 +
  182 +### T7 — 提交中态防重复提交(submitting,BR10)(jsdom 组件测)
  183 +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.submitting.test.tsx`:
  184 + - `::button loading and fields disabled while submitting`——`usrApi.login` 返回 pending Promise,提交后按钮 `loading` 且三字段禁用(`submitting` 态)。
  185 + - `::ignores duplicate submit while pending`——pending 期间再次点击 / 回车,`usrApi.login` 仅被调用 1 次(防重复提交,BR10)。
  186 +- [ ] **2. 实现最小代码**:`LoginPage` 加 `submitting` 本地态(提交前置 true、settle 后置 false),按钮 `loading={submitting}`、字段 `disabled`,提交时若已 `submitting` 直接 return。
  187 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.submitting`
  188 +- [ ] **4. commit**:`feat(fe-login): 提交中态与防重复提交 REQ-USR-004`
  189 +
  190 +### T8 — 登录成功:写 authSlice + 持久化 + 跳转 /(success,BR9)(jsdom 组件测)
  191 +- [ ] **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`):
  192 + - `::dispatches setCredentials and persists token on success`——成功后 store `auth.token==='tk'`、`auth.user` 写入,`localStorage.xly_erp_token==='tk'`。
  193 + - `::shows success message and navigates to '/' with replace`——`message.success('登录成功')` 被调,`navigate('/', { replace:true })` 被调(D3)。
  194 +- [ ] **2. 实现最小代码**:`onFinish` 成功分支 dispatch `setCredentials({ token, user })`、`message.success('登录成功')`、`navigate('/', { replace:true })`。
  195 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.success`
  196 +- [ ] **4. commit**:`feat(fe-login): 登录成功落地登录态与跳转 REQ-USR-004`
  197 +
  198 +### T9 — 登录失败错误码分流文案 + 失败后清空聚焦(error,BR6/BR7/BR8 + D5)(jsdom 组件测)
  199 +- [ ] **1. 写失败测试**:`frontend/tests/unit/LoginPage.error.test.tsx`(mock `usrApi.login` reject `ApiError`;mock `message`;表单预填合法值):
  200 + - `::40101 shows 用户名或密码错误 and clears+focuses password`——reject `code:40101`,`message.error('用户名或密码错误')`,密码框被清空且获焦(BR6 + D5)。
  201 + - `::40302 shows 该账号已被禁用,请联系管理员`——`message.error` 文案匹配(BR7)。
  202 + - `::42901 shows 登录尝试过于频繁,请稍后再试 and clears password`——文案匹配 + 密码清空(BR8 + D5)。
  203 + - `::40001 shows 请填写用户名、密码并选择版本`——兜底文案(spec § 4)。
  204 + - `::network error shows 网络异常,请稍后重试`——reject 网络异常 `ApiError`(如 `code:-1`),兜底文案。
  205 + - `::button recovers clickable and username/version preserved after failure`——失败后按钮恢复可点、用户名与版本保留(spec § 3 error 态 / D5)。
  206 +- [ ] **2. 实现最小代码**:`onFinish` 失败分支按 `err.code` 查 `LOGIN_ERROR_MESSAGES` 表(未命中走网络异常文案)`message.error(...)`;`40101`/`42901` 后 `form.setFieldValue('password', '')` 并聚焦密码框;恢复 `submitting=false`。
  207 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:unit -- LoginPage.error`
  208 +- [ ] **4. commit**:`feat(fe-login): 登录失败错误码分流文案与失败后处理 REQ-USR-004`
  209 +
  210 +### T10 — E2E 登录关键旅程(Playwright)
  211 +- [ ] **1. 写失败测试**:`frontend/tests/e2e/login.spec.ts`(`page.route` 桩 `**/api/usr/companies` 返回 `{code:0,...,data:[...]}`、`**/api/usr/login` 按用例返回成功 / `40101`):
  212 + - `::loads /login and shows version options`——访问 `/login`,版本下拉渲染桩返回项。
  213 + - `::blocks submit with validation when empty`——空提交看到必填校验提示,未发起 login 请求。
  214 + - `::successful login navigates away from /login`——填全 + 桩成功响应 → URL 离开 `/login`(到 `/`)、可见「登录成功」提示。
  215 + - `::failed login stays on /login with error`——桩 `40101` → 停留 `/login`、可见「用户名或密码错误」。
  216 +- [ ] **2. 实现最小代码**:补齐 `playwright.config.ts`(`webServer` 起 `npm run dev`、`baseURL` 指向 dev server)及任何为可测性需要的最小 `data-testid`(仅在 RTL 无法稳定定位时添加)。
  217 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run test:e2e -- login`(首次需 `npx playwright install` 装浏览器)。
  218 +- [ ] **4. commit**:`test(fe-login): 登录页 E2E 关键旅程 REQ-USR-004`
  219 +
  220 +### T11 — 全量门禁回归 + 收尾(chore)
  221 +- [ ] **1. 写失败测试**:无新增测试;本任务跑全量验证。
  222 +- [ ] **2. 实现最小代码**:修复 lint / build / 类型问题(如有);确认语义色全部 `var(--color-*)`、无硬编码 hex/rgba(主视觉装饰 scoped 除外,D7)。
  223 +- [ ] **3. 子会话验证 PASS**:`cd frontend && npm run lint && npm run build && npm run test:unit && npm run test:e2e` 全绿。
  224 +- [ ] **4. commit**:`chore(fe-login): FE-01 门禁回归通过 REQ-USR-004`
  225 +
  226 +---
  227 +
  228 +## 完成判据(Definition of Done)
  229 +
  230 +1. `/login` 路由可达,渲染复刻原型 `#screen-login` 的品牌头 / 深蓝主视觉 + 右侧浮层登录卡 / 页脚三段式(spec § 2 / § 6.1-6.2)。
  231 +2. 状态机 ≥5 态全部覆盖并有测试固化:`companiesLoading` / `idle` / `empty` / `submitting` / `error` / `success`(spec § 3)。
  232 +3. 业务规则 BR1~BR11 在组件层 / E2E 有对应断言(身份真伪 / 禁用 / 限流由后端裁决,前端按返回码渲染,不复制后端逻辑)(spec § 5)。
  233 +4. 消费 `GET /api/usr/companies`(预加载、空态、重试)与 `POST /api/usr/login`(成功 / `40001` / `40101` / `40302` / `42901` / 网络异常)按错误码表分流文案(spec § 4),文案逐字一致。
  234 +5. 成功后写 Redux `authSlice` + 持久化 `localStorage[xly_erp_token]` + `navigate('/', { replace:true })`(spec § 6.6 / D3 / D6)。
  235 +6. 统一走 `api/request.ts` + `api/usrApi.ts`,不在页面散用 axios(docs/04 § 2.3)。
  236 +7. 语义色只用 `var(--color-*)`,AntD `colorPrimary` 对齐 `--color-primary`,主视觉装饰 scoped 不新增全局 token(spec § 7 / D7)。
  237 +8. 全部落点在 `frontend/**`,无 `backend/` / `sql/` / `scripts/` 改动。
  238 +9. 门禁全绿:`npm run lint` / `npm run build` / `npm run test:unit` / `npm run test:e2e`(docs/04 § 零)。
  239 +
  240 +## 自审记录
  241 +
  242 +- **占位符扫描**:本计划无 `【人工填写:】` / `TBD` / `TODO` 占位。
  243 +- **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(本计划层)见下。
  244 +- **本计划新增决策 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[]。
  245 +- **类型一致性**:`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 }` 对齐。
... ...