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 | +}); | ... | ... |