表单设计器
This commit is contained in:
parent
b4836b1c91
commit
ef5415f412
697
frontend/src/components/FormDesigner/Designer.tsx
Normal file
697
frontend/src/components/FormDesigner/Designer.tsx
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器组件(设计时)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 可视化拖拽设计表单
|
||||||
|
* - 支持字段属性配置
|
||||||
|
* - 支持验证规则和联动规则
|
||||||
|
* - 导入/导出 JSON Schema
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* ```tsx
|
||||||
|
* <FormDesigner
|
||||||
|
* value={formSchema}
|
||||||
|
* onChange={setFormSchema}
|
||||||
|
* onSave={handleSave}
|
||||||
|
* readonly={false}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Button, Space, message, Modal } from 'antd';
|
||||||
|
import {
|
||||||
|
SaveOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter
|
||||||
|
} 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';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
export interface FormDesignerProps {
|
||||||
|
value?: FormSchema; // 受控模式:表单 Schema
|
||||||
|
onChange?: (schema: FormSchema) => void; // 受控模式:Schema 变化回调
|
||||||
|
onSave?: (schema: FormSchema) => void; // 保存回调
|
||||||
|
readonly?: boolean; // 只读模式
|
||||||
|
showToolbar?: boolean; // 是否显示工具栏
|
||||||
|
extraActions?: React.ReactNode; // 额外的操作按钮
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
readonly = false,
|
||||||
|
showToolbar = true,
|
||||||
|
extraActions
|
||||||
|
}) => {
|
||||||
|
// 内部状态(非受控模式使用)
|
||||||
|
const [internalFields, setInternalFields] = useState<FieldConfig[]>([]);
|
||||||
|
const [internalFormConfig, setInternalFormConfig] = useState<FormConfig>({
|
||||||
|
labelAlign: 'right',
|
||||||
|
size: 'middle',
|
||||||
|
formWidth: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用受控值或内部值
|
||||||
|
const fields = value?.fields ?? internalFields;
|
||||||
|
const formConfig = value?.formConfig ?? internalFormConfig;
|
||||||
|
|
||||||
|
const setFields = useCallback((updater: React.SetStateAction<FieldConfig[]>) => {
|
||||||
|
const newFields = typeof updater === 'function' ? updater(fields) : updater;
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
// 受控模式:通知父组件
|
||||||
|
onChange({
|
||||||
|
version: '1.0',
|
||||||
|
formConfig,
|
||||||
|
fields: newFields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 非受控模式:更新内部状态
|
||||||
|
setInternalFields(newFields);
|
||||||
|
}
|
||||||
|
}, [fields, formConfig, onChange]);
|
||||||
|
|
||||||
|
const setFormConfig = useCallback((updater: React.SetStateAction<FormConfig>) => {
|
||||||
|
const newConfig = typeof updater === 'function' ? updater(formConfig) : updater;
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
version: '1.0',
|
||||||
|
formConfig: newConfig,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalFormConfig(newConfig);
|
||||||
|
}
|
||||||
|
}, [fields, formConfig, onChange]);
|
||||||
|
|
||||||
|
// 选中的字段
|
||||||
|
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
||||||
|
|
||||||
|
// 剪贴板(用于复制粘贴)
|
||||||
|
const [clipboard, setClipboard] = useState<FieldConfig | null>(null);
|
||||||
|
|
||||||
|
// 预览模式
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
|
||||||
|
// 正在拖拽的元素ID
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 拖拽传感器 - 添加激活约束,减少误触和卡顿
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8, // 移动8px后才激活拖拽
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成唯一 ID
|
||||||
|
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// 处理拖拽开始
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id.toString());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
// 清除拖拽状态
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeData = active.data.current;
|
||||||
|
const overData = over.data.current;
|
||||||
|
|
||||||
|
// 从组件面板拖拽新组件到栅格列
|
||||||
|
if (activeData?.isNew && overData?.gridId && overData?.colIndex !== undefined) {
|
||||||
|
const componentType = active.id.toString().replace('new-', '');
|
||||||
|
const component = COMPONENT_LIST.find((c) => c.type === componentType);
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
id: generateId(),
|
||||||
|
type: component.type,
|
||||||
|
label: component.label,
|
||||||
|
name: `${component.type}_${Date.now()}`,
|
||||||
|
...component.defaultConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFields((prev) => {
|
||||||
|
const updateGrid = (fieldList: FieldConfig[]): FieldConfig[] => {
|
||||||
|
return fieldList.map(field => {
|
||||||
|
if (field.id === overData.gridId && field.type === 'grid' && field.children) {
|
||||||
|
const newChildren = [...field.children];
|
||||||
|
newChildren[overData.colIndex] = [...newChildren[overData.colIndex], newField];
|
||||||
|
return { ...field, children: newChildren };
|
||||||
|
}
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
children: field.children.map(col => updateGrid(col)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return updateGrid(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success(`已添加${component.label}到栅格列`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从组件面板拖拽新组件到画布或字段
|
||||||
|
if (activeData?.isNew && overData?.accept === 'field') {
|
||||||
|
const componentType = active.id.toString().replace('new-', '');
|
||||||
|
const component = COMPONENT_LIST.find((c) => c.type === componentType);
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
id: generateId(),
|
||||||
|
type: component.type,
|
||||||
|
label: component.label,
|
||||||
|
name: `${component.type}_${Date.now()}`,
|
||||||
|
...component.defaultConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFields((prev) => {
|
||||||
|
const newFields = [...prev];
|
||||||
|
|
||||||
|
// 如果拖到画布空白处,添加到末尾
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
newFields.push(newField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFields;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success(`已添加${component.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画布内字段排序
|
||||||
|
if (!activeData?.isNew && overData?.accept === 'field') {
|
||||||
|
setFields((prev) => {
|
||||||
|
const oldIndex = prev.findIndex((f) => f.id === active.id);
|
||||||
|
const newIndex = prev.findIndex((f) => f.id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
return arrayMove(prev, oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [setFields]);
|
||||||
|
|
||||||
|
// 选择字段
|
||||||
|
const handleSelectField = useCallback((fieldId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
setSelectedFieldId(fieldId);
|
||||||
|
}, [readonly]);
|
||||||
|
|
||||||
|
// 深拷贝字段(包括嵌套的栅格子字段)
|
||||||
|
const deepCopyField = useCallback((field: FieldConfig): FieldConfig => {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
...field,
|
||||||
|
id: generateId(),
|
||||||
|
name: field.name + '_copy_' + Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是栅格布局,递归复制子字段
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
newField.children = field.children.map(columnFields =>
|
||||||
|
columnFields.map(childField => deepCopyField(childField))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newField;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 复制字段到剪贴板
|
||||||
|
const handleCopyField = useCallback((fieldId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
// 递归查找字段
|
||||||
|
const findField = (fieldList: FieldConfig[], id: string): FieldConfig | null => {
|
||||||
|
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 = findField(columnFields, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = findField(fields, fieldId);
|
||||||
|
if (field) {
|
||||||
|
setClipboard(field);
|
||||||
|
message.success('已复制字段到剪贴板');
|
||||||
|
}
|
||||||
|
}, [fields, readonly]);
|
||||||
|
|
||||||
|
// 粘贴字段到主列表末尾
|
||||||
|
const handlePasteToCanvas = useCallback(() => {
|
||||||
|
if (readonly || !clipboard) {
|
||||||
|
message.warning('剪贴板为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField = deepCopyField(clipboard);
|
||||||
|
setFields(prev => [...prev, newField]);
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success('已粘贴字段');
|
||||||
|
}, [clipboard, deepCopyField, readonly, setFields]);
|
||||||
|
|
||||||
|
// 粘贴字段到栅格列
|
||||||
|
const handlePasteToGrid = useCallback((gridId: string, colIndex: number) => {
|
||||||
|
if (readonly || !clipboard) {
|
||||||
|
message.warning('剪贴板为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField = deepCopyField(clipboard);
|
||||||
|
|
||||||
|
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('已粘贴字段到栅格');
|
||||||
|
}, [clipboard, deepCopyField, readonly, setFields]);
|
||||||
|
|
||||||
|
// 删除字段(支持删除栅格内的字段)
|
||||||
|
const handleDeleteField = useCallback((fieldId: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
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, readonly, setFields]);
|
||||||
|
|
||||||
|
// 更新字段属性(支持更新栅格内的字段)
|
||||||
|
const handleFieldChange = useCallback((updatedField: FieldConfig) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
// 递归更新函数
|
||||||
|
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));
|
||||||
|
}, [readonly, setFields]);
|
||||||
|
|
||||||
|
// 更新表单配置
|
||||||
|
const handleFormConfigChange = useCallback((newConfig: Partial<FormConfig>) => {
|
||||||
|
if (readonly) return;
|
||||||
|
setFormConfig(prev => ({ ...prev, ...newConfig }));
|
||||||
|
}, [readonly, setFormConfig]);
|
||||||
|
|
||||||
|
// 保存表单
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
message.warning('表单为空,请先添加字段');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: FormSchema = {
|
||||||
|
version: '1.0',
|
||||||
|
formConfig,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
onSave(schema);
|
||||||
|
} else {
|
||||||
|
console.log('保存的表单 Schema:', JSON.stringify(schema, null, 2));
|
||||||
|
message.success('表单已保存!请查看控制台');
|
||||||
|
}
|
||||||
|
}, [fields, formConfig, onSave]);
|
||||||
|
|
||||||
|
// 导出 JSON
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
message.warning('表单为空,无法导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: FormSchema = {
|
||||||
|
version: '1.0',
|
||||||
|
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(() => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
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, readonly, setFields, setFormConfig]);
|
||||||
|
|
||||||
|
// 清空表单
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认清空',
|
||||||
|
content: '确定要清空所有字段吗?此操作不可恢复。',
|
||||||
|
onOk: () => {
|
||||||
|
setFields([]);
|
||||||
|
setSelectedFieldId(undefined);
|
||||||
|
message.info('表单已清空');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [readonly, setFields]);
|
||||||
|
|
||||||
|
// 递归查找选中的字段(包括栅格内的字段)
|
||||||
|
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) || null;
|
||||||
|
|
||||||
|
// 获取所有字段(扁平化,包括grid中的嵌套字段)
|
||||||
|
const getAllFields = useCallback((fieldList: FieldConfig[]): FieldConfig[] => {
|
||||||
|
const result: FieldConfig[] = [];
|
||||||
|
|
||||||
|
fieldList.forEach(field => {
|
||||||
|
// 只添加实际的表单字段,不包括布局组件
|
||||||
|
if (field.type !== 'divider' && field.type !== 'grid' && field.type !== 'text') {
|
||||||
|
result.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是grid,递归获取其子字段
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
field.children.forEach(columnFields => {
|
||||||
|
result.push(...getAllFields(columnFields));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allFields = getAllFields(fields);
|
||||||
|
|
||||||
|
// 快捷键支持
|
||||||
|
useEffect(() => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// 检查是否在输入框、文本域等可编辑元素中
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isEditable =
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable;
|
||||||
|
|
||||||
|
// 如果在可编辑元素中,不触发快捷键
|
||||||
|
if (isEditable) return;
|
||||||
|
|
||||||
|
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
// Ctrl+C / Cmd+C: 复制选中的字段
|
||||||
|
if (ctrlKey && e.key === 'c' && selectedFieldId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCopyField(selectedFieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V / Cmd+V: 粘贴到画布
|
||||||
|
if (ctrlKey && e.key === 'v' && clipboard) {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePasteToCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete: 删除选中的字段
|
||||||
|
if (e.key === 'Delete' && selectedFieldId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDeleteField(selectedFieldId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [selectedFieldId, clipboard, readonly, handleCopyField, handlePasteToCanvas, handleDeleteField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-designer">
|
||||||
|
{showToolbar && (
|
||||||
|
<div className="form-designer-header">
|
||||||
|
<div className="form-designer-title">表单设计</div>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={readonly}
|
||||||
|
>
|
||||||
|
导入 JSON
|
||||||
|
</Button>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||||
|
导出 JSON
|
||||||
|
</Button>
|
||||||
|
<Button icon={<EyeOutlined />} onClick={() => setPreviewVisible(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={readonly}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={readonly}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
{extraActions}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
>
|
||||||
|
<div className="form-designer-body">
|
||||||
|
{/* 左侧组件面板 */}
|
||||||
|
<div style={{ width: 280 }}>
|
||||||
|
<ComponentPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间设计画布 */}
|
||||||
|
<DesignCanvas
|
||||||
|
fields={fields}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={handleSelectField}
|
||||||
|
onDeleteField={handleDeleteField}
|
||||||
|
onCopyField={handleCopyField}
|
||||||
|
onPasteToCanvas={handlePasteToCanvas}
|
||||||
|
onPasteToGrid={handlePasteToGrid}
|
||||||
|
labelAlign={formConfig.labelAlign}
|
||||||
|
hasClipboard={!!clipboard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧属性面板 */}
|
||||||
|
<PropertyPanel
|
||||||
|
selectedField={selectedField}
|
||||||
|
formConfig={formConfig}
|
||||||
|
onFieldChange={handleFieldChange}
|
||||||
|
onFormConfigChange={handleFormConfigChange}
|
||||||
|
allFields={allFields}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 拖拽覆盖层 - 显示正在拖拽的元素 */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeId && activeId.startsWith('new-') ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: '#fff',
|
||||||
|
border: '2px solid #1890ff',
|
||||||
|
borderRadius: 6,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
cursor: 'grabbing',
|
||||||
|
}}>
|
||||||
|
{COMPONENT_LIST.find(c => `new-${c.type}` === activeId)?.label || '组件'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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;
|
||||||
|
|
||||||
394
frontend/src/components/FormDesigner/Renderer.tsx
Normal file
394
frontend/src/components/FormDesigner/Renderer.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* 表单渲染器组件(运行时)
|
||||||
|
* 根据 Schema 渲染可交互的表单
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Button, message } from 'antd';
|
||||||
|
import type { Rule } from 'antd/es/form';
|
||||||
|
import type { FieldConfig, FormSchema, ValidationRule, LinkageRule } from './types';
|
||||||
|
import FieldRenderer from './components/FieldRenderer';
|
||||||
|
import GridFieldPreview from './components/GridFieldPreview';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
export interface FormRendererProps {
|
||||||
|
schema: FormSchema; // 表单 Schema
|
||||||
|
value?: Record<string, any>; // 表单值(受控)
|
||||||
|
onChange?: (values: Record<string, any>) => void; // 值变化回调
|
||||||
|
onSubmit?: (values: Record<string, any>) => void; // 提交回调
|
||||||
|
onCancel?: () => void; // 取消回调
|
||||||
|
readonly?: boolean; // 只读模式
|
||||||
|
showSubmit?: boolean; // 是否显示提交按钮
|
||||||
|
showCancel?: boolean; // 是否显示取消按钮
|
||||||
|
submitText?: string; // 提交按钮文本
|
||||||
|
cancelText?: string; // 取消按钮文本
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormRenderer: React.FC<FormRendererProps> = ({
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
readonly = false,
|
||||||
|
showSubmit = true,
|
||||||
|
showCancel = false,
|
||||||
|
submitText = '提交',
|
||||||
|
cancelText = '取消'
|
||||||
|
}) => {
|
||||||
|
const { fields, formConfig } = schema;
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>(value || {});
|
||||||
|
const [fieldStates, setFieldStates] = useState<Record<string, {
|
||||||
|
visible?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>>({});
|
||||||
|
|
||||||
|
// 同步外部值到表单
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
form.setFieldsValue(value);
|
||||||
|
setFormData(value);
|
||||||
|
}
|
||||||
|
}, [value, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setFormData(values);
|
||||||
|
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(values);
|
||||||
|
} else {
|
||||||
|
console.log('表单提交数据:', values);
|
||||||
|
message.success('表单提交成功!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('请填写必填项');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
|
const resetValues = {};
|
||||||
|
setFormData(resetValues);
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(resetValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.info('表单已重置');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValuesChange = (_: any, allValues: Record<string, any>) => {
|
||||||
|
setFormData(allValues);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(allValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将验证规则转换为Ant Design的Rule数组
|
||||||
|
const convertValidationRules = (validationRules?: ValidationRule[]): Rule[] => {
|
||||||
|
if (!validationRules || validationRules.length === 0) return [];
|
||||||
|
|
||||||
|
return validationRules.map(rule => {
|
||||||
|
const antdRule: Rule = {
|
||||||
|
type: rule.type === 'email' ? 'email' : rule.type === 'url' ? 'url' : undefined,
|
||||||
|
message: rule.message || `请输入正确的${rule.type}`,
|
||||||
|
// 注意:不设置 trigger,或者不仅仅设置为 blur,这样提交时也会触发验证
|
||||||
|
// 如果用户指定了 trigger,我们也在提交时触发
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (rule.type) {
|
||||||
|
case 'required':
|
||||||
|
antdRule.required = true;
|
||||||
|
// 必填验证始终在提交时触发,不管用户选择了什么 trigger
|
||||||
|
break;
|
||||||
|
case 'pattern':
|
||||||
|
if (rule.value) {
|
||||||
|
antdRule.pattern = new RegExp(rule.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'min':
|
||||||
|
antdRule.type = 'number';
|
||||||
|
antdRule.min = rule.value;
|
||||||
|
break;
|
||||||
|
case 'max':
|
||||||
|
antdRule.type = 'number';
|
||||||
|
antdRule.max = rule.value;
|
||||||
|
break;
|
||||||
|
case 'minLength':
|
||||||
|
antdRule.min = rule.value;
|
||||||
|
break;
|
||||||
|
case 'maxLength':
|
||||||
|
antdRule.max = rule.value;
|
||||||
|
break;
|
||||||
|
case 'phone':
|
||||||
|
antdRule.pattern = /^1[3-9]\d{9}$/;
|
||||||
|
break;
|
||||||
|
case 'idCard':
|
||||||
|
antdRule.pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return antdRule;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 评估联动条件
|
||||||
|
const evaluateCondition = (condition: any, formValues: Record<string, any>): boolean => {
|
||||||
|
const fieldValue = formValues[condition.field];
|
||||||
|
const compareValue = condition.value;
|
||||||
|
|
||||||
|
switch (condition.operator) {
|
||||||
|
case '==':
|
||||||
|
return fieldValue == compareValue;
|
||||||
|
case '!=':
|
||||||
|
return fieldValue != compareValue;
|
||||||
|
case '>':
|
||||||
|
return Number(fieldValue) > Number(compareValue);
|
||||||
|
case '<':
|
||||||
|
return Number(fieldValue) < Number(compareValue);
|
||||||
|
case '>=':
|
||||||
|
return Number(fieldValue) >= Number(compareValue);
|
||||||
|
case '<=':
|
||||||
|
return Number(fieldValue) <= Number(compareValue);
|
||||||
|
case 'includes':
|
||||||
|
return Array.isArray(fieldValue) ? fieldValue.includes(compareValue) : String(fieldValue).includes(compareValue);
|
||||||
|
case 'notIncludes':
|
||||||
|
return Array.isArray(fieldValue) ? !fieldValue.includes(compareValue) : !String(fieldValue).includes(compareValue);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理联动规则
|
||||||
|
useEffect(() => {
|
||||||
|
const flattenFields = (fieldList: FieldConfig[]): FieldConfig[] => {
|
||||||
|
const result: FieldConfig[] = [];
|
||||||
|
fieldList.forEach(field => {
|
||||||
|
if (field.type !== 'divider' && field.type !== 'text') {
|
||||||
|
result.push(field);
|
||||||
|
}
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
field.children.forEach(columnFields => {
|
||||||
|
result.push(...flattenFields(columnFields));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allFields = flattenFields(fields);
|
||||||
|
const newFieldStates: Record<string, any> = {};
|
||||||
|
|
||||||
|
allFields.forEach(field => {
|
||||||
|
if (field.linkageRules && field.linkageRules.length > 0) {
|
||||||
|
field.linkageRules.forEach((rule: LinkageRule) => {
|
||||||
|
// 检查所有条件是否都满足
|
||||||
|
const allConditionsMet = rule.conditions.every(condition =>
|
||||||
|
evaluateCondition(condition, formData)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allConditionsMet) {
|
||||||
|
if (!newFieldStates[field.name]) {
|
||||||
|
newFieldStates[field.name] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (rule.type) {
|
||||||
|
case 'visible':
|
||||||
|
newFieldStates[field.name].visible = rule.action;
|
||||||
|
break;
|
||||||
|
case 'disabled':
|
||||||
|
newFieldStates[field.name].disabled = rule.action;
|
||||||
|
break;
|
||||||
|
case 'required':
|
||||||
|
newFieldStates[field.name].required = rule.action;
|
||||||
|
break;
|
||||||
|
case 'value':
|
||||||
|
// 值联动需要直接设置表单值
|
||||||
|
form.setFieldValue(field.name, rule.action);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFieldStates(newFieldStates);
|
||||||
|
}, [formData, fields, form]);
|
||||||
|
|
||||||
|
// 设置默认值
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
const collectDefaultValues = (fieldList: FieldConfig[]) => {
|
||||||
|
fieldList.forEach(field => {
|
||||||
|
if (field.defaultValue !== undefined && field.name) {
|
||||||
|
defaultValues[field.name] = field.defaultValue;
|
||||||
|
}
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
field.children.forEach(columnFields => {
|
||||||
|
collectDefaultValues(columnFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
collectDefaultValues(fields);
|
||||||
|
form.setFieldsValue(defaultValues);
|
||||||
|
setFormData(prev => ({ ...prev, ...defaultValues }));
|
||||||
|
}, [fields, form]);
|
||||||
|
|
||||||
|
// 递归渲染字段(包括栅格布局内的字段)
|
||||||
|
const renderFields = (fieldList: FieldConfig[]): React.ReactNode => {
|
||||||
|
return fieldList.map((field) => {
|
||||||
|
// 布局组件:文本、分割线
|
||||||
|
if (field.type === 'text' || field.type === 'divider') {
|
||||||
|
return (
|
||||||
|
<div key={field.id}>
|
||||||
|
<FieldRenderer field={field} isPreview={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 栅格布局:使用专门的预览组件,自动处理内部字段的 Form.Item
|
||||||
|
if (field.type === 'grid') {
|
||||||
|
return (
|
||||||
|
<GridFieldPreview
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
formData={formData}
|
||||||
|
onFieldChange={(name, value) => setFormData(prev => ({ ...prev, [name]: value }))}
|
||||||
|
formConfig={formConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取字段状态(联动规则影响)
|
||||||
|
const fieldState = fieldStates[field.name] || {};
|
||||||
|
const isVisible = fieldState.visible !== false; // 默认显示
|
||||||
|
const isDisabled = fieldState.disabled || field.disabled || false;
|
||||||
|
const isRequired = fieldState.required !== undefined ? fieldState.required : field.required;
|
||||||
|
|
||||||
|
// 如果字段被隐藏,不渲染
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并基础验证规则和自定义验证规则
|
||||||
|
const customRules = convertValidationRules(field.validationRules);
|
||||||
|
|
||||||
|
// 🐛 调试:打印验证规则
|
||||||
|
if (field.validationRules && field.validationRules.length > 0) {
|
||||||
|
console.log(`📋 字段 "${field.label}" 的验证规则:`, field.validationRules);
|
||||||
|
console.log(`✅ 转换后的规则:`, customRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查自定义验证规则中是否已经包含必填验证
|
||||||
|
const hasRequiredRule = field.validationRules?.some(rule => rule.type === 'required');
|
||||||
|
|
||||||
|
// 基础规则:只有在没有自定义必填验证时,才使用字段属性中的"是否必填"
|
||||||
|
const baseRules: Rule[] = [];
|
||||||
|
if (isRequired && !hasRequiredRule) {
|
||||||
|
baseRules.push({
|
||||||
|
required: true,
|
||||||
|
message: `请输入${field.label}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRules = [...baseRules, ...customRules];
|
||||||
|
|
||||||
|
// 🐛 调试:打印最终规则
|
||||||
|
if (allRules.length > 0) {
|
||||||
|
console.log(`🎯 字段 "${field.label}" 最终验证规则:`, allRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通表单组件使用 Form.Item 包裹
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={field.id}
|
||||||
|
label={field.label}
|
||||||
|
name={field.name}
|
||||||
|
colon={true}
|
||||||
|
rules={allRules}
|
||||||
|
>
|
||||||
|
<FieldRenderer
|
||||||
|
field={{ ...field, disabled: isDisabled }}
|
||||||
|
value={formData[field.name]}
|
||||||
|
onChange={(value) => setFormData(prev => ({ ...prev, [field.name]: value }))}
|
||||||
|
isPreview={true}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#8c8c8c' }}>
|
||||||
|
表单为空
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLayout = formConfig.labelAlign === 'top' ? 'vertical' : 'horizontal';
|
||||||
|
const labelColSpan = formConfig.labelAlign === 'left' ? 6 : formConfig.labelAlign === 'right' ? 6 : undefined;
|
||||||
|
const wrapperColSpan = formConfig.labelAlign === 'left' || formConfig.labelAlign === 'right' ? 18 : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 40px' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout={formLayout}
|
||||||
|
size={formConfig.size}
|
||||||
|
colon={true}
|
||||||
|
labelAlign={formConfig.labelAlign === 'top' ? undefined : formConfig.labelAlign}
|
||||||
|
labelCol={labelColSpan ? { span: labelColSpan } : undefined}
|
||||||
|
wrapperCol={wrapperColSpan ? { span: wrapperColSpan } : undefined}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
disabled={readonly}
|
||||||
|
>
|
||||||
|
{formConfig.title && (
|
||||||
|
<h3 style={{ marginBottom: 24 }}>{formConfig.title}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderFields(fields)}
|
||||||
|
|
||||||
|
{!readonly && (showSubmit || showCancel) && (
|
||||||
|
<Form.Item
|
||||||
|
style={{ marginTop: 32, marginBottom: 0 }}
|
||||||
|
labelCol={{ span: 0 }}
|
||||||
|
wrapperCol={{ span: 24 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{showSubmit && (
|
||||||
|
<Button type="primary" onClick={handleSubmit}>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showCancel && (
|
||||||
|
<Button onClick={onCancel || handleReset}>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!showCancel && (
|
||||||
|
<Button onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormRenderer;
|
||||||
|
|
||||||
@ -39,6 +39,9 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { setNodeRef } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: 'canvas',
|
id: 'canvas',
|
||||||
|
data: {
|
||||||
|
accept: 'field',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -50,6 +50,9 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
|||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: field.id,
|
id: field.id,
|
||||||
|
data: {
|
||||||
|
accept: 'field',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
@ -74,8 +74,16 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
|||||||
|
|
||||||
if (field.type === 'text') {
|
if (field.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '8px 0' }}>
|
<div style={{
|
||||||
<Text>{field.content || '这是一段文字'}</Text>
|
padding: '8px 0',
|
||||||
|
textAlign: field.textAlign || 'left',
|
||||||
|
}}>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: field.fontSize ? `${field.fontSize}px` : '14px',
|
||||||
|
color: field.textColor || '#000000',
|
||||||
|
}}>
|
||||||
|
{field.content || '这是一段文字'}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -161,6 +169,11 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
|||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: field.checkboxLayout === 'vertical' ? 'column' : 'row',
|
||||||
|
gap: field.checkboxLayout === 'vertical' ? '8px' : '16px',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -109,6 +109,11 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: dropId,
|
id: dropId,
|
||||||
|
data: {
|
||||||
|
accept: 'field',
|
||||||
|
gridId,
|
||||||
|
colIndex,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 预览模式下的样式
|
// 预览模式下的样式
|
||||||
@ -274,9 +274,30 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedField.type === 'text' && (
|
{selectedField.type === 'text' && (
|
||||||
<Form.Item label="文本内容" name="content">
|
<>
|
||||||
<Input.TextArea rows={3} placeholder="请输入文本内容" />
|
<Form.Item label="文本内容" name="content">
|
||||||
</Form.Item>
|
<Input.TextArea rows={3} placeholder="请输入文本内容" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="文字对齐" name="textAlign">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio.Button value="left">靠左</Radio.Button>
|
||||||
|
<Radio.Button value="center">居中</Radio.Button>
|
||||||
|
<Radio.Button value="right">靠右</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="字体大小" name="fontSize">
|
||||||
|
<InputNumber
|
||||||
|
min={12}
|
||||||
|
max={72}
|
||||||
|
placeholder="字体大小(px)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="px"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="字体颜色" name="textColor">
|
||||||
|
<Input type="color" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedField.type === 'grid' && (
|
{selectedField.type === 'grid' && (
|
||||||
@ -383,6 +404,15 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedField.type === 'checkbox' && (
|
||||||
|
<Form.Item label="布局方式" name="checkboxLayout">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio.Button value="horizontal">行内</Radio.Button>
|
||||||
|
<Radio.Button value="vertical">块级</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasOptions && (
|
{hasOptions && (
|
||||||
<div>
|
<div>
|
||||||
<Divider style={{ margin: '16px 0' }} />
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
@ -119,6 +119,7 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
|||||||
category: '基础字段',
|
category: '基础字段',
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
dataSourceType: 'static',
|
dataSourceType: 'static',
|
||||||
|
checkboxLayout: 'horizontal', // 默认行内布局
|
||||||
options: [
|
options: [
|
||||||
{ label: '选项1', value: '1' },
|
{ label: '选项1', value: '1' },
|
||||||
{ label: '选项2', value: '2' },
|
{ label: '选项2', value: '2' },
|
||||||
@ -161,6 +162,9 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
|||||||
category: '基础字段',
|
category: '基础字段',
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
content: '这是一段文字',
|
content: '这是一段文字',
|
||||||
|
textAlign: 'left', // 默认左对齐
|
||||||
|
fontSize: 14, // 默认字体大小 14px
|
||||||
|
textColor: '#000000', // 默认黑色
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 高级字段
|
// 高级字段
|
||||||
37
frontend/src/components/FormDesigner/index.tsx
Normal file
37
frontend/src/components/FormDesigner/index.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器组件库入口
|
||||||
|
*
|
||||||
|
* 提供两个核心组件:
|
||||||
|
* 1. FormDesigner - 设计时组件,用于可视化设计表单
|
||||||
|
* 2. FormRenderer - 运行时组件,用于渲染和提交表单
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as FormDesigner } from './Designer';
|
||||||
|
export type { FormDesignerProps } from './Designer';
|
||||||
|
|
||||||
|
export { default as FormRenderer } from './Renderer';
|
||||||
|
export type { FormRendererProps } from './Renderer';
|
||||||
|
|
||||||
|
// 导出类型定义
|
||||||
|
export type {
|
||||||
|
FieldType,
|
||||||
|
FieldOption,
|
||||||
|
CascadeFieldOption,
|
||||||
|
DataSourceType,
|
||||||
|
ValidationType,
|
||||||
|
ValidationRule,
|
||||||
|
LinkageType,
|
||||||
|
LinkageCondition,
|
||||||
|
LinkageRule,
|
||||||
|
ApiDataSource,
|
||||||
|
PredefinedDataSource,
|
||||||
|
PredefinedCascadeDataSource,
|
||||||
|
FieldConfig,
|
||||||
|
FormConfig,
|
||||||
|
FormSchema,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 导出组件配置
|
||||||
|
export { COMPONENT_LIST, getComponentsByCategory } from './config';
|
||||||
|
export type { ComponentMeta } from './config';
|
||||||
|
|
||||||
@ -2,6 +2,35 @@
|
|||||||
* 表单设计器样式
|
* 表单设计器样式
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* 设计器容器 */
|
||||||
|
.form-designer {
|
||||||
|
height: calc(100vh - 150px); /* 默认高度,适应带导航的页面 */
|
||||||
|
min-height: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* 组件面板 */
|
/* 组件面板 */
|
||||||
.form-designer-component-panel {
|
.form-designer-component-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -131,6 +131,10 @@ export interface FieldConfig {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
span?: number; // 栅格占位格数(该字段在栅格中占几列)
|
span?: number; // 栅格占位格数(该字段在栅格中占几列)
|
||||||
content?: string; // 文本内容(用于 text 组件)
|
content?: string; // 文本内容(用于 text 组件)
|
||||||
|
textAlign?: 'left' | 'center' | 'right'; // 文字对齐方式(用于 text 组件)
|
||||||
|
fontSize?: number; // 文字大小(用于 text 组件)
|
||||||
|
textColor?: string; // 文字颜色(用于 text 组件)
|
||||||
|
checkboxLayout?: 'horizontal' | 'vertical'; // 多选框布局方式(horizontal:行内,vertical:块级)
|
||||||
columns?: number; // 栅格列数(用于 grid 组件)
|
columns?: number; // 栅格列数(用于 grid 组件)
|
||||||
columnSpans?: number[]; // 栅格每列宽度(用于 grid 组件,如 [2, 18, 2],总和不超过24)
|
columnSpans?: number[]; // 栅格每列宽度(用于 grid 组件,如 [2, 18, 2],总和不超过24)
|
||||||
gutter?: number; // 栅格间距(用于 grid 组件)
|
gutter?: number; // 栅格间距(用于 grid 组件)
|
||||||
@ -145,9 +149,15 @@ export interface FormConfig {
|
|||||||
size?: 'small' | 'middle' | 'large';
|
size?: 'small' | 'middle' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单 Schema
|
// 表单 Schema(标准化数据格式)
|
||||||
export interface FormSchema {
|
export interface FormSchema {
|
||||||
|
version: string; // Schema 版本号
|
||||||
formConfig: FormConfig;
|
formConfig: FormConfig;
|
||||||
fields: FieldConfig[];
|
fields: FieldConfig[];
|
||||||
|
metadata?: { // 元数据(可选)
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
author?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,576 +1,349 @@
|
|||||||
/**
|
/**
|
||||||
* 表单设计器主入口
|
* FormDesigner 组件使用示例页面
|
||||||
*
|
*
|
||||||
* 功能:
|
* 展示如何使用 @/components/FormDesigner 中的组件:
|
||||||
* - 左侧:组件面板
|
* - FormDesigner: 表单设计器(设计时)
|
||||||
* - 中间:设计画布
|
* - FormRenderer: 表单渲染器(运行时)
|
||||||
* - 右侧:属性配置
|
|
||||||
* - 导入/导出 JSON Schema
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button, Space, message, Modal } from 'antd';
|
import { message, Tabs, Card, Button, Modal, Space, Divider } from 'antd';
|
||||||
import {
|
import { FormDesigner, FormRenderer, type FormSchema } from '@/components/FormDesigner';
|
||||||
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 FormDesignerExamplesPage: React.FC = () => {
|
||||||
// 表单字段列表
|
// ==================== 示例 1: 表单设计器 ====================
|
||||||
const [fields, setFields] = useState<FieldConfig[]>([]);
|
const [designerSchema, setDesignerSchema] = useState<FormSchema>();
|
||||||
|
|
||||||
// 选中的字段
|
const handleDesignerSave = async (schema: FormSchema) => {
|
||||||
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
console.log('💾 保存的表单 Schema:', JSON.stringify(schema, null, 2));
|
||||||
|
message.success('表单设计已保存!请查看控制台');
|
||||||
// 剪贴板(用于复制粘贴)
|
setPreviewSchema(schema); // 保存后更新预览
|
||||||
const [clipboard, setClipboard] = useState<FieldConfig | null>(null);
|
// 实际项目中这里会调用后端 API 保存
|
||||||
|
// await formSchemaService.save(schema);
|
||||||
// 表单配置
|
|
||||||
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 deepCopyField = useCallback((field: FieldConfig): FieldConfig => {
|
|
||||||
const newField: FieldConfig = {
|
|
||||||
...field,
|
|
||||||
id: generateId(),
|
|
||||||
name: field.name + '_copy_' + Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果是栅格布局,递归复制子字段
|
|
||||||
if (field.type === 'grid' && field.children) {
|
|
||||||
newField.children = field.children.map(columnFields =>
|
|
||||||
columnFields.map(childField => deepCopyField(childField))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newField;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 复制字段到剪贴板
|
|
||||||
const handleCopyField = useCallback((fieldId: string) => {
|
|
||||||
// 递归查找字段
|
|
||||||
const findField = (fieldList: FieldConfig[], id: string): FieldConfig | null => {
|
|
||||||
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 = findField(columnFields, id);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const field = findField(fields, fieldId);
|
|
||||||
if (field) {
|
|
||||||
setClipboard(field);
|
|
||||||
message.success('已复制字段到剪贴板');
|
|
||||||
}
|
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
// 粘贴字段到主列表末尾
|
|
||||||
const handlePasteToCanvas = useCallback(() => {
|
|
||||||
if (!clipboard) {
|
|
||||||
message.warning('剪贴板为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField = deepCopyField(clipboard);
|
|
||||||
setFields(prev => [...prev, newField]);
|
|
||||||
setSelectedFieldId(newField.id);
|
|
||||||
message.success('已粘贴字段');
|
|
||||||
}, [clipboard, deepCopyField]);
|
|
||||||
|
|
||||||
// 粘贴字段到栅格列
|
|
||||||
const handlePasteToGrid = useCallback((gridId: string, colIndex: number) => {
|
|
||||||
if (!clipboard) {
|
|
||||||
message.warning('剪贴板为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField = deepCopyField(clipboard);
|
|
||||||
|
|
||||||
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('已粘贴字段到栅格');
|
|
||||||
}, [clipboard, deepCopyField]);
|
|
||||||
|
|
||||||
// 删除字段(支持删除栅格内的字段)
|
|
||||||
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) => {
|
|
||||||
// 🐛 调试:打印字段更新
|
|
||||||
console.log('🔄 [FormDesigner] 字段更新:', updatedField);
|
|
||||||
if (updatedField.validationRules && updatedField.validationRules.length > 0) {
|
|
||||||
console.log('✅ [FormDesigner] 字段包含验证规则:', updatedField.validationRules);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归更新函数
|
|
||||||
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 => {
|
|
||||||
const newFields = updateFieldRecursive(prev);
|
|
||||||
console.log('💾 [FormDesigner] 字段列表已更新');
|
|
||||||
return newFields;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 更新表单配置
|
|
||||||
const handleFormConfigChange = useCallback((newConfig: Partial<FormConfig>) => {
|
|
||||||
setFormConfig(prev => ({ ...prev, ...newConfig }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 保存表单
|
|
||||||
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) || null;
|
// ==================== 示例 2: 表单渲染器(静态) ====================
|
||||||
|
const [previewSchema, setPreviewSchema] = useState<FormSchema>();
|
||||||
|
const [staticFormData, setStaticFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 获取所有字段(扁平化,包括grid中的嵌套字段)
|
const handleStaticFormSubmit = async (data: Record<string, any>) => {
|
||||||
const getAllFields = useCallback((fieldList: FieldConfig[]): FieldConfig[] => {
|
console.log('📤 静态表单提交数据:', data);
|
||||||
const result: FieldConfig[] = [];
|
message.success('表单提交成功!请查看控制台');
|
||||||
|
};
|
||||||
|
|
||||||
fieldList.forEach(field => {
|
// ==================== 示例 3: 工作流表单(弹窗) ====================
|
||||||
// 只添加实际的表单字段,不包括布局组件
|
const [workflowModalVisible, setWorkflowModalVisible] = useState(false);
|
||||||
if (field.type !== 'divider' && field.type !== 'grid' && field.type !== 'text') {
|
|
||||||
result.push(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是grid,递归获取其子字段
|
const workflowFormSchema: FormSchema = {
|
||||||
if (field.type === 'grid' && field.children) {
|
version: '1.0',
|
||||||
field.children.forEach(columnFields => {
|
formConfig: {
|
||||||
result.push(...getAllFields(columnFields));
|
labelAlign: 'right',
|
||||||
});
|
size: 'middle',
|
||||||
}
|
formWidth: 600
|
||||||
});
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'wf_field_1',
|
||||||
|
type: 'input',
|
||||||
|
label: '工作流名称',
|
||||||
|
name: 'workflowName',
|
||||||
|
placeholder: '请输入工作流名称',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wf_field_2',
|
||||||
|
type: 'select',
|
||||||
|
label: '目标环境',
|
||||||
|
name: 'environment',
|
||||||
|
placeholder: '请选择环境',
|
||||||
|
required: true,
|
||||||
|
dataSourceType: 'static',
|
||||||
|
options: [
|
||||||
|
{ label: '开发环境', value: 'dev' },
|
||||||
|
{ label: '测试环境', value: 'test' },
|
||||||
|
{ label: '生产环境', value: 'prod' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wf_field_3',
|
||||||
|
type: 'textarea',
|
||||||
|
label: '执行说明',
|
||||||
|
name: 'description',
|
||||||
|
placeholder: '请输入执行说明',
|
||||||
|
rows: 4
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
return result;
|
const handleWorkflowSubmit = async (data: Record<string, any>) => {
|
||||||
}, []);
|
console.log('🚀 工作流启动参数:', data);
|
||||||
|
message.success('工作流已启动!');
|
||||||
|
setWorkflowModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
const allFields = getAllFields(fields);
|
// ==================== 示例 4: 只读模式 ====================
|
||||||
|
const readonlySchema: FormSchema = {
|
||||||
|
version: '1.0',
|
||||||
|
formConfig: {
|
||||||
|
labelAlign: 'right',
|
||||||
|
size: 'middle',
|
||||||
|
formWidth: 600
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'ro_field_1',
|
||||||
|
type: 'input',
|
||||||
|
label: '申请人',
|
||||||
|
name: 'applicant'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ro_field_2',
|
||||||
|
type: 'date',
|
||||||
|
label: '申请日期',
|
||||||
|
name: 'applyDate'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ro_field_3',
|
||||||
|
type: 'select',
|
||||||
|
label: '审批状态',
|
||||||
|
name: 'approvalStatus',
|
||||||
|
dataSourceType: 'static',
|
||||||
|
options: [
|
||||||
|
{ label: '待审批', value: 'pending' },
|
||||||
|
{ label: '已通过', value: 'approved' },
|
||||||
|
{ label: '已拒绝', value: 'rejected' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ro_field_4',
|
||||||
|
type: 'textarea',
|
||||||
|
label: '审批意见',
|
||||||
|
name: 'approvalComment'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// 快捷键支持
|
const readonlyData = {
|
||||||
useEffect(() => {
|
applicant: '张三',
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
applyDate: '2024-07-20',
|
||||||
// 检查是否在输入框、文本域等可编辑元素中
|
approvalStatus: 'approved',
|
||||||
const target = e.target as HTMLElement;
|
approvalComment: '同意申请,准予执行。',
|
||||||
const isEditable =
|
};
|
||||||
target.tagName === 'INPUT' ||
|
|
||||||
target.tagName === 'TEXTAREA' ||
|
|
||||||
target.isContentEditable;
|
|
||||||
|
|
||||||
// 如果在可编辑元素中,不触发快捷键
|
// ==================== 示例 5: 复杂表单(带栅格布局) ====================
|
||||||
if (isEditable) return;
|
const [complexFormData, setComplexFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
const complexFormSchema: FormSchema = {
|
||||||
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
version: '1.0',
|
||||||
|
formConfig: {
|
||||||
|
labelAlign: 'right',
|
||||||
|
size: 'middle',
|
||||||
|
formWidth: 800,
|
||||||
|
title: '用户信息登记表'
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'grid_1',
|
||||||
|
type: 'grid',
|
||||||
|
label: '基本信息',
|
||||||
|
name: 'grid_basic',
|
||||||
|
columns: 2,
|
||||||
|
columnSpans: [12, 12],
|
||||||
|
gutter: 16,
|
||||||
|
children: [
|
||||||
|
[
|
||||||
|
{ id: 'f1', type: 'input', label: '姓名', name: 'name', placeholder: '请输入姓名', required: true },
|
||||||
|
{ id: 'f2', type: 'number', label: '年龄', name: 'age', placeholder: '请输入年龄', min: 1, max: 150 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: 'f3', type: 'radio', label: '性别', name: 'gender', dataSourceType: 'static', options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }] },
|
||||||
|
{ id: 'f4', type: 'date', label: '出生日期', name: 'birthday', placeholder: '请选择日期' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grid_2',
|
||||||
|
type: 'grid',
|
||||||
|
label: '联系方式',
|
||||||
|
name: 'grid_contact',
|
||||||
|
columns: 2,
|
||||||
|
columnSpans: [12, 12],
|
||||||
|
gutter: 16,
|
||||||
|
children: [
|
||||||
|
[
|
||||||
|
{ id: 'f5', type: 'input', label: '手机号', name: 'phone', placeholder: '请输入手机号' },
|
||||||
|
{ id: 'f6', type: 'input', label: '邮箱', name: 'email', placeholder: '请输入邮箱' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: 'f7', type: 'input', label: '微信', name: 'wechat', placeholder: '请输入微信号' },
|
||||||
|
{ id: 'f8', type: 'input', label: 'QQ', name: 'qq', placeholder: '请输入QQ号' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f9',
|
||||||
|
type: 'textarea',
|
||||||
|
label: '个人简介',
|
||||||
|
name: 'bio',
|
||||||
|
placeholder: '请输入个人简介',
|
||||||
|
rows: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// Ctrl+C / Cmd+C: 复制选中的字段
|
const handleComplexFormSubmit = async (data: Record<string, any>) => {
|
||||||
if (ctrlKey && e.key === 'c' && selectedFieldId) {
|
console.log('📋 复杂表单提交数据:', data);
|
||||||
e.preventDefault();
|
message.success('表单提交成功!');
|
||||||
handleCopyField(selectedFieldId);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+V / Cmd+V: 粘贴到画布
|
// ==================== Tab 配置 ====================
|
||||||
if (ctrlKey && e.key === 'v' && clipboard) {
|
const tabItems = [
|
||||||
e.preventDefault();
|
{
|
||||||
handlePasteToCanvas();
|
key: '1',
|
||||||
}
|
label: '📝 示例 1: 表单设计器',
|
||||||
|
children: (
|
||||||
// Delete: 删除选中的字段
|
<Card>
|
||||||
if (e.key === 'Delete' && selectedFieldId) {
|
<h2>表单设计器(FormDesigner)</h2>
|
||||||
e.preventDefault();
|
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||||
handleDeleteField(selectedFieldId);
|
可视化拖拽设计表单,支持导入/导出 JSON Schema,支持字段属性配置、验证规则、联动规则等。
|
||||||
}
|
</p>
|
||||||
};
|
<Divider />
|
||||||
|
<FormDesigner
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
value={designerSchema}
|
||||||
return () => {
|
onChange={setDesignerSchema}
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
onSave={handleDesignerSave}
|
||||||
};
|
/>
|
||||||
}, [selectedFieldId, clipboard, handleCopyField, handlePasteToCanvas, handleDeleteField]);
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: '🎨 示例 2: 静态表单渲染',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<h2>表单渲染器(静态模式)</h2>
|
||||||
|
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||||
|
根据设计器保存的 Schema 渲染表单,支持数据收集、验证和提交。
|
||||||
|
</p>
|
||||||
|
<Divider />
|
||||||
|
{previewSchema ? (
|
||||||
|
<FormRenderer
|
||||||
|
schema={previewSchema}
|
||||||
|
value={staticFormData}
|
||||||
|
onChange={setStaticFormData}
|
||||||
|
onSubmit={handleStaticFormSubmit}
|
||||||
|
showCancel
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>
|
||||||
|
请先在"示例 1"中设计表单并保存
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: '🚀 示例 3: 工作流表单',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<h2>工作流表单(弹窗模式)</h2>
|
||||||
|
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||||
|
模拟工作流启动场景,在 Modal 中使用表单渲染器收集参数。
|
||||||
|
</p>
|
||||||
|
<Divider />
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<h3>使用场景</h3>
|
||||||
|
<ul>
|
||||||
|
<li>工作流启动参数收集</li>
|
||||||
|
<li>任务审批表单</li>
|
||||||
|
<li>快捷操作弹窗</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" size="large" onClick={() => setWorkflowModalVisible(true)}>
|
||||||
|
启动工作流
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Modal
|
||||||
|
title="启动工作流"
|
||||||
|
open={workflowModalVisible}
|
||||||
|
onCancel={() => setWorkflowModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={workflowFormSchema.formConfig.formWidth || 600}
|
||||||
|
>
|
||||||
|
<FormRenderer
|
||||||
|
schema={workflowFormSchema}
|
||||||
|
onSubmit={handleWorkflowSubmit}
|
||||||
|
onCancel={() => setWorkflowModalVisible(false)}
|
||||||
|
showCancel
|
||||||
|
submitText="启动"
|
||||||
|
cancelText="取消"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4',
|
||||||
|
label: '👁️ 示例 4: 只读模式',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<h2>只读模式</h2>
|
||||||
|
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||||
|
查看已提交的表单数据,所有字段禁用编辑,不显示提交按钮。
|
||||||
|
</p>
|
||||||
|
<Divider />
|
||||||
|
<div>
|
||||||
|
<h3>使用场景</h3>
|
||||||
|
<ul>
|
||||||
|
<li>审批流程详情查看</li>
|
||||||
|
<li>历史数据展示</li>
|
||||||
|
<li>表单归档查看</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<FormRenderer
|
||||||
|
schema={readonlySchema}
|
||||||
|
value={readonlyData}
|
||||||
|
readonly={true}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
label: '📋 示例 5: 复杂表单',
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<h2>复杂表单(带栅格布局)</h2>
|
||||||
|
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||||
|
展示包含栅格布局的复杂表单,支持多列排版。
|
||||||
|
</p>
|
||||||
|
<Divider />
|
||||||
|
<FormRenderer
|
||||||
|
schema={complexFormSchema}
|
||||||
|
value={complexFormData}
|
||||||
|
onChange={setComplexFormData}
|
||||||
|
onSubmit={handleComplexFormSubmit}
|
||||||
|
showCancel
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ padding: 24 }}>
|
||||||
{/* 顶部工具栏 */}
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div
|
<h1>FormDesigner 组件使用示例</h1>
|
||||||
style={{
|
<p style={{ color: '#666', fontSize: 14 }}>
|
||||||
padding: '12px 24px',
|
本页面展示如何使用 <code>@/components/FormDesigner</code> 中的 <strong>FormDesigner</strong> 和 <strong>FormRenderer</strong> 组件。
|
||||||
background: '#fff',
|
</p>
|
||||||
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>
|
</div>
|
||||||
|
<Tabs defaultActiveKey="1" items={tabItems} />
|
||||||
{/* 主体区域 */}
|
|
||||||
<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}
|
|
||||||
onCopyField={handleCopyField}
|
|
||||||
onPasteToCanvas={handlePasteToCanvas}
|
|
||||||
onPasteToGrid={handlePasteToGrid}
|
|
||||||
onDeleteField={handleDeleteField}
|
|
||||||
labelAlign={formConfig.labelAlign}
|
|
||||||
hasClipboard={!!clipboard}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 右侧属性面板 */}
|
|
||||||
<PropertyPanel
|
|
||||||
selectedField={selectedField}
|
|
||||||
formConfig={formConfig}
|
|
||||||
onFieldChange={handleFieldChange}
|
|
||||||
onFormConfigChange={handleFormConfigChange}
|
|
||||||
allFields={allFields}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay>{/* 拖拽时的视觉反馈 */}</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{/* 预览模态框 */}
|
|
||||||
<Modal
|
|
||||||
title="表单预览"
|
|
||||||
open={previewVisible}
|
|
||||||
onCancel={() => setPreviewVisible(false)}
|
|
||||||
width={formConfig.formWidth || 600}
|
|
||||||
footer={null}
|
|
||||||
afterOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
console.log('🎬 [Modal] 打开预览,当前字段列表:', fields);
|
|
||||||
|
|
||||||
// 递归检查所有字段(包括grid内的嵌套字段)
|
|
||||||
const checkFieldsRecursive = (fieldList: FieldConfig[], level = 0) => {
|
|
||||||
fieldList.forEach(field => {
|
|
||||||
const indent = ' '.repeat(level);
|
|
||||||
console.log(`${indent}📦 [Modal] 字段: "${field.label}" (${field.type})`, field);
|
|
||||||
|
|
||||||
if (field.validationRules && field.validationRules.length > 0) {
|
|
||||||
console.log(`${indent}📋 [Modal] ✅ 包含验证规则:`, field.validationRules);
|
|
||||||
} else {
|
|
||||||
console.log(`${indent}📋 [Modal] ❌ 没有验证规则`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归检查grid的子字段
|
|
||||||
if (field.type === 'grid' && field.children) {
|
|
||||||
field.children.forEach((columnFields, colIndex) => {
|
|
||||||
console.log(`${indent} 🔸 Grid列 ${colIndex + 1}:`);
|
|
||||||
checkFieldsRecursive(columnFields, level + 2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
checkFieldsRecursive(fields);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormPreview fields={fields} formConfig={formConfig} />
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FormDesigner;
|
export default FormDesignerExamplesPage;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user