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