ReplanOrgGraphButton.vue 6.87 KB
<template>
  <div class="replan" :class="[variant, phase]">
    <button class="rp-btn" :disabled="busy" @click="onClick">
      <template v-if="phase === 'posting'">⏳ 正在触发…</template>
      <template v-else-if="phase === 'planning'">⏳ 规划中 {{ mmss }}</template>
      <template v-else>🧭 {{ hasGraph ? '重新规划组织' : '规划组织结构' }}</template>
    </button>

    <!-- 过渡态的说明文字:fire-and-forget 的调用点完没反应最容易被当成没点上 -->
    <p v-if="note" class="rp-note" :class="noteKind">
      {{ note }}
      <button v-if="phase === 'timeout'" class="rp-link" @click="waitMore">继续等</button>
    </p>
  </div>
</template>

<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';

/**
 * 「重新规划组织」入口(R-18.1 / 27-P3B §四 W8)。
 *
 * 用户原话:「人工的操作不应该是跑脚本,而是我点击 org graph planning,让他来真实运行,
 * 并且 plan 出来需要雇佣哪几个 agent 到 company」。后端 `POST /:companyId/org-graph/replan`
 * 从 P2 起就在(company.js),但在此之前**全仓库没有任何前端调用方**,只能 curl。
 *
 * 三件事这个组件全包了,因为两个页面(GraphConsole 左栏、CompanyDashboard 组织流程块)
 * 要的是同一套行为,只是大小不同:
 *   1. 触发:POST replan(服务端 hire orggraph-planning + fire-and-forget 起一轮对话)
 *   2. 过渡态:按钮转「规划中 mm:ss」并禁用 —— agent 一轮是分钟级的,没有过渡态用户会连点
 *   3. 刷新:轮询 GET org-graph,图变了就 emit('planned'),由父组件重载自己的视图
 *
 * **为什么是轮询不是 SSE**:replan 端点内部起的那条 agent 对话 SSE 由服务端自己消费掉了
 * (company.js triggerOrgGraphPlanning 的 fetch),浏览器手里没有那条流的把手,端点也不回
 * conversationId。控制台现成的 SSE(/graph-runs/:id/stream)是 graph run 的事件轨,org graph
 * 规划不产生 graph run。所以能观测到「规划完成」的唯一信号就是 org graph 本身变了。
 */
const props = defineProps({
  companyId: { type: String, required: true },
  /** 已有图时按钮文案改成「重新规划」,并在点击前确认一次:writeGraph 是整图覆盖 */
  hasGraph: { type: Boolean, default: false },
  /** primary = 空态里的大按钮;inline = 已有图时挂在标题栏的小按钮 */
  variant: { type: String, default: 'inline' },
});
const emit = defineEmits(['planned']);

const POLL_MS = 4000;
const WAIT_MS = 6 * 60 * 1000; // 一轮 orggraph-planning 的墙钟量级是分钟;超时不代表失败,只代表别再干等

const phase = ref('idle');   // idle | posting | planning | done | timeout | error
const note = ref('');
const startedAt = ref(0);
const now = ref(Date.now());

let poller = null;
let ticker = null;
let baseline = '';

const busy = computed(() => phase.value === 'posting' || phase.value === 'planning');
const noteKind = computed(() => (phase.value === 'error' ? 'bad' : phase.value === 'done' ? 'ok' : ''));
const mmss = computed(() => {
  const s = Math.max(0, Math.floor((now.value - startedAt.value) / 1000));
  return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
});

onBeforeUnmount(stopTimers);

function stopTimers() {
  clearInterval(poller); poller = null;
  clearInterval(ticker); ticker = null;
}

/**
 * 图的指纹。节点 id 也进指纹:writeGraph 是「旧行整体置 removed + 插新行」,
 * 即便重新规划出的形状与原来一模一样,节点 id 也会全变——只比形状会把「重规划出同一张图」
 * 误判成超时。
 */
async function fetchSig() {
  const r = await fetch(`/api/company/${props.companyId}/org-graph`);
  if (!r.ok) throw new Error(`org-graph HTTP ${r.status}`);
  const g = await r.json();
  return JSON.stringify([
    (g.nodes || []).map((n) => [n.id, n.roleName, n.systemAgentId]),
    (g.edges || []).map((e) => [e.from, e.to, e.condition, e.failReason ?? null, e.edgeKind]),
  ]);
}

async function onClick() {
  if (busy.value) return;
  if (props.hasGraph && !window.confirm(
    '重新规划会让 orggraph-planning 重画整张 org graph,并覆盖现有的节点与边(旧图作废)。继续?'
  )) return;

  phase.value = 'posting';
  note.value = '';
  try {
    // 先取基线,再 POST:反过来的话规划快到抢在取基线之前完成,就永远比不出变化
    baseline = await fetchSig().catch(() => '');
    const r = await fetch(`/api/company/${props.companyId}/org-graph/replan`, { method: 'POST' });
    if (!r.ok) {
      const j = await r.json().catch(() => ({}));
      throw new Error(j.error || `HTTP ${r.status}`);
    }
  } catch (e) {
    phase.value = 'error';
    note.value = `触发失败:${e.message}`;
    return;
  }
  startWaiting();
}

function startWaiting() {
  phase.value = 'planning';
  startedAt.value = Date.now();
  now.value = Date.now();
  note.value = 'orggraph-planning 已启动:读公司目标 → 选骨架 → 落图 → 按拓扑序招人。这一轮是分钟级的,图出现后本页会自动刷新。';
  stopTimers();
  ticker = setInterval(() => { now.value = Date.now(); }, 1000);
  poller = setInterval(poll, POLL_MS);
}

/** 超时不是失败判定,只是停止干等;再点一下继续轮询(基线不重置,之前的变化照样算数) */
function waitMore() { startWaiting(); }

async function poll() {
  let sig;
  try {
    sig = await fetchSig();
  } catch {
    return; // 单次轮询失败(后端重启等)不打断,等下一拍
  }
  if (sig !== baseline) {
    stopTimers();
    phase.value = 'done';
    note.value = '✅ 组织流程已生成,已刷新。';
    emit('planned');
    return;
  }
  if (Date.now() - startedAt.value > WAIT_MS) {
    stopTimers();
    phase.value = 'timeout';
    note.value = '等了 6 分钟还没看到新图。规划可能仍在进行,也可能失败了(同一 agent 不允许并发会话,上一轮没结束时这次触发会被拒)。去「团队成员 → orggraph-planning」的对话里看它跑到哪一步了。';
  }
}
</script>

<style scoped>
.replan { display: inline-flex; flex-direction: column; gap: 6px; min-width: 0; }
.replan.primary { display: flex; align-items: center; }

.rp-btn {
  cursor: pointer; border-radius: 6px; border: 1px solid #2b6cb0;
  background: #2b6cb0; color: #fff; font-size: 12px; padding: 5px 12px;
  white-space: nowrap;
}
.rp-btn:disabled { cursor: default; opacity: .6; }
.replan.inline .rp-btn { background: #fff; color: #2b6cb0; padding: 3px 9px; font-size: 11px; }

.rp-note { margin: 0; font-size: 11px; line-height: 1.7; color: #777; max-width: 420px; }
.rp-note.ok { color: #2f855a; }
.rp-note.bad { color: #c53030; }
.rp-link {
  border: none; background: none; padding: 0 0 0 4px; cursor: pointer;
  color: #2b6cb0; font-size: 11px; text-decoration: underline;
}
</style>