618 lines
21 KiB
TypeScript
618 lines
21 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { message } from 'antd';
|
||
import { ReactFlowProvider, useReactFlow } from '@xyflow/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 type { FlowNode, FlowEdge, FlowNodeData } from './types';
|
||
import type { WorkflowNodeDefinition } from './nodes/types';
|
||
import { NodeType } from './types';
|
||
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
||
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
||
import { useHistory } from './hooks/useHistory';
|
||
|
||
// 样式
|
||
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 reactFlowWrapper = useRef<HTMLDivElement>(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);
|
||
|
||
// 保存和加载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) {
|
||
const data = await loadWorkflow(currentWorkflowId);
|
||
if (data) {
|
||
setNodes(data.nodes);
|
||
setEdges(data.edges);
|
||
setWorkflowTitle(data.definition.name);
|
||
}
|
||
}
|
||
};
|
||
|
||
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[] = [
|
||
{
|
||
id: '1',
|
||
type: 'START_EVENT',
|
||
position: { x: 250, y: 50 },
|
||
data: {
|
||
label: '开始',
|
||
nodeType: NodeType.START_EVENT,
|
||
category: 'EVENT' as any,
|
||
icon: '▶️',
|
||
color: '#10b981'
|
||
}
|
||
},
|
||
{
|
||
id: '2',
|
||
type: 'USER_TASK',
|
||
position: { x: 200, y: 150 },
|
||
data: {
|
||
label: '用户审批',
|
||
nodeType: NodeType.USER_TASK,
|
||
category: 'TASK' as any,
|
||
icon: '👤',
|
||
color: '#6366f1'
|
||
}
|
||
},
|
||
{
|
||
id: '3',
|
||
type: 'END_EVENT',
|
||
position: { x: 250, y: 250 },
|
||
data: {
|
||
label: '结束',
|
||
nodeType: NodeType.END_EVENT,
|
||
category: 'EVENT' as any,
|
||
icon: '⏹️',
|
||
color: '#ef4444'
|
||
}
|
||
}
|
||
];
|
||
|
||
const initialEdges: FlowEdge[] = [
|
||
{
|
||
id: 'e1-2',
|
||
source: '1',
|
||
target: '2',
|
||
type: 'default',
|
||
animated: true,
|
||
data: {
|
||
label: '提交',
|
||
condition: {
|
||
type: 'DEFAULT',
|
||
priority: 0
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'e2-3',
|
||
source: '2',
|
||
target: '3',
|
||
type: 'default',
|
||
animated: true,
|
||
data: {
|
||
label: '通过',
|
||
condition: {
|
||
type: 'DEFAULT',
|
||
priority: 0
|
||
}
|
||
}
|
||
}
|
||
];
|
||
|
||
// 工具栏事件处理
|
||
const handleSave = useCallback(async () => {
|
||
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) {
|
||
console.log('保存工作流成功:', { nodes, edges });
|
||
}
|
||
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
|
||
|
||
const handleBack = useCallback(() => {
|
||
navigate('/workflow2/definition');
|
||
}, [navigate]);
|
||
|
||
// 撤销操作
|
||
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 = `${node.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
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: `e${newSource}-${newTarget}`,
|
||
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: `${nodeType}-${Date.now()}`,
|
||
type: nodeType,
|
||
position,
|
||
data: {
|
||
label: nodeDefinition.nodeName,
|
||
nodeType: nodeDefinition.nodeType,
|
||
category: nodeDefinition.category,
|
||
icon: nodeDefinition.uiConfig.style.icon,
|
||
color: nodeDefinition.uiConfig.style.fill,
|
||
// 保存原始节点定义引用,用于配置
|
||
nodeDefinition,
|
||
// 初始化配置数据
|
||
configs: {
|
||
nodeName: nodeDefinition.nodeName,
|
||
nodeCode: nodeDefinition.nodeCode,
|
||
description: nodeDefinition.description
|
||
},
|
||
inputMapping: {},
|
||
outputMapping: {}
|
||
}
|
||
};
|
||
|
||
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);
|
||
setConfigEdge(edge);
|
||
setEdgeConfigModalVisible(true);
|
||
}
|
||
}, []);
|
||
|
||
// 处理节点配置更新
|
||
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';
|
||
const isInDrawer = target.closest('.ant-drawer-body') !== null;
|
||
const isInModal = target.closest('.ant-modal') !== null;
|
||
|
||
// 在抽屉或模态框内,且在输入元素中时,允许原生行为
|
||
const shouldSkipShortcut = isInputElement || isInDrawer || isInModal;
|
||
|
||
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();
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]);
|
||
|
||
return (
|
||
<div
|
||
className="workflow-design-container"
|
||
style={{
|
||
// 确保覆盖父容器的overflow设置
|
||
overflow: 'hidden'
|
||
}}
|
||
>
|
||
{/* 工具栏 */}
|
||
<WorkflowToolbar
|
||
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
|
||
onSave={handleSave}
|
||
onBack={handleBack}
|
||
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}
|
||
onCancel={handleCloseConfigModal}
|
||
onOk={handleNodeConfigUpdate}
|
||
/>
|
||
|
||
{/* 边条件配置弹窗 */}
|
||
<EdgeConfigModal
|
||
visible={edgeConfigModalVisible}
|
||
edge={configEdge}
|
||
onOk={handleEdgeConditionUpdate}
|
||
onCancel={handleCloseEdgeConfigModal}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const WorkflowDesign: React.FC = () => {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<WorkflowDesignInner />
|
||
</ReactFlowProvider>
|
||
);
|
||
};
|
||
|
||
export default WorkflowDesign;
|