Commit 52fe7fa51e7a58679e56e087c497c7bb9c6c8e92

Authored by zichun
1 parent 73e3415a

feat(frontend): LoginForm + LoginHero/Footer + LoginPage 集成 + jsdom polyfills

REQ_ID: FE-01
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' }));