深圳网站工作室关于做电影的网站设计
深圳网站工作室,关于做电影的网站设计,软件开发项目管理的分析,品牌网络推广公司Vue2项目实战#xff1a;用AntV X6打造可拖拽流程图编辑器#xff08;附完整代码#xff09;
最近在重构一个内部审批系统#xff0c;产品经理提了个需求#xff1a;需要一个能让业务人员自己画流程图的模块。这可不是简单的静态展示#xff0c;用户得能拖拽节点、自由连…Vue2项目实战用AntV X6打造可拖拽流程图编辑器附完整代码最近在重构一个内部审批系统产品经理提了个需求需要一个能让业务人员自己画流程图的模块。这可不是简单的静态展示用户得能拖拽节点、自由连线、随时调整。市面上现成的方案要么太重要么定制性太差最后我们决定基于 AntV X6 在现有的 Vue2 项目里自己动手实现。如果你也正面临类似的需求比如要做一个工作流设计器、系统架构图工具或者任何需要可视化编排的界面那么这篇文章就是为你准备的。我会带你从零开始一步步搭建一个功能完备的流程图编辑器过程中遇到的坑和优化点也会毫无保留地分享出来。整个实现思路清晰代码可直接复用让我们开始吧。1. 环境搭建与项目初始化在开始编码之前我们需要确保开发环境准备就绪。虽然现在 Vue3 是主流但很多存量项目依然运行在 Vue2 上本次实战就基于 Vue2 Element UI 的技术栈。AntV X6 是一个功能强大的图编辑引擎它不依赖特定框架但与 Vue 的集成度很高。首先在你的 Vue2 项目中安装必要的依赖。打开终端进入项目根目录执行npm install antv/x6 --save npm install antv/x6-vue-shape --saveantv/x6-vue-shape这个包至关重要它允许我们在 X6 的画布中使用 Vue 组件作为节点这为高度自定义节点UI提供了可能。接下来安装一些常用的 X6 插件它们能极大提升编辑器的用户体验npm install antv/x6-plugin-clipboard antv/x6-plugin-history antv/x6-plugin-keyboard antv/x6-plugin-selection antv/x6-plugin-snapline antv/x6-plugin-transform antv/x6-plugin-dnd --save提示antv/x6-plugin-dnd是实现从侧边栏拖拽节点到画布的核心插件务必安装。安装完成后我习惯在项目的src/utils目录下创建一个x6-init.js文件用于集中初始化和配置 X6 的 Graph 实例。这样做的好处是配置与组件逻辑分离后期维护和复用更方便。一个基础的画布配置可能长这样// src/utils/x6-init.js import { Graph } from antv/x6; export function initGraph(container) { const graph new Graph({ container: container, width: 100%, height: 600, grid: { size: 10, // 网格大小 visible: true, // 显示网格 }, panning: { enabled: true, eventTypes: [leftMouseDown, mouseWheel], // 支持鼠标拖拽和滚轮平移 }, mousewheel: { enabled: true, modifiers: [ctrl, meta], // 按住Ctrl或Cmd键滚动缩放 zoomAtMousePosition: true, // 以鼠标位置为中心缩放 }, connecting: { snap: true, // 开启自动吸附 allowBlank: false, // 不允许连接到空白点 allowLoop: false, // 不允许自连接 highlight: true, // 高亮可连接点 connector: rounded, // 连线为圆角样式 router: manhattan, // 使用曼哈顿路由直角连线 }, background: { color: #fafafa, // 画布背景色 }, }); return graph; }这个配置开启了网格、画布平移缩放、连线的自动吸附和高亮是流程图编辑器的基础骨架。接下来我们将在 Vue 组件中引入并使用它。2. 构建编辑器核心布局与画布一个典型的流程图编辑器界面通常分为三个区域顶部的工具栏、左侧的节点素材库Stencil、以及中央的主画布。我们将使用 Element UI 的el-container布局组件快速搭建这个结构。首先在 Vue 单文件组件中引入必要的模块并搭建模板框架template div classflow-editor !-- 顶部工具栏 -- el-header height60px classeditor-header el-button-group el-button sizesmall iconel-icon-plus clickhandleZoomIn放大/el-button el-button sizesmall iconel-icon-minus clickhandleZoomOut缩小/el-button el-button sizesmall iconel-icon-refresh clickhandleZoomToFit适应画布/el-button el-divider directionvertical/el-divider el-button sizesmall iconel-icon-download clickhandleExport导出/el-button el-button sizesmall iconel-icon-upload clickhandleImport导入/el-button /el-button-group div classtoolbar-right el-button typeprimary sizesmall iconel-icon-check clickhandleSave保存/el-button /div /el-header el-container classeditor-main !-- 左侧节点面板 -- el-aside width180px classnode-panel div classpanel-title基础节点/div div classnode-list div classnode-item mousedownstartDrag(start, $event) div classnode-shape start-node/div span开始/span /div div classnode-item mousedownstartDrag(task, $event) div classnode-shape task-node/div span任务/span /div div classnode-item mousedownstartDrag(decision, $event) div classnode-shape decision-node/div span判断/span /div div classnode-item mousedownstartDrag(end, $event) div classnode-shape end-node/div span结束/span /div /div /el-aside !-- 中央画布区域 -- el-main classcanvas-area div refgraphContainer classx6-graph-container/div /el-main /el-container /div /template script import { Graph, Dnd } from antv/x6; import { initGraph } from /utils/x6-init; // 引入刚才写的初始化函数 import antv/x6-vue-shape; export default { name: FlowEditor, data() { return { graph: null, dnd: null, // 拖拽插件实例 }; }, mounted() { this.initEditor(); }, methods: { initEditor() { // 初始化画布 const container this.$refs.graphContainer; this.graph initGraph(container); // 初始化拖拽插件 this.dnd new Dnd({ target: this.graph, scaled: false, // 拖拽时节点不缩放 }); // 注册自定义节点后续会详细展开 this.registerCustomNodes(); // 绑定画布事件后续会详细展开 this.bindGraphEvents(); }, // 其他方法... }, }; /script style scoped .editor-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4e7ed; background: #fff; } .node-panel { border-right: 1px solid #e4e7ed; background: #f8f9fa; padding: 10px; } .panel-title { font-weight: bold; margin-bottom: 15px; color: #303133; } .node-list { display: flex; flex-direction: column; gap: 15px; } .node-item { display: flex; flex-direction: column; align-items: center; cursor: move; padding: 8px; border-radius: 4px; transition: background-color 0.2s; } .node-item:hover { background-color: #ebedf0; } .node-shape { width: 60px; height: 40px; margin-bottom: 5px; } .start-node { background-color: #67c23a; border-radius: 20px; } .task-node { background-color: #409eff; border-radius: 6px; } .decision-node { background-color: #e6a23c; clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); } .end-node { background-color: #f56c6c; border-radius: 20px; } .canvas-area { padding: 0; position: relative; overflow: hidden; } .x6-graph-container { width: 100%; height: 100%; } /style这个布局已经具备了编辑器的基础形态。左侧的节点面板通过 CSS 绘制了不同形状的节点预览并通过mousedown事件为拖拽功能埋下了伏笔。中央的graphContainerdiv 将作为 X6 画布的挂载点。3. 实现节点拖拽与连线交互拖拽和连线是流程图编辑器的灵魂。用户从左侧面板拖出一个节点放到画布上然后在节点之间建立连接这个体验必须流畅自然。X6 的Dnd拖拽插件和内置的连接机制让这一切变得简单。3.1 注册自定义节点在将节点拖入画布前我们需要告诉 X6 这些节点长什么样、有什么属性。X6 提供了Graph.registerNode方法来注册自定义节点。我们在initEditor方法中调用一个专门的注册函数methods: { registerCustomNodes() { // 1. 定义节点的连接桩Ports配置 const portsConfig { groups: { top: { position: top, attrs: { circle: { r: 4, magnet: true, stroke: #5F95FF, strokeWidth: 1, fill: #fff } } }, right: { position: right, attrs: { circle: { r: 4, magnet: true, stroke: #5F95FF, strokeWidth: 1, fill: #fff } } }, bottom: { position: bottom, attrs: { circle: { r: 4, magnet: true, stroke: #5F95FF, strokeWidth: 1, fill: #fff } } }, left: { position: left, attrs: { circle: { r: 4, magnet: true, stroke: #5F95FF, strokeWidth: 1, fill: #fff } } }, }, items: [ { group: top, id: top-port }, { group: right, id: right-port }, { group: bottom, id: bottom-port }, { group: left, id: left-port }, ], }; // 2. 注册“开始”节点圆形 Graph.registerNode(flow-start, { inherit: circle, // 继承基础圆形 width: 60, height: 60, attrs: { body: { fill: #67c23a, stroke: #5daf34, strokeWidth: 2, }, label: { text: 开始, fill: #fff, fontSize: 14, fontWeight: bold, }, }, ports: { ...portsConfig }, // 应用连接桩配置 }); // 3. 注册“任务”节点矩形 Graph.registerNode(flow-task, { inherit: rect, width: 100, height: 60, attrs: { body: { fill: #409eff, stroke: #337ecc, strokeWidth: 2, rx: 8, // 圆角 ry: 8, }, label: { text: 任务, fill: #fff, fontSize: 14, refX: 0.5, // 标签居中 refY: 0.5, textAnchor: middle, textVerticalAnchor: middle, }, }, ports: { ...portsConfig }, }); // 类似地注册“判断”菱形和“结束”圆形节点... Graph.registerNode(flow-decision, { inherit: polygon, points: 0,50 50,0 100,50 50,100, // 菱形的四个点 attrs: { body: { fill: #e6a23c, stroke: #cf9236, strokeWidth: 2, }, label: { text: 判断, fill: #fff, fontSize: 14, refX: 0.5, refY: 0.5, textAnchor: middle, textVerticalAnchor: middle, }, }, ports: { ...portsConfig }, }); }, }3.2 实现拖拽投放逻辑节点注册好后我们需要实现左侧面板的拖拽事件。当用户在面板的节点上按下鼠标时startDrag方法会被触发它需要创建一个对应的 X6 节点模型并交给Dnd插件开始拖拽。methods: { startDrag(type, e) { // 根据节点类型创建对应的节点数据模型 let nodeConfig; switch (type) { case start: nodeConfig { shape: flow-start, label: 开始, data: { nodeType: start }, // 自定义业务数据 }; break; case task: nodeConfig { shape: flow-task, label: 新任务, data: { nodeType: task, config: {} }, }; break; case decision: nodeConfig { shape: flow-decision, label: 条件判断, data: { nodeType: decision }, }; break; case end: nodeConfig { shape: flow-start, // 复用形状用样式区分 attrs: { body: { fill: #f56c6c, stroke: #dd6161 }, label: { text: 结束 }, }, data: { nodeType: end }, }; break; default: return; } // 创建节点实例 const node this.graph.createNode(nodeConfig); // 开始拖拽。e 是原生的鼠标事件对象 this.dnd.start(node, e.nativeEvent || e); }, }当用户将节点拖拽到画布区域并释放鼠标时Dnd插件会自动处理节点的放置。至此拖拽功能就完成了。用户现在可以从左侧拖出各种节点到画布上。3.3 配置连线与交互反馈为了让连线体验更好我们需要对画布的连接行为进行更细致的配置并添加一些交互反馈。回到src/utils/x6-init.js中的initGraph函数我们可以增强connecting配置connecting: { snap: { radius: 24 }, // 吸附半径设为24像素 allowBlank: false, allowLoop: false, allowNode: false, // 是否允许连接到节点本身非连接桩通常设为false allowEdge: false, // 是否允许连接到边通常设为false highlight: true, connector: { name: rounded, args: { radius: 8, // 圆角半径 }, }, router: { name: manhattan, args: { step: 10, // 路由步长影响连线的拐点位置 }, }, // 连接桩验证可以控制哪些桩可以连接 validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) { // 禁止连接到自身 if (sourceView targetView) { return false; } // 可以在这里添加更多业务规则比如特定类型的节点不能相连 return true; }, },此外为了提升用户体验我们可以监听画布事件为节点和连线添加悬停效果methods: { bindGraphEvents() { const graph this.graph; // 节点鼠标移入显示操作工具 graph.on(node:mouseenter, ({ node }) { // 添加一个删除按钮工具 node.addTools({ name: button-remove, args: { x: 100%, y: 0, offset: { x: -10, y: 10 }, }, }); }); // 节点鼠标移出移除工具 graph.on(node:mouseleave, ({ node }) { node.removeTools(); }); // 连线鼠标移入显示可编辑的顶点和删除按钮 graph.on(edge:mouseenter, ({ edge }) { edge.addTools([ { name: vertices, // 显示顶点可拖动调整连线路径 }, { name: button-remove, // 删除按钮 args: { distance: 30, }, }, ]); }); graph.on(edge:mouseleave, ({ edge }) { edge.removeTools(); }); // 监听连接创建事件可以在这里为连线添加标签或自定义样式 graph.on(edge:connected, ({ edge }) { edge.setAttrs({ line: { stroke: #5F95FF, strokeWidth: 2, targetMarker: { name: block, width: 12, height: 8, }, }, }); // 可以在这里触发业务逻辑比如更新后端数据关系 console.log(连线已创建:, edge); }); }, }现在你的编辑器已经具备了核心的拖拽和连线能力并且有了不错的交互反馈。4. 集成功能插件与节点编辑一个专业的编辑器离不开撤销重做、框选、对齐线、快捷键等增强功能。X6 将这些功能模块化成了插件我们可以按需引入。同时双击节点进行属性编辑也是一个非常常见的需求。4.1 引入并配置核心插件在组件的initEditor方法中在创建graph实例后我们批量引入并启用插件import { Keyboard, Selection, Snapline, Clipboard, History, Transform, // 用于节点变换缩放、旋转 } from antv/x6-plugin-keyboard; // 注意实际应从各自插件包导入 // 在 initEditor 方法中初始化画布后 initEditor() { const graph this.graph; // 假设graph已初始化 // 使用插件 graph .use( new Selection({ enabled: true, rubberband: true, // 启用框选 showNodeSelectionBox: true, modifiers: shift, // 按Shift键多选 }) ) .use( new Snapline({ enabled: true, // 启用对齐线 }) ) .use( new Clipboard({ enabled: true, }) ) .use( new History({ enabled: true, beforeAddCommand: (event, args) { // 可以在这里过滤不需要记录的历史命令 return true; }, }) ) .use( new Transform({ resizing: true, // 允许调整大小 rotating: true, // 允许旋转 }) ) .use( new Keyboard({ enabled: true, global: true, // 全局快捷键 }) ); // 绑定常用快捷键 this.bindKeyboardShortcuts(graph); }然后实现bindKeyboardShortcuts方法为编辑器添加快捷键支持bindKeyboardShortcuts(graph) { // 删除节点/边 graph.bindKey([delete, backspace], () { const cells graph.getSelectedCells(); if (cells.length) { graph.removeCells(cells); } }); // 复制粘贴 (CtrlC / CtrlV) graph.bindKey(ctrlc, () { const cells graph.getSelectedCells(); if (cells.length) { graph.copy(cells); } return false; // 阻止浏览器默认行为 }); graph.bindKey(ctrlv, () { if (!graph.isClipboardEmpty()) { const cells graph.paste(); graph.cleanSelection(); graph.select(cells); } return false; }); // 撤销重做 (CtrlZ / CtrlY) graph.bindKey(ctrlz, () { if (graph.canUndo()) { graph.undo(); } return false; }); graph.bindKey(ctrly, () { if (graph.canRedo()) { graph.redo(); } return false; }); // 全选 (CtrlA) graph.bindKey(ctrla, () { const cells graph.getCells(); graph.select(cells); return false; }); }4.2 实现节点属性编辑抽屉当用户双击一个节点时我们期望弹出一个表单来编辑节点的标签、颜色等属性。这里我们用 Element UI 的el-drawer抽屉组件来实现。首先在模板中添加抽屉组件template !-- ... 其他模板代码 ... -- el-drawer title节点属性 :visible.synceditDrawerVisible :directionrtl size30% :before-closehandleDrawerClose el-form :modelcurrentNodeData label-width80px v-ifcurrentNodeData el-form-item label节点名称 el-input v-modelcurrentNodeData.label changeupdateNodeLabel/el-input /el-form-item el-form-item label填充颜色 el-color-picker v-modelcurrentNodeData.fillColor changeupdateNodeColor/el-color-picker /el-form-item el-form-item label业务数据 v-ifcurrentNodeData.data el-input typetextarea :rows3 v-modelcurrentNodeData.data.remark placeholder请输入节点备注 changeupdateNodeData /el-input /el-form-item el-form-item el-button typeprimary clicksaveNodeProperties保存/el-button el-button clickeditDrawerVisible false取消/el-button /el-form-item /el-form /el-drawer /template然后在组件的 data 和 methods 中补充相关逻辑data() { return { // ... 其他数据 ... editDrawerVisible: false, currentNode: null, // 当前正在编辑的X6节点实例 currentNodeData: null, // 当前节点的数据模型用于表单绑定 }; }, methods: { // ... 其他方法 ... // 在 bindGraphEvents 中监听节点双击事件 bindGraphEvents() { const graph this.graph; // ... 其他事件监听 ... graph.on(node:dblclick, ({ node }) { this.currentNode node; // 从X6节点实例中提取数据用于表单绑定 this.currentNodeData { id: node.id, label: node.getAttrByPath(label/text) || node.label || , fillColor: node.getAttrByPath(body/fill) || #409eff, data: node.getData() || {}, // 获取节点上挂载的自定义业务数据 }; this.editDrawerVisible true; }); }, // 更新节点标签 updateNodeLabel(label) { if (this.currentNode) { // 更新节点上的标签属性 this.currentNode.setAttrByPath(label/text, label); // 也可以更新节点的label属性如果用到 this.currentNode.label label; } }, // 更新节点颜色 updateNodeColor(color) { if (this.currentNode) { this.currentNode.setAttrByPath(body/fill, color); } }, // 更新自定义业务数据 updateNodeData() { if (this.currentNode this.currentNodeData.data) { this.currentNode.setData(this.currentNodeData.data); } }, // 保存所有属性变更 saveNodeProperties() { // 这里可以整合所有更新并可能触发一个保存到后端的操作 this.$message.success(节点属性已更新); this.editDrawerVisible false; }, handleDrawerClose(done) { // 关闭前可以做一些确认 this.$confirm(确定放弃未保存的修改吗, 提示, { confirmButtonText: 确定, cancelButtonText: 取消, type: warning, }) .then(() { done(); }) .catch(() {}); }, },现在用户双击画布上的任何节点右侧都会滑出一个抽屉可以实时编辑节点的外观和业务数据。所有修改都会立即反映在画布上。5. 数据持久化与高级功能拓展编辑器功能基本成型后我们需要考虑如何将用户绘制的流程图保存下来以及如何实现一些更高级的功能如导入/导出、子流程、批量操作等。5.1 流程图数据的序列化与反序列化X6 提供了graph.toJSON()和graph.fromJSON()方法可以非常方便地将整个画布的状态包括节点、连线、位置、样式等转换为 JSON 数据并从中恢复。methods: { // 获取当前流程图数据用于保存 getGraphData() { const graphData this.graph.toJSON(); // 你可以对数据进行精简或加密处理 const simplifiedData { cells: graphData.cells, // 可以添加一些元信息如版本、创建时间等 meta: { version: 1.0, savedAt: new Date().toISOString(), }, }; return JSON.stringify(simplifiedData, null, 2); // 格式化为美观的JSON字符串 }, // 从数据加载流程图 loadGraphData(jsonString) { try { const data JSON.parse(jsonString); // 清空当前画布 this.graph.clearCells(); // 从JSON恢复 this.graph.fromJSON(data); // 恢复后可能需要重新居中或缩放视图 this.graph.centerContent(); this.$message.success(流程图加载成功); } catch (error) { console.error(加载流程图数据失败:, error); this.$message.error(数据格式错误加载失败); } }, // 在工具栏按钮中调用 handleSave() { const data this.getGraphData(); // 这里可以调用API保存到服务器或使用浏览器的本地存储 localStorage.setItem(flowchart_backup, data); this.$message.success(已保存到本地缓存); // 或者触发一个下载 // this.downloadJSON(data, my-flowchart.json); }, handleImport() { // 这里可以触发一个文件选择器读取本地JSON文件 // 模拟从本地存储加载 const savedData localStorage.getItem(flowchart_backup); if (savedData) { this.loadGraphData(savedData); } else { this.$message.warning(未找到本地缓存数据); } }, // 导出为图片 handleExport() { this.graph.toPNG( (dataUri) { // 创建一个临时链接触发下载 const link document.createElement(a); link.download flowchart.png; link.href dataUri; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, { backgroundColor: #fafafa, padding: { top: 20, right: 20, bottom: 20, left: 20, }, quality: 1, // 图片质量 } ); }, }5.2 实现子流程与节点分组对于复杂的流程图将相关节点分组管理非常有用。X6 的Group节点可以满足这个需求。我们可以注册一个分组节点并允许用户框选多个节点后将其放入一个组内。首先注册一个分组类型的节点registerCustomNodes() { // ... 注册其他节点 ... // 注册分组节点 Graph.registerNode(flow-group, { inherit: rect, markup: [ { tagName: rect, selector: body, }, { tagName: text, selector: label, }, ], attrs: { body: { fill: rgba(149, 179, 215, 0.1), stroke: rgba(149, 179, 215, 0.8), strokeWidth: 1, strokeDasharray: 5,5, // 虚线边框 pointerEvents: none, // 分组本身不拦截事件 }, label: { text: 分组, refX: 10, refY: 10, fontSize: 12, fill: #666, }, }, // 分组节点通常不需要连接桩 ports: false, }); }然后在工具栏添加一个“创建分组”的按钮并实现其逻辑methods: { // 创建分组 createGroup() { const selectedCells this.graph.getSelectedCells(); const nodes selectedCells.filter(cell cell.isNode() cell.shape ! flow-group); if (nodes.length 2) { this.$message.warning(请至少选择两个节点以创建分组); return; } // 计算包围盒确定分组的位置和大小 const bbox this.graph.getCellsBBox(nodes); const padding 20; const groupNode this.graph.createNode({ shape: flow-group, x: bbox.x - padding, y: bbox.y - padding, width: bbox.width padding * 2, height: bbox.height padding * 2, zIndex: -1, // 确保分组在子节点下方 data: { isGroup: true, children: nodes.map(node node.id), // 记录子节点ID }, }); // 将分组添加到画布 this.graph.addNode(groupNode); // 将选中的节点添加到分组中 nodes.forEach(node { node.addTo(groupNode); }); this.$message.success(已创建分组包含 ${nodes.length} 个节点); }, }5.3 性能优化与实战建议当流程图变得非常复杂时性能可能会成为问题。这里有几个实战中总结的优化建议虚拟渲染对于超大型图表可以考虑只渲染视口内的元素。X6 本身在这方面做了很多优化但自定义复杂节点时仍需注意。批量操作当需要同时添加或删除大量节点时使用graph.batchUpdate将操作包裹起来可以显著提升性能并确保操作原子性。this.graph.batchUpdate(() { for (let i 0; i 100; i) { this.graph.addNode({ ... }); } });简化节点定义避免在节点attrs中使用过于复杂的 SVG 滤镜或渐变它们会加重渲染负担。事件委托如果画布上有成千上万的节点为每个节点单独绑定事件监听器是不可取的。应尽量使用画布graph级别的事件监听然后通过event.target来判断具体是哪个节点。最后在项目实际集成时记得将编辑器的状态如缩放级别、视图中心点也一并保存这样用户下次打开时能恢复到完全相同的视图。可以将这些信息一并存入我们之前提到的 JSON 数据中。整个流程编辑器搭建下来代码量不小但模块清晰功能解耦得很好。从环境搭建、核心交互到插件集成和高级功能我们一步步实现了一个具备生产可用性的工具。最重要的是基于 X6 的强大和 Vue 的灵活你可以在此基础上轻松扩展出更多符合自身业务需求的特性比如节点状态标记、条件分支的验证、与后端流程引擎的对接等等。希望这个实战指南能为你打开思路节省大量摸索的时间。如果在实现过程中遇到具体问题不妨多翻阅 X6 的官方文档里面提供了非常丰富的 API 和示例。