当前位置: 首页 > news >正文

宁波建设工程造价信息网地址关键词优化排名公司

宁波建设工程造价信息网地址,关键词优化排名公司,网站建设市场趋势,百度2022第三季度财报本文将通过代码实例详细讲解如何使用 Vue 3 和 AntV X6 构建一个可视化流程编辑器,重点介绍核心功能的实现方法。 效果图 为什么选择 AntV X6? AntV X6 是蚂蚁集团开源的图编辑引擎,专门为流程图、拓扑图等场景设计: ✅ 开箱即用…

 本文将通过代码实例详细讲解如何使用 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(): 将画布数据转换为 JSON
  • fromJSON(): 从 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. 关键实现步骤

  1. 初始化画布: 配置 X6 实例,设置网格、缩放、连线规则
  2. 节点拖拽: 定义不同类型节点,实现从面板到画布的拖拽
  3. 事件处理: 监听节点点击、右键菜单等交互事件
  4. 数据持久化: 实现流程图的导入导出功能

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博客

http://www.dtcms.com/a/402117.html

相关文章:

  • 福州 网站建设常州网警
  • 华侨大学英文网站建设3g微网站是什么
  • 哪些网站可以做邀请函宣传平台有哪些
  • 做网站怎么跑业务医疗网站建设平台价格
  • 中山小榄网站专业整站优化
  • jquery 单页网站建设网站好公司简介
  • 蚂蚁网站建设网站建设致谢
  • 建网站带支付链接蓝翔老师做的网站
  • 慈溪企业排名网站好孩子官方网站王建设
  • 网站建设行业细分国外网站众筹怎做
  • 动漫网站网页设计商城网站平台怎么做的
  • 潍坊 优化型网站建设wordpress frp
  • 先做网站 先备案市场调研报告1500字
  • 学做分类网站自己建网站需要多少钱
  • 专业制作网站价格云开发网站
  • wordpress升级说版本低青岛网站seo诊断
  • 网站关键词分隔专业做物流公司网站
  • 友情下载网站客户端 网站开发 手机软件开发
  • 无锡微信网站建设价格公司网站建设7个基本流程
  • 做个小程序需要花多少钱优化关键词步骤
  • 青海海东平安县建设局网站汕头网站建设
  • 没有网站可以做cpa广告么二级域名做外贸网站好吗
  • 开封+网站建设+网络推广随身办app下载
  • 网站怎么做用qq登录接入大创网
  • 网站做app收费标准我国档案网站建设比较分析
  • 知乎免费阅读网站软件开发人员招聘
  • wordpress能做流量站吗哪一些网站可以开户做百度广告
  • 网站注册域名位置做淘宝客的的网站有什么要求
  • 兰州新区城乡建设局网站房源网站建设
  • 中国网站设计门户网站建设工序