Commit 01f5cd2a0a7aa6d3bb0bf8e4d61665232c0eef8c

Authored by zichun
1 parent cc70720c

feat(frontend): UsersListPage + 子组件(Toolbar/FilterBar/Table)+ 集成测试

REQ_ID: FE-02
frontend/src/pages/users/UsersFilterBar.tsx 0 → 100644
  1 +import { Form, Select, Input, Button, Space } from 'antd';
  2 +import { QUERY_FIELD_OPTIONS, MATCH_MODE_OPTIONS } from './usersConstants';
  3 +
  4 +export interface UsersFilterValues {
  5 + queryField?: string;
  6 + matchMode?: 'contains' | 'notContains' | 'equals';
  7 + queryValue?: string;
  8 +}
  9 +
  10 +interface Props {
  11 + onSearch: (values: UsersFilterValues) => void;
  12 + onReset: () => void;
  13 + disabled?: boolean;
  14 +}
  15 +
  16 +export default function UsersFilterBar({ onSearch, onReset, disabled = false }: Props) {
  17 + const [form] = Form.useForm<UsersFilterValues>();
  18 +
  19 + return (
  20 + <Form
  21 + form={form}
  22 + layout="inline"
  23 + onFinish={onSearch}
  24 + initialValues={{ queryField: 'username', matchMode: 'contains' }}
  25 + data-testid="users-filter-bar"
  26 + >
  27 + <Form.Item name="queryField" label="查询字段">
  28 + <Select
  29 + options={QUERY_FIELD_OPTIONS as any}
  30 + disabled={disabled}
  31 + style={{ width: 140 }}
  32 + data-testid="filter-queryfield"
  33 + />
  34 + </Form.Item>
  35 + <Form.Item name="matchMode" label="匹配方式">
  36 + <Select
  37 + options={MATCH_MODE_OPTIONS as any}
  38 + disabled={disabled}
  39 + style={{ width: 100 }}
  40 + data-testid="filter-matchmode"
  41 + />
  42 + </Form.Item>
  43 + <Form.Item name="queryValue" label="查询值">
  44 + <Input
  45 + placeholder="输入查询值"
  46 + disabled={disabled}
  47 + style={{ width: 200 }}
  48 + data-testid="filter-queryvalue"
  49 + />
  50 + </Form.Item>
  51 + <Form.Item>
  52 + <Space>
  53 + <Button
  54 + type="primary"
  55 + htmlType="submit"
  56 + disabled={disabled}
  57 + data-testid="filter-search"
  58 + >
  59 + 搜索
  60 + </Button>
  61 + <Button
  62 + disabled={disabled}
  63 + data-testid="filter-reset"
  64 + onClick={() => {
  65 + form.resetFields();
  66 + onReset();
  67 + }}
  68 + >
  69 + 清空
  70 + </Button>
  71 + </Space>
  72 + </Form.Item>
  73 + </Form>
  74 + );
  75 +}
