宁波建设工程造价信息网地址关键词优化排名公司
本文将通过代码实例详细讲解如何使用 Vue 3 和 AntV X6 构建一个可视化流程编辑器,重点介绍核心功能的实现方法。
效果图
为什么选择 AntV X6?
AntV X6 是蚂蚁集团开源的图编辑引擎,专门为流程图、拓扑图等场景设计:
- ✅ 开箱即用:内置节点、连线、拖拽等功能
- ✅ Vue 友好:与 Vue 3 完美集成
- ✅ 文档完善:中文文档,示例丰富
- ✅ 功能强大:支持自定义节点、连线规则等
项目结构
我们的流程编辑器由 4 个核心组件组成:
流程编辑器
├── ProcessToolbar.vue # 工具栏(保存、导出等)
├── NodePanel.vue # 左侧节点面板
├── ProcessCanvas.vue # 中间画布区域
└── NodeConfig.vue # 右侧属性配置
代码
1. main.vue
<template><div class="process-container"><!-- 顶部工具栏 --><ProcessToolbar @save="handleSave" @export="handleExport" @import="handleImport" @fitToContent="handleFitToContent" /><div class="process-content"><!-- 左侧节点区域 --><div class="process-left"><NodePanel ref="nodePanelRef" @nodeSelected="handleNodeSelected" /></div><!-- 中间画布区域 --><div class="process-center"><ProcessCanvas ref="canvasRef" :currentNode="currentNode" @nodeSelected="handleCanvasNodeSelected"/></div><!-- 右侧属性配置区域 --><div class="process-right"><NodeConfig:current-node="currentNode"@update-node="handleNodeUpdated"/></div></div></div>
</template><script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";import NodePanel from "./component/NodePanel.vue";
import ProcessCanvas from "./component/ProcessCanvas.vue";
import NodeConfig from "./component/NodeConfig.vue";
import ProcessToolbar from "./component/ProcessToolbar.vue";// 引用组件实例
const nodePanelRef = ref(null);
const canvasRef = ref(null);
// 选中的节点
const currentNode = ref(null);
const handleNodeSelected = (data) => {// 只在拖拽时调用ProcessCanvas的startDrag,不要设置currentNodeif (canvasRef.value && canvasRef.value.startDrag) {canvasRef.value.startDrag(data);}
};// 处理画布节点选中事件
const handleCanvasNodeSelected = (node) => {console.log('画布节点被选中', node);currentNode.value = node;
};// 保存流程
const handleSave = () => {if (canvasRef.value) {canvasRef.value.exportGraphJSON();ElMessage.success("保存流程");} else {ElMessage.warning("导出失败,画布未初始化");}
};// 导出流程
const handleExport = () => {if (canvasRef.value) {canvasRef.value.exportGraph();ElMessage.success("导出成功,图片已保存");} else {ElMessage.warning("导出失败,画布未初始化");}
};// 导入流程
const handleImport=()=>{if (canvasRef.value) {canvasRef.value.importGraphJSON();ElMessage.success("导入流程");} else {ElMessage.warning("导入失败,画布未初始化");}
}// 自适应画布
const handleFitToContent = () => {if (canvasRef.value) {canvasRef.value.fitToContent();ElMessage.success("画布已自适应内容");} else {ElMessage.warning("自适应失败,画布未初始化");}
}
</script><style lang="scss" scoped>
.process-container {height: calc(100vh - 120px);display: flex;flex-direction: column;background-color: #fff;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);.process-content {flex: 1;display: flex;overflow: hidden;}.process-left {width: 240px;height: 100%;overflow-y: auto;}.process-center {flex: 1;height: 100%;position: relative;}.process-right {width: 300px;height: 100%;overflow-y: auto;}
}
</style>
2.NodePanel.vue
<template><div class="node-panel"><div class="panel-title">流程节点</div><div class="node-list"><divv-for="(item, index) in nodeList":key="index"class="node-item":data-type="item.type":data-shape="item.shape":data-name="item.name"@mousedown="onDragStart($event, item)"><div class="node-item-label">{{ item.name }}</div></div></div></div>
</template><script setup>
import { ref } from "vue";// 定义节点列表
const nodeList = ref([{ name: "开始工序", type: "start", shape: "circle", iconClass: "start-node" },{ name: "结束工序", type: "end", shape: "circle", iconClass: "end-node" },{name: "加工工序",type: "processing",shape: "rect",iconClass: "processing-node",},{name: "检验工序",type: "inspection",shape: "diamond",iconClass: "inspection-node",},{ name: "返工工序", type: "rework", shape: "rect", iconClass: "rework-node" },{name: "运输工序",type: "transport",shape: "rect",iconClass: "transport-node",},{name: "组装工序",type: "assembly",shape: "rect",iconClass: "assembly-node",},{name: "包装工序",type: "packaging",shape: "rect",iconClass: "packaging-node",},
]);
const emit = defineEmits(["nodeSelected"]);
// 拖拽开始事件
const onDragStart = (e, nodeConfig) => {emit("nodeSelected", { event: e, nodeConfig });
};defineExpose({nodeList,
});
</script><style lang="scss" scoped>
.node-panel {height: 100%;border-right: 1px solid #dcdfe6;.panel-title {padding: 10px;font-size: 16px;font-weight: bold;border-bottom: 1px solid #ebeef5;}.node-list {padding: 10px;.node-item {display: flex;align-items: center;padding: 8px 12px;margin-bottom: 10px;border: 1px solid #dcdfe6;border-radius: 4px;cursor: move;transition: all 0.3s;&:hover {background-color: #f5f7fa;border-color: #c6e2ff;}.node-item-icon {width: 24px;height: 24px;display: flex;align-items: center;justify-content: center;margin-right: 8px;&.start-node {color: #67c23a;}&.end-node {color: #f56c6c;}&.approval-node {color: #409eff;}&.condition-node {color: #e6a23c;}&.process-node {color: #909399;}&.user-node {color: #9254de;}}.node-item-label {font-size: 14px;}}}
}
</style>
3.ProcessCanvas.vue
<template><div class="process-canvas" ref="canvasRef"><div class="canvas-container" ref="container"></div></div>
</template><script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import { Graph, Shape } from "@antv/x6";
import { Dnd } from "@antv/x6-plugin-dnd";
import { Export } from '@antv/x6-plugin-export'// 组件属性定义
const props = defineProps({currentNode: {type: Object,default: () => ({}),},
});// 定义向父组件发射的事件
const emit = defineEmits(['nodeSelected']);/*** 画布相关的响应式变量*/
const container = ref(null); // 画布容器DOM引用
const graph = ref(null); // X6图形实例/*** 初始化X6画布* 配置画布的基本属性、网格、缩放、平移、连接线等功能*/
const initGraph = () => {if (!container.value) return;// 创建X6图形实例graph.value = new Graph({container: container.value,width: '100%',height: 600,grid: true,background: {color: "#F2F7FA", // 画布背景色},// 网格配置grid: {visible: true,type: "doubleMesh", // 双重网格args: [{color: "#eee", // 主网格线颜色thickness: 1, // 主网格线宽度},{color: "#ddd", // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},// 缩放与平移配置mousewheel: true, // 使用滚轮控制缩放panning: {enabled: true,modifiers: [], // 触发键盘事件进行平移:'alt' | 'ctrl' | 'meta' | 'shift'eventTypes: ["leftMouseDown"], // 触发鼠标事件进行平移:'leftMouseDown' | 'rightMouseDown' | 'mouseWheel'},// 连接线配置connecting: {router:{name: 'manhattan', // 曼哈顿路由算法,连接线呈直角}, connector: {name: 'rounded', // 圆角连接线args: {radius: 8, // 圆角半径},},anchor: 'center', // 连接点位置connectionPoint: 'boundary', // 连接点类型allowBlank: false, // 不允许连接到空白区域allowLoop: false, // 不允许自环allowNode: false, // 不允许连接到节点(只允许连接到连接桩)allowEdge: false, // 不允许连接到边highlight: true, // 拖动连接线时高亮显示可用的连接桩snap: true, // 拖动连接线时自动吸附到节点或连接桩/*** 自定义新建边的样式* @returns {Shape.Edge} 返回配置好的边实例*/createEdge(){// 根据连接的节点类型设置不同的边样式return new Shape.Edge({attrs: {line: {stroke: '#1890ff', // 默认蓝色strokeWidth: 1.5,targetMarker: {name: 'classic',size: 8,},},},router: {name: 'manhattan',},zIndex: 0,})},/*** 验证连接是否有效* @param {Object} params 连接参数* @param {Object} params.sourceView 源节点视图* @param {Object} params.targetView 目标节点视图* @param {Object} params.sourceMagnet 源连接桩* @param {Object} params.targetMagnet 目标连接桩* @returns {boolean} 连接是否有效*/validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) {// 检查连接桩是否存在if (!sourceMagnet || !targetMagnet) {return false}// 不允许连接到自己if (sourceView === targetView) {return false}// 获取源节点和目标节点的类型const sourceNodeType = sourceView.cell.getData()?.type;const targetNodeType = targetView.cell.getData()?.type;// 开始节点只能作为源节点,不能作为目标节点if (targetNodeType === 'start') {return false;}// 结束节点只能作为目标节点,不能作为源节点if (sourceNodeType === 'end') {return false;}return true},},});// 添加导出插件graph.value.use(new Export())/*** 监听节点点击事件* 当节点被点击时,触发节点选中事件*/graph.value.on("node:click", ({node}) => {console.log("节点被点击", node);// 触发节点选中事件,通知父组件emit('nodeSelected', node);});/*** 监听节点右键菜单事件* 显示删除节点的上下文菜单*/graph.value.on('node:contextmenu', ({ node, e }) => {e.preventDefault(); // 阻止默认右键菜单// 创建右键菜单DOM元素const contextMenu = document.createElement('div');contextMenu.className = 'context-menu';contextMenu.innerHTML = `<div class="menu-item" data-action="delete"><span>删除节点</span></div>`;// 设置菜单位置(跟随鼠标位置)contextMenu.style.position = 'fixed';contextMenu.style.left = e.clientX + 'px';contextMenu.style.top = e.clientY + 'px';contextMenu.style.zIndex = '9999';// 将菜单添加到页面document.body.appendChild(contextMenu);// 绑定菜单项点击事件const deleteItem = contextMenu.querySelector('[data-action="delete"]');deleteItem.addEventListener('click', () => {// 删除节点graph.value.removeNode(node);console.log('节点已删除:', node.id);// 如果删除的是当前选中的节点,清空右侧配置emit('nodeSelected', null);// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}});/*** 点击其他地方关闭菜单的处理函数* @param {Event} event 点击事件*/const closeMenu = (event) => {if (!contextMenu.contains(event.target)) {// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}// 移除全局点击事件监听器document.removeEventListener('click', closeMenu);}};// 延迟添加点击事件,避免立即触发setTimeout(() => {document.addEventListener('click', closeMenu);}, 100);});/*** 监听连接线创建事件* 实现节点连接数量限制逻辑*/graph.value.on('edge:connected', ({ edge, isNew }) => {if (isNew) {console.log('新连接线已创建', edge);// 获取源节点和目标节点const sourceNode = edge.getSourceNode();const targetNode = edge.getTargetNode();if (sourceNode && targetNode) {const sourceType = sourceNode.getData()?.type;const targetType = targetNode.getData()?.type;// 为开始节点和结束节点实现单连接限制if (sourceType === 'start' || sourceType === 'end') {// 获取源节点的所有连接线const sourceEdges = graph.value.getConnectedEdges(sourceNode);// 移除除当前连接线外的其他连接线sourceEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getSourceNode() === sourceNode) {graph.value.removeEdge(existingEdge);}});}if (targetType === 'start' || targetType === 'end') {// 获取目标节点的所有连接线const targetEdges = graph.value.getConnectedEdges(targetNode);// 移除除当前连接线外的其他连接线targetEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getTargetNode() === targetNode) {graph.value.removeEdge(existingEdge);}});}// 为普通工序节点实现最多两个连接的限制if (sourceType !== 'start' && sourceType !== 'end') {// 获取源节点的所有出边const sourceEdges = graph.value.getConnectedEdges(sourceNode);const sourceOutgoingEdges = sourceEdges.filter(e => e.getSourceNode() === sourceNode);// 如果超过2个连接,移除最旧的连接if (sourceOutgoingEdges.length > 2) {const edgesToRemove = sourceOutgoingEdges.slice(0, sourceOutgoingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}if (targetType !== 'start' && targetType !== 'end') {// 获取目标节点的所有入边const targetEdges = graph.value.getConnectedEdges(targetNode);const targetIncomingEdges = targetEdges.filter(e => e.getTargetNode() === targetNode);// 如果超过2个连接,移除最旧的连接if (targetIncomingEdges.length > 2) {const edgesToRemove = targetIncomingEdges.slice(0, targetIncomingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}}}});/*** 监听连接线移除事件*/graph.value.on('edge:removed', ({ edge }) => {console.log('连接线被移除', edge);});// 注释掉的鼠标悬停事件,保持连接点始终可见// graph.value.on('node:mouseenter', ({ node }) => {// // 显示所有连接点// node.attr('circle', { style: { visibility: 'visible' } });// });// graph.value.on('node:mouseleave', ({ node }) => {// // 隐藏所有连接点// node.attr('circle', { style: { visibility: 'hidden' } });// });
};/*** 开始拖拽节点到画布* 根据节点类型创建不同样式的节点配置* @param {Object} data 拖拽数据* @param {Object} data.nodeConfig 节点配置信息* @param {Event} data.event 拖拽事件*/
const startDrag = (data) => {if (!data || !data.nodeConfig) return;const { nodeConfig, event } = data;// 基础节点配置let nodeSettings = {width: 100,height: 40,label: nodeConfig.name,attrs: {body: {fill: "#fff",stroke: "#1890ff",strokeWidth: 1,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},};// 根据节点类型设置不同的配置if (nodeConfig.type === 'start') {// 开始节点配置 - 圆形,绿色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#e8f7ee",stroke: "#67c23a",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 开始节点拥有四个方向的连接点(只能作为源端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'start', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'end') {// 结束节点配置 - 圆形,红色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#fef0f0",stroke: "#f56c6c",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 结束节点拥有四个方向的连接点(只能作为目标端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'end', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'inspection') {// 检验工序节点配置 - 菱形,橙色主题nodeSettings = {...nodeSettings,shape: 'polygon',width: 80,height: 80,attrs: {body: {fill: "#fef9e7",stroke: "#e6a23c",strokeWidth: 2,refPoints: '0,10 10,0 20,10 10,20', // 菱形的四个点},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 检验节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'inspection', nodeType: nodeConfig.type },};} else {// 其他节点配置 - 矩形,蓝色主题nodeSettings = {...nodeSettings,shape: 'rect',// 普通节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'normal', nodeType: nodeConfig.type },};}// 创建节点实例const node = graph.value.createNode(nodeSettings);// 创建拖拽实例const dnd = new Dnd({target: graph.value,/*** 验证节点是否可以放置到画布* @returns {boolean} 是否允许放置*/validateNode: () => {console.log("成功拖拽到画布");return true;}});// 开始拖拽dnd.start(node, event);
};// 注释掉的watch监听器,改为直接调用startDrag
// watch(() => props.currentNode, (newValue) => {
// startDrag(newValue);
// }, { deep: true });/*** 导出流程图为PNG图片* 将当前画布内容导出为图片文件*/
const exportGraph = () => {if (!graph.value) return;// 导出PNG图片graph.value.exportPNG('process-graph',{backgroundColor: '#f8f9fa', // 设置导出图片的背景色padding:100, // 图片边距quality: 1, // 图片质量});
};/*** 导出流程为JSON格式* 将画布数据序列化为JSON并保存到本地存储*/
const exportGraphJSON = () => {if (!graph.value) return;const jsonData = graph.value.toJSON(); // 获取画布数据// 存入本地存储localStorage.setItem('x6-process-graph', JSON.stringify(jsonData));console.log('流程图 JSON 已保存到本地存储',jsonData);
};/*** 从本地存储导入流程* 从localStorage读取之前保存的流程数据并恢复到画布*/
const importGraphJSON = () => {const jsonString = localStorage.getItem('x6-process-graph');if (jsonString && graph.value) {try {const jsonData = JSON.parse(jsonString);graph.value.fromJSON(jsonData);console.log('流程图 JSON 已从本地存储导入');} catch (error) {console.error('导入流程图失败:', error);}}
};/*** 自适应画布内容* 结合手动计算和X6内置方法,实现画布内容的最佳显示效果*/
const fitToContent = () => {if (!graph.value) return;// 获取所有节点const nodes = graph.value.getNodes();if (nodes.length === 0) {console.log('画布中没有节点,无法自适应');return;}// 结合手动计算和X6内置方法,获得更好的自适应效果// 第一步:手动计算内容边界和合适的缩放比例let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;// 遍历所有节点,计算内容边界nodes.forEach(node => {const bbox = node.getBBox();minX = Math.min(minX, bbox.x);minY = Math.min(minY, bbox.y);maxX = Math.max(maxX, bbox.x + bbox.width);maxY = Math.max(maxY, bbox.y + bbox.height);});// 添加边距const padding = 80;const contentWidth = maxX - minX + padding * 2;const contentHeight = maxY - minY + padding * 2;// 获取画布容器尺寸const containerRect = container.value.getBoundingClientRect();const containerWidth = containerRect.width;const containerHeight = containerRect.height;// 计算缩放比例(保持宽高比)const scaleX = containerWidth / contentWidth;const scaleY = containerHeight / contentHeight;const scale = Math.min(scaleX, scaleY, 1.5); // 限制最大缩放比例为1.5// 第二步:设置缩放比例graph.value.zoomTo(scale);// 第三步:延迟调用centerContent方法进行内容居中setTimeout(() => {graph.value.centerContent({padding: padding,useCellGeometry: true});}, 100);
};// 组件挂载时初始化画布
onMounted(() => {initGraph();
});// 组件卸载时清理资源
onUnmounted(() => {if (graph.value) {graph.value.dispose();}
});// 暴露给父组件的方法
defineExpose({startDrag,exportGraph,exportGraphJSON,importGraphJSON,fitToContent,
});
</script><style lang="scss" scoped>
.process-canvas {width: 100%;height: 100%;position: relative;overflow: hidden;.canvas-container {width: 100%;height: 100%;background-color: #f8f9fa;}
}
</style><style lang="scss">
// 右键菜单样式(不使用scoped,因为菜单添加到body)
.context-menu {background: #fff;border: 1px solid #e4e7ed;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);padding: 4px 0;min-width: 120px;font-size: 14px;.menu-item {padding: 8px 16px;cursor: pointer;color: #606266;transition: all 0.2s;&:hover {background-color: #f5f7fa;color: #409eff;}&[data-action="delete"]:hover {background-color: #fef0f0;color: #f56c6c;}span {display: flex;align-items: center;&::before {content: '🗑️';margin-right: 8px;font-size: 12px;}}}
}
</style>
4.NodeConfig.vue
<template><div class="node-config"><div class="config-title">节点配置</div><div v-if="!currentNode" class="empty-tip"><el-empty description="请选择一个节点进行配置" /></div><div v-else class="config-form"><el-form :model="nodeForm" label-width="80px" size="small"><el-form-item label="节点名称"><el-input v-model="nodeForm.label" placeholder="请输入节点名称" @blur="updateNodeLabel"/></el-form-item><el-form-item label="节点类型"><el-input v-model="nodeForm.type" disabled/></el-form-item><el-form-item label="节点宽度"><el-input-number v-model="nodeForm.width" :min="50" :max="300" @change="updateNodeSize"/></el-form-item><el-form-item label="节点高度"><el-input-number v-model="nodeForm.height" :min="30" :max="200" @change="updateNodeSize"/></el-form-item><el-form-item label="填充颜色"><el-color-picker v-model="nodeForm.fillColor" @change="updateNodeStyle"/></el-form-item><el-form-item label="边框颜色"><el-color-picker v-model="nodeForm.strokeColor" @change="updateNodeStyle"/></el-form-item><el-form-item label="边框宽度"><el-input-number v-model="nodeForm.strokeWidth" :min="1" :max="10" @change="updateNodeStyle"/></el-form-item><el-form-item label="字体大小"><el-input-number v-model="nodeForm.fontSize" :min="10" :max="24" @change="updateNodeStyle"/></el-form-item><el-form-item label="字体颜色"><el-color-picker v-model="nodeForm.fontColor" @change="updateNodeStyle"/></el-form-item></el-form></div></div>
</template><script setup>
import { ref, watch, computed } from 'vue';const props = defineProps({currentNode: {type: Object,default: null,},
});const emit = defineEmits(['update-node']);// 节点配置表单数据
const nodeForm = ref({label: '',type: '',width: 100,height: 40,fillColor: '#ffffff',strokeColor: '#1890ff',strokeWidth: 1,fontSize: 12,fontColor: '#333333'
});// 监听当前节点变化,更新表单数据
watch(() => props.currentNode, (newNode) => {if (newNode) {const attrs = newNode.getAttrs();const size = newNode.getSize();const data = newNode.getData();nodeForm.value = {label: newNode.getLabel() || '',type: data?.type || '',width: size.width || 100,height: size.height || 40,fillColor: attrs?.body?.fill || '#ffffff',strokeColor: attrs?.body?.stroke || '#1890ff',strokeWidth: attrs?.body?.strokeWidth || 1,fontSize: attrs?.label?.fontSize || 12,fontColor: attrs?.label?.fill || '#333333'};}
}, { immediate: true });// 更新节点标签
const updateNodeLabel = () => {if (props.currentNode && nodeForm.value.label) {props.currentNode.setLabel(nodeForm.value.label);props.currentNode.attr('label/text', nodeForm.value.label);emit('update-node', props.currentNode);}
};// 更新节点尺寸
const updateNodeSize = () => {if (props.currentNode) {props.currentNode.resize(nodeForm.value.width, nodeForm.value.height);emit('update-node', props.currentNode);}
};// 更新节点样式
const updateNodeStyle = () => {if (props.currentNode) {props.currentNode.attr({body: {fill: nodeForm.value.fillColor,stroke: nodeForm.value.strokeColor,strokeWidth: nodeForm.value.strokeWidth},label: {fontSize: nodeForm.value.fontSize,fill: nodeForm.value.fontColor}});emit('update-node', props.currentNode);}
};
</script><style lang="scss" scoped>
.node-config {height: 100%;border-left: 1px solid #dcdfe6;display: flex;flex-direction: column;.config-title {padding: 10px;font-size: 16px;font-weight: bold;border-bottom: 1px solid #ebeef5;}.empty-tip {flex: 1;display: flex;align-items: center;justify-content: center;}.config-form {flex: 1;padding: 15px;overflow-y: auto;}
}
</style>
5. ProcessToolbar.vue
<template><div class="process-toolbar"><el-button type="primary" @click="handleSave">保存</el-button><el-button @click="handleExport">导出</el-button><el-button type="primary" @click="importGraphJSON">一键复原</el-button><el-button type="info" @click="handleFitToContent">自适应画布</el-button></div>
</template><script setup>// 定义事件
const emit = defineEmits(["save", "export","import", "fitToContent"]);// 保存流程
const handleSave = () => {emit("save");
};// 导出流程
const handleExport = () => {emit("export");
};
// 导入流程
const importGraphJSON = () => {emit("import");
};
// 自适应画布
const handleFitToContent = () => {emit("fitToContent");
};
</script><style lang="scss" scoped>
.process-toolbar {padding: 8px 16px;border-bottom: 1px solid #dcdfe6;display: flex;gap: 8px;justify-content: flex-end;
}
</style>
核心功能实现
1. 画布初始化
首先创建 X6 画布,这是整个流程编辑器的核心:
// ProcessCanvas.vue<template><div class="process-canvas" ref="canvasRef"><div class="canvas-container" ref="container"></div></div>
</template><script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import { Graph, Shape } from "@antv/x6";
import { Dnd } from "@antv/x6-plugin-dnd";
import { Export } from '@antv/x6-plugin-export'// 组件属性定义
const props = defineProps({currentNode: {type: Object,default: () => ({}),},
});// 定义向父组件发射的事件
const emit = defineEmits(['nodeSelected']);/*** 画布相关的响应式变量*/
const container = ref(null); // 画布容器DOM引用
const graph = ref(null); // X6图形实例/*** 初始化X6画布* 配置画布的基本属性、网格、缩放、平移、连接线等功能*/
const initGraph = () => {if (!container.value) return;// 创建X6图形实例graph.value = new Graph({container: container.value,width: '100%',height: 600,grid: true,background: {color: "#F2F7FA", // 画布背景色},// 网格配置grid: {visible: true,type: "doubleMesh", // 双重网格args: [{color: "#eee", // 主网格线颜色thickness: 1, // 主网格线宽度},{color: "#ddd", // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},// 缩放与平移配置mousewheel: true, // 使用滚轮控制缩放panning: {enabled: true,modifiers: [], // 触发键盘事件进行平移:'alt' | 'ctrl' | 'meta' | 'shift'eventTypes: ["leftMouseDown"], // 触发鼠标事件进行平移:'leftMouseDown' | 'rightMouseDown' | 'mouseWheel'},// 连接线配置connecting: {router:{name: 'manhattan', // 曼哈顿路由算法,连接线呈直角}, connector: {name: 'rounded', // 圆角连接线args: {radius: 8, // 圆角半径},},anchor: 'center', // 连接点位置connectionPoint: 'boundary', // 连接点类型allowBlank: false, // 不允许连接到空白区域allowLoop: false, // 不允许自环allowNode: false, // 不允许连接到节点(只允许连接到连接桩)allowEdge: false, // 不允许连接到边highlight: true, // 拖动连接线时高亮显示可用的连接桩snap: true, // 拖动连接线时自动吸附到节点或连接桩/*** 自定义新建边的样式* @returns {Shape.Edge} 返回配置好的边实例*/createEdge(){// 根据连接的节点类型设置不同的边样式return new Shape.Edge({attrs: {line: {stroke: '#1890ff', // 默认蓝色strokeWidth: 1.5,targetMarker: {name: 'classic',size: 8,},},},router: {name: 'manhattan',},zIndex: 0,})},/*** 验证连接是否有效* @param {Object} params 连接参数* @param {Object} params.sourceView 源节点视图* @param {Object} params.targetView 目标节点视图* @param {Object} params.sourceMagnet 源连接桩* @param {Object} params.targetMagnet 目标连接桩* @returns {boolean} 连接是否有效*/validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) {// 检查连接桩是否存在if (!sourceMagnet || !targetMagnet) {return false}// 不允许连接到自己if (sourceView === targetView) {return false}// 获取源节点和目标节点的类型const sourceNodeType = sourceView.cell.getData()?.type;const targetNodeType = targetView.cell.getData()?.type;// 开始节点只能作为源节点,不能作为目标节点if (targetNodeType === 'start') {return false;}// 结束节点只能作为目标节点,不能作为源节点if (sourceNodeType === 'end') {return false;}return true},},});// 添加导出插件graph.value.use(new Export())/*** 监听节点点击事件* 当节点被点击时,触发节点选中事件*/graph.value.on("node:click", ({node}) => {console.log("节点被点击", node);// 触发节点选中事件,通知父组件emit('nodeSelected', node);});/*** 监听节点右键菜单事件* 显示删除节点的上下文菜单*/graph.value.on('node:contextmenu', ({ node, e }) => {e.preventDefault(); // 阻止默认右键菜单// 创建右键菜单DOM元素const contextMenu = document.createElement('div');contextMenu.className = 'context-menu';contextMenu.innerHTML = `<div class="menu-item" data-action="delete"><span>删除节点</span></div>`;// 设置菜单位置(跟随鼠标位置)contextMenu.style.position = 'fixed';contextMenu.style.left = e.clientX + 'px';contextMenu.style.top = e.clientY + 'px';contextMenu.style.zIndex = '9999';// 将菜单添加到页面document.body.appendChild(contextMenu);// 绑定菜单项点击事件const deleteItem = contextMenu.querySelector('[data-action="delete"]');deleteItem.addEventListener('click', () => {// 删除节点graph.value.removeNode(node);console.log('节点已删除:', node.id);// 如果删除的是当前选中的节点,清空右侧配置emit('nodeSelected', null);// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}});/*** 点击其他地方关闭菜单的处理函数* @param {Event} event 点击事件*/const closeMenu = (event) => {if (!contextMenu.contains(event.target)) {// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}// 移除全局点击事件监听器document.removeEventListener('click', closeMenu);}};// 延迟添加点击事件,避免立即触发setTimeout(() => {document.addEventListener('click', closeMenu);}, 100);});/*** 监听连接线创建事件* 实现节点连接数量限制逻辑*/graph.value.on('edge:connected', ({ edge, isNew }) => {if (isNew) {console.log('新连接线已创建', edge);// 获取源节点和目标节点const sourceNode = edge.getSourceNode();const targetNode = edge.getTargetNode();if (sourceNode && targetNode) {const sourceType = sourceNode.getData()?.type;const targetType = targetNode.getData()?.type;// 为开始节点和结束节点实现单连接限制if (sourceType === 'start' || sourceType === 'end') {// 获取源节点的所有连接线const sourceEdges = graph.value.getConnectedEdges(sourceNode);// 移除除当前连接线外的其他连接线sourceEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getSourceNode() === sourceNode) {graph.value.removeEdge(existingEdge);}});}if (targetType === 'start' || targetType === 'end') {// 获取目标节点的所有连接线const targetEdges = graph.value.getConnectedEdges(targetNode);// 移除除当前连接线外的其他连接线targetEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getTargetNode() === targetNode) {graph.value.removeEdge(existingEdge);}});}// 为普通工序节点实现最多两个连接的限制if (sourceType !== 'start' && sourceType !== 'end') {// 获取源节点的所有出边const sourceEdges = graph.value.getConnectedEdges(sourceNode);const sourceOutgoingEdges = sourceEdges.filter(e => e.getSourceNode() === sourceNode);// 如果超过2个连接,移除最旧的连接if (sourceOutgoingEdges.length > 2) {const edgesToRemove = sourceOutgoingEdges.slice(0, sourceOutgoingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}if (targetType !== 'start' && targetType !== 'end') {// 获取目标节点的所有入边const targetEdges = graph.value.getConnectedEdges(targetNode);const targetIncomingEdges = targetEdges.filter(e => e.getTargetNode() === targetNode);// 如果超过2个连接,移除最旧的连接if (targetIncomingEdges.length > 2) {const edgesToRemove = targetIncomingEdges.slice(0, targetIncomingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}}}});/*** 监听连接线移除事件*/graph.value.on('edge:removed', ({ edge }) => {console.log('连接线被移除', edge);});// 注释掉的鼠标悬停事件,保持连接点始终可见// graph.value.on('node:mouseenter', ({ node }) => {// // 显示所有连接点// node.attr('circle', { style: { visibility: 'visible' } });// });// graph.value.on('node:mouseleave', ({ node }) => {// // 隐藏所有连接点// node.attr('circle', { style: { visibility: 'hidden' } });// });
};/*** 开始拖拽节点到画布* 根据节点类型创建不同样式的节点配置* @param {Object} data 拖拽数据* @param {Object} data.nodeConfig 节点配置信息* @param {Event} data.event 拖拽事件*/
const startDrag = (data) => {if (!data || !data.nodeConfig) return;const { nodeConfig, event } = data;// 基础节点配置let nodeSettings = {width: 100,height: 40,label: nodeConfig.name,attrs: {body: {fill: "#fff",stroke: "#1890ff",strokeWidth: 1,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},};// 根据节点类型设置不同的配置if (nodeConfig.type === 'start') {// 开始节点配置 - 圆形,绿色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#e8f7ee",stroke: "#67c23a",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 开始节点拥有四个方向的连接点(只能作为源端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'start', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'end') {// 结束节点配置 - 圆形,红色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#fef0f0",stroke: "#f56c6c",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 结束节点拥有四个方向的连接点(只能作为目标端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'end', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'inspection') {// 检验工序节点配置 - 菱形,橙色主题nodeSettings = {...nodeSettings,shape: 'polygon',width: 80,height: 80,attrs: {body: {fill: "#fef9e7",stroke: "#e6a23c",strokeWidth: 2,refPoints: '0,10 10,0 20,10 10,20', // 菱形的四个点},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 检验节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'inspection', nodeType: nodeConfig.type },};} else {// 其他节点配置 - 矩形,蓝色主题nodeSettings = {...nodeSettings,shape: 'rect',// 普通节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'normal', nodeType: nodeConfig.type },};}// 创建节点实例const node = graph.value.createNode(nodeSettings);// 创建拖拽实例const dnd = new Dnd({target: graph.value,/*** 验证节点是否可以放置到画布* @returns {boolean} 是否允许放置*/validateNode: () => {console.log("成功拖拽到画布");return true;}});// 开始拖拽dnd.start(node, event);
};// 注释掉的watch监听器,改为直接调用startDrag
// watch(() => props.currentNode, (newValue) => {
// startDrag(newValue);
// }, { deep: true });/*** 导出流程图为PNG图片* 将当前画布内容导出为图片文件*/
const exportGraph = () => {if (!graph.value) return;// 导出PNG图片graph.value.exportPNG('process-graph',{backgroundColor: '#f8f9fa', // 设置导出图片的背景色padding:100, // 图片边距quality: 1, // 图片质量});
};/*** 导出流程为JSON格式* 将画布数据序列化为JSON并保存到本地存储*/
const exportGraphJSON = () => {if (!graph.value) return;const jsonData = graph.value.toJSON(); // 获取画布数据// 存入本地存储localStorage.setItem('x6-process-graph', JSON.stringify(jsonData));console.log('流程图 JSON 已保存到本地存储',jsonData);
};/*** 从本地存储导入流程* 从localStorage读取之前保存的流程数据并恢复到画布*/
const importGraphJSON = () => {const jsonString = localStorage.getItem('x6-process-graph');if (jsonString && graph.value) {try {const jsonData = JSON.parse(jsonString);graph.value.fromJSON(jsonData);console.log('流程图 JSON 已从本地存储导入');} catch (error) {console.error('导入流程图失败:', error);}}
};/*** 自适应画布内容* 结合手动计算和X6内置方法,实现画布内容的最佳显示效果*/
const fitToContent = () => {if (!graph.value) return;// 获取所有节点const nodes = graph.value.getNodes();if (nodes.length === 0) {console.log('画布中没有节点,无法自适应');return;}// 结合手动计算和X6内置方法,获得更好的自适应效果// 第一步:手动计算内容边界和合适的缩放比例let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;// 遍历所有节点,计算内容边界nodes.forEach(node => {const bbox = node.getBBox();minX = Math.min(minX, bbox.x);minY = Math.min(minY, bbox.y);maxX = Math.max(maxX, bbox.x + bbox.width);maxY = Math.max(maxY, bbox.y + bbox.height);});// 添加边距const padding = 80;const contentWidth = maxX - minX + padding * 2;const contentHeight = maxY - minY + padding * 2;// 获取画布容器尺寸const containerRect = container.value.getBoundingClientRect();const containerWidth = containerRect.width;const containerHeight = containerRect.height;// 计算缩放比例(保持宽高比)const scaleX = containerWidth / contentWidth;const scaleY = containerHeight / contentHeight;const scale = Math.min(scaleX, scaleY, 1.5); // 限制最大缩放比例为1.5// 第二步:设置缩放比例graph.value.zoomTo(scale);// 第三步:延迟调用centerContent方法进行内容居中setTimeout(() => {graph.value.centerContent({padding: padding,useCellGeometry: true});}, 100);
};// 组件挂载时初始化画布
onMounted(() => {initGraph();
});// 组件卸载时清理资源
onUnmounted(() => {if (graph.value) {graph.value.dispose();}
});// 暴露给父组件的方法
defineExpose({startDrag,exportGraph,exportGraphJSON,importGraphJSON,fitToContent,
});
</script><style lang="scss" scoped>
.process-canvas {width: 100%;height: 100%;position: relative;overflow: hidden;.canvas-container {width: 100%;height: 100%;background-color: #f8f9fa;}
}
</style><style lang="scss">
// 右键菜单样式(不使用scoped,因为菜单添加到body)
.context-menu {background: #fff;border: 1px solid #e4e7ed;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);padding: 4px 0;min-width: 120px;font-size: 14px;.menu-item {padding: 8px 16px;cursor: pointer;color: #606266;transition: all 0.2s;&:hover {background-color: #f5f7fa;color: #409eff;}&[data-action="delete"]:hover {background-color: #fef0f0;color: #f56c6c;}span {display: flex;align-items: center;&::before {content: '🗑️';margin-right: 8px;font-size: 12px;}}}
}
</style>
关键配置说明:
grid
: 显示网格,帮助用户对齐节点mousewheel
: 支持滚轮缩放panning
: 支持拖拽画布connecting
: 连线规则配置validateConnection
: 自定义连接验证逻辑
2. 节点拖拽功能
从左侧面板拖拽节点到画布是核心功能,我们需要定义不同类型的节点:
// NodePanel.vue - 节点面板<template><div class="node-panel"><div class="panel-title">流程节点</div><div class="node-list"><divv-for="(item, index) in nodeList":key="index"class="node-item":data-type="item.type":data-shape="item.shape":data-name="item.name"@mousedown="onDragStart($event, item)"><div class="node-item-label">{{ item.name }}</div></div></div></div>
</template><script setup>
import { ref } from "vue";// 定义节点列表
const nodeList = ref([{ name: "开始工序", type: "start", shape: "circle", iconClass: "start-node" },{ name: "结束工序", type: "end", shape: "circle", iconClass: "end-node" },{name: "加工工序",type: "processing",shape: "rect",iconClass: "processing-node",},{name: "检验工序",type: "inspection",shape: "diamond",iconClass: "inspection-node",},{ name: "返工工序", type: "rework", shape: "rect", iconClass: "rework-node" },{name: "运输工序",type: "transport",shape: "rect",iconClass: "transport-node",},{name: "组装工序",type: "assembly",shape: "rect",iconClass: "assembly-node",},{name: "包装工序",type: "packaging",shape: "rect",iconClass: "packaging-node",},
]);
const emit = defineEmits(["nodeSelected"]);
// 拖拽开始事件
const onDragStart = (e, nodeConfig) => {emit("nodeSelected", { event: e, nodeConfig });
};defineExpose({nodeList,
});
</script><style lang="scss" scoped>
.node-panel {height: 100%;border-right: 1px solid #dcdfe6;.panel-title {padding: 10px;font-size: 16px;font-weight: bold;border-bottom: 1px solid #ebeef5;}.node-list {padding: 10px;.node-item {display: flex;align-items: center;padding: 8px 12px;margin-bottom: 10px;border: 1px solid #dcdfe6;border-radius: 4px;cursor: move;transition: all 0.3s;&:hover {background-color: #f5f7fa;border-color: #c6e2ff;}.node-item-icon {width: 24px;height: 24px;display: flex;align-items: center;justify-content: center;margin-right: 8px;&.start-node {color: #67c23a;}&.end-node {color: #f56c6c;}&.approval-node {color: #409eff;}&.condition-node {color: #e6a23c;}&.process-node {color: #909399;}&.user-node {color: #9254de;}}.node-item-label {font-size: 14px;}}}
}
</style>
// ProcessCanvas.vue - 处理拖拽<template><div class="process-canvas" ref="canvasRef"><div class="canvas-container" ref="container"></div></div>
</template><script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import { Graph, Shape } from "@antv/x6";
import { Dnd } from "@antv/x6-plugin-dnd";
import { Export } from '@antv/x6-plugin-export'// 组件属性定义
const props = defineProps({currentNode: {type: Object,default: () => ({}),},
});// 定义向父组件发射的事件
const emit = defineEmits(['nodeSelected']);/*** 画布相关的响应式变量*/
const container = ref(null); // 画布容器DOM引用
const graph = ref(null); // X6图形实例/*** 初始化X6画布* 配置画布的基本属性、网格、缩放、平移、连接线等功能*/
const initGraph = () => {if (!container.value) return;// 创建X6图形实例graph.value = new Graph({container: container.value,width: '100%',height: 600,grid: true,background: {color: "#F2F7FA", // 画布背景色},// 网格配置grid: {visible: true,type: "doubleMesh", // 双重网格args: [{color: "#eee", // 主网格线颜色thickness: 1, // 主网格线宽度},{color: "#ddd", // 次网格线颜色thickness: 1, // 次网格线宽度factor: 4, // 主次网格线间隔},],},// 缩放与平移配置mousewheel: true, // 使用滚轮控制缩放panning: {enabled: true,modifiers: [], // 触发键盘事件进行平移:'alt' | 'ctrl' | 'meta' | 'shift'eventTypes: ["leftMouseDown"], // 触发鼠标事件进行平移:'leftMouseDown' | 'rightMouseDown' | 'mouseWheel'},// 连接线配置connecting: {router:{name: 'manhattan', // 曼哈顿路由算法,连接线呈直角}, connector: {name: 'rounded', // 圆角连接线args: {radius: 8, // 圆角半径},},anchor: 'center', // 连接点位置connectionPoint: 'boundary', // 连接点类型allowBlank: false, // 不允许连接到空白区域allowLoop: false, // 不允许自环allowNode: false, // 不允许连接到节点(只允许连接到连接桩)allowEdge: false, // 不允许连接到边highlight: true, // 拖动连接线时高亮显示可用的连接桩snap: true, // 拖动连接线时自动吸附到节点或连接桩/*** 自定义新建边的样式* @returns {Shape.Edge} 返回配置好的边实例*/createEdge(){// 根据连接的节点类型设置不同的边样式return new Shape.Edge({attrs: {line: {stroke: '#1890ff', // 默认蓝色strokeWidth: 1.5,targetMarker: {name: 'classic',size: 8,},},},router: {name: 'manhattan',},zIndex: 0,})},/*** 验证连接是否有效* @param {Object} params 连接参数* @param {Object} params.sourceView 源节点视图* @param {Object} params.targetView 目标节点视图* @param {Object} params.sourceMagnet 源连接桩* @param {Object} params.targetMagnet 目标连接桩* @returns {boolean} 连接是否有效*/validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) {// 检查连接桩是否存在if (!sourceMagnet || !targetMagnet) {return false}// 不允许连接到自己if (sourceView === targetView) {return false}// 获取源节点和目标节点的类型const sourceNodeType = sourceView.cell.getData()?.type;const targetNodeType = targetView.cell.getData()?.type;// 开始节点只能作为源节点,不能作为目标节点if (targetNodeType === 'start') {return false;}// 结束节点只能作为目标节点,不能作为源节点if (sourceNodeType === 'end') {return false;}return true},},});// 添加导出插件graph.value.use(new Export())/*** 监听节点点击事件* 当节点被点击时,触发节点选中事件*/graph.value.on("node:click", ({node}) => {console.log("节点被点击", node);// 触发节点选中事件,通知父组件emit('nodeSelected', node);});/*** 监听节点右键菜单事件* 显示删除节点的上下文菜单*/graph.value.on('node:contextmenu', ({ node, e }) => {e.preventDefault(); // 阻止默认右键菜单// 创建右键菜单DOM元素const contextMenu = document.createElement('div');contextMenu.className = 'context-menu';contextMenu.innerHTML = `<div class="menu-item" data-action="delete"><span>删除节点</span></div>`;// 设置菜单位置(跟随鼠标位置)contextMenu.style.position = 'fixed';contextMenu.style.left = e.clientX + 'px';contextMenu.style.top = e.clientY + 'px';contextMenu.style.zIndex = '9999';// 将菜单添加到页面document.body.appendChild(contextMenu);// 绑定菜单项点击事件const deleteItem = contextMenu.querySelector('[data-action="delete"]');deleteItem.addEventListener('click', () => {// 删除节点graph.value.removeNode(node);console.log('节点已删除:', node.id);// 如果删除的是当前选中的节点,清空右侧配置emit('nodeSelected', null);// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}});/*** 点击其他地方关闭菜单的处理函数* @param {Event} event 点击事件*/const closeMenu = (event) => {if (!contextMenu.contains(event.target)) {// 安全移除菜单DOM元素if (document.body.contains(contextMenu)) {document.body.removeChild(contextMenu);}// 移除全局点击事件监听器document.removeEventListener('click', closeMenu);}};// 延迟添加点击事件,避免立即触发setTimeout(() => {document.addEventListener('click', closeMenu);}, 100);});/*** 监听连接线创建事件* 实现节点连接数量限制逻辑*/graph.value.on('edge:connected', ({ edge, isNew }) => {if (isNew) {console.log('新连接线已创建', edge);// 获取源节点和目标节点const sourceNode = edge.getSourceNode();const targetNode = edge.getTargetNode();if (sourceNode && targetNode) {const sourceType = sourceNode.getData()?.type;const targetType = targetNode.getData()?.type;// 为开始节点和结束节点实现单连接限制if (sourceType === 'start' || sourceType === 'end') {// 获取源节点的所有连接线const sourceEdges = graph.value.getConnectedEdges(sourceNode);// 移除除当前连接线外的其他连接线sourceEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getSourceNode() === sourceNode) {graph.value.removeEdge(existingEdge);}});}if (targetType === 'start' || targetType === 'end') {// 获取目标节点的所有连接线const targetEdges = graph.value.getConnectedEdges(targetNode);// 移除除当前连接线外的其他连接线targetEdges.forEach(existingEdge => {if (existingEdge.id !== edge.id && existingEdge.getTargetNode() === targetNode) {graph.value.removeEdge(existingEdge);}});}// 为普通工序节点实现最多两个连接的限制if (sourceType !== 'start' && sourceType !== 'end') {// 获取源节点的所有出边const sourceEdges = graph.value.getConnectedEdges(sourceNode);const sourceOutgoingEdges = sourceEdges.filter(e => e.getSourceNode() === sourceNode);// 如果超过2个连接,移除最旧的连接if (sourceOutgoingEdges.length > 2) {const edgesToRemove = sourceOutgoingEdges.slice(0, sourceOutgoingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}if (targetType !== 'start' && targetType !== 'end') {// 获取目标节点的所有入边const targetEdges = graph.value.getConnectedEdges(targetNode);const targetIncomingEdges = targetEdges.filter(e => e.getTargetNode() === targetNode);// 如果超过2个连接,移除最旧的连接if (targetIncomingEdges.length > 2) {const edgesToRemove = targetIncomingEdges.slice(0, targetIncomingEdges.length - 2);edgesToRemove.forEach(oldEdge => {if (oldEdge.id !== edge.id) {graph.value.removeEdge(oldEdge);}});}}}}});/*** 监听连接线移除事件*/graph.value.on('edge:removed', ({ edge }) => {console.log('连接线被移除', edge);});// 注释掉的鼠标悬停事件,保持连接点始终可见// graph.value.on('node:mouseenter', ({ node }) => {// // 显示所有连接点// node.attr('circle', { style: { visibility: 'visible' } });// });// graph.value.on('node:mouseleave', ({ node }) => {// // 隐藏所有连接点// node.attr('circle', { style: { visibility: 'hidden' } });// });
};/*** 开始拖拽节点到画布* 根据节点类型创建不同样式的节点配置* @param {Object} data 拖拽数据* @param {Object} data.nodeConfig 节点配置信息* @param {Event} data.event 拖拽事件*/
const startDrag = (data) => {if (!data || !data.nodeConfig) return;const { nodeConfig, event } = data;// 基础节点配置let nodeSettings = {width: 100,height: 40,label: nodeConfig.name,attrs: {body: {fill: "#fff",stroke: "#1890ff",strokeWidth: 1,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},};// 根据节点类型设置不同的配置if (nodeConfig.type === 'start') {// 开始节点配置 - 圆形,绿色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#e8f7ee",stroke: "#67c23a",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 开始节点拥有四个方向的连接点(只能作为源端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'source', // 只能作为源端口stroke: '#67c23a',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'start', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'end') {// 结束节点配置 - 圆形,红色主题nodeSettings = {...nodeSettings,shape: 'circle',width: 60,height: 60,attrs: {body: {fill: "#fef0f0",stroke: "#f56c6c",strokeWidth: 2,},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 结束节点拥有四个方向的连接点(只能作为目标端口)ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: 'target', // 只能作为目标端口stroke: '#f56c6c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'end', nodeType: nodeConfig.type },};} else if (nodeConfig.type === 'inspection') {// 检验工序节点配置 - 菱形,橙色主题nodeSettings = {...nodeSettings,shape: 'polygon',width: 80,height: 80,attrs: {body: {fill: "#fef9e7",stroke: "#e6a23c",strokeWidth: 2,refPoints: '0,10 10,0 20,10 10,20', // 菱形的四个点},label: {text: nodeConfig.name,fill: "#333",fontSize: 12,textAnchor: 'middle',textVerticalAnchor: 'middle',},},// 检验节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#e6a23c',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'inspection', nodeType: nodeConfig.type },};} else {// 其他节点配置 - 矩形,蓝色主题nodeSettings = {...nodeSettings,shape: 'rect',// 普通节点的连接点配置ports: {groups: {top: {position: 'top',attrs: {circle: {r: 4,magnet: true, // 既可作为源端口也可作为目标端口stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},right: {position: 'right',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},bottom: {position: 'bottom',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},left: {position: 'left',attrs: {circle: {r: 4,magnet: true,stroke: '#1890ff',strokeWidth: 1,fill: '#fff',style: { visibility: 'visible' },},},},},items: [{ id: 'port-top', group: 'top' },{ id: 'port-right', group: 'right' },{ id: 'port-bottom', group: 'bottom' },{ id: 'port-left', group: 'left' },],},data: { type: 'normal', nodeType: nodeConfig.type },};}// 创建节点实例const node = graph.value.createNode(nodeSettings);// 创建拖拽实例const dnd = new Dnd({target: graph.value,/*** 验证节点是否可以放置到画布* @returns {boolean} 是否允许放置*/validateNode: () => {console.log("成功拖拽到画布");return true;}});// 开始拖拽dnd.start(node, event);
};// 注释掉的watch监听器,改为直接调用startDrag
// watch(() => props.currentNode, (newValue) => {
// startDrag(newValue);
// }, { deep: true });/*** 导出流程图为PNG图片* 将当前画布内容导出为图片文件*/
const exportGraph = () => {if (!graph.value) return;// 导出PNG图片graph.value.exportPNG('process-graph',{backgroundColor: '#f8f9fa', // 设置导出图片的背景色padding:100, // 图片边距quality: 1, // 图片质量});
};/*** 导出流程为JSON格式* 将画布数据序列化为JSON并保存到本地存储*/
const exportGraphJSON = () => {if (!graph.value) return;const jsonData = graph.value.toJSON(); // 获取画布数据// 存入本地存储localStorage.setItem('x6-process-graph', JSON.stringify(jsonData));console.log('流程图 JSON 已保存到本地存储',jsonData);
};/*** 从本地存储导入流程* 从localStorage读取之前保存的流程数据并恢复到画布*/
const importGraphJSON = () => {const jsonString = localStorage.getItem('x6-process-graph');if (jsonString && graph.value) {try {const jsonData = JSON.parse(jsonString);graph.value.fromJSON(jsonData);console.log('流程图 JSON 已从本地存储导入');} catch (error) {console.error('导入流程图失败:', error);}}
};/*** 自适应画布内容* 结合手动计算和X6内置方法,实现画布内容的最佳显示效果*/
const fitToContent = () => {if (!graph.value) return;// 获取所有节点const nodes = graph.value.getNodes();if (nodes.length === 0) {console.log('画布中没有节点,无法自适应');return;}// 结合手动计算和X6内置方法,获得更好的自适应效果// 第一步:手动计算内容边界和合适的缩放比例let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;// 遍历所有节点,计算内容边界nodes.forEach(node => {const bbox = node.getBBox();minX = Math.min(minX, bbox.x);minY = Math.min(minY, bbox.y);maxX = Math.max(maxX, bbox.x + bbox.width);maxY = Math.max(maxY, bbox.y + bbox.height);});// 添加边距const padding = 80;const contentWidth = maxX - minX + padding * 2;const contentHeight = maxY - minY + padding * 2;// 获取画布容器尺寸const containerRect = container.value.getBoundingClientRect();const containerWidth = containerRect.width;const containerHeight = containerRect.height;// 计算缩放比例(保持宽高比)const scaleX = containerWidth / contentWidth;const scaleY = containerHeight / contentHeight;const scale = Math.min(scaleX, scaleY, 1.5); // 限制最大缩放比例为1.5// 第二步:设置缩放比例graph.value.zoomTo(scale);// 第三步:延迟调用centerContent方法进行内容居中setTimeout(() => {graph.value.centerContent({padding: padding,useCellGeometry: true});}, 100);
};// 组件挂载时初始化画布
onMounted(() => {initGraph();
});// 组件卸载时清理资源
onUnmounted(() => {if (graph.value) {graph.value.dispose();}
});// 暴露给父组件的方法
defineExpose({startDrag,exportGraph,exportGraphJSON,importGraphJSON,fitToContent,
});
</script><style lang="scss" scoped>
.process-canvas {width: 100%;height: 100%;position: relative;overflow: hidden;.canvas-container {width: 100%;height: 100%;background-color: #f8f9fa;}
}
</style><style lang="scss">
// 右键菜单样式(不使用scoped,因为菜单添加到body)
.context-menu {background: #fff;border: 1px solid #e4e7ed;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);padding: 4px 0;min-width: 120px;font-size: 14px;.menu-item {padding: 8px 16px;cursor: pointer;color: #606266;transition: all 0.2s;&:hover {background-color: #f5f7fa;color: #409eff;}&[data-action="delete"]:hover {background-color: #fef0f0;color: #f56c6c;}span {display: flex;align-items: center;&::before {content: '🗑️';margin-right: 8px;font-size: 12px;}}}
}
</style>
实现要点:
- 不同节点类型使用不同的形状(圆形、矩形、菱形)
- 通过
attrs.body
设置节点样式 - 使用 X6 的 Dnd 插件实现拖拽功能
3. 节点右键菜单
为节点添加右键菜单,提供删除等操作:
// ProcessCanvas.vue
export default {setup() {// 显示右键菜单const showContextMenu = (node, event) => {event.preventDefault()// 创建菜单const menu = document.createElement('div')menu.innerHTML = `<div class="context-menu"><div class="menu-item" data-action="delete">删除节点</div></div>`// 设置菜单位置menu.style.cssText = `position: fixed;left: ${event.clientX}px;top: ${event.clientY}px;z-index: 9999;background: white;border: 1px solid #ddd;border-radius: 4px;box-shadow: 0 2px 8px rgba(0,0,0,0.1);`document.body.appendChild(menu)// 处理菜单点击menu.addEventListener('click', (e) => {const action = e.target.dataset.actionif (action === 'delete') {graph.value.removeNode(node)emit('nodeSelected', null) // 清空选中状态}document.body.removeChild(menu)})// 点击外部关闭菜单const closeMenu = (e) => {if (!menu.contains(e.target)) {document.body.removeChild(menu)document.removeEventListener('click', closeMenu)}}setTimeout(() => {document.addEventListener('click', closeMenu)}, 100)}// 监听右键事件graph.value.on('node:contextmenu', ({ node, e }) => {showContextMenu(node, e)})return {showContextMenu}}
}
关键点:
- 使用原生 DOM 创建菜单
- 根据鼠标位置定位菜单
- 删除节点后清空选中状态
4. 导入导出功能
实现流程图的保存和加载:
// ProcessToolbar.vue - 工具栏
export default {setup() {// 导出为 JSONconst exportJSON = () => {const data = graph.value.toJSON()// 创建下载链接const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'})const url = URL.createObjectURL(blob)const link = document.createElement('a')link.href = urllink.download = `process-${Date.now()}.json`link.click()URL.revokeObjectURL(url)}// 导入 JSONconst importJSON = () => {const input = document.createElement('input')input.type = 'file'input.accept = '.json'input.onchange = (e) => {const file = e.target.files[0]if (!file) returnconst reader = new FileReader()reader.onload = (event) => {try {const data = JSON.parse(event.target.result)// 清空画布并导入数据graph.value.clearCells()graph.value.fromJSON(data)ElMessage.success('导入成功')} catch (error) {ElMessage.error('导入失败:' + error.message)}}reader.readAsText(file)}input.click()}// 导出为图片const exportImage = async () => {try {const dataURL = await graph.value.exportPNG('process-diagram', {backgroundColor: '#f5f5f5',padding: 20})// 下载图片const link = document.createElement('a')link.href = dataURLlink.download = `process-${Date.now()}.png`link.click()ElMessage.success('导出成功')} catch (error) {ElMessage.error('导出失败')}}return {exportJSON,importJSON,exportImage}}
}
核心方法:
toJSON()
: 将画布数据转换为 JSONfromJSON()
: 从 JSON 数据恢复画布exportPNG()
: 导出为图片格式
常见问题与解决方案
1. 内存泄漏问题
问题:组件销毁时 X6 实例没有正确清理。
解决方案:
// 在组件销毁时清理资源
onUnmounted(() => {if (graph.value) {graph.value.dispose() // 销毁 X6 实例}
})
2. 响应式数据优化
问题:将整个 graph 实例设为响应式会影响性能。
解决方案:
// ❌ 错误做法
const graph = ref(new Graph(...))// ✅ 正确做法
const graph = shallowRef(null) // 使用 shallowRef
const currentNode = ref(null) // 只将必要的状态设为响应式
3. 连接点不显示
问题:节点的连接点有时不显示。
解决方案:
// 在节点配置中确保连接点可见
ports: {groups: {default: {attrs: {circle: {style: { visibility: 'visible' } // 关键设置}}}}
}
4. 删除节点后右侧配置未清空
问题:删除节点后,右侧属性配置面板仍显示已删除节点的信息。
解决方案:
// 删除节点时清空选中状态
const deleteNode = (node) => {graph.value.removeNode(node)emit('nodeSelected', null) // 清空当前选中节点
}
学习要点总结
1. 核心技术栈
- Vue 3: 使用 Composition API 组织代码逻辑
- AntV X6: 专业的图编辑引擎,功能强大
- Element Plus: 提供 UI 组件支持
2. 关键实现步骤
- 初始化画布: 配置 X6 实例,设置网格、缩放、连线规则
- 节点拖拽: 定义不同类型节点,实现从面板到画布的拖拽
- 事件处理: 监听节点点击、右键菜单等交互事件
- 数据持久化: 实现流程图的导入导出功能
3. 开发建议
- 使用
shallowRef
而不是ref
来存储 X6 实例 - 组件销毁时记得调用
graph.dispose()
清理资源 - 合理设置连接验证规则,避免无效连接
- 删除节点时要清空选中状态
4. 扩展方向
- 添加更多节点类型(网关、子流程等)
- 实现撤销/重做功能
- 支持节点分组和折叠
- 添加流程验证功能
参考文档:
https://juejin.cn/post/7516176289030291508#heading-5
src/views/features/process/index.vue · 宋开心/KolaAdmin - Gitee.com
Antv x6-CSDN博客