725 lines
27 KiB
TypeScript
725 lines
27 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { message } from 'antd';
|
||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||
import { Loader2 } from 'lucide-react';
|
||
|
||
import WorkflowToolbar from './components/WorkflowToolbar';
|
||
import NodePanel from './components/NodePanel';
|
||
import FlowCanvas from './components/FlowCanvas';
|
||
import NodeConfigModal from './components/NodeConfigModal';
|
||
import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal';
|
||
import FormPreviewModal from './components/FormPreviewModal';
|
||
import KeyboardShortcutsPanel from './components/KeyboardShortcutsPanel';
|
||
import type { FlowNode, FlowEdge, FlowNodeData } from './types';
|
||
import type { WorkflowNodeDefinition } from './nodes/types';
|
||
import { isConfigurableNode } from './nodes/types';
|
||
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
||
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
||
import { useHistory } from './hooks/useHistory';
|
||
import { generateNodeId, generateEdgeId } from './utils/idGenerator';
|
||
import { getDefinitionById as getFormDefinitionById } from '@/pages/Form/Definition/List/service';
|
||
import type { FormDefinitionResponse } from '@/pages/Form/Definition/List/types';
|
||
|
||
// 样式
|
||
import '@xyflow/react/dist/style.css';
|
||
import './index.less';
|
||
|
||
const WorkflowDesignInner: React.FC = () => {
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
const {
|
||
getNodes,
|
||
setNodes,
|
||
getEdges,
|
||
setEdges,
|
||
screenToFlowPosition,
|
||
fitView,
|
||
zoomIn,
|
||
zoomOut,
|
||
getZoom
|
||
} = useReactFlow();
|
||
|
||
const [workflowTitle, setWorkflowTitle] = useState('新建工作流');
|
||
const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例
|
||
const [loading, setLoading] = useState(false); // 加载状态
|
||
const [lastSaveTime, setLastSaveTime] = useState<Date | null>(null); // 最后保存时间
|
||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 当前工作流ID
|
||
const currentWorkflowId = id ? parseInt(id) : undefined;
|
||
|
||
// 节点配置模态框状态
|
||
const [configModalVisible, setConfigModalVisible] = useState(false);
|
||
const [configNode, setConfigNode] = useState<FlowNode | null>(null);
|
||
|
||
// 边配置模态框状态
|
||
const [edgeConfigModalVisible, setEdgeConfigModalVisible] = useState(false);
|
||
const [configEdge, setConfigEdge] = useState<FlowEdge | null>(null);
|
||
|
||
// 表单定义数据和预览弹窗状态
|
||
const [formDefinition, setFormDefinition] = useState<FormDefinitionResponse | null>(null);
|
||
const [formPreviewVisible, setFormPreviewVisible] = useState(false);
|
||
|
||
// 快捷键面板状态
|
||
const [shortcutsPanelOpen, setShortcutsPanelOpen] = useState(false);
|
||
|
||
// 提取表单字段(用于变量引用)
|
||
const formFields = useMemo(() => {
|
||
if (!formDefinition?.schema?.fields) return [];
|
||
|
||
const extractFields = (fields: any[]): any[] => {
|
||
const result: any[] = [];
|
||
|
||
for (const field of fields) {
|
||
// 跳过布局组件
|
||
if (['text', 'divider'].includes(field.type)) {
|
||
continue;
|
||
}
|
||
|
||
// 栅格布局:递归提取子字段
|
||
if (field.type === 'grid' && field.children) {
|
||
for (const column of field.children) {
|
||
if (Array.isArray(column)) {
|
||
result.push(...extractFields(column));
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 普通字段
|
||
result.push({
|
||
name: field.name,
|
||
label: field.label || field.name,
|
||
type: field.type,
|
||
description: field.description,
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
return extractFields(formDefinition.schema.fields);
|
||
}, [formDefinition]);
|
||
|
||
// 保存和加载hooks
|
||
const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave();
|
||
const { workflowDefinition, loadWorkflow } = useWorkflowLoad();
|
||
|
||
// 历史记录管理
|
||
const history = useHistory();
|
||
|
||
// 剪贴板(用于复制粘贴)
|
||
const clipboard = useRef<{ nodes: FlowNode[]; edges: FlowEdge[] } | null>(null);
|
||
|
||
// 加载工作流数据
|
||
useEffect(() => {
|
||
const loadData = async () => {
|
||
if (currentWorkflowId) {
|
||
setLoading(true);
|
||
try {
|
||
const data = await loadWorkflow(currentWorkflowId);
|
||
if (data) {
|
||
setNodes(data.nodes);
|
||
setEdges(data.edges);
|
||
setWorkflowTitle(data.definition?.name || '未命名工作流');
|
||
|
||
// 如果工作流关联了表单,加载表单定义数据
|
||
if (data.definition?.formDefinitionId) {
|
||
try {
|
||
const formDef = await getFormDefinitionById(data.definition.formDefinitionId);
|
||
setFormDefinition(formDef);
|
||
console.log('表单定义数据已加载:', formDef);
|
||
} catch (error) {
|
||
console.error('加载表单定义失败:', error);
|
||
}
|
||
} else {
|
||
setFormDefinition(null);
|
||
}
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadData();
|
||
}, [currentWorkflowId, loadWorkflow, setNodes, setEdges]);
|
||
|
||
// 初始化缩放比例
|
||
useEffect(() => {
|
||
setCurrentZoom(getZoom());
|
||
}, [getZoom]);
|
||
|
||
// 自动适应视图
|
||
useEffect(() => {
|
||
// 延迟执行fitView以确保节点已渲染
|
||
const timer = setTimeout(() => {
|
||
fitView({
|
||
padding: 0.1,
|
||
duration: 800,
|
||
minZoom: 1.0, // 最小缩放100%
|
||
maxZoom: 1.0 // 最大缩放100%,确保默认100%
|
||
});
|
||
// 更新zoom显示
|
||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||
}, 100);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [fitView, getZoom]);
|
||
|
||
// 初始化空白画布
|
||
const initialNodes: FlowNode[] = [];
|
||
const initialEdges: FlowEdge[] = [];
|
||
|
||
// 工具栏事件处理
|
||
const handleSave = useCallback(async (isAutoSave = false) => {
|
||
const nodes = getNodes() as FlowNode[];
|
||
const edges = getEdges() as FlowEdge[];
|
||
|
||
const success = await saveWorkflow({
|
||
nodes,
|
||
edges,
|
||
workflowId: currentWorkflowId,
|
||
name: workflowTitle,
|
||
description: workflowDefinition?.description || '',
|
||
definitionData: workflowDefinition // 传递原始定义数据
|
||
});
|
||
|
||
if (success) {
|
||
setLastSaveTime(new Date());
|
||
if (!isAutoSave) {
|
||
console.log('保存工作流成功:', { nodes, edges });
|
||
}
|
||
}
|
||
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
|
||
|
||
// 自动保存
|
||
useEffect(() => {
|
||
if (!currentWorkflowId || !hasUnsavedChanges) return;
|
||
|
||
// 清除之前的定时器
|
||
if (autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
}
|
||
|
||
// 30秒后自动保存
|
||
autoSaveTimerRef.current = setTimeout(() => {
|
||
handleSave(true);
|
||
message.success('已自动保存', 1);
|
||
}, 30000);
|
||
|
||
return () => {
|
||
if (autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
}
|
||
};
|
||
}, [currentWorkflowId, hasUnsavedChanges, handleSave]);
|
||
|
||
const handleBack = useCallback(() => {
|
||
navigate(-1); // 返回上一页,避免硬编码路径
|
||
}, [navigate]);
|
||
|
||
// 预览表单
|
||
const handlePreviewForm = useCallback(() => {
|
||
if (!formDefinition) {
|
||
message.warning('当前工作流未关联启动表单');
|
||
return;
|
||
}
|
||
|
||
setFormPreviewVisible(true);
|
||
}, [formDefinition]);
|
||
|
||
// 撤销操作
|
||
const handleUndo = useCallback(() => {
|
||
const state = history.undo();
|
||
if (state) {
|
||
history.pauseRecording();
|
||
setNodes(state.nodes);
|
||
setEdges(state.edges);
|
||
setTimeout(() => history.resumeRecording(), 100);
|
||
message.success('已撤销');
|
||
} else {
|
||
message.info('没有可撤销的操作');
|
||
}
|
||
}, [history, setNodes, setEdges]);
|
||
|
||
// 重做操作
|
||
const handleRedo = useCallback(() => {
|
||
const state = history.redo();
|
||
if (state) {
|
||
history.pauseRecording();
|
||
setNodes(state.nodes);
|
||
setEdges(state.edges);
|
||
setTimeout(() => history.resumeRecording(), 100);
|
||
message.success('已重做');
|
||
} else {
|
||
message.info('没有可重做的操作');
|
||
}
|
||
}, [history, setNodes, setEdges]);
|
||
|
||
// 复制选中的节点和边
|
||
const handleCopy = useCallback(() => {
|
||
const selectedNodes = getNodes().filter(node => node.selected);
|
||
const selectedEdges = getEdges().filter(edge => {
|
||
// 只复制两端都被选中的边
|
||
return selectedNodes.some(n => n.id === edge.source) &&
|
||
selectedNodes.some(n => n.id === edge.target);
|
||
});
|
||
|
||
if (selectedNodes.length > 0) {
|
||
clipboard.current = {
|
||
nodes: JSON.parse(JSON.stringify(selectedNodes)),
|
||
edges: JSON.parse(JSON.stringify(selectedEdges))
|
||
};
|
||
message.success(`已复制 ${selectedNodes.length} 个节点`);
|
||
} else {
|
||
message.warning('请先选择要复制的节点');
|
||
}
|
||
}, [getNodes, getEdges]);
|
||
|
||
// 粘贴节点
|
||
const handlePaste = useCallback(() => {
|
||
if (!clipboard.current || clipboard.current.nodes.length === 0) {
|
||
message.info('剪贴板为空');
|
||
return;
|
||
}
|
||
|
||
const { nodes: copiedNodes, edges: copiedEdges } = clipboard.current;
|
||
const offset = 50; // 粘贴偏移量
|
||
const idMap = new Map<string, string>();
|
||
|
||
// 创建新节点(带偏移)
|
||
const newNodes = copiedNodes.map(node => {
|
||
const newId = generateNodeId(); // ✅ 使用标准ID生成函数: sid_xxxxxxxx_xxxx_xxxx_xxxx_xxxxxxxxxxxx
|
||
idMap.set(node.id, newId);
|
||
|
||
return {
|
||
...node,
|
||
id: newId,
|
||
position: {
|
||
x: node.position.x + offset,
|
||
y: node.position.y + offset
|
||
},
|
||
selected: true
|
||
};
|
||
});
|
||
|
||
// 创建新边(更新source和target为新ID)
|
||
const newEdges = copiedEdges.map(edge => {
|
||
const newSource = idMap.get(edge.source);
|
||
const newTarget = idMap.get(edge.target);
|
||
|
||
if (!newSource || !newTarget) return null;
|
||
|
||
return {
|
||
...edge,
|
||
id: generateEdgeId(), // ✅ 使用标准ID生成函数: eid_xxxxxxxx_xxxx_xxxx_xxxx_xxxxxxxxxxxx
|
||
source: newSource,
|
||
target: newTarget,
|
||
selected: true
|
||
};
|
||
}).filter(edge => edge !== null) as FlowEdge[];
|
||
|
||
// 取消其他元素的选中状态
|
||
setNodes(nodes => [
|
||
...nodes.map(n => ({ ...n, selected: false })),
|
||
...newNodes
|
||
]);
|
||
setEdges(edges => [
|
||
...edges.map(e => ({ ...e, selected: false })),
|
||
...newEdges
|
||
]);
|
||
|
||
message.success(`已粘贴 ${newNodes.length} 个节点`);
|
||
}, [setNodes, setEdges]);
|
||
|
||
const handleDelete = useCallback(() => {
|
||
const selectedNodes = getNodes().filter(node => node.selected);
|
||
const selectedEdges = getEdges().filter(edge => edge.selected);
|
||
|
||
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
||
setNodes(nodes => nodes.filter(node => !node.selected));
|
||
setEdges(edges => edges.filter(edge => !edge.selected));
|
||
message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`);
|
||
markUnsaved();
|
||
} else {
|
||
message.warning('请先选择要删除的元素');
|
||
}
|
||
}, [getNodes, getEdges, setNodes, setEdges, markUnsaved]);
|
||
|
||
const handleSelectAll = useCallback(() => {
|
||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||
setEdges(edges => edges.map(edge => ({ ...edge, selected: true })));
|
||
message.info('已全选所有元素');
|
||
}, [setNodes, setEdges]);
|
||
|
||
const handleFitView = useCallback(() => {
|
||
fitView({ padding: 0.2, duration: 800 });
|
||
// 延迟更新zoom值以获取最新的缩放比例
|
||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||
}, [fitView, getZoom]);
|
||
|
||
const handleZoomIn = useCallback(() => {
|
||
zoomIn({ duration: 300 });
|
||
// 延迟更新zoom值以获取最新的缩放比例
|
||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||
}, [zoomIn, getZoom]);
|
||
|
||
const handleZoomOut = useCallback(() => {
|
||
zoomOut({ duration: 300 });
|
||
// 延迟更新zoom值以获取最新的缩放比例
|
||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||
}, [zoomOut, getZoom]);
|
||
|
||
// 处理节点拖拽放置 - 使用官方推荐的screenToFlowPosition方法
|
||
const handleDrop = useCallback((event: React.DragEvent) => {
|
||
event.preventDefault();
|
||
|
||
const dragData = event.dataTransfer.getData('application/reactflow');
|
||
if (!dragData) return;
|
||
|
||
try {
|
||
const { nodeType, nodeDefinition }: { nodeType: string; nodeDefinition: WorkflowNodeDefinition } = JSON.parse(dragData);
|
||
|
||
// 根据React Flow官方文档,screenToFlowPosition会自动处理所有边界计算
|
||
// 不需要手动减去容器边界!
|
||
const position = screenToFlowPosition({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
});
|
||
|
||
const newNode: FlowNode = {
|
||
id: generateNodeId(), // ✅ 生成格式: sid_xxxxxxxx_xxxx_xxxx_xxxx_xxxxxxxxxxxx
|
||
type: nodeType,
|
||
position,
|
||
data: {
|
||
label: nodeDefinition.nodeName,
|
||
nodeType: nodeDefinition.nodeType,
|
||
category: nodeDefinition.category,
|
||
icon: nodeDefinition.renderConfig.icon.content,
|
||
color: nodeDefinition.renderConfig.theme.primary,
|
||
// 保存原始节点定义引用,用于配置
|
||
nodeDefinition,
|
||
// 初始化配置数据(nodeCode 使用预定义的常量值)
|
||
configs: {
|
||
nodeName: nodeDefinition.nodeName,
|
||
nodeCode: nodeDefinition.nodeCode, // 使用预定义的 CODE(如 "END_EVENT")
|
||
description: nodeDefinition.description
|
||
},
|
||
inputMapping: {},
|
||
outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : [] // ✅ 从节点定义中获取输出能力
|
||
}
|
||
};
|
||
|
||
setNodes(nodes => [...nodes, newNode]);
|
||
message.success(`已添加 ${nodeDefinition.nodeName} 节点`);
|
||
} catch (error) {
|
||
console.error('解析拖拽数据失败:', error);
|
||
message.error('添加节点失败');
|
||
}
|
||
}, [screenToFlowPosition, setNodes]);
|
||
|
||
// 处理节点双击 - 打开配置面板
|
||
const handleNodeClick = useCallback((event: React.MouseEvent, node: FlowNode) => {
|
||
// 只处理双击事件
|
||
if (event.detail === 2) {
|
||
console.log('双击节点,打开配置:', node);
|
||
setConfigNode(node);
|
||
setConfigModalVisible(true);
|
||
}
|
||
}, []);
|
||
|
||
// 处理边双击 - 打开条件配置弹窗
|
||
const handleEdgeClick = useCallback((event: React.MouseEvent, edge: FlowEdge) => {
|
||
if (event.detail === 2) {
|
||
console.log('双击边,打开配置:', edge);
|
||
|
||
// 检查源节点是否为并行网关
|
||
const sourceNode = getNodes().find(n => n.id === edge.source);
|
||
const gatewayType = (sourceNode?.data?.inputMapping as any)?.gatewayType
|
||
|| (sourceNode?.data?.configs as any)?.gatewayType;
|
||
const isParallelGateway = sourceNode?.data?.nodeType === 'GATEWAY_NODE'
|
||
&& gatewayType === 'parallelGateway';
|
||
|
||
// 并行网关的边无需配置,直接提示
|
||
if (isParallelGateway) {
|
||
message.info('并行网关的所有分支自动执行,无需配置条件');
|
||
return;
|
||
}
|
||
|
||
setConfigEdge(edge);
|
||
setEdgeConfigModalVisible(true);
|
||
}
|
||
}, [getNodes]);
|
||
|
||
// 处理节点配置更新
|
||
const handleNodeConfigUpdate = useCallback((nodeId: string, updatedData: Partial<FlowNodeData>) => {
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === nodeId
|
||
? {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
...updatedData,
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
markUnsaved(); // 标记有未保存更改
|
||
}, [setNodes, markUnsaved]);
|
||
|
||
// 关闭节点配置模态框
|
||
const handleCloseConfigModal = useCallback(() => {
|
||
setConfigModalVisible(false);
|
||
setConfigNode(null);
|
||
}, []);
|
||
|
||
// 处理边条件更新
|
||
const handleEdgeConditionUpdate = useCallback((edgeId: string, condition: EdgeCondition) => {
|
||
setEdges((edges) =>
|
||
edges.map((edge) => {
|
||
if (edge.id === edgeId) {
|
||
// 根据条件类型生成显示文本
|
||
const label = condition.type === 'EXPRESSION'
|
||
? condition.expression
|
||
: '默认路径';
|
||
|
||
return {
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
condition,
|
||
},
|
||
label,
|
||
};
|
||
}
|
||
return edge;
|
||
})
|
||
);
|
||
markUnsaved(); // 标记有未保存更改
|
||
message.success('边条件配置已更新');
|
||
setEdgeConfigModalVisible(false);
|
||
setConfigEdge(null);
|
||
}, [setEdges, markUnsaved]);
|
||
|
||
// 关闭边配置模态框
|
||
const handleCloseEdgeConfigModal = useCallback(() => {
|
||
setEdgeConfigModalVisible(false);
|
||
setConfigEdge(null);
|
||
}, []);
|
||
|
||
// 监听视图变化(缩放、平移等)
|
||
const handleViewportChange = useCallback(() => {
|
||
const zoom = getZoom();
|
||
setCurrentZoom(zoom);
|
||
}, [getZoom]);
|
||
|
||
// 监听节点和边的变化,记录到历史
|
||
useEffect(() => {
|
||
const nodes = getNodes() as FlowNode[];
|
||
const edges = getEdges() as FlowEdge[];
|
||
|
||
// 只在有节点或边时记录(避免空状态)
|
||
if (nodes.length > 0 || edges.length > 0) {
|
||
history.record(nodes, edges);
|
||
}
|
||
}, [getNodes, getEdges, history]);
|
||
|
||
// 键盘快捷键支持
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// 检查焦点是否在输入框、文本域或可编辑元素内
|
||
const target = e.target as HTMLElement;
|
||
const tagName = target.tagName?.toUpperCase();
|
||
const isInputElement = tagName === 'INPUT' ||
|
||
tagName === 'TEXTAREA' ||
|
||
target.isContentEditable ||
|
||
target.getAttribute('contenteditable') === 'true';
|
||
|
||
// 检查是否在 shadcn/ui Sheet、Dialog 或其他模态框内
|
||
const isInSheet = target.closest('[role="dialog"]') !== null;
|
||
const isInModal = target.closest('[role="alertdialog"]') !== null;
|
||
const isInPopover = target.closest('[role="menu"]') !== null ||
|
||
target.closest('[role="listbox"]') !== null ||
|
||
target.closest('[data-radix-popper-content-wrapper]') !== null;
|
||
|
||
// 在模态框或弹窗内,或者在输入元素中时,允许原生行为
|
||
const shouldSkipShortcut = isInputElement || isInSheet || isInModal || isInPopover;
|
||
|
||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||
|
||
// Ctrl+Z / Cmd+Z - 撤销(仅在画布区域)
|
||
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handleUndo();
|
||
}
|
||
}
|
||
// Ctrl+Shift+Z / Cmd+Shift+Z - 重做(仅在画布区域)
|
||
else if (ctrlKey && e.key === 'z' && e.shiftKey) {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handleRedo();
|
||
}
|
||
}
|
||
// Ctrl+C / Cmd+C - 复制节点(仅在画布区域)
|
||
else if (ctrlKey && e.key === 'c') {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handleCopy();
|
||
}
|
||
}
|
||
// Ctrl+V / Cmd+V - 粘贴节点(仅在画布区域)
|
||
else if (ctrlKey && e.key === 'v') {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handlePaste();
|
||
}
|
||
}
|
||
// Ctrl+A / Cmd+A - 全选节点(仅在画布区域)
|
||
else if (ctrlKey && e.key === 'a') {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handleSelectAll();
|
||
}
|
||
}
|
||
// Delete / Backspace - 删除节点(仅在画布区域)
|
||
else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
if (!shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
handleDelete();
|
||
}
|
||
}
|
||
// ? - 显示快捷键面板
|
||
else if (e.key === '?' && !shouldSkipShortcut) {
|
||
e.preventDefault();
|
||
setShortcutsPanelOpen(true);
|
||
}
|
||
// Ctrl+S / Cmd+S - 保存
|
||
else if (ctrlKey && e.key === 's') {
|
||
e.preventDefault();
|
||
handleSave(false);
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete, handleSave]);
|
||
|
||
return (
|
||
<div
|
||
className="workflow-design-container"
|
||
style={{
|
||
// 确保覆盖父容器的overflow设置
|
||
overflow: 'hidden'
|
||
}}
|
||
>
|
||
{/* 加载状态 */}
|
||
{loading && (
|
||
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||
<p className="text-sm text-muted-foreground">加载工作流中...</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 工具栏 */}
|
||
<WorkflowToolbar
|
||
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
|
||
onSave={() => handleSave(false)}
|
||
onBack={handleBack}
|
||
onPreviewForm={handlePreviewForm}
|
||
hasFormDefinition={!!formDefinition}
|
||
onUndo={handleUndo}
|
||
onRedo={handleRedo}
|
||
onZoomIn={handleZoomIn}
|
||
onZoomOut={handleZoomOut}
|
||
onFitView={handleFitView}
|
||
canUndo={history.canUndo}
|
||
canRedo={history.canRedo}
|
||
zoom={currentZoom}
|
||
/>
|
||
|
||
{/* 主要内容区域 */}
|
||
<div className="workflow-content-area">
|
||
{/* 节点面板 */}
|
||
<NodePanel />
|
||
|
||
{/* 画布区域 */}
|
||
<div
|
||
ref={reactFlowWrapper}
|
||
className="workflow-canvas-area"
|
||
>
|
||
<FlowCanvas
|
||
initialNodes={initialNodes}
|
||
initialEdges={initialEdges}
|
||
onNodeClick={handleNodeClick}
|
||
onEdgeClick={handleEdgeClick}
|
||
onDrop={handleDrop}
|
||
onViewportChange={handleViewportChange}
|
||
className="workflow-canvas"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 节点配置弹窗 */}
|
||
<NodeConfigModal
|
||
visible={configModalVisible}
|
||
node={configNode}
|
||
allNodes={getNodes() as FlowNode[]}
|
||
allEdges={getEdges() as FlowEdge[]}
|
||
formFields={formFields}
|
||
onCancel={handleCloseConfigModal}
|
||
onOk={handleNodeConfigUpdate}
|
||
/>
|
||
|
||
{/* 边条件配置弹窗 */}
|
||
<EdgeConfigModal
|
||
visible={edgeConfigModalVisible}
|
||
edge={configEdge}
|
||
allNodes={getNodes() as FlowNode[]}
|
||
allEdges={getEdges() as FlowEdge[]}
|
||
formFields={formFields}
|
||
onOk={handleEdgeConditionUpdate}
|
||
onCancel={handleCloseEdgeConfigModal}
|
||
/>
|
||
|
||
{/* 表单预览弹窗 */}
|
||
<FormPreviewModal
|
||
open={formPreviewVisible}
|
||
onClose={() => setFormPreviewVisible(false)}
|
||
formDefinition={formDefinition}
|
||
/>
|
||
|
||
{/* 快捷键面板 */}
|
||
<KeyboardShortcutsPanel
|
||
open={shortcutsPanelOpen}
|
||
onClose={() => setShortcutsPanelOpen(false)}
|
||
/>
|
||
|
||
{/* 最后保存时间提示 */}
|
||
{lastSaveTime && (
|
||
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600 z-10">
|
||
最后保存: {lastSaveTime.toLocaleTimeString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const WorkflowDesign: React.FC = () => {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<WorkflowDesignInner />
|
||
</ReactFlowProvider>
|
||
);
|
||
};
|
||
|
||
export default WorkflowDesign;
|