Commit 18fc2e326f650090aa5775ec104baf8161497c95

Authored by zichun
1 parent 01f5cd2a

feat(frontend): UserFormPage (create + edit) + 子组件 + 路由接入

REQ_ID: FE-02
frontend/src/pages/users/UserFormFields.tsx 0 → 100644
  1 +import { Form, Input, Select, Checkbox } from 'antd';
  2 +import { USER_TYPE_OPTIONS, LANGUAGE_OPTIONS, EMPLOYEE_OPTIONS } from './usersConstants';
  3 +
  4 +interface Props {
  5 + mode: 'create' | 'edit';
  6 + disabled?: boolean;
  7 +}
  8 +
  9 +export default function UserFormFields({ mode, disabled = false }: Props) {
  10 + return (
  11 + <>
  12 + <Form.Item
  13 + label="用户名"
  14 + name="username"
  15 + rules={
  16 + mode === 'create'
  17 + ? [
  18 + { required: true, message: '请输入用户名' },
  19 + {
  20 + pattern: /^[A-Za-z0-9_]{3,20}$/,
  21 + message: '用户名必须为 3-20 位字母数字下划线',
  22 + },
  23 + ]
  24 + : []
  25 + }
  26 + >
  27 + <Input
  28 + placeholder="3-20 位字母数字下划线"
  29 + disabled={disabled || mode === 'edit'}
  30 + autoComplete="off"
  31 + />
  32 + </Form.Item>
  33 +
  34 + <Form.Item
  35 + label="用户号"
  36 + name="userCode"
  37 + rules={[
  38 + { required: true, message: '请输入用户号' },
  39 + { max: 50, message: '用户号不能超过 50 字符' },
  40 + ]}
  41 + >
  42 + <Input placeholder="用户号" disabled={disabled} />
  43 + </Form.Item>
  44 +
  45 + <Form.Item
  46 + label="类型"
  47 + name="userType"
  48 + rules={[{ required: true, message: '请选择类型' }]}
  49 + >
  50 + <Select options={USER_TYPE_OPTIONS as any} disabled={disabled} />
  51 + </Form.Item>
  52 +
  53 + <Form.Item
  54 + label="语言"
  55 + name="language"
  56 + rules={[{ required: true, message: '请选择语言' }]}
  57 + >
  58 + <Select options={LANGUAGE_OPTIONS as any} disabled={disabled} />
  59 + </Form.Item>
  60 +
  61 + <Form.Item label="单据修改权限" name="canEditDocument" valuePropName="checked">
  62 + <Checkbox disabled={disabled}>允许修改</Checkbox>
  63 + </Form.Item>
  64 +
  65 + <Form.Item label="员工名" name="employeeId">
  66 + <Select
  67 + options={EMPLOYEE_OPTIONS}
  68 + disabled={disabled}
  69 + allowClear
  70 + placeholder="可选,不选 = 无关联"
  71 + />
  72 + </Form.Item>
  73 + </>
  74 + );
  75 +}
