从 0 到 1 搭一个可用的 Vue Flow 工作流编排器(含下载/加载/自动布局) 关键词Vue Flow、流程编排、可视化编辑器、Vue3、TypeScript、Dagre、前端工程化这是什么从哪来的这个 Demo 脱胎于开源项目gijela一套 AI 智能体管理后台中的工作流编排模块经过剥离和精简后整理成了可独立运行的最小案例。gijela 主仓 GitHubhttps://github.com/wojiaozhangtudou/gijela Giteehttps://gitee.com/zhangjq123/gijelaDemo 在主仓的位置原始编辑器gijela-bloom/gijela-bloom-pistol/src/views/FlowEditor.vue独立 DemoVue Flow 案例/demo/src/views/FlowEditor.vuegijela 的完整工程除了这个编辑器之外还包含后端Spring Boot 多模块、聊天应用、AI Skill 插件等这个 Demo 只抽取了前端画布核心部分去掉了权限、状态管理、接口依赖让你可以直接看到最干净的 Vue Flow 实现方式。这个案例能做什么当前版本已经是可演示 可二开的状态功能包括左侧节点库拖拽建点start / llm / end 三种类型四方向连接点上/下/左/右悬停显示连线创建、选中、删除、端点拖拽修改右侧面板显示节点/连线详情Dagre 自动布局从左到右方向工作流 JSON 一键下载选择本地 JSON 文件加载JSON 预览弹窗页面内 Toast 通知替代浏览器 alert一句话不仅能画图还能稳定保存/恢复图。本地启动先 clone 仓库然后在仓库根目录执行# Windows / PowerShell从仓库根目录开始Set-Location.\Vue Flow 案例\demopnpm install pnpm dev浏览器打开http://localhost:5173即可看到效果。Node.js 建议 18包管理器用 pnpm。踩过哪些坑实现细节全记录1坐标错位 → 用 screenToFlowCoordinate 转换问题拖拽节点到画布后位置经常偏缩放画布后更明显。原因浏览器事件的坐标是屏幕坐标Vue Flow 的画布有自己的坐标系受缩放/平移影响两者不能直接混用。解决方案const{screenToFlowCoordinate,addNode}useVueFlow();constonDrop(event:DragEvent){if(!event.dataTransfer?.types.includes(biz-node-type))return;constnodeTypeevent.dataTransfer.getData(biz-node-type)asBizNodeType;// 关键屏幕坐标 → 画布坐标constpositionscreenToFlowCoordinate({x:event.clientX,y:event.clientY,});addNode({id:node-${Date.now()},label:nodeType,position,type:biz-node,data:{type:nodeType},});};无论画布怎么缩放平移落点都是精确的。2连线格式混乱 → 统一持久化格式问题Vue Flow 内部的Edge对象用source/target字段但持久化到 JSON 时如果直接把内部对象序列化以后的格式就锁死在 Vue Flow 的实现细节上了升级或迁移都很麻烦。解决方案定义独立的持久化类型和 Vue Flow 的内部类型解耦// types/flow.tsexportinterfaceDefinitionEdge{id:string;sourceNodeId:string;// ← 持久化字段不用 sourcetargetNodeId:string;// ← 持久化字段不用 targetlabel?:string;}exportinterfaceWorkflowDefinition{nodes:DefinitionNode[];edges:DefinitionEdge[];}创建连线时做规则校验再转成持久化格式constonConnectEdge(params:Connection){const{source,target}params;// 禁止自环if(sourcetarget){showToast(❌ 不支持自环连接,error);return;}// source → target 去重constexistsedges.value.some((e)e.sourcesourcee.targettarget);if(exists){showToast(⚠️ 该连接已存在,warning);return;}addEdges([{id:edge-${Date.now()},source,target,label:${source}→${target},}]);};导出时主动做字段映射functionbuildDefinition():WorkflowDefinition{return{nodes:nodes.value.map((n)({id:n.id,label:String(n.label??),position:n.position,data:n.data,})),edges:edges.value.map((e)({id:e.id,sourceNodeId:e.source,// ← Vue Flow 内部 → 持久化字段targetNodeId:e.target,label:e.label,})),};}3导入文件崩溃 → 分阶段 nextTick 串行加载问题直接一次性调setNodes和setEdges会触发Cannot read properties of undefined (reading source)原因Vue Flow 内部渲染时会访问节点和边的引用。如果两者同帧更新Vue Flow 渲染边时节点实例可能还没就绪拿到的就是undefined。解决方案分两个渲染帧加载中间用nextTick隔开asyncfunctionapplyDefinition(def:WorkflowDefinition){// 1. 清空画布和选中状态setNodes([]);setEdges([]);selectedNode.valuenull;selectedEdge.valuenull;// 2. 先加载节点setNodes(def.nodes.map((n)({id:n.id,label:n.label,position:n.position,type:biz-node,data:n.data,})));// 3. 等 Vue 完成这一帧渲染awaitnextTick();// 4. 再加载连线setEdges(def.edges.map((e)({id:e.id,source:e.sourceNodeId,// ← 持久化字段 → Vue Flow 内部target:e.targetNodeId,label:e.label,})));// 5. 等待渲染完成后适应视图awaitnextTick();fitView({padding:0.1,duration:300});showToast(✅ 加载成功,success);}为什么有效nextTick等待 Vue 将节点渲染进 DOMVue Flow 在下一帧才去建立边的引用此时节点已经存在不会再出现undefined。4连接点体验差 → Handle 悬停时才显示问题四个方向的 Handle 全部常驻显示节点看起来很乱隐藏后拖拽时又容易找不到。解决方案FlowNode.vuetemplate div classflow-node :classflow-node--${data.type} mouseenterhovering true mouseleavehovering false span classnode-label{{ data.type }}/span Handle typetarget :positionPosition.Top idtop / Handle typesource :positionPosition.Bottom idbottom / Handle typetarget :positionPosition.Left idleft / Handle typesource :positionPosition.Right idright / /div /template script setup langts import { Handle, Position } from vue-flow/core; defineProps{ data: { type: string } }(); /script style scoped /* Handle 默认透明 */ :deep(.vue-flow__handle) { opacity: 0; transition: opacity 0.15s; } /* 悬停时显示 */ .flow-node:hover :deep(.vue-flow__handle) { opacity: 1; } /* 按类型区分颜色 */ .flow-node { padding: 10px 16px; border-radius: 6px; border-left: 4px solid #d9d9d9; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.1); } .flow-node--start { border-left-color: #52c41a; } .flow-node--llm { border-left-color: #1677ff; } .flow-node--end { border-left-color: #ff4d4f; } /style注意Handle 的position属性必须明确传用Position枚举不要省略否则 Vue Flow 内部定位会出问题。5保存 → 一键下载 JSONfunctiondownloadDefinition(){constdefinitionbuildDefinition();constjsonJSON.stringify(definition,null,2);constblobnewBlob([json],{type:application/json});consturlURL.createObjectURL(blob);constlinkdocument.createElement(a);link.hrefurl;link.downloadworkflow-${newDate().toISOString().slice(0,10)}.json;link.click();URL.revokeObjectURL(url);showToast(✅ 下载成功,success);}6自动布局 → Dagre 计算节点坐标手动排列节点很费时接入 Dagre 之后一键整理成 LR 方向的层次布局。pnpmadddagre types/dagreimportdagrefromdagre;constNODE_WIDTH160;constNODE_HEIGHT60;functionapplyDagreLayout(){constgnewdagre.graphlib.Graph();g.setGraph({rankdir:LR,nodesep:60,ranksep:80});g.setDefaultEdgeLabel(()({}));// 注册节点尺寸nodes.value.forEach((node){g.setNode(node.id,{width:NODE_WIDTH,height:NODE_HEIGHT});});// 注册边edges.value.forEach((edge){g.setEdge(edge.source,edge.target);});// 执行布局计算dagre.layout(g);// 将计算结果写回节点坐标setNodes(nodes.value.map((node){const{x,y}g.node(node.id);return{...node,position:{x:x-NODE_WIDTH/2,// Dagre 返回的是中心点转换为左上角y:y-NODE_HEIGHT/2,},};}));// 布局后自适应视图nextTick(()fitView({padding:0.1,duration:300}));showToast(✅ 自动布局完成,success);}注意布局后如果想保存坐标直接导出即可Dagre 修改的是nodes.value里的positionbuildDefinition()读的就是这个值不需要额外处理。7加载 → 文件选择 校验 分阶段应用asyncfunctionloadFromFile(){constinputdocument.createElement(input);input.typefile;input.accept.json;input.onchangeasync(e){constfile(e.targetasHTMLInputElement).files?.[0];if(!file)return;try{consttextawaitfile.text();constrawDefJSON.parse(text);// 格式校验validateDefinition(rawDef);// 分阶段加载awaitapplyDefinition(rawDef);}catch(err:any){showToast(❌ 加载失败${err.message},error);}};input.click();}functionvalidateDefinition(def:any):void{if(!Array.isArray(def?.nodes))thrownewError(缺少 nodes 字段);if(!Array.isArray(def?.edges))thrownewError(缺少 edges 字段);def.edges.forEach((edge:any,idx:number){if(!edge.sourceNodeId||!edge.targetNodeId){thrownewError(第${idx1}条边缺少 sourceNodeId/targetNodeId);}});}8Toast 通知 → 替代 alertconsttoastsrefArray{id:string;message:string;type:success|error|warning}([]);lettoastTimer:ReturnTypetypeofsetTimeout|nullnull;functionshowToast(message:string,type:success|error|warningsuccess){constidtoast-${Date.now()};toasts.value.push({id,message,type});toastTimersetTimeout((){toasts.valuetoasts.value.filter((t)t.id!id);},3000);}// 组件卸载时清理定时器onBeforeUnmount((){if(toastTimer)clearTimeout(toastTimer);});模板渲染TransitionGroup nametoast tagdiv classtoast-container div v-fortoast in toasts :keytoast.id classtoast :classtoast--${toast.type} {{ toast.message }} /div /TransitionGroupCSS.toast-container{position:fixed;bottom:24px;right:24px;display:flex;flex-direction:column;gap:8px;z-index:9999;}.toast{padding:10px 18px;border-radius:6px;color:#fff;font-size:14px;box-shadow:0 2px 8pxrgba(0,0,0,0.15);}.toast--success{background:#52c41a;}.toast--error{background:#ff4d4f;}.toast--warning{background:#faad14;}.toast-enter-active, .toast-leave-active{transition:all 0.3s ease;}.toast-enter-from, .toast-leave-to{opacity:0;transform:translateY(20px);}常见问题Q导入后节点位置全挤在一起加载完成后调fitView()自动适配视图或提前用 Dagre 布局再导出。Q连线删不掉确认是否绑定了edge-click事件constonEdgeClick({edge}:{edge:Edge}){removeEdges([edge.id]);};QDagre 布局后导出下次导入位置还是乱Dagre 会直接修改nodes.value里的position导出时直接读nodes.value即可拿到布局后的坐标不需要额外保存。QHandle 拖拽时不响应检查position属性是否用了Position枚举// ✓ 正确Handle typesource:positionPosition.Right/// ✗ 字符串有时不被识别Handle typesourcepositionright/性能建议节点 50 时防抖拖拽和连接事件加 debounce避免频繁触发重渲染Web WorkerDagre 计算量大时放进 Worker不阻塞主线程按需渲染超大图配合视口裁剪只渲染可见区域节点import{debounce}fromlodash-es;constonConnectEdgedebounce((params:Connection){validateAndAddEdge(params);},150);项目结构Vue Flow 案例/ ├─ README.md └─ demo/ ├─ package.json └─ src/ ├─ views/ │ └─ FlowEditor.vue ← 主编辑器全部核心逻辑 ├─ components/ │ └─ FlowNode.vue ← 自定义节点组件 └─ types/ └─ flow.ts ← 类型定义Demo 与 gijela 原工程的功能对比功能本 Demogijela 原工程基础画布编辑✅✅保存 / 加载✅✅自动布局Dagre✅✅权限控制❌✅节点参数动态表单❌✅运行态状态同步❌✅版本快照❌规划中后端任务引擎联调❌✅这个 Demo 是单机 MVP原工程是生产级实现可以按需参考。后续可以做什么基础打好后推荐按这个顺序扩展撤销 / 重做基于快照栈或使用useUndoRedo()Composable连接规则按节点类型限制连线如 start 不能作目标节点节点参数编辑点击节点弹出配置面板支持动态 Schema 表单运行态高亮后端推送执行进度节点实时变色多人协作WebSocket 广播节点变更多用户同时编辑小结做工作流编辑器踩最多坑的通常不是画图这件事本身而是坐标系换算没做对拖哪偏哪导入/导出格式没解耦耦合了 Vue Flow 内部实现加载时序没处理好一导入就崩这个 Demo 把这几个关键点都踩过一遍整理成了可直接运行的案例。如果你也在做类似的东西可以直接从这里起步欢迎 Star 和交流GitHubhttps://github.com/wojiaozhangtudou/gijelaGiteehttps://gitee.com/zhangjq123/gijela