Commit d90e520af9cc8c76c66f9f6f7023ca9068ba2894

Authored by zichun
1 parent 8e6cce3f

feat(fe-login): 登录页三段式布局与区域结构 REQ-USR-004

frontend/src/App.tsx
1 -// FE-01: 应用根组件(骨架占位,T4 接入 Provider + Router + ConfigProvider) 1 +// REQ-USR-004: 应用根(Provider + Router + AntD ConfigProvider + App 上下文)
  2 +import { ConfigProvider, App as AntdApp } from 'antd';
  3 +import zhCN from 'antd/locale/zh_CN';
  4 +import { Provider } from 'react-redux';
  5 +import { BrowserRouter } from 'react-router-dom';
  6 +import { store } from './store/store';
  7 +import AppRouter from './router';
  8 +import { readPrimaryColor } from './styles/theme';
  9 +
2 export default function App() { 10 export default function App() {
3 - return <div id="app-root" />; 11 + return (
  12 + <Provider store={store}>
  13 + <ConfigProvider
  14 + locale={zhCN}
  15 + theme={{ token: { colorPrimary: readPrimaryColor() } }}
  16 + >
  17 + <AntdApp>
  18 + <BrowserRouter>
  19 + <AppRouter />
  20 + </BrowserRouter>
  21 + </AntdApp>
  22 + </ConfigProvider>
  23 + </Provider>
  24 + );
4 } 25 }
frontend/src/pages/usr/Login/Login.module.css 0 → 100644
  1 +/*
  2 + * REQ-USR-004 登录页 scoped 样式。
  3 + * 语义色(按钮/文字/边框/背景/错误)一律 var(--color-*);
  4 + * 主视觉深蓝渐变 / 网格透视为登录页局部装饰(D7),不挪用语义 token、不新增全局 token。
  5 + */
  6 +
  7 +.wrap {
  8 + position: absolute;
  9 + inset: 0;
  10 + background: var(--color-bg-base);
  11 + display: flex;
  12 + flex-direction: column;
  13 +}
  14 +
  15 +/* 品牌头 */
  16 +.head {
  17 + display: flex;
  18 + align-items: center;
  19 + gap: 12px;
  20 + padding: 18px 36px;
  21 + background: var(--color-bg-base);
  22 +}
  23 +
  24 +.logo {
  25 + width: 42px;
  26 + height: 42px;
  27 + display: flex;
  28 + align-items: center;
  29 + justify-content: center;
  30 +}
  31 +
  32 +.brandName {
  33 + font-size: 24px;
  34 + font-weight: 700;
  35 + letter-spacing: 2px;
  36 + color: var(--color-primary);
  37 +}
  38 +
  39 +.brandSub {
  40 + color: var(--color-text);
  41 + font-size: 14px;
  42 + margin-left: 6px;
  43 +}
  44 +
  45 +/* 主视觉(深蓝装饰,D7 scoped,不使用语义 token) */
  46 +.hero {
  47 + flex: 1;
  48 + position: relative;
  49 + overflow: hidden;
  50 + background: radial-gradient(
  51 + ellipse at center,
  52 + #1a4ea0 0%,
  53 + #0a1d44 60%,
  54 + #050d20 100%
  55 + );
  56 +}
  57 +
  58 +.hero::before {
  59 + content: '';
  60 + position: absolute;
  61 + inset: 0;
  62 + background-image: linear-gradient(rgba(80, 160, 255, 0.18) 1px, transparent 1px),
  63 + linear-gradient(90deg, rgba(80, 160, 255, 0.18) 1px, transparent 1px);
  64 + background-size: 80px 80px;
  65 + transform: perspective(800px) rotateX(55deg) translateY(20%);
  66 + transform-origin: center;
  67 + opacity: 0.55;
  68 +}
  69 +
  70 +.hero::after {
  71 + content: '';
  72 + position: absolute;
  73 + inset: 0;
  74 + background: radial-gradient(
  75 + ellipse 800px 300px at 50% 50%,
  76 + rgba(140, 200, 255, 0.35),
  77 + transparent 60%
  78 + ),
  79 + radial-gradient(circle 200px at 30% 40%, rgba(255, 255, 255, 0.15), transparent 70%),
  80 + radial-gradient(circle 160px at 70% 60%, rgba(255, 255, 255, 0.12), transparent 70%);
  81 +}
  82 +
  83 +.heroText {
  84 + position: absolute;
  85 + left: 8%;
  86 + top: 35%;
  87 + color: #fff;
  88 + z-index: 2;
  89 +}
  90 +
  91 +.heroEn {
  92 + font-size: 30px;
  93 + font-weight: 300;
  94 + letter-spacing: 1px;
  95 + color: #cfe1ff;
  96 + margin-bottom: 6px;
  97 +}
  98 +
  99 +.heroZh {
  100 + font-size: 54px;
  101 + font-weight: 700;
  102 + color: #fff;
  103 + letter-spacing: 4px;
  104 + margin-bottom: 4px;
  105 +}
  106 +
  107 +.heroErp {
  108 + font-size: 90px;
  109 + font-weight: 800;
  110 + color: #fff;
  111 + letter-spacing: 8px;
  112 + line-height: 0.9;
  113 +}
  114 +
  115 +/* 右侧浮层登录卡 */
  116 +.card {
  117 + position: absolute;
  118 + right: 8%;
  119 + top: 50%;
  120 + transform: translateY(-50%);
  121 + background: var(--color-form-bg-edit);
  122 + width: 380px;
  123 + padding: 36px 32px;
  124 + border-radius: 2px;
  125 + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
  126 + z-index: 3;
  127 +}
  128 +
  129 +.cardTitle {
  130 + margin: 0 0 22px;
  131 + font-size: 18px;
  132 + color: var(--color-text);
  133 + font-weight: 500;
  134 +}
  135 +
  136 +.submitBtn {
  137 + letter-spacing: 8px;
  138 +}
  139 +
  140 +.emptyHint {
  141 + margin-top: -6px;
  142 + margin-bottom: 12px;
  143 + color: var(--color-text-secondary);
  144 + font-size: 12px;
  145 +}
  146 +
  147 +.retryBox {
  148 + margin-top: -6px;
  149 + margin-bottom: 12px;
  150 + color: var(--color-error);
  151 + font-size: 12px;
  152 + display: flex;
  153 + align-items: center;
  154 + gap: 8px;
  155 +}
  156 +
  157 +/* 页脚版权 */
  158 +.foot {
  159 + background: var(--color-bg-base);
  160 + text-align: center;
  161 + padding: 14px 8px;
  162 + color: var(--color-text-secondary);
  163 + font-size: 12px;
  164 + border-top: 1px solid var(--color-border);
  165 +}
  166 +
  167 +.footIcp {
  168 + display: inline-flex;
  169 + align-items: center;
  170 + gap: 4px;
  171 + margin-left: 6px;
  172 +}
  173 +
  174 +/* 响应式回流:窄视口卡片居中(D4 默认行为) */
  175 +@media (max-width: 768px) {
  176 + .card {
  177 + position: static;
  178 + transform: none;
  179 + margin: 24px auto;
  180 + width: calc(100% - 48px);
  181 + max-width: 380px;
  182 + }
  183 + .heroText {
  184 + position: static;
  185 + padding: 24px 8%;
  186 + }
  187 +}
