feat(web): Add Workflow

This commit is contained in:
zhaoying
2025-12-22 10:46:19 +08:00
parent e1bccff79b
commit 281aec23e3
65 changed files with 2843 additions and 31 deletions

View File

@@ -0,0 +1,741 @@
import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { App } from 'antd'
import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6';
import { register } from '@antv/x6-react-shape';
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary } from '../constant';
import type { WorkflowConfig, NodeProperties } from '../types';
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
export interface UseWorkflowGraphProps {
containerRef: React.RefObject<HTMLDivElement>;
miniMapRef: React.RefObject<HTMLDivElement>;
}
export interface UseWorkflowGraphReturn {
config: WorkflowConfig | null;
graphRef: React.MutableRefObject<Graph | undefined>;
selectedNode: Node | null;
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
zoomLevel: number;
setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
canUndo: boolean;
canRedo: boolean;
isHandMode: boolean;
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
onUndo: () => void;
onRedo: () => void;
onDrop: (event: React.DragEvent) => void;
blankClick: () => void;
deleteEvent: () => boolean | void;
copyEvent: () => boolean | void;
parseEvent: () => boolean | void;
handleSave: (flag?: boolean) => Promise<any>;
}
const edge_color = '#155EEF';
const edge_selected_color = '#4DA8FF'
export const useWorkflowGraph = ({
containerRef,
miniMapRef,
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
const { id } = useParams();
const { message } = App.useApp();
const { t } = useTranslation()
const graphRef = useRef<Graph>();
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const historyRef = useRef<{ undoStack: string[], redoStack: string[] }>({ undoStack: [], redoStack: [] });
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isHandMode, setIsHandMode] = useState(false);
const [config, setConfig] = useState<WorkflowConfig | null>(null);
useEffect(() => {
getConfig()
}, [id])
const getConfig = () => {
if (!id) return
getWorkflowConfig(id)
.then(res => {
setConfig(res as WorkflowConfig)
})
}
useEffect(() => {
initWorkflow()
}, [config, graphRef.current])
const initWorkflow = () => {
if (!config || !graphRef.current) return
const { nodes, edges } = config
if (nodes.length) {
const nodeList = nodes.map(node => {
const { id, type, name, position, config } = node
let nodeLibraryConfig = [...nodeLibrary]
.flatMap(category => category.nodes)
.find(n => n.type === type)
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
if (nodeLibraryConfig?.config) {
Object.keys(nodeLibraryConfig.config).forEach(key => {
if (nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
nodeLibraryConfig.config[key].defaultValue = config[key]
}
})
}
const nodeConfig = {
...(graphNodeLibrary[type] ?? graphNodeLibrary.default),
id,
type,
name,
data: { ...node, ...nodeLibraryConfig},
...position,
}
return nodeConfig
})
graphRef.current?.addNodes(nodeList)
}
if (edges.length) {
const edgeList = edges.map(edge => {
const { source, target } = edge
const sourceCell = graphRef.current?.getCellById(source)
const targetCell = graphRef.current?.getCellById(target)
if (sourceCell && targetCell) {
const sourcePorts = (sourceCell as Node).getPorts()
const targetPorts = (targetCell as Node).getPorts()
const edgeConfig = {
source: {
cell: sourceCell.id,
port: sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'
},
target: {
cell: targetCell.id,
port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left'
},
// label,
attrs: {
line: {
stroke: edge_color,
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
}
return edgeConfig
}
return null
})
graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
}
}
const saveState = () => {
if (!graphRef.current) return;
const state = JSON.stringify(graphRef.current.toJSON());
historyRef.current.undoStack.push(state);
historyRef.current.redoStack = [];
if (historyRef.current.undoStack.length > 50) {
historyRef.current.undoStack.shift();
}
updateHistoryState();
};
const updateHistoryState = () => {
setCanUndo(historyRef.current.undoStack.length > 1);
setCanRedo(historyRef.current.redoStack.length > 0);
};
// 撤销
const onUndo = () => {
if (!graphRef.current || historyRef.current.undoStack.length === 0) return;
const { undoStack = [], redoStack = [] } = historyRef.current
const currentState = JSON.stringify(graphRef.current.toJSON());
const prevState = undoStack[undoStack.length - 2];
historyRef.current.redoStack = [...redoStack, currentState]
historyRef.current.undoStack = undoStack.slice(0, undoStack.length - 1)
graphRef.current.fromJSON(JSON.parse(prevState));
updateHistoryState();
};
// 重做
const onRedo = () => {
if (!graphRef.current || historyRef.current.redoStack.length === 0) return;
const { undoStack = [], redoStack = [] } = historyRef.current
const nextState = redoStack[redoStack.length - 1];
historyRef.current.undoStack = [...undoStack, nextState]
historyRef.current.redoStack = redoStack.slice(0, redoStack.length - 1)
graphRef.current.fromJSON(JSON.parse(nextState));
updateHistoryState();
};
// 使用插件
const setupPlugins = () => {
if (!graphRef.current || !miniMapRef.current) return;
// 添加小地图
graphRef.current.use(
new MiniMap({
container: miniMapRef.current,
width: 100,
height: 80,
padding: 5,
}),
);
graphRef.current.use(
new Snapline({
enabled: true,
}),
);
graphRef.current.use(
new Clipboard({
enabled: true,
useLocalStorage: true,
}),
);
graphRef.current.use(
new Keyboard({
enabled: true,
global: true,
}),
);
};
// 显示/隐藏连接桩
const showPorts = (show: boolean) => {
const container = containerRef.current!;
const ports = container.querySelectorAll('.x6-port-body') as NodeListOf<SVGElement>;
for (let i = 0, len = ports.length; i < len; i += 1) {
ports[i].style.visibility = show ? 'visible' : 'hidden';
}
};
// 节点选择事件
const nodeClick = ({ node }: { node: Node }) => {
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
const data = vo.getData();
if (data.isSelected) {
vo.setData({
...data,
isSelected: false,
});
}
});
node.setData({
...node.getData(),
isSelected: true,
});
setSelectedNode(node);
};
// 连线选择事件
const edgeClick = ({ edge }: { edge: Edge }) => {
edge.setAttrByPath('line/stroke', edge_selected_color);
clearNodeSelect();
};
// 清空选中节点
const clearNodeSelect = () => {
const nodes = graphRef.current?.getNodes();
nodes?.forEach(node => {
const data = node.getData();
if (data.isSelected) {
node.setData({
...data,
isSelected: false,
});
}
});
setSelectedNode(null);
};
// 清空选中连线
const clearEdgeSelect = () => {
graphRef.current?.getEdges().forEach(e => {
e.setAttrByPath('line/stroke', edge_color);
e.setAttrByPath('line/strokeWidth', 1);
});
};
// 画布点击事件,取消选择
const blankClick = () => {
clearNodeSelect();
clearEdgeSelect();
graphRef.current?.cleanSelection();
};
// 画布缩放事件
const scaleEvent = ({ sx }: { sx: number }) => {
setZoomLevel(sx);
};
// 节点移动事件
const nodeMoved = ({ node }: { node: Node }) => {
const parentId = node.getData()?.parentId;
if (parentId) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === parentId);
if (parentNode?.getData()?.isGroup) {
// 获取父节点和子节点的边界框
const parentBBox = parentNode.getBBox();
const childBBox = node.getBBox();
// 计算父节点的内边距
const padding = 24;
const headerHeight = 50;
// 计算子节点允许的最小和最大位置
const minX = parentBBox.x + padding;
const minY = parentBBox.y + padding + headerHeight;
const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width;
const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height;
// 限制子节点在父节点内移动
let newX = childBBox.x;
let newY = childBBox.y;
if (newX < minX) newX = minX;
if (newY < minY) newY = minY;
if (newX > maxX) newX = maxX;
if (newY > maxY) newY = maxY;
// 如果子节点位置被限制,更新其位置
if (newX !== childBBox.x || newY !== childBBox.y) {
node.setPosition(newX, newY);
}
}
}
};
// 复制快捷键事件
const copyEvent = () => {
if (!graphRef.current) return false;
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
if (selectedNodes.length) {
graphRef.current.copy(selectedNodes);
}
return false;
};
// 粘贴快捷键事件
const parseEvent = () => {
if (!graphRef.current?.isClipboardEmpty()) {
graphRef.current?.paste({ offset: 32 });
blankClick();
}
return false;
};
// 撤销快捷键事件
const undoEvent = () => {
if (canUndo) {
onUndo();
}
return false;
};
// 重做快捷键事件
const redoEvent = () => {
if (canRedo) {
onRedo();
}
return false;
};
// 删除选中的节点和连线事件
const deleteEvent = () => {
if (!graphRef.current) return;
const nodes = graphRef.current?.getNodes();
const edges = graphRef.current?.getEdges();
const cells: (Node | Edge)[] = [];
const nodesToDelete: Node[] = [];
const parentNodesToUpdate: Node[] = [];
// 首先收集所有选中的节点,但排除默认子节点
nodes?.forEach(node => {
const data = node.getData();
// 如果节点是默认子节点,不允许单独删除
if (data.isSelected && !data.isDefault) {
nodesToDelete.push(node);
}
});
// 收集与选中节点相关的连线
edges?.forEach(edge => {
const attrs = edge.getAttrs()
if (attrs.line.stroke === edge_selected_color) {
cells.push(edge)
}
const sourceId = edge.getSourceCellId();
const targetId = edge.getTargetCellId();
if (sourceId && targetId) {
const sourceNode = nodes?.find(n => n.id === sourceId);
const targetNode = nodes?.find(n => n.id === targetId);
if (sourceNode?.getData()?.isSelected || targetNode?.getData()?.isSelected) {
cells.push(edge);
}
}
})
// 对于每个选中的节点
if (nodesToDelete.length > 0) {
nodesToDelete.forEach(nodeToDelete => {
// 检查是否为子节点
const nodeData = nodeToDelete.getData();
if (nodeData.parentId) {
// 找到对应的父节点
const parentNode = nodes?.find(n => n.id === nodeData.parentId);
if (parentNode) {
// 使用removeChild方法删除子节点
parentNode.removeChild(nodeToDelete);
parentNodesToUpdate.push(parentNode);
}
}
// 检查是否为 LoopNode、IterationNode 或 SubGraphNode
else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') {
// 查找所有 parentId 为当前节点 id 的子节点
nodes?.forEach(node => {
const data = node.getData();
if (data.parentId === nodeToDelete.id) {
cells.push(node);
}
});
// 添加父节点到删除列表
cells.push(nodeToDelete);
}
// 普通节点
else {
cells.push(nodeToDelete);
}
});
blankClick();
}
// 删除所有收集的节点和连线
if (cells.length > 0) {
graphRef.current?.removeCells(cells);
}
return false;
};
// 调整画布大小
const handleResize = () => {
if (containerRef.current && graphRef.current) {
graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight);
}
};
// 初始化
const init = () => {
if (!containerRef.current || !miniMapRef.current) return;
// 注册React形状
nodeRegisterLibrary.forEach((item) => {
register(item);
});
const container = containerRef.current;
graphRef.current = new Graph({
container,
background: {
color: '#F0F3F8',
},
// width: container.clientWidth || 800,
// height: container.clientHeight || 600,
autoResize: true,
grid: {
visible: true,
type: 'dot',
size: 10,
args: {
color: '#939AB1', // 网点颜色
thickness: 1, // 网点大小
}
},
panning: false,
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
},
connecting: {
// router: 'orth',
// router: 'manhattan',
connector: {
name: 'rounded',
args: {
radius: 8,
},
},
anchor: 'center',
connectionPoint: 'anchor',
allowBlank: false,
allowNode: false,
allowEdge: false,
highlight: true,
snap: {
radius: 20,
},
createEdge() {
return graphRef.current?.createEdge({
attrs: {
line: {
stroke: edge_color,
strokeWidth: 1,
},
},
zIndex: 0,
});
},
validateConnection({ sourceCell, targetCell, targetMagnet }) {
if (!targetMagnet) return false;
const sourceType = sourceCell?.getData()?.type;
const targetType = targetCell?.getData()?.type;
// 开始节点不能作为连线的终点
if (targetType === 'start') return false;
// 结束节点不能作为连线的起点
if (sourceType === 'end') return false;
// 获取源节点和目标节点的父节点ID
const sourceParentId = sourceCell?.getData()?.parentId;
const targetParentId = targetCell?.getData()?.parentId;
// 验证父子节点关系:
// 1. 如果两个节点都有父节点ID必须相同才能连线
// 2. 如果一个有父节点ID另一个没有不能连线
// 3. 如果两个都没有父节点ID可以正常连线
if (sourceParentId && targetParentId) {
// 同一父节点下的子节点可以互相连线
return sourceParentId === targetParentId;
} else if (sourceParentId || targetParentId) {
// 一个有父节点,一个没有,不能连线
return false;
}
return true;
},
},
embedding: {
enabled: true,
validate (this, { parent }) {
const parentData = parent.getData()
return parentData.type === 'iteration' || parentData.type === 'loop'
}
},
translating: {
restrict(view) {
if (!view) return null
const cell = view.cell
if (cell.isNode()) {
const parent = cell.getParent()
if (parent) {
return parent.getBBox()
}
}
return null
},
},
});
// 使用插件
setupPlugins();
// 监听连线mouseleave事件
graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => {
if (edge.getAttrByPath('line/stroke') !== edge_selected_color) {
edge.setAttrByPath('line/stroke', edge_color);
edge.setAttrByPath('line/strokeWidth', 1);
}
});
// 监听节点选择事件
graphRef.current.on('node:click', nodeClick);
// 监听连线选择事件
graphRef.current.on('edge:click', edgeClick);
// 监听画布点击事件,取消选择
graphRef.current.on('blank:click', blankClick);
// 监听缩放事件
graphRef.current.on('scale', scaleEvent);
// 监听节点移动事件
graphRef.current.on('node:moved', nodeMoved);
// 监听画布变化事件
const events = [
'node:added',
'node:removed',
'edge:added',
'edge:removed',
];
events.forEach(event => {
graphRef.current!.on(event, () => {
console.log('event', event);
setTimeout(() => saveState(), 50);
});
});
// 监听撤销键盘事件
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], undoEvent);
// 监听重做键盘事件
graphRef.current.bindKey(['ctrl+shift+z', 'cmd+shift+z', 'ctrl+y', 'cmd+y'], redoEvent);
// 监听复制键盘事件
graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent);
// 监听粘贴键盘事件
graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent);
// 删除选中的节点和连线
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
// 保存初始状态
setTimeout(() => saveState(), 100);
// init window hook
(window as Window & { __x6_instances__?: Graph[] }).__x6_instances__ = [];
(window as Window & { __x6_instances__?: Graph[] }).__x6_instances__?.push(graphRef.current);
};
useEffect(() => {
if (!containerRef.current || !miniMapRef.current) return;
init();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
graphRef.current?.dispose();
};
}, []);
const onDrop = (event: React.DragEvent) => {
if (!graphRef.current) return;
event.preventDefault();
const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
const graph = graphRef.current;
if (!graph) return;
const point = graphRef.current.clientToLocal(event.clientX, event.clientY);
// 获取节点库中的原始配置避免config数据串联
let nodeLibraryConfig = [...nodeLibrary]
.flatMap(category => category.nodes)
.find(n => n.type === dragData.type);
nodeLibraryConfig = { config: {}, ...nodeLibraryConfig } as NodeProperties;
// 创建干净的节点数据,只保留必要的字段
const cleanNodeData = {
id: `${dragData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: t(`workflow.${dragData.type}`),
...nodeLibraryConfig
};
if (dragData.type === 'loop' || dragData.type === 'iteration') {
graphRef.current.addNode({
...graphNodeLibrary[dragData.type],
x: point.x - 150,
y: point.y - 100,
data: { ...cleanNodeData, isGroup: true },
});
} else if (dragData.type === 'condition') {
// 创建条件节点
graphRef.current.addNode({
...graphNodeLibrary[dragData.type],
x: point.x - 100,
y: point.y - 60,
data: { ...cleanNodeData, elifCount: 0 },
});
} else {
// 检查是否放置在群组内
const groups = graphRef.current.getNodes().filter(node => {
const shape = node.shape;
return shape === 'loop-node' || shape === 'iteration-node' || shape === 'subgraph-node';
});
let parentGroup = null;
for (const group of groups) {
const bbox = group.getBBox();
if (point.x >= bbox.x && point.x <= bbox.x + bbox.width &&
point.y >= bbox.y && point.y <= bbox.y + bbox.height) {
parentGroup = group;
break;
}
}
const childNode = graphRef.current.addNode({
...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default),
x: point.x - 60,
y: point.y - 20,
data: { ...cleanNodeData, parentId: parentGroup?.id },
});
parentGroup?.addChild(childNode);
}
};
// 保存workflow配置
const handleSave = (flag = true) => {
if (!graphRef.current || !config) return Promise.resolve()
return new Promise((resolve, reject) => {
const nodes = graphRef.current?.getNodes() || [];
const edges = graphRef.current?.getEdges() || []
const params = {
...config,
nodes: nodes.map((node: Node) => {
const data = node.getData();
const position = node.getPosition();
const config: Record<string, any> = {}
if (data.config) {
Object.keys(data.config).forEach(key => {
if (data.config[key] && 'defaultValue' in data.config[key]) {
config[key] = data.config[key].defaultValue
}
})
}
return {
id: data.id || node.id,
type: data.type,
name: data.name,
position: {
x: position.x,
y: position.y,
},
config: config
};
}),
edges: edges.map((edge: Edge) => {
return {
source: edge.getSourceCellId(),
target: edge.getTargetCellId(),
// label: edge.getAttrs()?.label?.text,
};
}),
}
saveWorkflowConfig(config.app_id, params as WorkflowConfig)
.then(() => {
if (flag) {
message.success(t('common.saveSuccess'))
}
resolve(true)
}).catch(error => {
reject(error)
})
})
}
return {
config,
graphRef,
selectedNode,
setSelectedNode,
zoomLevel,
setZoomLevel,
canUndo,
canRedo,
isHandMode,
setIsHandMode,
onUndo,
onRedo,
onDrop,
blankClick,
deleteEvent,
copyEvent,
parseEvent,
handleSave
};
};