From fbe5371333daabd566cc685d140ba3ab228340ed Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 16:30:30 +0800 Subject: [PATCH] feat(fe-login): 登录失败错误码分流文案与失败后处理 REQ-USR-004 --- frontend/src/pages/usr/Login/LoginPage.tsx | 12 +++++++++--- frontend/tests/unit/LoginPage.error.test.tsx | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/unit/LoginPage.error.test.tsx diff --git a/frontend/src/pages/usr/Login/LoginPage.tsx b/frontend/src/pages/usr/Login/LoginPage.tsx index 121bd51..65584ab 100644 --- a/frontend/src/pages/usr/Login/LoginPage.tsx +++ b/frontend/src/pages/usr/Login/LoginPage.tsx @@ -1,6 +1,6 @@ // REQ-USR-004: 登录页(复刻 prototype #screen-login 三段式布局 + 真实对接后端) -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Form, Input, Select, App as AntdApp } from 'antd'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Form, Input, Select, App as AntdApp, type InputRef } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { useAppDispatch } from '../../../store/hooks'; @@ -34,6 +34,7 @@ export default function LoginPage() { const dispatch = useAppDispatch(); const navigate = useNavigate(); const { message } = AntdApp.useApp(); + const passwordRef = useRef(null); const [companies, setCompanies] = useState([]); const [companiesLoading, setCompaniesLoading] = useState(false); @@ -85,7 +86,11 @@ export default function LoginPage() { message.error(resolveLoginErrorText(code)); if (CLEAR_PASSWORD_CODES.has(code)) { form.setFieldValue('password', ''); - form.getFieldInstance('password')?.focus?.(); + // 字段在 submitting 期间禁用,待 submitting 复位后再聚焦密码框(D5) + setSubmitting(false); + // 等待禁用状态解除后聚焦,避免聚焦到 disabled 输入失败 + setTimeout(() => passwordRef.current?.focus(), 0); + return; } } finally { setSubmitting(false); @@ -141,6 +146,7 @@ export default function LoginPage() { rules={[{ required: true, message: '请输入密码' }]} > } placeholder="请输入你的密码" size="large" diff --git a/frontend/tests/unit/LoginPage.error.test.tsx b/frontend/tests/unit/LoginPage.error.test.tsx new file mode 100644 index 0000000..0707529 --- /dev/null +++ b/frontend/tests/unit/LoginPage.error.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const messageSpy = { success: vi.fn(), error: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ + fetchCompanies: vi.fn(), + login: vi.fn(), +})); + +import { fetchCompanies, login } from '../../src/api/usrApi'; +import LoginPage from '../../src/pages/usr/Login/LoginPage'; +import { ApiError, NETWORK_ERROR_CODE } from '../../src/api/request'; +import { renderWithProviders } from './renderLogin'; + +const mockedFetch = fetchCompanies as unknown as ReturnType; +const mockedLogin = login as unknown as ReturnType; + +async function fillAndSubmit() { + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('请输入你的用户名'), 'admin'); + await user.type(screen.getByPlaceholderText('请输入你的密码'), 'secret'); + await user.click(screen.getByRole('button', { name: /登\s*录/ })); + return user; +} + +describe('LoginPage 登录失败错误码分流', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockedFetch.mockResolvedValue([{ id: 1, sCompanyName: '甲公司', sVersion: '标准版' }]); + }); + + it('40101 shows 用户名或密码错误 and clears+focuses password', async () => { + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('用户名或密码错误')); + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement; + await waitFor(() => expect(password.value).toBe('')); + expect(document.activeElement).toBe(password); + }); + + it('40302 shows 该账号已被禁用,请联系管理员', async () => { + mockedLogin.mockRejectedValue(new ApiError(40302, '已禁用')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('该账号已被禁用,请联系管理员'), + ); + }); + + it('42901 shows 登录尝试过于频繁,请稍后再试 and clears password', async () => { + mockedLogin.mockRejectedValue(new ApiError(42901, '限流')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('登录尝试过于频繁,请稍后再试'), + ); + const password = screen.getByPlaceholderText('请输入你的密码') as HTMLInputElement; + await waitFor(() => expect(password.value).toBe('')); + }); + + it('40001 shows 请填写用户名、密码并选择版本', async () => { + mockedLogin.mockRejectedValue(new ApiError(40001, '参数错误')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => + expect(messageSpy.error).toHaveBeenCalledWith('请填写用户名、密码并选择版本'), + ); + }); + + it('network error shows 网络异常,请稍后重试', async () => { + mockedLogin.mockRejectedValue(new ApiError(NETWORK_ERROR_CODE, '网络异常')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalledWith('网络异常,请稍后重试')); + }); + + it('button recovers clickable and username/version preserved after failure', async () => { + mockedLogin.mockRejectedValue(new ApiError(40101, '认证失败')); + renderWithProviders(); + expect(await screen.findByText('甲公司(标准版)')).toBeInTheDocument(); + await fillAndSubmit(); + await waitFor(() => expect(messageSpy.error).toHaveBeenCalled()); + const submit = screen.getByRole('button', { name: /登\s*录/ }); + await waitFor(() => expect(submit).not.toHaveClass('ant-btn-loading')); + expect(submit).not.toBeDisabled(); + // 用户名保留 + expect((screen.getByPlaceholderText('请输入你的用户名') as HTMLInputElement).value).toBe('admin'); + // 版本保留(单项自动选中仍在) + expect(screen.getByText('甲公司(标准版)')).toBeInTheDocument(); + }); +}); -- libgit2 0.22.2