适用于 React + TypeScript + Arco Design Web 的配置化表单与弹窗表单方案
文档目标: 说明当前 FormEdit / FormEditModal / types / utils 方案的职责分层、设计原因、优点与风险、关键语法,以及实际使用方式。
1. 方案概览
当前方案是一套"配置驱动 + ref 驱动 + 弹窗承载 + 字段类型扩展"的通用表单基础设施。它的核心不是写死某个业务表单,而是通过一组统一的配置和类型,把新增、编辑、查看、弹窗提交这些高重复场景抽象出来。
• FormEdit:负责表单渲染、字段分发、初始值处理、对外暴露表单 API。
• FormEditModal:负责弹窗显示、确定/取消流程、是否关闭、是否重置。
• types:负责定义字段类型、配置结构、ref 方法、组件 props,是整个方案的契约层。
• utils:负责类名合并、初始值归一化、根据表单配置构建整表默认值。
2. 每个代码文件的详解
2.1 types.ts
types.ts 是整套方案最底层的协议文件。它的作用不是直接渲染 UI,而是约束"这套表单系统支持什么配置、暴露什么能力、字段有哪些类型"。
• 定义 EditState:统一新增、编辑、查看三种状态,避免业务层自行约定字符串。
• 定义 FormFieldType:从基础的 input / textarea / select 扩展到 switch / upload / cascader / datePicker / custom。
• 定义 FormItemConfig:让每个字段既包含业务属性,也包含布局和组件渲染属性。
• 定义 FormEditRef:把 validate、resetFields、setValues、getValues、setFieldValue 等能力统一收敛。
• 定义 FormEditProps:支持表单整体布局方向 direction、列数 columns、回填控制 syncKey、卡片/纯内容 layout 等。
css
import type { ReactNode, RefObject } from 'react';
export type EditState = 'add' | 'edit' | 'view';
export type FieldValue = string | number | boolean;
export type FormFieldType =
| 'input'
| 'textarea'
| 'select'
| 'radioGroup'
| 'checkboxGroup'
| 'switch'
| 'upload'
| 'cascader'
| 'datePicker'
| 'divider'
| 'custom';
export interface OptionItem {
key?: string | number;
label: string;
value: FieldValue;
disabled?: boolean;
children?: OptionItem[];
}
export interface RuleItem {
required?: boolean;
message?: string;
trigger?: 'change' | 'blur' | Array<'change' | 'blur'>;
validator?: (value: unknown, values: Record<string, unknown>) => void | string | Promise<void | string>;
}
export interface RenderFieldContext {
value: unknown;
formData: Record<string, unknown>;
editState: EditState;
disabled: boolean;
setFieldValue: (key: string, value: unknown) => void;
getFieldValue: (key: string) => unknown;
setValues: (values: Record<string, unknown>) => void;
}
export interface FormItemConfig {
key: string;
title?: string;
type: FormFieldType;
hidden?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
/**
* columns=2 时可设置 2 跨整行
*/
colSpan?: 1 | 2;
/**
* 外层栅格项 className
*/
className?: string;
/**
* Form.Item 自身 className
*/
formItemClassName?: string;
rules?: RuleItem[];
maxLength?: number;
showWordLimit?: boolean;
rows?: number;
/**
* select / radioGroup / checkboxGroup / cascader 共用
*/
options?: OptionItem[];
/**
* select 多选
*/
mode?: 'single' | 'multiple';
/**
* 选项展示 label-value
*/
showKV?: boolean;
/**
* radio / checkbox 排列方向
*/
direction?: 'horizontal' | 'vertical';
/**
* checkbox 最大可选数
*/
max?: number;
extra?: ReactNode;
initialValue?: unknown;
/**
* 透传给具体字段组件的属性
*/
fieldProps?: Record<string, unknown>;
/**
* switch 专用
*/
checkedValue?: string | number | boolean;
uncheckedValue?: string | number | boolean;
/**
* upload 专用
*/
uploadAction?: string;
limit?: number;
/**
* datePicker 专用
*/
datePickerType?: 'date' | 'week' | 'month' | 'quarter' | 'year' | 'range';
format?: string;
/**
* custom 专用
*/
render?: (ctx: RenderFieldContext) => ReactNode;
onChange?: (value: unknown, formData: Record<string, unknown>) => void;
}
export interface FormEditProps {
modelValue?: Record<string, unknown>;
formArr: FormItemConfig[];
editState?: EditState;
className?: string;
title?: ReactNode;
description?: ReactNode;
width?: number | string;
layout?: 'card' | 'plain';
/**
* 表单布局方向
* horizontal:水平布局
* vertical:垂直布局
*/
direction?: 'horizontal' | 'vertical';
/**
* 默认 1 列
* 字段多时可用 2 列
*/
columns?: 1 | 2;
/**
* 用于控制何时重新回填表单
* 推荐编辑态传入主键,例如 id
*/
syncKey?: string | number | null | undefined;
onValuesChange?: (changedValues: Record<string, unknown>, values: Record<string, unknown>) => void;
}
export interface FormEditRef {
validate: () => Promise<boolean>;
resetFields: () => void;
setValues: (values: Record<string, unknown>) => void;
getValues: () => Record<string, unknown>;
setFieldValue: (key: string, value: unknown) => void;
getFieldValue: (key: string) => unknown;
clearErrors: () => void;
}
export interface FormEditModalProps {
open?: boolean;
title?: ReactNode;
width?: string | number;
className?: string;
contentClassName?: string;
showFooter?: boolean;
useCustomFooter?: boolean;
footer?: ReactNode;
okText?: string;
cancelText?: string;
confirmLoading?: boolean;
maskClosable?: boolean;
escToClose?: boolean;
destroyOnClose?: boolean;
closeOnOk?: boolean;
closeOnCancel?: boolean;
formRef?: RefObject<FormEditRef | null>;
validateBeforeOk?: boolean;
resetAfterClose?: boolean;
children?: ReactNode;
onOpenChange?: (open: boolean) => void;
onCancel?: () => void;
onOk?: (values?: Record<string, unknown>) => void | Promise<void>;
}
export interface FormEditModalRef {
open: () => void;
close: () => void;
toggle: () => void;
}
这一层的好处是:调用方和组件实现方共用同一份类型约束,后续扩展字段类型时,编译器会帮助你定位所有待补位置。
2.2 utils.ts
utils.ts 的核心是让表单初始值更稳定。通用表单最容易出问题的地方,不是"字段渲染不出来",而是不同控件在新增态、编辑态、回填态下的默认值类型不一致。
• cx:合并 className,支持字符串、数组、对象条件写法。
• normalizeInitialValue:根据字段类型给出合理默认值,例如多选 select、upload、cascader、range 类型日期使用数组,switch 使用 uncheckedValue 或 false。
• buildInitialFormData:把单字段归一化提升为整张表单数据的构建函数。
• getVisibleFormItems:过滤 hidden 字段,避免隐藏字段仍参与渲染和初始值流程。
ini
import type { FormItemConfig } from './types';
type ClassDictionary = Record<string, boolean | null | undefined>;
type ClassArray = ClassValue[];
type ClassValue = string | null | undefined | false | ClassDictionary | ClassArray;
export function cx(...args: ClassValue[]): string {
const classes: string[] = [];
const append = (value: ClassValue) => {
if (!value) return;
if (typeof value === 'string') {
classes.push(value);
return;
}
if (Array.isArray(value)) {
value.forEach(append);
return;
}
if (typeof value === 'object') {
Object.keys(value).forEach((key) => {
if (value[key]) {
classes.push(key);
}
});
}
};
args.forEach(append);
return classes.join(' ');
}
export function normalizeInitialValue(item: FormItemConfig, value: unknown): unknown {
if (value !== undefined) return value;
if (item.initialValue !== undefined) return item.initialValue;
if (item.type === 'checkboxGroup') return [];
if (item.type === 'select' && item.mode === 'multiple') return [];
if (item.type === 'upload') return [];
if (item.type === 'cascader') return [];
if (item.type === 'datePicker' && item.datePickerType === 'range') return [];
if (item.type === 'switch') {
return item.uncheckedValue ?? false;
}
return '';
}
export function buildInitialFormData(formArr: FormItemConfig[], modelValue: Record<string, unknown> = {}) {
const nextData: Record<string, unknown> = {};
formArr.forEach((item) => {
if (item.type === 'divider') return;
nextData[item.key] = normalizeInitialValue(item, modelValue[item.key]);
});
return nextData;
}
export function getVisibleFormItems(formArr: FormItemConfig[]) {
return formArr.filter((item) => !item.hidden);
}
这层看似简单,但它决定了表单是否能稳定受控,是否会出现默认值错乱或组件警告。
2.3 formEdit.tsx
formEdit.tsx 是整套方案的核心。它接收字段配置 formArr,把配置转换成真正的 Arco Form 结构,并通过 ref 向父组件暴露命令式方法。
• 通过 Form.useForm 获取 Arco 表单实例。
• 通过 useMemo 构建 initialValues,避免每次 render 都重新生成。
• 通过 syncKey + lastPatchedKeyRef 控制何时重新回填表单,避免误覆盖用户输入。
• 通过 buildArcoRules 把业务规则适配成 Arco 规则。
• 通过 renderField 按 type 分发不同字段类型的渲染逻辑。
• 通过 direction 控制 Form 的整体布局方向,通过 columns + colSpan 控制字段网格布局。
ini
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import {
Card,
Cascader,
Checkbox,
DatePicker,
Divider,
Form,
Input,
Radio,
Select,
Switch,
Typography,
Upload,
} from '@arco-design/web-react';
import type { FormEditProps, FormEditRef, FormItemConfig } from './types';
import { buildInitialFormData, cx, getVisibleFormItems } from './utils';
const { Title, Paragraph, Text } = Typography;
type FormItemProps = React.ComponentProps<typeof Form.Item>;
type ArcoRules = NonNullable<FormItemProps['rules']>;
type ArcoRule = ArcoRules extends Array<infer T> ? T : never;
function buildArcoRules(item: FormItemConfig, getValues: () => Record<string, unknown>): ArcoRule[] {
const rules: ArcoRule[] = [];
if (item.required) {
rules.push({
required: true,
message: `${item.title || item.key}不能为空`,
} as ArcoRule);
}
if (!item.rules?.length) {
return rules;
}
item.rules.forEach((rule) => {
if (rule.required) {
rules.push({
required: true,
message: rule.message || `${item.title || item.key}不能为空`,
trigger: rule.trigger,
} as ArcoRule);
}
if (rule.validator) {
rules.push({
trigger: rule.trigger,
validator: (value, callback) => {
Promise.resolve(rule.validator?.(value, getValues()))
.then((result) => {
if (typeof result === 'string' && result) {
callback(result);
return;
}
callback();
})
.catch((error: unknown) => {
if (error instanceof Error) {
callback(error.message);
return;
}
if (typeof error === 'string') {
callback(error);
return;
}
callback(rule.message || '校验失败');
});
},
} as ArcoRule);
}
});
return rules;
}
function getFieldColClass(columns: 1 | 2, item: FormItemConfig) {
if (columns === 1) return 'col-span-1';
return item.colSpan === 2 ? 'col-span-2' : 'col-span-1';
}
function renderLabel(item: FormItemConfig) {
if (!item.title) return null;
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap">
{item.required ? <span className="leading-none text-red-500">*</span> : null}
<span>{item.title}</span>
</span>
);
}
function FormEditInner(
{
modelValue = {},
formArr,
editState = 'add',
className,
title,
description,
width = 760,
layout = 'card',
direction = 'vertical',
columns = 1,
syncKey,
onValuesChange,
}: FormEditProps,
ref: React.Ref<FormEditRef>,
) {
const [form] = Form.useForm();
const isView = editState === 'view';
const visibleFormItems = useMemo(() => {
return getVisibleFormItems(formArr);
}, [formArr]);
const initialValues = useMemo(() => {
return buildInitialFormData(visibleFormItems, modelValue);
}, [visibleFormItems, modelValue]);
const currentPatchKey = syncKey ?? modelValue;
const lastPatchedKeyRef = useRef(currentPatchKey);
const mountedRef = useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
form.setFieldsValue(initialValues);
lastPatchedKeyRef.current = currentPatchKey;
return;
}
if (lastPatchedKeyRef.current === currentPatchKey) return;
lastPatchedKeyRef.current = currentPatchKey;
form.resetFields();
form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
}, [currentPatchKey, form, initialValues, modelValue, visibleFormItems]);
useImperativeHandle(
ref,
() => ({
validate: async () => {
try {
await form.validate();
return true;
} catch {
return false;
}
},
resetFields: () => {
form.resetFields();
form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
},
setValues: (values) => {
form.setFieldsValue(values);
},
getValues: () => {
return form.getFieldsValue() as Record<string, unknown>;
},
setFieldValue: (key, value) => {
form.setFieldValue(key, value);
},
getFieldValue: (key) => {
return form.getFieldValue(key);
},
clearErrors: () => {
const currentValues = form.getFieldsValue() as Record<string, unknown>;
form.clearFields();
form.setFieldsValue(currentValues);
},
}),
[form, modelValue, visibleFormItems],
);
const getAllValues = () => {
return form.getFieldsValue() as Record<string, unknown>;
};
const renderField = (item: FormItemConfig) => {
const commonDisabled = isView || item.disabled;
const commonValue = form.getFieldValue(item.key);
const fieldContext = {
value: commonValue,
formData: getAllValues(),
editState,
disabled: !!commonDisabled,
setFieldValue: (key: string, value: unknown) => form.setFieldValue(key, value),
getFieldValue: (key: string) => form.getFieldValue(key),
setValues: (values: Record<string, unknown>) => form.setFieldsValue(values),
};
switch (item.type) {
case 'input':
return (
<Input
allowClear
disabled={commonDisabled}
placeholder={item.placeholder || '请输入'}
maxLength={item.maxLength}
showWordLimit={item.showWordLimit}
{...item.fieldProps}
/>
);
case 'textarea':
return (
<Input.TextArea
allowClear
disabled={commonDisabled}
placeholder={item.placeholder || '请输入'}
maxLength={item.maxLength}
showWordLimit={item.showWordLimit}
autoSize={{
minRows: item.rows || 4,
maxRows: Math.max((item.rows || 4) + 3, 6),
}}
{...item.fieldProps}
/>
);
case 'select':
return (
<Select
allowClear
disabled={commonDisabled}
placeholder={item.placeholder || '请选择'}
mode={item.mode === 'multiple' ? 'multiple' : undefined}
maxTagCount={item.mode === 'multiple' ? 3 : undefined}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
>
{(item.options || []).map((opt) => (
<Select.Option key={opt.key ?? String(opt.value)} value={opt.value} disabled={opt.disabled}>
{item.showKV ? `${opt.label}-${opt.value}` : opt.label}
</Select.Option>
))}
</Select>
);
case 'radioGroup':
return (
<Radio.Group
direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
disabled={commonDisabled}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
>
{(item.options || []).map((opt) => (
<Radio key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
{opt.label}
</Radio>
))}
</Radio.Group>
);
case 'checkboxGroup':
return (
<Checkbox.Group
direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
disabled={commonDisabled}
max={item.max}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
>
{(item.options || []).map((opt) => (
<Checkbox key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
{opt.label}
</Checkbox>
))}
</Checkbox.Group>
);
case 'switch':
return (
<Switch
disabled={commonDisabled}
checked={commonValue === (item.checkedValue ?? true)}
checkedValue={item.checkedValue ?? true}
uncheckedValue={item.uncheckedValue ?? false}
onChange={(value) => {
form.setFieldValue(item.key, value);
item.onChange?.(value, {
...getAllValues(),
[item.key]: value,
});
}}
{...item.fieldProps}
/>
);
case 'upload':
return (
<Upload
disabled={commonDisabled}
action={item.uploadAction}
limit={item.limit}
fileList={Array.isArray(commonValue) ? (commonValue as never[]) : []}
onChange={(fileList) => {
form.setFieldValue(item.key, fileList);
item.onChange?.(fileList, {
...getAllValues(),
[item.key]: fileList,
});
}}
{...item.fieldProps}
/>
);
case 'cascader':
return (
<Cascader
disabled={commonDisabled}
placeholder={item.placeholder || '请选择'}
options={item.options || []}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
/>
);
case 'datePicker':
if (item.datePickerType === 'range') {
return (
<DatePicker.RangePicker
disabled={commonDisabled}
placeholder={['开始日期', '结束日期']}
format={item.format}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
/>
);
}
return (
<DatePicker
disabled={commonDisabled}
placeholder={item.placeholder || '请选择日期'}
format={item.format}
picker={item.datePickerType && item.datePickerType !== 'date' ? item.datePickerType : undefined}
onChange={(value) => {
item.onChange?.(value, getAllValues());
}}
{...item.fieldProps}
/>
);
case 'divider':
return <Divider style={{ margin: '4px 0 12px' }} />;
case 'custom':
if (item.render) {
return item.render(fieldContext);
}
return <Text type="secondary">custom 类型缺少 render 配置</Text>;
default:
return <Text type="secondary">暂不支持的表单类型:{item.type}</Text>;
}
};
const formContent = (
<div className={cx('w-full', className)}>
{title || description ? (
<div style={{ marginBottom: 24 }}>
{title ? <Title heading={6}>{title}</Title> : null}
{description ? (
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{description}
</Paragraph>
) : null}
</div>
) : null}
<Form
form={form}
layout={direction}
initialValues={initialValues}
autoComplete="off"
onValuesChange={(changedValues, values) => {
onValuesChange?.(changedValues as Record<string, unknown>, values as Record<string, unknown>);
}}
>
<div className={cx('grid gap-x-5', columns === 2 ? 'grid-cols-2' : 'grid-cols-1')}>
{visibleFormItems.map((item, index) => {
if (item.type === 'divider') {
return (
<div key={`${item.key}-${index}`} className="col-span-full">
{renderField(item)}
</div>
);
}
return (
<div key={`${item.key}-${index}`} className={cx(getFieldColClass(columns, item), item.className)}>
<Form.Item
className={item.formItemClassName}
label={renderLabel(item)}
field={item.key}
rules={buildArcoRules(item, () => form.getFieldsValue() as Record<string, unknown>)}
requiredSymbol={false}
extra={item.extra}
triggerPropName={item.type === 'switch' ? 'checked' : 'value'}
>
{renderField(item)}
</Form.Item>
</div>
);
})}
</div>
</Form>
</div>
);
const containerStyle = {
width: typeof width === 'number' ? `${width}px` : width,
maxWidth: '100%',
margin: '0 auto',
};
if (layout === 'plain') {
return <div style={containerStyle}>{formContent}</div>;
}
return (
<Card bordered style={{ borderRadius: 16 }}>
<div style={containerStyle}>{formContent}</div>
</Card>
);
}
FormEditInner.displayName = 'FormEdit';
const FormEdit = forwardRef(FormEditInner);
FormEdit.displayName = 'FormEdit';
export default FormEdit;
当前版本里,renderField 已经覆盖 input、textarea、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、divider、custom 等类型,说明这套组件已经从"基础表单"提升为"可承载复杂业务表单"的组件。
2.4 formEditModal.tsx
formEditModal.tsx 负责承载表单弹窗。它把"打开 / 关闭、点击确定、点击取消、关闭后是否重置、提交前是否校验"等通用行为统一起来,让业务页面只关心 open 状态和 onOk 保存逻辑。
• 支持受控 / 非受控兼容的显示方式。
• 支持通过 formRef 在点击确定前自动 validate。
• 支持 closeOnOk / closeOnCancel 控制点击后是否关闭。
• 支持 resetAfterClose 控制关闭后是否恢复初始值。
• 支持自定义 footer,也支持内置确定 / 取消按钮。
这一层的价值在于:把弹窗提交流程标准化,避免每个页面重复写 validate -> getValues -> onOk -> close 这类模板代码。
ini
import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { Button, Modal, Space } from '@arco-design/web-react';
import type { FormEditModalProps, FormEditModalRef } from './types';
function FormEditModalInner(
{
open,
title,
width = 720,
className,
contentClassName,
showFooter = true,
useCustomFooter = false,
footer,
okText = '确定',
cancelText = '取消',
confirmLoading = false,
maskClosable = false,
escToClose = true,
destroyOnClose = true,
closeOnOk = true,
closeOnCancel = true,
formRef,
validateBeforeOk = true,
resetAfterClose = false,
children,
onOpenChange,
onCancel,
onOk,
}: FormEditModalProps,
ref: React.Ref<FormEditModalRef>,
) {
const [innerOpen, setInnerOpen] = useState(false);
const isControlled = open !== undefined;
const visible = isControlled ? open : innerOpen;
const setVisible = (next: boolean) => {
if (!isControlled) {
setInnerOpen(next);
}
onOpenChange?.(next);
};
const handleClose = () => {
setVisible(false);
};
const handleAfterClose = () => {
if (resetAfterClose) {
formRef?.current?.resetFields();
}
};
const handleCancel = () => {
onCancel?.();
if (closeOnCancel) {
handleClose();
}
};
const handleOk = async () => {
if (validateBeforeOk && formRef?.current) {
const passed = await formRef.current.validate();
if (!passed) return;
}
const values = formRef?.current?.getValues();
try {
await onOk?.(values);
if (closeOnOk) {
handleClose();
}
} catch (error) {
console.error('FormEditModal onOk 执行失败:', error);
}
};
useImperativeHandle(
ref,
() => ({
open: () => setVisible(true),
close: () => setVisible(false),
toggle: () => setVisible(!visible),
}),
[visible],
);
const defaultFooter = useMemo(() => {
if (!showFooter) return null;
if (useCustomFooter) {
return footer;
}
return (
<Space>
<Button onClick={handleCancel}>{cancelText}</Button>
<Button type="primary" loading={confirmLoading} onClick={handleOk}>
{okText}
</Button>
</Space>
);
}, [cancelText, confirmLoading, footer, okText, showFooter, useCustomFooter, handleCancel, handleOk]);
return (
<Modal
visible={visible}
title={title}
style={{ width }}
className={className}
unmountOnExit={destroyOnClose}
maskClosable={maskClosable}
escToExit={escToClose}
onCancel={handleCancel}
afterClose={handleAfterClose}
footer={defaultFooter}
>
<div className={contentClassName}>{children}</div>
</Modal>
);
}
FormEditModalInner.displayName = 'FormEditModal';
const FormEditModal = forwardRef(FormEditModalInner);
FormEditModal.displayName = 'FormEditModal';
export default FormEditModal;
2.5 示例页面
javascript
import { useMemo, useRef, useState } from 'react';
import { Button, Input, Message, Space, Tag } from '@arco-design/web-react';
import { FormEdit, FormEditModal } from '@/components/FormEdit';
import type { FormEditRef, FormItemConfig } from '@/components/FormEdit';
export default function DemoModalForm() {
const singleColFormRef = useRef<FormEditRef>(null);
const doubleColFormRef = useRef<FormEditRef>(null);
const customFormRef = useRef<FormEditRef>(null);
const [singleOpen, setSingleOpen] = useState(false);
const [doubleOpen, setDoubleOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(false);
const [singleSubmitLoading, setSingleSubmitLoading] = useState(false);
const [doubleSubmitLoading, setDoubleSubmitLoading] = useState(false);
const [customSubmitLoading, setCustomSubmitLoading] = useState(false);
// 一行一列:字段少,适合小弹窗
const singleColumnFormArr = useMemo<FormItemConfig[]>(() => {
return [
{
key: 'name',
title: '名称',
type: 'input',
required: true,
placeholder: '请输入名称',
},
{
key: 'city',
title: '所属城市',
type: 'select',
placeholder: '请选择城市',
required: true,
options: [
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '深圳', value: 'shenzhen' },
],
},
{
key: 'desc',
title: '说明',
type: 'textarea',
placeholder: '请输入说明',
rows: 5,
extra: '适合字段较少、弹窗较窄的场景',
},
];
}, []);
// 一行两列:字段多,部分字段支持跨整行
const doubleColumnFormArr = useMemo<FormItemConfig[]>(() => {
return [
{
key: 'name',
title: '名称',
type: 'input',
required: true,
placeholder: '请输入名称',
},
{
key: 'city',
title: '所属城市',
type: 'select',
placeholder: '请选择城市',
options: [
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '深圳', value: 'shenzhen' },
],
},
{
key: 'type',
title: '类型',
type: 'radioGroup',
options: [
{ label: '个人', value: 'personal' },
{ label: '企业', value: 'company' },
],
},
{
key: 'tags',
title: '标签',
type: 'checkboxGroup',
direction: 'horizontal',
options: [
{ label: '热门', value: 'hot' },
{ label: '推荐', value: 'recommend' },
{ label: '最新', value: 'new' },
],
},
{
key: 'desc',
title: '说明',
type: 'textarea',
placeholder: '请输入说明',
rows: 5,
colSpan: 2,
extra: '这个字段比较长,所以在两列布局里跨整行显示',
},
];
}, []);
// 一行 2 列,使用全部封装组件
const customFormArr = useMemo<FormItemConfig[]>(() => {
return [
{
key: 'projectName',
title: '项目名称',
type: 'input',
required: true,
placeholder: '请输入项目名称',
},
{
key: 'status',
title: '启用状态',
type: 'switch',
required: true,
checkedValue: 1,
uncheckedValue: 0,
initialValue: 1,
extra: '开启为 1,关闭为 0',
},
{
key: 'city',
title: '所属城市',
type: 'select',
placeholder: '请选择城市',
required: true,
options: [
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '深圳', value: 'shenzhen' },
{ label: '杭州', value: 'hangzhou' },
],
},
{
key: 'identityType',
title: '用户类型',
type: 'radioGroup',
required: true,
options: [
{ label: '个人', value: 'personal' },
{ label: '企业', value: 'company' },
],
},
{
key: 'tags',
title: '标签',
type: 'checkboxGroup',
direction: 'horizontal',
options: [
{ label: '热门', value: 'hot' },
{ label: '推荐', value: 'recommend' },
{ label: '最新', value: 'new' },
],
},
{
key: 'region',
title: '地区级联',
type: 'cascader',
required: true,
placeholder: '请选择地区',
options: [
{
label: '浙江省',
value: 'zhejiang',
children: [
{
label: '杭州市',
value: 'hangzhou',
children: [
{ label: '西湖区', value: 'xihu' },
{ label: '滨江区', value: 'binjiang' },
],
},
{
label: '宁波市',
value: 'ningbo',
children: [{ label: '鄞州区', value: 'yinzhou' }],
},
],
},
{
label: '广东省',
value: 'guangdong',
children: [
{
label: '深圳市',
value: 'shenzhen',
children: [
{ label: '南山区', value: 'nanshan' },
{ label: '福田区', value: 'futian' },
],
},
],
},
],
},
{
key: 'publishDate',
title: '发布日期',
type: 'datePicker',
required: true,
datePickerType: 'date',
format: 'YYYY-MM-DD',
placeholder: '请选择发布日期',
},
{
key: 'timeRange',
title: '时间范围',
type: 'datePicker',
required: true,
datePickerType: 'range',
format: 'YYYY-MM-DD',
},
{
key: 'cover',
title: '上传封面',
type: 'upload',
uploadAction: '/api/upload',
limit: 1,
fieldProps: {
listType: 'picture-card',
imagePreview: true,
},
extra: '示例,后续需要讲他封装为单独的组件,图片上传oss',
},
{
key: 'customField',
title: '自定义组件',
type: 'custom',
required: false,
render: ({ value, setFieldValue, disabled }) => {
return (
<div className="flex flex-wrap items-center gap-2">
<Tag
checkable
checked={value === 'A'}
onClick={() => {
if (disabled) return;
setFieldValue('customField', 'A');
}}
>
方案 A
</Tag>
<Tag
checkable
checked={value === 'B'}
onClick={() => {
if (disabled) return;
setFieldValue('customField', 'B');
}}
>
方案 B
</Tag>
<Tag
checkable
checked={value === 'C'}
onClick={() => {
if (disabled) return;
setFieldValue('customField', 'C');
}}
>
方案 C
</Tag>
<Input
style={{ width: 220 }}
placeholder="也可以输入自定义值"
value={typeof value === 'string' ? value : ''}
disabled={disabled}
onChange={(nextValue) => {
setFieldValue('customField', nextValue);
}}
/>
</div>
);
},
extra: '这里演示 custom 自定义渲染能力',
},
{
key: 'desc',
title: '说明',
type: 'textarea',
placeholder: '请输入详细说明',
rows: 5,
colSpan: 2,
extra: '最下面一行跨整行显示,用于填写较长说明内容',
},
];
}, []);
return (
<div className="p-6">
<Space size="large">
<Button type="primary" onClick={() => setSingleOpen(true)}>
打开一列布局弹窗
</Button>
<Button type="primary" status="success" onClick={() => setDoubleOpen(true)}>
打开两列布局弹窗
</Button>
<Button type="primary" status="warning" onClick={() => setCustomOpen(true)}>
打开有自定义的布局弹窗
</Button>
</Space>
<FormEditModal
open={singleOpen}
title="新增信息(一列布局)"
width={460}
formRef={singleColFormRef}
confirmLoading={singleSubmitLoading}
closeOnOk
closeOnCancel
resetAfterClose
onOpenChange={setSingleOpen}
onOk={async (values) => {
setSingleSubmitLoading(true);
try {
console.log('一列布局提交数据:', values);
Message.success('一列布局提交成功');
} finally {
setSingleSubmitLoading(false);
}
}}
>
<FormEdit
ref={singleColFormRef}
modelValue={{
name: '',
city: '',
desc: '',
}}
formArr={singleColumnFormArr}
layout="plain"
direction="horizontal"
columns={1}
title="基础信息"
description="当前示例为一行一列布局,适合字段较少或弹窗较窄的场景"
/>
</FormEditModal>
<FormEditModal
open={doubleOpen}
title="新增信息(两列布局)"
width={720}
formRef={doubleColFormRef}
confirmLoading={doubleSubmitLoading}
closeOnOk
closeOnCancel
resetAfterClose
onOpenChange={setDoubleOpen}
onOk={async (values) => {
setDoubleSubmitLoading(true);
try {
console.log('两列布局提交数据:', values);
Message.success('两列布局提交成功');
} finally {
setDoubleSubmitLoading(false);
}
}}
>
<FormEdit
ref={doubleColFormRef}
modelValue={{
name: '',
city: '',
type: 'personal',
tags: [],
desc: '',
}}
formArr={doubleColumnFormArr}
layout="plain"
direction="horizontal"
columns={2}
title="基础信息"
description="当前示例为一行两列布局,长文本字段可通过 colSpan: 2 跨整行显示"
/>
</FormEditModal>
<FormEditModal
open={customOpen}
title="新增信息(两列 + 全组件示例)"
width={920}
formRef={customFormRef}
confirmLoading={customSubmitLoading}
closeOnOk
closeOnCancel
resetAfterClose
onOpenChange={setCustomOpen}
onOk={async (values) => {
setCustomSubmitLoading(true);
try {
console.log('自定义两列布局提交数据:', values);
Message.success('自定义布局提交成功');
} finally {
setCustomSubmitLoading(false);
}
}}
>
<FormEdit
ref={customFormRef}
modelValue={{
projectName: '',
status: 1,
city: '',
identityType: 'personal',
tags: [],
region: [],
publishDate: '',
timeRange: [],
cover: [],
customField: 'A',
desc: '',
}}
formArr={customFormArr}
layout="plain"
columns={2}
title="完整表单示例"
description="当前示例为两列布局,演示了 input、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、custom、textarea 等所有封装能力"
/>
</FormEditModal>
</div>
);
}
3. 方案为什么这么设计,解决了什么问题
这套方案之所以采用"配置驱动 + ref 驱动 + 弹窗容器分离"的方式,是因为后台项目里的表单高度重复,但每个业务页又会有细小差异。如果每次都手写 Form.Item,不仅重复,而且难以统一。
• 解决重复开发问题:大部分新增/编辑/查看表单只需要配置 formArr,不需要从头写 UI。
• 解决行为不统一问题:校验、重置、关闭、查看态禁用、标题与说明展示都统一收口。
• 解决字段扩展问题:新增 switch、upload、cascader、datePicker、custom 后,可以覆盖更多真实业务场景。
• 解决回填控制问题:通过 syncKey 控制何时重新 patch 表单,避免编辑态误覆盖。
• 解决布局不灵活问题:通过 direction 和 columns 拆开"整体表单方向"和"字段网格布局"两个维度。
4. 方案的优点、改进点
4.1 优点
• 抽象清晰:类型、工具函数、表单、弹窗分层明确。
• 字段能力完整:覆盖了常见后台表单大部分控件。
• 配置统一:字段描述、布局控制、默认值、规则、扩展属性集中在一处。
• 对业务友好:父页面主要关心配置和 onOk 保存逻辑,不必处理表单底层细节。
• 可扩展性强:custom 字段为特殊业务组件提供了稳定扩展入口。
5. 关键语法解释
5.1 forwardRef
让函数组件可以接收 ref。当前 FormEdit 和 FormEditModal 都需要向父组件暴露方法,因此必须使用它。
5.2 useImperativeHandle
自定义 ref.current 上暴露的内容。这里暴露的是 validate、getValues、resetFields、open、close 等方法,而不是整个内部实例。
5.3 useMemo
缓存计算结果。当前主要用于 initialValues 和 visibleFormItems,减少重复计算并让依赖更明确。
5.4 useEffect
在数据变化时同步副作用。当前主要用于 modelValue / syncKey 变化时重新 patch 表单。
5.5 useRef
跨 render 持久保存引用。这里用于 mountedRef、lastPatchedKeyRef,也用于 formRef / modalRef。
5.6 联合类型
例如 direction?: 'horizontal' | 'vertical'。这种写法能把取值范围限制在有限集合内,减少误传。
6. 使用示例
6.1 基础单列表单
ini
<FormEdit ref={formRef}
formArr={singleColumnFormArr}
layout="plain"
direction="horizontal"
columns={1}
/>
适用于字段较少、小弹窗场景。
6.2 两列表单 + 跨行 textarea
yaml
{
key: 'desc',
title: '说明',
type: 'textarea',
rows: 5,
colSpan: 2,
}
适用于两列布局中某些长文本字段需要独占一行的场景。
6.3 switch / cascader / datePicker / upload
bash
[
{ key: 'status', title: '状态', type: 'switch', checkedValue: 1, uncheckedValue: 0 },
{ key: 'region', title: '地区', type: 'cascader', options: regionOptions },
{ key: 'publishDate', title: '发布日期', type: 'datePicker', datePickerType: 'date' },
{ key: 'cover', title: '封面', type: 'upload', uploadAction: '/api/upload', limit: 1 },
]
6.4 custom 字段
javascript
{
key: 'customField',
title: '自定义组件',
type: 'custom',
render: ({ value, setFieldValue }) => (
<Input
value={typeof value === 'string' ? value : ''}
onChange={(nextValue) => setFieldValue('customField', nextValue)}
/>
),
}
当内置字段类型不够用时,可以通过 custom 直接接入业务特有组件。
6.5 弹窗表单
ini
<FormEditModal
open={open}
title="新增信息"
formRef={formRef}
onOpenChange={setOpen}
onOk={async (values) => {
console.log(values);
}}
<FormEdit
ref={formRef}
formArr={formArr}
layout="plain"
direction="horizontal"
columns={2}
/>
</FormEditModal>
这是最推荐的项目用法:弹窗负责交互流程,FormEdit 负责表单本体。
7. 结论
当前这套 FormEdit 结构是一套面向项目复用的表单基础设施。它通过类型契约、配置化字段、布局解耦、规则适配、初始值归一化和弹窗容器分离,把后台系统中最常见的新增/编辑/查看型表单进行了有效抽象。