表单设计器

This commit is contained in:
dengqichen 2025-10-24 01:12:11 +08:00
parent ef5415f412
commit 374a74e4ab
4 changed files with 85 additions and 52 deletions

View File

@ -18,7 +18,7 @@
* ``` * ```
*/ */
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Button, Space, message, Modal } from 'antd'; import { Button, Space, message, Modal } from 'antd';
import { import {
SaveOutlined, SaveOutlined,
@ -41,7 +41,7 @@ import { arrayMove } from '@dnd-kit/sortable';
import ComponentPanel from './components/ComponentPanel'; import ComponentPanel from './components/ComponentPanel';
import DesignCanvas from './components/DesignCanvas'; import DesignCanvas from './components/DesignCanvas';
import PropertyPanel from './components/PropertyPanel'; import PropertyPanel from './components/PropertyPanel';
import FormPreview from './components/FormPreview'; import FormPreview, { type FormPreviewRef } from './components/FormPreview';
import { COMPONENT_LIST } from './config'; import { COMPONENT_LIST } from './config';
import type { FieldConfig, FormConfig, FormSchema } from './types'; import type { FieldConfig, FormConfig, FormSchema } from './types';
import './styles.css'; import './styles.css';
@ -63,6 +63,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
showToolbar = true, showToolbar = true,
extraActions extraActions
}) => { }) => {
// 表单预览 ref
const formPreviewRef = useRef<FormPreviewRef>(null);
// 内部状态(非受控模式使用) // 内部状态(非受控模式使用)
const [internalFields, setInternalFields] = useState<FieldConfig[]>([]); const [internalFields, setInternalFields] = useState<FieldConfig[]>([]);
const [internalFormConfig, setInternalFormConfig] = useState<FormConfig>({ const [internalFormConfig, setInternalFormConfig] = useState<FormConfig>({
@ -208,10 +211,10 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
if (over.id === 'canvas') { if (over.id === 'canvas') {
newFields.push(newField); newFields.push(newField);
} else { } else {
// 如果拖到某个字段上,插入到该字段位置 // 如果拖到某个字段上,插入到该字段之后
const overIndex = newFields.findIndex((f) => f.id === over.id); const overIndex = newFields.findIndex((f) => f.id === over.id);
if (overIndex >= 0) { if (overIndex >= 0) {
newFields.splice(overIndex, 0, newField); newFields.splice(overIndex + 1, 0, newField); // 插入到目标字段之后
} else { } else {
newFields.push(newField); newFields.push(newField);
} }
@ -685,9 +688,26 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
open={previewVisible} open={previewVisible}
onCancel={() => setPreviewVisible(false)} onCancel={() => setPreviewVisible(false)}
width={formConfig.formWidth || 600} width={formConfig.formWidth || 600}
footer={null} bodyStyle={{
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
overflowX: 'hidden',
padding: 0,
}}
footer={
<div style={{ textAlign: 'center' }}>
<Space>
<Button type="primary" onClick={() => formPreviewRef.current?.submit()}>
</Button>
<Button onClick={() => formPreviewRef.current?.reset()}>
</Button>
</Space>
</div>
}
> >
<FormPreview fields={fields} formConfig={formConfig} /> <FormPreview ref={formPreviewRef} fields={fields} formConfig={formConfig} />
</Modal> </Modal>
</div> </div>
); );

View File

@ -38,8 +38,8 @@ const DraggableComponent: React.FC<{ component: ComponentMeta }> = ({ component
{...attributes} {...attributes}
className="form-designer-component-item" className="form-designer-component-item"
> >
<Icon style={{ marginRight: 8, fontSize: 16 }} /> <Icon style={{ marginRight: 6, fontSize: 14, flexShrink: 0 }} />
<Text>{component.label}</Text> <Text style={{ fontSize: 12 }}>{component.label}</Text>
</div> </div>
); );
}; };
@ -57,30 +57,32 @@ const ComponentPanel: React.FC = () => {
<Text strong></Text> <Text strong></Text>
</div> </div>
<Collapse <div className="form-designer-component-panel-body">
defaultActiveKey={['基础字段', '高级字段', '布局字段']} <Collapse
ghost defaultActiveKey={['基础字段', '高级字段', '布局字段']}
bordered={false} ghost
items={categoryOrder bordered={false}
.map((category) => { items={categoryOrder
const components = componentsByCategory[category]; .map((category) => {
if (!components || components.length === 0) return null; const components = componentsByCategory[category];
if (!components || components.length === 0) return null;
return {
key: category, return {
label: category, key: category,
children: ( label: category,
<div className="form-designer-component-list"> children: (
{components.map((component) => ( <div className="form-designer-component-list">
<DraggableComponent key={component.type} component={component} /> {components.map((component) => (
))} <DraggableComponent key={component.type} component={component} />
</div> ))}
), </div>
}; ),
}) };
.filter(Boolean) as any[] })
} .filter(Boolean) as any[]
/> }
/>
</div>
</div> </div>
); );
}; };

