画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态,任何一件事都要在同一个视口里表达清楚。
这一天补齐八项操作。每一项都不难,难的是它们之间不能互相打架。
一、URL 是唯一事实来源
原先画布是个单页状态机:打开就是列表,点进去切到编辑器,刷新回列表。
改成路由:
/canvas 列表
/canvas/:id 编辑器
四个行为一起对齐:
- 深链直达编辑器。
- 刷新不回列表。
- 后退回列表,而不是退出画布模块。
- 打开画布写入地址栏。
popstate 监听已有的解析函数,pushState / replaceState 写入。
这条改动看起来只是加了个路由,实际改变的是状态的归属:画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行,批量框要能被分享,都要求「当前在看哪张画布」是一个可以从外部确定的量。
二、运行历史
一个新面板,两个接口:
GET /api/canvas-flow/runs?flowId=… 列表
GET /api/canvas-flow/runs/:id 快照
列表默认取 10 条。点开某一条,用它的快照推导出画布上每个节点的状态,覆盖显示。
这个面板的数据来源和实时运行状态用的是同一套 nodeStates——历史记录不是另一条平行显示,而是把画布切到那一次运行的视角。
登录态走单一来源:
// 登录态只有 authToken() 一个来源(有守卫测试盯着,别直读 localStorage)
这条注释是守卫测试抓出来的结果,不是提前的设计。
三、复制粘贴
复制粘贴里有两个决定值得记。
用应用内剪贴板,不碰系统剪贴板。
let clipboard: FlowClipboardPayload | null = null;
粘贴的内容是节点和边,格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板,用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。
没选节点时不拦截。
// Cmd/Ctrl+C:复制选中的节点。
// 没选节点就不拦截——用户可能正在复制节点产物里的文字,抢了就是坏默认行为
// Cmd/Ctrl+V:粘贴。应用内剪贴板为空时同样放行系统默认行为
这两条合起来是一个原则:快捷键只在它有明确意图时生效。用户按 Cmd+C 时可能是想复制提示词里的文字,此时抢过来是损失。
粘贴到画布时重新生成 ID(节点 ID 最多重试 20 次防碰撞),偏移 24 像素,粘贴的结果进入撤销栈并成为新的选中项。
四、一键整理布局
按依赖深度分层的纯函数,不引布局引擎:
/**
* 一键整理布局:按依赖深度分层的纯函数。
*
* 不引 dagre/elk——画布的图是小规模 DAG(上限 100 节点),
* 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图,
* 引一个布局引擎为它的边缘能力买单不值。
*/
const COLUMN_GAP = 380;
const ROW_GAP = 240;
const ORIGIN = { x: 40, y: 60 };
同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。
节点上限 100,单批最大 50 项,展开后的总任务上限 200。这几个数决定了上面那个判断成立:在这个规模内,一条规则够用。
五、便签不能做成节点
画布上要有地方写注释。最自然的做法是加一个「便签节点」,但它的实现方式正好相反:
/**
* 画布便签:纯注释,不参与调度、连线与计费。
*/
// 不是 xyflow 节点——它不参与连线、调度与计费,做成节点要在注册表、校验器、执行器三处逐一开豁免,成本远高于一张自绘卡片。
做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处,将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片,这些例外一个都不需要。
契约里限制条数 100、单条 2000 字。
六、批量框
批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则:
框内节点的输出不能接到框外,反过来可以。
// 批量框边界规则:框内节点的输出不能接到框外(或另一个框)。
// 框内子图每份各跑一遍,往外接意味着下游要收 N 份——运行时不支持这种收束;
// 反方向(框外 → 框内)合法:同一上游共享给每份拷贝。
拒绝时的文案要给下一步:
批量框内的节点不能连到框外:框内每份各跑一遍,产物会直接进素材库。要串联处理就把目标节点也拖进框里。
第二件事是删除节点的连带处理。节点被删掉之后,批量框里会留下幽灵成员,而运行创建时会拒绝整张图:
/**
* 从所有批量框成员里剔除已删除的节点;成员清空的框一并删除。
*
* 删除节点必须同步清理:残留的幽灵成员会让 run-create 直接拒绝整张画布
* (「批量框引用了图中不存在的节点」),用户面对的是一张再也跑不起来的图。
*/
第三件事是撤销栈。批量框要进快照:
export interface GraphSnapshot {
readonly nodes: readonly CanvasFlowNode[];
readonly edges: readonly CanvasFlowEdge[];
readonly selected: readonly string[];
/** 批量框。撤销/重做要连它一起回放,否则撤销删框后节点回来了框没了 */
readonly batchGroups: readonly CanvasFlowBatchGroup[];
}
第四件事是状态聚合。批量框内一个节点对应多行执行状态,显示取哪个:
failed > running > pending > cancelled > succeeded > idle
原先只让 failed 优先。结果是第一份先成功、第二份还在跑的时候,节点就提前显示成「成功」。
七、续跑不重复扣费
这一项是这八项里唯一涉及钱的。
失败或被取消的运行,可以续跑。实现方式不是「重跑一遍」:
/**
* 单节点重试:给失败/被取消的运行造一个「续跑」运行。成功节点的行原样回填
* (产物、billingRef、时间戳都保留)——executor 的调度器见到 succeeded 行
* 会直接当作上游已就绪,不会重新执行,也就不会重复扣费;其余节点
* (failed / cancelled / pending)重置成全新的 pending 行,正常调度重跑。
* 不修改原运行:重试是一条新的 CanvasFlowRun,历史记录保持完整。
*/
关键在于复用判定落在行状态上,而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑:被判成功的节点带着原来的 billingRef,调度器看到它就不再执行。
预估只算子集,余额检查同理。接口层有幂等入口(按用户 + 请求 ID 查重)和归属校验。不可重试的两种情形给出明确文案:
这次运行已全部成功,没有可重试的节点
运行还没结束,等它终结后再重试
界面上的按钮从「重试」改成「重试失败节点」,带一句说明:
成功节点的产物直接沿用,只有失败的节点会重新执行并计费。
八、八项之间的关系
单独看每一项,都是常规功能。放在一起时,出现了几条贯穿的取舍:
状态的归属要单一。 画布 ID 放 URL;运行状态用同一套 nodeStates;撤销栈是唯一的变更入口。三处都收成一个来源之后,「刷新之后看到什么」才有确定答案。
例外要少。 便签不做成节点,就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外,就多一处将来会忘记的地方。
别抢用户的操作。 没选节点时不拦 Cmd+C;整理布局保留同列原有顺序;批量框拒绝时给下一步而不是只报错。
涉及钱的判定落在状态上。 续跑复用靠行状态,不靠运行的新旧;批量份数由框上显式配置,预估与执行读同一个函数。
最后一条是这一天唯一和故障档案有关的部分。同一天里,批量链路翻出一处行约定矛盾和一处从未命中的推断分支,两处的成因都是「两侧各写一份」。八项补齐之后,取值入口都比之前更集中——这不是巧合,是同一件事的两个方向。
■