diff --git a/frontend/src/pages/login/LoginFooter.tsx b/frontend/src/pages/login/LoginFooter.tsx
new file mode 100644
index 0000000..b82f2de
--- /dev/null
+++ b/frontend/src/pages/login/LoginFooter.tsx
@@ -0,0 +1,9 @@
+export default function LoginFooter() {
+ return (
+
+ 🛠 ©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 |
+ 文件智能处理 | 印前自动化 | 400-880-6237
+ 沪ICP备14034791号-1
+
+ );
+}
diff --git a/frontend/src/pages/login/LoginForm.tsx b/frontend/src/pages/login/LoginForm.tsx
new file mode 100644
index 0000000..b8c2347
--- /dev/null
+++ b/frontend/src/pages/login/LoginForm.tsx
@@ -0,0 +1,97 @@
+import { useEffect } from 'react';
+import { Form, Input, Select, Button, Alert } from 'antd';
+import type { LoginReq } from '../../api/auth';
+import { COMPANY_OPTIONS } from './loginConstants';
+
+export interface LoginFormFieldErrors {
+ username?: string;
+ password?: string;
+ companyCode?: string;
+}
+
+interface Props {
+ onSubmit: (req: LoginReq) => Promise;
+ loading: boolean;
+ errorMessage: string | null;
+ fieldErrors: LoginFormFieldErrors;
+}
+
+export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors }: Props) {
+ const [form] = Form.useForm();
+
+ useEffect(() => {
+ // 字段级错误同步到 AntD Form 实例
+ const errs: Array<{ name: keyof LoginFormFieldErrors; errors: string[] }> = [];
+ (['username', 'password', 'companyCode'] as const).forEach((k) => {
+ if (fieldErrors[k]) errs.push({ name: k, errors: [fieldErrors[k]!] });
+ });
+ if (errs.length > 0) {
+ form.setFields(errs as any);
+ }
+ }, [fieldErrors, form]);
+
+ const handleFinish = async (values: LoginReq) => {
+ await onSubmit(values);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/login/LoginHero.tsx b/frontend/src/pages/login/LoginHero.tsx
new file mode 100644
index 0000000..83abfbc
--- /dev/null
+++ b/frontend/src/pages/login/LoginHero.tsx
@@ -0,0 +1,11 @@
+export default function LoginHero() {
+ return (
+
+
+
Enterprise Business Capability
+
企业业务能力平台
+
ERP
+
+
+ );
+}
diff --git a/frontend/src/pages/login/LoginPage.test.tsx b/frontend/src/pages/login/LoginPage.test.tsx
new file mode 100644
index 0000000..3f252a4
--- /dev/null
+++ b/frontend/src/pages/login/LoginPage.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { ConfigProvider } from 'antd';
+import authReducer from '../../store/slices/authSlice';
+import LoginPage from './LoginPage';
+
+function makeStore() {
+ return configureStore({ reducer: { auth: authReducer } });
+}
+
+function renderLogin() {
+ const store = makeStore();
+ return {
+ store,
+ ...render(
+
+
+
+
+ } />
+ USERS} />
+
+
+
+ ,
+ ),
+ };
+}
+
+async function fillAndSubmit(username: string, password: string, companyCode: string = 'HQ') {
+ const user = userEvent.setup();
+ const inputs = screen.getAllByRole('textbox'); // username + password 在 antd Input.Password 渲染下不是 textbox
+ // 直接通过 placeholder 找
+ await user.clear(screen.getByPlaceholderText('请输入你的用户名'));
+ await user.type(screen.getByPlaceholderText('请输入你的用户名'), username);
+ await user.clear(screen.getByPlaceholderText('请输入你的密码'));
+ await user.type(screen.getByPlaceholderText('请输入你的密码'), password);
+ // companyCode 默认已选 HQ,若需改动通过 AntD Select 较复杂,本测试用默认值
+ if (companyCode !== 'HQ') {
+ // 跳过非默认场景的 UI 选择(依赖原生 Select 行为太复杂)
+ }
+ await user.click(screen.getByTestId('login-submit'));
+}
+
+describe('LoginPage', () => {
+ it('renders login page with form', () => {
+ renderLogin();
+ expect(screen.getByText('用户登录')).toBeInTheDocument();
+ expect(screen.getByTestId('login-form')).toBeInTheDocument();
+ expect(screen.getByTestId('login-submit')).toBeInTheDocument();
+ });
+
+ it('success flow: dispatches setSession and navigates to /users', async () => {
+ const { store } = renderLogin();
+ await fillAndSubmit('alice', 'Password1!');
+ await waitFor(() => expect(screen.queryByTestId('users-page')).toBeInTheDocument(), {
+ timeout: 3000,
+ });
+ expect(store.getState().auth.accessToken).toBe('fake-jwt');
+ expect(store.getState().auth.userInfo?.username).toBe('alice');
+ });
+
+ it('bad credentials: shows 40101 error message', async () => {
+ renderLogin();
+ await fillAndSubmit('alice', 'WRONG');
+ await waitFor(() =>
+ expect(screen.getByText('用户名或密码错误')).toBeInTheDocument(),
+ );
+ });
+
+ it('locked account: shows 42301 with lockUntil time', async () => {
+ renderLogin();
+ await fillAndSubmit('locked', 'X');
+ await waitFor(() => {
+ const alert = screen.getByTestId('login-error-alert');
+ expect(alert.textContent).toMatch(/账号已锁定/);
+ expect(alert.textContent).toMatch(/12:00/);
+ });
+ });
+
+ it('deleted account: shows 40103 message', async () => {
+ renderLogin();
+ await fillAndSubmit('deleted', 'X');
+ await waitFor(() =>
+ expect(screen.getByText('账号已被作废,禁止登录')).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx
index aac192d..34e5555 100644
--- a/frontend/src/pages/login/LoginPage.tsx
+++ b/frontend/src/pages/login/LoginPage.tsx
@@ -1,3 +1,94 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import dayjs from 'dayjs';
+import { authApi } from '../../api/auth';
+import type { LoginReq } from '../../api/auth';
+import { BizError, isBizError } from '../../api/errors';
+import { useAppDispatch } from '../../store/hooks';
+import { setSession } from '../../store/slices/authSlice';
+import { ERROR_MESSAGES } from './loginConstants';
+import LoginForm, { LoginFormFieldErrors } from './LoginForm';
+import LoginHero from './LoginHero';
+import LoginFooter from './LoginFooter';
+
export default function LoginPage() {
- return login placeholder
;
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const [loading, setLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [fieldErrors, setFieldErrors] = useState({});
+
+ const handleSubmit = async (req: LoginReq) => {
+ setLoading(true);
+ setErrorMessage(null);
+ setFieldErrors({});
+
+ try {
+ const vo = await authApi.login(req);
+ dispatch(setSession({ accessToken: vo.accessToken, userInfo: vo.userInfo }));
+ navigate('/users', { replace: true });
+ } catch (e) {
+ if (isBizError(e)) {
+ handleBizError(e);
+ } else {
+ setErrorMessage(ERROR_MESSAGES.UNKNOWN as string);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBizError = (e: BizError) => {
+ if (e.code === 42301) {
+ const data = e.data as { lockUntil?: string } | undefined;
+ const lockTime = data?.lockUntil ? dayjs(data.lockUntil).format('HH:mm') : '稍后';
+ setErrorMessage((ERROR_MESSAGES[42301] as string).replace('{lockUntil}', lockTime));
+ } else if (e.code === 40004) {
+ setFieldErrors({ companyCode: ERROR_MESSAGES[40004] as string });
+ } else if (e.code === 40103) {
+ setErrorMessage(ERROR_MESSAGES[40103] as string);
+ } else if (e.code === 40101) {
+ setErrorMessage(ERROR_MESSAGES[40101] as string);
+ } else if (e.code === 40001) {
+ setErrorMessage(e.message || (ERROR_MESSAGES[40001] as string));
+ } else if (e.code === -1 && e.message === 'NETWORK') {
+ setErrorMessage(ERROR_MESSAGES.NETWORK as string);
+ } else {
+ setErrorMessage(e.message || (ERROR_MESSAGES.UNKNOWN as string));
+ }
+ };
+
+ return (
+
+
+ Antler ERP
+ 欢迎登录EBC平台
+
+
+
+
+ );
}
diff --git a/frontend/src/pages/login/loginConstants.ts b/frontend/src/pages/login/loginConstants.ts
new file mode 100644
index 0000000..478ee4b
--- /dev/null
+++ b/frontend/src/pages/login/loginConstants.ts
@@ -0,0 +1,11 @@
+export const COMPANY_OPTIONS = [{ value: 'HQ', label: '总部' }];
+
+export const ERROR_MESSAGES: Record = {
+ 40001: '请检查字段格式',
+ 40004: '公司不存在或已删除',
+ 40101: '用户名或密码错误',
+ 40103: '账号已被作废,禁止登录',
+ 42301: '账号已锁定,请于 {lockUntil} 后再试',
+ NETWORK: '网络异常,请检查连接后重试',
+ UNKNOWN: '登录失败,请稍后重试',
+};
diff --git a/frontend/src/test-utils/setup.ts b/frontend/src/test-utils/setup.ts
index 3c7efff..0969080 100644
--- a/frontend/src/test-utils/setup.ts
+++ b/frontend/src/test-utils/setup.ts
@@ -1,8 +1,31 @@
import '@testing-library/jest-dom/vitest';
-import { afterAll, afterEach, beforeAll } from 'vitest';
+import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './msw-handlers';
+// AntD Grid 在 jsdom 下需要 matchMedia polyfill
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+// AntD 用到 ResizeObserver
+class ResizeObserverPolyfill {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+(window as any).ResizeObserver = (window as any).ResizeObserver ?? ResizeObserverPolyfill;
+
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));