frontend/src/pages/users/UserFormPage.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 UserFormPage from './UserFormPage';
  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 renderForm(mode: 'create' | 'edit', initialEntry: string) {
  29 + return render(
  30 + <Provider store={makeStore()}>
  31 + <ConfigProvider>
  32 + <MemoryRouter initialEntries={[initialEntry]}>
  33 + <Routes>
  34 + <Route path="/users" element={<div data-testid="users-list">LIST</div>} />
  35 + <Route path="/users/new" element={<UserFormPage mode={mode} />} />
  36 + <Route path="/users/:userId" element={<UserFormPage mode={mode} />} />
  37 + </Routes>
  38 + </MemoryRouter>
  39 + </ConfigProvider>
  40 + </Provider>,
  41 + );
  42 +}
  43 +
  44 +describe('UserFormPage (create)', () => {
  45 + it('renders empty form with username editable', () => {
  46 + renderForm('create', '/users/new');
  47 + const usernameInput = screen.getByLabelText('用户名') as HTMLInputElement;
  48 + expect(usernameInput).not.toBeDisabled();
  49 + });
  50 +
  51 + it('cancel button navigates back to /users', async () => {
  52 + renderForm('create', '/users/new');
  53 + const user = userEvent.setup();
  54 + await user.click(screen.getByTestId('form-cancel'));
  55 + expect(await screen.findByTestId('users-list')).toBeInTheDocument();
  56 + });
  57 +
  58 + it('submit valid form navigates to /users', async () => {
  59 + renderForm('create', '/users/new');
  60 + const user = userEvent.setup();
  61 + await user.type(screen.getByLabelText('用户名'), 'newbie');
  62 + await user.type(screen.getByLabelText('用户号'), 'U999');
  63 + await user.click(screen.getByTestId('form-save'));
  64 + expect(await screen.findByTestId('users-list', {}, { timeout: 3000 })).toBeInTheDocument();
  65 + });
  66 +
  67 + it('duplicate username (40901) shows field-level error', async () => {
  68 + renderForm('create', '/users/new');
  69 + const user = userEvent.setup();
  70 + await user.type(screen.getByLabelText('用户名'), 'dup');
  71 + await user.type(screen.getByLabelText('用户号'), 'U999');
  72 + await user.click(screen.getByTestId('form-save'));
  73 + await waitFor(() => expect(screen.getByText('用户名已存在')).toBeInTheDocument());
  74 + });
  75 +});
  76 +
  77 +describe('UserFormPage (edit)', () => {
  78 + it('fetches detail and prefills form with username readonly', async () => {
  79 + renderForm('edit', '/users/1');
  80 + await waitFor(() => {
  81 + const usernameInput = screen.getByLabelText('用户名') as HTMLInputElement;
  82 + expect(usernameInput.value).toBe('alice');
  83 + expect(usernameInput).toBeDisabled();
  84 + });
  85 + });
  86 +
  87 + it('unknown userId (40401) shows 404 result', async () => {
  88 + renderForm('edit', '/users/99999');
  89 + await waitFor(() => expect(screen.getByTestId('user-not-found')).toBeInTheDocument());
  90 + });
  91 +});
frontend/src/pages/users/UserFormPage.tsx 0 → 100644
  1 +import { useEffect, useState } from 'react';
  2 +import { useNavigate, useParams } from 'react-router-dom';
  3 +import { Alert, Button, Card, Form, Result, Space, Spin, message } from 'antd';
  4 +import { usersApi } from '../../api/users';
  5 +import type { CreateUserReq, UpdateUserReq, UserDetail } from '../../api/users';
  6 +import { BizError, isBizError } from '../../api/errors';
  7 +import { ERROR_MESSAGES } from './usersConstants';
  8 +import UserFormFields from './UserFormFields';
  9 +import UserPermissionPanel from './UserPermissionPanel';
  10 +
  11 +interface Props {
  12 + mode: 'create' | 'edit';
  13 +}
  14 +
  15 +interface FormValues {
  16 + username?: string;
  17 + userCode?: string;
  18 + userType?: 'NORMAL' | 'SUPER_ADMIN';
  19 + language?: 'zh-CN' | 'en-US' | 'zh-TW';
  20 + canEditDocument?: boolean;
  21 + employeeId?: number;
  22 +}
  23 +
  24 +export default function UserFormPage({ mode }: Props) {
  25 + const navigate = useNavigate();
  26 + const params = useParams<{ userId?: string }>();
  27 + const userId = params.userId ? Number(params.userId) : undefined;
  28 +
  29 + const [form] = Form.useForm<FormValues>();
  30 + const [loadingInitial, setLoadingInitial] = useState(mode === 'edit');
  31 + const [submitting, setSubmitting] = useState(false);
  32 + const [errorMessage, setErrorMessage] = useState<string | null>(null);
  33 + const [notFound, setNotFound] = useState(false);
  34 + const [permissionCategoryIds, setPermissionCategoryIds] = useState<number[]>([]);
  35 + const [originalDetail, setOriginalDetail] = useState<UserDetail | null>(null);
  36 +
  37 + useEffect(() => {
  38 + if (mode !== 'edit' || userId == null) return;
  39 + let cancelled = false;
  40 + (async () => {
  41 + try {
  42 + const detail = await usersApi.get(userId);
  43 + if (cancelled) return;
  44 + setOriginalDetail(detail);
  45 + form.setFieldsValue({
  46 + username: detail.username,
  47 + userCode: detail.userCode,
  48 + userType: detail.userType,
  49 + language: detail.language as FormValues['language'],
  50 + canEditDocument: false, // detail VO 当前不返回,默认 false
  51 + employeeId: detail.employeeId ?? undefined,
  52 + });
  53 + setPermissionCategoryIds(detail.permissionCategoryIds ?? []);
  54 + } catch (e) {
  55 + if (cancelled) return;
  56 + if (isBizError(e) && e.code === 40401) {
  57 + setNotFound(true);
  58 + } else if (isBizError(e)) {
  59 + setErrorMessage(e.message || (ERROR_MESSAGES.UNKNOWN as string));
  60 + } else {
  61 + setErrorMessage(ERROR_MESSAGES.UNKNOWN as string);
  62 + }
  63 + } finally {
  64 + if (!cancelled) setLoadingInitial(false);
  65 + }
  66 + })();
  67 + return () => {
  68 + cancelled = true;
  69 + };
  70 + }, [mode, userId, form]);
  71 +
  72 + const handleSubmit = async (values: FormValues) => {
  73 + setSubmitting(true);
  74 + setErrorMessage(null);
  75 + form.setFields([
  76 + { name: 'username', errors: [] },
  77 + { name: 'userCode', errors: [] },
  78 + ]);
  79 +
  80 + try {
  81 + if (mode === 'create') {
  82 + await usersApi.create({
  83 + username: values.username!,
  84 + userCode: values.userCode!,
  85 + userType: values.userType!,
  86 + language: values.language!,
  87 + canEditDocument: !!values.canEditDocument,
  88 + employeeId: values.employeeId,
  89 + permissionCategoryIds,
  90 + });
  91 + message.success('新增用户成功');
  92 + } else if (userId != null) {
  93 + const patch: UpdateUserReq = {
  94 + userCode: values.userCode,
  95 + userType: values.userType,
  96 + language: values.language,
  97 + canEditDocument: values.canEditDocument,
  98 + employeeId: values.employeeId,
  99 + permissionCategoryIds,
  100 + };
  101 + await usersApi.update(userId, patch);
  102 + message.success('保存成功');
  103 + }
  104 + navigate('/users');
  105 + } catch (e) {
  106 + handleBizError(e);
  107 + } finally {
  108 + setSubmitting(false);
  109 + }
  110 + };
  111 +
  112 + const handleBizError = (e: unknown) => {
  113 + if (!isBizError(e)) {
  114 + setErrorMessage(ERROR_MESSAGES.UNKNOWN as string);
  115 + return;
  116 + }
  117 + const be = e as BizError;
  118 + if (be.code === 40901) {
  119 + form.setFields([{ name: 'username', errors: [ERROR_MESSAGES[40901] as string] }]);
  120 + } else if (be.code === 40902) {
  121 + form.setFields([{ name: 'userCode', errors: [ERROR_MESSAGES[40902] as string] }]);
  122 + } else if (be.code === 40004) {
  123 + setErrorMessage(ERROR_MESSAGES[40004] as string);
  124 + } else if (be.code === 40401) {
  125 + setNotFound(true);
  126 + } else if (be.code === -1) {
  127 + setErrorMessage(ERROR_MESSAGES.NETWORK as string);
  128 + } else {
  129 + setErrorMessage(be.message || (ERROR_MESSAGES.UNKNOWN as string));
  130 + }
  131 + };
  132 +
  133 + if (notFound) {
  134 + return (
  135 + <div data-testid="user-not-found">
  136 + <Result
  137 + status="404"
  138 + title="用户不存在"
  139 + extra={
  140 + <Button type="primary" onClick={() => navigate('/users')}>
  141 + 返回列表
  142 + </Button>
  143 + }
  144 + />
  145 + </div>
  146 + );
  147 + }
  148 +
  149 + return (
  150 + <div data-testid={mode === 'create' ? 'user-form-create' : 'user-form-edit'} style={{ padding: 16 }}>
  151 + <Space style={{ marginBottom: 12 }}>
  152 + <Button
  153 + type="primary"
  154 + loading={submitting}
  155 + onClick={() => form.submit()}
  156 + data-testid="form-save"
  157 + >
  158 + 保存
  159 + </Button>
  160 + <Button onClick={() => navigate('/users')} data-testid="form-cancel">
  161 + 取消
  162 + </Button>
  163 + </Space>
  164 + {errorMessage && (
  165 + <Alert
  166 + type="error"
  167 + message={errorMessage}
  168 + showIcon
  169 + style={{ marginBottom: 12 }}
  170 + data-testid="form-error-alert"
  171 + />
  172 + )}
  173 + <Spin spinning={loadingInitial}>
  174 + <Card>
  175 + <Form<FormValues>
  176 + form={form}
  177 + layout="vertical"
  178 + onFinish={handleSubmit}
  179 + initialValues={{
  180 + userType: 'NORMAL',
  181 + language: 'zh-CN',
  182 + canEditDocument: false,
  183 + }}
  184 + disabled={submitting || loadingInitial}
  185 + data-testid="user-form"
  186 + >
  187 + <UserFormFields mode={mode} disabled={submitting || loadingInitial} />
  188 + </Form>
  189 + <UserPermissionPanel
  190 + value={permissionCategoryIds}
  191 + onChange={setPermissionCategoryIds}
  192 + disabled={submitting || loadingInitial}
  193 + />
  194 + </Card>
  195 + </Spin>
  196 + </div>
  197 + );
  198 +}
