Commit 981d8e4a3a7c9d4292cb8204b4f499c9fc01c7d2

Authored by zichun
1 parent 4e0722ef

feat(usr): 用户单据表单网格 UserBasicForm REQ-USR-001 REQ-USR-002

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 +});