2026-05-15-FE-01.md 18.9 KB

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(ConfigProvidertokens.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.jsonfrontend/vite.config.tsfrontend/tsconfig.jsonfrontend/tsconfig.node.jsonfrontend/index.htmlfrontend/vitest.config.tsfrontend/playwright.config.tsfrontend/.eslintrc.cjsfrontend/.gitignore
  • frontend/src/main.tsxfrontend/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<LoginVo>
  • 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:

  • <LoginForm onSubmit={(req: LoginReq) => Promise<void>} loading={boolean} errorMessage={string | null} fieldErrors={{username?: string; password?: string; companyCode?: string}} />

路由表(router/index.tsx 写死):

  • /login<LoginPage /> 公开
  • /users<RequireAuth><UsersPage /></RequireAuth>(占位组件,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.jsonfrontend/vite.config.tsfrontend/tsconfig.jsonfrontend/tsconfig.node.jsonfrontend/index.htmlfrontend/vitest.config.tsfrontend/.eslintrc.cjsfrontend/.gitignore
  • Create: frontend/src/main.tsxfrontend/src/App.tsxfrontend/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
    • 意图: 渲染 <App /> → 期望页面上有 "Antler ERP" 文字(暂用作 placeholder;后续任务会被覆盖)
    • 子会话确认 FAIL(App / package.json / vite 都不存在)
  • Step 2: 实现最小代码:全部 bootstrap 文件 + App.tsx 内最小返回 <div>Antler ERP</div>

  • 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:

  • <App /> 内层一定包 <ConfigProvider theme={{ token: { colorPrimary: '#1677ff', ...overrides } }}>

    • 注意: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 <App /> 后通过 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.tsfrontend/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 === 200response.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/v1http://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<LoginVo>apiClient.post('/auth/login', req)
  • LoginReqLoginVoUserInfo 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.tsfrontend/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)
  • useAppDispatchuseAppSelector 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.tsxfrontend/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 <Navigate to="/login" replace />
  • router = createBrowserRouter([...])

    • /login<LoginPage />(FE-01 提供)
    • /users<RequireAuth><UsersPlaceholder /></RequireAuth>(占位组件,本任务用 <div>users placeholder</div> 占位;FE-02 替换)
    • *<Navigate to="/users" />
  • 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:

  • <LoginForm onSubmit={(req: LoginReq) => Promise<void>} 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:

  • <LoginHero /> 渲染左侧文字(Enterprise Business Capability / 企业业务能力平台 / ERP)+ logo SVG
  • <LoginFooter /> 渲染版权 + 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:

  • <LoginPage /> — 默认导出,无 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.tswebServer 自动起 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