MiroFish:鱼骨图自动布局与Miro落图实战
MiroFish 这个名字是我在给一个小团队做研发复盘工具时顺手起的。Miro 是大家天天开白板会的地方Fish 取自 fishbone也就是因果图、鱼骨图。合起来它干的事就一句话把复盘会里乱七八糟的口语化原因描述自动整理成一张结构规整的鱼骨图然后直接画到 Miro 白板上对应的位置。不用手动拉线、不用手动摆卡片参会的人吵完图也差不多出来了。我做过不少这种“会议产出自动化”的小工具说实话大部分最后都死在“生成的图还没人手画得好看”上。MiroFish 是我少数坚持用下来、还推荐给了两个朋友团队的。它解决的问题很具体一次故障复盘大家嘴上能说出三四十条原因但真正落图的时候主持人的手速跟不上讨论速度最后往往只记下十条而且归类和分层全靠临场发挥会后再看那张图基本没法用。MiroFish 把“记录—归类—分层—落图”这四步里后面三步都自动化了人只需要负责说和确认。这篇文章适合三类人看。第一类是需要频繁做根因分析、故障复盘、质量问题归零的研发和测试同学你可能不写代码但会用白板第二类是想给 Miro 写插件、或者想给自己团队做内部效率工具的工程师里面有完整的 API 调用和鉴权流程第三类是做会议纪要、流程梳理的同学你可以只用它的解析规则和布局思路落图那部分换成你顺手的工具。下面写的这些东西有些是我项目里真跑过的有些是同类型工具通用的常规做法我会尽量把两者分开说清楚你按自己的场景挑着用。1. 项目缘起与整体设计思路1.1 MiroFish 要解决的到底是什么问题先把这个工具的边界划清楚。它不是一个“AI 帮你分析问题”的工具也不是一个完整的质量管理平台它就是一个把散装文本变成结构化鱼骨图的转换器。输入端可能是三种东西会议白板上的便签文本、聊天记录里复制出来的一堆短句、或者事先存在表格里的原因清单。输出端是 Miro 白板上的一张鱼骨图包含一个大骨分类层、中骨子分类层、小骨具体原因层以及从主骨连到小骨的完整连线。我为什么不直接做“AI 分析 生成结论”因为在这类场景里分析的判断权必须留在人手里。复盘会上最有价值的部分是争论是“这到底算人的问题还是流程的问题”这种吵架。如果工具直接把分类定死大家反而不吵了图是好看了真因可能被埋掉。所以 MiroFish 的设计原则是分类只给建议落图前一定要有一次人工确认确认动作就是最简单的“这行不要”“这行改到设备下面”。从工作量上看解析层大概占了我三成时间布局和落图占五成剩下两成全部花在“确认”这个交互环节上。这个比例跟我一开始的预期是反的我原本以为解析最难。实际做下来难的不是把句子分对而是让人愿意去改。后面第 4 章会讲我是怎么把确认环节压到几十秒内的。说回正题MiroFish 最终形态是两部分一个跑在本地的命令行程序负责解析和排布计算一个 Miro 侧的应用负责把计算结果画到画布上。为什么拆成两部分下一小节会说。1.2 为什么是鱼骨图而不是思维导图或亲和图选鱼骨图作为输出形态不是我审美偏好是被场景逼出来的。复盘场景有三个硬约束要有固定的分类维度、要能追到具体原因、要能一眼看出哪一类原因最多。思维导图能表达层次但它没有“分类维度固定”这个特性根节点下面的分支是随便挂的开完会你没法横向比较“这次人因占比是不是比上次高”。亲和图也就是把便签聚类能解决归类问题但它丢失了层级和因果方向看的人不知道哪条是因、哪条是果。看板适合跟踪行动项不适合做因果展开。鱼骨图的妙处在于它的骨架是预先固定的。经典制造业里用 5M1E人Man、机Machine、料Material、法Method、测Measurement、环Environment。软件团队我更习惯用一套改过的人、流程、工具链、需求与设计、数据与配置、环境。骨架固定之后所有原因都往这六个筐里扔扔的过程本身就是一次归因讨论。这里有个经验骨架别超过七个。我试过加到九个结果大骨挤在一起中骨的小卡片开始互相压视觉上完全没法看。六个是最舒服的七个是上限。另外提醒一句如果你的团队做的是纯探索性的事比如产品创意发散鱼骨图不合适硬套会很难受。鱼骨图天生是为“找原因”服务的它的主骨是有方向的那个箭头指向的就是你要解释的现象。1.3 三种技术路线的取舍对比把结果画到 Miro 上我认真评估过三条路最后选了第三条。先把对比摆出来你可以直接对照自己的场景。路线实现方式优点主要问题适合场景纯 REST API 脚本本地脚本直接调 Miro REST API 创建卡片和连线无前端、无部署几十行就能跑通需要手动申请令牌令牌过期要重配无法读取当前画布选区个人用、一次性生成Miro Web SDK 应用做成 Miro 内的应用在画布上直接运行能读选区、能自动聚焦、交互体验最好要处理鉴权跳转和权限范围打包发布有门槛团队内部长期使用混合模式我选的本地算布局REST API 落图Web SDK 做确认交互算法调试方便交互也能保住多一层数据传递要设计中间格式想快速起步又不想牺牲体验我选混合模式的核心理由很朴素布局算法需要反复调试而调试的时候我不想每次都开白板。把布局计算放在本地我可以把算出来的坐标直接打印成文本或者渲染成一张 SVG 看一眼改一次参数跑一次几秒钟的事。如果全部塞进 Web SDK 里每次改都要刷新应用、重新鉴权一轮下来好几分钟迭代速度差了十倍不止。中间格式我用的是 JSON结构很简单一个 nodes 数组加一个 edges 数组每个 node 带 id、文本、层级、坐标、宽高、样式标签每条 edge 带起点 id、终点 id、线型。这个格式的好处是它跟 Miro 的 API 对象几乎是同构的落图时基本是字段映射不需要再转换。后面第 3 章和第 4 章会分别讲这个格式怎么算出来、怎么落到画布上。提示如果你只是想验证效果先用纯 REST API 脚本跑通一次别一上来就做应用。应用开发里的鉴权跳转、权限范围、发布审核这几关卡掉的热情比技术难度本身多得多。2. 输入解析层把一段大白话拆成可用的因果链2.1 5M1E 分类骨架怎么定分类骨架是整个工具的地基骨架定歪了后面所有工作都是白费。我给团队定的是六类这里把每一类的边界和常见误区说清楚因为边界模糊是分类出错的第一大原因。人指操作者的技能、状态、认知、沟通。注意“人”不包含“人没有按流程做”那条要归到流程里因为流程本身没有防呆设计才是真因。这个区分我们吵了两次才统一统一之后归类的准确率明显上升。流程指制度、规范、评审机制、职责划分、交接方式。判断标准很简单如果换一个人来做还会犯同样的错那就是流程问题。工具链指编译、构建、部署、监控、IDE、自动化脚本这些。注意“配置写错了”通常要归到数据与配置不归工具链除非是工具默认值不合理导致的。需求与设计指需求本身含糊、变更频繁、设计文档缺失、接口约定不清。这一类的输入通常带着“其实当时没说清楚”这种字眼。数据与配置指环境变量、参数、开关、数据库内容、灰度配置。环境指基础设施、网络、第三方服务、物理条件。这里要注意一点云厂商故障会同时被人归到“环境”和“工具链”建议统一归环境避免同一个原因出现在两处。把这六类做成一张对照表贴在白板旁边效果比写在文档里好得多。下面是我实际用的关键词映射表的一部分你可以直接抄去改分类高频触发词容易误判的情况人忘了、以为、不熟悉、沟通、交接、看错“没按规范做”应归流程流程没有评审、来不及、临时、口头、没人负责“规范写了但没执行”看是否有防呆工具链构建失败、监控缺失、脚本、日志、报警“配置错了”通常归数据与配置需求与设计需求变更、没写清楚、接口、方案、文档“技术选型错”要看是否设计决策数据与配置参数、开关、环境变量、脏数据、灰度默认值不合理可归工具链环境网络、机房、第三方、依赖服务、机器云服务故障统一归环境2.2 半结构化文本的清洗与切分规则输入端的东西通常很脏先要把它们变成一行一条的短句。这一步没有花哨的技巧全靠规则堆但规则的正确顺序很重要顺序错了会出现“先切了再洗结果把须保留的标点洗掉”这种低级问题。我的清洗流水线是五步顺序不能换去掉时间戳、发言人前缀、聊天工具的表情符号和引用符号。这些是噪音留着会干扰后面的切分。统一标点。把中文标点全转成英文标点把连续标点压成一个。按标点切分。分号、句号、问号、感叹号是强切分符逗号是弱切分符只有当逗号两边长度都超过 12 个字时才切。长度规整。超过 30 个字的句子按连接词二次切分连接词表就是“因为、所以、导致、结果、然后、接着、另外”。去重与合并。用编辑距离做近重复检测相似度超过 0.85 的合并成一条保留字数多的那条。第 3 步的“弱切分”是我踩坑之后加的。一开始所有逗号都切结果是“日志级别配置错误导致线上大量报错”被切成两条分类的时候前一条归了数据配置后一条归了环境明明是一个原因被拆成两处。加了长度门槛之后短句里的逗号就不切了。第 5 步的去重也不能省。复盘会里同一件事经常被三个人用三种说法讲一遍不去重的话鱼骨图上会出现三条意思一样的小骨视觉上显得某一类问题特别多误导判断。编辑距离用最简单的 Levenshtein 就行不需要上语义模型实测下来效果够用。注意清洗阶段千万不要做“主观改写”。我见过有人为了让句子更通顺直接把“以为已经发了”改写成“发布流程存在遗漏”改完分类是准了但会上讨论时的原始语气全丢了复盘会最有价值的那部分信息就没了。清洗只做格式处理不做语义润色。2.3 规则优先、模型兜底的分层解析策略分类这一层我的策略是“规则打底模型兜底人做终审”。为什么不让模型从头包到尾因为我实测过纯模型方案在长尾句子上的稳定性不够同一条句子今天分到人、明天分到流程团队用两次就不信任这个工具了。而规则方案虽然覆盖不全但行为是可预测的团队能慢慢摸出它的脾气。具体分层是这样第一层关键词精确匹配。命中就定分类置信度标记为高。这一层大概能覆盖一半左右的输入。第二层句式模式匹配。比如句子里出现“没有……”“缺少……”“忘了……”按预先定义的模式表给候选分类。这一层再覆盖两成。第三层才是模型。把剩下的句子批量送给一个轻量分类模型要求它只能从六个分类里选一个同时输出一个 0 到 1 的分数。分数低于 0.6 的标记为待定不参与自动落图进到人工确认列表的最前面。function classify(text) { const cleaned normalize(text); // 第一层关键词精确命中 for (const rule of KEYWORD_RULES) { if (rule.words.some((w) cleaned.includes(w))) { return { category: rule.category, score: 0.95, source: keyword }; } } // 第二层句式模式 for (const pattern of PATTERN_RULES) { if (pattern.regex.test(cleaned)) { return { category: pattern.category, score: 0.75, source: pattern }; } } // 第三层模型兜底低分进人工确认 const predicted modelPredict(cleaned); return { category: predicted.category, score: predicted.score, source: model, needReview: predicted.score 0.6, }; }这套分层跑下来我这边待人工确认的比例大概在两成到三成之间。听起来不少但因为确认界面做得足够顺手实际花的时间比想象中短很多。这里分享一个经验不要追求把待确认比例压到很低。我一度把它压到 5%代价是规则写得极其复杂规则之间开始互相打架维护成本陡增而且错判的隐蔽性更强了——低置信度的错误会被高置信度的标签掩盖掉。留两成给人看反而是最稳的。3. 布局算法鱼骨的坐标是怎么算出来的3.1 主骨与斜骨的几何模型布局这块是 MiroFish 里我最花心思的部分因为一张图“能不能看”八成取决于间距而不是内容。先把几何模型讲清楚后面所有计算都基于它。我把画布看成一个笛卡尔坐标系原点放在画布中心向右为 x 正方向。主骨是一条从尾部到头部、略微向上的水平线右端是鱼头位置放一张描述现象的卡片比如“线上支付失败率突增”。主骨左端是尾部留一段空白视觉上像个尾巴。大骨从主骨上引出来跟主骨成 60 度角上下交替排列。为什么是 60 度不是 45 度45 度看起来更舒展但水平投影太长大骨之间的横向间距会被吃掉中骨上挂的小卡片容易撞到相邻大骨而且 45 度时整张图偏扁宽高比接近 3:1Miro 视口缩放之后字会很小。60 度时水平投影只有长度的一半整张图更接近 2:1缩放后阅读体验好很多。我试过 70 度太陡了标签文字跟大骨的角度冲突看起来别扭。60 度是折中点。计算规则定成这样主骨长度spineLen起点在-spineLen / 2终点在spineLen / 2y 都是 0。鱼头卡片右边缘对齐主骨终点再往右留headGap。大骨数量n把主骨按n 1等分得到 n 个附着点 x 坐标。等分而不是按内容多少分配是为了让骨架看起来均匀视觉上更稳。第 i 根大骨i 从 0 开始附着点px -spineLen / 2 (i 1) * spineLen / (n 1)。方向向上还是向下由i % 2决定。大骨自由端坐标fx px - cos(θ) * bigLenfy (i % 2 0 ? -1 : 1) * sin(θ) * bigLen。注意 fx 是往左的这样大骨朝尾巴方向斜标签放在自由端上方或下方不会跟鱼头挤在一起。主骨长度不是拍脑袋定的它由大骨数量和小骨密度反推。我的公式是spineLen n * (bigLen * cos(θ) * 2 minBigGap)其中minBigGap取 120。六根大骨、bigLen 取 280 的时候算出来 spineLen 大概在 1450 左右实测这个宽度下中骨上的卡片不会互相压。这个值你可以按自己的标签长度微调原则是宁可让图宽一点也不要让卡片重叠。3.2 小骨的堆叠、避让与画布自适应大骨定好之后中骨和小骨的排布才是真正麻烦的地方因为小骨的数量是不可控的某一类下面可能挂 8 条另一类只挂 1 条。我的做法是先算需求再定尺寸。具体来说分三步先把每个大骨下的原因按中骨分组得到每组的小骨条数再按最大组的小骨条数决定中骨之间的纵向间距最后反推中骨长度是否需要加长。这里绝对不能反过来——先定死尺寸再往里塞内容塞不下就只能缩字号或者截断前者难看后者丢信息。中骨的纵向间距公式是midGap smallCardHeight smallCardGap其中卡片高度取 48间距取 16也就是 64。为什么不设更大的间距因为间距太大中骨会显得松散整张图的垂直高度膨胀视口缩放到能看全的时候字会变小。64 是我试出来的一个平衡点字号 14、卡片高 48 的时候两行卡片之间的距离肉眼看着刚好。中骨的长度由最长的那一组决定midLen maxSmallCount * (smallCardWidth smallCardGap)。这里 smallCardWidth 我取 200加上 16 的间距一条小骨占 216。如果某一组有 5 条小骨中骨就要 1080 长。这个长度会吃掉主骨左侧的空间所以我在算完之后会做一次全局检查如果中骨自由端伸出了画布左边界就把整个图整体右移或者给大骨加长、把大骨自由端往右收一点。这个“检查—回退”的逻辑我写成了一个循环最多迭代三次避免无限循环。还有一个小技巧值得单独说交错避让。相邻两根大骨如果都是向上的它们的中骨会平行排列在小骨数量差异大的时候视觉上很乱。我的处理是让同一侧的大骨在纵向上做阶梯错开第一根的中骨起点比第二根高出一个smallCardHeight第三根再高一个。这样看起来像是自然生长的阶梯比强行对齐要舒服。代价是垂直跨度变大所以这个错开量我只在单侧大骨数超过 3 的时候才启用。function layoutBigBone(causes, side, anchorX, config) { const groups groupByMidCategory(causes); const maxCount Math.max(...groups.map((g) g.items.length)); const midLen maxCount * (config.smallCardWidth config.smallCardGap); const nodes []; const edges []; const bigTip { x: anchorX - Math.cos(config.theta) * config.bigLen, y: side * Math.sin(config.theta) * config.bigLen, }; groups.forEach((group, gi) { const step side * gi * config.staggerStep; const midAnchor { x: anchorX - Math.cos(config.theta) * config.bigLen * 0.5, y: side * Math.sin(config.theta) * config.bigLen * 0.5 step, }; group.items.forEach((item, ii) { nodes.push({ id: item.id, text: item.text, level: small, x: midAnchor.x - config.smallCardWidth / 2 - ii * (config.smallCardWidth config.smallCardGap), y: midAnchor.y side * (config.smallCardHeight config.smallCardGap) * (ii % 2 0 ? 0 : 1), width: config.smallCardWidth, height: config.smallCardHeight, }); }); }); return { nodes, edges, bigTip, midLen }; }这段代码是简化版实际项目里还多了边界检查和去重。但核心逻辑就是所有坐标都是从层级关系推导出来的没有一个是手填的。这一点很重要因为只要有一个手填的坐标后面加内容的时候就会看到她突然压住别人。3.3 视觉规范字号、颜色、卡片宽高布局算完还有一层视觉规范。这部分容易被忽略但它直接决定了图看起来是专业还是业余。我把实际用的一套参数列出来你可以直接拿去改成自己的。元素卡片宽 × 高字号填充色说明鱼头现象320 × 9020 加粗深色底、白字全图唯一一个深色块视线锚点大骨分类180 × 6016 加粗品牌色浅底六类各一个颜色色相拉开中骨子类200 × 5014白底细边不填色避免跟大骨抢视线小骨具体原因200 × 4814浅灰底数量最多颜色必须最弱连线线宽 2-与大骨同色小骨连线线宽降到 1颜色这块我的原则是层级越高颜色越重数量越多颜色越轻。小骨是整个图里数量最多的元素如果它颜色重整张图就会变成一片花花绿绿的噪点看的人根本找不到骨架在哪。所以小骨统一用浅灰只靠位置和连线表达归属。字号的坑我也踩过。一开始小骨用 12 号字本地预览没问题贴到 Miro 上之后因为视口缩放肉眼几乎看不清。后来统一提到 14并且在落图完成后自动把视口缩放到刚好框住整张图问题才解决。这里有个经验值Miro 画布上正文类文本低于 14 号在 100% 缩放下就偏小了如果图表整体宽度超过 2000必须有自动缩放到全图这一步否则用户打开白板第一眼看到的是一堆看不清的碎片。4. 落到 Miro 画布API 实操全流程4.1 环境准备与依赖安装先说环境。我这边是 Node 环境跑的命令行工具版本要求不高但有两个依赖必须装。一个是 HTTP 客户端我用原生的 fetchNode 18 以上自带省一个依赖。另一个是 SVG 渲染库只用于本地预览布局效果跑通之后可以删掉。# 检查 Node 版本低于 18 的话先升级 node -v mkdir mirofish cd mirofish npm init -y # 本地预览布局用不预览可以不装 npm install sharp # 配置用避免把令牌硬编码在代码里 npm install dotenv目录结构我建议这么分后面加功能不容易乱mirofish/ config/ categories.yaml # 六类骨架和关键词表 layout.json # 布局参数 src/ parse.js # 清洗与分类 layout.js # 坐标计算 render.js # 生成中间格式 JSON push.js # 调 Miro API 落图 preview.js # 本地 SVG 预览 .env # 令牌加进 .gitignore配置文件独立出来的好处是换团队换场景的时候只改 yaml不动代码。我后来把这套工具给另一个做硬件的朋友用他只花了十分钟改分类表和关键词其他一行没动。.env里至少要有两个东西访问令牌和画板 ID。画板 ID 就在白板链接里形如https://miro.com/app/board/xxxxxxxxxxx/等号前面那一串就是。这个 ID 千万别写死在代码里我一个同事就是写死了换白板忘了改把测试数据画到了客户演示用的白板上场面一度非常尴尬。4.2 鉴权与权限范围配置鉴权这块有两个选择我把它俩的区别说清楚你按需选。第一种是访问令牌直接在开发者后台生成一串长令牌贴在.env里用。优点是简单五分钟就能跑通缺点是有效期有限过期要手动换而且权限范围比较宽不太适合给整个团队用。第二种是OAuth 授权码流程也就是做应用那一套。用户在 Miro 里点授权拿到授权码换访问令牌和刷新令牌后续自动刷新。这套流程麻烦但适合团队长期使用权限范围也能精确控制到只读或者只写。我做混合模式的时候两者都用了本地命令行用访问令牌快速验证跑通之后换成 OAuth 拿的令牌。换取令牌的请求大致是这样curl -X POST https://api.miro.com/v1/oauth/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typeauthorization_code \ -d client_id${MIRO_CLIENT_ID} \ -d client_secret${MIRO_CLIENT_SECRET} \ -d code${AUTH_CODE} \ -d redirect_uri${REDIRECT_URI}权限范围这块我强烈建议只申请 boards:read 和 boards:write别贪多。申请范围越宽应用审核越麻烦而且万一令牌泄露影响面也大。MiroFish 实际只用到创建卡片、创建连线、读取卡片位置这三类操作read 和 write 这两项完全够。注意访问令牌和客户端密钥绝对不能提交到代码仓库。我见过不止一个项目把令牌写在了前端代码里等于把白板的完全编辑权限公开了。哪怕只是内部仓库也要养成用.env加.gitignore的习惯。顺手加一条 pre-commit 检查用正则扫一遍有没有Bearer后面跟长字符串的情况五分钟的事能省掉很多麻烦。4.3 批量创建卡片、连线与自动聚焦落图这一步的核心思路是分批并发加限流。创建一个卡片是一次请求一张图可能有五六十个元素串行发请求会慢到难以接受全部并发又会被限流挡回来。我的做法是并发度控制在 5 到 8 之间每批之间留一点间隔同时在代码里对返回的状态码做处理遇到限流就退避重试。先创建卡片拿到每个卡片的 id再创建连线。这个顺序不能反因为连线需要引用两端的元素 id。async function createShape(node, boardId, token) { const res await fetch(https://api.miro.com/v2/boards/${boardId}/shapes, { method: POST, headers: { Authorization: Bearer ${token}, Content-Type: application/json, }, body: JSON.stringify({ data: { shape: round_rectangle, content: node.text }, style: { fillColor: node.level small ? #F5F6F8 : #FFFFFF, borderColor: #DDE0E6, borderWidth: 1, fontSize: node.level small ? 14 : 16, textAlign: center, textAlignVertical: middle, }, position: { x: node.x, y: node.y }, geometry: { width: node.width, height: node.height }, }), }); if (res.status 429) { await sleep(1000 Math.random() * 500); return createShape(node, boardId, token); } if (!res.ok) { throw new Error(create shape failed: ${res.status} ${await res.text()}); } const data await res.json(); return data.id; } async function createConnector(startId, endId, boardId, token, color) { const res await fetch(https://api.miro.com/v2/boards/${boardId}/connectors, { method: POST, headers: { Authorization: Bearer ${token}, Content-Type: application/json, }, body: JSON.stringify({ start: { item: startId }, end: { item: endId }, shape: straight, style: { strokeColor: color, strokeWidth: 2, endStrokeCap: stealth }, }), }); if (!res.ok) throw new Error(connector failed: ${res.status}); return (await res.json()).id; }卡片和连线都创建完之后最后一步是自动聚焦。如果不做这一步用户点开白板看到的是原本的视野新画的图可能在屏幕外需要自己拖半天。聚焦有两种做法一种是把视口中心移到图的中心并设置合适缩放另一种是在 Web SDK 环境下用选中接口把所有新元素选中然后调“缩放到选中项”。我两种都用过。纯 REST API 方案只能做第一种需要提前知道图的包围盒也就是所有元素的最小和最大 x、y这个在布局阶段就能算出来。Web SDK 方案更省事直接选中再缩放缺点是依赖应用环境。实测下来第一种在元素数量多的时候反而更稳因为它不依赖画布的当前状态。包围盒的计算很简单遍历所有节点取minX min(x - width / 2)、maxX max(x width / 2)y 同理然后中心点就是两个平均缩放比例是画布可视宽高除以包围盒宽高再乘一个小于 1 的系数留点边距。这个系数我取 0.85留白看起来舒服。4.4 幂等与回滚重复执行怎么办这是我最想强调的一节因为没有幂等设计的自动化工具第二次运行就是灾难。你想想第一次跑完生成了 50 个元素会上讨论后改了几条然后你想重新生成一版结果又画了 50 个上去白板上变成 100 个其中一半是废弃的。清理这堆东西花的时间比手动画还多。我的方案是打标记加分组。创建每个元素的时候把这次运行的任务 ID 写进卡片的元数据里Miro 的元素支持自定义元数据字段同时给所有新元素加一个同名的分组或标签。重新执行的时候先按任务 ID 查出上一次生成的所有元素全部删掉再生成新的。如果你不想用元数据还有个更土但更稳的办法约定一个固定区域。每次生成前先算出这次图会占用的矩形范围然后查询这个范围内已有的元素如果它们的创建者是你自己并且带有特定前缀文本就删掉。这个办法的问题是会误删用户手动放进去的卡片所以我更推荐元数据方案。另外还有一点创建过程最好支持失败中断后重跑。具体做法是每创建成功一个元素就把 id 写进一个本地的进度文件重跑的时候先读进度文件跳过已经创建的。这样即使中途网络断了也不会产生半张图加半张图的诡异状态。我在做第一版的时候没做这个有一次网络抖动断了重跑了三次白板上出现了三张重叠的图那次之后我立刻补上了进度文件。5. 常见问题与排查技巧实录5.1 报错速查表下面这张表是我这半年真实遇到过的问题按出现频率排序。前三行几乎每个用过的人都碰到过。现象可能原因排查动作解决方式401 未授权令牌过期或权限范围不含 write检查.env里的令牌生成时间在开发者后台看应用范围重新生成令牌补上 boards:write403 禁止访问令牌有效但无权访问该画板用同一个账号手动打开画板链接确认画板归属和协作权限429 请求过多并发度太高触发节流看日志里连续失败的请求数并发降到 5加指数退避重试卡片全部叠在左上角坐标算成相对坐标或未传 position打印中间格式 JSON 看 x、y 值确保传的是画布绝对坐标连线连到了错误的卡片元素 id 映射错乱核对 id 到文本的映射表创建后立即记录返回 id不要靠顺序推断图上只有卡片没有线连线在卡片之前创建看执行日志顺序先建卡片拿 id再建连线字太小看不清未做自动聚焦看图的包围盒宽度结束后按包围盒缩放到全图部分卡片缺失中途请求失败被吞掉看进度文件里缺哪些 id补上重试与进度记录这张表里我最想说的是“卡片全部叠在左上角”那一条。我刚开始调 API 的时候以为位置参数是相对画布原点的偏移结果是绝对坐标而且我把自己算的坐标又减了一次中心点所有卡片全挤到了左上角。排查的时候不要盯着代码看直接打印中间格式 JSON一眼就能看出来。布局问题基本都能通过打印中间格式定位不要靠猜。5.2 布局跑偏与连线错位的排查布局类的问题比较隐蔽因为代码不报错只是结果不好看。我把常见的三种跑偏模式和解法整理一下。第一种是某一侧大骨特别挤另一侧很空。原因通常是按内容条数分配了主骨长度内容多的一侧占了更多空间。解法是改成等分主骨让骨架视觉均匀内容多少只影响中骨长度不影响大骨位置。这条我第 3 章讲过但实际改的时候容易忘。第二种是小骨卡片互相压住。原因不是间距算错而是长文本换行后卡片高度撑开实际高度超过了预设的 48。Miro 的卡片在文本超出时会自动增高这个行为不受几何参数控制。我的解法是在布局阶段预估行数按每行最多 12 个中文字符算超过就按两行的高度 72 来排。宁可留白多一点也不要在落图后才发现压住了。这个 12 字的估算值你可以按自己的字号调字号越大每行越少。第三种是连线看起来是斜的、不整齐。这个通常是用了直线连接而两端卡片的锚点位置不固定。解法是把连线形状改成折线elbow或者统一让连线的锚点落在卡片的固定边上。我用的是折线加固定锚点看起来规整很多。实测下来折线在鱼骨图这种正交结构里比直线好看因为大骨是斜的直线连出来的角度很随机。还有一个关于连线的小坑如果卡片是分组状态连线不能跨组连接会直接报错。所以要么全部分组要么全不分组不要混着来。我一开始为了好管理给大骨做了分组结果连线全失败了排查了半小时才反应过来是分组的限制。5.3 性能与配额的实测数据性能这块我做过几组对照数据不一定适用于你但量级可以参考。元素规模并发度总耗时是否触发限流30 个卡片 25 条线5约 8 秒否60 个卡片 55 条线5约 16 秒否60 个卡片 55 条线10约 11 秒偶发120 个卡片 110 条线10约 40 秒是重试十余次结论很直白并发度提到 10 以上收益很小但限流风险明显上升。我最后稳定在并发 6配合指数退避120 个元素的场景大概 35 秒左右完成一次都没被拦截。画布侧还有一个隐性成本容易被忽略元素数量过多会让白板本身变卡。我在一张已经有两百多个历史元素的画板上做过测试加上新生成的一百多个之后拖动视野开始有轻微的迟滞感。所以现在我的习惯是每次复盘用一张新画板或者把历史内容折叠到画板外的区域。如果你团队的白板是长期复用的建议给每次生成的图加一个带日期的框架框住方便折叠。另外提一个省时间的技巧布局先在本地渲染成 SVG 看一眼。我写了一个几分钟就能搞定的 SVG 渲染函数把中间格式 JSON 画成静态图。改一次参数本地出图不到一秒确认没问题再调 API 落图。这个小工具让我在布局调参阶段节省了大量时间也不占用任何接口配额。强烈建议你也建一个。6. 后续可扩展的几个方向与个人体会6.1 从鱼骨图到行动项闭环现在 MiroFish 只做了“把原因画出来”这一步但复盘真正的价值在行动项。我最近在试的一个扩展是在人工确认环节里给每条小骨加一个“是否可行动”的开关打开的原因会被自动生成一张行动项卡片放到画板右侧的一个看板列里带上负责人和截止时间的占位符。这样一张白板上就同时有了因果结构和待办清单复盘会开完行动项也分完了。这个扩展的技术难点不在生成卡片而在防止行动项重复。同一场会里两条不同的小骨可能指向同一个改进动作如果不去重行动项会膨胀。我的思路是在生成前做一次相似度比对超过阈值的合并成一条并把关联的小骨 id 都记下来。这块我还在调阈值定太高会漏合并定太低会误合并目前用的是 0.8。6.2 模板化与团队复用第二个方向是把分类骨架做成模板。不同团队关注的维度差别很大硬件团队用 5M1E软件团队用我改的那六类客服团队可能更关心渠道和人员。如果每次都要改代码里的分类表复用成本就太高了。我现在的做法是把分类、关键词、颜色、布局参数全部放进 yaml换模板就是换一个文件。一个模板文件大概五十行新团队接入基本十分钟内能跑起来。模板还有一个附带好处可以做横向对比。同一套模板下的多次复盘因为骨架一致可以直接统计每个分类下的小骨数量看趋势。比如连续三次复盘里“流程”类的原因从 3 条涨到 9 条这就是个很明确的信号。这个统计功能我还没做进工具目前是导出一份 JSON 然后用表格软件做个透视表够用了。6.3 我个人踩坑后的几点体会最后说点实在的。这个工具从起念头到稳定用起来前后大概两个月中间推翻过两次设计。我把最值钱的三条体会留在这里。第一条先做确认环节再做自动生成。我一上来就想把自动化做满结果第一版生成率很高但可用度很低因为没人愿意去改它画错的地方。后来我把顺序倒过来先花一周把确认交互做顺手让人改起来比手画还快然后再提升自动生成的比例。这个顺序反过来之后工具的接受度完全不一样了。第二条宁可少画不要画歪。低置信度的原因我一开始也往图上放想着反正用户会改。实际效果是图上出现了几条明显分类错误的小骨之后用户对整个图的信任就崩了后面正确的部分也不看了。现在我的策略是低置信度的全部先列在待确认区不自动落图。图上一眼看上去全是对的这个感觉比多覆盖几成内容重要得多。第三条布局参数的默认值比算法本身重要。我一开始在算法上花了很多时间想做一个能自动适应任何内容的智能布局后来发现根本没必要。绝大多数场景的元素分布都差不多把六类骨架的间距、角度、字号这几个默认值调到舒服比写一个复杂的自适应算法划算得多。现在我那套布局代码逻辑很朴素但参数是反复调过的出图效果好过很多花哨的实现。这套东西现在跑得挺稳我自己的团队每周复盘都在用。如果你也想试我建议第一步先别碰 API就用本地 SVG 预览把布局跑通感受一下参数变化对可读性的影响这一步花的时间绝对不会白费。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →