Commit cd493340477fd01bc3d8b79978531f33d3b217a7

Authored by zichun
1 parent 6deae3a6

feat(usr): 用户列表页面集成与路由接线 UserListPage REQ-USR-003

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 &#39;./RedirectIfAuthed&#39;;
8 8 import AppErrorBoundary from './AppErrorBoundary';
9 9 import AppLayout from '../layouts/AppLayout/AppLayout';
10 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 13 // FE-04 用户单据容器占位(新增 / 修改)
18 14 function UserDetailPlaceholder() {
... ... @@ -42,7 +38,7 @@ export default function AppRouter() {
42 38 }
43 39 >
44 40 <Route index element={<HomePage />} />
45   - <Route path="/usr/users" element={<UserListPlaceholder />} />
  41 + <Route path="/usr/users" element={<UserListPage />} />
46 42 <Route path="/usr/users/new" element={<UserDetailPlaceholder />} />
47 43 <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} />
48 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 +});
... ...