diff --git a/frontend/src/pages/users/UsersFilterBar.tsx b/frontend/src/pages/users/UsersFilterBar.tsx new file mode 100644 index 0000000..0ea79ea --- /dev/null +++ b/frontend/src/pages/users/UsersFilterBar.tsx @@ -0,0 +1,75 @@ +import { Form, Select, Input, Button, Space } from 'antd'; +import { QUERY_FIELD_OPTIONS, MATCH_MODE_OPTIONS } from './usersConstants'; + +export interface UsersFilterValues { + queryField?: string; + matchMode?: 'contains' | 'notContains' | 'equals'; + queryValue?: string; +} + +interface Props { + onSearch: (values: UsersFilterValues) => void; + onReset: () => void; + disabled?: boolean; +} + +export default function UsersFilterBar({ onSearch, onReset, disabled = false }: Props) { + const [form] = Form.useForm(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/users/UsersListPage.test.tsx b/frontend/src/pages/users/UsersListPage.test.tsx new file mode 100644 index 0000000..c7ff7c2 --- /dev/null +++ b/frontend/src/pages/users/UsersListPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { ConfigProvider } from 'antd'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { setSession } from '../../store/slices/authSlice'; +import UsersListPage from './UsersListPage'; + +function makeStore() { + const store = configureStore({ reducer: { auth: authReducer } }); + store.dispatch( + setSession({ + accessToken: 'jwt', + userInfo: { + userId: 2, + username: 'admin', + userType: 'SUPER_ADMIN', + language: 'zh-CN', + companyCode: 'HQ', + }, + }), + ); + return store; +} + +function renderPage() { + return render( + + + + + } /> + NEW} /> + DETAIL} /> + + + + , + ); +} + +describe('UsersListPage', () => { + it('mount → list 加载并渲染表格行', async () => { + renderPage(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + // "admin" 在两处出现(username 与 createdBy),用 getAllByText + expect(screen.getAllByText('admin').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('bob_deleted')).toBeInTheDocument(); + }); + + it('点击新增 → 导航到 /users/new', async () => { + renderPage(); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + await user.click(screen.getByTestId('toolbar-add')); + expect(await screen.findByTestId('users-new')).toBeInTheDocument(); + }); + + it('点击行 → 导航到 /users/:userId', async () => { + renderPage(); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + await user.click(screen.getByText('alice')); + expect(await screen.findByTestId('users-detail')).toBeInTheDocument(); + }); + + it('刷新按钮可调用', async () => { + renderPage(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + const user = userEvent.setup(); + await user.click(screen.getByTestId('toolbar-refresh')); + // 重新查询后列表仍存在 + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + }); +}); diff --git a/frontend/src/pages/users/UsersListPage.tsx b/frontend/src/pages/users/UsersListPage.tsx new file mode 100644 index 0000000..a5796d6 --- /dev/null +++ b/frontend/src/pages/users/UsersListPage.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Alert, Button, Space } from 'antd'; +import { usersApi } from '../../api/users'; +import type { UserListItem, UsersListQuery } from '../../api/users'; +import { BizError, isBizError } from '../../api/errors'; +import { ERROR_MESSAGES } from './usersConstants'; +import UsersToolbar from './UsersToolbar'; +import UsersFilterBar from './UsersFilterBar'; +import type { UsersFilterValues } from './UsersFilterBar'; +import UsersTable from './UsersTable'; + +export default function UsersListPage() { + const navigate = useNavigate(); + const [query, setQuery] = useState({ page: 1, size: 20 }); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const fetchList = useCallback(async (q: UsersListQuery) => { + setLoading(true); + setErrorMessage(null); + try { + const result = await usersApi.list(q); + setRecords(result.records); + setTotal(result.total); + } catch (e) { + if (isBizError(e)) { + if (e.code === -1) setErrorMessage(ERROR_MESSAGES.NETWORK as string); + else setErrorMessage(e.message || (ERROR_MESSAGES.UNKNOWN as string)); + } else { + setErrorMessage(ERROR_MESSAGES.UNKNOWN as string); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchList(query); + }, [query, fetchList]); + + const handleSearch = (filterValues: UsersFilterValues) => { + setQuery((prev) => ({ + ...prev, + page: 1, + queryField: filterValues.queryField, + matchMode: filterValues.matchMode, + queryValue: filterValues.queryValue, + })); + }; + + const handleReset = () => { + setQuery({ page: 1, size: query.size }); + }; + + const handleRefresh = () => fetchList(query); + + return ( +
+ navigate('/users/new')} /> + + {errorMessage && ( + + + + } + data-testid="users-list-error" + /> + )} + navigate(`/users/${row.userId}`)} + onPageChange={(page, size) => setQuery((prev) => ({ ...prev, page, size }))} + /> +
+ ); +} diff --git a/frontend/src/pages/users/UsersTable.tsx b/frontend/src/pages/users/UsersTable.tsx new file mode 100644 index 0000000..2a98563 --- /dev/null +++ b/frontend/src/pages/users/UsersTable.tsx @@ -0,0 +1,76 @@ +import { Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { UserListItem } from '../../api/users'; + +interface Props { + records: UserListItem[]; + loading: boolean; + total: number; + page: number; + size: number; + onRowClick: (row: UserListItem) => void; + onPageChange: (page: number, size: number) => void; +} + +export default function UsersTable({ + records, + loading, + total, + page, + size, + onRowClick, + onPageChange, +}: Props) { + const columns: ColumnsType = [ + { + title: '序号', + key: 'index', + width: 60, + render: (_: unknown, __: unknown, idx: number) => (page - 1) * size + idx + 1, + }, + { title: '用户名', dataIndex: 'username', key: 'username' }, + { title: '员工名', dataIndex: 'employeeName', key: 'employeeName' }, + { title: '用户号', dataIndex: 'userCode', key: 'userCode' }, + { title: '部门', dataIndex: 'departmentName', key: 'departmentName' }, + { + title: '用户类型', + dataIndex: 'userType', + key: 'userType', + render: (v: string) => (v === 'SUPER_ADMIN' ? '超级管理员' : '普通用户'), + }, + { title: '语言', dataIndex: 'language', key: 'language' }, + { + title: '作废', + dataIndex: 'isDeleted', + key: 'isDeleted', + render: (v: boolean) => + v ? 作废 : 启用, + }, + { title: '登录日期', dataIndex: 'lastLoginDate', key: 'lastLoginDate' }, + { title: '制单人', dataIndex: 'createdBy', key: 'createdBy' }, + { title: '制单日期', dataIndex: 'createdDate', key: 'createdDate' }, + ]; + + return ( + + rowKey="userId" + columns={columns} + dataSource={records} + loading={loading} + onRow={(row) => ({ + onClick: () => onRowClick(row), + style: { cursor: 'pointer' }, + 'data-testid': `user-row-${row.userId}`, + })} + pagination={{ + current: page, + pageSize: size, + total, + showSizeChanger: true, + pageSizeOptions: ['10', '20', '50', '100'], + onChange: onPageChange, + }} + data-testid="users-table" + /> + ); +} diff --git a/frontend/src/pages/users/UsersToolbar.tsx b/frontend/src/pages/users/UsersToolbar.tsx new file mode 100644 index 0000000..fd9e33a --- /dev/null +++ b/frontend/src/pages/users/UsersToolbar.tsx @@ -0,0 +1,22 @@ +import { Button, Space } from 'antd'; + +interface Props { + onRefresh: () => void; + onAdd: () => void; +} + +export default function UsersToolbar({ onRefresh, onAdd }: Props) { + return ( + + + + + + ); +} diff --git a/frontend/src/pages/users/usersConstants.ts b/frontend/src/pages/users/usersConstants.ts new file mode 100644 index 0000000..3f5b919 --- /dev/null +++ b/frontend/src/pages/users/usersConstants.ts @@ -0,0 +1,51 @@ +export const USER_TYPE_OPTIONS = [ + { value: 'NORMAL', label: '普通用户' }, + { value: 'SUPER_ADMIN', label: '超级管理员' }, +] as const; + +export const LANGUAGE_OPTIONS = [ + { value: 'zh-CN', label: '中文' }, + { value: 'en-US', label: '英文' }, + { value: 'zh-TW', label: '繁体' }, +] as const; + +export const QUERY_FIELD_OPTIONS = [ + { value: 'username', label: '用户名' }, + { value: 'employeeName', label: '员工名' }, + { value: 'userCode', label: '用户号' }, + { value: 'departmentName', label: '部门' }, + { value: 'userType', label: '用户类型' }, + { value: 'isDeleted', label: '作废' }, + { value: 'createdBy', label: '制单人' }, +] as const; + +export const MATCH_MODE_OPTIONS = [ + { value: 'contains', label: '包含' }, + { value: 'notContains', label: '不包含' }, + { value: 'equals', label: '等于' }, +] as const; + +// Fixture:employee 下拉,待后端 GET /api/v1/employees 实现后替换 +export const EMPLOYEE_OPTIONS = [ + { value: 0, label: '(无 / 解除关联)' }, + { value: 1, label: '张三 (E001)' }, +]; + +// Fixture:权限分类,待后端 GET /api/v1/permission-categories 实现后替换 +export const PERMISSION_CATEGORY_OPTIONS = [ + { value: 1, label: 'PUR 采购管理' }, + { value: 2, label: 'SAL 销售管理' }, +]; + +export const ERROR_MESSAGES: Record = { + 40001: '请检查字段格式', + 40004: '员工或权限分类不存在或已删除', + 40101: '会话失效,请重新登录', + 40301: '权限不足,仅超级管理员可调用', + 40302: '不允许停用当前登录用户自己', + 40401: '用户不存在', + 40901: '用户名已存在', + 40902: '用户号已被占用', + NETWORK: '网络异常,请检查连接后重试', + UNKNOWN: '操作失败,请稍后重试', +};