frontend/src/pages/users/UsersListPage.test.tsx 0 → 100644
  1 +import { describe, it, expect } from 'vitest';
  2 +import { render, screen, waitFor } from '@testing-library/react';
  3 +import userEvent from '@testing-library/user-event';
  4 +import { MemoryRouter, Routes, Route } from 'react-router-dom';
  5 +import { Provider } from 'react-redux';
  6 +import { ConfigProvider } from 'antd';
  7 +import { configureStore } from '@reduxjs/toolkit';
  8 +import authReducer, { setSession } from '../../store/slices/authSlice';
  9 +import UsersListPage from './UsersListPage';
  10 +
  11 +function makeStore() {
  12 + const store = configureStore({ reducer: { auth: authReducer } });
  13 + store.dispatch(
  14 + setSession({
  15 + accessToken: 'jwt',
  16 + userInfo: {
  17 + userId: 2,
  18 + username: 'admin',
  19 + userType: 'SUPER_ADMIN',
  20 + language: 'zh-CN',
  21 + companyCode: 'HQ',
  22 + },
  23 + }),
  24 + );
  25 + return store;
  26 +}
  27 +
  28 +function renderPage() {
  29 + return render(
  30 + <Provider store={makeStore()}>
  31 + <ConfigProvider>
  32 + <MemoryRouter initialEntries={['/users']}>
  33 + <Routes>
  34 + <Route path="/users" element={<UsersListPage />} />
  35 + <Route path="/users/new" element={<div data-testid="users-new">NEW</div>} />
  36 + <Route path="/users/:userId" element={<div data-testid="users-detail">DETAIL</div>} />
  37 + </Routes>
  38 + </MemoryRouter>
  39 + </ConfigProvider>
  40 + </Provider>,
  41 + );
  42 +}
  43 +
  44 +describe('UsersListPage', () => {
  45 + it('mount → list 加载并渲染表格行', async () => {
  46 + renderPage();
  47 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
  48 + // "admin" 在两处出现(username 与 createdBy),用 getAllByText
  49 + expect(screen.getAllByText('admin').length).toBeGreaterThanOrEqual(1);
  50 + expect(screen.getByText('bob_deleted')).toBeInTheDocument();
  51 + });
  52 +
  53 + it('点击新增 → 导航到 /users/new', async () => {
  54 + renderPage();
  55 + const user = userEvent.setup();
  56 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
  57 + await user.click(screen.getByTestId('toolbar-add'));
  58 + expect(await screen.findByTestId('users-new')).toBeInTheDocument();
  59 + });
  60 +
  61 + it('点击行 → 导航到 /users/:userId', async () => {
  62 + renderPage();
  63 + const user = userEvent.setup();
  64 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
  65 + await user.click(screen.getByText('alice'));
  66 + expect(await screen.findByTestId('users-detail')).toBeInTheDocument();
  67 + });
  68 +
  69 + it('刷新按钮可调用', async () => {
  70 + renderPage();
  71 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
  72 + const user = userEvent.setup();
  73 + await user.click(screen.getByTestId('toolbar-refresh'));
  74 + // 重新查询后列表仍存在
  75 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
  76 + });
  77 +});
frontend/src/pages/users/UsersListPage.tsx 0 → 100644
  1 +import { useEffect, useState, useCallback } from 'react';
  2 +import { useNavigate } from 'react-router-dom';
  3 +import { Alert, Button, Space } from 'antd';
  4 +import { usersApi } from '../../api/users';
  5 +import type { UserListItem, UsersListQuery } from '../../api/users';
  6 +import { BizError, isBizError } from '../../api/errors';
  7 +import { ERROR_MESSAGES } from './usersConstants';
  8 +import UsersToolbar from './UsersToolbar';
  9 +import UsersFilterBar from './UsersFilterBar';
  10 +import type { UsersFilterValues } from './UsersFilterBar';
  11 +import UsersTable from './UsersTable';
  12 +
  13 +export default function UsersListPage() {
  14 + const navigate = useNavigate();
  15 + const [query, setQuery] = useState<UsersListQuery>({ page: 1, size: 20 });
  16 + const [records, setRecords] = useState<UserListItem[]>([]);
  17 + const [total, setTotal] = useState(0);
  18 + const [loading, setLoading] = useState(false);
  19 + const [errorMessage, setErrorMessage] = useState<string | null>(null);
  20 +
  21 + const fetchList = useCallback(async (q: UsersListQuery) => {
  22 + setLoading(true);
  23 + setErrorMessage(null);
  24 + try {
  25 + const result = await usersApi.list(q);
  26 + setRecords(result.records);
  27 + setTotal(result.total);
  28 + } catch (e) {
  29 + if (isBizError(e)) {
  30 + if (e.code === -1) setErrorMessage(ERROR_MESSAGES.NETWORK as string);
  31 + else setErrorMessage(e.message || (ERROR_MESSAGES.UNKNOWN as string));
  32 + } else {
  33 + setErrorMessage(ERROR_MESSAGES.UNKNOWN as string);
  34 + }
  35 + } finally {
  36 + setLoading(false);
  37 + }
  38 + }, []);
  39 +
  40 + useEffect(() => {
  41 + fetchList(query);
  42 + }, [query, fetchList]);
  43 +
  44 + const handleSearch = (filterValues: UsersFilterValues) => {
  45 + setQuery((prev) => ({
  46 + ...prev,
  47 + page: 1,
  48 + queryField: filterValues.queryField,
  49 + matchMode: filterValues.matchMode,
  50 + queryValue: filterValues.queryValue,
  51 + }));
  52 + };
  53 +
  54 + const handleReset = () => {
  55 + setQuery({ page: 1, size: query.size });
  56 + };
  57 +
  58 + const handleRefresh = () => fetchList(query);
  59 +
  60 + return (
  61 + <div data-testid="users-list-page" style={{ padding: 16, background: 'var(--color-bg-page)' }}>
  62 + <UsersToolbar onRefresh={handleRefresh} onAdd={() => navigate('/users/new')} />
  63 + <UsersFilterBar onSearch={handleSearch} onReset={handleReset} disabled={loading} />
  64 + {errorMessage && (
  65 + <Alert
  66 + type="error"
  67 + message={errorMessage}
  68 + showIcon
  69 + style={{ margin: '12px 0' }}
  70 + action={
  71 + <Space>
  72 + <Button size="small" onClick={handleRefresh}>
  73 + 重试
  74 + </Button>
  75 + </Space>
  76 + }
  77 + data-testid="users-list-error"
  78 + />
  79 + )}
  80 + <UsersTable
  81 + records={records}
  82 + loading={loading}
  83 + total={total}
  84 + page={query.page ?? 1}
  85 + size={query.size ?? 20}
  86 + onRowClick={(row) => navigate(`/users/${row.userId}`)}
  87 + onPageChange={(page, size) => setQuery((prev) => ({ ...prev, page, size }))}
  88 + />
  89 + </div>
  90 + );
  91 +}
