Commit 52fe7fa51e7a58679e56e087c497c7bb9c6c8e92
1 parent
73e3415a
feat(frontend): LoginForm + LoginHero/Footer + LoginPage 集成 + jsdom polyfills
REQ_ID: FE-01
Showing
7 changed files
with
336 additions
and
2 deletions
frontend/src/pages/login/LoginFooter.tsx
0 → 100644
| 1 | +export default function LoginFooter() { | |
| 2 | + return ( | |
| 3 | + <div className="login-foot" data-testid="login-footer"> | |
| 4 | + 🛠 ©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 | | |
| 5 | + 文件智能处理 | 印前自动化 | 400-880-6237 | |
| 6 | + <span style={{ marginLeft: 6 }}>沪ICP备14034791号-1</span> | |
| 7 | + </div> | |
| 8 | + ); | |
| 9 | +} | ... | ... |
frontend/src/pages/login/LoginForm.tsx
0 → 100644
| 1 | +import { useEffect } from 'react'; | |
| 2 | +import { Form, Input, Select, Button, Alert } from 'antd'; | |
| 3 | +import type { LoginReq } from '../../api/auth'; | |
| 4 | +import { COMPANY_OPTIONS } from './loginConstants'; | |
| 5 | + | |
| 6 | +export interface LoginFormFieldErrors { | |
| 7 | + username?: string; | |
| 8 | + password?: string; | |
| 9 | + companyCode?: string; | |
| 10 | +} | |
| 11 | + | |
| 12 | +interface Props { | |
| 13 | + onSubmit: (req: LoginReq) => Promise<void>; | |
| 14 | + loading: boolean; | |
| 15 | + errorMessage: string | null; | |
| 16 | + fieldErrors: LoginFormFieldErrors; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors }: Props) { | |
| 20 | + const [form] = Form.useForm<LoginReq>(); | |
| 21 | + | |
| 22 | + useEffect(() => { | |
| 23 | + // 字段级错误同步到 AntD Form 实例 | |
| 24 | + const errs: Array<{ name: keyof LoginFormFieldErrors; errors: string[] }> = []; | |
| 25 | + (['username', 'password', 'companyCode'] as const).forEach((k) => { | |
| 26 | + if (fieldErrors[k]) errs.push({ name: k, errors: [fieldErrors[k]!] }); | |
| 27 | + }); | |
| 28 | + if (errs.length > 0) { | |
| 29 | + form.setFields(errs as any); | |
| 30 | + } | |
| 31 | + }, [fieldErrors, form]); | |
| 32 | + | |
| 33 | + const handleFinish = async (values: LoginReq) => { | |
| 34 | + await onSubmit(values); | |
| 35 | + }; | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <Form | |
| 39 | + form={form} | |
| 40 | + layout="vertical" | |
| 41 | + onFinish={handleFinish} | |
| 42 | + initialValues={{ companyCode: 'HQ' }} | |
| 43 | + data-testid="login-form" | |
| 44 | + > | |
| 45 | + {errorMessage && ( | |
| 46 | + <Alert | |
| 47 | + type="error" | |
| 48 | + message={errorMessage} | |
| 49 | + showIcon | |
| 50 | + style={{ marginBottom: 16 }} | |
| 51 | + data-testid="login-error-alert" | |
| 52 | + /> | |
| 53 | + )} | |
| 54 | + | |
| 55 | + <Form.Item | |
| 56 | + name="username" | |
| 57 | + rules={[{ required: true, message: '请输入用户名' }]} | |
| 58 | + > | |
| 59 | + <Input | |
| 60 | + placeholder="请输入你的用户名" | |
| 61 | + disabled={loading} | |
| 62 | + autoComplete="username" | |
| 63 | + /> | |
| 64 | + </Form.Item> | |
| 65 | + | |
| 66 | + <Form.Item | |
| 67 | + name="password" | |
| 68 | + rules={[{ required: true, message: '请输入密码' }]} | |
| 69 | + > | |
| 70 | + <Input.Password | |
| 71 | + placeholder="请输入你的密码" | |
| 72 | + disabled={loading} | |
| 73 | + autoComplete="current-password" | |
| 74 | + /> | |
| 75 | + </Form.Item> | |
| 76 | + | |
| 77 | + <Form.Item | |
| 78 | + name="companyCode" | |
| 79 | + rules={[{ required: true, message: '请选择公司' }]} | |
| 80 | + > | |
| 81 | + <Select options={COMPANY_OPTIONS} disabled={loading} data-testid="company-select" /> | |
| 82 | + </Form.Item> | |
| 83 | + | |
| 84 | + <Form.Item> | |
| 85 | + <Button | |
| 86 | + type="primary" | |
| 87 | + htmlType="submit" | |
| 88 | + block | |
| 89 | + loading={loading} | |
| 90 | + data-testid="login-submit" | |
| 91 | + > | |
| 92 | + {loading ? '登录中...' : '登 录'} | |
| 93 | + </Button> | |
| 94 | + </Form.Item> | |
| 95 | + </Form> | |
| 96 | + ); | |
| 97 | +} | ... | ... |
frontend/src/pages/login/LoginHero.tsx
0 → 100644
| 1 | +export default function LoginHero() { | |
| 2 | + return ( | |
| 3 | + <div className="login-hero" data-testid="login-hero"> | |
| 4 | + <div className="login-text"> | |
| 5 | + <div className="en">Enterprise Business Capability</div> | |
| 6 | + <div className="zh">企业业务能力平台</div> | |
| 7 | + <div className="erp">ERP</div> | |
| 8 | + </div> | |
| 9 | + </div> | |
| 10 | + ); | |
| 11 | +} | ... | ... |
frontend/src/pages/login/LoginPage.test.tsx
0 → 100644
| 1 | +import { describe, it, expect, vi } from 'vitest'; | |
| 2 | +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; | |
| 3 | +import userEvent from '@testing-library/user-event'; | |
| 4 | +import { MemoryRouter, Routes, Route } from 'react-router-dom'; | |
| 5 | +import { Provider } from 'react-redux'; | |
| 6 | +import { configureStore } from '@reduxjs/toolkit'; | |
| 7 | +import { ConfigProvider } from 'antd'; | |
| 8 | +import authReducer from '../../store/slices/authSlice'; | |
| 9 | +import LoginPage from './LoginPage'; | |
| 10 | + | |
| 11 | +function makeStore() { | |
| 12 | + return configureStore({ reducer: { auth: authReducer } }); | |
| 13 | +} | |
| 14 | + | |
| 15 | +function renderLogin() { | |
| 16 | + const store = makeStore(); | |
| 17 | + return { | |
| 18 | + store, | |
| 19 | + ...render( | |
| 20 | + <Provider store={store}> | |
| 21 | + <ConfigProvider> | |
| 22 | + <MemoryRouter initialEntries={['/login']}> | |
| 23 | + <Routes> | |
| 24 | + <Route path="/login" element={<LoginPage />} /> | |
| 25 | + <Route path="/users" element={<div data-testid="users-page">USERS</div>} /> | |
| 26 | + </Routes> | |
| 27 | + </MemoryRouter> | |
| 28 | + </ConfigProvider> | |
| 29 | + </Provider>, | |
| 30 | + ), | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +async function fillAndSubmit(username: string, password: string, companyCode: string = 'HQ') { | |
| 35 | + const user = userEvent.setup(); | |
| 36 | + const inputs = screen.getAllByRole('textbox'); // username + password 在 antd Input.Password 渲染下不是 textbox | |
| 37 | + // 直接通过 placeholder 找 | |
| 38 | + await user.clear(screen.getByPlaceholderText('请输入你的用户名')); | |
| 39 | + await user.type(screen.getByPlaceholderText('请输入你的用户名'), username); | |
| 40 | + await user.clear(screen.getByPlaceholderText('请输入你的密码')); | |
| 41 | + await user.type(screen.getByPlaceholderText('请输入你的密码'), password); | |
| 42 | + // companyCode 默认已选 HQ,若需改动通过 AntD Select 较复杂,本测试用默认值 | |
| 43 | + if (companyCode !== 'HQ') { | |
| 44 | + // 跳过非默认场景的 UI 选择(依赖原生 Select 行为太复杂) | |
| 45 | + } | |
| 46 | + await user.click(screen.getByTestId('login-submit')); | |
| 47 | +} | |
| 48 | + | |
| 49 | +describe('LoginPage', () => { | |
| 50 | + it('renders login page with form', () => { | |
| 51 | + renderLogin(); | |
| 52 | + expect(screen.getByText('用户登录')).toBeInTheDocument(); | |
| 53 | + expect(screen.getByTestId('login-form')).toBeInTheDocument(); | |
| 54 | + expect(screen.getByTestId('login-submit')).toBeInTheDocument(); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('success flow: dispatches setSession and navigates to /users', async () => { | |
| 58 | + const { store } = renderLogin(); | |
| 59 | + await fillAndSubmit('alice', 'Password1!'); | |
| 60 | + await waitFor(() => expect(screen.queryByTestId('users-page')).toBeInTheDocument(), { | |
| 61 | + timeout: 3000, | |
| 62 | + }); | |
| 63 | + expect(store.getState().auth.accessToken).toBe('fake-jwt'); | |
| 64 | + expect(store.getState().auth.userInfo?.username).toBe('alice'); | |
| 65 | + }); | |
| 66 | + | |
| 67 | + it('bad credentials: shows 40101 error message', async () => { | |
| 68 | + renderLogin(); | |
| 69 | + await fillAndSubmit('alice', 'WRONG'); | |
| 70 | + await waitFor(() => | |
| 71 | + expect(screen.getByText('用户名或密码错误')).toBeInTheDocument(), | |
| 72 | + ); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it('locked account: shows 42301 with lockUntil time', async () => { | |
| 76 | + renderLogin(); | |
| 77 | + await fillAndSubmit('locked', 'X'); | |
| 78 | + await waitFor(() => { | |
| 79 | + const alert = screen.getByTestId('login-error-alert'); | |
| 80 | + expect(alert.textContent).toMatch(/账号已锁定/); | |
| 81 | + expect(alert.textContent).toMatch(/12:00/); | |
| 82 | + }); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it('deleted account: shows 40103 message', async () => { | |
| 86 | + renderLogin(); | |
| 87 | + await fillAndSubmit('deleted', 'X'); | |
| 88 | + await waitFor(() => | |
| 89 | + expect(screen.getByText('账号已被作废,禁止登录')).toBeInTheDocument(), | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | +}); | ... | ... |
frontend/src/pages/login/LoginPage.tsx
| 1 | +import { useState } from 'react'; | |
| 2 | +import { useNavigate } from 'react-router-dom'; | |
| 3 | +import dayjs from 'dayjs'; | |
| 4 | +import { authApi } from '../../api/auth'; | |
| 5 | +import type { LoginReq } from '../../api/auth'; | |
| 6 | +import { BizError, isBizError } from '../../api/errors'; | |
| 7 | +import { useAppDispatch } from '../../store/hooks'; | |
| 8 | +import { setSession } from '../../store/slices/authSlice'; | |
| 9 | +import { ERROR_MESSAGES } from './loginConstants'; | |
| 10 | +import LoginForm, { LoginFormFieldErrors } from './LoginForm'; | |
| 11 | +import LoginHero from './LoginHero'; | |
| 12 | +import LoginFooter from './LoginFooter'; | |
| 13 | + | |
| 1 | 14 | export default function LoginPage() { |
| 2 | - return <div data-testid="login-page-placeholder">login placeholder</div>; | |
| 15 | + const dispatch = useAppDispatch(); | |
| 16 | + const navigate = useNavigate(); | |
| 17 | + | |
| 18 | + const [loading, setLoading] = useState(false); | |
| 19 | + const [errorMessage, setErrorMessage] = useState<string | null>(null); | |
| 20 | + const [fieldErrors, setFieldErrors] = useState<LoginFormFieldErrors>({}); | |
| 21 | + | |
| 22 | + const handleSubmit = async (req: LoginReq) => { | |
| 23 | + setLoading(true); | |
| 24 | + setErrorMessage(null); | |
| 25 | + setFieldErrors({}); | |
| 26 | + | |
| 27 | + try { | |
| 28 | + const vo = await authApi.login(req); | |
| 29 | + dispatch(setSession({ accessToken: vo.accessToken, userInfo: vo.userInfo })); | |
| 30 | + navigate('/users', { replace: true }); | |
| 31 | + } catch (e) { | |
| 32 | + if (isBizError(e)) { | |
| 33 | + handleBizError(e); | |
| 34 | + } else { | |
| 35 | + setErrorMessage(ERROR_MESSAGES.UNKNOWN as string); | |
| 36 | + } | |
| 37 | + } finally { | |
| 38 | + setLoading(false); | |
| 39 | + } | |
| 40 | + }; | |
| 41 | + | |
| 42 | + const handleBizError = (e: BizError) => { | |
| 43 | + if (e.code === 42301) { | |
| 44 | + const data = e.data as { lockUntil?: string } | undefined; | |
| 45 | + const lockTime = data?.lockUntil ? dayjs(data.lockUntil).format('HH:mm') : '稍后'; | |
| 46 | + setErrorMessage((ERROR_MESSAGES[42301] as string).replace('{lockUntil}', lockTime)); | |
| 47 | + } else if (e.code === 40004) { | |
| 48 | + setFieldErrors({ companyCode: ERROR_MESSAGES[40004] as string }); | |
| 49 | + } else if (e.code === 40103) { | |
| 50 | + setErrorMessage(ERROR_MESSAGES[40103] as string); | |
| 51 | + } else if (e.code === 40101) { | |
| 52 | + setErrorMessage(ERROR_MESSAGES[40101] as string); | |
| 53 | + } else if (e.code === 40001) { | |
| 54 | + setErrorMessage(e.message || (ERROR_MESSAGES[40001] as string)); | |
| 55 | + } else if (e.code === -1 && e.message === 'NETWORK') { | |
| 56 | + setErrorMessage(ERROR_MESSAGES.NETWORK as string); | |
| 57 | + } else { | |
| 58 | + setErrorMessage(e.message || (ERROR_MESSAGES.UNKNOWN as string)); | |
| 59 | + } | |
| 60 | + }; | |
| 61 | + | |
| 62 | + return ( | |
| 63 | + <div className="login-wrap" data-testid="login-page"> | |
| 64 | + <div className="login-head"> | |
| 65 | + <span className="name">Antler ERP</span> | |
| 66 | + <span className="sub">欢迎登录EBC平台</span> | |
| 67 | + </div> | |
| 68 | + <div | |
| 69 | + className="login-body" | |
| 70 | + style={{ display: 'flex', gap: 32, padding: 24 }} | |
| 71 | + > | |
| 72 | + <LoginHero /> | |
| 73 | + <div | |
| 74 | + className="login-card" | |
| 75 | + style={{ | |
| 76 | + width: 360, | |
| 77 | + padding: 24, | |
| 78 | + background: 'var(--color-form-bg-edit)', | |
| 79 | + borderRadius: 8, | |
| 80 | + }} | |
| 81 | + > | |
| 82 | + <h3>用户登录</h3> | |
| 83 | + <LoginForm | |
| 84 | + onSubmit={handleSubmit} | |
| 85 | + loading={loading} | |
| 86 | + errorMessage={errorMessage} | |
| 87 | + fieldErrors={fieldErrors} | |
| 88 | + /> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + <LoginFooter /> | |
| 92 | + </div> | |
| 93 | + ); | |
| 3 | 94 | } | ... | ... |
frontend/src/pages/login/loginConstants.ts
0 → 100644
| 1 | +export const COMPANY_OPTIONS = [{ value: 'HQ', label: '总部' }]; | |
| 2 | + | |
| 3 | +export const ERROR_MESSAGES: Record<number | string, string> = { | |
| 4 | + 40001: '请检查字段格式', | |
| 5 | + 40004: '公司不存在或已删除', | |
| 6 | + 40101: '用户名或密码错误', | |
| 7 | + 40103: '账号已被作废,禁止登录', | |
| 8 | + 42301: '账号已锁定,请于 {lockUntil} 后再试', | |
| 9 | + NETWORK: '网络异常,请检查连接后重试', | |
| 10 | + UNKNOWN: '登录失败,请稍后重试', | |
| 11 | +}; | ... | ... |
frontend/src/test-utils/setup.ts
| 1 | 1 | import '@testing-library/jest-dom/vitest'; |
| 2 | -import { afterAll, afterEach, beforeAll } from 'vitest'; | |
| 2 | +import { afterAll, afterEach, beforeAll, vi } from 'vitest'; | |
| 3 | 3 | import { setupServer } from 'msw/node'; |
| 4 | 4 | import { handlers } from './msw-handlers'; |
| 5 | 5 | |
| 6 | +// AntD Grid 在 jsdom 下需要 matchMedia polyfill | |
| 7 | +Object.defineProperty(window, 'matchMedia', { | |
| 8 | + writable: true, | |
| 9 | + value: vi.fn().mockImplementation((query: string) => ({ | |
| 10 | + matches: false, | |
| 11 | + media: query, | |
| 12 | + onchange: null, | |
| 13 | + addListener: vi.fn(), | |
| 14 | + removeListener: vi.fn(), | |
| 15 | + addEventListener: vi.fn(), | |
| 16 | + removeEventListener: vi.fn(), | |
| 17 | + dispatchEvent: vi.fn(), | |
| 18 | + })), | |
| 19 | +}); | |
| 20 | + | |
| 21 | +// AntD 用到 ResizeObserver | |
| 22 | +class ResizeObserverPolyfill { | |
| 23 | + observe() {} | |
| 24 | + unobserve() {} | |
| 25 | + disconnect() {} | |
| 26 | +} | |
| 27 | +(window as any).ResizeObserver = (window as any).ResizeObserver ?? ResizeObserverPolyfill; | |
| 28 | + | |
| 6 | 29 | export const server = setupServer(...handlers); |
| 7 | 30 | |
| 8 | 31 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); | ... | ... |