888 lines
27 KiB
TypeScript
888 lines
27 KiB
TypeScript
/**
|
||
* 表单设计器组件(设计时)
|
||
*
|
||
* 功能:
|
||
* - 可视化拖拽设计表单
|
||
* - 支持字段属性配置
|
||
* - 支持验证规则和联动规则
|
||
* - 导入/导出 JSON Schema
|
||
*
|
||
* 使用方式:
|
||
* ```tsx
|
||
* <FormDesigner
|
||
* value={formSchema}
|
||
* onChange={setFormSchema}
|
||
* onSave={handleSave}
|
||
* readonly={false}
|
||
* />
|
||
* ```
|
||
*/
|
||
|
||
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<ComponentMeta[]>(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<FormDesignerProps> = ({
|
||
value,
|
||
onChange,
|
||
onSave,
|
||
readonly = false,
|
||
showToolbar = true,
|
||
extraActions,
|
||
extraComponents = [],
|
||
}) => {
|
||
// 表单预览 ref
|
||
const formPreviewRef = useRef<FormPreviewRef>(null);
|
||
|
||
// 内部状态(非受控模式使用)
|
||
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 fieldNames = useMemo(() => {
|
||
const names = new Map<string, string[]>(); // 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<string>();
|
||
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<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>();
|
||
|
||
// 待选中的字段ID(用于确保字段添加后再选中)
|
||
const [pendingSelectId, setPendingSelectId] = useState<string | null>(null);
|
||
|
||
// 剪贴板(用于复制粘贴)
|
||
const [clipboard, setClipboard] = useState<FieldConfig | null>(null);
|
||
|
||
// 预览模式
|
||
const [previewVisible, setPreviewVisible] = useState(false);
|
||
|
||
// 正在拖拽的元素ID
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
|
||
// 拖拽悬停的目标ID(用于显示插入指示器)
|
||
const [overId, setOverId] = useState<string | null>(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<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;
|
||
|
||
// 🔄 当有待选中的字段时,等待该字段被添加到 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 (
|
||
<ComponentsContext.Provider value={allComponents}>
|
||
<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}
|
||
onDragOver={handleDragOver}
|
||
onDragEnd={handleDragEnd}
|
||
collisionDetection={customCollisionDetection}
|
||
>
|
||
<div className="form-designer-body">
|
||
{/* 左侧组件面板 */}
|
||
<div style={{ width: 280 }}>
|
||
<ComponentPanel extraComponents={extraComponents} />
|
||
</div>
|
||
|
||
{/* 中间设计画布 */}
|
||
<DesignCanvas
|
||
fields={fields}
|
||
selectedFieldId={selectedFieldId}
|
||
onSelectField={handleSelectField}
|
||
onDeleteField={handleDeleteField}
|
||
onCopyField={handleCopyField}
|
||
onPasteToCanvas={handlePasteToCanvas}
|
||
onPasteToGrid={handlePasteToGrid}
|
||
labelAlign={formConfig.labelAlign}
|
||
hasClipboard={!!clipboard}
|
||
activeId={activeId}
|
||
overId={overId}
|
||
duplicateFieldIds={duplicateFieldIds}
|
||
/>
|
||
|
||
{/* 右侧属性面板 */}
|
||
<PropertyPanel
|
||
selectedField={selectedField}
|
||
formConfig={formConfig}
|
||
onFieldChange={handleFieldChange}
|
||
onFormConfigChange={handleFormConfigChange}
|
||
allFields={allFields}
|
||
fieldNames={fieldNames}
|
||
/>
|
||
</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',
|
||
}}>
|
||
{allComponents.find(c => `new-${c.type}` === activeId)?.label || '组件'}
|
||
</div>
|
||
) : null}
|
||
</DragOverlay>
|
||
</DndContext>
|
||
|
||
{/* 预览模态框 */}
|
||
<Modal
|
||
title="表单预览"
|
||
open={previewVisible}
|
||
onCancel={() => setPreviewVisible(false)}
|
||
width={formConfig.formWidth || 600}
|
||
styles={{
|
||
body: {
|
||
maxHeight: '60vh',
|
||
minHeight: '300px',
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden',
|
||
padding: 0,
|
||
}
|
||
}}
|
||
footer={
|
||
<div style={{ textAlign: 'center' }}>
|
||
<Space>
|
||
<Button type="primary" onClick={() => formPreviewRef.current?.submit()}>
|
||
提交
|
||
</Button>
|
||
<Button onClick={() => formPreviewRef.current?.reset()}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
}
|
||
>
|
||
<FormPreview ref={formPreviewRef} fields={fields} formConfig={formConfig} />
|
||
</Modal>
|
||
</div>
|
||
</ComponentsContext.Provider>
|
||
);
|
||
};
|
||
|
||
export default FormDesigner;
|
||
|