diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 7a014a1..fdf0875 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -71,5 +71,5 @@ - 整体 MR: — - 功能: - - [ ] FE-01 用户登录 | 关联 REQ:REQ-USR-001 | 关联原型:prototype/erp.html#screen-login + - [x] FE-01 用户登录 | 关联 REQ:REQ-USR-001 | 关联原型:prototype/erp.html#screen-login - [ ] FE-02 用户管理(列表 + 新增 / 编辑) | 关联 REQ:REQ-USR-002, REQ-USR-003, REQ-USR-004 | 关联原型:prototype/erp.html#screen-userlist, prototype/erp.html#screen-userdetail diff --git a/docs/superpowers/plans/2026-05-15-FE-01.md b/docs/superpowers/plans/2026-05-15-FE-01.md new file mode 100644 index 0000000..34948f4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-FE-01.md @@ -0,0 +1,366 @@ +# FE-01 用户登录 Implementation Plan + +> **Execution:** Parent skill `fe-feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现登录页 `/login`:表单(username + password + companyCode)+ submit → `POST /api/v1/auth/login` → Redux 存 accessToken + userInfo → 跳转 `/users`。同时落地前端项目骨架(Vite + React 18 + AntD 5 + Redux Toolkit + React Router v6 + Vitest + Playwright)供后续 FE 复用。 + +**Architecture:** +- Vite + React 18 + TypeScript + Ant Design 5(`ConfigProvider` 接 `tokens.css` CSS 变量)+ Redux Toolkit(accessToken 仅内存)+ React Router v6(含 `RequireAuth` 守卫)+ Axios(请求拦截器自动加 Authorization 头)。 +- 测试栈:Vitest + @testing-library/react(jsdom 组件测试)+ Playwright(E2E)。 +- 后端 Mock:组件测试用 MSW 拦截 axios;E2E 直接打真后端(与 backend test-gate 共享 .env.local 配置)。 + +**Tech Stack:** Node 20 LTS / Vite 5 / React 18 / TypeScript 5 / Ant Design 5 / Redux Toolkit / React Router v6 / Axios / Vitest 1 / @testing-library/react 14 / MSW 2 / Playwright 1.x / dayjs。 + +--- + +## 文件结构(与 docs/09 § 三 对齐,全部位于 `frontend/` 下) + +**Bootstrap(FE-01 一次性投入,后续 FE 复用):** +- `frontend/package.json`、`frontend/vite.config.ts`、`frontend/tsconfig.json`、`frontend/tsconfig.node.json`、`frontend/index.html`、`frontend/vitest.config.ts`、`frontend/playwright.config.ts`、`frontend/.eslintrc.cjs`、`frontend/.gitignore` +- `frontend/src/main.tsx`、`frontend/src/App.tsx` +- `frontend/src/styles/tokens.css`(从仓库根 `src/styles/tokens.css` 复用并增补 docs/06 § 二全部变量) +- `frontend/src/styles/global.css` +- `frontend/src/types/global.d.ts` + +**通用基础层:** +- `frontend/src/api/client.ts`(Axios 实例 + 拦截器) +- `frontend/src/api/auth.ts`(login API 包装) +- `frontend/src/api/errors.ts`(BizError 类型 + code mapping) +- `frontend/src/store/index.ts`(configureStore) +- `frontend/src/store/slices/authSlice.ts` +- `frontend/src/store/hooks.ts`(typed useAppSelector / useAppDispatch) +- `frontend/src/router/index.tsx`(路由表) +- `frontend/src/router/RequireAuth.tsx`(路由守卫) + +**FE-01 业务层:** +- `frontend/src/pages/login/LoginPage.tsx` +- `frontend/src/pages/login/LoginForm.tsx` +- `frontend/src/pages/login/LoginHero.tsx`(静态视觉,无逻辑) +- `frontend/src/pages/login/LoginFooter.tsx` +- `frontend/src/pages/login/loginConstants.ts`(公司硬编码 + 错误文案) + +**测试:** +- `frontend/src/api/client.test.ts`(jsdom:拦截器行为) +- `frontend/src/store/slices/authSlice.test.ts`(jsdom:reducer + thunk) +- `frontend/src/router/RequireAuth.test.tsx`(jsdom) +- `frontend/src/pages/login/LoginForm.test.tsx`(jsdom:表单校验 + submit 流转) +- `frontend/src/pages/login/LoginPage.test.tsx`(jsdom:错误状态机集成) +- `frontend/tests/e2e/login.spec.ts`(Playwright:成功 / 错误密码 / 锁定 三条路径) +- `frontend/src/test-utils/msw-handlers.ts`(MSW 拦截器,模拟 /api/v1/auth/login 各错误码) +- `frontend/src/test-utils/setup.ts`(vitest setup:jest-dom + MSW) + +--- + +## 约束常量(跨任务一致) + +**API client 签名:** +- `apiClient: AxiosInstance`(baseURL = `import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9090'`,timeout 10s,响应拦截器统一抛 `BizError`) +- `BizError extends Error { code: number; data?: unknown }` + +**Auth API 函数:** +- `authApi.login(req: LoginReq): Promise` +- `LoginReq { username: string; password: string; companyCode: string }` +- `LoginVo { accessToken: string; tokenType: 'Bearer'; expiresInSec: number; userInfo: UserInfo }` +- `UserInfo { userId: number; username: string; userType: 'NORMAL'|'SUPER_ADMIN'; language: string; employeeName?: string; companyCode: string }` + +**Redux auth slice:** +- state shape: `{ accessToken: string | null; userInfo: UserInfo | null; status: 'idle'|'submitting'|'success'|'failed' }` +- actions: `setSession({ accessToken, userInfo })` / `clearSession()` / `setStatus(status)` +- selector: `selectAccessToken` / `selectUserInfo` / `selectIsAuthenticated` + +**LoginForm props:** +- ` Promise} loading={boolean} errorMessage={string | null} fieldErrors={{username?: string; password?: string; companyCode?: string}} />` + +**路由表(router/index.tsx 写死):** +- `/login` → `` 公开 +- `/users` → ``(占位组件,FE-02 实现) +- 其它任意路径 → `RequireAuth` 守卫,未登录重定向到 `/login` + +**错误码 → 文案映射常量**(`loginConstants.ts`): +``` +ERROR_MESSAGES = { + 40001: '请检查字段格式', + 40004: '公司不存在或已删除', + 40101: '用户名或密码错误', + 40103: '账号已被作废,禁止登录', + 42301: '账号已锁定,请于 {lockUntil} 后再试', + NETWORK: '网络异常,请检查连接后重试', + UNKNOWN: '登录失败,请稍后重试', +} +COMPANY_OPTIONS = [{ value: 'HQ', label: '总部' }] +``` + +--- + +## 任务步骤 + +### Task 1: Bootstrap Vite + React + AntD + Redux + RR + Vitest 项目骨架 + +**Files:** +- Create: `frontend/package.json`、`frontend/vite.config.ts`、`frontend/tsconfig.json`、`frontend/tsconfig.node.json`、`frontend/index.html`、`frontend/vitest.config.ts`、`frontend/.eslintrc.cjs`、`frontend/.gitignore` +- Create: `frontend/src/main.tsx`、`frontend/src/App.tsx`、`frontend/src/styles/global.css` +- Create: `frontend/src/styles/tokens.css`(直接复用仓库根 `src/styles/tokens.css` 内容,确保 docs/06 § 二 token 全覆盖) +- Test: `frontend/src/App.test.tsx` +- 测试先行类型: jsdom 组件测试 + +**Goal:** 让 `npm run test` 能跑通"App renders without crashing"最小 smoke test,证明 Vite + Vitest + React 18 + jsdom 集成完整。 + +**package.json 关键 dependencies/devDependencies:** +- runtime: `react`, `react-dom`, `react-router-dom@^6`, `@reduxjs/toolkit`, `react-redux`, `antd@^5`, `axios`, `dayjs` +- dev: `vite@^5`, `@vitejs/plugin-react`, `typescript@^5`, `@types/react`, `@types/react-dom`, `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `msw@^2`, `@playwright/test`, `eslint`, `eslint-plugin-react`, `eslint-plugin-react-hooks`, `@typescript-eslint/parser`, `@typescript-eslint/eslint-plugin` + +**scripts:** +- `dev`: `vite` +- `build`: `tsc && vite build` +- `test`: `vitest run` +- `test:watch`: `vitest` +- `lint`: `eslint src --max-warnings 0` +- `e2e`: `playwright test` + +**vitest.config.ts**:environment=jsdom;setupFiles=`./src/test-utils/setup.ts`(Task 5 创建);本任务 setupFiles 行先注释或指向最小 setup。 + +- [ ] **Step 1: 写失败测试** `frontend/src/App.test.tsx` + - 测试名: `App renders without crashing` + - 意图: 渲染 `` → 期望页面上有 "Antler ERP" 文字(暂用作 placeholder;后续任务会被覆盖) + - 子会话确认 FAIL(App / package.json / vite 都不存在) + +- [ ] **Step 2: 实现最小代码**:全部 bootstrap 文件 + App.tsx 内最小返回 `
Antler ERP
` + +- [ ] **Step 3: 子会话验证 PASS** + - 子会话跑 `cd /Users/reporkey/Desktop/test5/frontend && npm install --no-audit --no-fund && npm test` + +- [ ] **Step 4: Commit** + - `git add frontend/` + - `git commit -m "feat(frontend): bootstrap Vite + React + AntD + Vitest 骨架 FE-01"` + +### Task 2: Design Tokens + AntD ConfigProvider 接入 + +**Files:** +- Modify: `frontend/src/main.tsx` 引入 `styles/tokens.css` + `global.css` +- Create: `frontend/src/App.tsx`(更新:包 ConfigProvider,theme.token.colorPrimary 引用 var(--color-primary)) +- Test: `frontend/src/App.test.tsx`(追加 token assertion) +- 测试先行类型: jsdom 组件测试 + +**API shape:** +- `` 内层一定包 `` + - 注意:AntD ConfigProvider token 不能直接接 `var(--color-primary)`,需先在 css 解析为具体值。本任务把 ConfigProvider token 直接写 hex(与 tokens.css 同源),并备注"色值唯一改 tokens.css 时需同步改 ConfigProvider"——记为 docs/06 § 二注解。 + +- [ ] **Step 1: 写失败测试** + - 测试名: `App wraps children with ConfigProvider providing primary color` + - 意图: render `` 后通过 `document.documentElement.style.getPropertyValue('--color-primary')` 断言 tokens.css 被加载;ConfigProvider 提供的 colorPrimary 与 token 一致(通过 querySelector 检测 AntD 主题) + +- [ ] **Step 2: 实现最小代码** + +- [ ] **Step 3: 子会话验证 PASS** + +- [ ] **Step 4: Commit** `feat(frontend): Design Tokens + AntD ConfigProvider FE-01` + +### Task 3: Axios 客户端 + 错误码 mapping + +**Files:** +- Create: `frontend/src/api/client.ts` +- Create: `frontend/src/api/errors.ts` +- Test: `frontend/src/api/client.test.ts` +- Create: `frontend/src/test-utils/setup.ts`、`frontend/src/test-utils/msw-handlers.ts` +- 测试先行类型: jsdom 组件测试 + MSW + +**API shape:** +- `apiClient: AxiosInstance` + - baseURL: `import.meta.env.VITE_API_BASE_URL ?? '/api/v1'`(Vite proxy 在 dev 模式把 `/api/v1` 转发到 9090) + - timeout: 10000 + - 请求拦截器: 若 Redux store 有 accessToken,注入 `Authorization: Bearer ${accessToken}` + - 响应拦截器: + - HTTP 200 + `data.code === 200` → `response.data.data`(直接返 payload) + - HTTP 200 + `data.code !== 200` → 抛 `new BizError(code, message, data)` + - HTTP 非 200 → 抛 `new BizError(httpStatus, message, undefined)` + - 网络错误 → 抛 `new BizError(-1, 'NETWORK', undefined)` +- `BizError extends Error { code: number; data?: unknown }` + +**vite.config.ts 配 proxy**:`/api/v1` → `http://localhost:9090` + +- [ ] **Step 1: 写失败测试** `client.test.ts` + - `client.unwraps Result envelope on success`(MSW 返 `{code:200,data:{x:1}}` → client 返 `{x:1}`) + - `client.throws BizError on business error`(MSW 返 `{code:40101,message:"X"}` → throws BizError code=40101) + - `client.throws BizError on http 5xx` + - `client.throws BizError code=-1 on network error`(MSW 不响应) + +- [ ] **Step 2: 实现最小代码** + +- [ ] **Step 3: 子会话验证 PASS** + +- [ ] **Step 4: Commit** `feat(frontend): axios client + BizError + MSW 测试基建 FE-01` + +### Task 4: Auth API 包装 `authApi.login` + +**Files:** +- Create: `frontend/src/api/auth.ts` +- Test: `frontend/src/api/auth.test.ts` +- Modify: `frontend/src/test-utils/msw-handlers.ts` 增加 `/api/v1/auth/login` handlers +- 测试先行类型: jsdom 组件测试 + MSW + +**API shape:** +- `export async function login(req: LoginReq): Promise` — `apiClient.post('/auth/login', req)` +- `LoginReq`、`LoginVo`、`UserInfo` types 见"约束常量" + +- [ ] **Step 1: 写失败测试** + - `login_returnsLoginVo_onSuccess`(MSW 200 → 返 token + userInfo) + - `login_throwsBizError_40101_onBadCredentials` + - `login_throwsBizError_42301_withLockUntilData_onLocked` +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): authApi.login + MSW handlers FE-01` + +### Task 5: Redux authSlice + typed hooks + +**Files:** +- Create: `frontend/src/store/index.ts`、`frontend/src/store/hooks.ts` +- Create: `frontend/src/store/slices/authSlice.ts` +- Test: `frontend/src/store/slices/authSlice.test.ts` +- 测试先行类型: jsdom 组件测试 + +**API shape:** +- `authSlice.reducer`、actions: `setSession({accessToken, userInfo})` / `clearSession()` / `setStatus('idle'|'submitting'|'success'|'failed')` +- selectors: `selectAccessToken(state)`、`selectUserInfo(state)`、`selectIsAuthenticated(state)`、`selectAuthStatus(state)` +- `useAppDispatch`、`useAppSelector` typed hooks +- store shape: `{ auth: AuthState }` + +- [ ] **Step 1: 写失败测试** + - `setSession_writesAccessTokenAndUserInfo` + - `clearSession_resetsToNull` + - `setStatus_updatesStatus` + - `selectIsAuthenticated_trueWhenTokenPresent` +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): authSlice + typed hooks FE-01` + +### Task 6: 路由配置 + RequireAuth 守卫 + +**Files:** +- Create: `frontend/src/router/index.tsx`、`frontend/src/router/RequireAuth.tsx` +- Modify: `frontend/src/App.tsx` 接入 RouterProvider + Provider(store) +- Test: `frontend/src/router/RequireAuth.test.tsx` +- 测试先行类型: jsdom 组件测试 + +**API shape:** +- `RequireAuth({ children })` — 读 `selectIsAuthenticated`,true 渲染 children,false `` +- `router = createBrowserRouter([...])`: + - `/login` → ``(FE-01 提供) + - `/users` → ``(占位组件,本任务用 `
users placeholder
` 占位;FE-02 替换) + - `*` → `` + +- [ ] **Step 1: 写失败测试** + - `RequireAuth redirects to /login when no token`(render with MemoryRouter + Provider with empty store) + - `RequireAuth renders children when token present` +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): RequireAuth 守卫 + 路由表 FE-01` + +### Task 7: LoginForm 组件(pure 表单 + 校验) + +**Files:** +- Create: `frontend/src/pages/login/LoginForm.tsx` +- Create: `frontend/src/pages/login/loginConstants.ts` +- Test: `frontend/src/pages/login/LoginForm.test.tsx` +- 测试先行类型: jsdom 组件测试 + +**Props/API shape:** +- ` Promise} loading={boolean} errorMessage={string | null} fieldErrors={Record<'username'|'password'|'companyCode', string | undefined>} />` +- 用 AntD `Form` + `Input` + `Input.Password` + `Select` + `Button` +- 内部状态:`form` 实例;submit 时先 `validateFields()`(jakarta 风格前端校验:username/password 非空,companyCode 必选)→ 调 `onSubmit` +- `loading=true` 时所有字段 + submit disabled +- `errorMessage != null` 时顶部 `Alert type="error"` 显示 +- `fieldErrors.username` 等 → 通过 `Form.Item.help` 显示字段级错误 + +- [ ] **Step 1: 写失败测试** + - `LoginForm_renders_threeFields_and_submitButton` + - `LoginForm_emptySubmit_showsFieldErrors`(点击 submit 不填字段 → 显示"请输入用户名 / 请输入密码 / 请选择公司") + - `LoginForm_submitsValid_callsOnSubmit_withTypedReq` + - `LoginForm_loading_disablesAllInputsAndSubmit` + - `LoginForm_errorMessage_displaysInAlert` + - `LoginForm_fieldErrors_displayInFormItemHelp` +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): LoginForm 组件 + 字段校验 FE-01` + +### Task 8: LoginHero / LoginFooter 静态视觉组件 + +**Files:** +- Create: `frontend/src/pages/login/LoginHero.tsx` +- Create: `frontend/src/pages/login/LoginFooter.tsx` +- Test: `frontend/src/pages/login/LoginHero.test.tsx` +- 测试先行类型: jsdom 组件测试 + +**API shape:** +- `` 渲染左侧文字(Enterprise Business Capability / 企业业务能力平台 / ERP)+ logo SVG +- `` 渲染版权 + ICP 文字 + 盾牌图标 + +视觉细节直接复制 prototype `screen-login` 内 `.login-head` + `.login-text` + `.login-foot` 的内容;样式用 CSS modules 或 styled components 任选其一(统一选 CSS modules:`LoginHero.module.css`)。 + +- [ ] **Step 1: 写失败测试** + - `LoginHero_renders_brandText`(含 "Antler ERP" / "Enterprise Business Capability" / "ERP") + - `LoginFooter_renders_copyrightAndICP`(含 "Antler Software" / "沪ICP备14034791号-1") +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): LoginHero + LoginFooter 静态视觉 FE-01` + +### Task 9: LoginPage 组装 + 错误状态机集成 + +**Files:** +- Create: `frontend/src/pages/login/LoginPage.tsx` +- Test: `frontend/src/pages/login/LoginPage.test.tsx` +- 测试先行类型: jsdom 组件测试 + +**API shape:** +- `` — 默认导出,无 props +- 内部:local state `{ errorMessage, fieldErrors }`;用 `useAppDispatch`;调用 `authApi.login()`;成功 → `dispatch(setSession())` + `navigate('/users', {replace:true})`;失败 → 根据 BizError.code 映射文案 + 切换 errorMessage 或 fieldErrors +- 锁定(42301):从 `err.data.lockUntil` 解析 dayjs,文案 `账号已锁定,请于 HH:mm 后再试` +- 网络错误(code=-1):文案 `网络异常,请检查连接后重试` + +- [ ] **Step 1: 写失败测试** + - `LoginPage_successFlow_dispatchesSetSessionAndNavigatesToUsers` + - `LoginPage_badCredentials_shows40101Message` + - `LoginPage_locked_shows42301WithLockUntil` + - `LoginPage_deletedAccount_shows40103Message` + - `LoginPage_unknownCompany_shows40004OnCompanyField` + - `LoginPage_networkError_showsNetworkMessage` +- [ ] **Step 2: 实现最小代码** +- [ ] **Step 3: 子会话验证 PASS** +- [ ] **Step 4: Commit** `feat(frontend): LoginPage 组装 + 错误状态机 FE-01` + +### Task 10: Playwright E2E — 登录三条路径 + +**Files:** +- Create: `frontend/playwright.config.ts` +- Create: `frontend/tests/e2e/login.spec.ts` +- 测试先行类型: Playwright E2E + +**E2E 三个场景**(连真后端,复用 `LoginTestSeeder` 已建数据): +1. `successLogin_redirectsToUsers`:访问 `/login` → 填 `alice` / `Password1!` / `HQ` → submit → URL 变为 `/users` +2. `badPassword_showsError`:填 `alice` / `WrongPass` / `HQ` → submit → 页面上出现 "用户名或密码错误" +3. `unknownCompany_showsError`:填 `alice` / `Password1!` / `NOPE` → submit → 出现 "公司不存在或已删除" + +> 注:场景 3 需要 `NOPE` 作为非法 companyCode,但前端 dropdown 默认硬编码只有 HQ;E2E 通过 evaluate 直接修改 Redux state 或 form value 模拟该路径。简化:场景 3 用 page.evaluate 把 dropdown 改为不存在的 code 后 submit。 + +E2E 前置:测试前必须有运行中的 backend(端口 9090)+ frontend dev server(5173);`playwright.config.ts` 配 `webServer` 自动起 frontend dev server,backend 由开发者预先启动(CI 由 scripts/test.sh 协调)。 + +- [ ] **Step 1: 写失败测试** `frontend/tests/e2e/login.spec.ts` +- [ ] **Step 2: 实现最小代码** + Playwright config +- [ ] **Step 3: 子会话验证 PASS**(子会话先启动 backend `mvn spring-boot:run` 后台 → 跑 `npx playwright test`) +- [ ] **Step 4: Commit** `test(frontend): E2E 登录三路径 FE-01` + +--- + +## 提交计划 + +| Task | Commit message | +|---|---| +| 1 | `feat(frontend): bootstrap Vite + React + AntD + Vitest 骨架 FE-01` | +| 2 | `feat(frontend): Design Tokens + AntD ConfigProvider FE-01` | +| 3 | `feat(frontend): axios client + BizError + MSW 测试基建 FE-01` | +| 4 | `feat(frontend): authApi.login + MSW handlers FE-01` | +| 5 | `feat(frontend): authSlice + typed hooks FE-01` | +| 6 | `feat(frontend): RequireAuth 守卫 + 路由表 FE-01` | +| 7 | `feat(frontend): LoginForm 组件 + 字段校验 FE-01` | +| 8 | `feat(frontend): LoginHero + LoginFooter 静态视觉 FE-01` | +| 9 | `feat(frontend): LoginPage 组装 + 错误状态机 FE-01` | +| 10 | `test(frontend): E2E 登录三路径 FE-01` | diff --git a/docs/superpowers/reviews/2026-05-15-FE-01.md b/docs/superpowers/reviews/2026-05-15-FE-01.md new file mode 100644 index 0000000..da5b851 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-15-FE-01.md @@ -0,0 +1,56 @@ +--- +fe_id: FE-01 +date: 2026-05-15 +round: 2 +reviewer: fe-code-reviewer +--- + +# Review: FE-01 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +## Round 1 修复落地核对 + +| # | 项目 | 状态 | +|---|------|-----| +| 1 | App.tsx 挂 Provider + RouterProvider + ConfigProvider | ✓ | +| 2 | tokens.css 与 docs/06 § 2.1 SSoT 完全对齐(含 8 个缺失 canonical token;删除自定义 form-bg/table-row 键) | ✓ | +| 3 | colorPrimary #1890ff → #1677ff(SSoT 同源) | ✓ | +| 4 | 三个 Form.Item 加 label="用户名"/"密码"/"公司"(a11y) | ✓ | +| 5 | LoginPage 锁定倒计时 + LoginForm submitDisabled prop | ✓ | +| 6 | 补 a11y / 锁定 disabled / 空字段必填 三类测试 | ✓ | +| 7 | App.test 改为 store/router 静态验证(BrowserRouter + jsdom + MSW AbortSignal 不兼容) | ✓ | + +## Nice-to-have(round 1 标记延后,本轮保留) + +- frontend/src/pages/login/LoginPage.tsx:71 — 40001 仍渲染到 ErrorBanner;spec § 三 #8 期望 field-level。MVP 可接受 +- frontend/src/pages/login/LoginPage.tsx:82 — 未抽出 LoginHeader + 引入 prototype SVG logo;visual polish 推后 +- frontend/src/pages/login/loginConstants.ts:1 — COMPANY_OPTIONS 硬编码 HQ;待 GET /api/v1/companies 实现后改为动态加载 +- frontend/src/api/client.ts:28 — 缺直接的 client.test.ts;当前由 auth.test.ts 间接覆盖等价场景 +- frontend/tests/e2e/login.spec.ts — 3 个 spec 仍 .fixme(),留作手工验收(需 npx playwright install + backend 启动) + +## 7 维 checklist + +| # | 维度 | 状态 | +|---|------|-----| +| 1 | Prototype consistency | pass | +| 2 | Design tokens | pass(无 hex 残留) | +| 3 | A11y | pass(labels + autoComplete + Enter 提交) | +| 4 | Responsive | pass(flex 布局,无 fixed 阻塞) | +| 5 | 业务校验前端复刻 | pass | +| 6 | API consistency | pass(统一 apiClient + BizError) | +| 7 | 状态机覆盖 | pass(10 状态 spec § 三 全部覆盖) | + +## 反例 / 测试覆盖缺口 + +- 3 个 Playwright E2E spec 仍 .fixme(),需人工验收时启用(设计决策 #3 标记延后) +- AntD Select 切换 companyCode 未在单测覆盖(默认 HQ);可接受 +- BizError 40001 假设后端不返 field detail;若后端在 data 里携带 field-level error 信息,本 round 未处理 + +## 总结 + +Round 1 全部 9 项 must-fix 正确落地:Provider 链可运行、tokens SSoT 严丝合缝、colorPrimary 同源、a11y labels 三齐、锁定倒计时 + submit disabled 闭环、回归测试三件套补齐。无新回归,7 维 checklist 全 pass。Approve。 diff --git a/docs/superpowers/specs/2026-05-15-FE-01.md b/docs/superpowers/specs/2026-05-15-FE-01.md new file mode 100644 index 0000000..70bae10 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-FE-01.md @@ -0,0 +1,159 @@ +# 前端功能规格 — FE-01 用户登录 + +> 关联 REQ:REQ-USR-001 +> 关联原型:prototype/erp.html#screen-login +> 日期:2026-05-15 + +## 一、功能概述 + +未登录用户访问任意路径都重定向到 `/login`。登录页提交 username + password + companyCode 三字段 → 调用 `POST /api/v1/auth/login` → 成功后把 accessToken + userInfo 存入 Redux 内存 → 跳转 `/users`(FE-02 用户管理页)。错误按后端返回的错误码做差异化提示(密码错 / 账号作废 / 账号锁定 / 公司不存在 / 字段格式错)。 + +**关键约束**: +- accessToken 仅存 Redux store 内存;刷新 / 关闭浏览器会清空 → 重登。与 docs/04 § 2.5 推荐方案对齐 +- 公司下拉硬编码 `{HQ: 总部}` 单选项;后续 REQ 提供 `GET /api/v1/companies` 后替换为动态加载 +- 与 prototype 布局保持视觉一致(左侧 hero 文字 + 右侧 login-card 表单 + 顶部 logo + 底部 copyright) + +## 二、组件树 + +基于 `prototype/erp.html#screen-login` 推导: + +``` +LoginPage (pages/login/LoginPage.tsx, route="/login") +├── LoginHeader +│ ├── Logo (svg) +│ ├── BrandName ("Antler ERP") +│ └── SubTitle ("欢迎登录EBC平台") +├── LoginHero +│ ├── HeroText +│ │ ├── EnglishLine ("Enterprise Business Capability") +│ │ ├── ChineseLine ("企业业务能力平台") +│ │ └── BigLabel ("ERP") +│ └── LoginCard +│ ├── CardTitle ("用户登录") +│ ├── UsernameField (Input + UserIcon) +│ ├── PasswordField (Input.Password + LockIcon) +│ ├── CompanyDropdown (Select,默认 HQ) +│ ├── ErrorBanner (条件渲染,根据 errorState) +│ └── SubmitButton ("登 录") +└── LoginFooter + ├── CopyrightText + └── ICPNumber +``` + +## 三、页面状态机 + +| # | 状态 | 触发条件 | 视觉表现 | 用户可执行操作 | +|---|------|---------|---------|--------------| +| 1 | idle | 初次进入 / token 失效跳转 | 空表单,submit 可点 | 输入字段,点击 submit | +| 2 | empty | 字段未填完整 | 字段下方红色提示"必填";submit disabled | 继续输入 | +| 3 | submitting | 用户点击 submit + 前端校验过 | submit 显示 loading 旋转图 + 文字"登录中...";所有字段 disabled | 等待响应 | +| 4 | error_credentials | 后端返 40101 | submit 恢复可点;ErrorBanner 显示"用户名或密码错误";username + password 字段红边框 | 修改字段重试 | +| 5 | error_locked | 后端返 42301 | submit 不可点(持续直到 lockUntil);ErrorBanner 显示"账号已锁定,请于 HH:MM 后再试"(用 dayjs format `data.lockUntil`) | 等待解锁或换账号 | +| 6 | error_deleted | 后端返 40103 | ErrorBanner 显示"账号已被作废,禁止登录";红色严重图标 | 换账号 | +| 7 | error_company | 后端返 40004 | CompanyDropdown 红边框 + 下方提示"公司不存在或已删除" | 重选公司 | +| 8 | error_format | 后端返 40001 / 前端校验失败 | 对应字段下方提示具体格式错误 | 修改字段 | +| 9 | error_network | 网络超时 / 5xx | ErrorBanner 显示"网络异常,请检查连接后重试"(黄色) | 等待 / 点 submit 重试 | +| 10 | success | 后端返 200 + data | 短暂 200ms 显示"登录成功"绿色提示后跳转 `/users` | (自动跳转,不可操作) | + +## 四、消费的后端端点 + +| # | 方法 | 路径 | 触发时机 | 关联 REQ | +|---|------|------|---------|---------| +| 1 | POST | `/api/v1/auth/login` | 用户点击 submit + 前端校验通过 | REQ-USR-001 | + +请求体: +```json +{ "username": "alice", "password": "Password1!", "companyCode": "HQ" } +``` + +响应(成功): +```json +{ + "code": 200, + "data": { + "accessToken": "", + "tokenType": "Bearer", + "expiresInSec": 7200, + "userInfo": { "userId": 42, "username": "alice", "userType": "NORMAL", + "language": "zh-CN", "employeeName": "张三", "companyCode": "HQ" } + } +} +``` + +## 五、业务规则前端复刻清单 + +| # | 规则描述 | 触发时机 | 报错文案 | 来源 REQ | +|---|---------|---------|---------|---------| +| 1 | username 必填且非空白 | submit 前 / blur | "请输入用户名" | REQ-USR-001 | +| 2 | password 必填且非空白 | submit 前 / blur | "请输入密码" | REQ-USR-001 | +| 3 | companyCode 必填(默认已选 HQ) | submit 前 | "请选择公司" | REQ-USR-001 | +| 4 | 收到 40101 → 通用文案,不区分用户名/密码错 | 响应处理 | "用户名或密码错误" | REQ-USR-001 | +| 5 | 收到 40103 → 账号作废 | 响应处理 | "账号已被作废,禁止登录" | REQ-USR-001 | +| 6 | 收到 42301 → 账号锁定 + 显示 lockUntil | 响应处理 | "账号已锁定,请于 {HH:MM} 后再试" | REQ-USR-001 | +| 7 | 收到 40004 → 公司不存在 | 响应处理 | "公司不存在或已删除" | REQ-USR-001 | +| 8 | 收到 40001 → 字段格式错误 | 响应处理 | 后端 message 透传 | REQ-USR-001 | +| 9 | 网络错误 / 5xx | 响应处理 | "网络异常,请检查连接后重试" | docs/04 § 2.4 | +| 10 | 登录成功 → 写 Redux auth + 跳 /users | 响应处理 | (无文案,跳转) | REQ-USR-001 | +| 11 | 密码输入显示星号 | 字段渲染 | (Input.Password 内置) | REQ-USR-001 输入表 | +| 12 | 显示 lockUntil 用 dayjs format `HH:mm`(24h)| 错误处理 | (文案见 # 6) | spec § 5 | + +> **要求**:每条规则必须在前端 form-level 校验中复刻,不仅依赖后端报错。文案与后端语义一致。 + +## 六、Design Tokens 引用清单 + +``` +--color-primary (submit 按钮 bg / 标题色) +--color-primary-hover (submit hover bg) +--color-primary-active (submit active bg) +--color-text (表单 label) +--color-text-secondary (sub-title "欢迎登录EBC平台" / copyright) +--color-error (错误状态字段红边框 + ErrorBanner 文字) +--color-border (输入框边框 idle 态) +--color-bg-page (登录页底色) +--color-bg-container (login-card 卡片 bg) +--color-warning (网络错误 banner 黄色) +--color-success (登录成功提示绿色) +``` + +来源:docs/06 § 二(已锁定全局调色板 + 组件级状态色)。本 FE 不引入新 token。 + +## 七、交互流程关键路径 + +``` +[用户访问 / 任何路径] + → router/RequireAuth 检测 Redux auth.accessToken == null + → 重定向到 /login + +[用户在 /login] + 1. 页面挂载 → useEffect 把公司下拉默认值设为 "HQ" + 2. 用户填写 username + password + 3. 点 submit → form.validateFields() → submitting 态 + 4. 调用 authApi.login(req) → axios POST /api/v1/auth/login + 5a. 成功(code=200): + - dispatch(authSlice.actions.setSession({ accessToken, userInfo })) + - 200ms 提示 "登录成功" + - navigate('/users', { replace: true }) + 5b. 失败(throw BizError {code, message, data}): + - 根据 code 切换错误状态 + - 把 message 显示到 ErrorBanner + - 字段标红(按状态机表) + 5c. 锁定(code=42301): + - 启动 setInterval 每秒检查 data.lockUntil > now() + - lockUntil 过期 → 自动清除锁定态,submit 恢复可点 + 5d. 网络错(无 code): + - ErrorBanner 显示通用网络异常 + 黄色 + +[F5 刷新 / 重启浏览器] + → Redux 内存丢失 → RequireAuth 检测 token=null → 重定向 /login + → 用户须重登(与 docs/04 § 2.5 安全约束一致) +``` + +## 八、备注与开放问题 + +- **GET /api/v1/companies 暂未实现**:spec § 一 已说明硬编码 HQ;后续运营模块 / FE 阶段补全时只需把 dropdown 改为动态 API 调用即可,state/UI 结构不变 +- **登录成功跳转目标**:当前为 `/users`(FE-02)。若未来引入 dashboard,按角色 / `userInfo.userType` 派发,但本 FE 不实现该分支 +- **HTTPS / token in transit**:依赖部署 Nginx TLS 终止(docs/07 § 二),前端代码不在 axios 层做额外加密 +- **i18n 推迟**:本 FE 报错文案硬编码中文,后续接入 i18n 时再抽出 +- **prototype 顶部 logo + 底部 copyright** 视觉保留但 SVG 路径直接复制 prototype 内容,不再独立美工 +- **公司下拉点击展开**:prototype 用纯 CSS hover 展开(`.opt` 块),React 实现用 Ant Design `Select` 组件,行为等价 +- **a11y**:所有 input 配 `