WorkGraphCanvas.vue 19.6 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 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
<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>
        <!-- fan-in 等待态(27-P4 T10 / §4.3 决策):pending 节点在等哪几个上游,
             紧凑徽标 + title 悬停看明细。只有 2+ 条入边的真 fan-in 才画,
             单一入边的普通顺序等待看紧邻上游状态就够了,不加这个徽标制造噪声。 -->
        <g v-if="joinWaitLabels.get(n.key)" class="join-wait">
          <text :x="NW - 10" :y="38" text-anchor="end" class="join-wait-label">
            {{ joinWaitLabels.get(n.key).text }}
            <title>{{ joinWaitLabels.get(n.key).title }}</title>
          </text>
        </g>
        <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>
        <!-- 「下一步」不再是单数:advanceBatch(27-P4 T4)一次最多认领 maxConcurrency 个
             ready 节点,并行分支下真实会被同批拾取的可能是好几个,标一个会误导。 -->
        <text v-if="nextNodeIdSet.has(n.key)" :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';
import { resolveJoinWait, joinWaitLabel } from './graph-console/join-status';

/**
 * 图画布(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
  // 下一批 advanceBatch 会拾取哪些(defNodeId 数组)——27-P4 T4 之后不再是单数,
  // 并行分支下真实会被同一批认领的可能是好几个节点。
  nextNodeIds: { type: Array, default: () => [] },
  hardCap: { type: Number, default: 3 },         // 单边回退硬顶(§四.D.2.1)
});
defineEmits(['select']);

const nextNodeIdSet = computed(() => new Set((props.nextNodeIds || []).map(String)));

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));

/**
 * fan-in 等待态徽标(27-P4 T10):defNodeId → {text, title} | undefined。
 * 与 elapsedLabels 同款理由单独拆 computed——它依赖全部节点+全部边而不是单个节点,
 * 混进 laidOut 会让布局 computed 背上一份不相关的依赖。
 */
const joinWaitLabels = computed(() => {
  const m = new Map();
  for (const n of props.nodes) {
    const label = joinWaitLabel(resolveJoinWait(n, props.nodes, props.edges));
    if (label) m.set(String(n.defNodeId), label);
  }
  return m;
});

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; }
.join-wait-label { font-size:10px; fill:#8a4b00; font-weight:600; font-variant-numeric: tabular-nums; cursor:help; }
.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>