import React, {useEffect, useRef, useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; import {Button, Card, Layout, message, Space, Spin, Drawer, Form, Dropdown} from 'antd'; import {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons'; import {getDefinition, updateDefinition} from '../../service'; import {WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types'; import {Graph, Node, Cell, Edge, Shape} from '@antv/x6'; import '@antv/x6-react-shape'; import {Selection} from '@antv/x6-plugin-selection'; import {History} from '@antv/x6-plugin-history'; import {Clipboard} from '@antv/x6-plugin-clipboard'; import {Transform} from '@antv/x6-plugin-transform'; import {Keyboard} from '@antv/x6-plugin-keyboard'; import {Snapline} from '@antv/x6-plugin-snapline'; import {MiniMap} from '@antv/x6-plugin-minimap'; import './index.module.less'; import NodePanel from './components/NodePanel'; import NodeConfig from './components/NodeConfig'; import Toolbar from './components/Toolbar'; import {NodeType, getNodeTypes} from './service'; import {DeleteOutlined, CopyOutlined, SettingOutlined, ClearOutlined, FullscreenOutlined} from '@ant-design/icons'; import EdgeConfig from './components/EdgeConfig'; import { validateFlow, hasCycle } from './validate'; import { generateNodeStyle, generatePorts, calculateNodePosition, calculateCanvasPosition } from './utils/nodeUtils'; import { Position } from './types'; import { NODE_CONFIG } from './configs/nodeConfig'; import { isWorkflowError } from './utils/errors'; const {Sider, Content} = Layout; interface NodeData { type: string; name?: string; description?: string; config: { executor?: string; retryTimes?: number; retryInterval?: number; script?: string; timeout?: number; workingDirectory?: string; environment?: string; successExitCode?: string; [key: string]: any; }; } const FlowDesigner: React.FC = () => { const navigate = useNavigate(); const {id} = useParams<{ id: string }>(); const [loading, setLoading] = useState(false); const [detail, setDetail] = useState(); const containerRef = useRef(null); const graphRef = useRef(); const [graph, setGraph] = useState(); const draggedNodeRef = useRef(); const [configVisible, setConfigVisible] = useState(false); const [currentNode, setCurrentNode] = useState(); const [currentNodeType, setCurrentNodeType] = useState(); const [nodeTypes, setNodeTypes] = useState([]); const [form] = Form.useForm(); const [currentEdge, setCurrentEdge] = useState(); const [edgeConfigVisible, setEdgeConfigVisible] = useState(false); const [edgeForm] = Form.useForm(); // 右键菜单状态 const [contextMenu, setContextMenu] = useState<{ x: number; y: number; visible: boolean; type: 'node' | 'edge' | 'canvas'; cell?: Cell; }>({ x: 0, y: 0, visible: false, type: 'canvas', }); // 右键菜单项 const menuItems = { node: [ { key: 'delete', label: '删除节点', icon: , onClick: () => { if (contextMenu.cell) { contextMenu.cell.remove(); } setContextMenu(prev => ({ ...prev, visible: false })); }, }, { key: 'copy', label: '复制节点', icon: , onClick: () => { if (contextMenu.cell && contextMenu.cell.isNode()) { const pos = contextMenu.cell.position(); const newCell = contextMenu.cell.clone(); newCell.position(pos.x + 20, pos.y + 20); graph?.addCell(newCell); } setContextMenu(prev => ({ ...prev, visible: false })); }, }, { key: 'config', label: '配置节点', icon: , onClick: () => { if (contextMenu.cell && contextMenu.cell.isNode()) { setCurrentNode(contextMenu.cell); const data = contextMenu.cell.getData() as NodeData; const nodeType = nodeTypes.find(type => type.code === data.type); if (nodeType) { setCurrentNodeType(nodeType); const formValues = { name: data.name || nodeType.name, description: data.description, ...data.config }; form.setFieldsValue(formValues); } setConfigVisible(true); } setContextMenu(prev => ({ ...prev, visible: false })); }, }, ], edge: [ { key: 'config', label: '配置连线', icon: , onClick: () => { if (contextMenu.cell && contextMenu.cell.isEdge()) { handleEdgeConfig(contextMenu.cell); } setContextMenu(prev => ({ ...prev, visible: false })); }, }, { key: 'delete', label: '删除连线', icon: , onClick: () => { if (contextMenu.cell) { contextMenu.cell.remove(); } setContextMenu(prev => ({ ...prev, visible: false })); }, }, ], canvas: [ { key: 'clear', label: '清空画布', icon: , onClick: () => { graph?.clearCells(); setContextMenu(prev => ({ ...prev, visible: false })); }, }, { key: 'fit', label: '适应画布', icon: , onClick: () => { graph?.zoomToFit({ padding: 20 }); setContextMenu(prev => ({ ...prev, visible: false })); }, }, ], }; // 获取所有节点类型 const fetchNodeTypes = async () => { try { const types = await getNodeTypes({enabled: true}); setNodeTypes(types); return types; } catch (error) { console.error('获取节点类型失败:', error); message.error('获取节点类型失败'); return []; } }; // 首次加载获取节点类型 useEffect(() => { fetchNodeTypes(); }, []); // 初始化图形 const initGraph = () => { if (!containerRef.current) return; const graph = new Graph({ container: containerRef.current, grid: { visible: true, type: 'mesh', size: 10, args: { color: '#e5e5e5', thickness: 1, }, }, mousewheel: { enabled: true, modifiers: ['ctrl', 'meta'], minScale: 0.5, maxScale: 2, }, connecting: { snap: true, allowBlank: false, allowLoop: false, allowNode: false, allowEdge: false, connector: { name: 'rounded', args: { radius: 8, }, }, router: { name: 'manhattan', args: { padding: 1, }, }, validateConnection({sourceCell, targetCell, sourceMagnet, targetMagnet}) { if (sourceCell === targetCell) { return false; } if (!sourceMagnet || !targetMagnet) { return false; } return true; }, }, defaultEdge: { attrs: { line: { stroke: '#5F95FF', strokeWidth: 1, targetMarker: { name: 'classic', size: 8, }, }, }, router: { name: 'manhattan', args: { padding: 1, }, }, connector: { name: 'rounded', args: { radius: 8, }, }, labels: [ { attrs: { label: { text: '', fill: '#333', fontSize: 12, }, rect: { fill: '#fff', stroke: '#5F95FF', strokeWidth: 1, rx: 3, ry: 3, refWidth: 1, refHeight: 1, refX: 0, refY: 0, }, }, position: { distance: 0.5, offset: { x: 0, y: -10, }, }, }, ], }, highlighting: { magnetAvailable: { name: 'stroke', args: { padding: 4, attrs: { strokeWidth: 4, stroke: '#52c41a', }, }, }, magnetAdsorbed: { name: 'stroke', args: { padding: 4, attrs: { strokeWidth: 4, stroke: '#1890ff', }, }, }, }, keyboard: { enabled: true, }, clipboard: { enabled: true, }, history: { enabled: true, }, snapline: { enabled: true, }, translating: { restrict: true, }, background: { color: '#ffffff', // 画布背景色 }, }); // 启用必要的功能 graph.use( new Selection({ enabled: true, multiple: true, rubberband: true, rubberEdge: true, movable: true, showNodeSelectionBox: true, showEdgeSelectionBox: true, selectCellOnMoved: false, selectEdgeOnMoved: false, selectNodeOnMoved: false, className: 'node-selected', strict: true, }) ); graph.use( new History({ enabled: true, beforeAddCommand: (event: string, args: any) => { if (event === 'cell:change:*') { return true; } return true; }, }) ); graph.use( new Clipboard({ enabled: true, }) ); graph.use( new Transform({ resizing: { enabled: true, minWidth: 1, minHeight: 1, orthogonal: true, restricted: true, }, rotating: { enabled: true, grid: 15, }, }) ); graph.use( new Keyboard({ enabled: true, }) ); graph.use( new Snapline({ enabled: true, }) ); // 启用小地图 graph.use( new MiniMap({ container: document.getElementById('workflow-minimap'), width: 200, height: 150, padding: 10, scalable: false, minScale: 0.5, maxScale: 2, graphOptions: { async: true, // 简化节点渲染 grid: false, background: { color: '#f5f5f5', }, }, }) ); // 绑定右键菜单事件 graph.on('cell:contextmenu', ({ cell, e }) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, visible: true, type: cell.isNode() ? 'node' : 'edge', cell, }); }); graph.on('blank:contextmenu', ({ e }) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, visible: true, type: 'canvas', }); }); // 点击画布时隐藏右键菜单 graph.on('blank:click', () => { setContextMenu(prev => ({ ...prev, visible: false })); }); // 点击节点时隐藏右键菜单 graph.on('cell:click', () => { setContextMenu(prev => ({ ...prev, visible: false })); }); graphRef.current = graph; setGraph(graph); // 加载流程图数据 if (detail) { loadGraphData(graph, detail); } // 监听画布拖拽事件 containerRef.current.addEventListener('dragover', handleDragOver); containerRef.current.addEventListener('drop', handleDrop); // 监听节点点击事件 graph.on('node:click', ({node}: { node: Node }) => { setCurrentNode(node); const data = node.getData() as NodeData; console.log('Node clicked, data:', data); if (data) { // 获取节点类型 const nodeType = nodeTypes.find(type => type.code === data.type); if (nodeType) { setCurrentNodeType(nodeType); // 合并节点基本配置和执器配置 const formValues = { name: data.name || nodeType.name, description: data.description, ...data.config // 直接展开所有配置 }; console.log('Setting form values:', formValues); form.setFieldsValue(formValues); setConfigVisible(true); } else { message.error('未找到对应的节点类型'); } } }); // 监听选择状态变化 graph.on('selection:changed', () => { // 强制更新工具栏状态 setGraph(graph); }); }; // 处理拖拽移动 const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'copy'; }; // 处理拖拽放置 const handleDrop = (e: DragEvent) => { e.preventDefault(); const nodeType = draggedNodeRef.current; if (!nodeType || !graphRef.current || !containerRef.current) { message.error('无效的节点类型或画布未初始化'); return; } try { const rect = containerRef.current.getBoundingClientRect(); const matrix = graphRef.current.matrix(); // 使用新的工具函数计算画布位置 const dropPosition = calculateCanvasPosition( e.clientX, e.clientY, rect, { scale: matrix.a, offsetX: matrix.e, offsetY: matrix.f, } ); // 获取节点配置 const position = calculateNodePosition(nodeType.code, dropPosition); const nodeStyle = generateNodeStyle(nodeType.code); const ports = generatePorts(nodeType.code); const config = NODE_CONFIG[nodeType.code]; if (!config) { throw new Error(`未找到节点类型 ${nodeType.code} 的配置`); } // 创建节点 const node = graphRef.current.addNode({ ...position, ...nodeStyle, ports, data: { type: nodeType.code, name: config.label, // 使用配置中的中文名称 config: {} as any, }, }); // 选中新创建的节点并打开配置 graphRef.current.cleanSelection(); graphRef.current.select(node); setCurrentNode(node); setCurrentNodeType(nodeType); form.setFieldsValue({ name: config.label }); // 使用配置中的中文名称 setConfigVisible(true); message.success('节点创建成功'); } catch (error) { console.error('Error creating node:', error); if (isWorkflowError(error)) { message.error(`创建节点失败:${error.message}`); } else { message.error('创建节点失败:未知错误'); } // 清理状态 setCurrentNode(undefined); setCurrentNodeType(undefined); setConfigVisible(false); } }; // 验证Shell节点配置 function validateShellConfig(config: any): boolean { if (!config.script?.trim()) { throw new Error('脚本内容不能为空'); } if (config.timeout !== undefined && config.timeout <= 0) { throw new Error('超时时间必须大于0'); } if (config.retryTimes !== undefined && config.retryTimes < 0) { throw new Error('重试次数不能为负数'); } if (config.retryInterval !== undefined && config.retryInterval < 0) { throw new Error('重试间不能为负数'); } return true; } // 处理配置保存 const handleConfigSave = async () => { try { const values = await form.validateFields(); if (currentNode) { const data = currentNode.getData() as NodeData; const {name, description, ...config} = values; // 如果是Shell节点,验证配置 if (data.type === 'SHELL' && config.executor === 'SHELL') { validateShellConfig(config); } // 更新节点数据 currentNode.setData({ ...data, name, description, config, }); // 更新节点标签 currentNode.setAttrByPath('label/text', name); message.success('配置保存成功'); setConfigVisible(false); } } catch (error) { // 表单验证失败或Shell配置验证失败 message.error((error as Error).message); } }; // 获取详情 const fetchDetail = async () => { if (!id) return; setLoading(true); try { const response = await getDefinition(parseInt(id)); if (response) { setDetail(response); } } finally { setLoading(false); } }; // 处理保存 const handleSave = async () => { if (!id || !detail || !graphRef.current || detail.status !== WorkflowStatus.DRAFT) return; try { // 验证流程 const result = validateFlow(graphRef.current); const hasCycleResult = hasCycle(graphRef.current); if (hasCycleResult) { message.error('流程图中存在循环,请检查'); return; } if (!result.valid) { message.error(result.errors.join('\n')); return; } const graphData = graphRef.current.toJSON(); // 收集节点配置数据 const nodes = graphRef.current.getNodes().map(node => { const data = node.getData() as NodeData; return { id: node.id, type: data.type, name: data.name || '', description: data.description, config: data.config || {} }; }); // 收集连线配置数据 const transitions = graphRef.current.getEdges().map(edge => { const data = edge.getData() || {}; return { from: edge.getSourceCellId(), to: edge.getTargetCellId(), condition: data.condition || '', description: data.description || '', priority: data.priority || 0 }; }); // 构建更新数据 const data = { ...detail, graphDefinition: JSON.stringify(graphData), nodeConfig: JSON.stringify({nodes}), transitionConfig: JSON.stringify({transitions}) }; await updateDefinition(parseInt(id), data); message.success('保存成功'); } catch (error) { message.error('保存失败'); console.error('保存失败:', error); } }; // 处理返回 const handleBack = () => { navigate('/workflow/definition'); }; // 首次加载 useEffect(() => { fetchDetail(); }, [id]); // 初始化图形 useEffect(() => { if (detail && containerRef.current) { initGraph(); } // 清理事件监听 return () => { if (containerRef.current) { containerRef.current.removeEventListener('dragover', handleDragOver); containerRef.current.removeEventListener('drop', handleDrop); } if (graphRef.current) { graphRef.current.dispose(); } }; }, [detail, containerRef.current]); useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver(() => { if (graphRef.current) { const container = containerRef.current; if (container) { const { width, height } = container.getBoundingClientRect(); graphRef.current.resize(width, height); } } }); resizeObserver.observe(containerRef.current); return () => { resizeObserver.disconnect(); }; }, []); // 处理节点拖拽开始 const handleNodeDragStart = (nodeType: NodeType) => { draggedNodeRef.current = nodeType; }; // 加载流程图数据 const loadGraphData = (graph: Graph, detail: WorkflowDefinition) => { try { // 加载图数据 const graphData = JSON.parse(detail.graphDefinition); graph.fromJSON(graphData); // 加载节点配置数据 if (detail.nodeConfig) { const nodeConfigData = JSON.parse(detail.nodeConfig); // 更新节点数据和显示 graph.getNodes().forEach(node => { const nodeConfig = nodeConfigData.nodes.find((n: any) => n.id === node.id); if (nodeConfig) { console.log('Node config found:', nodeConfig.config); const nodeData = { type: nodeConfig.type, name: nodeConfig.name, description: nodeConfig.description, config: nodeConfig.config }; console.log('Setting node data:', nodeData); node.setData(nodeData); node.setAttrByPath('label/text', nodeConfig.name); } }); } } catch (error) { message.error('加载流程图数据失败'); console.error('加载流程图数据失败:', error); } }; // 添加连线配置处理函数 const handleEdgeConfig = (edge: Edge) => { setCurrentEdge(edge); const data = edge.getData() || {}; edgeForm.setFieldsValue(data); setEdgeConfigVisible(true); }; // 添加连线配置保存函数 const handleEdgeConfigSubmit = async () => { if (!currentEdge) return; try { const values = await edgeForm.validateFields(); currentEdge.setData(values); // 更新连线标签 if (values.description) { currentEdge.setLabelAt(0, values.description); } setEdgeConfigVisible(false); } catch (error) { // 表单验证失败 } }; if (loading) { return (
); } return ( } className="workflow-designer" >
{ domEvent.stopPropagation(); }, }} open={contextMenu.visible} trigger={['contextMenu']} >
setConfigVisible(false)} extra={ } > {currentNodeType && ( )} setEdgeConfigVisible(false)} open={edgeConfigVisible} extra={ } > {currentEdge && ( { if (changedValues.description) { currentEdge.setLabelAt(0, changedValues.description); } }} /> )} ); }; export default FlowDesigner;