frontend/src/pages/usr/Login/LoginPage.tsx 0 → 100644
  1 +// REQ-USR-004: 登录页(复刻 prototype #screen-login 三段式布局 + 真实对接后端)
  2 +import { useCallback, useEffect, useMemo, useState } from 'react';
  3 +import { Button, Form, Input, Select, App as AntdApp } from 'antd';
  4 +import { UserOutlined, LockOutlined } from '@ant-design/icons';
  5 +import { useNavigate } from 'react-router-dom';
  6 +import { useAppDispatch } from '../../../store/hooks';
  7 +import { setCredentials } from '../../../store/slices/authSlice';
  8 +import { login, fetchCompanies } from '../../../api/usrApi';
  9 +import type { CompanyOption, LoginPayload } from '../../../api/types';
  10 +import { ApiError } from '../../../api/request';
  11 +import {
  12 + LOGIN_SUCCESS_TEXT,
  13 + resolveLoginErrorText,
  14 + CLEAR_PASSWORD_CODES,
  15 +} from './loginMessages';
  16 +import styles from './Login.module.css';
  17 +
  18 +interface LoginFormValues {
  19 + sUserName: string;
  20 + password: string;
  21 + companyId: number;
  22 +}
  23 +
  24 +// 版本下拉 label 规则(D8):有 sVersion → 「公司名(版本)」,否则仅公司名;value 恒取 id
  25 +function toOption(c: CompanyOption) {
  26 + return {
  27 + value: c.id,
  28 + label: c.sVersion ? `${c.sCompanyName}(${c.sVersion})` : c.sCompanyName,
  29 + };
  30 +}
  31 +
  32 +export default function LoginPage() {
  33 + const [form] = Form.useForm<LoginFormValues>();
  34 + const dispatch = useAppDispatch();
  35 + const navigate = useNavigate();
  36 + const { message } = AntdApp.useApp();
  37 +
  38 + const [companies, setCompanies] = useState<CompanyOption[]>([]);
  39 + const [companiesLoading, setCompaniesLoading] = useState(false);
  40 + const [companiesError, setCompaniesError] = useState(false);
  41 + const [submitting, setSubmitting] = useState(false);
  42 +
  43 + const loadCompanies = useCallback(async () => {
  44 + setCompaniesLoading(true);
  45 + setCompaniesError(false);
  46 + try {
  47 + const list = await fetchCompanies();
  48 + setCompanies(list);
  49 + // 仅一项时默认选中(spec § 6.3)
  50 + if (list.length === 1) {
  51 + form.setFieldValue('companyId', list[0].id);
  52 + }
  53 + } catch {
  54 + setCompaniesError(true);
  55 + setCompanies([]);
  56 + message.error('版本加载失败');
  57 + } finally {
  58 + setCompaniesLoading(false);
  59 + }
  60 + }, [form, message]);
  61 +
  62 + // 页面挂载即预加载版本下拉(spec § 3 companiesLoading)
  63 + useEffect(() => {
  64 + void loadCompanies();
  65 + }, [loadCompanies]);
  66 +
  67 + const options = useMemo(() => companies.map(toOption), [companies]);
  68 + const isEmpty = !companiesLoading && !companiesError && companies.length === 0;
  69 +
  70 + const handleFinish = async (values: LoginFormValues) => {
  71 + if (submitting) return; // 防重复提交(BR10)
  72 + setSubmitting(true);
  73 + const payload: LoginPayload = {
  74 + sUserName: values.sUserName,
  75 + password: values.password,
  76 + companyId: values.companyId,
  77 + };
  78 + try {
  79 + const { token, user } = await login(payload);
  80 + dispatch(setCredentials({ token, user }));
  81 + message.success(LOGIN_SUCCESS_TEXT);
  82 + navigate('/', { replace: true });
  83 + } catch (err) {
  84 + const code = err instanceof ApiError ? err.code : -1;
  85 + message.error(resolveLoginErrorText(code));
  86 + if (CLEAR_PASSWORD_CODES.has(code)) {
  87 + form.setFieldValue('password', '');
  88 + form.getFieldInstance('password')?.focus?.();
  89 + }
  90 + } finally {
  91 + setSubmitting(false);
  92 + }
  93 + };
  94 +
  95 + const versionPlaceholder = companiesLoading ? '加载版本中…' : '请选择版本';
  96 +
  97 + return (
  98 + <div className={styles.wrap}>
  99 + <div className={styles.head}>
  100 + <span className={styles.logo}>
  101 + <svg viewBox="0 0 64 64" width="42" height="42" fill="#0e1216" aria-hidden="true">
  102 + <path d="M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z" />
  103 + <path d="M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z" />
  104 + <path d="M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z" />
  105 + </svg>
  106 + </span>
  107 + <span className={styles.brandName}>Antler ERP</span>
  108 + <span className={styles.brandSub}>欢迎登录EBC平台</span>
  109 + </div>
  110 +
  111 + <div className={styles.hero}>
  112 + <div className={styles.heroText}>
  113 + <div className={styles.heroEn}>Enterprise Business Capability</div>
  114 + <div className={styles.heroZh}>企业业务能力平台</div>
  115 + <div className={styles.heroErp}>ERP</div>
  116 + </div>
  117 +
  118 + <div className={styles.card}>
  119 + <h3 className={styles.cardTitle}>用户登录</h3>
  120 + <Form<LoginFormValues>
  121 + form={form}
  122 + onFinish={handleFinish}
  123 + layout="vertical"
  124 + requiredMark={false}
  125 + disabled={submitting}
  126 + >
  127 + <Form.Item
  128 + name="sUserName"
  129 + rules={[{ required: true, message: '请输入用户名' }]}
  130 + >
  131 + <Input
  132 + prefix={<UserOutlined />}
  133 + placeholder="请输入你的用户名"
  134 + size="large"
  135 + autoComplete="username"
  136 + />
  137 + </Form.Item>
  138 +
  139 + <Form.Item
  140 + name="password"
  141 + rules={[{ required: true, message: '请输入密码' }]}
  142 + >
  143 + <Input.Password
  144 + prefix={<LockOutlined />}
  145 + placeholder="请输入你的密码"
  146 + size="large"
  147 + autoComplete="current-password"
  148 + />
  149 + </Form.Item>
  150 +
  151 + <Form.Item
  152 + name="companyId"
  153 + rules={[{ required: true, message: '请选择版本' }]}
  154 + >
  155 + <Select
  156 + placeholder={versionPlaceholder}
  157 + size="large"
  158 + loading={companiesLoading}
  159 + disabled={companiesLoading}
  160 + options={options}
  161 + notFoundContent={isEmpty ? '暂无可用版本' : undefined}
  162 + />
  163 + </Form.Item>
  164 +
  165 + {isEmpty && (
  166 + <div className={styles.emptyHint}>未获取到可登录版本,请联系管理员</div>
  167 + )}
  168 +
  169 + {companiesError && (
  170 + <div className={styles.retryBox}>
  171 + <span>版本加载失败</span>
  172 + <Button
  173 + type="link"
  174 + size="small"
  175 + disabled={false}
  176 + onClick={() => void loadCompanies()}
  177 + >
  178 + 点击重试
  179 + </Button>
  180 + </div>
  181 + )}
  182 +
  183 + <Form.Item>
  184 + <Button
  185 + type="primary"
  186 + htmlType="submit"
  187 + block
  188 + size="large"
  189 + loading={submitting}
  190 + className={styles.submitBtn}
  191 + >
  192 + 登 录
  193 + </Button>
  194 + </Form.Item>
  195 + </Form>
  196 + </div>
  197 + </div>
  198 +
  199 + <div className={styles.foot}>
  200 + 🛠 ©Copyright Antler Software | 印刷智慧工厂 | 印刷MES | 印刷ERP | 印刷电商平台 |
  201 + 文件智能处理 | 印前自动化 | 400-880-6237
  202 + <span className={styles.footIcp}>
  203 + <svg width="14" height="14" viewBox="0 0 24 24" fill="#3a6cb6" aria-hidden="true">
  204 + <path d="M12 2l9 4v6c0 5-4 9-9 10-5-1-9-5-9-10V6z" />
  205 + </svg>
  206 + 沪ICP备14034791号-1
  207 + </span>
  208 + </div>
  209 + </div>
  210 + );
  211 +}
