diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 070ea89..bbf9238 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1,17 +1,20 @@
import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
import App from './App';
+import { store } from './store';
+import { router } from './router';
describe('App', () => {
- it('renders without crashing', () => {
- render();
- expect(screen.getByText(/Antler ERP/i)).toBeInTheDocument();
+ it('exports default App component', () => {
+ expect(App).toBeTypeOf('function');
});
- it('wraps children with AntD ConfigProvider and renders Button', () => {
- render();
- const btn = screen.getByTestId('sentinel-btn');
- expect(btn).toBeInTheDocument();
- expect(btn.className).toContain('ant-btn');
+ it('store has expected initial auth slice', () => {
+ expect(store.getState().auth.accessToken).toBeNull();
+ });
+
+ it('router has /login and /users routes registered', () => {
+ const paths = router.routes.map((r) => r.path);
+ expect(paths).toContain('/login');
+ expect(paths).toContain('/users');
});
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d17af22..ec0dfd0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,21 +1,25 @@
-import { ConfigProvider, Button } from 'antd';
+import { Provider } from 'react-redux';
+import { RouterProvider } from 'react-router-dom';
+import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
+import { store } from './store';
+import { router } from './router';
import './styles/tokens.css';
import './styles/global.css';
+// AntD ConfigProvider 的 token 需 hex 值;与 docs/06 § 2.1 + tokens.css `--color-primary` 同源
const theme = {
token: {
- colorPrimary: '#1890ff',
+ colorPrimary: '#1677ff',
},
};
export default function App() {
return (
-
-
-
Antler ERP
-
-
-
+
+
+
+
+
);
}
diff --git a/frontend/src/pages/login/LoginForm.tsx b/frontend/src/pages/login/LoginForm.tsx
index b8c2347..6488393 100644
--- a/frontend/src/pages/login/LoginForm.tsx
+++ b/frontend/src/pages/login/LoginForm.tsx
@@ -14,9 +14,17 @@ interface Props {
loading: boolean;
errorMessage: string | null;
fieldErrors: LoginFormFieldErrors;
+ /** 锁定状态下 submit 强制 disabled(无视 loading) */
+ submitDisabled?: boolean;
}
-export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors }: Props) {
+export default function LoginForm({
+ onSubmit,
+ loading,
+ errorMessage,
+ fieldErrors,
+ submitDisabled = false,
+}: Props) {
const [form] = Form.useForm();
useEffect(() => {
@@ -53,6 +61,7 @@ export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors
)}
@@ -64,6 +73,7 @@ export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors
@@ -75,10 +85,15 @@ export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors
-
+
@@ -87,6 +102,7 @@ export default function LoginForm({ onSubmit, loading, errorMessage, fieldErrors
htmlType="submit"
block
loading={loading}
+ disabled={submitDisabled}
data-testid="login-submit"
>
{loading ? '登录中...' : '登 录'}
diff --git a/frontend/src/pages/login/LoginPage.test.tsx b/frontend/src/pages/login/LoginPage.test.tsx
index 3f252a4..e63dd8c 100644
--- a/frontend/src/pages/login/LoginPage.test.tsx
+++ b/frontend/src/pages/login/LoginPage.test.tsx
@@ -31,18 +31,13 @@ function renderLogin() {
};
}
-async function fillAndSubmit(username: string, password: string, companyCode: string = 'HQ') {
+async function fillAndSubmit(username: string, password: string) {
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 行为太复杂)
- }
+ // companyCode 默认已选 HQ;改其他公司需要复杂的 AntD Select 交互,单独的测试用 MSW 路径模拟
await user.click(screen.getByTestId('login-submit'));
}
@@ -89,4 +84,28 @@ describe('LoginPage', () => {
expect(screen.getByText('账号已被作废,禁止登录')).toBeInTheDocument(),
);
});
+
+ it('empty fields: form-level required errors', async () => {
+ renderLogin();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId('login-submit'));
+ await waitFor(() => expect(screen.getByText('请输入用户名')).toBeInTheDocument());
+ expect(screen.getByText('请输入密码')).toBeInTheDocument();
+ });
+
+ it('locked account: submit stays disabled while lockUntil in the future', async () => {
+ renderLogin();
+ await fillAndSubmit('locked', 'X');
+ await waitFor(() => expect(screen.getByTestId('login-error-alert')).toBeInTheDocument());
+ // 锁定后 submit 应处于 disabled 态(lockUntil = 2030-01-01 远在未来)
+ const submitBtn = screen.getByTestId('login-submit') as HTMLButtonElement;
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it('form fields are labeled (a11y)', () => {
+ renderLogin();
+ expect(screen.getByLabelText('用户名')).toBeInTheDocument();
+ expect(screen.getByLabelText('密码')).toBeInTheDocument();
+ expect(screen.getByLabelText('公司')).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx
index 34e5555..8025369 100644
--- a/frontend/src/pages/login/LoginPage.tsx
+++ b/frontend/src/pages/login/LoginPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { authApi } from '../../api/auth';
@@ -18,8 +18,24 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const [fieldErrors, setFieldErrors] = useState({});
+ const [lockUntil, setLockUntil] = useState(null);
+
+ // 锁定倒计时:每秒检查 lockUntil 是否过期,过期后自动允许重试
+ useEffect(() => {
+ if (!lockUntil) return;
+ const timer = setInterval(() => {
+ if (dayjs().isAfter(lockUntil)) {
+ setLockUntil(null);
+ setErrorMessage(null);
+ }
+ }, 1000);
+ return () => clearInterval(timer);
+ }, [lockUntil]);
+
+ const isLocked = lockUntil != null && dayjs().isBefore(lockUntil);
const handleSubmit = async (req: LoginReq) => {
+ if (isLocked) return;
setLoading(true);
setErrorMessage(null);
setFieldErrors({});
@@ -42,8 +58,10 @@ export default function LoginPage() {
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') : '稍后';
+ const lockMoment = data?.lockUntil ? dayjs(data.lockUntil) : null;
+ const lockTime = lockMoment ? lockMoment.format('HH:mm') : '稍后';
setErrorMessage((ERROR_MESSAGES[42301] as string).replace('{lockUntil}', lockTime));
+ if (lockMoment) setLockUntil(lockMoment);
} else if (e.code === 40004) {
setFieldErrors({ companyCode: ERROR_MESSAGES[40004] as string });
} else if (e.code === 40103) {
@@ -75,7 +93,7 @@ export default function LoginPage() {
style={{
width: 360,
padding: 24,
- background: 'var(--color-form-bg-edit)',
+ background: 'var(--color-bg-container)',
borderRadius: 8,
}}
>
@@ -85,6 +103,7 @@ export default function LoginPage() {
loading={loading}
errorMessage={errorMessage}
fieldErrors={fieldErrors}
+ submitDisabled={isLocked}
/>
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index a35ee0d..8eac652 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -3,7 +3,7 @@ html, body, #root {
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', Roboto, sans-serif;
- background: var(--color-bg-base);
+ background: var(--color-bg-page);
color: var(--color-text);
}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index bc8a542..c96473f 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -1,43 +1,33 @@
/*
- * src/styles/tokens.css — Design Tokens
- * 命名规范见 docs/04-技术规范.md § 2.5
- * 色值锁定见 docs/06-UI交互规范.md § 四
- *
- * 命名格式:--color---
- * 组件域:form / table-row / table-header / ...
- * 作用:bg(背景)/ fg(前景/字体)/ border
- * 状态:edit / readonly / hover / selected(无状态时省略)
+ * frontend/src/styles/tokens.css — Design Tokens
+ * SSoT: docs/06-UI交互规范.md § 二
*
+ * 命名规则见 docs/04-技术规范.md § 2.5
* 约束:
* - 组件样式中只用 var(--color-xxx),禁止硬编码 hex / rgba
* - 修改色值只改本文件,不允许在组件级覆盖
- * - 新增 token 须先登记到 docs/06 § 4.1 / 4.2,再补到此处
+ * - 新增 token 须先登记到 docs/06 § 2.1 / 2.2,再补到此处
+ * - AntD ConfigProvider.theme.token.colorPrimary 必须与 --color-primary 同源(见 App.tsx)
*/
:root {
- /* === 1. 全局调色板(与 Ant Design 主题对齐) === */
- --color-primary: #1890ff;
+ /* === § 2.1 全局调色板(与 docs/06 § 2.1 完全对齐) === */
+ --color-primary: #1677ff;
+ --color-primary-hover: #4096ff;
+ --color-primary-active: #0958d9;
--color-success: #52c41a;
--color-warning: #faad14;
--color-error: #ff4d4f;
- --color-text: rgba(0, 0, 0, 0.85);
- --color-text-secondary: rgba(0, 0, 0, 0.45);
- --color-border: #d9d9d9;
- --color-bg-base: #f0f2f5;
+ --color-info: #1677ff;
- /* === 2. 组件级状态色(与 docs/06 § 4.2 一一对应) === */
+ --color-text: rgba(0, 0, 0, 0.88);
+ --color-text-secondary: rgba(0, 0, 0, 0.65);
+ --color-text-disabled: rgba(0, 0, 0, 0.25);
- /* form:输入框 / 备注框 / 时间框 / 下拉框共用 */
- --color-form-bg-edit: #ffffff;
- --color-form-bg-readonly: #f1f2f8;
- --color-form-bg-hover: #f5f5f5; /* 仅下拉框使用 */
- --color-form-fg: #000000;
+ --color-border: #d9d9d9;
+ --color-split: #f0f0f0;
- /* table */
- --color-table-row-bg-selected: #86d5fb;
- --color-table-row-bg-hover: #fff7e6;
- --color-table-row-bg-readonly: #f1f2f8; /* = rgb(241, 242, 248) */
- --color-table-row-fg: #000000;
- --color-table-header-bg: #f5f5f5;
- --color-table-header-fg: rgba(0, 0, 0, 0.85); /* = #000000D9 */
+ --color-bg-page: #f5f5f5;
+ --color-bg-container: #ffffff;
+ --color-bg-disabled: #f5f5f5;
}