Commit cd493340477fd01bc3d8b79978531f33d3b217a7
1 parent
6deae3a6
feat(usr): 用户列表页面集成与路由接线 UserListPage REQ-USR-003
Showing
3 changed files
with
260 additions
and
6 deletions
frontend/src/pages/usr/UserList/index.tsx
0 → 100644
| 1 | +// REQ-USR-003: 用户列表页面容器(组合工具栏/筛选栏/表格+分页,对接 GET /api/usr/users) | ||
| 2 | +import { useState } from 'react'; | ||
| 3 | +import { Button } from 'antd'; | ||
| 4 | +import { useNavigate } from 'react-router-dom'; | ||
| 5 | +import type { UserVO } from '../../../api/types'; | ||
| 6 | +import UserToolbar from './UserToolbar'; | ||
| 7 | +import UserFilterBar from './UserFilterBar'; | ||
| 8 | +import UserTable from './UserTable'; | ||
| 9 | +import { useUserList } from './useUserList'; | ||
| 10 | +import { TEXT_ERROR } from './constants'; | ||
| 11 | +import styles from './UserList.module.css'; | ||
| 12 | + | ||
| 13 | +export default function UserListPage() { | ||
| 14 | + const navigate = useNavigate(); | ||
| 15 | + const { | ||
| 16 | + list, | ||
| 17 | + total, | ||
| 18 | + loading, | ||
| 19 | + error, | ||
| 20 | + query, | ||
| 21 | + exporting, | ||
| 22 | + search, | ||
| 23 | + refresh, | ||
| 24 | + clear, | ||
| 25 | + setQueryField, | ||
| 26 | + setMatchType, | ||
| 27 | + setQueryValue, | ||
| 28 | + changePage, | ||
| 29 | + exportExcel, | ||
| 30 | + } = useUserList(); | ||
| 31 | + | ||
| 32 | + // 单选标记仅服务「进单据」语义,不参与查询(spec D8) | ||
| 33 | + const [selectedRowKey, setSelectedRowKey] = useState<number | null>(null); | ||
| 34 | + | ||
| 35 | + const handleRowDoubleClick = (row: UserVO) => { | ||
| 36 | + navigate('/usr/users/' + row.id); // BR12 | ||
| 37 | + }; | ||
| 38 | + | ||
| 39 | + return ( | ||
| 40 | + <div className={styles.page}> | ||
| 41 | + <UserToolbar | ||
| 42 | + onRefresh={refresh} | ||
| 43 | + onAdd={() => navigate('/usr/users/new')} // BR13 | ||
| 44 | + onExport={() => void exportExcel()} | ||
| 45 | + exporting={exporting} | ||
| 46 | + loading={loading} | ||
| 47 | + /> | ||
| 48 | + <UserFilterBar | ||
| 49 | + query={query} | ||
| 50 | + onChangeQueryField={setQueryField} | ||
| 51 | + onChangeMatchType={setMatchType} | ||
| 52 | + onChangeQueryValue={setQueryValue} | ||
| 53 | + onSearch={search} | ||
| 54 | + onClear={clear} | ||
| 55 | + /> | ||
| 56 | + {error ? ( | ||
| 57 | + <div className={styles.errorBox} data-testid="userlist-error"> | ||
| 58 | + <span className={styles.errorText}>{TEXT_ERROR}</span> | ||
| 59 | + <Button type="primary" onClick={() => refresh()}> | ||
| 60 | + 点击重试 | ||
| 61 | + </Button> | ||
| 62 | + </div> | ||
| 63 | + ) : ( | ||
| 64 | + <UserTable | ||
| 65 | + rows={list} | ||
| 66 | + loading={loading} | ||
| 67 | + total={total} | ||
| 68 | + pageNum={query.pageNum} | ||
| 69 | + pageSize={query.pageSize} | ||
| 70 | + onChangePage={changePage} | ||
| 71 | + onRowDoubleClick={handleRowDoubleClick} | ||
| 72 | + selectedRowKey={selectedRowKey} | ||
| 73 | + onSelectRow={setSelectedRowKey} | ||
| 74 | + /> | ||
| 75 | + )} | ||
| 76 | + </div> | ||
| 77 | + ); | ||
| 78 | +} |
frontend/src/router/index.tsx
| @@ -8,11 +8,7 @@ import RedirectIfAuthed from './RedirectIfAuthed'; | @@ -8,11 +8,7 @@ import RedirectIfAuthed from './RedirectIfAuthed'; | ||
| 8 | import AppErrorBoundary from './AppErrorBoundary'; | 8 | import AppErrorBoundary from './AppErrorBoundary'; |
| 9 | import AppLayout from '../layouts/AppLayout/AppLayout'; | 9 | import AppLayout from '../layouts/AppLayout/AppLayout'; |
| 10 | import HomePage from '../pages/home/HomePage/HomePage'; | 10 | import HomePage from '../pages/home/HomePage/HomePage'; |
| 11 | - | ||
| 12 | -// FE-03 用户列表容器占位(本 FE 仅提供导航入口与标签挂载位) | ||
| 13 | -function UserListPlaceholder() { | ||
| 14 | - return <div data-testid="fe03-userlist-placeholder" />; | ||
| 15 | -} | 11 | +import UserListPage from '../pages/usr/UserList'; |
| 16 | 12 | ||
| 17 | // FE-04 用户单据容器占位(新增 / 修改) | 13 | // FE-04 用户单据容器占位(新增 / 修改) |
| 18 | function UserDetailPlaceholder() { | 14 | function UserDetailPlaceholder() { |
| @@ -42,7 +38,7 @@ export default function AppRouter() { | @@ -42,7 +38,7 @@ export default function AppRouter() { | ||
| 42 | } | 38 | } |
| 43 | > | 39 | > |
| 44 | <Route index element={<HomePage />} /> | 40 | <Route index element={<HomePage />} /> |
| 45 | - <Route path="/usr/users" element={<UserListPlaceholder />} /> | 41 | + <Route path="/usr/users" element={<UserListPage />} /> |
| 46 | <Route path="/usr/users/new" element={<UserDetailPlaceholder />} /> | 42 | <Route path="/usr/users/new" element={<UserDetailPlaceholder />} /> |
| 47 | <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} /> | 43 | <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} /> |
| 48 | {/* 受保护区内未匹配 → 回主页(D7) */} | 44 | {/* 受保护区内未匹配 → 回主页(D7) */} |
frontend/tests/unit/UserListPage.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: UserListPage 页面集成(状态机贯通 + 导航 + 错误重试,BR3/BR7/BR8/BR9/BR12/BR13/BR15) | ||
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 3 | +import { screen, waitFor, within } from '@testing-library/react'; | ||
| 4 | +import userEvent from '@testing-library/user-event'; | ||
| 5 | +import { Routes, Route, useLocation } from 'react-router-dom'; | ||
| 6 | +import { renderShell } from './renderShell'; | ||
| 7 | + | ||
| 8 | +// 桩 message | ||
| 9 | +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; | ||
| 10 | +vi.mock('antd', async () => { | ||
| 11 | + const actual = await vi.importActual<typeof import('antd')>('antd'); | ||
| 12 | + return { | ||
| 13 | + ...actual, | ||
| 14 | + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), | ||
| 15 | + }; | ||
| 16 | +}); | ||
| 17 | + | ||
| 18 | +vi.mock('../../src/api/usrApi', () => ({ listUsers: vi.fn() })); | ||
| 19 | + | ||
| 20 | +import { listUsers } from '../../src/api/usrApi'; | ||
| 21 | +import UserListPage from '../../src/pages/usr/UserList'; | ||
| 22 | +import { ApiError } from '../../src/api/request'; | ||
| 23 | +import type { UserVO } from '../../src/api/types'; | ||
| 24 | + | ||
| 25 | +const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>; | ||
| 26 | + | ||
| 27 | +function makeUser(id: number, name = `user${id}`): UserVO { | ||
| 28 | + return { | ||
| 29 | + id, | ||
| 30 | + sUserName: name, | ||
| 31 | + employeeName: null, | ||
| 32 | + sUserNo: null, | ||
| 33 | + departmentName: null, | ||
| 34 | + sUserType: '普通用户', | ||
| 35 | + sLanguage: '中文', | ||
| 36 | + iIsVoid: 0, | ||
| 37 | + tLastLoginDate: null, | ||
| 38 | + sCreator: 'admin', | ||
| 39 | + tCreateDate: '2024-01-01T00:00:00', | ||
| 40 | + }; | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { | ||
| 44 | + return { records, total, pageNum, pageSize }; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +function LocationProbe() { | ||
| 48 | + const loc = useLocation(); | ||
| 49 | + return <div data-testid="loc">{loc.pathname}</div>; | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +function renderPage() { | ||
| 53 | + return renderShell( | ||
| 54 | + <> | ||
| 55 | + <LocationProbe /> | ||
| 56 | + <Routes> | ||
| 57 | + <Route path="/usr/users" element={<UserListPage />} /> | ||
| 58 | + <Route path="/usr/users/new" element={<div data-testid="new-sentinel">new</div>} /> | ||
| 59 | + <Route path="/usr/users/:id" element={<div data-testid="detail-sentinel">detail</div>} /> | ||
| 60 | + </Routes> | ||
| 61 | + </>, | ||
| 62 | + { | ||
| 63 | + initialEntries: ['/usr/users'], | ||
| 64 | + preloadedAuth: { | ||
| 65 | + token: 't', | ||
| 66 | + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, | ||
| 67 | + }, | ||
| 68 | + }, | ||
| 69 | + ); | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +function lastQuery() { | ||
| 73 | + const calls = mockedList.mock.calls; | ||
| 74 | + return calls[calls.length - 1][0]; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +describe('UserListPage 集成', () => { | ||
| 78 | + beforeEach(() => { | ||
| 79 | + vi.clearAllMocks(); | ||
| 80 | + }); | ||
| 81 | + | ||
| 82 | + it('initial load renders rows from listUsers (default query) (BR2)', async () => { | ||
| 83 | + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2)); | ||
| 84 | + renderPage(); | ||
| 85 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 86 | + expect(mockedList.mock.calls[0][0]).toMatchObject({ | ||
| 87 | + queryField: '用户名', | ||
| 88 | + matchType: '包含', | ||
| 89 | + pageNum: 1, | ||
| 90 | + pageSize: 10, | ||
| 91 | + }); | ||
| 92 | + expect(await screen.findByText('user1')).toBeInTheDocument(); | ||
| 93 | + expect(await screen.findByText('user2')).toBeInTheDocument(); | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + it('search with value submits queryValue and shows results (BR7/BR3)', async () => { | ||
| 97 | + const user = userEvent.setup(); | ||
| 98 | + mockedList.mockResolvedValue(page([makeUser(9, '李雷')], 1)); | ||
| 99 | + renderPage(); | ||
| 100 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 101 | + | ||
| 102 | + const input = screen.getByTestId('filter-query-value').querySelector('input')!; | ||
| 103 | + await user.type(input, '李'); | ||
| 104 | + await user.click(screen.getByTestId('btn-search')); | ||
| 105 | + await waitFor(() => { | ||
| 106 | + const q = lastQuery(); | ||
| 107 | + expect(q.queryValue).toBe('李'); | ||
| 108 | + expect(q.pageNum).toBe(1); | ||
| 109 | + }); | ||
| 110 | + expect(await screen.findByText('李雷')).toBeInTheDocument(); | ||
| 111 | + }); | ||
| 112 | + | ||
| 113 | + it('empty response shows 暂无匹配的用户 (BR14)', async () => { | ||
| 114 | + mockedList.mockResolvedValue(page([], 0)); | ||
| 115 | + renderPage(); | ||
| 116 | + expect(await screen.findByText('暂无匹配的用户')).toBeInTheDocument(); | ||
| 117 | + expect(messageSpy.error).not.toHaveBeenCalled(); | ||
| 118 | + }); | ||
| 119 | + | ||
| 120 | + it('error response shows 点击重试; retry calls refresh', async () => { | ||
| 121 | + const user = userEvent.setup(); | ||
| 122 | + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); | ||
| 123 | + renderPage(); | ||
| 124 | + expect(await screen.findByText('加载失败,点击重试')).toBeInTheDocument(); | ||
| 125 | + | ||
| 126 | + mockedList.mockResolvedValueOnce(page([makeUser(3)], 1)); | ||
| 127 | + await user.click(screen.getByTestId('userlist-error').querySelector('button')!); | ||
| 128 | + expect(await screen.findByText('user3')).toBeInTheDocument(); | ||
| 129 | + }); | ||
| 130 | + | ||
| 131 | + it('新增 navigates to /usr/users/new (BR13)', async () => { | ||
| 132 | + const user = userEvent.setup(); | ||
| 133 | + mockedList.mockResolvedValue(page([makeUser(1)], 1)); | ||
| 134 | + renderPage(); | ||
| 135 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 136 | + await user.click(screen.getByTestId('btn-add')); | ||
| 137 | + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'); | ||
| 138 | + expect(screen.getByTestId('new-sentinel')).toBeInTheDocument(); | ||
| 139 | + }); | ||
| 140 | + | ||
| 141 | + it('double click row navigates to /usr/users/:id (BR12)', async () => { | ||
| 142 | + const user = userEvent.setup(); | ||
| 143 | + mockedList.mockResolvedValue(page([makeUser(42)], 1)); | ||
| 144 | + renderPage(); | ||
| 145 | + await screen.findByText('user42'); | ||
| 146 | + await user.dblClick(screen.getByText('user42')); | ||
| 147 | + expect(screen.getByTestId('loc').textContent).toBe('/usr/users/42'); | ||
| 148 | + expect(screen.getByTestId('detail-sentinel')).toBeInTheDocument(); | ||
| 149 | + }); | ||
| 150 | + | ||
| 151 | + it('refresh keeps current page (BR8)', async () => { | ||
| 152 | + const user = userEvent.setup(); | ||
| 153 | + // 初次加载回显第 1 页 | ||
| 154 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 1, 10)); | ||
| 155 | + renderPage(); | ||
| 156 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 157 | + await screen.findByText('user1'); | ||
| 158 | + // 翻到第 2 页(点下一页),响应回显 pageNum=2 | ||
| 159 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 30, 2, 10)); | ||
| 160 | + await user.click(screen.getByTitle('下一页')); | ||
| 161 | + await waitFor(() => expect(lastQuery().pageNum).toBe(2)); | ||
| 162 | + mockedList.mockClear(); | ||
| 163 | + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); | ||
| 164 | + await user.click(screen.getByTestId('btn-refresh')); | ||
| 165 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 166 | + // 刷新保持当前页(2),不回第 1 页 | ||
| 167 | + expect(lastQuery().pageNum).toBe(2); | ||
| 168 | + }); | ||
| 169 | + | ||
| 170 | + it('response pageNum echo syncs pagination (BR15)', async () => { | ||
| 171 | + mockedList.mockResolvedValue(page([makeUser(1)], 50, 5, 10)); | ||
| 172 | + renderPage(); | ||
| 173 | + // 后端回显 pageNum=5,分页当前页应跟随响应 | ||
| 174 | + await screen.findByText('user1'); | ||
| 175 | + await waitFor(() => { | ||
| 176 | + const pager = screen.getByTestId('user-table'); | ||
| 177 | + expect(within(pager).getByTitle('5')).toHaveClass('ant-pagination-item-active'); | ||
| 178 | + }); | ||
| 179 | + }); | ||
| 180 | +}); |