frontend/src/pages/usr/Login/loginMessages.ts 0 → 100644
  1 +// REQ-USR-004: 登录错误码 → 前端文案映射(spec § 4 / docs/05;严格沿用,不细化 40101)
  2 +import { NETWORK_ERROR_CODE } from '../../../api/request';
  3 +
  4 +export const LOGIN_SUCCESS_TEXT = '登录成功';
  5 +
  6 +/** 登录失败错误码文案表;未命中走网络异常兜底文案 */
  7 +export const LOGIN_ERROR_MESSAGES: Record<number, string> = {
  8 + 40001: '请填写用户名、密码并选择版本',
  9 + 40101: '用户名或密码错误',
  10 + 40302: '该账号已被禁用,请联系管理员',
  11 + 42901: '登录尝试过于频繁,请稍后再试',
  12 + [NETWORK_ERROR_CODE]: '网络异常,请稍后重试',
  13 +};
  14 +
  15 +/** 网络异常 / 未命中错误码的兜底文案 */
  16 +export const NETWORK_ERROR_TEXT = '网络异常,请稍后重试';
  17 +
  18 +/** 失败后需清空密码并聚焦的错误码(D5:40101 认证失败 / 42901 限流) */
  19 +export const CLEAR_PASSWORD_CODES = new Set<number>([40101, 42901]);
  20 +
  21 +export function resolveLoginErrorText(code: number): string {
  22 + return LOGIN_ERROR_MESSAGES[code] ?? NETWORK_ERROR_TEXT;
  23 +}
