From d90e520af9cc8c76c66f9f6f7023ca9068ba2894 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 16:23:56 +0800 Subject: [PATCH] feat(fe-login): 登录页三段式布局与区域结构 REQ-USR-004 --- frontend/src/App.tsx | 25 +++++++++++++++++++++++-- frontend/src/pages/usr/Login/Login.module.css | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/Login/LoginPage.tsx | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/Login/loginMessages.ts | 23 +++++++++++++++++++++++ frontend/src/router/index.tsx | 18 ++++++++++++++++++ frontend/src/store/hooks.ts | 6 ++++++ frontend/src/styles/theme.ts | 14 ++++++++++++++ frontend/tests/unit/LoginPage.layout.test.tsx | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/renderLogin.tsx | 36 ++++++++++++++++++++++++++++++++++++ 9 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/usr/Login/Login.module.css create mode 100644 frontend/src/pages/usr/Login/LoginPage.tsx create mode 100644 frontend/src/pages/usr/Login/loginMessages.ts create mode 100644 frontend/src/router/index.tsx create mode 100644 frontend/src/store/hooks.ts create mode 100644 frontend/src/styles/theme.ts create mode 100644 frontend/tests/unit/LoginPage.layout.test.tsx create mode 100644 frontend/tests/unit/renderLogin.tsx 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
+
+ +
+

用户登录

+ + form={form} + onFinish={handleFinish} + layout="vertical" + requiredMark={false} + disabled={submitting} + > + + } + placeholder="请输入你的用户名" + size="large" + autoComplete="username" + /> + + + + } + placeholder="请输入你的密码" + size="large" + autoComplete="current-password" + /> + + + +