Commit 18fc2e326f650090aa5775ec104baf8161497c95
1 parent
01f5cd2a
feat(frontend): UserFormPage (create + edit) + 子组件 + 路由接入
REQ_ID: FE-02
Showing
5 changed files
with
421 additions
and
8 deletions
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 | 1 | import { createBrowserRouter, Navigate } from 'react-router-dom'; |
| 2 | 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 | 7 | export const router = createBrowserRouter([ |
| 10 | 8 | { path: '/login', element: <LoginPage /> }, |
| 11 | 9 | { |
| 12 | 10 | path: '/users', |
| 13 | 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 | 33 | { path: '*', element: <Navigate to="/users" replace /> }, | ... | ... |