frontend/src/router/index.tsx 0 → 100644
  1 +// REQ-USR-004: 路由表(FE 共享骨架)。/login → LoginPage;'/' 占位待 FE-02 落地。
  2 +import { Routes, Route, Navigate } from 'react-router-dom';
  3 +import LoginPage from '../pages/usr/Login/LoginPage';
  4 +
  5 +// 主页占位:FE-02 将替换为真实应用壳。本 REQ 仅需 '/' 可达(登录成功 navigate('/'))。
  6 +function HomePlaceholder() {
  7 + return <div id="home-placeholder" />;
  8 +}
  9 +
  10 +export default function AppRouter() {
  11 + return (
  12 + <Routes>
  13 + <Route path="/login" element={<LoginPage />} />
  14 + <Route path="/" element={<HomePlaceholder />} />
  15 + <Route path="*" element={<Navigate to="/login" replace />} />
  16 + </Routes>
  17 + );
  18 +}
frontend/src/store/hooks.ts 0 → 100644
  1 +// REQ-USR-004: 类型化 Redux hooks(FE 共享骨架)
  2 +import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
  3 +import type { RootState, AppDispatch } from './store';
  4 +
  5 +export const useAppDispatch: () => AppDispatch = useDispatch;
  6 +export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
frontend/src/styles/theme.ts 0 → 100644
  1 +// REQ-USR-004: 把 Design Token --color-primary(tokens.css,SSoT)对齐到 AntD colorPrimary。
  2 +// AntD 需要具体色值以派生主题色阶,这里在运行时读取 CSS 变量计算值,回退到 token 当前值。
  3 +
  4 +const FALLBACK_PRIMARY = '#1890ff'; // = tokens.css --color-primary 当前值(仅作读取失败兜底)
  5 +
  6 +export function readPrimaryColor(): string {
  7 + if (typeof document !== 'undefined' && document.documentElement) {
  8 + const v = getComputedStyle(document.documentElement)
  9 + .getPropertyValue('--color-primary')
  10 + .trim();
  11 + if (v) return v;
  12 + }
  13 + return FALLBACK_PRIMARY;
  14 +}
