Commit ccafa9e8747c5c3bcd62b73d235d4384d6e51f37

Authored by zichun
1 parent a353c5a4

feat(usr): 用户列表表格 UserTable 与列定义 REQ-USR-003

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