diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ab4406a..a610f45 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,25 @@
-// FE-01: 应用根组件(骨架占位,T4 接入 Provider + Router + ConfigProvider)
+// REQ-USR-004: 应用根(Provider + Router + AntD ConfigProvider + App 上下文)
+import { ConfigProvider, App as AntdApp } from 'antd';
+import zhCN from 'antd/locale/zh_CN';
+import { Provider } from 'react-redux';
+import { BrowserRouter } from 'react-router-dom';
+import { store } from './store/store';
+import AppRouter from './router';
+import { readPrimaryColor } from './styles/theme';
+
export default function App() {
- return
;
+ return (
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/frontend/src/pages/usr/Login/Login.module.css b/frontend/src/pages/usr/Login/Login.module.css
new file mode 100644
index 0000000..b35d5aa
--- /dev/null
+++ b/frontend/src/pages/usr/Login/Login.module.css
@@ -0,0 +1,187 @@
+/*
+ * REQ-USR-004 登录页 scoped 样式。
+ * 语义色(按钮/文字/边框/背景/错误)一律 var(--color-*);
+ * 主视觉深蓝渐变 / 网格透视为登录页局部装饰(D7),不挪用语义 token、不新增全局 token。
+ */
+
+.wrap {
+ position: absolute;
+ inset: 0;
+ background: var(--color-bg-base);
+ display: flex;
+ flex-direction: column;
+}
+
+/* 品牌头 */
+.head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 18px 36px;
+ background: var(--color-bg-base);
+}
+
+.logo {
+ width: 42px;
+ height: 42px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.brandName {
+ font-size: 24px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: var(--color-primary);
+}
+
+.brandSub {
+ color: var(--color-text);
+ font-size: 14px;
+ margin-left: 6px;
+}
+
+/* 主视觉(深蓝装饰,D7 scoped,不使用语义 token) */
+.hero {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+ background: radial-gradient(
+ ellipse at center,
+ #1a4ea0 0%,
+ #0a1d44 60%,
+ #050d20 100%
+ );
+}
+
+.hero::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image: linear-gradient(rgba(80, 160, 255, 0.18) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(80, 160, 255, 0.18) 1px, transparent 1px);
+ background-size: 80px 80px;
+ transform: perspective(800px) rotateX(55deg) translateY(20%);
+ transform-origin: center;
+ opacity: 0.55;
+}
+
+.hero::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(
+ ellipse 800px 300px at 50% 50%,
+ rgba(140, 200, 255, 0.35),
+ transparent 60%
+ ),
+ radial-gradient(circle 200px at 30% 40%, rgba(255, 255, 255, 0.15), transparent 70%),
+ radial-gradient(circle 160px at 70% 60%, rgba(255, 255, 255, 0.12), transparent 70%);
+}
+
+.heroText {
+ position: absolute;
+ left: 8%;
+ top: 35%;
+ color: #fff;
+ z-index: 2;
+}
+
+.heroEn {
+ font-size: 30px;
+ font-weight: 300;
+ letter-spacing: 1px;
+ color: #cfe1ff;
+ margin-bottom: 6px;
+}
+
+.heroZh {
+ font-size: 54px;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 4px;
+ margin-bottom: 4px;
+}
+
+.heroErp {
+ font-size: 90px;
+ font-weight: 800;
+ color: #fff;
+ letter-spacing: 8px;
+ line-height: 0.9;
+}
+
+/* 右侧浮层登录卡 */
+.card {
+ position: absolute;
+ right: 8%;
+ top: 50%;
+ transform: translateY(-50%);
+ background: var(--color-form-bg-edit);
+ width: 380px;
+ padding: 36px 32px;
+ border-radius: 2px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
+ z-index: 3;
+}
+
+.cardTitle {
+ margin: 0 0 22px;
+ font-size: 18px;
+ color: var(--color-text);
+ font-weight: 500;
+}
+
+.submitBtn {
+ letter-spacing: 8px;
+}
+
+.emptyHint {
+ margin-top: -6px;
+ margin-bottom: 12px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+}
+
+.retryBox {
+ margin-top: -6px;
+ margin-bottom: 12px;
+ color: var(--color-error);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* 页脚版权 */
+.foot {
+ background: var(--color-bg-base);
+ text-align: center;
+ padding: 14px 8px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ border-top: 1px solid var(--color-border);
+}
+
+.footIcp {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 6px;
+}
+
+/* 响应式回流:窄视口卡片居中(D4 默认行为) */
+@media (max-width: 768px) {
+ .card {
+ position: static;
+ transform: none;
+ margin: 24px auto;
+ width: calc(100% - 48px);
+ max-width: 380px;
+ }
+ .heroText {
+ position: static;
+ padding: 24px 8%;
+ }
+}
diff --git a/frontend/src/pages/usr/Login/LoginPage.tsx b/frontend/src/pages/usr/Login/LoginPage.tsx
new file mode 100644
index 0000000..121bd51
--- /dev/null
+++ b/frontend/src/pages/usr/Login/LoginPage.tsx
@@ -0,0 +1,211 @@
+// REQ-USR-004: 登录页(复刻 prototype #screen-login 三段式布局 + 真实对接后端)
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Button, Form, Input, Select, App as AntdApp } from 'antd';
+import { UserOutlined, LockOutlined } from '@ant-design/icons';
+import { useNavigate } from 'react-router-dom';
+import { useAppDispatch } from '../../../store/hooks';
+import { setCredentials } from '../../../store/slices/authSlice';
+import { login, fetchCompanies } from '../../../api/usrApi';
+import type { CompanyOption, LoginPayload } from '../../../api/types';
+import { ApiError } from '../../../api/request';
+import {
+ LOGIN_SUCCESS_TEXT,
+ resolveLoginErrorText,
+ CLEAR_PASSWORD_CODES,
+} from './loginMessages';
+import styles from './Login.module.css';
+
+interface LoginFormValues {
+ sUserName: string;
+ password: string;
+ companyId: number;
+}
+
+// 版本下拉 label 规则(D8):有 sVersion → 「公司名(版本)」,否则仅公司名;value 恒取 id
+function toOption(c: CompanyOption) {
+ return {
+ value: c.id,
+ label: c.sVersion ? `${c.sCompanyName}(${c.sVersion})` : c.sCompanyName,
+ };
+}
+
+export default function LoginPage() {
+ const [form] = Form.useForm();
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const { message } = AntdApp.useApp();
+
+ const [companies, setCompanies] = useState([]);
+ const [companiesLoading, setCompaniesLoading] = useState(false);
+ const [companiesError, setCompaniesError] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const loadCompanies = useCallback(async () => {
+ setCompaniesLoading(true);
+ setCompaniesError(false);
+ try {
+ const list = await fetchCompanies();
+ setCompanies(list);
+ // 仅一项时默认选中(spec § 6.3)
+ if (list.length === 1) {
+ form.setFieldValue('companyId', list[0].id);
+ }
+ } catch {
+ setCompaniesError(true);
+ setCompanies([]);
+ message.error('版本加载失败');
+ } finally {
+ setCompaniesLoading(false);
+ }
+ }, [form, message]);
+
+ // 页面挂载即预加载版本下拉(spec § 3 companiesLoading)
+ useEffect(() => {
+ void loadCompanies();
+ }, [loadCompanies]);
+
+ const options = useMemo(() => companies.map(toOption), [companies]);
+ const isEmpty = !companiesLoading && !companiesError && companies.length === 0;
+
+ const handleFinish = async (values: LoginFormValues) => {
+ if (submitting) return; // 防重复提交(BR10)
+ setSubmitting(true);
+ const payload: LoginPayload = {
+ sUserName: values.sUserName,
+ password: values.password,
+ companyId: values.companyId,
+ };
+ try {
+ const { token, user } = await login(payload);
+ dispatch(setCredentials({ token, user }));
+ message.success(LOGIN_SUCCESS_TEXT);
+ navigate('/', { replace: true });
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : -1;
+ message.error(resolveLoginErrorText(code));
+ if (CLEAR_PASSWORD_CODES.has(code)) {
+ form.setFieldValue('password', '');
+ form.getFieldInstance('password')?.focus?.();
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const versionPlaceholder = companiesLoading ? '加载版本中…' : '请选择版本';
+
+ return (
+
+
+
+
+
+
Antler ERP
+
欢迎登录EBC平台
+
+
+
+
+
Enterprise Business Capability
+
企业业务能力平台
+
ERP
+
+
+
+
用户登录
+
+ }
+ placeholder="请输入你的用户名"
+ size="large"
+ autoComplete="username"
+ />
+
+
+
+ }
+ placeholder="请输入你的密码"
+ size="large"
+ autoComplete="current-password"
+ />
+
+
+
+
+
+
+ {isEmpty && (
+
未获取到可登录版本,请联系管理员
+ )}
+
+ {companiesError && (
+
+ 版本加载失败
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ 🛠 ©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 |
+ 文件智能处理 | 印前自动化 | 400-880-6237
+
+
+ 沪ICP备14034791号-1
+
+
+
+ );
+}
diff --git a/frontend/src/pages/usr/Login/loginMessages.ts b/frontend/src/pages/usr/Login/loginMessages.ts
new file mode 100644
index 0000000..696b508
--- /dev/null
+++ b/frontend/src/pages/usr/Login/loginMessages.ts
@@ -0,0 +1,23 @@
+// REQ-USR-004: 登录错误码 → 前端文案映射(spec § 4 / docs/05;严格沿用,不细化 40101)
+import { NETWORK_ERROR_CODE } from '../../../api/request';
+
+export const LOGIN_SUCCESS_TEXT = '登录成功';
+
+/** 登录失败错误码文案表;未命中走网络异常兜底文案 */
+export const LOGIN_ERROR_MESSAGES: Record = {
+ 40001: '请填写用户名、密码并选择版本',
+ 40101: '用户名或密码错误',
+ 40302: '该账号已被禁用,请联系管理员',
+ 42901: '登录尝试过于频繁,请稍后再试',
+ [NETWORK_ERROR_CODE]: '网络异常,请稍后重试',
+};
+
+/** 网络异常 / 未命中错误码的兜底文案 */
+export const NETWORK_ERROR_TEXT = '网络异常,请稍后重试';
+
+/** 失败后需清空密码并聚焦的错误码(D5:40101 认证失败 / 42901 限流) */
+export const CLEAR_PASSWORD_CODES = new Set([40101, 42901]);
+
+export function resolveLoginErrorText(code: number): string {
+ return LOGIN_ERROR_MESSAGES[code] ?? NETWORK_ERROR_TEXT;
+}
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
new file mode 100644
index 0000000..5dc09a7
--- /dev/null
+++ b/frontend/src/router/index.tsx
@@ -0,0 +1,18 @@
+// REQ-USR-004: 路由表(FE 共享骨架)。/login → LoginPage;'/' 占位待 FE-02 落地。
+import { Routes, Route, Navigate } from 'react-router-dom';
+import LoginPage from '../pages/usr/Login/LoginPage';
+
+// 主页占位:FE-02 将替换为真实应用壳。本 REQ 仅需 '/' 可达(登录成功 navigate('/'))。
+function HomePlaceholder() {
+ return ;
+}
+
+export default function AppRouter() {
+ return (
+
+ } />
+ } />
+ } />
+
+ );
+}
diff --git a/frontend/src/store/hooks.ts b/frontend/src/store/hooks.ts
new file mode 100644
index 0000000..f9da029
--- /dev/null
+++ b/frontend/src/store/hooks.ts
@@ -0,0 +1,6 @@
+// REQ-USR-004: 类型化 Redux hooks(FE 共享骨架)
+import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
+import type { RootState, AppDispatch } from './store';
+
+export const useAppDispatch: () => AppDispatch = useDispatch;
+export const useAppSelector: TypedUseSelectorHook = useSelector;
diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts
new file mode 100644
index 0000000..3fcea59
--- /dev/null
+++ b/frontend/src/styles/theme.ts
@@ -0,0 +1,14 @@
+// REQ-USR-004: 把 Design Token --color-primary(tokens.css,SSoT)对齐到 AntD colorPrimary。
+// AntD 需要具体色值以派生主题色阶,这里在运行时读取 CSS 变量计算值,回退到 token 当前值。
+
+const FALLBACK_PRIMARY = '#1890ff'; // = tokens.css --color-primary 当前值(仅作读取失败兜底)
+
+export function readPrimaryColor(): string {
+ if (typeof document !== 'undefined' && document.documentElement) {
+ const v = getComputedStyle(document.documentElement)
+ .getPropertyValue('--color-primary')
+ .trim();
+ if (v) return v;
+ }
+ return FALLBACK_PRIMARY;
+}
diff --git a/frontend/tests/unit/LoginPage.layout.test.tsx b/frontend/tests/unit/LoginPage.layout.test.tsx
new file mode 100644
index 0000000..d4440ed
--- /dev/null
+++ b/frontend/tests/unit/LoginPage.layout.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+
+// 隔离网络:版本取数返回空列表,避免 act 警告与真实请求
+vi.mock('../../src/api/usrApi', () => ({
+ fetchCompanies: vi.fn().mockResolvedValue([]),
+ login: vi.fn(),
+}));
+
+import LoginPage from '../../src/pages/usr/Login/LoginPage';
+import { renderWithProviders } from './renderLogin';
+
+describe('LoginPage 布局', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('renders brand header / hero slogan / footer', () => {
+ renderWithProviders();
+ expect(screen.getByText('Antler ERP')).toBeInTheDocument();
+ expect(screen.getByText('欢迎登录EBC平台')).toBeInTheDocument();
+ expect(screen.getByText('企业业务能力平台')).toBeInTheDocument();
+ expect(screen.getByText('ERP')).toBeInTheDocument();
+ // 页脚版权 + 备案号
+ expect(screen.getByText(/Antler Software/)).toBeInTheDocument();
+ expect(screen.getByText(/沪ICP备14034791号-1/)).toBeInTheDocument();
+ });
+
+ it('renders login card title 用户登录', () => {
+ renderWithProviders();
+ expect(screen.getByText('用户登录')).toBeInTheDocument();
+ });
+
+ it('renders username/password/version fields and submit button 登 录', () => {
+ renderWithProviders();
+ const username = screen.getByPlaceholderText('请输入你的用户名');
+ expect(username).toBeInTheDocument();
+ const password = screen.getByPlaceholderText('请输入你的密码');
+ expect(password).toBeInTheDocument();
+ // BR3:密码掩码显示
+ expect(password).toHaveAttribute('type', 'password');
+ // 版本下拉 placeholder(idle 后为「请选择版本」,初始 loading 为「加载版本中…」)
+ expect(
+ screen.getByText((t) => t.includes('请选择版本') || t.includes('加载版本中')),
+ ).toBeInTheDocument();
+ // 提交按钮
+ expect(screen.getByRole('button', { name: /登\s*录/ })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/tests/unit/renderLogin.tsx b/frontend/tests/unit/renderLogin.tsx
new file mode 100644
index 0000000..b52c517
--- /dev/null
+++ b/frontend/tests/unit/renderLogin.tsx
@@ -0,0 +1,36 @@
+// REQ-USR-004: LoginPage 组件测试共享渲染工具(Provider + Router + AntD App 上下文)
+import type { ReactElement } from 'react';
+import { render } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { MemoryRouter } from 'react-router-dom';
+import { App as AntdApp, ConfigProvider } from 'antd';
+import { configureStore } from '@reduxjs/toolkit';
+import authReducer from '../../src/store/slices/authSlice';
+import type { RootState } from '../../src/store/store';
+
+export function makeStore() {
+ return configureStore({ reducer: { auth: authReducer } });
+}
+
+export function renderWithProviders(
+ ui: ReactElement,
+ options?: { store?: ReturnType; initialEntries?: string[] },
+) {
+ const store = options?.store ?? makeStore();
+ const result = render(
+
+
+
+
+ {ui}
+
+
+
+ ,
+ );
+ return {
+ ...result,
+ store,
+ getState: () => store.getState() as RootState,
+ };
+}