From 981d8e4a3a7c9d4292cb8204b4f499c9fc01c7d2 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 18:14:04 +0800 Subject: [PATCH] feat(usr): 用户单据表单网格 UserBasicForm REQ-USR-001 REQ-USR-002 --- frontend/src/pages/usr/UserDetail/UserBasicForm.tsx | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserDetail/UserDetail.module.css | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/UserBasicForm.test.tsx | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/usr/UserDetail/UserBasicForm.tsx create mode 100644 frontend/src/pages/usr/UserDetail/UserDetail.module.css create mode 100644 frontend/tests/unit/UserBasicForm.test.tsx diff --git a/frontend/src/pages/usr/UserDetail/UserBasicForm.tsx b/frontend/src/pages/usr/UserDetail/UserBasicForm.tsx new file mode 100644 index 0000000..11ef4e3 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/UserBasicForm.tsx @@ -0,0 +1,137 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据表单网格(3 列布局,8 字段 + 员工联动 + 前置校验,BR1-BR9) +import { Form, Input, Select, Checkbox, type FormInstance } from 'antd'; +import type { EmployeeOption } from '../../../api/types'; +import { + USER_TYPE_OPTIONS, + LANGUAGE_OPTIONS, + USERNAME_PATTERN, + LABEL_CREATE_TIME, + LABEL_CREATOR, + LABEL_EMPLOYEE, + LABEL_USERNAME, + LABEL_USER_TYPE, + LABEL_LANGUAGE, + LABEL_USER_NO, + LABEL_CAN_MODIFY_BILL, + TEXT_CREATOR_PLACEHOLDER, + MSG_USERNAME_FORMAT, + MSG_USERNAME_REQUIRED, + MSG_USERNO_REQUIRED, + MSG_USERTYPE_REQUIRED, + MSG_LANGUAGE_REQUIRED, + type UserFormValues, +} from './constants'; +import styles from './UserDetail.module.css'; + +export interface UserBasicFormProps { + form: FormInstance; + mode: 'create' | 'edit'; + employees: EmployeeOption[]; + readonlyCreateTime?: string; + readonlyCreator?: string; + onSelectEmployee(value: number | null): void; +} + +const toEnumOptions = (arr: readonly string[]) => arr.map((v) => ({ value: v, label: v })); + +export default function UserBasicForm({ + form, + mode, + employees, + readonlyCreateTime, + readonlyCreator, + onSelectEmployee, +}: UserBasicFormProps) { + void form; // 表单由父级 Form 实例驱动(受控 initialValues / validate / submit) + + const creatorText = + mode === 'edit' ? readonlyCreator || '' : TEXT_CREATOR_PLACEHOLDER; + const createTimeText = mode === 'edit' ? readonlyCreateTime || '' : ''; + + return ( +
+ {/* 创建时间(只读,BR1) */} + +
+ {createTimeText} +
+
+ + {/* 制单人(只读,BR2) */} + +
+ {creatorText} +
+
+ + {/* 员工名(Select,选中联动带出用户名/用户号,BR5) */} + + + + + {/* 类型(Select 枚举,create 默认普通用户,BR6) */} + + + + + {/* 用户号(必填,BR4,可由员工名联动带出,BR5) */} + + + + + {/* 单据修改权限(Checkbox,默认否,BR8) */} + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserDetail/UserDetail.module.css b/frontend/src/pages/usr/UserDetail/UserDetail.module.css new file mode 100644 index 0000000..9086720 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/UserDetail.module.css @@ -0,0 +1,137 @@ +/* REQ-USR-001 / REQ-USR-002: 用户单据页 scoped 样式。 + 语义色只用 var(--color-*);工具栏深色底为页面局部装饰(非语义 token,D10,与 FE-02/FE-03 一致)。 */ + +.page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-base); +} + +/* === 工具栏:深色底为页面局部装饰(D10) === */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #2c2f36; +} + +.toolbarSpacer { + flex: 1 1 auto; +} + +.toolbar :global(.ant-btn) { + color: #ffffff; +} + +.toolbar :global(.ant-btn-primary) { + color: #ffffff; +} + +.gear { + color: #ffffff; + font-size: 16px; + cursor: pointer; + padding: 0 8px; +} + +/* === 表单网格:3 列白底 === */ +.formGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0 16px; + padding: 16px 12px; + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); + color: var(--color-form-fg); +} + +.readonlyField { + display: flex; + align-items: center; + min-height: 32px; + padding: 0 11px; + background: var(--color-form-bg-readonly); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-form-fg); +} + +.formGrid :global(.ant-form-item-label > label) { + color: var(--color-text); +} + +/* === 权限页签条 === */ +.permTabs { + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); +} + +.permTabs :global(.ant-tabs-tab) { + color: var(--color-text); +} + +.permTabs :global(.ant-tabs-tab-disabled) { + color: var(--color-text-secondary); +} + +.permTabs :global(.ant-tabs-ink-bar) { + background: var(--color-primary); +} + +/* === 权限分类勾选列表 === */ +.permList { + flex: 1 1 auto; + overflow: auto; + background: var(--color-form-bg-edit); +} + +.permHead { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-table-header-bg); + color: var(--color-table-header-fg); + border-bottom: 1px solid var(--color-border); +} + +.permHeadSort { + margin-left: auto; + color: var(--color-text-secondary); +} + +.permRow { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + color: var(--color-text); + border-bottom: 1px solid var(--color-border); +} + +.permRow:hover { + background: var(--color-table-row-bg-hover); +} + +.permEmpty { + padding: 24px 12px; + color: var(--color-text-secondary); + text-align: center; +} + +/* === 取数失败占位 === */ +.loadError { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 0; + color: var(--color-text-secondary); +} + +.loadErrorText { + color: var(--color-error); +} diff --git a/frontend/tests/unit/UserBasicForm.test.tsx b/frontend/tests/unit/UserBasicForm.test.tsx new file mode 100644 index 0000000..7135362 --- /dev/null +++ b/frontend/tests/unit/UserBasicForm.test.tsx @@ -0,0 +1,153 @@ +// REQ-USR-001 / REQ-USR-002: UserBasicForm 表单网格单测(字段/默认/只读/枚举/必填+格式/员工联动,BR1-BR9) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Form } from 'antd'; +import { renderShell } from './renderShell'; +import UserBasicForm from '../../src/pages/usr/UserDetail/UserBasicForm'; +import { CREATE_DEFAULTS, type UserFormValues } from '../../src/pages/usr/UserDetail/constants'; +import type { EmployeeOption } from '../../src/api/types'; + +const EMPLOYEES: EmployeeOption[] = [ + { value: 3, label: '张三', sEmployeeNo: 'zs' }, + { value: 4, label: '李四', sEmployeeNo: 'ls' }, +]; + +interface HarnessProps { + mode?: 'create' | 'edit'; + initialValues?: Partial; + onSelectEmployee?: (v: number | null) => void; + readonlyCreator?: string; + exposeSubmit?: (submit: () => void) => void; +} + +function Harness({ + mode = 'create', + initialValues, + onSelectEmployee = () => {}, + readonlyCreator, + exposeSubmit, +}: HarnessProps) { + const [form] = Form.useForm(); + exposeSubmit?.(() => form.submit()); + return ( +
{}} + > + + + ); +} + +function setup(props: HarnessProps = {}) { + let submit = () => {}; + renderShell( + { submit = s; }} />, + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { triggerSubmit: () => submit() }; +} + +describe('UserBasicForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 8 labeled fields', () => { + setup(); + expect(screen.getByText('创建时间')).toBeInTheDocument(); + expect(screen.getByText('制单人')).toBeInTheDocument(); + expect(screen.getByText('员工名')).toBeInTheDocument(); + expect(screen.getByText('用户名')).toBeInTheDocument(); + expect(screen.getByText('类型')).toBeInTheDocument(); + expect(screen.getByText('语言')).toBeInTheDocument(); + expect(screen.getByText('用户号')).toBeInTheDocument(); + expect(screen.getByText('单据修改权限')).toBeInTheDocument(); + }); + + it('create mode username editable; edit mode username disabled', () => { + const { unmount } = renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByTestId('field-username')).not.toBeDisabled(); + unmount(); + renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByTestId('field-username')).toBeDisabled(); + }); + + it('create mode defaults usertype 普通用户', () => { + setup({ mode: 'create' }); + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); + }); + + it('username format rule rejects short/invalid and required when empty', async () => { + const user = userEvent.setup(); + const { triggerSubmit } = setup({ mode: 'create' }); + const input = screen.getByTestId('field-username') as HTMLInputElement; + await user.type(input, 'ab'); + triggerSubmit(); + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); + await user.clear(input); + triggerSubmit(); + expect(await screen.findByText('请输入用户名')).toBeInTheDocument(); + }); + + it('userno required', async () => { + const { triggerSubmit } = setup({ mode: 'create', initialValues: { sUserName: 'zhangsan', sUserNo: '' } }); + triggerSubmit(); + expect(await screen.findByText('请输入用户号')).toBeInTheDocument(); + }); + + it('usertype/language selects expose enum options only', async () => { + const user = userEvent.setup(); + setup({ mode: 'create' }); + await user.click(screen.getByTestId('select-usertype').querySelector('.ant-select-selector')!); + await waitFor(() => expect(screen.getAllByText('超级管理员').length).toBeGreaterThan(0)); + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); + await waitFor(() => { + expect(screen.getByText('英文')).toBeInTheDocument(); + expect(screen.getByText('繁体')).toBeInTheDocument(); + }); + }); + + it('create mode creator shows 保存后自动生成; edit shows readonlyCreator', () => { + const { unmount } = renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByText('保存后自动生成')).toBeInTheDocument(); + unmount(); + renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('selecting employee calls onSelectEmployee', async () => { + const user = userEvent.setup(); + const onSelectEmployee = vi.fn(); + setup({ mode: 'create', onSelectEmployee }); + await user.click(screen.getByTestId('select-employee').querySelector('.ant-select-selector')!); + await user.click(await screen.findByText('张三')); + expect(onSelectEmployee).toHaveBeenCalledWith(3); + }); + + it('单据修改权限 checkbox default unchecked (create)', () => { + setup({ mode: 'create' }); + const cb = screen.getByTestId('field-canmodify') as HTMLInputElement; + expect(cb.checked).toBe(false); + }); +}); -- libgit2 0.22.2