frontend/src/pages/users/UserPermissionPanel.tsx 0 → 100644
  1 +import { Tabs, Checkbox } from 'antd';
  2 +import { PERMISSION_CATEGORY_OPTIONS } from './usersConstants';
  3 +
  4 +interface Props {
  5 + value: number[];
  6 + onChange: (ids: number[]) => void;
  7 + disabled?: boolean;
  8 +}
  9 +
  10 +export default function UserPermissionPanel({ value, onChange, disabled = false }: Props) {
  11 + return (
  12 + <div data-testid="user-permission-panel">
  13 + <Tabs
  14 + items={[
  15 + {
  16 + key: 'main',
  17 + label: '权限组',
  18 + children: (
  19 + <Checkbox.Group
  20 + options={PERMISSION_CATEGORY_OPTIONS}
  21 + value={value}
  22 + onChange={(checked) => onChange(checked as number[])}
  23 + disabled={disabled}
  24 + data-testid="permission-category-group"
  25 + />
  26 + ),
  27 + },
  28 + { key: 'customer', label: '客户查看权限', disabled: true, children: null },
  29 + { key: 'supplier', label: '供应商查看权限', disabled: true, children: null },
  30 + { key: 'person', label: '人员查看权限', disabled: true, children: null },
  31 + ]}
  32 + />
  33 + </div>
  34 + );
  35 +}
frontend/src/router/index.tsx
1 import { createBrowserRouter, Navigate } from 'react-router-dom'; 1 import { createBrowserRouter, Navigate } from 'react-router-dom';
2 import LoginPage from '../pages/login/LoginPage'; 2 import LoginPage from '../pages/login/LoginPage';
3 -import RequireAuth from './RequireAuth';  
4 -  
5 -function UsersPlaceholder() {  
6 - return <div data-testid="users-placeholder">users placeholder</div>;  
7 -} 3 +import UsersListPage from '../pages/users/UsersListPage';
  4 +import UserFormPage from '../pages/users/UserFormPage';
  5 +import RequireSuperAdmin from './RequireSuperAdmin';
8 6
9 export const router = createBrowserRouter([ 7 export const router = createBrowserRouter([
10 { path: '/login', element: <LoginPage /> }, 8 { path: '/login', element: <LoginPage /> },
11 { 9 {
12 path: '/users', 10 path: '/users',
13 element: ( 11 element: (
14 - <RequireAuth>  
15 - <UsersPlaceholder />  
16 - </RequireAuth> 12 + <RequireSuperAdmin>
  13 + <UsersListPage />
  14 + </RequireSuperAdmin>
  15 + ),
  16 + },
  17 + {
  18 + path: '/users/new',
  19 + element: (
  20 + <RequireSuperAdmin>
  21 + <UserFormPage mode="create" />
  22 + </RequireSuperAdmin>
  23 + ),
  24 + },
  25 + {
  26 + path: '/users/:userId',
  27 + element: (
  28 + <RequireSuperAdmin>
  29 + <UserFormPage mode="edit" />
  30 + </RequireSuperAdmin>
17 ), 31 ),
18 }, 32 },
19 { path: '*', element: <Navigate to="/users" replace /> }, 33 { path: '*', element: <Navigate to="/users" replace /> },