deploy-ease-platform/frontend/src/pages/Workflow2/Design/index.tsx
dengqichen 33c70b7894 1
2025-10-21 12:16:37 +08:00

618 lines
21 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.

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;