diff --git a/frontend/src/pages/usr/UserList/index.tsx b/frontend/src/pages/usr/UserList/index.tsx new file mode 100644 index 0000000..1fb4064 --- /dev/null +++ b/frontend/src/pages/usr/UserList/index.tsx @@ -0,0 +1,78 @@ +// REQ-USR-003: 用户列表页面容器(组合工具栏/筛选栏/表格+分页,对接 GET /api/usr/users) +import { useState } from 'react'; +import { Button } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import type { UserVO } from '../../../api/types'; +import UserToolbar from './UserToolbar'; +import UserFilterBar from './UserFilterBar'; +import UserTable from './UserTable'; +import { useUserList } from './useUserList'; +import { TEXT_ERROR } from './constants'; +import styles from './UserList.module.css'; + +export default function UserListPage() { + const navigate = useNavigate(); + const { + list, + total, + loading, + error, + query, + exporting, + search, + refresh, + clear, + setQueryField, + setMatchType, + setQueryValue, + changePage, + exportExcel, + } = useUserList(); + + // 单选标记仅服务「进单据」语义,不参与查询(spec D8) + const [selectedRowKey, setSelectedRowKey] = useState(null); + + const handleRowDoubleClick = (row: UserVO) => { + navigate('/usr/users/' + row.id); // BR12 + }; + + return ( +
+ navigate('/usr/users/new')} // BR13 + onExport={() => void exportExcel()} + exporting={exporting} + loading={loading} + /> + + {error ? ( +
+ {TEXT_ERROR} + +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 9510baf..1d7565d 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -8,11 +8,7 @@ import RedirectIfAuthed from './RedirectIfAuthed'; import AppErrorBoundary from './AppErrorBoundary'; import AppLayout from '../layouts/AppLayout/AppLayout'; import HomePage from '../pages/home/HomePage/HomePage'; - -// FE-03 用户列表容器占位(本 FE 仅提供导航入口与标签挂载位) -function UserListPlaceholder() { - return
; -} +import UserListPage from '../pages/usr/UserList'; // FE-04 用户单据容器占位(新增 / 修改) function UserDetailPlaceholder() { @@ -42,7 +38,7 @@ export default function AppRouter() { } > } /> - } /> + } /> } /> } /> {/* 受保护区内未匹配 → 回主页(D7) */} diff --git a/frontend/tests/unit/UserListPage.test.tsx b/frontend/tests/unit/UserListPage.test.tsx new file mode 100644 index 0000000..28b226d --- /dev/null +++ b/frontend/tests/unit/UserListPage.test.tsx @@ -0,0 +1,180 @@ +// REQ-USR-003: UserListPage 页面集成(状态机贯通 + 导航 + 错误重试,BR3/BR7/BR8/BR9/BR12/BR13/BR15) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; + +// 桩 message +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: 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', () => ({ listUsers: vi.fn() })); + +import { listUsers } from '../../src/api/usrApi'; +import UserListPage from '../../src/pages/usr/UserList'; +import { ApiError } from '../../src/api/request'; +import type { UserVO } from '../../src/api/types'; + +const mockedList = listUsers as unknown as ReturnType; + +function makeUser(id: number, name = `user${id}`): UserVO { + return { + id, + sUserName: name, + employeeName: null, + sUserNo: null, + departmentName: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + }; +} + +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { + return { records, total, pageNum, pageSize }; +} + +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderPage() { + return renderShell( + <> + + + } /> + new
} /> + detail} /> + + , + { + initialEntries: ['/usr/users'], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }, + ); +} + +function lastQuery() { + const calls = mockedList.mock.calls; + return calls[calls.length - 1][0]; +} + +describe('UserListPage 集成', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initial load renders rows from listUsers (default query) (BR2)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(mockedList.mock.calls[0][0]).toMatchObject({ + queryField: '用户名', + matchType: '包含', + pageNum: 1, + pageSize: 10, + }); + expect(await screen.findByText('user1')).toBeInTheDocument(); + expect(await screen.findByText('user2')).toBeInTheDocument(); + }); + + it('search with value submits queryValue and shows results (BR7/BR3)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(9, '李雷')], 1)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + await user.type(input, '李'); + await user.click(screen.getByTestId('btn-search')); + await waitFor(() => { + const q = lastQuery(); + expect(q.queryValue).toBe('李'); + expect(q.pageNum).toBe(1); + }); + expect(await screen.findByText('李雷')).toBeInTheDocument(); + }); + + it('empty response shows 暂无匹配的用户 (BR14)', async () => { + mockedList.mockResolvedValue(page([], 0)); + renderPage(); + expect(await screen.findByText('暂无匹配的用户')).toBeInTheDocument(); + expect(messageSpy.error).not.toHaveBeenCalled(); + }); + + it('error response shows 点击重试; retry calls refresh', async () => { + const user = userEvent.setup(); + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + renderPage(); + expect(await screen.findByText('加载失败,点击重试')).toBeInTheDocument(); + + mockedList.mockResolvedValueOnce(page([makeUser(3)], 1)); + await user.click(screen.getByTestId('userlist-error').querySelector('button')!); + expect(await screen.findByText('user3')).toBeInTheDocument(); + }); + + it('新增 navigates to /usr/users/new (BR13)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(1)], 1)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + await user.click(screen.getByTestId('btn-add')); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'); + expect(screen.getByTestId('new-sentinel')).toBeInTheDocument(); + }); + + it('double click row navigates to /usr/users/:id (BR12)', async () => { + const user = userEvent.setup(); + mockedList.mockResolvedValue(page([makeUser(42)], 1)); + renderPage(); + await screen.findByText('user42'); + await user.dblClick(screen.getByText('user42')); + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/42'); + expect(screen.getByTestId('detail-sentinel')).toBeInTheDocument(); + }); + + it('refresh keeps current page (BR8)', async () => { + const user = userEvent.setup(); + // 初次加载回显第 1 页 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 1, 10)); + renderPage(); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + await screen.findByText('user1'); + // 翻到第 2 页(点下一页),响应回显 pageNum=2 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 2, 10)); + await user.click(screen.getByTitle('下一页')); + await waitFor(() => expect(lastQuery().pageNum).toBe(2)); + mockedList.mockClear(); + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); + await user.click(screen.getByTestId('btn-refresh')); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + // 刷新保持当前页(2),不回第 1 页 + expect(lastQuery().pageNum).toBe(2); + }); + + it('response pageNum echo syncs pagination (BR15)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 50, 5, 10)); + renderPage(); + // 后端回显 pageNum=5,分页当前页应跟随响应 + await screen.findByText('user1'); + await waitFor(() => { + const pager = screen.getByTestId('user-table'); + expect(within(pager).getByTitle('5')).toHaveClass('ant-pagination-item-active'); + }); + }); +});