View File

@ -3,8 +3,8 @@
* *
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { Form, Button, message } from 'antd'; import { Form, message } from 'antd';
import type { Rule } from 'antd/es/form'; import type { Rule } from 'antd/es/form';
import type { FieldConfig, FormConfig, ValidationRule, LinkageRule } from '../types'; import type { FieldConfig, FormConfig, ValidationRule, LinkageRule } from '../types';
import FieldRenderer from './FieldRenderer'; import FieldRenderer from './FieldRenderer';
@ -16,7 +16,12 @@ interface FormPreviewProps {
formConfig: FormConfig; formConfig: FormConfig;
} }
const FormPreview: React.FC<FormPreviewProps> = ({ fields, formConfig }) => { export interface FormPreviewRef {
submit: () => Promise<void>;
reset: () => void;
}
const FormPreview = forwardRef<FormPreviewRef, FormPreviewProps>(({ fields, formConfig }, ref) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [fieldStates, setFieldStates] = useState<Record<string, { const [fieldStates, setFieldStates] = useState<Record<string, {
@ -42,6 +47,12 @@ const FormPreview: React.FC<FormPreviewProps> = ({ fields, formConfig }) => {
message.info('表单已重置'); message.info('表单已重置');
}; };
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
submit: handleSubmit,
reset: handleReset,
}));
// 将验证规则转换为Ant Design的Rule数组 // 将验证规则转换为Ant Design的Rule数组
const convertValidationRules = (validationRules?: ValidationRule[]): Rule[] => { const convertValidationRules = (validationRules?: ValidationRule[]): Rule[] => {
if (!validationRules || validationRules.length === 0) return []; if (!validationRules || validationRules.length === 0) return [];
@ -307,21 +318,6 @@ const FormPreview: React.FC<FormPreviewProps> = ({ fields, formConfig }) => {
)} )}
{renderFields(fields)} {renderFields(fields)}
<Form.Item
style={{ marginTop: 32, marginBottom: 0 }}
labelCol={{ span: 0 }}
wrapperCol={{ span: 24 }}
>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8 }}>
<Button type="primary" onClick={handleSubmit}>
</Button>
<Button onClick={handleReset}>
</Button>
</div>
</Form.Item>
</Form> </Form>
{Object.keys(formData).length > 0 && ( {Object.keys(formData).length > 0 && (
@ -332,7 +328,9 @@ const FormPreview: React.FC<FormPreviewProps> = ({ fields, formConfig }) => {
)} )}
</div> </div>
); );
}; });
FormPreview.displayName = 'FormPreview';
export default FormPreview; export default FormPreview;

View File

@ -44,6 +44,13 @@
padding: 16px; padding: 16px;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
background: #fff; background: #fff;
flex-shrink: 0;
}
.form-designer-component-panel-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
} }
.form-designer-component-list { .form-designer-component-list {
@ -56,13 +63,19 @@
.form-designer-component-item { .form-designer-component-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 10px 12px; justify-content: center;
padding: 8px 6px;
background: #fff; background: #fff;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
border-radius: 6px; border-radius: 6px;
transition: all 0.3s ease; transition: all 0.3s ease;
user-select: none; user-select: none;
cursor: move; cursor: move;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 36px;
} }
.form-designer-component-item:hover { .form-designer-component-item:hover {