表单设计器

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 {
SaveOutlined,
@ -41,7 +41,7 @@ import { arrayMove } from '@dnd-kit/sortable';
import ComponentPanel from './components/ComponentPanel';
import DesignCanvas from './components/DesignCanvas';
import PropertyPanel from './components/PropertyPanel';
import FormPreview from './components/FormPreview';
import FormPreview, { type FormPreviewRef } from './components/FormPreview';
import { COMPONENT_LIST } from './config';
import type { FieldConfig, FormConfig, FormSchema } from './types';
import './styles.css';
@ -63,6 +63,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
showToolbar = true,
extraActions
}) => {
// 表单预览 ref
const formPreviewRef = useRef<FormPreviewRef>(null);
// 内部状态(非受控模式使用)
const [internalFields, setInternalFields] = useState<FieldConfig[]>([]);
const [internalFormConfig, setInternalFormConfig] = useState<FormConfig>({
@ -208,10 +211,10 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
if (over.id === 'canvas') {
newFields.push(newField);
} else {
// 如果拖到某个字段上,插入到该字段位置
// 如果拖到某个字段上,插入到该字段之后
const overIndex = newFields.findIndex((f) => f.id === over.id);
if (overIndex >= 0) {
newFields.splice(overIndex, 0, newField);
newFields.splice(overIndex + 1, 0, newField); // 插入到目标字段之后
} else {
newFields.push(newField);
}
@ -685,9 +688,26 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
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>
</div>
);

View File

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

View File

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

View File

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