deploy-ease-platform/frontend/src/components/FormDesigner/Designer.tsx
2025-10-25 12:29:02 +08:00

888 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 表单设计器组件(设计时)
*
* 功能:
* - 可视化拖拽设计表单
* - 支持字段属性配置
* - 支持验证规则和联动规则
* - 导入/导出 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;