Commit fbe5371333daabd566cc685d140ba3ab228340ed

Authored by zichun
1 parent 3826bed7

feat(fe-login): 登录失败错误码分流文案与失败后处理 REQ-USR-004

frontend/src/pages/usr/Login/LoginPage.tsx
1 // REQ-USR-004: 登录页(复刻 prototype #screen-login 三段式布局 + 真实对接后端) 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'; 2 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  3 +import { Button, Form, Input, Select, App as AntdApp, type InputRef } from 'antd';
4 import { UserOutlined, LockOutlined } from '@ant-design/icons'; 4 import { UserOutlined, LockOutlined } from '@ant-design/icons';
5 import { useNavigate } from 'react-router-dom'; 5 import { useNavigate } from 'react-router-dom';
6 import { useAppDispatch } from '../../../store/hooks'; 6 import { useAppDispatch } from '../../../store/hooks';
@@ -34,6 +34,7 @@ export default function LoginPage() { @@ -34,6 +34,7 @@ export default function LoginPage() {
34 const dispatch = useAppDispatch(); 34 const dispatch = useAppDispatch();
35 const navigate = useNavigate(); 35 const navigate = useNavigate();
36 const { message } = AntdApp.useApp(); 36 const { message } = AntdApp.useApp();
  37 + const passwordRef = useRef<InputRef>(null);
37 38
38 const [companies, setCompanies] = useState<CompanyOption[]>([]); 39 const [companies, setCompanies] = useState<CompanyOption[]>([]);
39 const [companiesLoading, setCompaniesLoading] = useState(false); 40 const [companiesLoading, setCompaniesLoading] = useState(false);
@@ -85,7 +86,11 @@ export default function LoginPage() { @@ -85,7 +86,11 @@ export default function LoginPage() {
85 message.error(resolveLoginErrorText(code)); 86 message.error(resolveLoginErrorText(code));
86 if (CLEAR_PASSWORD_CODES.has(code)) { 87 if (CLEAR_PASSWORD_CODES.has(code)) {
87 form.setFieldValue('password', ''); 88 form.setFieldValue('password', '');
88 - form.getFieldInstance('password')?.focus?.(); 89 + // 字段在 submitting 期间禁用,待 submitting 复位后再聚焦密码框(D5)
  90 + setSubmitting(false);
  91 + // 等待禁用状态解除后聚焦,避免聚焦到 disabled 输入失败
  92 + setTimeout(() => passwordRef.current?.focus(), 0);
  93 + return;
89 } 94 }
90 } finally { 95 } finally {
91 setSubmitting(false); 96 setSubmitting(false);
@@ -141,6 +146,7 @@ export default function LoginPage() { @@ -141,6 +146,7 @@ export default function LoginPage() {
141 rules={[{ required: true, message: '请输入密码' }]} 146 rules={[{ required: true, message: '请输入密码' }]}
142 > 147 >
143 <Input.Password 148 <Input.Password
  149 + ref={passwordRef}
144 prefix={<LockOutlined />} 150 prefix={<LockOutlined />}
145 placeholder="请输入你的密码" 151 placeholder="请输入你的密码"
146 size="large" 152 size="large"
frontend/tests/unit/LoginPage.error.test.tsx 0 → 100644
  1 +import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 +import { screen, waitFor } from '@testing-library/react';
  3 +import userEvent from '@testing-library/user-event';
  4 +
  5 +const messageSpy = { success: vi.fn(), error: vi.fn() };
  6 +vi.mock('antd', async () => {
  7 + const actual = await vi.importActual<typeof import('antd')>('antd');
  8 + return {
  9 + ...actual,
  10 + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }),
  11 + };
  12 +});
  13 +
  14 +vi.mock('../../src/api/usrApi', () => ({
  15 + fetchCompanies: vi.fn(),
  16 + login: vi.fn(),
  17 +}));
  18 +
  19 +import { fetchCompanies, login } from '../../src/api/usrApi';
  20 +import LoginPage from '../../src/pages/usr/Login/LoginPage';
  21 +import { ApiError, NETWORK_ERROR_CODE } from '../../src/api/request';
  22 +import { renderWithProviders } from './renderLogin';
  23 +
  24 +const mockedFetch = fetchCompanies as unknown as ReturnType<typeof vi.fn>;
  25 +const mockedLogin = login as unknown as ReturnType<typeof vi.fn>;
  26 +
  27 +async function fillAndSubmit() {
  28 + const user = userEvent.setup();
  29 + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin');
  30 + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret');
  31 + await user.click(screen.getByRole('button', { name: /登\s*录/ }));
  32 + return user;
  33 +}
  34 +
  35 +describe('LoginPage 登录失败错误码分流', () => {
  36 + beforeEach(() => {
  37 + vi.clearAllMocks();
  38 + localStorage.clear();
  39 + mockedFetch.mockResolvedValue([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]);
  40 + });
  41 +
  42 + it('40101 shows 用户名或密码错误 and clears+focuses password', async () => {
  43 + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败'));
  44 + renderWithProviders(<LoginPage />);
  45 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  46 + await fillAndSubmit();
  47 + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('用户名或密码错误'));
  48 + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement;
  49 + await waitFor(() => expect(password.value).toBe(''));
  50 + expect(document.activeElement).toBe(password);
  51 + });
  52 +
  53 + it('40302 shows 该账号已被禁用,请联系管理员', async () => {
  54 + mockedLogin.mockRejectedValue(new ApiError(40302, '已禁用'));
  55 + renderWithProviders(<LoginPage />);
  56 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  57 + await fillAndSubmit();
  58 + await waitFor(() =>
  59 + expect(messageSpy.error).toHaveBeenCalledWith('该账号已被禁用,请联系管理员'),
  60 + );
  61 + });
  62 +
  63 + it('42901 shows 登录尝试过于频繁,请稍后再试 and clears password', async () => {
  64 + mockedLogin.mockRejectedValue(new ApiError(42901, '限流'));
  65 + renderWithProviders(<LoginPage />);
  66 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  67 + await fillAndSubmit();
  68 + await waitFor(() =>
  69 + expect(messageSpy.error).toHaveBeenCalledWith('登录尝试过于频繁,请稍后再试'),
  70 + );
  71 + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement;
  72 + await waitFor(() => expect(password.value).toBe(''));
  73 + });
  74 +
  75 + it('40001 shows 请填写用户名、密码并选择版本', async () => {
  76 + mockedLogin.mockRejectedValue(new ApiError(40001, '参数错误'));
  77 + renderWithProviders(<LoginPage />);
  78 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  79 + await fillAndSubmit();
  80 + await waitFor(() =>
  81 + expect(messageSpy.error).toHaveBeenCalledWith('请填写用户名、密码并选择版本'),
  82 + );
  83 + });
  84 +
  85 + it('network error shows 网络异常,请稍后重试', async () => {
  86 + mockedLogin.mockRejectedValue(new ApiError(NETWORK_ERROR_CODE, '网络异常'));
  87 + renderWithProviders(<LoginPage />);
  88 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  89 + await fillAndSubmit();
  90 + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('网络异常,请稍后重试'));
  91 + });
  92 +
  93 + it('button recovers clickable and username/version preserved after failure', async () => {
  94 + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败'));
  95 + renderWithProviders(<LoginPage />);
  96 + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument();
  97 + await fillAndSubmit();
  98 + await waitFor(() => expect(messageSpy.error).toHaveBeenCalled());
  99 + const submit = screen.getByRole('button', { name: /登\s*录/ });
  100 + await waitFor(() => expect(submit).not.toHaveClass('ant-btn-loading'));
  101 + expect(submit).not.toBeDisabled();
  102 + // 用户名保留
  103 + expect((screen.getByPlaceholderText('请输入你的用户名') as HTMLInputElement).value).toBe('admin');
  104 + // 版本保留(单项自动选中仍在)
  105 + expect(screen.getByText('甲公司(标准版)')).toBeInTheDocument();
  106 + });
  107 +});