frontend/src/pages/users/UsersTable.tsx 0 → 100644
  1 +import { Table, Tag } from 'antd';
  2 +import type { ColumnsType } from 'antd/es/table';
  3 +import type { UserListItem } from '../../api/users';
  4 +
  5 +interface Props {
  6 + records: UserListItem[];
  7 + loading: boolean;
  8 + total: number;
  9 + page: number;
  10 + size: number;
  11 + onRowClick: (row: UserListItem) => void;
  12 + onPageChange: (page: number, size: number) => void;
  13 +}
  14 +
  15 +export default function UsersTable({
  16 + records,
  17 + loading,
  18 + total,
  19 + page,
  20 + size,
  21 + onRowClick,
  22 + onPageChange,
  23 +}: Props) {
  24 + const columns: ColumnsType<UserListItem> = [
  25 + {
  26 + title: '序号',
  27 + key: 'index',
  28 + width: 60,
  29 + render: (_: unknown, __: unknown, idx: number) => (page - 1) * size + idx + 1,
  30 + },
  31 + { title: '用户名', dataIndex: 'username', key: 'username' },
  32 + { title: '员工名', dataIndex: 'employeeName', key: 'employeeName' },
  33 + { title: '用户号', dataIndex: 'userCode', key: 'userCode' },
  34 + { title: '部门', dataIndex: 'departmentName', key: 'departmentName' },
  35 + {
  36 + title: '用户类型',
  37 + dataIndex: 'userType',
  38 + key: 'userType',
  39 + render: (v: string) => (v === 'SUPER_ADMIN' ? '超级管理员' : '普通用户'),
  40 + },
  41 + { title: '语言', dataIndex: 'language', key: 'language' },
  42 + {
  43 + title: '作废',
  44 + dataIndex: 'isDeleted',
  45 + key: 'isDeleted',
  46 + render: (v: boolean) =>
  47 + v ? <Tag color="error">作废</Tag> : <Tag color="success">启用</Tag>,
  48 + },
  49 + { title: '登录日期', dataIndex: 'lastLoginDate', key: 'lastLoginDate' },
  50 + { title: '制单人', dataIndex: 'createdBy', key: 'createdBy' },
  51 + { title: '制单日期', dataIndex: 'createdDate', key: 'createdDate' },
  52 + ];
  53 +
  54 + return (
  55 + <Table<UserListItem>
  56 + rowKey="userId"
  57 + columns={columns}
  58 + dataSource={records}
  59 + loading={loading}
  60 + onRow={(row) => ({
  61 + onClick: () => onRowClick(row),
  62 + style: { cursor: 'pointer' },
  63 + 'data-testid': `user-row-${row.userId}`,
  64 + })}
  65 + pagination={{
  66 + current: page,
  67 + pageSize: size,
  68 + total,
  69 + showSizeChanger: true,
  70 + pageSizeOptions: ['10', '20', '50', '100'],
  71 + onChange: onPageChange,
  72 + }}
  73 + data-testid="users-table"
  74 + />
  75 + );
  76 +}
