Commit d90e520af9cc8c76c66f9f6f7023ca9068ba2894
1 parent
8e6cce3f
feat(fe-login): 登录页三段式布局与区域结构 REQ-USR-004
Showing
9 changed files
with
567 additions
and
2 deletions
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 | 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 | +} | ... | ... |