deploy-ease-platform/frontend/src/pages/Workflow/Definition/Designer/index.tsx
2024-12-06 21:45:55 +08:00

922 lines
30 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, {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<WorkflowDefinition>();
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph>();
const [graph, setGraph] = useState<Graph>();
const draggedNodeRef = useRef<NodeType>();
const [configVisible, setConfigVisible] = useState(false);
const [currentNode, setCurrentNode] = useState<Node>();
const [currentNodeType, setCurrentNodeType] = useState<NodeType>();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [form] = Form.useForm();
const [currentEdge, setCurrentEdge] = useState<Edge>();
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: <DeleteOutlined />,
onClick: () => {
if (contextMenu.cell) {
contextMenu.cell.remove();
}
setContextMenu(prev => ({ ...prev, visible: false }));
},
},
{
key: 'copy',
label: '复制节点',
icon: <CopyOutlined />,
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: <SettingOutlined />,
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: <SettingOutlined />,
onClick: () => {
if (contextMenu.cell && contextMenu.cell.isEdge()) {
handleEdgeConfig(contextMenu.cell);
}
setContextMenu(prev => ({ ...prev, visible: false }));
},
},
{
key: 'delete',
label: '删除连线',
icon: <DeleteOutlined />,
onClick: () => {
if (contextMenu.cell) {
contextMenu.cell.remove();
}
setContextMenu(prev => ({ ...prev, visible: false }));
},
},
],
canvas: [
{
key: 'clear',
label: '清空画布',
icon: <ClearOutlined />,
onClick: () => {
graph?.clearCells();
setContextMenu(prev => ({ ...prev, visible: false }));
},
},
{
key: 'fit',
label: '适应画布',
icon: <FullscreenOutlined />,
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 (
<div style={{textAlign: 'center', padding: 100}}>
<Spin size="large"/>
</div>
);
}
return (
<Card
title={
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<Space>
<Button type="primary" icon={<SaveOutlined/>} onClick={handleSave}></Button>
<Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)}></Button>
</Space>
</div>
}
className="workflow-designer"
>
<Layout>
<Sider width={250} className="workflow-designer-sider">
<NodePanel onNodeDragStart={handleNodeDragStart}/>
</Sider>
<Layout>
<Toolbar graph={graph}/>
<Content className="workflow-designer-content">
<div ref={containerRef} className="workflow-designer-graph"/>
<div id="workflow-minimap" className="workflow-designer-minimap"/>
<Dropdown
menu={{
items: menuItems[contextMenu.type],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
},
}}
open={contextMenu.visible}
trigger={['contextMenu']}
>
<div
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
width: 1,
height: 1,
}}
/>
</Dropdown>
</Content>
</Layout>
</Layout>
<Drawer
title="节点配置"
width={400}
open={configVisible}
onClose={() => setConfigVisible(false)}
extra={
<Space>
<Button onClick={() => setConfigVisible(false)}></Button>
<Button type="primary" onClick={handleConfigSave}>
</Button>
</Space>
}
>
{currentNodeType && (
<NodeConfig nodeType={currentNodeType} form={form}/>
)}
</Drawer>
<Drawer
title="连线配置"
placement="right"
width={400}
onClose={() => setEdgeConfigVisible(false)}
open={edgeConfigVisible}
extra={
<Space>
<Button onClick={() => setEdgeConfigVisible(false)}></Button>
<Button type="primary" onClick={handleEdgeConfigSubmit}>
</Button>
</Space>
}
>
{currentEdge && (
<EdgeConfig
edge={currentEdge}
form={edgeForm}
onValuesChange={(changedValues, allValues) => {
if (changedValues.description) {
currentEdge.setLabelAt(0, changedValues.description);
}
}}
/>
)}
</Drawer>
</Card>
);
};
export default FlowDesigner;