/** * 表单设计器组件(设计时) * * 功能: * - 可视化拖拽设计表单 * - 支持字段属性配置 * - 支持验证规则和联动规则 * - 导入/导出 JSON Schema * * 使用方式: * ```tsx * * ``` */ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Button, Space, message, Modal } from 'antd'; import { SaveOutlined, DownloadOutlined, UploadOutlined, EyeOutlined, ClearOutlined, } from '@ant-design/icons'; import { DndContext, DragEndEvent, DragStartEvent, DragOverEvent, DragOverlay, PointerSensor, useSensor, useSensors, pointerWithin, 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, { type FormPreviewRef } from './Preview'; import { COMPONENT_LIST } from './config'; import type { FieldConfig, FormConfig, FormSchema } from './types'; import type { ComponentMeta } from './config'; import './styles.css'; // 🔌 组件列表 Context(用于插件式扩展) export const ComponentsContext = React.createContext(COMPONENT_LIST); export interface FormDesignerProps { value?: FormSchema; // 受控模式:表单 Schema onChange?: (schema: FormSchema) => void; // 受控模式:Schema 变化回调 onSave?: (schema: FormSchema) => void; // 保存回调 readonly?: boolean; // 只读模式 showToolbar?: boolean; // 是否显示工具栏 extraActions?: React.ReactNode; // 额外的操作按钮 extraComponents?: ComponentMeta[]; // 🆕 扩展字段组件(如工作流字段) } const FormDesigner: React.FC = ({ value, onChange, onSave, readonly = false, showToolbar = true, extraActions, extraComponents = [], }) => { // 表单预览 ref const formPreviewRef = useRef(null); // 内部状态(非受控模式使用) const [internalFields, setInternalFields] = useState([]); const [internalFormConfig, setInternalFormConfig] = useState({ labelAlign: 'right', size: 'middle', formWidth: 600, }); // 使用受控值或内部值 const fields = value?.fields ?? internalFields; const formConfig = value?.formConfig ?? internalFormConfig; // 🔍 字段名冲突检测 const fieldNames = useMemo(() => { const names = new Map(); // name -> fieldIds[] const collectNames = (fieldList: FieldConfig[]) => { fieldList.forEach(field => { // 只收集有效的 name(非空且非布局组件) if (field.name && field.name.trim() && field.type !== 'text' && field.type !== 'divider') { const ids = names.get(field.name) || []; ids.push(field.id); names.set(field.name, ids); } // 递归处理栅格布局 if (field.type === 'grid' && field.children) { field.children.forEach(col => collectNames(col)); } }); }; collectNames(fields); return names; }, [fields]); // 🚨 找出所有冲突的字段 ID const duplicateFieldIds = useMemo(() => { const ids = new Set(); fieldNames.forEach((fieldIds: string[]) => { if (fieldIds.length > 1) { // 该 name 被多个字段使用,标记所有这些字段为冲突 fieldIds.forEach((id: string) => ids.add(id)); } }); return ids; }, [fieldNames]); const setFields = useCallback((updater: React.SetStateAction) => { 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) => { 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(); // 待选中的字段ID(用于确保字段添加后再选中) const [pendingSelectId, setPendingSelectId] = useState(null); // 剪贴板(用于复制粘贴) const [clipboard, setClipboard] = useState(null); // 预览模式 const [previewVisible, setPreviewVisible] = useState(false); // 正在拖拽的元素ID const [activeId, setActiveId] = useState(null); // 拖拽悬停的目标ID(用于显示插入指示器) const [overId, setOverId] = useState(null); // 拖拽传感器 - 添加激活约束,减少误触和卡顿 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 移动8px后才激活拖拽 }, }) ); // 🔌 合并核心组件和扩展组件 const allComponents = useMemo(() => { return [...COMPONENT_LIST, ...extraComponents]; }, [extraComponents]); // 自定义碰撞检测策略:基于边界判断,区分"插入"和"拖入" const customCollisionDetection = useCallback((args: any) => { const pointerCollisions = pointerWithin(args); if (pointerCollisions.length === 0) { return closestCenter(args); } // 查找栅格字段的碰撞 const gridFieldCollisions = pointerCollisions.filter((collision: any) => { const fieldId = collision.id.toString(); return fields.some(f => f.id === fieldId && f.type === 'grid'); }); // 如果拖到栅格字段上,使用边界检测 if (gridFieldCollisions.length > 0 && args.pointerCoordinates) { const gridCollision = gridFieldCollisions[0]; const droppableContainer = args.droppableContainers.find( (container: any) => container.id === gridCollision.id ); if (droppableContainer?.rect?.current) { const rect = droppableContainer.rect.current; const pointer = args.pointerCoordinates; // 计算鼠标在栅格字段中的相对垂直位置(0-1) const relativeY = (pointer.y - rect.top) / rect.height; // 边缘阈值:上下各 20% const edgeThreshold = 0.2; if (relativeY < edgeThreshold || relativeY > (1 - edgeThreshold)) { // 在边缘区域 → 返回栅格字段本身(插入到栅格前后) return [gridCollision]; } else { // 在中心区域 → 查找栅格列(拖入栅格内部) const gridColumnCollisions = pointerCollisions.filter((collision: any) => { const id = collision.id.toString(); return id.startsWith('grid-') && id.includes('-col-'); }); if (gridColumnCollisions.length > 0) { return gridColumnCollisions; } } } } // 非栅格字段:优先级排序 // 1. 优先选择非栅格字段 const nonGridFields = pointerCollisions.filter((collision: any) => { const id = collision.id.toString(); return id.startsWith('field_') && !fields.some(f => f.id === id && f.type === 'grid'); }); if (nonGridFields.length > 0) { return nonGridFields; } // 2. 画布或底部区域 const canvasCollisions = pointerCollisions.filter((collision: any) => { const id = collision.id.toString(); return id === 'canvas' || id === 'canvas-bottom'; }); if (canvasCollisions.length > 0) { return canvasCollisions; } // 3. 栅格列 const gridColumns = pointerCollisions.filter((collision: any) => { const id = collision.id.toString(); return id.startsWith('grid-') && id.includes('-col-'); }); if (gridColumns.length > 0) { return gridColumns; } return pointerCollisions; }, [fields]); // 生成唯一 ID const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // 处理拖拽开始 const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id.toString()); setOverId(null); }, []); // 处理拖拽悬停 const handleDragOver = useCallback((event: DragOverEvent) => { const { over } = event; setOverId(over ? over.id.toString() : null); }, []); // 处理拖拽结束 const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; // 清除拖拽状态 setActiveId(null); setOverId(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 = allComponents.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); }); // 🔄 使用 pendingSelectId 确保字段添加后再选中 setPendingSelectId(newField.id); message.success(`已添加${component.label}到栅格列`); } return; } // 从组件面板拖拽新组件到画布或字段 if (activeData?.isNew && overData?.accept === 'field') { const componentType = active.id.toString().replace('new-', ''); const component = allComponents.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' || over.id === 'canvas-bottom' || overData?.isBottom) { newFields.push(newField); } else { // 如果拖到某个字段上,插入到该字段之前(修复拖拽插入位置bug) const overIndex = newFields.findIndex((f) => f.id === over.id); if (overIndex >= 0) { newFields.splice(overIndex, 0, newField); // 插入到目标字段之前 } else { newFields.push(newField); } } return newFields; }); // 🔄 使用 pendingSelectId 确保字段添加后再选中 setPendingSelectId(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]); // 🔄 使用 pendingSelectId 确保字段添加后再选中 setPendingSelectId(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; })); // 🔄 使用 pendingSelectId 确保字段添加后再选中 setPendingSelectId(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) => { 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; // 🔄 当有待选中的字段时,等待该字段被添加到 fields 后再选中 useEffect(() => { if (pendingSelectId) { const field = findFieldById(fields, pendingSelectId); if (field) { console.log('✅ 字段已添加,现在选中:', pendingSelectId, field.type); setSelectedFieldId(pendingSelectId); setPendingSelectId(null); // 清除待选中状态 } else { console.log('⏳ 等待字段添加到 fields:', pendingSelectId); } } }, [fields, pendingSelectId]); // 获取所有字段(扁平化,包括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 (
{showToolbar && (
表单设计
{extraActions}
)}
{/* 左侧组件面板 */}
{/* 中间设计画布 */} {/* 右侧属性面板 */}
{/* 拖拽覆盖层 - 显示正在拖拽的元素 */} {activeId && activeId.startsWith('new-') ? (
{allComponents.find(c => `new-${c.type}` === activeId)?.label || '组件'}
) : null}
{/* 预览模态框 */} setPreviewVisible(false)} width={formConfig.formWidth || 600} styles={{ body: { maxHeight: '60vh', minHeight: '300px', overflowY: 'auto', overflowX: 'hidden', padding: 0, } }} footer={
} >
); }; export default FormDesigner;