frontend/src/pages/users/UsersToolbar.tsx 0 → 100644
  1 +import { Button, Space } from 'antd';
  2 +
  3 +interface Props {
  4 + onRefresh: () => void;
  5 + onAdd: () => void;
  6 +}
  7 +
  8 +export default function UsersToolbar({ onRefresh, onAdd }: Props) {
  9 + return (
  10 + <Space data-testid="users-toolbar" style={{ marginBottom: 12 }}>
  11 + <Button data-testid="toolbar-refresh" onClick={onRefresh}>
  12 + 刷新
  13 + </Button>
  14 + <Button type="primary" data-testid="toolbar-add" onClick={onAdd}>
  15 + 新增
  16 + </Button>
  17 + <Button disabled data-testid="toolbar-export">
  18 + 导出Excel
  19 + </Button>
  20 + </Space>
  21 + );
  22 +}
frontend/src/pages/users/usersConstants.ts 0 → 100644
  1 +export const USER_TYPE_OPTIONS = [
  2 + { value: 'NORMAL', label: '普通用户' },
  3 + { value: 'SUPER_ADMIN', label: '超级管理员' },
  4 +] as const;
  5 +
  6 +export const LANGUAGE_OPTIONS = [
  7 + { value: 'zh-CN', label: '中文' },
  8 + { value: 'en-US', label: '英文' },
  9 + { value: 'zh-TW', label: '繁体' },
  10 +] as const;
  11 +
  12 +export const QUERY_FIELD_OPTIONS = [
  13 + { value: 'username', label: '用户名' },
  14 + { value: 'employeeName', label: '员工名' },
  15 + { value: 'userCode', label: '用户号' },
  16 + { value: 'departmentName', label: '部门' },
  17 + { value: 'userType', label: '用户类型' },
  18 + { value: 'isDeleted', label: '作废' },
  19 + { value: 'createdBy', label: '制单人' },
  20 +] as const;
  21 +
  22 +export const MATCH_MODE_OPTIONS = [
  23 + { value: 'contains', label: '包含' },
  24 + { value: 'notContains', label: '不包含' },
  25 + { value: 'equals', label: '等于' },
  26 +] as const;
  27 +
  28 +// Fixture:employee 下拉,待后端 GET /api/v1/employees 实现后替换
  29 +export const EMPLOYEE_OPTIONS = [
  30 + { value: 0, label: '(无 / 解除关联)' },
  31 + { value: 1, label: '张三 (E001)' },
  32 +];
  33 +
  34 +// Fixture:权限分类,待后端 GET /api/v1/permission-categories 实现后替换
  35 +export const PERMISSION_CATEGORY_OPTIONS = [
  36 + { value: 1, label: 'PUR 采购管理' },
  37 + { value: 2, label: 'SAL 销售管理' },
  38 +];
  39 +
  40 +export const ERROR_MESSAGES: Record<number | string, string> = {
  41 + 40001: '请检查字段格式',
  42 + 40004: '员工或权限分类不存在或已删除',
  43 + 40101: '会话失效,请重新登录',
  44 + 40301: '权限不足,仅超级管理员可调用',
  45 + 40302: '不允许停用当前登录用户自己',
  46 + 40401: '用户不存在',
  47 + 40901: '用户名已存在',
  48 + 40902: '用户号已被占用',
  49 + NETWORK: '网络异常,请检查连接后重试',
  50 + UNKNOWN: '操作失败,请稍后重试',
  51 +};