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 | 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 /> }, |