Commit 981d8e4a3a7c9d4292cb8204b4f499c9fc01c7d2
1 parent
4e0722ef
feat(usr): 用户单据表单网格 UserBasicForm REQ-USR-001 REQ-USR-002
Showing
3 changed files
with
427 additions
and
0 deletions
frontend/src/pages/usr/UserDetail/UserBasicForm.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 用户单据表单网格(3 列布局,8 字段 + 员工联动 + 前置校验,BR1-BR9) | |
| 2 | +import { Form, Input, Select, Checkbox, type FormInstance } from 'antd'; | |
| 3 | +import type { EmployeeOption } from '../../../api/types'; | |
| 4 | +import { | |
| 5 | + USER_TYPE_OPTIONS, | |
| 6 | + LANGUAGE_OPTIONS, | |
| 7 | + USERNAME_PATTERN, | |
| 8 | + LABEL_CREATE_TIME, | |
| 9 | + LABEL_CREATOR, | |
| 10 | + LABEL_EMPLOYEE, | |
| 11 | + LABEL_USERNAME, | |
| 12 | + LABEL_USER_TYPE, | |
| 13 | + LABEL_LANGUAGE, | |
| 14 | + LABEL_USER_NO, | |
| 15 | + LABEL_CAN_MODIFY_BILL, | |
| 16 | + TEXT_CREATOR_PLACEHOLDER, | |
| 17 | + MSG_USERNAME_FORMAT, | |
| 18 | + MSG_USERNAME_REQUIRED, | |
| 19 | + MSG_USERNO_REQUIRED, | |
| 20 | + MSG_USERTYPE_REQUIRED, | |
| 21 | + MSG_LANGUAGE_REQUIRED, | |
| 22 | + type UserFormValues, | |
| 23 | +} from './constants'; | |
| 24 | +import styles from './UserDetail.module.css'; | |
| 25 | + | |
| 26 | +export interface UserBasicFormProps { | |
| 27 | + form: FormInstance<UserFormValues>; | |
| 28 | + mode: 'create' | 'edit'; | |
| 29 | + employees: EmployeeOption[]; | |
| 30 | + readonlyCreateTime?: string; | |
| 31 | + readonlyCreator?: string; | |
| 32 | + onSelectEmployee(value: number | null): void; | |
| 33 | +} | |
| 34 | + | |
| 35 | +const toEnumOptions = (arr: readonly string[]) => arr.map((v) => ({ value: v, label: v })); | |
| 36 | + | |
| 37 | +export default function UserBasicForm({ | |
| 38 | + form, | |
| 39 | + mode, | |
| 40 | + employees, | |
| 41 | + readonlyCreateTime, | |
| 42 | + readonlyCreator, | |
| 43 | + onSelectEmployee, | |
| 44 | +}: UserBasicFormProps) { | |
| 45 | + void form; // 表单由父级 Form 实例驱动(受控 initialValues / validate / submit) | |
| 46 | + | |
| 47 | + const creatorText = | |
| 48 | + mode === 'edit' ? readonlyCreator || '' : TEXT_CREATOR_PLACEHOLDER; | |
| 49 | + const createTimeText = mode === 'edit' ? readonlyCreateTime || '' : ''; | |
| 50 | + | |
| 51 | + return ( | |
| 52 | + <div className={styles.formGrid}> | |
| 53 | + {/* 创建时间(只读,BR1) */} | |
| 54 | + <Form.Item label={LABEL_CREATE_TIME}> | |
| 55 | + <div className={styles.readonlyField} data-testid="field-createtime"> | |
| 56 | + {createTimeText} | |
| 57 | + </div> | |
| 58 | + </Form.Item> | |
| 59 | + | |
| 60 | + {/* 制单人(只读,BR2) */} | |
| 61 | + <Form.Item label={LABEL_CREATOR}> | |
| 62 | + <div className={styles.readonlyField} data-testid="field-creator"> | |
| 63 | + {creatorText} | |
| 64 | + </div> | |
| 65 | + </Form.Item> | |
| 66 | + | |
| 67 | + {/* 员工名(Select,选中联动带出用户名/用户号,BR5) */} | |
| 68 | + <Form.Item label={LABEL_EMPLOYEE} name="iEmployeeId"> | |
| 69 | + <Select | |
| 70 | + allowClear | |
| 71 | + placeholder="请选择员工" | |
| 72 | + options={employees.map((e) => ({ value: e.value, label: e.label }))} | |
| 73 | + onChange={(v) => onSelectEmployee((v as number | undefined) ?? null)} | |
| 74 | + virtual={false} | |
| 75 | + data-testid="select-employee" | |
| 76 | + /> | |
| 77 | + </Form.Item> | |
| 78 | + | |
| 79 | + {/* 用户名(create 可编辑必填 + 格式校验;edit 只读,BR3) */} | |
| 80 | + <Form.Item | |
| 81 | + label={LABEL_USERNAME} | |
| 82 | + name="sUserName" | |
| 83 | + rules={ | |
| 84 | + mode === 'create' | |
| 85 | + ? [ | |
| 86 | + { required: true, message: MSG_USERNAME_REQUIRED }, | |
| 87 | + { pattern: USERNAME_PATTERN, message: MSG_USERNAME_FORMAT }, | |
| 88 | + ] | |
| 89 | + : [] | |
| 90 | + } | |
| 91 | + > | |
| 92 | + <Input disabled={mode === 'edit'} data-testid="field-username" /> | |
| 93 | + </Form.Item> | |
| 94 | + | |
| 95 | + {/* 类型(Select 枚举,create 默认普通用户,BR6) */} | |
| 96 | + <Form.Item | |
| 97 | + label={LABEL_USER_TYPE} | |
| 98 | + name="sUserType" | |
| 99 | + rules={[{ required: true, message: MSG_USERTYPE_REQUIRED }]} | |
| 100 | + > | |
| 101 | + <Select | |
| 102 | + options={toEnumOptions(USER_TYPE_OPTIONS)} | |
| 103 | + virtual={false} | |
| 104 | + data-testid="select-usertype" | |
| 105 | + /> | |
| 106 | + </Form.Item> | |
| 107 | + | |
| 108 | + {/* 语言(Select 枚举,必填,BR7) */} | |
| 109 | + <Form.Item | |
| 110 | + label={LABEL_LANGUAGE} | |
| 111 | + name="sLanguage" | |
| 112 | + rules={[{ required: true, message: MSG_LANGUAGE_REQUIRED }]} | |
| 113 | + > | |
| 114 | + <Select | |
| 115 | + options={toEnumOptions(LANGUAGE_OPTIONS)} | |
| 116 | + placeholder="请选择语言" | |
| 117 | + virtual={false} | |
| 118 | + data-testid="select-language" | |
| 119 | + /> | |
| 120 | + </Form.Item> | |
| 121 | + | |
| 122 | + {/* 用户号(必填,BR4,可由员工名联动带出,BR5) */} | |
| 123 | + <Form.Item | |
| 124 | + label={LABEL_USER_NO} | |
| 125 | + name="sUserNo" | |
| 126 | + rules={[{ required: true, message: MSG_USERNO_REQUIRED }]} | |
| 127 | + > | |
| 128 | + <Input data-testid="field-userno" /> | |
| 129 | + </Form.Item> | |
| 130 | + | |
| 131 | + {/* 单据修改权限(Checkbox,默认否,BR8) */} | |
| 132 | + <Form.Item label={LABEL_CAN_MODIFY_BILL} name="iCanModifyBill" valuePropName="checked"> | |
| 133 | + <Checkbox data-testid="field-canmodify" /> | |
| 134 | + </Form.Item> | |
| 135 | + </div> | |
| 136 | + ); | |
| 137 | +} | ... | ... |
frontend/src/pages/usr/UserDetail/UserDetail.module.css
0 → 100644
| 1 | +/* REQ-USR-001 / REQ-USR-002: 用户单据页 scoped 样式。 | |
| 2 | + 语义色只用 var(--color-*);工具栏深色底为页面局部装饰(非语义 token,D10,与 FE-02/FE-03 一致)。 */ | |
| 3 | + | |
| 4 | +.page { | |
| 5 | + display: flex; | |
| 6 | + flex-direction: column; | |
| 7 | + height: 100%; | |
| 8 | + background: var(--color-bg-base); | |
| 9 | +} | |
| 10 | + | |
| 11 | +/* === 工具栏:深色底为页面局部装饰(D10) === */ | |
| 12 | +.toolbar { | |
| 13 | + display: flex; | |
| 14 | + align-items: center; | |
| 15 | + gap: 4px; | |
| 16 | + padding: 6px 12px; | |
| 17 | + background: #2c2f36; | |
| 18 | +} | |
| 19 | + | |
| 20 | +.toolbarSpacer { | |
| 21 | + flex: 1 1 auto; | |
| 22 | +} | |
| 23 | + | |
| 24 | +.toolbar :global(.ant-btn) { | |
| 25 | + color: #ffffff; | |
| 26 | +} | |
| 27 | + | |
| 28 | +.toolbar :global(.ant-btn-primary) { | |
| 29 | + color: #ffffff; | |
| 30 | +} | |
| 31 | + | |
| 32 | +.gear { | |
| 33 | + color: #ffffff; | |
| 34 | + font-size: 16px; | |
| 35 | + cursor: pointer; | |
| 36 | + padding: 0 8px; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/* === 表单网格:3 列白底 === */ | |
| 40 | +.formGrid { | |
| 41 | + display: grid; | |
| 42 | + grid-template-columns: repeat(3, 1fr); | |
| 43 | + gap: 0 16px; | |
| 44 | + padding: 16px 12px; | |
| 45 | + background: var(--color-form-bg-edit); | |
| 46 | + border-bottom: 1px solid var(--color-border); | |
| 47 | + color: var(--color-form-fg); | |
| 48 | +} | |
| 49 | + | |
| 50 | +.readonlyField { | |
| 51 | + display: flex; | |
| 52 | + align-items: center; | |
| 53 | + min-height: 32px; | |
| 54 | + padding: 0 11px; | |
| 55 | + background: var(--color-form-bg-readonly); | |
| 56 | + border: 1px solid var(--color-border); | |
| 57 | + border-radius: 6px; | |
| 58 | + color: var(--color-form-fg); | |
| 59 | +} | |
| 60 | + | |
| 61 | +.formGrid :global(.ant-form-item-label > label) { | |
| 62 | + color: var(--color-text); | |
| 63 | +} | |
| 64 | + | |
| 65 | +/* === 权限页签条 === */ | |
| 66 | +.permTabs { | |
| 67 | + background: var(--color-form-bg-edit); | |
| 68 | + border-bottom: 1px solid var(--color-border); | |
| 69 | +} | |
| 70 | + | |
| 71 | +.permTabs :global(.ant-tabs-tab) { | |
| 72 | + color: var(--color-text); | |
| 73 | +} | |
| 74 | + | |
| 75 | +.permTabs :global(.ant-tabs-tab-disabled) { | |
| 76 | + color: var(--color-text-secondary); | |
| 77 | +} | |
| 78 | + | |
| 79 | +.permTabs :global(.ant-tabs-ink-bar) { | |
| 80 | + background: var(--color-primary); | |
| 81 | +} | |
| 82 | + | |
| 83 | +/* === 权限分类勾选列表 === */ | |
| 84 | +.permList { | |
| 85 | + flex: 1 1 auto; | |
| 86 | + overflow: auto; | |
| 87 | + background: var(--color-form-bg-edit); | |
| 88 | +} | |
| 89 | + | |
| 90 | +.permHead { | |
| 91 | + display: flex; | |
| 92 | + align-items: center; | |
| 93 | + gap: 8px; | |
| 94 | + padding: 8px 12px; | |
| 95 | + background: var(--color-table-header-bg); | |
| 96 | + color: var(--color-table-header-fg); | |
| 97 | + border-bottom: 1px solid var(--color-border); | |
| 98 | +} | |
| 99 | + | |
| 100 | +.permHeadSort { | |
| 101 | + margin-left: auto; | |
| 102 | + color: var(--color-text-secondary); | |
| 103 | +} | |
| 104 | + | |
| 105 | +.permRow { | |
| 106 | + display: flex; | |
| 107 | + align-items: center; | |
| 108 | + gap: 8px; | |
| 109 | + padding: 8px 12px; | |
| 110 | + color: var(--color-text); | |
| 111 | + border-bottom: 1px solid var(--color-border); | |
| 112 | +} | |
| 113 | + | |
| 114 | +.permRow:hover { | |
| 115 | + background: var(--color-table-row-bg-hover); | |
| 116 | +} | |
| 117 | + | |
| 118 | +.permEmpty { | |
| 119 | + padding: 24px 12px; | |
| 120 | + color: var(--color-text-secondary); | |
| 121 | + text-align: center; | |
| 122 | +} | |
| 123 | + | |
| 124 | +/* === 取数失败占位 === */ | |
| 125 | +.loadError { | |
| 126 | + display: flex; | |
| 127 | + flex-direction: column; | |
| 128 | + align-items: center; | |
| 129 | + justify-content: center; | |
| 130 | + gap: 12px; | |
| 131 | + padding: 48px 0; | |
| 132 | + color: var(--color-text-secondary); | |
| 133 | +} | |
| 134 | + | |
| 135 | +.loadErrorText { | |
| 136 | + color: var(--color-error); | |
| 137 | +} | ... | ... |
frontend/tests/unit/UserBasicForm.test.tsx
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: UserBasicForm 表单网格单测(字段/默认/只读/枚举/必填+格式/员工联动,BR1-BR9) | |
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| 3 | +import { screen, waitFor, within } from '@testing-library/react'; | |
| 4 | +import userEvent from '@testing-library/user-event'; | |
| 5 | +import { Form } from 'antd'; | |
| 6 | +import { renderShell } from './renderShell'; | |
| 7 | +import UserBasicForm from '../../src/pages/usr/UserDetail/UserBasicForm'; | |
| 8 | +import { CREATE_DEFAULTS, type UserFormValues } from '../../src/pages/usr/UserDetail/constants'; | |
| 9 | +import type { EmployeeOption } from '../../src/api/types'; | |
| 10 | + | |
| 11 | +const EMPLOYEES: EmployeeOption[] = [ | |
| 12 | + { value: 3, label: '张三', sEmployeeNo: 'zs' }, | |
| 13 | + { value: 4, label: '李四', sEmployeeNo: 'ls' }, | |
| 14 | +]; | |
| 15 | + | |
| 16 | +interface HarnessProps { | |
| 17 | + mode?: 'create' | 'edit'; | |
| 18 | + initialValues?: Partial<UserFormValues>; | |
| 19 | + onSelectEmployee?: (v: number | null) => void; | |
| 20 | + readonlyCreator?: string; | |
| 21 | + exposeSubmit?: (submit: () => void) => void; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function Harness({ | |
| 25 | + mode = 'create', | |
| 26 | + initialValues, | |
| 27 | + onSelectEmployee = () => {}, | |
| 28 | + readonlyCreator, | |
| 29 | + exposeSubmit, | |
| 30 | +}: HarnessProps) { | |
| 31 | + const [form] = Form.useForm<UserFormValues>(); | |
| 32 | + exposeSubmit?.(() => form.submit()); | |
| 33 | + return ( | |
| 34 | + <Form | |
| 35 | + form={form} | |
| 36 | + initialValues={{ ...CREATE_DEFAULTS, ...initialValues }} | |
| 37 | + onFinish={() => {}} | |
| 38 | + > | |
| 39 | + <UserBasicForm | |
| 40 | + form={form} | |
| 41 | + mode={mode} | |
| 42 | + employees={EMPLOYEES} | |
| 43 | + readonlyCreator={readonlyCreator} | |
| 44 | + onSelectEmployee={onSelectEmployee} | |
| 45 | + /> | |
| 46 | + </Form> | |
| 47 | + ); | |
| 48 | +} | |
| 49 | + | |
| 50 | +function setup(props: HarnessProps = {}) { | |
| 51 | + let submit = () => {}; | |
| 52 | + renderShell( | |
| 53 | + <Harness {...props} exposeSubmit={(s) => { submit = s; }} />, | |
| 54 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 55 | + ); | |
| 56 | + return { triggerSubmit: () => submit() }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +describe('UserBasicForm', () => { | |
| 60 | + beforeEach(() => { | |
| 61 | + vi.clearAllMocks(); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it('renders 8 labeled fields', () => { | |
| 65 | + setup(); | |
| 66 | + expect(screen.getByText('创建时间')).toBeInTheDocument(); | |
| 67 | + expect(screen.getByText('制单人')).toBeInTheDocument(); | |
| 68 | + expect(screen.getByText('员工名')).toBeInTheDocument(); | |
| 69 | + expect(screen.getByText('用户名')).toBeInTheDocument(); | |
| 70 | + expect(screen.getByText('类型')).toBeInTheDocument(); | |
| 71 | + expect(screen.getByText('语言')).toBeInTheDocument(); | |
| 72 | + expect(screen.getByText('用户号')).toBeInTheDocument(); | |
| 73 | + expect(screen.getByText('单据修改权限')).toBeInTheDocument(); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it('create mode username editable; edit mode username disabled', () => { | |
| 77 | + const { unmount } = renderShell( | |
| 78 | + <Harness mode="create" />, | |
| 79 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 80 | + ); | |
| 81 | + expect(screen.getByTestId('field-username')).not.toBeDisabled(); | |
| 82 | + unmount(); | |
| 83 | + renderShell( | |
| 84 | + <Harness mode="edit" initialValues={{ sUserName: 'zhangsan' }} />, | |
| 85 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 86 | + ); | |
| 87 | + expect(screen.getByTestId('field-username')).toBeDisabled(); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it('create mode defaults usertype 普通用户', () => { | |
| 91 | + setup({ mode: 'create' }); | |
| 92 | + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it('username format rule rejects short/invalid and required when empty', async () => { | |
| 96 | + const user = userEvent.setup(); | |
| 97 | + const { triggerSubmit } = setup({ mode: 'create' }); | |
| 98 | + const input = screen.getByTestId('field-username') as HTMLInputElement; | |
| 99 | + await user.type(input, 'ab'); | |
| 100 | + triggerSubmit(); | |
| 101 | + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); | |
| 102 | + await user.clear(input); | |
| 103 | + triggerSubmit(); | |
| 104 | + expect(await screen.findByText('请输入用户名')).toBeInTheDocument(); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it('userno required', async () => { | |
| 108 | + const { triggerSubmit } = setup({ mode: 'create', initialValues: { sUserName: 'zhangsan', sUserNo: '' } }); | |
| 109 | + triggerSubmit(); | |
| 110 | + expect(await screen.findByText('请输入用户号')).toBeInTheDocument(); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + it('usertype/language selects expose enum options only', async () => { | |
| 114 | + const user = userEvent.setup(); | |
| 115 | + setup({ mode: 'create' }); | |
| 116 | + await user.click(screen.getByTestId('select-usertype').querySelector('.ant-select-selector')!); | |
| 117 | + await waitFor(() => expect(screen.getAllByText('超级管理员').length).toBeGreaterThan(0)); | |
| 118 | + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); | |
| 119 | + await waitFor(() => { | |
| 120 | + expect(screen.getByText('英文')).toBeInTheDocument(); | |
| 121 | + expect(screen.getByText('繁体')).toBeInTheDocument(); | |
| 122 | + }); | |
| 123 | + }); | |
| 124 | + | |
| 125 | + it('create mode creator shows 保存后自动生成; edit shows readonlyCreator', () => { | |
| 126 | + const { unmount } = renderShell( | |
| 127 | + <Harness mode="create" />, | |
| 128 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 129 | + ); | |
| 130 | + expect(screen.getByText('保存后自动生成')).toBeInTheDocument(); | |
| 131 | + unmount(); | |
| 132 | + renderShell( | |
| 133 | + <Harness mode="edit" readonlyCreator="admin" />, | |
| 134 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | |
| 135 | + ); | |
| 136 | + expect(screen.getByText('admin')).toBeInTheDocument(); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + it('selecting employee calls onSelectEmployee', async () => { | |
| 140 | + const user = userEvent.setup(); | |
| 141 | + const onSelectEmployee = vi.fn(); | |
| 142 | + setup({ mode: 'create', onSelectEmployee }); | |
| 143 | + await user.click(screen.getByTestId('select-employee').querySelector('.ant-select-selector')!); | |
| 144 | + await user.click(await screen.findByText('张三')); | |
| 145 | + expect(onSelectEmployee).toHaveBeenCalledWith(3); | |
| 146 | + }); | |
| 147 | + | |
| 148 | + it('单据修改权限 checkbox default unchecked (create)', () => { | |
| 149 | + setup({ mode: 'create' }); | |
| 150 | + const cb = screen.getByTestId('field-canmodify') as HTMLInputElement; | |
| 151 | + expect(cb.checked).toBe(false); | |
| 152 | + }); | |
| 153 | +}); | ... | ... |