WorkGraphCanvas.vue 15.3 KB
<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>

      <!-- 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';

/**
 * 图画布(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}`;

/**
 * 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';
    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 || '(兜底边:任何未匹配的失败)'}` : ''),
      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; 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; }

.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>