GraphConsole.vue
30.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
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
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
<template>
<div class="console">
<!-- 页头:公司 + 常驻预算条(§四.B.4) -->
<header class="top">
<div class="who">
<router-link :to="`/${companyId}/overview`" class="back">←</router-link>
<b>{{ company?.name || companyId }}</b>
<span class="sep">组织与执行</span>
</div>
<div class="budget" v-if="budget">
<span class="chip">
<i>calls</i> {{ spent.agent_calls || 0 }}/{{ budget.total_agent_calls || '∞' }}
<em class="bar"><em :style="{ width: pct(spent.agent_calls, budget.total_agent_calls) }" /></em>
</span>
<span class="chip">
<i>tokens</i> {{ fmt(spent.tokens) }}/{{ fmt(budget.total_tokens) }}
</span>
<!-- 停了就说停了:光冻住数字还不够,一个不动的计时器和一个动得很慢的计时器长得一样 -->
<span class="chip" :class="{ frozen: clockStopped }">
<i>墙钟</i> {{ elapsed }}
<em v-if="clockStopped" class="halt" :title="`run 已 ${rootState.run.run_status},墙钟停在最后一次状态转换(${rootState.run.updated_at})`">已停</em>
</span>
</div>
<div class="budget" v-else>
<span class="chip muted">无 root run 预算</span>
</div>
</header>
<!-- 面包屑:公司 › org-coder › (子图)—— 与 §四.B.1 的图一致,点任一层回到那一层 -->
<nav class="crumbs">
<button class="crumb" :class="{ on: !stack.length }" @click="popTo(-1)">公司</button>
<template v-for="(c, i) in stack" :key="c.runId">
<span class="arrow">›</span>
<button class="crumb" :class="{ on: i === stack.length - 1 }" @click="popTo(i)">{{ c.label }}</button>
</template>
</nav>
<div class="cols" :class="{ rail: railMode, stacked }">
<!-- 最左:目标·任务两级栏(§七.1)。窄屏收成 44px 图标条 + 抽屉展开 ——
org graph 那栏最不能压,它是全局导航 -->
<aside class="col goals" :class="{ rail: railMode && !drawer, 'drawer-open': railMode && drawer }">
<button v-if="railMode" class="rail-toggle" :title="drawer ? '收起' : '展开目标·任务'" @click="drawer = !drawer">
{{ drawer ? '×' : '☰' }}
</button>
<GoalTaskPane
v-show="!railMode || drawer"
:company-id="companyId"
:goals="goals"
:loading="goalsLoading"
:selected-task-id="selectedTaskId"
:selected-goal-id="selectedGoalId"
@select="selectTask"
@refresh="reloadTree"
/>
</aside>
<!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) -->
<aside class="col left">
<div v-if="hasOrgGraph || rootRun" class="left-actions">
<ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" />
</div>
<GraphRunPane
v-if="rootRun"
:key="rootRun.id"
:run-id="rootRun.id"
title="Org Graph"
compact
@select="onOrgSelect"
@state="onRootState"
/>
<template v-else-if="hasOrgGraph">
<header class="pane-head">
<span class="ttl">Org Graph</span>
<span class="tag">定义(未执行)</span>
</header>
<!-- 选中的是 draft / queued task(还没有在跑的 run)时也走这一支:图是公司级定义,节点全 pending。
文案走 pendingRunHint:排队中要说清「前面还有几个」(那是「点了下达却什么都没发生」的
唯一答案),其余状态用中文标签 —— 原先这里直接打 `{{ status }}` 的英文原文,
跟左栏的中文标签是两套说法 -->
<p class="hint">
<template v-if="selectedTask">
「{{ selectedTask.title }}」{{ pendingRunHint(selectedTask) }}
</template>
<template v-else>
从最左栏选一条任务;没有选中时这里画的是 org graph 定义本身,节点状态一律 pending。
</template>
</p>
<WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect" />
</template>
<!-- 空态:整个控制台在这种公司上什么都做不了(没有 org graph 就没有 org node 可跑) -->
<div v-else class="org-empty">
<div class="es-icon">🕸️</div>
<h3>这家公司还没有 org graph</h3>
<p>
org graph 由 <code>orggraph-planning</code> 规划:它读公司目标选骨架、把图落库,
再按拓扑序把图里的角色招进公司。规划完成前,控制台没有可执行的 org node。
</p>
<ReplanOrgGraphButton :company-id="companyId" variant="primary" @planned="onReplanned" />
</div>
</aside>
<!-- 中栏:选中 org node 的 work graph(下钻后是子 run) -->
<main class="col mid">
<div v-if="!currentRunId" class="empty-state">
<template v-if="orgNode">
<!-- §四.B.5 空态:从未跑过 work graph,不报错不留白 -->
<div class="es-icon">◔</div>
<h3>{{ orgNode.name || orgNode.defNodeId }} 还没有跑过 work graph</h3>
<p>
org node 的 work graph 由 <code>workgraph-planning</code> 在它接到任务时现规划(§四.A.1)。
这个节点被 <code>advanceOne</code> 拾取执行后,这里会出现它的图。
</p>
</template>
<template v-else>
<div class="es-icon">◧</div>
<h3>从左栏选一个 org node</h3>
<p>中栏显示该 org node 这次任务里跑的 work graph;控制按钮作用于当前这一层。</p>
</template>
</div>
<template v-else>
<div v-if="historyRuns.length > 1" class="history">
<label>历史 work run</label>
<select :value="stack[0]?.runId" @change="switchHistory($event.target.value)">
<option v-for="r in historyRuns" :key="r.id" :value="r.id">
{{ r.created_at }} · {{ r.run_status }} · {{ r.id }}
</option>
</select>
</div>
<GraphRunPane
:key="currentRunId"
:run-id="currentRunId"
:title="stack[stack.length - 1]?.label || 'Work Graph'"
@select="onNodeSelect"
@state="onMidState"
>
<template #empty>这张 work graph 还没有节点。</template>
</GraphRunPane>
</template>
</main>
<!-- 右栏:节点详情四 tab -->
<aside class="col right">
<!-- 账本与事件轨必须取**选中节点所属那一层**的:选中的是左栏 org node 时,
它的回退记录与事件在 root run 上,中栏那份是另一张图的(W12-UI 修的第三处) -->
<!-- nodes/edges/taskFiles/companyRoot 同样取**选中节点所属那一层**:R-19 的输入解析
是「本层入边的契约 × 本 task 的产物实例」,拿错层的边就会解析出别的图的输入 -->
<NodeDetailPanel
:node="selectedNode"
:loop-records="selectedState.loopRecords || []"
:events="selectedState.events || []"
:nodes="selectedState.nodes || []"
:edges="selectedState.edges || []"
:files="selectedState.taskFiles || null"
:company-root="selectedState.companyRoot || null"
:company-id="companyId"
:run-id="selectedRunId || ''"
@dive="dive"
/>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue';
import GraphRunPane from '@/components/graph-console/GraphRunPane.vue';
import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';
import {
findTask, pickDefaultSelection, runStateToTaskPatch, hasStartedRun, pendingRunHint,
} from '@/components/graph-console/goal-task-model.js';
/**
* 执行控制台(T3 + T4,§四.B;P3C §七 加了最左侧的目标·任务栏)。四栏:
* 目标·任务 / org graph / work graph 画布 / 节点详情。
*
* 层级模型直接对应 §四.B.1 的面包屑「公司 › org-coder › (子图)」:
* stack = [] → 公司层(中栏提示选节点)
* stack = [work run] → 某个 org node 的 work graph
* stack = [work run, 子 run, …] → 逐层下钻,每层都是完整可交互的 pane(自带五个控制按钮)
*
* **右边三栏画的永远是「当前选中那条 task」的图**(P3C §七.3)。task 是 org graph / work graph
* 的唯一上下文,所以 goal 只负责分组与展开收起,选中态一律落在 task 上。
*/
const route = useRoute();
const router = useRouter();
const props = defineProps({ id: { type: String, default: '' } });
const companyId = computed(() => props.id || route.params.id);
const company = ref(null);
const allRuns = ref([]);
const defNodes = ref([]);
const defEdges = ref([]);
const goals = ref([]);
const goalsLoading = ref(false);
const selectedGoalId = ref('');
const selectedTaskId = ref('');
const orgNode = ref(null); // 左栏选中的 org node(run 状态合并后的对象)
const stack = ref([]); // 中栏层级栈
const selectedNode = ref(null); // 右栏详情对象
/**
* 右栏这个节点是从哪一栏选的('root' = 左栏 org run,'mid' = 中栏 work run)。
*
* 这一个字段是「NodeDetailPanel 不跟 SSE 走」那个缺陷的修法(27-P3B 记录的已知缺陷):
* 在此之前只有 onMidState 会把 selectedNode 指到最新快照,onRootState 完全不管,
* 于是**左栏选中的 org node 在右栏是一张永久冻结的快照**。
*/
const selectedFrom = ref('mid');
const rootState = ref({});
const midState = ref({ loopRecords: [], events: [] });
const selectedState = computed(() => (selectedFrom.value === 'root' ? rootState.value : midState.value));
/**
* 右栏这个节点属于哪个 run —— 「LLM 原文」tab 拉 transcript 要它。
* 必须跟着 selectedFrom 走:左栏选的节点属于 org run,中栏的属于当前 work run。
*/
const selectedRunId = computed(() =>
(selectedFrom.value === 'root' ? (rootRun.value && rootRun.value.id) : currentRunId.value) || ''
);
const hasOrgGraph = computed(() => defNodes.value.length > 0);
const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
const budget = computed(() => rootState.value.budget || null);
const spent = computed(() => budget.value?.spent || {});
const selectedTask = computed(() => (findTask(goals.value, selectedTaskId.value) || {}).task || null);
/**
* **左栏画哪张图,由选中的 task 决定**(P3C §七.3 / §一.A)。
*
* 改这一条之前是 `roots.find(active) || roots[0]` —— 拿到了公司全部 root run,
* 却只画其中一条:任意一条历史任务都可能被当成「要画的图」,其余全丢,
* 这正是「历史任务看不见、切不了」那个问题的成因。现在没有选中 task(或它还没起过 run)
* 就退到 org graph 定义视图,而不是随手挑一条别的任务的图画上去。
*
* allRuns 里找不到时用 task 上的 runId 兜一个最小对象:run 列表是挂载时拉的,
* 刚下达的那条可能还没进去,而 GraphRunPane 只需要一个 id 就能自己拉全量 + 订 SSE。
*
* **排队中的 task 有 runId 但不画 run 面板**(判据在 hasStartedRun,理由写在那里):
* 那条 run 是 `run_status='queued'`,画出来是一块全灰、不刷新、也不解释自己为什么不动的面板。
* 退到下面的定义视图,由 pendingRunHint 说清「排在第几」。
*/
const rootRun = computed(() => {
const t = selectedTask.value;
if (!hasStartedRun(t)) return null;
return allRuns.value.find((r) => r.id === t.runId) || { id: t.runId, run_status: t.status };
});
/** 同一个 org node 生命周期里可能跑过多张 work graph,默认最新、下拉切历史(§四.B.1) */
const historyRuns = computed(() =>
orgNode.value ? allRuns.value.filter((r) => r.parent_node_run_id === orgNode.value.id) : []);
const now = ref(Date.now());
let tick = null;
let treePoll = null;
/**
* 墙钟:**run 不在跑的时候必须停**。
*
* 以前恒 `now - started_at`,没有终点:一条昨晚就 blocked 的 run,第二天顶栏还在涨到
* 「16h 58min」,而那个数字是页面上唯一的时间信号 —— 读起来就是「它还在干活」。
* 于是「已经停了、等人处置」被显示成了「还在跑、再等等」,这两件事需要的动作完全相反。
*
* 终点取 `run.updated_at`(最后一次状态转换的时刻)。不新加列:run 的每次状态转换都会
* 写 updated_at,收敛那一下正是最后一次,够准了。
* ponytail: 精度上限 = 「最后一次状态转换」而非「最后一个 agent 收尾」,两者差一次落库;
* 要更准就得给 graph_runs 加一列 finished_at,为一个顶栏数字不值当。
*/
const CLOCK_LIVE = ['running', 'planning'];
const clockStopped = computed(() => {
const r = rootState.value.run;
return !!r && !CLOCK_LIVE.includes(r.run_status);
});
const elapsed = computed(() => {
const s0 = spent.value.started_at;
if (!s0) return '—';
const r = rootState.value.run;
const end = clockStopped.value && r && r.updated_at ? Date.parse(r.updated_at) : now.value;
const ms = end - Date.parse(s0);
if (!Number.isFinite(ms) || ms < 0) return '—';
const m = Math.floor(ms / 60000);
return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h ${m % 60}min`;
});
// ── 响应式布局:≤1400px 最左栏收成图标条 + 抽屉;≤1100px 沿用既有堆叠 ──────────
const stacked = ref(false);
const railMode = ref(false);
const drawer = ref(false);
let mqNarrow = null;
let mqStack = null;
function applyMq() {
stacked.value = !!(mqStack && mqStack.matches);
railMode.value = !!(mqNarrow && mqNarrow.matches) && !stacked.value;
if (!railMode.value) drawer.value = false; // 从窄屏拉宽时抽屉必须收掉,否则它会浮在四栏之上
}
onMounted(async () => {
tick = setInterval(() => { now.value = Date.now(); }, 30000);
if (typeof window !== 'undefined' && window.matchMedia) {
mqNarrow = window.matchMedia('(max-width: 1400px)');
mqStack = window.matchMedia('(max-width: 1100px)');
applyMq();
mqNarrow.addEventListener('change', applyMq);
mqStack.addEventListener('change', applyMq);
}
// URL 里的选中态先认下来(刷新 / 分享可复原,§七.3),拉完数据再校验它还在不在
selectedGoalId.value = String(route.query.goal || '');
selectedTaskId.value = String(route.query.task || '');
await Promise.all([loadCompany(), loadRuns(), loadOrgDef(), loadGoals()]);
// ponytail: 没有公司级 SSE 通道,未选中的行只能靠轮询兜底(§七.3)。
// 上限就是这 20s 的延迟;升级路径是 graph-run-sse 加一个 company topic,前端订一条就够。
treePoll = setInterval(() => { loadGoals(); }, 20000);
});
onBeforeUnmount(() => {
clearInterval(tick);
clearInterval(treePoll);
if (mqNarrow) mqNarrow.removeEventListener('change', applyMq);
if (mqStack) mqStack.removeEventListener('change', applyMq);
});
async function loadCompany() {
try {
const r = await fetch(`/api/company/${companyId.value}`);
if (r.ok) company.value = await r.json();
} catch { /* 页头名字掉了不影响功能 */ }
}
/**
* 整棵目标·任务树。withTasks=1 让服务端一次带出每个 goal 的 task 摘要(含进度),
* 不按 goal 逐个查 —— 那就是端点注释里点名要避开的 N+1。
*/
async function loadGoals() {
goalsLoading.value = true;
try {
const r = await fetch(`/api/company/${companyId.value}/goals?withTasks=1`);
if (!r.ok) return;
const j = await r.json();
goals.value = j.goals || [];
ensureSelection();
} catch { /* 轮询失败保留上一份树,别把已经画出来的东西清空 */ } finally {
goalsLoading.value = false;
}
}
/**
* 选中态校验:URL 里带的 task 可能已经被归档 / 属于别的公司,这时退到默认
* (最近一条 running,没有就最近创建的,§七.3)。
*/
function ensureSelection() {
if (selectedTaskId.value && findTask(goals.value, selectedTaskId.value)) {
// goal 侧顺手对齐一次:URL 里只带了 task 也能把它所在的 goal 高亮出来
selectedGoalId.value = findTask(goals.value, selectedTaskId.value).goal.goalId;
return;
}
const pick = pickDefaultSelection(goals.value);
if (pick) selectTask(pick);
else if (selectedTaskId.value) selectTask({ goalId: '', taskId: '' });
}
async function loadRuns() {
try {
const r = await fetch(`/api/company/${companyId.value}/graph-runs`);
if (!r.ok) return;
const j = await r.json();
allRuns.value = j.runs || [];
// 这里**不再挑 rootRun** —— 画哪张图由选中的 task 决定,见 rootRun 那个 computed。
//
// 响应里的 canDispatch 也不再读:建 goal 从不 409(POST /goals 只落草稿),
// 所以「新目标」入口永远可用;真正会被并发约束挡住的是**下达**,那一步由服务端 409
// 带着 hint 回来、界面原样透出。前端复述一份取值域正是 canDispatch 当初出事的原因
// (比后端多写了一个 blocked,公司失败一次就再也下不了任务)。
} catch { /* 无 run 时左栏退到定义视图 */ }
}
/** 没有 run 时左栏用 org graph 定义画一张静态图(org 节点全是 subgraph executor,§四.D.0) */
async function loadOrgDef() {
try {
const r = await fetch(`/api/company/${companyId.value}/org-graph`);
if (!r.ok) return;
const j = await r.json();
defNodes.value = (j.nodes || []).map((n) => ({
defNodeId: String(n.id), name: n.roleName, systemAgentId: n.systemAgentId,
nodeKind: 'subgraph', status: 'pending', attempt: 0,
}));
defEdges.value = (j.edges || []).map((e) => ({
// failReason 只在有值时出现在响应里(org-graph-service.getGraph),漏读它画布上就分不出
// 「精确 fail 边」和「兜底边」——这两条在同一对节点之间可以并存
id: e.id, from: String(e.from), to: String(e.to),
condition: e.condition, failReason: e.failReason, edgeKind: e.edgeKind,
}));
} catch { /* ignore */ }
}
/**
* **选中态**:三栏的「我正在看哪个节点 / 哪一层」。
*
* 它挂在**图的定义**上(orgNode / selectedNode 都带 defNodeId,stack 里是它们的子 run),
* 所以任何让「图的节点集合」失效的事件都要清它 —— 换 task 会(不同 run 的节点行是各自一份),
* replan 也会(新 def 里旧 defNodeId 可能压根不存在,留着就会在中栏画一个已经不在图上的
* 节点的 work run、在右栏显示一个查不到的详情)。
*/
function clearSelection() {
orgNode.value = null;
selectedNode.value = null;
stack.value = [];
}
/**
* 换了 task 之后所有**属于上一条 task** 的下游状态 = 选中态 + 两份 run 快照。
*
* 只写一份、两个调用方(selectTask 与 route.query.task 的 watcher)共用:
* 这两条路径清的东西必须逐字一致 —— 之前 watcher 少清 rootState / midState,
* 用浏览器后退切回上一条 task 时,顶栏的 budget / spent / elapsed(全部从 rootState 算)
* 显示的还是**上一条任务**的数字;新选中的若是 draft task(没有 run,走定义视图分支,
* 不会再有 onRootState 覆盖),那份旧预算就一直挂着不刷新。
*
* ⚠️ 与 onReplanned 的差别是**故意的**,别把它们合成一个:
* rootState / midState 是 **run 快照**(预算、账本、节点运行行),归属于 run 而不是图定义。
* 换 task 就是换 run,快照必须清;replan 不换 run —— 当前 run 的预算照旧在扣,
* 而且 GraphRunPane 用 `:key="rootRun.id"` 挂载,replan 后 runId 没变 → 组件不重挂、
* 不重连 SSE、不会再 emit 一次 state。此时清了 rootState,顶栏就会退成「无 root run 预算」,
* run 已结束的话再也没有下一个 SSE 事件把它填回来。
*/
function clearTaskScopedState() {
clearSelection();
rootState.value = {};
midState.value = { loopRecords: [], events: [] };
}
/**
* 切 task:**下游选中态全清**。
* 不同任务的 def node 不可复用(org node 的 run 行是每条 run 各一份),
* 串了会画出一张不存在的图 —— 与 onReplanned 的处理同理。
*/
function selectTask({ goalId, taskId }) {
const changed = taskId !== selectedTaskId.value;
selectedGoalId.value = goalId || '';
selectedTaskId.value = taskId || '';
if (changed) clearTaskScopedState();
if (railMode.value) drawer.value = false; // 窄屏抽屉里选完就让路给图
syncQuery();
}
/** 选中态进 URL(replace 不进历史栈:切任务不该把浏览器的后退按钮变成「上一个任务」) */
function syncQuery() {
const q = { ...route.query };
if (selectedGoalId.value) q.goal = selectedGoalId.value; else delete q.goal;
if (selectedTaskId.value) q.task = selectedTaskId.value; else delete q.task;
if (q.goal === route.query.goal && q.task === route.query.task) return;
router.replace({ query: q }).catch(() => { /* 重复导航不是错误 */ });
}
/** 树变了(建 goal / 下达 / 归档 / 改 task)→ 重拉树 + run 列表 */
async function reloadTree() {
await Promise.all([loadGoals(), loadRuns()]);
}
// 浏览器前进后退 / 外部链接改了 query → 跟着走
watch(() => route.query.task, (v) => {
const next = String(v || '');
if (next !== selectedTaskId.value) {
selectedGoalId.value = String(route.query.goal || '');
selectedTaskId.value = next;
clearTaskScopedState();
}
});
/**
* 规划完成(org graph 指纹变了)后重载左栏。run 列表一起重拉:新图的节点与旧 run 的
* def node 已经对不上了,不重拉的话左栏会拿旧 run 画一张不存在的图。
*
* 只清选中态、**不清 run 快照**(rootState / midState)—— 理由见 clearTaskScopedState 的注释:
* replan 换的是图定义,不是 run,当前 run 的预算与账本仍然有效且不会被重新推送一次。
*/
async function onReplanned() {
clearSelection();
await Promise.all([loadOrgDef(), loadRuns()]);
}
function onRootState(s) {
rootState.value = s;
// root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次,
// 运行中新开的子 run 不会进去,「历史 work run」下拉于是永远少最新那条 —— 用这份实时数据补上
mergeRuns(s.childRuns);
// §七.3:选中 task 的 run 有 SSE → 只更新那一行,不为一次状态变化重拉整棵树
patchSelectedTask(runStateToTaskPatch(s));
// 左栏节点也要跟着 SSE 走。orgNode 是中栏 runId 的来源,selectedNode 是右栏详情的来源,
// 两者可能指向同一行也可能不是(选了 org node 之后又去中栏点了 work node)。
const fresh = (id) => (s.nodes || []).find((n) => n.id === id) || null;
if (orgNode.value) {
const next = fresh(orgNode.value.id);
if (next) {
orgNode.value = next;
// org node 是在**被拾取执行时**才现规划出 work graph 的:选中它的那一刻多半还没有
// childRunId。子 run 一出现就把中栏指过去,不然用户得手动再点一次这个节点。
// 只在栈空时做 —— 用户已经下钻到某一层时不能把他的位置抢走。
if (next.childRunId && !stack.value.length) {
stack.value = [{ runId: next.childRunId, label: next.name || next.defNodeId }];
}
}
}
if (selectedFrom.value === 'root' && selectedNode.value) {
selectedNode.value = fresh(selectedNode.value.id) || selectedNode.value;
}
}
/** 把 SSE 算出来的补丁打到选中那一行上(整棵树的其余部分等 20s 轮询) */
function patchSelectedTask(patch) {
if (!patch || !selectedTaskId.value) return;
const hit = findTask(goals.value, selectedTaskId.value);
if (!hit) return;
goals.value = goals.value.map((g) => (g.goalId !== hit.goal.goalId ? g : {
...g,
tasks: (g.tasks || []).map((t) => (t.taskId === selectedTaskId.value ? { ...t, ...patch } : t)),
}));
}
function mergeRuns(list) {
if (!list || !list.length) return;
const seen = new Set(allRuns.value.map((r) => r.id));
const add = list.filter((r) => !seen.has(r.id));
if (add.length) allRuns.value = [...add, ...allRuns.value];
}
function onMidState(s) {
midState.value = s;
// 中栏刷新后把右栏详情指向同一条最新记录,否则详情面板会停在旧快照上
if (selectedFrom.value === 'mid' && selectedNode.value) {
const fresh = (s.nodes || []).find((n) => n.id === selectedNode.value.id);
if (fresh) selectedNode.value = fresh;
}
}
function onOrgSelect(node) {
orgNode.value = node;
selectedNode.value = node;
selectedFrom.value = 'root';
const runId = node.childRunId || historyRuns.value[0]?.id || null;
stack.value = runId ? [{ runId, label: node.name || node.defNodeId }] : [];
}
function onNodeSelect(node) {
selectedNode.value = node;
selectedFrom.value = 'mid';
}
/** 「子图」tab 的下钻:把子 run 推到栈顶,那一层的控制按钮就作用在子 run 上(§四.B.3) */
function dive({ runId, label }) {
if (!runId) return;
stack.value = [...stack.value, { runId, label: `${label || '子图'} ›子图` }];
selectedNode.value = null;
selectedFrom.value = 'mid';
}
function popTo(i) {
stack.value = i < 0 ? [] : stack.value.slice(0, i + 1);
selectedNode.value = null;
if (i < 0) orgNode.value = null;
}
function switchHistory(runId) {
const label = stack.value[0]?.label || 'work graph';
stack.value = [{ runId, label }];
selectedNode.value = null;
}
function pct(a, b) { return b ? `${Math.min(100, Math.round(((a || 0) / b) * 100))}%` : '0%'; }
function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); }
</script>
<style scoped>
.console { display:flex; flex-direction:column; height:100vh; background:#fff; color:#1a1a1a; }
.top {
display:flex; align-items:center; gap:16px; padding:10px 18px;
border-bottom:1px solid #eee; flex:0 0 auto;
}
.who { display:flex; align-items:center; gap:9px; font-size:14px; }
.back { text-decoration:none; color:#888; font-size:16px; }
.sep { color:#999; font-size:12px; }
.budget { margin-left:auto; display:flex; gap:10px; align-items:center; }
.chip { font-size:11px; color:#444; background:#f7f7f9; border-radius:12px; padding:3px 10px; display:flex; align-items:center; gap:6px; }
.chip i { color:#999; font-style:normal; }
.chip.muted { color:#aaa; }
/* 墙钟停摆:数字保留(那是真实耗时),但要一眼看出它不再走 */
.chip.frozen { background:#fff4e5; color:#8a4b00; }
.chip.frozen i { color:#c08a4a; }
.halt { font-style:normal; font-size:10px; background:#f0d0a8; color:#7a3d00; border-radius:7px; padding:0 5px; cursor:help; }
.bar { display:block; width:56px; height:4px; background:#e4e4e8; border-radius:2px; overflow:hidden; }
.bar em { display:block; height:100%; background:#2b6cb0; }
.crumbs { display:flex; align-items:center; gap:6px; padding:7px 18px; border-bottom:1px solid #f2f2f4; flex:0 0 auto; }
.crumb { border:none; background:none; cursor:pointer; font-size:12px; color:#2b6cb0; padding:2px 4px; }
.crumb.on { color:#1a1a1a; font-weight:600; cursor:default; }
.arrow { color:#ccc; font-size:12px; }
/* 四栏(§七.1)。每一栏都显式指定 grid-column:抽屉态下最左栏是 absolute(脱离流),
靠自动放置的话剩下三栏会整体左移一格、挤进那条 44px 的轨道里 */
.cols { position:relative; flex:1; display:grid; grid-template-columns:280px 320px minmax(0, 1fr) 360px; min-height:0; }
.cols > .goals { grid-column:1; }
.cols > .left { grid-column:2; }
.cols > .mid { grid-column:3; }
.cols > .right { grid-column:4; }
.col { padding:12px 14px; overflow:auto; min-width:0; }
.goals { border-right:1px solid #eee; background:#fcfcfd; }
.left { border-right:1px solid #eee; background:#fcfcfd; }
.right { border-left:1px solid #eee; }
/* ≤1400px:最左栏收成 44px 图标条(org graph 那栏最不能压,它是全局导航) */
.cols.rail { grid-template-columns:44px 320px minmax(0, 1fr) 360px; }
.goals.rail { padding:8px 4px; overflow:hidden; }
.goals.drawer-open {
position:absolute; left:0; top:0; bottom:0; width:280px; z-index:30;
box-shadow:2px 0 14px rgba(0,0,0,.14); padding:12px 14px;
}
.rail-toggle {
border:1px solid #e4e4e8; background:#fff; border-radius:6px; cursor:pointer;
width:32px; height:32px; font-size:14px; color:#666; line-height:1;
}
.goals.drawer-open .rail-toggle { position:absolute; right:6px; top:6px; width:24px; height:24px; }
.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; margin-bottom:6px; }
.ttl { font-weight:700; }
.tag { font-size:10px; color:#999; background:#f0f0f2; padding:1px 7px; border-radius:9px; }
.hint { font-size:11px; color:#999; margin:0 0 8px; line-height:1.6; }
.history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; }
.history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; }
.left-actions {
display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start;
justify-content:flex-end; margin-bottom:8px; padding:8px 12px 0;
}
/* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */
.org-empty { text-align:center; color:#888; padding:34px 10px; }
.org-empty h3 { font-size:13px; color:#555; margin:10px 0 6px; }
.org-empty p { font-size:11px; line-height:1.9; margin:0 0 14px; }
.org-empty code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }
.empty-state { text-align:center; color:#888; padding:60px 24px; }
.es-icon { font-size:34px; color:#d8d8dc; }
.empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; }
.empty-state p { font-size:12px; line-height:1.8; max-width:460px; margin:0 auto; }
.empty-state code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }
/* ≤1100px:沿用既有堆叠,目标栏排最上(DOM 顺序就是它) */
@media (max-width: 1100px) {
.cols, .cols.rail { grid-template-columns:1fr; }
.cols > .goals, .cols > .left, .cols > .mid, .cols > .right { grid-column:auto; }
.goals.drawer-open { position:static; width:auto; box-shadow:none; }
.goals, .left, .right { border:none; border-bottom:1px solid #eee; }
}
</style>