Commit 906f05c5e330f4e7dc287c813637a811a7c7d1d3
1 parent
5da79104
chore(fe-01): review approve + 归档 spec/plan/review
REQ_ID: FE-01
Showing
4 changed files
with
582 additions
and
1 deletions
docs/08-模块任务管理.md
| ... | ... | @@ -71,5 +71,5 @@ |
| 71 | 71 | |
| 72 | 72 | - 整体 MR: — |
| 73 | 73 | - 功能: |
| 74 | - - [ ] FE-01 用户登录 | 关联 REQ:REQ-USR-001 | 关联原型:prototype/erp.html#screen-login | |
| 74 | + - [x] FE-01 用户登录 | 关联 REQ:REQ-USR-001 | 关联原型:prototype/erp.html#screen-login | |
| 75 | 75 | - [ ] FE-02 用户管理(列表 + 新增 / 编辑) | 关联 REQ:REQ-USR-002, REQ-USR-003, REQ-USR-004 | 关联原型:prototype/erp.html#screen-userlist, prototype/erp.html#screen-userdetail | ... | ... |
docs/superpowers/plans/2026-05-15-FE-01.md
0 → 100644
| 1 | +# FE-01 用户登录 Implementation Plan | |
| 2 | + | |
| 3 | +> **Execution:** Parent skill `fe-feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | + | |
| 5 | +**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 复用。 | |
| 6 | + | |
| 7 | +**Architecture:** | |
| 8 | +- Vite + React 18 + TypeScript + Ant Design 5(`ConfigProvider` 接 `tokens.css` CSS 变量)+ Redux Toolkit(accessToken 仅内存)+ React Router v6(含 `RequireAuth` 守卫)+ Axios(请求拦截器自动加 Authorization 头)。 | |
| 9 | +- 测试栈:Vitest + @testing-library/react(jsdom 组件测试)+ Playwright(E2E)。 | |
| 10 | +- 后端 Mock:组件测试用 MSW 拦截 axios;E2E 直接打真后端(与 backend test-gate 共享 .env.local 配置)。 | |
| 11 | + | |
| 12 | +**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。 | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## 文件结构(与 docs/09 § 三 对齐,全部位于 `frontend/` 下) | |
| 17 | + | |
| 18 | +**Bootstrap(FE-01 一次性投入,后续 FE 复用):** | |
| 19 | +- `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` | |
| 20 | +- `frontend/src/main.tsx`、`frontend/src/App.tsx` | |
| 21 | +- `frontend/src/styles/tokens.css`(从仓库根 `src/styles/tokens.css` 复用并增补 docs/06 § 二全部变量) | |
| 22 | +- `frontend/src/styles/global.css` | |
| 23 | +- `frontend/src/types/global.d.ts` | |
| 24 | + | |
| 25 | +**通用基础层:** | |
| 26 | +- `frontend/src/api/client.ts`(Axios 实例 + 拦截器) | |
| 27 | +- `frontend/src/api/auth.ts`(login API 包装) | |
| 28 | +- `frontend/src/api/errors.ts`(BizError 类型 + code mapping) | |
| 29 | +- `frontend/src/store/index.ts`(configureStore) | |
| 30 | +- `frontend/src/store/slices/authSlice.ts` | |
| 31 | +- `frontend/src/store/hooks.ts`(typed useAppSelector / useAppDispatch) | |
| 32 | +- `frontend/src/router/index.tsx`(路由表) | |
| 33 | +- `frontend/src/router/RequireAuth.tsx`(路由守卫) | |
| 34 | + | |
| 35 | +**FE-01 业务层:** | |
| 36 | +- `frontend/src/pages/login/LoginPage.tsx` | |
| 37 | +- `frontend/src/pages/login/LoginForm.tsx` | |
| 38 | +- `frontend/src/pages/login/LoginHero.tsx`(静态视觉,无逻辑) | |
| 39 | +- `frontend/src/pages/login/LoginFooter.tsx` | |
| 40 | +- `frontend/src/pages/login/loginConstants.ts`(公司硬编码 + 错误文案) | |
| 41 | + | |
| 42 | +**测试:** | |
| 43 | +- `frontend/src/api/client.test.ts`(jsdom:拦截器行为) | |
| 44 | +- `frontend/src/store/slices/authSlice.test.ts`(jsdom:reducer + thunk) | |
| 45 | +- `frontend/src/router/RequireAuth.test.tsx`(jsdom) | |
| 46 | +- `frontend/src/pages/login/LoginForm.test.tsx`(jsdom:表单校验 + submit 流转) | |
| 47 | +- `frontend/src/pages/login/LoginPage.test.tsx`(jsdom:错误状态机集成) | |
| 48 | +- `frontend/tests/e2e/login.spec.ts`(Playwright:成功 / 错误密码 / 锁定 三条路径) | |
| 49 | +- `frontend/src/test-utils/msw-handlers.ts`(MSW 拦截器,模拟 /api/v1/auth/login 各错误码) | |
| 50 | +- `frontend/src/test-utils/setup.ts`(vitest setup:jest-dom + MSW) | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## 约束常量(跨任务一致) | |
| 55 | + | |
| 56 | +**API client 签名:** | |
| 57 | +- `apiClient: AxiosInstance`(baseURL = `import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9090'`,timeout 10s,响应拦截器统一抛 `BizError`) | |
| 58 | +- `BizError extends Error { code: number; data?: unknown }` | |
| 59 | + | |
| 60 | +**Auth API 函数:** | |
| 61 | +- `authApi.login(req: LoginReq): Promise<LoginVo>` | |
| 62 | +- `LoginReq { username: string; password: string; companyCode: string }` | |
| 63 | +- `LoginVo { accessToken: string; tokenType: 'Bearer'; expiresInSec: number; userInfo: UserInfo }` | |
| 64 | +- `UserInfo { userId: number; username: string; userType: 'NORMAL'|'SUPER_ADMIN'; language: string; employeeName?: string; companyCode: string }` | |
| 65 | + | |
| 66 | +**Redux auth slice:** | |
| 67 | +- state shape: `{ accessToken: string | null; userInfo: UserInfo | null; status: 'idle'|'submitting'|'success'|'failed' }` | |
| 68 | +- actions: `setSession({ accessToken, userInfo })` / `clearSession()` / `setStatus(status)` | |
| 69 | +- selector: `selectAccessToken` / `selectUserInfo` / `selectIsAuthenticated` | |
| 70 | + | |
| 71 | +**LoginForm props:** | |
| 72 | +- `<LoginForm onSubmit={(req: LoginReq) => Promise<void>} loading={boolean} errorMessage={string | null} fieldErrors={{username?: string; password?: string; companyCode?: string}} />` | |
| 73 | + | |
| 74 | +**路由表(router/index.tsx 写死):** | |
| 75 | +- `/login` → `<LoginPage />` 公开 | |
| 76 | +- `/users` → `<RequireAuth><UsersPage /></RequireAuth>`(占位组件,FE-02 实现) | |
| 77 | +- 其它任意路径 → `RequireAuth` 守卫,未登录重定向到 `/login` | |
| 78 | + | |
| 79 | +**错误码 → 文案映射常量**(`loginConstants.ts`): | |
| 80 | +``` | |
| 81 | +ERROR_MESSAGES = { | |
| 82 | + 40001: '请检查字段格式', | |
| 83 | + 40004: '公司不存在或已删除', | |
| 84 | + 40101: '用户名或密码错误', | |
| 85 | + 40103: '账号已被作废,禁止登录', | |
| 86 | + 42301: '账号已锁定,请于 {lockUntil} 后再试', | |
| 87 | + NETWORK: '网络异常,请检查连接后重试', | |
| 88 | + UNKNOWN: '登录失败,请稍后重试', | |
| 89 | +} | |
| 90 | +COMPANY_OPTIONS = [{ value: 'HQ', label: '总部' }] | |
| 91 | +``` | |
| 92 | + | |
| 93 | +--- | |
| 94 | + | |
| 95 | +## 任务步骤 | |
| 96 | + | |
| 97 | +### Task 1: Bootstrap Vite + React + AntD + Redux + RR + Vitest 项目骨架 | |
| 98 | + | |
| 99 | +**Files:** | |
| 100 | +- 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` | |
| 101 | +- Create: `frontend/src/main.tsx`、`frontend/src/App.tsx`、`frontend/src/styles/global.css` | |
| 102 | +- Create: `frontend/src/styles/tokens.css`(直接复用仓库根 `src/styles/tokens.css` 内容,确保 docs/06 § 二 token 全覆盖) | |
| 103 | +- Test: `frontend/src/App.test.tsx` | |
| 104 | +- 测试先行类型: jsdom 组件测试 | |
| 105 | + | |
| 106 | +**Goal:** 让 `npm run test` 能跑通"App renders without crashing"最小 smoke test,证明 Vite + Vitest + React 18 + jsdom 集成完整。 | |
| 107 | + | |
| 108 | +**package.json 关键 dependencies/devDependencies:** | |
| 109 | +- runtime: `react`, `react-dom`, `react-router-dom@^6`, `@reduxjs/toolkit`, `react-redux`, `antd@^5`, `axios`, `dayjs` | |
| 110 | +- 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` | |
| 111 | + | |
| 112 | +**scripts:** | |
| 113 | +- `dev`: `vite` | |
| 114 | +- `build`: `tsc && vite build` | |
| 115 | +- `test`: `vitest run` | |
| 116 | +- `test:watch`: `vitest` | |
| 117 | +- `lint`: `eslint src --max-warnings 0` | |
| 118 | +- `e2e`: `playwright test` | |
| 119 | + | |
| 120 | +**vitest.config.ts**:environment=jsdom;setupFiles=`./src/test-utils/setup.ts`(Task 5 创建);本任务 setupFiles 行先注释或指向最小 setup。 | |
| 121 | + | |
| 122 | +- [ ] **Step 1: 写失败测试** `frontend/src/App.test.tsx` | |
| 123 | + - 测试名: `App renders without crashing` | |
| 124 | + - 意图: 渲染 `<App />` → 期望页面上有 "Antler ERP" 文字(暂用作 placeholder;后续任务会被覆盖) | |
| 125 | + - 子会话确认 FAIL(App / package.json / vite 都不存在) | |
| 126 | + | |
| 127 | +- [ ] **Step 2: 实现最小代码**:全部 bootstrap 文件 + App.tsx 内最小返回 `<div>Antler ERP</div>` | |
| 128 | + | |
| 129 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 130 | + - 子会话跑 `cd /Users/reporkey/Desktop/test5/frontend && npm install --no-audit --no-fund && npm test` | |
| 131 | + | |
| 132 | +- [ ] **Step 4: Commit** | |
| 133 | + - `git add frontend/` | |
| 134 | + - `git commit -m "feat(frontend): bootstrap Vite + React + AntD + Vitest 骨架 FE-01"` | |
| 135 | + | |
| 136 | +### Task 2: Design Tokens + AntD ConfigProvider 接入 | |
| 137 | + | |
| 138 | +**Files:** | |
| 139 | +- Modify: `frontend/src/main.tsx` 引入 `styles/tokens.css` + `global.css` | |
| 140 | +- Create: `frontend/src/App.tsx`(更新:包 ConfigProvider,theme.token.colorPrimary 引用 var(--color-primary)) | |
| 141 | +- Test: `frontend/src/App.test.tsx`(追加 token assertion) | |
| 142 | +- 测试先行类型: jsdom 组件测试 | |
| 143 | + | |
| 144 | +**API shape:** | |
| 145 | +- `<App />` 内层一定包 `<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', ...overrides } }}>` | |
| 146 | + - 注意:AntD ConfigProvider token 不能直接接 `var(--color-primary)`,需先在 css 解析为具体值。本任务把 ConfigProvider token 直接写 hex(与 tokens.css 同源),并备注"色值唯一改 tokens.css 时需同步改 ConfigProvider"——记为 docs/06 § 二注解。 | |
| 147 | + | |
| 148 | +- [ ] **Step 1: 写失败测试** | |
| 149 | + - 测试名: `App wraps children with ConfigProvider providing primary color` | |
| 150 | + - 意图: render `<App />` 后通过 `document.documentElement.style.getPropertyValue('--color-primary')` 断言 tokens.css 被加载;ConfigProvider 提供的 colorPrimary 与 token 一致(通过 querySelector 检测 AntD 主题) | |
| 151 | + | |
| 152 | +- [ ] **Step 2: 实现最小代码** | |
| 153 | + | |
| 154 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 155 | + | |
| 156 | +- [ ] **Step 4: Commit** `feat(frontend): Design Tokens + AntD ConfigProvider FE-01` | |
| 157 | + | |
| 158 | +### Task 3: Axios 客户端 + 错误码 mapping | |
| 159 | + | |
| 160 | +**Files:** | |
| 161 | +- Create: `frontend/src/api/client.ts` | |
| 162 | +- Create: `frontend/src/api/errors.ts` | |
| 163 | +- Test: `frontend/src/api/client.test.ts` | |
| 164 | +- Create: `frontend/src/test-utils/setup.ts`、`frontend/src/test-utils/msw-handlers.ts` | |
| 165 | +- 测试先行类型: jsdom 组件测试 + MSW | |
| 166 | + | |
| 167 | +**API shape:** | |
| 168 | +- `apiClient: AxiosInstance` | |
| 169 | + - baseURL: `import.meta.env.VITE_API_BASE_URL ?? '/api/v1'`(Vite proxy 在 dev 模式把 `/api/v1` 转发到 9090) | |
| 170 | + - timeout: 10000 | |
| 171 | + - 请求拦截器: 若 Redux store 有 accessToken,注入 `Authorization: Bearer ${accessToken}` | |
| 172 | + - 响应拦截器: | |
| 173 | + - HTTP 200 + `data.code === 200` → `response.data.data`(直接返 payload) | |
| 174 | + - HTTP 200 + `data.code !== 200` → 抛 `new BizError(code, message, data)` | |
| 175 | + - HTTP 非 200 → 抛 `new BizError(httpStatus, message, undefined)` | |
| 176 | + - 网络错误 → 抛 `new BizError(-1, 'NETWORK', undefined)` | |
| 177 | +- `BizError extends Error { code: number; data?: unknown }` | |
| 178 | + | |
| 179 | +**vite.config.ts 配 proxy**:`/api/v1` → `http://localhost:9090` | |
| 180 | + | |
| 181 | +- [ ] **Step 1: 写失败测试** `client.test.ts` | |
| 182 | + - `client.unwraps Result envelope on success`(MSW 返 `{code:200,data:{x:1}}` → client 返 `{x:1}`) | |
| 183 | + - `client.throws BizError on business error`(MSW 返 `{code:40101,message:"X"}` → throws BizError code=40101) | |
| 184 | + - `client.throws BizError on http 5xx` | |
| 185 | + - `client.throws BizError code=-1 on network error`(MSW 不响应) | |
| 186 | + | |
| 187 | +- [ ] **Step 2: 实现最小代码** | |
| 188 | + | |
| 189 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 190 | + | |
| 191 | +- [ ] **Step 4: Commit** `feat(frontend): axios client + BizError + MSW 测试基建 FE-01` | |
| 192 | + | |
| 193 | +### Task 4: Auth API 包装 `authApi.login` | |
| 194 | + | |
| 195 | +**Files:** | |
| 196 | +- Create: `frontend/src/api/auth.ts` | |
| 197 | +- Test: `frontend/src/api/auth.test.ts` | |
| 198 | +- Modify: `frontend/src/test-utils/msw-handlers.ts` 增加 `/api/v1/auth/login` handlers | |
| 199 | +- 测试先行类型: jsdom 组件测试 + MSW | |
| 200 | + | |
| 201 | +**API shape:** | |
| 202 | +- `export async function login(req: LoginReq): Promise<LoginVo>` — `apiClient.post('/auth/login', req)` | |
| 203 | +- `LoginReq`、`LoginVo`、`UserInfo` types 见"约束常量" | |
| 204 | + | |
| 205 | +- [ ] **Step 1: 写失败测试** | |
| 206 | + - `login_returnsLoginVo_onSuccess`(MSW 200 → 返 token + userInfo) | |
| 207 | + - `login_throwsBizError_40101_onBadCredentials` | |
| 208 | + - `login_throwsBizError_42301_withLockUntilData_onLocked` | |
| 209 | +- [ ] **Step 2: 实现最小代码** | |
| 210 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 211 | +- [ ] **Step 4: Commit** `feat(frontend): authApi.login + MSW handlers FE-01` | |
| 212 | + | |
| 213 | +### Task 5: Redux authSlice + typed hooks | |
| 214 | + | |
| 215 | +**Files:** | |
| 216 | +- Create: `frontend/src/store/index.ts`、`frontend/src/store/hooks.ts` | |
| 217 | +- Create: `frontend/src/store/slices/authSlice.ts` | |
| 218 | +- Test: `frontend/src/store/slices/authSlice.test.ts` | |
| 219 | +- 测试先行类型: jsdom 组件测试 | |
| 220 | + | |
| 221 | +**API shape:** | |
| 222 | +- `authSlice.reducer`、actions: `setSession({accessToken, userInfo})` / `clearSession()` / `setStatus('idle'|'submitting'|'success'|'failed')` | |
| 223 | +- selectors: `selectAccessToken(state)`、`selectUserInfo(state)`、`selectIsAuthenticated(state)`、`selectAuthStatus(state)` | |
| 224 | +- `useAppDispatch`、`useAppSelector` typed hooks | |
| 225 | +- store shape: `{ auth: AuthState }` | |
| 226 | + | |
| 227 | +- [ ] **Step 1: 写失败测试** | |
| 228 | + - `setSession_writesAccessTokenAndUserInfo` | |
| 229 | + - `clearSession_resetsToNull` | |
| 230 | + - `setStatus_updatesStatus` | |
| 231 | + - `selectIsAuthenticated_trueWhenTokenPresent` | |
| 232 | +- [ ] **Step 2: 实现最小代码** | |
| 233 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 234 | +- [ ] **Step 4: Commit** `feat(frontend): authSlice + typed hooks FE-01` | |
| 235 | + | |
| 236 | +### Task 6: 路由配置 + RequireAuth 守卫 | |
| 237 | + | |
| 238 | +**Files:** | |
| 239 | +- Create: `frontend/src/router/index.tsx`、`frontend/src/router/RequireAuth.tsx` | |
| 240 | +- Modify: `frontend/src/App.tsx` 接入 RouterProvider + Provider(store) | |
| 241 | +- Test: `frontend/src/router/RequireAuth.test.tsx` | |
| 242 | +- 测试先行类型: jsdom 组件测试 | |
| 243 | + | |
| 244 | +**API shape:** | |
| 245 | +- `RequireAuth({ children })` — 读 `selectIsAuthenticated`,true 渲染 children,false `<Navigate to="/login" replace />` | |
| 246 | +- `router = createBrowserRouter([...])`: | |
| 247 | + - `/login` → `<LoginPage />`(FE-01 提供) | |
| 248 | + - `/users` → `<RequireAuth><UsersPlaceholder /></RequireAuth>`(占位组件,本任务用 `<div>users placeholder</div>` 占位;FE-02 替换) | |
| 249 | + - `*` → `<Navigate to="/users" />` | |
| 250 | + | |
| 251 | +- [ ] **Step 1: 写失败测试** | |
| 252 | + - `RequireAuth redirects to /login when no token`(render with MemoryRouter + Provider with empty store) | |
| 253 | + - `RequireAuth renders children when token present` | |
| 254 | +- [ ] **Step 2: 实现最小代码** | |
| 255 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 256 | +- [ ] **Step 4: Commit** `feat(frontend): RequireAuth 守卫 + 路由表 FE-01` | |
| 257 | + | |
| 258 | +### Task 7: LoginForm 组件(pure 表单 + 校验) | |
| 259 | + | |
| 260 | +**Files:** | |
| 261 | +- Create: `frontend/src/pages/login/LoginForm.tsx` | |
| 262 | +- Create: `frontend/src/pages/login/loginConstants.ts` | |
| 263 | +- Test: `frontend/src/pages/login/LoginForm.test.tsx` | |
| 264 | +- 测试先行类型: jsdom 组件测试 | |
| 265 | + | |
| 266 | +**Props/API shape:** | |
| 267 | +- `<LoginForm onSubmit={(req: LoginReq) => Promise<void>} loading={boolean} errorMessage={string | null} fieldErrors={Record<'username'|'password'|'companyCode', string | undefined>} />` | |
| 268 | +- 用 AntD `Form` + `Input` + `Input.Password` + `Select` + `Button` | |
| 269 | +- 内部状态:`form` 实例;submit 时先 `validateFields()`(jakarta 风格前端校验:username/password 非空,companyCode 必选)→ 调 `onSubmit` | |
| 270 | +- `loading=true` 时所有字段 + submit disabled | |
| 271 | +- `errorMessage != null` 时顶部 `Alert type="error"` 显示 | |
| 272 | +- `fieldErrors.username` 等 → 通过 `Form.Item.help` 显示字段级错误 | |
| 273 | + | |
| 274 | +- [ ] **Step 1: 写失败测试** | |
| 275 | + - `LoginForm_renders_threeFields_and_submitButton` | |
| 276 | + - `LoginForm_emptySubmit_showsFieldErrors`(点击 submit 不填字段 → 显示"请输入用户名 / 请输入密码 / 请选择公司") | |
| 277 | + - `LoginForm_submitsValid_callsOnSubmit_withTypedReq` | |
| 278 | + - `LoginForm_loading_disablesAllInputsAndSubmit` | |
| 279 | + - `LoginForm_errorMessage_displaysInAlert` | |
| 280 | + - `LoginForm_fieldErrors_displayInFormItemHelp` | |
| 281 | +- [ ] **Step 2: 实现最小代码** | |
| 282 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 283 | +- [ ] **Step 4: Commit** `feat(frontend): LoginForm 组件 + 字段校验 FE-01` | |
| 284 | + | |
| 285 | +### Task 8: LoginHero / LoginFooter 静态视觉组件 | |
| 286 | + | |
| 287 | +**Files:** | |
| 288 | +- Create: `frontend/src/pages/login/LoginHero.tsx` | |
| 289 | +- Create: `frontend/src/pages/login/LoginFooter.tsx` | |
| 290 | +- Test: `frontend/src/pages/login/LoginHero.test.tsx` | |
| 291 | +- 测试先行类型: jsdom 组件测试 | |
| 292 | + | |
| 293 | +**API shape:** | |
| 294 | +- `<LoginHero />` 渲染左侧文字(Enterprise Business Capability / 企业业务能力平台 / ERP)+ logo SVG | |
| 295 | +- `<LoginFooter />` 渲染版权 + ICP 文字 + 盾牌图标 | |
| 296 | + | |
| 297 | +视觉细节直接复制 prototype `screen-login` 内 `.login-head` + `.login-text` + `.login-foot` 的内容;样式用 CSS modules 或 styled components 任选其一(统一选 CSS modules:`LoginHero.module.css`)。 | |
| 298 | + | |
| 299 | +- [ ] **Step 1: 写失败测试** | |
| 300 | + - `LoginHero_renders_brandText`(含 "Antler ERP" / "Enterprise Business Capability" / "ERP") | |
| 301 | + - `LoginFooter_renders_copyrightAndICP`(含 "Antler Software" / "沪ICP备14034791号-1") | |
| 302 | +- [ ] **Step 2: 实现最小代码** | |
| 303 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 304 | +- [ ] **Step 4: Commit** `feat(frontend): LoginHero + LoginFooter 静态视觉 FE-01` | |
| 305 | + | |
| 306 | +### Task 9: LoginPage 组装 + 错误状态机集成 | |
| 307 | + | |
| 308 | +**Files:** | |
| 309 | +- Create: `frontend/src/pages/login/LoginPage.tsx` | |
| 310 | +- Test: `frontend/src/pages/login/LoginPage.test.tsx` | |
| 311 | +- 测试先行类型: jsdom 组件测试 | |
| 312 | + | |
| 313 | +**API shape:** | |
| 314 | +- `<LoginPage />` — 默认导出,无 props | |
| 315 | +- 内部:local state `{ errorMessage, fieldErrors }`;用 `useAppDispatch`;调用 `authApi.login()`;成功 → `dispatch(setSession())` + `navigate('/users', {replace:true})`;失败 → 根据 BizError.code 映射文案 + 切换 errorMessage 或 fieldErrors | |
| 316 | +- 锁定(42301):从 `err.data.lockUntil` 解析 dayjs,文案 `账号已锁定,请于 HH:mm 后再试` | |
| 317 | +- 网络错误(code=-1):文案 `网络异常,请检查连接后重试` | |
| 318 | + | |
| 319 | +- [ ] **Step 1: 写失败测试** | |
| 320 | + - `LoginPage_successFlow_dispatchesSetSessionAndNavigatesToUsers` | |
| 321 | + - `LoginPage_badCredentials_shows40101Message` | |
| 322 | + - `LoginPage_locked_shows42301WithLockUntil` | |
| 323 | + - `LoginPage_deletedAccount_shows40103Message` | |
| 324 | + - `LoginPage_unknownCompany_shows40004OnCompanyField` | |
| 325 | + - `LoginPage_networkError_showsNetworkMessage` | |
| 326 | +- [ ] **Step 2: 实现最小代码** | |
| 327 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 328 | +- [ ] **Step 4: Commit** `feat(frontend): LoginPage 组装 + 错误状态机 FE-01` | |
| 329 | + | |
| 330 | +### Task 10: Playwright E2E — 登录三条路径 | |
| 331 | + | |
| 332 | +**Files:** | |
| 333 | +- Create: `frontend/playwright.config.ts` | |
| 334 | +- Create: `frontend/tests/e2e/login.spec.ts` | |
| 335 | +- 测试先行类型: Playwright E2E | |
| 336 | + | |
| 337 | +**E2E 三个场景**(连真后端,复用 `LoginTestSeeder` 已建数据): | |
| 338 | +1. `successLogin_redirectsToUsers`:访问 `/login` → 填 `alice` / `Password1!` / `HQ` → submit → URL 变为 `/users` | |
| 339 | +2. `badPassword_showsError`:填 `alice` / `WrongPass` / `HQ` → submit → 页面上出现 "用户名或密码错误" | |
| 340 | +3. `unknownCompany_showsError`:填 `alice` / `Password1!` / `NOPE` → submit → 出现 "公司不存在或已删除" | |
| 341 | + | |
| 342 | +> 注:场景 3 需要 `NOPE` 作为非法 companyCode,但前端 dropdown 默认硬编码只有 HQ;E2E 通过 evaluate 直接修改 Redux state 或 form value 模拟该路径。简化:场景 3 用 page.evaluate 把 dropdown 改为不存在的 code 后 submit。 | |
| 343 | + | |
| 344 | +E2E 前置:测试前必须有运行中的 backend(端口 9090)+ frontend dev server(5173);`playwright.config.ts` 配 `webServer` 自动起 frontend dev server,backend 由开发者预先启动(CI 由 scripts/test.sh 协调)。 | |
| 345 | + | |
| 346 | +- [ ] **Step 1: 写失败测试** `frontend/tests/e2e/login.spec.ts` | |
| 347 | +- [ ] **Step 2: 实现最小代码** + Playwright config | |
| 348 | +- [ ] **Step 3: 子会话验证 PASS**(子会话先启动 backend `mvn spring-boot:run` 后台 → 跑 `npx playwright test`) | |
| 349 | +- [ ] **Step 4: Commit** `test(frontend): E2E 登录三路径 FE-01` | |
| 350 | + | |
| 351 | +--- | |
| 352 | + | |
| 353 | +## 提交计划 | |
| 354 | + | |
| 355 | +| Task | Commit message | | |
| 356 | +|---|---| | |
| 357 | +| 1 | `feat(frontend): bootstrap Vite + React + AntD + Vitest 骨架 FE-01` | | |
| 358 | +| 2 | `feat(frontend): Design Tokens + AntD ConfigProvider FE-01` | | |
| 359 | +| 3 | `feat(frontend): axios client + BizError + MSW 测试基建 FE-01` | | |
| 360 | +| 4 | `feat(frontend): authApi.login + MSW handlers FE-01` | | |
| 361 | +| 5 | `feat(frontend): authSlice + typed hooks FE-01` | | |
| 362 | +| 6 | `feat(frontend): RequireAuth 守卫 + 路由表 FE-01` | | |
| 363 | +| 7 | `feat(frontend): LoginForm 组件 + 字段校验 FE-01` | | |
| 364 | +| 8 | `feat(frontend): LoginHero + LoginFooter 静态视觉 FE-01` | | |
| 365 | +| 9 | `feat(frontend): LoginPage 组装 + 错误状态机 FE-01` | | |
| 366 | +| 10 | `test(frontend): E2E 登录三路径 FE-01` | | ... | ... |
docs/superpowers/reviews/2026-05-15-FE-01.md
0 → 100644
| 1 | +--- | |
| 2 | +fe_id: FE-01 | |
| 3 | +date: 2026-05-15 | |
| 4 | +round: 2 | |
| 5 | +reviewer: fe-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: FE-01 — round 2 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Round 1 修复落地核对 | |
| 17 | + | |
| 18 | +| # | 项目 | 状态 | | |
| 19 | +|---|------|-----| | |
| 20 | +| 1 | App.tsx 挂 Provider + RouterProvider + ConfigProvider | ✓ | | |
| 21 | +| 2 | tokens.css 与 docs/06 § 2.1 SSoT 完全对齐(含 8 个缺失 canonical token;删除自定义 form-bg/table-row 键) | ✓ | | |
| 22 | +| 3 | colorPrimary #1890ff → #1677ff(SSoT 同源) | ✓ | | |
| 23 | +| 4 | 三个 Form.Item 加 label="用户名"/"密码"/"公司"(a11y) | ✓ | | |
| 24 | +| 5 | LoginPage 锁定倒计时 + LoginForm submitDisabled prop | ✓ | | |
| 25 | +| 6 | 补 a11y / 锁定 disabled / 空字段必填 三类测试 | ✓ | | |
| 26 | +| 7 | App.test 改为 store/router 静态验证(BrowserRouter + jsdom + MSW AbortSignal 不兼容) | ✓ | | |
| 27 | + | |
| 28 | +## Nice-to-have(round 1 标记延后,本轮保留) | |
| 29 | + | |
| 30 | +- frontend/src/pages/login/LoginPage.tsx:71 — 40001 仍渲染到 ErrorBanner;spec § 三 #8 期望 field-level。MVP 可接受 | |
| 31 | +- frontend/src/pages/login/LoginPage.tsx:82 — 未抽出 LoginHeader + 引入 prototype SVG logo;visual polish 推后 | |
| 32 | +- frontend/src/pages/login/loginConstants.ts:1 — COMPANY_OPTIONS 硬编码 HQ;待 GET /api/v1/companies 实现后改为动态加载 | |
| 33 | +- frontend/src/api/client.ts:28 — 缺直接的 client.test.ts;当前由 auth.test.ts 间接覆盖等价场景 | |
| 34 | +- frontend/tests/e2e/login.spec.ts — 3 个 spec 仍 .fixme(),留作手工验收(需 npx playwright install + backend 启动) | |
| 35 | + | |
| 36 | +## 7 维 checklist | |
| 37 | + | |
| 38 | +| # | 维度 | 状态 | | |
| 39 | +|---|------|-----| | |
| 40 | +| 1 | Prototype consistency | pass | | |
| 41 | +| 2 | Design tokens | pass(无 hex 残留) | | |
| 42 | +| 3 | A11y | pass(labels + autoComplete + Enter 提交) | | |
| 43 | +| 4 | Responsive | pass(flex 布局,无 fixed 阻塞) | | |
| 44 | +| 5 | 业务校验前端复刻 | pass | | |
| 45 | +| 6 | API consistency | pass(统一 apiClient + BizError) | | |
| 46 | +| 7 | 状态机覆盖 | pass(10 状态 spec § 三 全部覆盖) | | |
| 47 | + | |
| 48 | +## 反例 / 测试覆盖缺口 | |
| 49 | + | |
| 50 | +- 3 个 Playwright E2E spec 仍 .fixme(),需人工验收时启用(设计决策 #3 标记延后) | |
| 51 | +- AntD Select 切换 companyCode 未在单测覆盖(默认 HQ);可接受 | |
| 52 | +- BizError 40001 假设后端不返 field detail;若后端在 data 里携带 field-level error 信息,本 round 未处理 | |
| 53 | + | |
| 54 | +## 总结 | |
| 55 | + | |
| 56 | +Round 1 全部 9 项 must-fix 正确落地:Provider 链可运行、tokens SSoT 严丝合缝、colorPrimary 同源、a11y labels 三齐、锁定倒计时 + submit disabled 闭环、回归测试三件套补齐。无新回归,7 维 checklist 全 pass。Approve。 | ... | ... |
docs/superpowers/specs/2026-05-15-FE-01.md
0 → 100644
| 1 | +# 前端功能规格 — FE-01 用户登录 | |
| 2 | + | |
| 3 | +> 关联 REQ:REQ-USR-001 | |
| 4 | +> 关联原型:prototype/erp.html#screen-login | |
| 5 | +> 日期:2026-05-15 | |
| 6 | + | |
| 7 | +## 一、功能概述 | |
| 8 | + | |
| 9 | +未登录用户访问任意路径都重定向到 `/login`。登录页提交 username + password + companyCode 三字段 → 调用 `POST /api/v1/auth/login` → 成功后把 accessToken + userInfo 存入 Redux 内存 → 跳转 `/users`(FE-02 用户管理页)。错误按后端返回的错误码做差异化提示(密码错 / 账号作废 / 账号锁定 / 公司不存在 / 字段格式错)。 | |
| 10 | + | |
| 11 | +**关键约束**: | |
| 12 | +- accessToken 仅存 Redux store 内存;刷新 / 关闭浏览器会清空 → 重登。与 docs/04 § 2.5 推荐方案对齐 | |
| 13 | +- 公司下拉硬编码 `{HQ: 总部}` 单选项;后续 REQ 提供 `GET /api/v1/companies` 后替换为动态加载 | |
| 14 | +- 与 prototype 布局保持视觉一致(左侧 hero 文字 + 右侧 login-card 表单 + 顶部 logo + 底部 copyright) | |
| 15 | + | |
| 16 | +## 二、组件树 | |
| 17 | + | |
| 18 | +基于 `prototype/erp.html#screen-login` 推导: | |
| 19 | + | |
| 20 | +``` | |
| 21 | +LoginPage (pages/login/LoginPage.tsx, route="/login") | |
| 22 | +├── LoginHeader | |
| 23 | +│ ├── Logo (svg) | |
| 24 | +│ ├── BrandName ("Antler ERP") | |
| 25 | +│ └── SubTitle ("欢迎登录EBC平台") | |
| 26 | +├── LoginHero | |
| 27 | +│ ├── HeroText | |
| 28 | +│ │ ├── EnglishLine ("Enterprise Business Capability") | |
| 29 | +│ │ ├── ChineseLine ("企业业务能力平台") | |
| 30 | +│ │ └── BigLabel ("ERP") | |
| 31 | +│ └── LoginCard | |
| 32 | +│ ├── CardTitle ("用户登录") | |
| 33 | +│ ├── UsernameField (Input + UserIcon) | |
| 34 | +│ ├── PasswordField (Input.Password + LockIcon) | |
| 35 | +│ ├── CompanyDropdown (Select,默认 HQ) | |
| 36 | +│ ├── ErrorBanner (条件渲染,根据 errorState) | |
| 37 | +│ └── SubmitButton ("登 录") | |
| 38 | +└── LoginFooter | |
| 39 | + ├── CopyrightText | |
| 40 | + └── ICPNumber | |
| 41 | +``` | |
| 42 | + | |
| 43 | +## 三、页面状态机 | |
| 44 | + | |
| 45 | +| # | 状态 | 触发条件 | 视觉表现 | 用户可执行操作 | | |
| 46 | +|---|------|---------|---------|--------------| | |
| 47 | +| 1 | idle | 初次进入 / token 失效跳转 | 空表单,submit 可点 | 输入字段,点击 submit | | |
| 48 | +| 2 | empty | 字段未填完整 | 字段下方红色提示"必填";submit disabled | 继续输入 | | |
| 49 | +| 3 | submitting | 用户点击 submit + 前端校验过 | submit 显示 loading 旋转图 + 文字"登录中...";所有字段 disabled | 等待响应 | | |
| 50 | +| 4 | error_credentials | 后端返 40101 | submit 恢复可点;ErrorBanner 显示"用户名或密码错误";username + password 字段红边框 | 修改字段重试 | | |
| 51 | +| 5 | error_locked | 后端返 42301 | submit 不可点(持续直到 lockUntil);ErrorBanner 显示"账号已锁定,请于 HH:MM 后再试"(用 dayjs format `data.lockUntil`) | 等待解锁或换账号 | | |
| 52 | +| 6 | error_deleted | 后端返 40103 | ErrorBanner 显示"账号已被作废,禁止登录";红色严重图标 | 换账号 | | |
| 53 | +| 7 | error_company | 后端返 40004 | CompanyDropdown 红边框 + 下方提示"公司不存在或已删除" | 重选公司 | | |
| 54 | +| 8 | error_format | 后端返 40001 / 前端校验失败 | 对应字段下方提示具体格式错误 | 修改字段 | | |
| 55 | +| 9 | error_network | 网络超时 / 5xx | ErrorBanner 显示"网络异常,请检查连接后重试"(黄色) | 等待 / 点 submit 重试 | | |
| 56 | +| 10 | success | 后端返 200 + data | 短暂 200ms 显示"登录成功"绿色提示后跳转 `/users` | (自动跳转,不可操作) | | |
| 57 | + | |
| 58 | +## 四、消费的后端端点 | |
| 59 | + | |
| 60 | +| # | 方法 | 路径 | 触发时机 | 关联 REQ | | |
| 61 | +|---|------|------|---------|---------| | |
| 62 | +| 1 | POST | `/api/v1/auth/login` | 用户点击 submit + 前端校验通过 | REQ-USR-001 | | |
| 63 | + | |
| 64 | +请求体: | |
| 65 | +```json | |
| 66 | +{ "username": "alice", "password": "Password1!", "companyCode": "HQ" } | |
| 67 | +``` | |
| 68 | + | |
| 69 | +响应(成功): | |
| 70 | +```json | |
| 71 | +{ | |
| 72 | + "code": 200, | |
| 73 | + "data": { | |
| 74 | + "accessToken": "<JWT>", | |
| 75 | + "tokenType": "Bearer", | |
| 76 | + "expiresInSec": 7200, | |
| 77 | + "userInfo": { "userId": 42, "username": "alice", "userType": "NORMAL", | |
| 78 | + "language": "zh-CN", "employeeName": "张三", "companyCode": "HQ" } | |
| 79 | + } | |
| 80 | +} | |
| 81 | +``` | |
| 82 | + | |
| 83 | +## 五、业务规则前端复刻清单 | |
| 84 | + | |
| 85 | +| # | 规则描述 | 触发时机 | 报错文案 | 来源 REQ | | |
| 86 | +|---|---------|---------|---------|---------| | |
| 87 | +| 1 | username 必填且非空白 | submit 前 / blur | "请输入用户名" | REQ-USR-001 | | |
| 88 | +| 2 | password 必填且非空白 | submit 前 / blur | "请输入密码" | REQ-USR-001 | | |
| 89 | +| 3 | companyCode 必填(默认已选 HQ) | submit 前 | "请选择公司" | REQ-USR-001 | | |
| 90 | +| 4 | 收到 40101 → 通用文案,不区分用户名/密码错 | 响应处理 | "用户名或密码错误" | REQ-USR-001 | | |
| 91 | +| 5 | 收到 40103 → 账号作废 | 响应处理 | "账号已被作废,禁止登录" | REQ-USR-001 | | |
| 92 | +| 6 | 收到 42301 → 账号锁定 + 显示 lockUntil | 响应处理 | "账号已锁定,请于 {HH:MM} 后再试" | REQ-USR-001 | | |
| 93 | +| 7 | 收到 40004 → 公司不存在 | 响应处理 | "公司不存在或已删除" | REQ-USR-001 | | |
| 94 | +| 8 | 收到 40001 → 字段格式错误 | 响应处理 | 后端 message 透传 | REQ-USR-001 | | |
| 95 | +| 9 | 网络错误 / 5xx | 响应处理 | "网络异常,请检查连接后重试" | docs/04 § 2.4 | | |
| 96 | +| 10 | 登录成功 → 写 Redux auth + 跳 /users | 响应处理 | (无文案,跳转) | REQ-USR-001 | | |
| 97 | +| 11 | 密码输入显示星号 | 字段渲染 | (Input.Password 内置) | REQ-USR-001 输入表 | | |
| 98 | +| 12 | 显示 lockUntil 用 dayjs format `HH:mm`(24h)| 错误处理 | (文案见 # 6) | spec § 5 | | |
| 99 | + | |
| 100 | +> **要求**:每条规则必须在前端 form-level 校验中复刻,不仅依赖后端报错。文案与后端语义一致。 | |
| 101 | + | |
| 102 | +## 六、Design Tokens 引用清单 | |
| 103 | + | |
| 104 | +``` | |
| 105 | +--color-primary (submit 按钮 bg / 标题色) | |
| 106 | +--color-primary-hover (submit hover bg) | |
| 107 | +--color-primary-active (submit active bg) | |
| 108 | +--color-text (表单 label) | |
| 109 | +--color-text-secondary (sub-title "欢迎登录EBC平台" / copyright) | |
| 110 | +--color-error (错误状态字段红边框 + ErrorBanner 文字) | |
| 111 | +--color-border (输入框边框 idle 态) | |
| 112 | +--color-bg-page (登录页底色) | |
| 113 | +--color-bg-container (login-card 卡片 bg) | |
| 114 | +--color-warning (网络错误 banner 黄色) | |
| 115 | +--color-success (登录成功提示绿色) | |
| 116 | +``` | |
| 117 | + | |
| 118 | +来源:docs/06 § 二(已锁定全局调色板 + 组件级状态色)。本 FE 不引入新 token。 | |
| 119 | + | |
| 120 | +## 七、交互流程关键路径 | |
| 121 | + | |
| 122 | +``` | |
| 123 | +[用户访问 / 任何路径] | |
| 124 | + → router/RequireAuth 检测 Redux auth.accessToken == null | |
| 125 | + → 重定向到 /login | |
| 126 | + | |
| 127 | +[用户在 /login] | |
| 128 | + 1. 页面挂载 → useEffect 把公司下拉默认值设为 "HQ" | |
| 129 | + 2. 用户填写 username + password | |
| 130 | + 3. 点 submit → form.validateFields() → submitting 态 | |
| 131 | + 4. 调用 authApi.login(req) → axios POST /api/v1/auth/login | |
| 132 | + 5a. 成功(code=200): | |
| 133 | + - dispatch(authSlice.actions.setSession({ accessToken, userInfo })) | |
| 134 | + - 200ms 提示 "登录成功" | |
| 135 | + - navigate('/users', { replace: true }) | |
| 136 | + 5b. 失败(throw BizError {code, message, data}): | |
| 137 | + - 根据 code 切换错误状态 | |
| 138 | + - 把 message 显示到 ErrorBanner | |
| 139 | + - 字段标红(按状态机表) | |
| 140 | + 5c. 锁定(code=42301): | |
| 141 | + - 启动 setInterval 每秒检查 data.lockUntil > now() | |
| 142 | + - lockUntil 过期 → 自动清除锁定态,submit 恢复可点 | |
| 143 | + 5d. 网络错(无 code): | |
| 144 | + - ErrorBanner 显示通用网络异常 + 黄色 | |
| 145 | + | |
| 146 | +[F5 刷新 / 重启浏览器] | |
| 147 | + → Redux 内存丢失 → RequireAuth 检测 token=null → 重定向 /login | |
| 148 | + → 用户须重登(与 docs/04 § 2.5 安全约束一致) | |
| 149 | +``` | |
| 150 | + | |
| 151 | +## 八、备注与开放问题 | |
| 152 | + | |
| 153 | +- **GET /api/v1/companies 暂未实现**:spec § 一 已说明硬编码 HQ;后续运营模块 / FE 阶段补全时只需把 dropdown 改为动态 API 调用即可,state/UI 结构不变 | |
| 154 | +- **登录成功跳转目标**:当前为 `/users`(FE-02)。若未来引入 dashboard,按角色 / `userInfo.userType` 派发,但本 FE 不实现该分支 | |
| 155 | +- **HTTPS / token in transit**:依赖部署 Nginx TLS 终止(docs/07 § 二),前端代码不在 axios 层做额外加密 | |
| 156 | +- **i18n 推迟**:本 FE 报错文案硬编码中文,后续接入 i18n 时再抽出 | |
| 157 | +- **prototype 顶部 logo + 底部 copyright** 视觉保留但 SVG 路径直接复制 prototype 内容,不再独立美工 | |
| 158 | +- **公司下拉点击展开**:prototype 用纯 CSS hover 展开(`.opt` 块),React 实现用 Ant Design `Select` 组件,行为等价 | |
| 159 | +- **a11y**:所有 input 配 `<label>`;submit 按钮 `type="submit"` 让 Enter 键提交(与 prototype 行为一致) | ... | ... |