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