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 | export default function LoginPage() { | 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 | import '@testing-library/jest-dom/vitest'; | 1 | import '@testing-library/jest-dom/vitest'; |
| 2 | -import { afterAll, afterEach, beforeAll } from 'vitest'; | 2 | +import { afterAll, afterEach, beforeAll, vi } from 'vitest'; |
| 3 | import { setupServer } from 'msw/node'; | 3 | import { setupServer } from 'msw/node'; |
| 4 | import { handlers } from './msw-handlers'; | 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 | export const server = setupServer(...handlers); | 29 | export const server = setupServer(...handlers); |
| 7 | 30 | ||
| 8 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); | 31 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); |