WorkGraphCanvas.vue
17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
<template>
<div class="canvas-wrap">
<svg v-if="nodes.length" :viewBox="`0 0 ${W} ${H}`" :width="W" :height="H">
<defs>
<!-- marker / pattern 的 id 必须逐实例唯一:一个页面上同时挂着 org 与 work 两个实例,
共用 id 会让第二个实例的箭头指向第一个实例的 defs(同名后定义者胜) -->
<marker :id="`arr-${uid}`" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#999" />
</marker>
<marker :id="`arr-fail-${uid}`" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#c96a00" />
</marker>
<pattern :id="`skip-${uid}`" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="6" height="6" fill="#f4f4f5" />
<line x1="0" y1="0" x2="0" y2="6" stroke="#d8d8dc" stroke-width="3" />
</pattern>
</defs>
<!-- 异常流程底框(§四.B.2 末段):fail 边 / 回退边 / blocked 节点整体降权,
统一压在这一层浅色背景框里,主干(success 前进边)浮在上面 -->
<g v-if="anomalyBand" class="anomaly-band">
<rect :x="anomalyBand.x" :y="anomalyBand.y" :width="anomalyBand.w" :height="anomalyBand.h" rx="8" />
<text :x="anomalyBand.x + 8" :y="anomalyBand.y + 14" class="band-label">异常流程</text>
</g>
<rect
v-for="n in anomalyNodes"
:key="'halo-' + n.key"
class="node-halo"
:x="n.x - 5" :y="n.y - 5" :width="NW + 10" :height="NH + 10" rx="9"
/>
<!-- forward 边:直线实线;condition='fail' 的降权 -->
<line
v-for="e in forwardEdges"
:key="'f' + e.key"
:class="['edge', e.condition]"
:x1="e.x1" :y1="e.y1" :x2="e.x2" :y2="e.y2"
:marker-end="`url(#arr-${uid})`"
>
<!-- 边上的 fail_reason 决定失败时走哪条边(§四.D.2.2),后端一直随 edges 一起返回;
不显示的话「为什么走了这条边」在图上完全不可见。悬停即可,无需布局 -->
<title>{{ e.tip }}</title>
</line>
<!-- 边上的数据契约(R-19 / R-4「边的输入输出」的字面落地):
只有**声明过契约**的边才多画这一枚小药丸,没声明的边一个像素都不多 —— 图上天然稀疏。
一条边多个 kind 时只显示第一个 + `+N`,全量与 required 与否留在 <title> 里,
GAP_X 只有 52px,塞第二个标签就会压到线上。 -->
<g v-for="e in contractPills" :key="'c' + e.key" class="c-pill" :class="{ opt: e.optionalOnly }">
<rect :x="e.x - e.w / 2" :y="e.y - 8" :width="e.w" :height="16" rx="8" />
<text :x="e.x" :y="e.y + 4" text-anchor="middle">{{ e.text }}</text>
<title>{{ e.title }}</title>
</g>
<!-- backward 边:虚线曲线 + N/3 循环徽标(§四.B.2) -->
<g v-for="e in backwardEdges" :key="'b' + e.key" class="back-edge">
<path :d="e.d" :marker-end="`url(#arr-fail-${uid})`" />
<g :transform="`translate(${e.mx},${e.my})`" class="loop-badge" :class="{ idle: !e.loopCount }">
<rect :x="-BADGE_W / 2" :y="-9" :width="BADGE_W" :height="18" rx="9" />
<text text-anchor="middle" y="4">{{ e.loopCount }}/{{ hardCap }}</text>
</g>
</g>
<!-- 节点 -->
<g
v-for="n in laidOut"
:key="'n' + n.key"
:transform="`translate(${n.x},${n.y})`"
class="node-g"
@click="$emit('select', n.raw)"
>
<rect
:width="NW" :height="NH" rx="6"
:class="['node', n.status, { selected: n.key === selectedId }]"
:fill="n.status === 'skipped' ? `url(#skip-${uid})` : undefined"
/>
<text :x="10" :y="21" class="name-label">{{ trim(n.name || n.key) }}</text>
<text :x="10" :y="38" class="status-label">
{{ n.status }}<tspan v-if="n.attempt > 0"> · #{{ n.attempt + 1 }}</tspan>
</text>
<!-- RUNNING 中的秒表(W12-UI)。真 CLI 一个节点是 3-7 分钟量级,点完「下一步」之后
卡片上原本只有一个不动的状态灯,看不出「还活着」还是「卡死了」。
这里只报**已经跑了多久**(引擎不知道 agent 还要多久,画进度条是骗人)。 -->
<text v-if="elapsedLabels.get(n.key)" :x="NW - 10" :y="38" text-anchor="end" class="elapsed-label">
⏱ {{ elapsedLabels.get(n.key) }}
</text>
<circle :cx="NW - 12" :cy="12" r="5" :class="['dot', n.status]" />
<!-- node_kind='subgraph' 的下钻角标 -->
<text v-if="n.nodeKind === 'subgraph'" :x="NW - 8" :y="NH - 7" text-anchor="end" class="sub-badge">⊞</text>
<text v-if="n.key === nextNodeId" :x="10" :y="NH - 6" class="next-badge">NEXT ▶</text>
</g>
</svg>
<div v-else class="empty">
<slot name="empty">这张图还没有节点</slot>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, onBeforeUnmount } from 'vue';
import { fmtDuration } from './graph-console/node-signals';
import { edgeContractLabel } from './graph-console/io-model';
/**
* 图画布(T3)——从 devloop/UnitsDAG.vue 抽取并扩展。
* org graph 与 work graph 用**同一个组件的两个实例**(§四.B.1),
* 因为两层图同构(R-9.1),这是引擎泛化在前端的对应收益。
*
* 复用自 UnitsDAG:SVG + <marker> 箭头、Kahn 分波拓扑布局(带 safety 上限)、按 status 着色、点击 emit。
* 本组件新增:subgraph 角标、backward 虚线曲线路由(分道避让)、N/3 循环徽标、异常流程降权底框。
*/
const props = defineProps({
// [{ id, defNodeId, name, nodeKind, status, attempt, childRunId, ... }],defNodeId 是边的寻址键
nodes: { type: Array, default: () => [] },
// [{ id, from, to, condition, failReason, edgeKind, contracts }],from/to 取 defNodeId。
// contracts = graph_edge_contracts 里这条边声明的数据契约 [{kind, required}](R-19);
// 后端没吐这个字段时就是 undefined → 画布上不多画任何东西,行为与之前完全一致
edges: { type: Array, default: () => [] },
// graph_run_loop_records 原始行(snake_case),徽标数字取同一 edge_id 的最新一条
loopRecords: { type: Array, default: () => [] },
selectedId: { type: String, default: null }, // defNodeId
nextNodeId: { type: String, default: null }, // 下一步会拾取哪个(§四.C:并行 ready 时必须标出来)
hardCap: { type: Number, default: 3 }, // 单边回退硬顶(§四.D.2.1)
});
defineEmits(['select']);
const uid = `wg${++instanceCount}`;
/**
* RUNNING 节点的秒表(W12-UI)。
*
* 单独一个 computed 而不是塞进 laidOut:laidOut 是布局,让它依赖每秒变化的时间戳
* 会把整张图的拓扑排序 + 分道每秒重算一遍。这里只产 defNodeId → 时长文本的映射。
* ticker 只在**真有节点在跑**时转,图静下来就停(run 结束后没有节点是 running)。
*/
const nowTs = ref(Date.now());
let ticker = null;
const elapsedLabels = computed(() => {
const m = new Map();
for (const n of props.nodes) {
if (n.status !== 'running' || !n.startedAt) continue;
const label = fmtDuration(nowTs.value - Date.parse(n.startedAt));
if (label) m.set(String(n.defNodeId), label);
}
return m;
});
watch(
() => props.nodes.some((n) => n.status === 'running' && n.startedAt),
(on) => {
clearInterval(ticker);
ticker = null;
if (!on) return;
nowTs.value = Date.now();
ticker = setInterval(() => { nowTs.value = Date.now(); }, 1000);
},
{ immediate: true },
);
onBeforeUnmount(() => clearInterval(ticker));
const NW = 158, NH = 54, GAP_X = 52, GAP_Y = 18, PAD = 20;
const LANE_H = 34, BADGE_W = 34;
/** 只有 forward 边参与拓扑排序——backward 边本来就是回路,进 Kahn 会死锁(与 org-graph-service 的 acyclic 判定同源) */
const forwardDefs = computed(() => props.edges.filter((e) => e.edgeKind !== 'backward'));
const waves = computed(() => {
const ids = props.nodes.map((n) => String(n.defNodeId));
const remaining = new Set(ids);
const deps = new Map(ids.map((id) => [id, []]));
for (const e of forwardDefs.value) {
const to = String(e.to);
if (deps.has(to) && deps.has(String(e.from))) deps.get(to).push(String(e.from));
}
const out = [];
let safety = 200;
while (remaining.size > 0 && safety-- > 0) {
const wave = [];
for (const id of remaining) {
if (deps.get(id).every((d) => !remaining.has(d))) wave.push(id);
}
if (!wave.length) break; // 理论上不会发生(写入前已过 acyclic 校验),兜底防死循环
for (const id of wave) remaining.delete(id);
out.push(wave);
}
if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失
return out;
});
const laidOut = computed(() => {
const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
const out = [];
waves.value.forEach((wave, wi) => {
wave.forEach((id, ni) => {
const n = byId.get(id) || {};
out.push({
key: id,
raw: n,
name: n.name,
status: n.status || 'pending',
nodeKind: n.nodeKind,
attempt: n.attempt || 0,
x: PAD + wi * (NW + GAP_X),
y: PAD + ni * (NH + GAP_Y),
});
});
});
return out;
});
const posById = computed(() => new Map(laidOut.value.map((n) => [n.key, n])));
const forwardEdges = computed(() =>
forwardDefs.value.flatMap((e, i) => {
const a = posById.value.get(String(e.from));
const b = posById.value.get(String(e.to));
if (!a || !b) return [];
const cond = e.condition === 'fail' ? 'fail' : 'success';
const c = edgeContractLabel(e);
return [{
key: e.id || `${e.from}-${e.to}-${i}`,
condition: cond,
tip: `${a.name || a.key} → ${b.name || b.key}|${cond}`
+ (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
// 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
+ (c ? `|数据契约 ${c.title}(? = 可选)` : ''),
x1: a.x + NW, y1: a.y + NH / 2,
x2: b.x, y2: b.y + NH / 2,
contract: c,
}];
}),
);
/**
* 契约药丸的位置 = 边的中点(两列之间的空白带),宽度按字数估。
* kind 名最长按 7 字符截断(`acceptance` → `accepta…`):GAP_X 只有 52px,
* 不截会盖到相邻节点上——完整名字在 <title> 里。
*/
const contractPills = computed(() =>
forwardEdges.value
.filter((e) => e.contract)
.map((e) => {
const { kind, extra } = e.contract;
// kind 截断、`+N` 保留:`acceptance +1` → `accepta… +1`(切掉 +N 就丢了「还有别的」这个信号)
const text = (kind.length > 8 ? `${kind.slice(0, 7)}…` : kind) + (extra ? ` +${extra}` : '');
return {
key: e.key,
x: (e.x1 + e.x2) / 2,
y: (e.y1 + e.y2) / 2,
w: Math.min(GAP_X + 10, text.length * 5.9 + 12),
text,
title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
optionalOnly: e.contract.optionalOnly,
};
})
);
/**
* backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。
*
* 重叠问题拆成两半,各用一招解决,不引图形库:
* ① 与节点重叠 —— 回退边一律走**节点带下方**的空白区,起终点都取节点底边中点,
* 三次贝塞尔两个控制点都压在同一条泳道高度上 → 曲线永远不穿过任何节点矩形;
* ② 与其它回退边重叠 —— 按 x 区间做贪心分道(区间重叠的边必然分到不同泳道),
* 泳道纵向拉开 LANE_H,画布高度随泳道数增长。区间不重叠的边共用一道,不浪费高度。
* 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变高;
* 升级路径是改成上下双侧分流(奇偶道走节点带上方),本版不做。
*/
const backwardEdges = computed(() => {
const raw = [];
props.edges.forEach((e, i) => {
if (e.edgeKind !== 'backward') return;
const a = posById.value.get(String(e.from));
const b = posById.value.get(String(e.to));
if (!a || !b) return;
const sx = a.x + NW / 2, sy = a.y + NH;
const tx = b.x + NW / 2, ty = b.y + NH;
raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty,
lo: Math.min(sx, tx), hi: Math.max(sx, tx) });
});
// 贪心分道:按区间左端排序,塞进第一条不与已占区间重叠的泳道
const lanes = [];
raw.sort((p, q) => p.lo - q.lo);
for (const e of raw) {
let lane = lanes.findIndex((occupied) => occupied.every((o) => e.lo >= o.hi || e.hi <= o.lo));
if (lane === -1) { lanes.push([]); lane = lanes.length - 1; }
lanes[lane].push(e);
e.lane = lane;
}
return raw.map((e) => {
const laneY = nodeBandH.value + PAD + (e.lane + 1) * LANE_H;
return {
...e,
d: `M${e.sx},${e.sy} C${e.sx},${laneY} ${e.tx},${laneY} ${e.tx},${e.ty}`,
// 三次贝塞尔 t=0.5 处的坐标(两个控制点同 y,纵向化简为 (sy+ty+6*laneY)/8)
mx: (e.sx + e.tx) / 2,
my: (e.sy + e.ty + 6 * laneY) / 8,
loopCount: latestLoopCount(e.edgeId),
};
});
});
/** 徽标数字 = graph_run_loop_records 里该 edge_id **最新一条**的 loop_count(路由层已按 created_at DESC 返回) */
function latestLoopCount(edgeId) {
if (!edgeId) return 0;
const rec = props.loopRecords.find((r) => String(r.edge_id) === String(edgeId));
return rec ? rec.loop_count : 0;
}
const laneCount = computed(() => {
const max = backwardEdges.value.reduce((m, e) => Math.max(m, e.lane + 1), 0);
return max;
});
const anomalyNodes = computed(() => laidOut.value.filter((n) => n.status === 'blocked' || n.status === 'fail'));
const anomalyBand = computed(() => {
if (!backwardEdges.value.length) return null;
return {
x: PAD / 2,
y: nodeBandH.value + PAD / 2,
w: W.value - PAD,
h: laneCount.value * LANE_H + LANE_H / 2,
};
});
const nodeBandH = computed(() => {
const maxRow = Math.max(...waves.value.map((w) => w.length), 1);
return PAD + maxRow * (NH + GAP_Y) - GAP_Y;
});
const W = computed(() =>
Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NW + GAP_X) - GAP_X));
const H = computed(() => nodeBandH.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
function trim(s) {
const str = String(s ?? '');
return str.length > 19 ? `${str.slice(0, 18)}…` : str;
}
</script>
<script>
// 实例计数器放普通 <script>,<script setup> 每次实例化都会重跑,模块作用域的变量才是全局唯一的
let instanceCount = 0;
</script>
<style scoped>
.canvas-wrap { overflow: auto; }
.canvas-wrap svg { display:block; background:#fafafb; border:1px solid #eee; border-radius:8px; }
.node-g { cursor:pointer; }
.node { fill:#fff; stroke:#c8c8cf; stroke-width:1.5; }
.node-g:hover .node { stroke:#333; }
.node.selected { stroke:#2b6cb0; stroke-width:2.5; }
/* 状态色沿用仓库既有语义(§四.B.2):绿 success / 蓝 running / 灰 pending·ready / 红 fail / 橙 blocked / 斜纹 skipped */
.node.pending { fill:#f7f7f8; }
.node.ready { fill:#eef4ff; stroke:#93b4e0; }
.node.running { fill:#cce5ff; stroke:#004085; }
.node.success { fill:#d4edda; stroke:#155724; }
.node.fail { fill:#f8d7da; stroke:#721c24; }
.node.blocked { fill:#ffe8cc; stroke:#a35b00; }
.node.skipped { stroke:#d8d8dc; }
.dot { fill:#bbb; }
/* 呼吸的状态灯:跟秒表一样,是「还活着」的信号,不承诺任何进度 */
.dot.running { fill:#0069d9; animation: node-pulse 1.6s ease-in-out infinite; transform-origin: center; }
@keyframes node-pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }
@media (prefers-reduced-motion: reduce) { .dot.running { animation: none; } }
.dot.success { fill:#1a9c4b; }
.dot.fail { fill:#d33; }
.dot.blocked { fill:#e08a00; }
.dot.ready { fill:#7aa7dd; }
.name-label { font-size:12.5px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; }
.status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; }
.elapsed-label { font-size:10px; fill:#004085; font-weight:600; font-variant-numeric: tabular-nums; pointer-events:none; }
.sub-badge { font-size:13px; fill:#5a67d8; pointer-events:none; }
.next-badge { font-size:9px; fill:#0069d9; font-weight:700; pointer-events:none; }
.edge { stroke:#999; stroke-width:1.5; fill:none; }
.edge.fail { stroke:#e0b080; stroke-width:1.2; stroke-dasharray:5 3; opacity:.65; }
/* 边上的数据契约药丸:与状态色系刻意区分(蓝=数据,橙=异常,绿=成功),
压在线上但底色不透明,避免线穿过文字 */
.c-pill rect { fill:#eef4fb; stroke:#9dc0e8; stroke-width:1; }
.c-pill text { font-size:9px; fill:#2b6cb0; font-weight:700; font-family: ui-monospace, monospace; pointer-events:none; }
.c-pill.opt rect { fill:#f7f7f9; stroke:#cfcfd6; stroke-dasharray:3 2; }
.c-pill.opt text { fill:#888; font-weight:500; }
.back-edge path { fill:none; stroke:#c96a00; stroke-width:1.6; stroke-dasharray:6 4; opacity:.85; }
.loop-badge rect { fill:#fff4e5; stroke:#c96a00; stroke-width:1; }
.loop-badge text { font-size:10px; fill:#8a4b00; font-weight:700; }
.loop-badge.idle rect { fill:#f6f6f7; stroke:#ccc; }
.loop-badge.idle text { fill:#999; font-weight:500; }
/* 异常流程整体降权:一层浅色背景框把回退区圈起来,主干留在框外 */
.anomaly-band rect { fill:#fff9f0; stroke:#f0dcc0; stroke-width:1; stroke-dasharray:4 3; }
.band-label { font-size:9px; fill:#c9a06a; }
.node-halo { fill:#fff4e5; stroke:none; }
.empty { padding:28px; color:#999; text-align:center; font-size:13px; }
</style>