Commit ccafa9e8747c5c3bcd62b73d235d4384d6e51f37
1 parent
a353c5a4
feat(usr): 用户列表表格 UserTable 与列定义 REQ-USR-003
Showing
3 changed files
with
252 additions
and
0 deletions
frontend/src/pages/usr/UserList/UserTable.tsx
0 → 100644
| 1 | +// REQ-USR-003: 用户列表表格(受控分页 / 单选 rowSelection / 行双击 / 空态,BR1/BR6/BR11/BR12/BR14/D8) | ||
| 2 | +import { Empty, Table } from 'antd'; | ||
| 3 | +import type { TablePaginationConfig } from 'antd'; | ||
| 4 | +import type { UserVO } from '../../../api/types'; | ||
| 5 | +import { buildUserColumns } from './columns'; | ||
| 6 | +import { PAGE_SIZE_OPTIONS, TEXT_EMPTY, totalText } from './constants'; | ||
| 7 | +import styles from './UserList.module.css'; | ||
| 8 | + | ||
| 9 | +export interface UserTableProps { | ||
| 10 | + rows: UserVO[]; | ||
| 11 | + loading: boolean; | ||
| 12 | + total: number; | ||
| 13 | + pageNum: number; | ||
| 14 | + pageSize: number; | ||
| 15 | + onChangePage(pageNum: number, pageSize: number): void; | ||
| 16 | + onRowDoubleClick(row: UserVO): void; | ||
| 17 | + selectedRowKey?: number | null; | ||
| 18 | + onSelectRow?(key: number | null): void; | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +export default function UserTable({ | ||
| 22 | + rows, | ||
| 23 | + loading, | ||
| 24 | + total, | ||
| 25 | + pageNum, | ||
| 26 | + pageSize, | ||
| 27 | + onChangePage, | ||
| 28 | + onRowDoubleClick, | ||
| 29 | + selectedRowKey, | ||
| 30 | + onSelectRow, | ||
| 31 | +}: UserTableProps) { | ||
| 32 | + const pagination: TablePaginationConfig = { | ||
| 33 | + current: pageNum, | ||
| 34 | + pageSize, | ||
| 35 | + total, | ||
| 36 | + showSizeChanger: true, | ||
| 37 | + pageSizeOptions: PAGE_SIZE_OPTIONS.map(String), | ||
| 38 | + showTotal: (t) => totalText(t), | ||
| 39 | + }; | ||
| 40 | + | ||
| 41 | + return ( | ||
| 42 | + <div className={styles.tableShell} data-testid="user-table"> | ||
| 43 | + <Table<UserVO> | ||
| 44 | + rowKey="id" | ||
| 45 | + columns={buildUserColumns({ pageNum, pageSize })} | ||
| 46 | + dataSource={rows} | ||
| 47 | + loading={loading} | ||
| 48 | + size="small" | ||
| 49 | + scroll={{ x: 'max-content' }} | ||
| 50 | + pagination={pagination} | ||
| 51 | + onChange={(p) => { | ||
| 52 | + // 受控分页:原样上报新的 (pageNum, pageSize),越界 / 改页大小回退由页面 hook 处理(BR11) | ||
| 53 | + onChangePage(p.current ?? pageNum, p.pageSize ?? pageSize); | ||
| 54 | + }} | ||
| 55 | + rowSelection={{ | ||
| 56 | + type: 'radio', | ||
| 57 | + selectedRowKeys: selectedRowKey != null ? [selectedRowKey] : [], | ||
| 58 | + onChange: (keys) => { | ||
| 59 | + // 仅复刻单选标记语义(spec D8),不参与查询 | ||
| 60 | + onSelectRow?.(keys.length ? Number(keys[0]) : null); | ||
| 61 | + }, | ||
| 62 | + }} | ||
| 63 | + onRow={(row) => ({ | ||
| 64 | + onDoubleClick: () => onRowDoubleClick(row), // BR12 | ||
| 65 | + })} | ||
| 66 | + locale={{ emptyText: <Empty description={TEXT_EMPTY} /> }} | ||
| 67 | + /> | ||
| 68 | + </div> | ||
| 69 | + ); | ||
| 70 | +} |
frontend/src/pages/usr/UserList/columns.tsx
0 → 100644
| 1 | +// REQ-USR-003: 用户列表列定义(序号按当前页 BR1;作废只读 0/1→否/是 BR6) | ||
| 2 | +import type { ColumnsType } from 'antd/es/table'; | ||
| 3 | +import type { UserVO } from '../../../api/types'; | ||
| 4 | + | ||
| 5 | +export interface BuildColumnsOpts { | ||
| 6 | + pageNum: number; | ||
| 7 | + pageSize: number; | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +/** 列顺序固定:序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废 / 登录日期 / 制单人 / 制单日期 */ | ||
| 11 | +export function buildUserColumns(opts: BuildColumnsOpts): ColumnsType<UserVO> { | ||
| 12 | + const { pageNum, pageSize } = opts; | ||
| 13 | + return [ | ||
| 14 | + { | ||
| 15 | + title: '序号', | ||
| 16 | + key: 'serial', | ||
| 17 | + width: 64, | ||
| 18 | + render: (_value, _record, index) => (pageNum - 1) * pageSize + index + 1, // BR1 | ||
| 19 | + }, | ||
| 20 | + { title: '用户名', dataIndex: 'sUserName', key: 'sUserName' }, | ||
| 21 | + { title: '员工名', dataIndex: 'employeeName', key: 'employeeName' }, | ||
| 22 | + { title: '用户号', dataIndex: 'sUserNo', key: 'sUserNo' }, | ||
| 23 | + { title: '部门', dataIndex: 'departmentName', key: 'departmentName' }, | ||
| 24 | + { title: '用户类型', dataIndex: 'sUserType', key: 'sUserType' }, | ||
| 25 | + { title: '语言', dataIndex: 'sLanguage', key: 'sLanguage' }, | ||
| 26 | + { | ||
| 27 | + title: '作废', | ||
| 28 | + dataIndex: 'iIsVoid', | ||
| 29 | + key: 'iIsVoid', | ||
| 30 | + width: 72, | ||
| 31 | + render: (v: number) => (v === 1 ? '是' : '否'), // 只读展示,BR6 | ||
| 32 | + }, | ||
| 33 | + { title: '登录日期', dataIndex: 'tLastLoginDate', key: 'tLastLoginDate' }, | ||
| 34 | + { title: '制单人', dataIndex: 'sCreator', key: 'sCreator' }, | ||
| 35 | + { title: '制单日期', dataIndex: 'tCreateDate', key: 'tCreateDate' }, | ||
| 36 | + ]; | ||
| 37 | +} |
frontend/tests/unit/UserTable.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: UserTable 表格单测(BR1/BR6/BR11/BR12/BR14/D8) | ||
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 3 | +import { screen, within } from '@testing-library/react'; | ||
| 4 | +import userEvent from '@testing-library/user-event'; | ||
| 5 | +import { renderShell } from './renderShell'; | ||
| 6 | +import UserTable from '../../src/pages/usr/UserList/UserTable'; | ||
| 7 | +import type { UserVO } from '../../src/api/types'; | ||
| 8 | + | ||
| 9 | +function makeUser(id: number, over?: Partial<UserVO>): UserVO { | ||
| 10 | + return { | ||
| 11 | + id, | ||
| 12 | + sUserName: `user${id}`, | ||
| 13 | + employeeName: `员工${id}`, | ||
| 14 | + sUserNo: `U00${id}`, | ||
| 15 | + departmentName: `部门${id}`, | ||
| 16 | + sUserType: '普通用户', | ||
| 17 | + sLanguage: '中文', | ||
| 18 | + iIsVoid: 0, | ||
| 19 | + tLastLoginDate: '2024-02-01T10:00:00', | ||
| 20 | + sCreator: 'admin', | ||
| 21 | + tCreateDate: '2024-01-01T00:00:00', | ||
| 22 | + ...over, | ||
| 23 | + }; | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +interface Props { | ||
| 27 | + rows?: UserVO[]; | ||
| 28 | + loading?: boolean; | ||
| 29 | + total?: number; | ||
| 30 | + pageNum?: number; | ||
| 31 | + pageSize?: number; | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +function setup(props?: Props) { | ||
| 35 | + const onChangePage = vi.fn(); | ||
| 36 | + const onRowDoubleClick = vi.fn(); | ||
| 37 | + const onSelectRow = vi.fn(); | ||
| 38 | + renderShell( | ||
| 39 | + <UserTable | ||
| 40 | + rows={props?.rows ?? [makeUser(1), makeUser(2)]} | ||
| 41 | + loading={props?.loading ?? false} | ||
| 42 | + total={props?.total ?? 2} | ||
| 43 | + pageNum={props?.pageNum ?? 1} | ||
| 44 | + pageSize={props?.pageSize ?? 10} | ||
| 45 | + onChangePage={onChangePage} | ||
| 46 | + onRowDoubleClick={onRowDoubleClick} | ||
| 47 | + onSelectRow={onSelectRow} | ||
| 48 | + />, | ||
| 49 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | ||
| 50 | + ); | ||
| 51 | + return { onChangePage, onRowDoubleClick, onSelectRow }; | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +const HEADERS = [ | ||
| 55 | + '序号', | ||
| 56 | + '用户名', | ||
| 57 | + '员工名', | ||
| 58 | + '用户号', | ||
| 59 | + '部门', | ||
| 60 | + '用户类型', | ||
| 61 | + '语言', | ||
| 62 | + '作废', | ||
| 63 | + '登录日期', | ||
| 64 | + '制单人', | ||
| 65 | + '制单日期', | ||
| 66 | +]; | ||
| 67 | + | ||
| 68 | +describe('UserTable', () => { | ||
| 69 | + beforeEach(() => { | ||
| 70 | + vi.clearAllMocks(); | ||
| 71 | + }); | ||
| 72 | + | ||
| 73 | + it('renders 11 column headers in order', () => { | ||
| 74 | + setup(); | ||
| 75 | + const headerCells = screen.getAllByRole('columnheader'); | ||
| 76 | + const texts = headerCells.map((c) => c.textContent ?? ''); | ||
| 77 | + for (const h of HEADERS) { | ||
| 78 | + expect(texts.some((t) => t.includes(h))).toBe(true); | ||
| 79 | + } | ||
| 80 | + // 业务列(序号..制单日期)顺序一致(排除首个 radio 选择列空表头) | ||
| 81 | + const businessTexts = texts.filter((t) => HEADERS.some((h) => t.includes(h))); | ||
| 82 | + const orderIdx = HEADERS.map((h) => businessTexts.findIndex((t) => t.includes(h))); | ||
| 83 | + const sorted = [...orderIdx].sort((a, b) => a - b); | ||
| 84 | + expect(orderIdx).toEqual(sorted); | ||
| 85 | + }); | ||
| 86 | + | ||
| 87 | + it('serial number is page-aware (BR1)', () => { | ||
| 88 | + setup({ pageNum: 2, pageSize: 10, rows: [makeUser(11), makeUser(12)], total: 30 }); | ||
| 89 | + // 第 2 页首行序号 = (2-1)*10 + 0 + 1 = 11 | ||
| 90 | + expect(screen.getByText('11')).toBeInTheDocument(); | ||
| 91 | + expect(screen.getByText('12')).toBeInTheDocument(); | ||
| 92 | + }); | ||
| 93 | + | ||
| 94 | + it('作废 column renders 否/是 read-only (BR6)', async () => { | ||
| 95 | + const user = userEvent.setup(); | ||
| 96 | + const { onSelectRow } = setup({ | ||
| 97 | + rows: [makeUser(1, { iIsVoid: 0 }), makeUser(2, { iIsVoid: 1 })], | ||
| 98 | + total: 2, | ||
| 99 | + }); | ||
| 100 | + expect(screen.getByText('否')).toBeInTheDocument(); | ||
| 101 | + expect(screen.getByText('是')).toBeInTheDocument(); | ||
| 102 | + // 点击「作废」单元不触发任何选择 / 写动作 | ||
| 103 | + await user.click(screen.getByText('是')); | ||
| 104 | + expect(onSelectRow).not.toHaveBeenCalled(); | ||
| 105 | + }); | ||
| 106 | + | ||
| 107 | + it('double click row navigates via onRowDoubleClick (BR12)', async () => { | ||
| 108 | + const user = userEvent.setup(); | ||
| 109 | + const { onRowDoubleClick } = setup({ rows: [makeUser(7)], total: 1 }); | ||
| 110 | + await user.dblClick(screen.getByText('user7')); | ||
| 111 | + expect(onRowDoubleClick).toHaveBeenCalledTimes(1); | ||
| 112 | + expect(onRowDoubleClick.mock.calls[0][0]).toMatchObject({ id: 7 }); | ||
| 113 | + }); | ||
| 114 | + | ||
| 115 | + it('controlled pagination reflects current/pageSize/total + showTotal (BR11)', async () => { | ||
| 116 | + const user = userEvent.setup(); | ||
| 117 | + const { onChangePage } = setup({ | ||
| 118 | + rows: [makeUser(1)], | ||
| 119 | + total: 37, | ||
| 120 | + pageNum: 1, | ||
| 121 | + pageSize: 10, | ||
| 122 | + }); | ||
| 123 | + expect(screen.getByText('共 37 条记录')).toBeInTheDocument(); | ||
| 124 | + // 点下一页 → onChangePage 收到 pageNum=2(renderShell 注入 zhCN locale,标题为「下一页」) | ||
| 125 | + await user.click(screen.getByTitle('下一页')); | ||
| 126 | + expect(onChangePage).toHaveBeenCalled(); | ||
| 127 | + expect(onChangePage.mock.calls[0][0]).toBe(2); | ||
| 128 | + expect(onChangePage.mock.calls[0][1]).toBe(10); | ||
| 129 | + }); | ||
| 130 | + | ||
| 131 | + it('empty rows shows Empty 暂无匹配的用户 (BR14)', () => { | ||
| 132 | + setup({ rows: [], total: 0 }); | ||
| 133 | + expect(screen.getByText('暂无匹配的用户')).toBeInTheDocument(); | ||
| 134 | + }); | ||
| 135 | + | ||
| 136 | + it('radio rowSelection single-select reports key without query (D8)', async () => { | ||
| 137 | + const user = userEvent.setup(); | ||
| 138 | + const { onSelectRow, onChangePage } = setup({ rows: [makeUser(5)], total: 1 }); | ||
| 139 | + const radio = screen.getByRole('radio'); | ||
| 140 | + await user.click(radio); | ||
| 141 | + expect(onSelectRow).toHaveBeenCalledWith(5); | ||
| 142 | + // 选择不触发取数 / 翻页 | ||
| 143 | + expect(onChangePage).not.toHaveBeenCalled(); | ||
| 144 | + }); | ||
| 145 | +}); |