WorkGraphCanvas.vue
12.8 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
<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})`"
/>
<!-- 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>
<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 } from 'vue';
/**
* 图画布(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 }],from/to 取 defNodeId
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}`;
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 [];
return [{
key: e.id || `${e.from}-${e.to}-${i}`,
condition: e.condition === 'fail' ? 'fail' : 'success',
x1: a.x + NW, y1: a.y + NH / 2,
x2: b.x, y2: b.y + NH / 2,
}];
}),
);
/**
* 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; }
.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; }
.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; }
.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>