deploy-ease-platform/frontend/src/pages/FormDesigner/index.tsx
2025-10-23 21:30:24 +08:00

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;