From ccafa9e8747c5c3bcd62b73d235d4384d6e51f37 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:41:59 +0800 Subject: [PATCH] feat(usr): 用户列表表格 UserTable 与列定义 REQ-USR-003 --- frontend/src/pages/usr/UserList/UserTable.tsx | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserList/columns.tsx | 37 +++++++++++++++++++++++++++++++++++++ frontend/tests/unit/UserTable.test.tsx | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/usr/UserList/UserTable.tsx create mode 100644 frontend/src/pages/usr/UserList/columns.tsx create mode 100644 frontend/tests/unit/UserTable.test.tsx diff --git a/frontend/src/pages/usr/UserList/UserTable.tsx b/frontend/src/pages/usr/UserList/UserTable.tsx new file mode 100644 index 0000000..d952434 --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserTable.tsx @@ -0,0 +1,70 @@ +// REQ-USR-003: 用户列表表格(受控分页 / 单选 rowSelection / 行双击 / 空态,BR1/BR6/BR11/BR12/BR14/D8) +import { Empty, Table } from 'antd'; +import type { TablePaginationConfig } from 'antd'; +import type { UserVO } from '../../../api/types'; +import { buildUserColumns } from './columns'; +import { PAGE_SIZE_OPTIONS, TEXT_EMPTY, totalText } from './constants'; +import styles from './UserList.module.css'; + +export interface UserTableProps { + rows: UserVO[]; + loading: boolean; + total: number; + pageNum: number; + pageSize: number; + onChangePage(pageNum: number, pageSize: number): void; + onRowDoubleClick(row: UserVO): void; + selectedRowKey?: number | null; + onSelectRow?(key: number | null): void; +} + +export default function UserTable({ + rows, + loading, + total, + pageNum, + pageSize, + onChangePage, + onRowDoubleClick, + selectedRowKey, + onSelectRow, +}: UserTableProps) { + const pagination: TablePaginationConfig = { + current: pageNum, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: PAGE_SIZE_OPTIONS.map(String), + showTotal: (t) => totalText(t), + }; + + return ( +
+ + rowKey="id" + columns={buildUserColumns({ pageNum, pageSize })} + dataSource={rows} + loading={loading} + size="small" + scroll={{ x: 'max-content' }} + pagination={pagination} + onChange={(p) => { + // 受控分页:原样上报新的 (pageNum, pageSize),越界 / 改页大小回退由页面 hook 处理(BR11) + onChangePage(p.current ?? pageNum, p.pageSize ?? pageSize); + }} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedRowKey != null ? [selectedRowKey] : [], + onChange: (keys) => { + // 仅复刻单选标记语义(spec D8),不参与查询 + onSelectRow?.(keys.length ? Number(keys[0]) : null); + }, + }} + onRow={(row) => ({ + onDoubleClick: () => onRowDoubleClick(row), // BR12 + })} + locale={{ emptyText: }} + /> +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/columns.tsx b/frontend/src/pages/usr/UserList/columns.tsx new file mode 100644 index 0000000..3732fd8 --- /dev/null +++ b/frontend/src/pages/usr/UserList/columns.tsx @@ -0,0 +1,37 @@ +// REQ-USR-003: 用户列表列定义(序号按当前页 BR1;作废只读 0/1→否/是 BR6) +import type { ColumnsType } from 'antd/es/table'; +import type { UserVO } from '../../../api/types'; + +export interface BuildColumnsOpts { + pageNum: number; + pageSize: number; +} + +/** 列顺序固定:序号 / 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 语言 / 作废 / 登录日期 / 制单人 / 制单日期 */ +export function buildUserColumns(opts: BuildColumnsOpts): ColumnsType { + const { pageNum, pageSize } = opts; + return [ + { + title: '序号', + key: 'serial', + width: 64, + render: (_value, _record, index) => (pageNum - 1) * pageSize + index + 1, // BR1 + }, + { title: '用户名', dataIndex: 'sUserName', key: 'sUserName' }, + { title: '员工名', dataIndex: 'employeeName', key: 'employeeName' }, + { title: '用户号', dataIndex: 'sUserNo', key: 'sUserNo' }, + { title: '部门', dataIndex: 'departmentName', key: 'departmentName' }, + { title: '用户类型', dataIndex: 'sUserType', key: 'sUserType' }, + { title: '语言', dataIndex: 'sLanguage', key: 'sLanguage' }, + { + title: '作废', + dataIndex: 'iIsVoid', + key: 'iIsVoid', + width: 72, + render: (v: number) => (v === 1 ? '是' : '否'), // 只读展示,BR6 + }, + { title: '登录日期', dataIndex: 'tLastLoginDate', key: 'tLastLoginDate' }, + { title: '制单人', dataIndex: 'sCreator', key: 'sCreator' }, + { title: '制单日期', dataIndex: 'tCreateDate', key: 'tCreateDate' }, + ]; +} diff --git a/frontend/tests/unit/UserTable.test.tsx b/frontend/tests/unit/UserTable.test.tsx new file mode 100644 index 0000000..31d4d85 --- /dev/null +++ b/frontend/tests/unit/UserTable.test.tsx @@ -0,0 +1,145 @@ +// REQ-USR-003: UserTable 表格单测(BR1/BR6/BR11/BR12/BR14/D8) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import UserTable from '../../src/pages/usr/UserList/UserTable'; +import type { UserVO } from '../../src/api/types'; + +function makeUser(id: number, over?: Partial): UserVO { + return { + id, + sUserName: `user${id}`, + employeeName: `员工${id}`, + sUserNo: `U00${id}`, + departmentName: `部门${id}`, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: '2024-02-01T10:00:00', + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + ...over, + }; +} + +interface Props { + rows?: UserVO[]; + loading?: boolean; + total?: number; + pageNum?: number; + pageSize?: number; +} + +function setup(props?: Props) { + const onChangePage = vi.fn(); + const onRowDoubleClick = vi.fn(); + const onSelectRow = vi.fn(); + renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onChangePage, onRowDoubleClick, onSelectRow }; +} + +const HEADERS = [ + '序号', + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '语言', + '作废', + '登录日期', + '制单人', + '制单日期', +]; + +describe('UserTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 11 column headers in order', () => { + setup(); + const headerCells = screen.getAllByRole('columnheader'); + const texts = headerCells.map((c) => c.textContent ?? ''); + for (const h of HEADERS) { + expect(texts.some((t) => t.includes(h))).toBe(true); + } + // 业务列(序号..制单日期)顺序一致(排除首个 radio 选择列空表头) + const businessTexts = texts.filter((t) => HEADERS.some((h) => t.includes(h))); + const orderIdx = HEADERS.map((h) => businessTexts.findIndex((t) => t.includes(h))); + const sorted = [...orderIdx].sort((a, b) => a - b); + expect(orderIdx).toEqual(sorted); + }); + + it('serial number is page-aware (BR1)', () => { + setup({ pageNum: 2, pageSize: 10, rows: [makeUser(11), makeUser(12)], total: 30 }); + // 第 2 页首行序号 = (2-1)*10 + 0 + 1 = 11 + expect(screen.getByText('11')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); + + it('作废 column renders 否/是 read-only (BR6)', async () => { + const user = userEvent.setup(); + const { onSelectRow } = setup({ + rows: [makeUser(1, { iIsVoid: 0 }), makeUser(2, { iIsVoid: 1 })], + total: 2, + }); + expect(screen.getByText('否')).toBeInTheDocument(); + expect(screen.getByText('是')).toBeInTheDocument(); + // 点击「作废」单元不触发任何选择 / 写动作 + await user.click(screen.getByText('是')); + expect(onSelectRow).not.toHaveBeenCalled(); + }); + + it('double click row navigates via onRowDoubleClick (BR12)', async () => { + const user = userEvent.setup(); + const { onRowDoubleClick } = setup({ rows: [makeUser(7)], total: 1 }); + await user.dblClick(screen.getByText('user7')); + expect(onRowDoubleClick).toHaveBeenCalledTimes(1); + expect(onRowDoubleClick.mock.calls[0][0]).toMatchObject({ id: 7 }); + }); + + it('controlled pagination reflects current/pageSize/total + showTotal (BR11)', async () => { + const user = userEvent.setup(); + const { onChangePage } = setup({ + rows: [makeUser(1)], + total: 37, + pageNum: 1, + pageSize: 10, + }); + expect(screen.getByText('共 37 条记录')).toBeInTheDocument(); + // 点下一页 → onChangePage 收到 pageNum=2(renderShell 注入 zhCN locale,标题为「下一页」) + await user.click(screen.getByTitle('下一页')); + expect(onChangePage).toHaveBeenCalled(); + expect(onChangePage.mock.calls[0][0]).toBe(2); + expect(onChangePage.mock.calls[0][1]).toBe(10); + }); + + it('empty rows shows Empty 暂无匹配的用户 (BR14)', () => { + setup({ rows: [], total: 0 }); + expect(screen.getByText('暂无匹配的用户')).toBeInTheDocument(); + }); + + it('radio rowSelection single-select reports key without query (D8)', async () => { + const user = userEvent.setup(); + const { onSelectRow, onChangePage } = setup({ rows: [makeUser(5)], total: 1 }); + const radio = screen.getByRole('radio'); + await user.click(radio); + expect(onSelectRow).toHaveBeenCalledWith(5); + // 选择不触发取数 / 翻页 + expect(onChangePage).not.toHaveBeenCalled(); + }); +}); -- libgit2 0.22.2