frontend/tests/unit/LoginPage.layout.test.tsx 0 → 100644
  1 +import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 +import { screen } from '@testing-library/react';
  3 +
  4 +// 隔离网络:版本取数返回空列表,避免 act 警告与真实请求
  5 +vi.mock('../../src/api/usrApi', () => ({
  6 + fetchCompanies: vi.fn().mockResolvedValue([]),
  7 + login: vi.fn(),
  8 +}));
  9 +
  10 +import LoginPage from '../../src/pages/usr/Login/LoginPage';
  11 +import { renderWithProviders } from './renderLogin';
  12 +
  13 +describe('LoginPage 布局', () => {
  14 + beforeEach(() => {
  15 + localStorage.clear();
  16 + });
  17 +
  18 + it('renders brand header / hero slogan / footer', () => {
  19 + renderWithProviders(<LoginPage />);
  20 + expect(screen.getByText('Antler ERP')).toBeInTheDocument();
  21 + expect(screen.getByText('欢迎登录EBC平台')).toBeInTheDocument();
  22 + expect(screen.getByText('企业业务能力平台')).toBeInTheDocument();
  23 + expect(screen.getByText('ERP')).toBeInTheDocument();
  24 + // 页脚版权 + 备案号
  25 + expect(screen.getByText(/Antler Software/)).toBeInTheDocument();
  26 + expect(screen.getByText(/沪ICP备14034791号-1/)).toBeInTheDocument();
  27 + });
  28 +
  29 + it('renders login card title 用户登录', () => {
  30 + renderWithProviders(<LoginPage />);
  31 + expect(screen.getByText('用户登录')).toBeInTheDocument();
  32 + });
  33 +
  34 + it('renders username/password/version fields and submit button 登 录', () => {
  35 + renderWithProviders(<LoginPage />);
  36 + const username = screen.getByPlaceholderText('请输入你的用户名');
  37 + expect(username).toBeInTheDocument();
  38 + const password = screen.getByPlaceholderText('请输入你的密码');
  39 + expect(password).toBeInTheDocument();
  40 + // BR3:密码掩码显示
  41 + expect(password).toHaveAttribute('type', 'password');
  42 + // 版本下拉 placeholder(idle 后为「请选择版本」,初始 loading 为「加载版本中…」)
  43 + expect(
  44 + screen.getByText((t) => t.includes('请选择版本') || t.includes('加载版本中')),
  45 + ).toBeInTheDocument();
  46 + // 提交按钮
  47 + expect(screen.getByRole('button', { name: /登\s*录/ })).toBeInTheDocument();
  48 + });
  49 +});
