Commit 01f5cd2a0a7aa6d3bb0bf8e4d61665232c0eef8c
1 parent
cc70720c
feat(frontend): UsersListPage + 子组件(Toolbar/FilterBar/Table)+ 集成测试
REQ_ID: FE-02
Showing
6 changed files
with
392 additions
and
0 deletions
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 | +}; | ... | ... |