379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
/**
|
|
* 表单设计器主入口
|
|
*
|
|
* 功能:
|
|
* - 左侧:组件面板
|
|
* - 中间:设计画布
|
|
* - 右侧:属性配置
|
|
* - 导入/导出 JSON Schema
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { Button, Space, message, Modal } from 'antd';
|
|
import {
|
|
SaveOutlined,
|
|
DownloadOutlined,
|
|
UploadOutlined,
|
|
EyeOutlined,
|
|
ClearOutlined,
|
|
} from '@ant-design/icons';
|
|
import { DndContext, DragEndEvent, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
|
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 { COMPONENT_LIST } from './config';
|
|
import type { FieldConfig, FormConfig, FormSchema } from './types';
|
|
|
|
const FormDesigner: React.FC = () => {
|
|
// 表单字段列表
|
|
const [fields, setFields] = useState<FieldConfig[]>([]);
|
|
|
|
// 选中的字段
|
|
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
|
|
|
// 表单配置
|
|
const [formConfig, setFormConfig] = useState<FormConfig>({
|
|
labelAlign: 'right',
|
|
size: 'middle',
|
|
formWidth: 600, // 表单弹窗宽度(像素)
|
|
});
|
|
|
|
// 预览模式
|
|
const [previewVisible, setPreviewVisible] = useState(false);
|
|
|
|
// 拖拽传感器
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
})
|
|
);
|
|
|
|
// 生成唯一 ID
|
|
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
// 处理拖拽结束
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!over) return;
|
|
|
|
const overId = over.id.toString();
|
|
|
|
// 从组件面板拖入新组件
|
|
if (active.data.current?.isNew) {
|
|
const componentType = active.id.toString().replace('new-', '');
|
|
const componentMeta = COMPONENT_LIST.find(c => c.type === componentType);
|
|
|
|
if (componentMeta) {
|
|
const newField: FieldConfig = {
|
|
id: generateId(),
|
|
type: componentMeta.type,
|
|
label: componentMeta.label,
|
|
name: componentType + '_' + Date.now(),
|
|
...componentMeta.defaultConfig,
|
|
};
|
|
|
|
// 检测是否拖入栅格列
|
|
if (overId.startsWith('grid-') && overId.includes('-col-')) {
|
|
const match = overId.match(/grid-(.+)-col-(\d+)/);
|
|
if (match) {
|
|
const [, gridId, colIndexStr] = match;
|
|
const colIndex = parseInt(colIndexStr, 10);
|
|
|
|
setFields(prev => prev.map(field => {
|
|
if (field.id === gridId && field.type === 'grid') {
|
|
const newChildren = [...(field.children || [])];
|
|
if (!newChildren[colIndex]) {
|
|
newChildren[colIndex] = [];
|
|
}
|
|
newChildren[colIndex] = [...newChildren[colIndex], newField];
|
|
return { ...field, children: newChildren };
|
|
}
|
|
return field;
|
|
}));
|
|
setSelectedFieldId(newField.id);
|
|
message.success(`已添加${componentMeta.label}到栅格列`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 拖入主画布
|
|
setFields(prev => [...prev, newField]);
|
|
setSelectedFieldId(newField.id);
|
|
message.success(`已添加${componentMeta.label}`);
|
|
}
|
|
}
|
|
// 画布内拖拽排序
|
|
else if (over.id === 'canvas' || fields.some(f => f.id === over.id)) {
|
|
const oldIndex = fields.findIndex(f => f.id === active.id);
|
|
const newIndex = fields.findIndex(f => f.id === over.id);
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
setFields(arrayMove(fields, oldIndex, newIndex));
|
|
}
|
|
}
|
|
}, [fields]);
|
|
|
|
// 选中字段
|
|
const handleSelectField = useCallback((fieldId: string) => {
|
|
setSelectedFieldId(fieldId);
|
|
}, []);
|
|
|
|
// 删除字段(支持删除栅格内的字段)
|
|
const handleDeleteField = useCallback((fieldId: string) => {
|
|
let deleted = false;
|
|
|
|
// 递归删除函数
|
|
const deleteFieldRecursive = (fieldList: FieldConfig[]): FieldConfig[] => {
|
|
return fieldList
|
|
.filter(f => {
|
|
if (f.id === fieldId) {
|
|
deleted = true;
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.map(f => {
|
|
// 如果是栅格布局,递归处理其子字段
|
|
if (f.type === 'grid' && f.children) {
|
|
return {
|
|
...f,
|
|
children: f.children.map(columnFields =>
|
|
deleteFieldRecursive(columnFields)
|
|
),
|
|
};
|
|
}
|
|
return f;
|
|
});
|
|
};
|
|
|
|
setFields(prev => deleteFieldRecursive(prev));
|
|
|
|
if (deleted) {
|
|
if (selectedFieldId === fieldId) {
|
|
setSelectedFieldId(undefined);
|
|
}
|
|
message.success('已删除字段');
|
|
}
|
|
}, [selectedFieldId]);
|
|
|
|
// 更新字段属性(支持更新栅格内的字段)
|
|
const handleFieldChange = useCallback((updatedField: FieldConfig) => {
|
|
// 递归更新函数
|
|
const updateFieldRecursive = (fieldList: FieldConfig[]): FieldConfig[] => {
|
|
return fieldList.map(f => {
|
|
if (f.id === updatedField.id) {
|
|
return updatedField;
|
|
}
|
|
// 如果是栅格布局,递归处理其子字段
|
|
if (f.type === 'grid' && f.children) {
|
|
return {
|
|
...f,
|
|
children: f.children.map(columnFields =>
|
|
updateFieldRecursive(columnFields)
|
|
),
|
|
};
|
|
}
|
|
return f;
|
|
});
|
|
};
|
|
|
|
setFields(prev => updateFieldRecursive(prev));
|
|
}, []);
|
|
|
|
// 保存表单
|
|
const handleSave = useCallback(() => {
|
|
if (fields.length === 0) {
|
|
message.warning('表单为空,请先添加字段');
|
|
return;
|
|
}
|
|
|
|
const schema: FormSchema = {
|
|
formConfig,
|
|
fields,
|
|
};
|
|
|
|
console.log('保存的表单 Schema:', JSON.stringify(schema, null, 2));
|
|
message.success('表单已保存!请查看控制台');
|
|
|
|
// TODO: 调用后端 API 保存
|
|
}, [fields, formConfig]);
|
|
|
|
// 导出 JSON
|
|
const handleExport = useCallback(() => {
|
|
if (fields.length === 0) {
|
|
message.warning('表单为空,无法导出');
|
|
return;
|
|
}
|
|
|
|
const schema: FormSchema = {
|
|
formConfig,
|
|
fields,
|
|
};
|
|
|
|
const dataStr = JSON.stringify(schema, null, 2);
|
|
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
|
const exportFileDefaultName = `form-schema-${Date.now()}.json`;
|
|
|
|
const linkElement = document.createElement('a');
|
|
linkElement.setAttribute('href', dataUri);
|
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
linkElement.click();
|
|
|
|
message.success('JSON Schema 已导出');
|
|
}, [fields, formConfig]);
|
|
|
|
// 导入 JSON
|
|
const handleImport = useCallback(() => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json';
|
|
|
|
input.onchange = (e: any) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event: any) => {
|
|
try {
|
|
const schema: FormSchema = JSON.parse(event.target.result);
|
|
|
|
if (!schema.fields || !Array.isArray(schema.fields)) {
|
|
message.error('无效的表单 Schema 格式');
|
|
return;
|
|
}
|
|
|
|
setFields(schema.fields);
|
|
setFormConfig(schema.formConfig || formConfig);
|
|
setSelectedFieldId(undefined);
|
|
message.success('JSON Schema 导入成功');
|
|
} catch (error) {
|
|
message.error('JSON 格式错误');
|
|
console.error('导入错误:', error);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
|
|
input.click();
|
|
}, [formConfig]);
|
|
|
|
// 清空表单
|
|
const handleClear = useCallback(() => {
|
|
Modal.confirm({
|
|
title: '确认清空',
|
|
content: '确定要清空所有字段吗?此操作不可恢复。',
|
|
onOk: () => {
|
|
setFields([]);
|
|
setSelectedFieldId(undefined);
|
|
message.info('表单已清空');
|
|
},
|
|
});
|
|
}, []);
|
|
|
|
// 递归查找选中的字段(包括栅格内的字段)
|
|
const findFieldById = (fieldList: FieldConfig[], id?: string): FieldConfig | undefined => {
|
|
if (!id) return undefined;
|
|
|
|
for (const field of fieldList) {
|
|
if (field.id === id) {
|
|
return field;
|
|
}
|
|
// 如果是栅格布局,递归查找子字段
|
|
if (field.type === 'grid' && field.children) {
|
|
for (const columnFields of field.children) {
|
|
const found = findFieldById(columnFields, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const selectedField = findFieldById(fields, selectedFieldId);
|
|
|
|
return (
|
|
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
{/* 顶部工具栏 */}
|
|
<div
|
|
style={{
|
|
padding: '12px 24px',
|
|
background: '#fff',
|
|
borderBottom: '1px solid #e8e8e8',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
zIndex: 1000,
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 500 }}>
|
|
表单设计器
|
|
</h2>
|
|
<Space>
|
|
<Button icon={<UploadOutlined />} onClick={handleImport}>
|
|
导入 JSON
|
|
</Button>
|
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
|
导出 JSON
|
|
</Button>
|
|
<Button icon={<EyeOutlined />} onClick={() => setPreviewVisible(true)}>
|
|
预览
|
|
</Button>
|
|
<Button icon={<ClearOutlined />} danger onClick={handleClear}>
|
|
清空
|
|
</Button>
|
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
|
|
保存
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 主体区域 */}
|
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
{/* 左侧组件面板 */}
|
|
<div style={{ width: 280 }}>
|
|
<ComponentPanel />
|
|
</div>
|
|
|
|
{/* 中间设计画布 */}
|
|
<DesignCanvas
|
|
fields={fields}
|
|
selectedFieldId={selectedFieldId}
|
|
onSelectField={handleSelectField}
|
|
onDeleteField={handleDeleteField}
|
|
labelAlign={formConfig.labelAlign}
|
|
/>
|
|
|
|
{/* 右侧属性面板 */}
|
|
<PropertyPanel
|
|
selectedField={selectedField}
|
|
formConfig={formConfig}
|
|
onFieldChange={handleFieldChange}
|
|
onFormConfigChange={setFormConfig}
|
|
/>
|
|
</div>
|
|
|
|
<DragOverlay>{/* 拖拽时的视觉反馈 */}</DragOverlay>
|
|
</DndContext>
|
|
|
|
{/* 预览模态框 */}
|
|
<Modal
|
|
title="表单预览"
|
|
open={previewVisible}
|
|
onCancel={() => setPreviewVisible(false)}
|
|
width={formConfig.formWidth || 600}
|
|
footer={null}
|
|
>
|
|
<FormPreview fields={fields} formConfig={formConfig} />
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FormDesigner;
|