frontend/tests/unit/renderLogin.tsx 0 → 100644
  1 +// REQ-USR-004: LoginPage 组件测试共享渲染工具(Provider + Router + AntD App 上下文)
  2 +import type { ReactElement } from 'react';
  3 +import { render } from '@testing-library/react';
  4 +import { Provider } from 'react-redux';
  5 +import { MemoryRouter } from 'react-router-dom';
  6 +import { App as AntdApp, ConfigProvider } from 'antd';
  7 +import { configureStore } from '@reduxjs/toolkit';
  8 +import authReducer from '../../src/store/slices/authSlice';
  9 +import type { RootState } from '../../src/store/store';
  10 +
  11 +export function makeStore() {
  12 + return configureStore({ reducer: { auth: authReducer } });
  13 +}
  14 +
  15 +export function renderWithProviders(
  16 + ui: ReactElement,
  17 + options?: { store?: ReturnType<typeof makeStore>; initialEntries?: string[] },
  18 +) {
  19 + const store = options?.store ?? makeStore();
  20 + const result = render(
  21 + <Provider store={store}>
  22 + <ConfigProvider>
  23 + <AntdApp>
  24 + <MemoryRouter initialEntries={options?.initialEntries ?? ['/login']}>
  25 + {ui}
  26 + </MemoryRouter>
  27 + </AntdApp>
  28 + </ConfigProvider>
  29 + </Provider>,
  30 + );
  31 + return {
  32 + ...result,
  33 + store,
  34 + getState: () => store.getState() as RootState,
  35 + };
  36 +}