# 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` |