GoalTaskPane.vue
18.3 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
<!--
目标·任务两级栏(27-P3C §七.2)—— 控制台最左侧那一栏。
**选中态永远落在 task 上**:org graph / work graph 都是 task 级的事实,选中一个 goal
画不出图。点 goal 行只做展开收起(外加把「这个 goal 是当前上下文」告诉父组件),
真正驱动右边三栏的是被选中的那条 task。
状态点的颜色一个都不新造,全部来自 goal-task-model.js(那份又是照抄 WorkGraphCanvas 的 .dot):
左栏这个点和画布上那个点说的是同一件事的两级,颜色对不上会让人以为是两套状态。
-->
<template>
<div class="pane">
<NewGoalForm
:company-id="companyId"
:queue-stat="stat"
:slots="slots"
@changed="$emit('refresh')"
@dispatched="onDispatched"
/>
<p v-if="loading && !goals.length" class="tip">加载中…</p>
<p v-else-if="!goals.length" class="tip">
还没有目标。点上面「+ 新目标」把一句需求交给这家公司 —— 它会被拆成若干条任务,逐条执行。
</p>
<ul class="goals">
<li v-for="g in goals" :key="g.goalId" class="goal" :class="{ ctx: g.goalId === selectedGoalId }">
<div class="g-head">
<button class="caret" :title="collapsed.has(g.goalId) ? '展开' : '收起'" @click="toggle(g.goalId)">
{{ collapsed.has(g.goalId) ? '▸' : '▾' }}
</button>
<span class="dot" :class="{ pulse: goalStyle(g.status).pulse }" :style="dotStyle(goalStyle(g.status))" />
<button class="g-title" :title="`${goalStyle(g.status).label} · ${g.description || ''}`" @click="toggle(g.goalId)">
{{ g.title || g.goalId }}
</button>
<span v-if="goalProgressLabel(g)" class="g-prog">{{ goalProgressLabel(g) }}</span>
<button class="more" title="更多" @click="menu = menu === g.goalId ? '' : g.goalId">⋯</button>
</div>
<!-- ⋯ 菜单:编辑 / 下达全部 / 归档 -->
<div v-if="menu === g.goalId" class="menu">
<button @click="startEdit(g)">编辑</button>
<button :disabled="busy" @click="dispatchGoal(g)">下达全部</button>
<button class="danger" :disabled="busy" @click="archiveGoal(g)">归档</button>
</div>
<!-- 内联编辑:description 在 dispatched_at 之后是冻结字段(§四.4),这里直接禁用并说明,
而不是让用户改完再吃一个 409 -->
<div v-if="editing === g.goalId" class="edit">
<input v-model="editTitle" class="in" placeholder="标题" />
<textarea
v-model="editDesc"
class="in ta"
rows="3"
:disabled="!!g.dispatchedAt"
:placeholder="g.dispatchedAt ? '已下达,描述已冻结(它是验收边界)' : '需求描述'"
/>
<div class="e-acts">
<button class="btn primary" :disabled="busy" @click="saveEdit(g)">保存</button>
<button class="btn ghost" :disabled="busy" @click="editing = ''">取消</button>
</div>
</div>
<p v-if="errors[g.goalId]" class="err">{{ errors[g.goalId] }}</p>
<template v-if="!collapsed.has(g.goalId)">
<!-- 拆分中:goal 下一条 task 都还没有,给骨架屏而不是「暂无任务」——
后者会让人以为拆分已经失败了 -->
<div v-if="g.status === 'planning'" class="planning">
<span class="p-txt">正在拆分…</span>
<i class="skel" /><i class="skel" />
</div>
<p v-else-if="g.planningError" class="degraded">
自动拆分失败,已降级为单任务。<em>{{ g.planningError }}</em>
</p>
<ul class="tasks">
<li
v-for="t in g.tasks || []"
:key="t.taskId"
class="task"
:class="{ on: t.taskId === selectedTaskId, blocked: t.status === 'blocked' }"
@click="$emit('select', { goalId: g.goalId, taskId: t.taskId })"
>
<span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))" />
<div class="t-body">
<div class="t-title">
<span
v-if="(t.dependsOn || []).length"
class="dep"
:title="dependsLabel(t, g.tasks || [])"
>⇢</span>{{ t.title || t.taskId }}
</div>
<div class="t-sub">
<!-- 排队中的行把状态标签换成位次:「排队中」谁都看得出来,「前面还有几个」才是
用户点完下达之后真正在问的问题。位次是服务端算的,前端不排(见 queueBadge) -->
<span
v-if="queueBadge(t)"
class="st queue"
:style="{ color: taskStyle(t.status).color }"
:title="queueBadge(t).title"
>{{ queueBadge(t).text }}</span>
<span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span>
<span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span>
</div>
<!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 -->
<div v-if="blockedLabel(t, g.tasks || [])" class="t-blocked" :title="t.blockedReason">
⚠ {{ blockedLabel(t, g.tasks || []) }}
</div>
</div>
<button
v-if="taskActions(t).length"
class="more t-more"
title="更多"
@click.stop="menu = menu === t.taskId ? '' : t.taskId"
>⋯</button>
<!-- 每条 task 的操作菜单。做不成的(重跑)置灰并说明,不给能点却必然 4xx 的入口 -->
<div v-if="menu === t.taskId" class="menu t-menu" @click.stop>
<button
v-for="a in taskActions(t)"
:key="a.key"
:class="{ danger: a.danger }"
:disabled="busy || a.disabled"
:title="a.title"
@click="runTaskAction(g, t, a)"
>{{ a.label }}</button>
</div>
</li>
</ul>
<p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p>
</template>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';
import NewGoalForm from './NewGoalForm.vue';
import {
goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel,
queueBadge, blockedLabel, parseBlockedReason, taskActions, queueStat, dispatchedTaskId,
} from './goal-task-model.js';
const props = defineProps({
companyId: { type: String, required: true },
goals: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
selectedTaskId: { type: String, default: '' },
selectedGoalId: { type: String, default: '' },
/** 执行位 `{ max, used, free }`,服务端原样给的;没给就是 null(见 goal-task-model.readSlots) */
slots: { type: Object, default: null },
});
const emit = defineEmits(['select', 'refresh']);
const collapsed = ref(new Set());
const menu = ref('');
const editing = ref('');
const editTitle = ref('');
const editDesc = ref('');
const busy = ref(false);
const errors = reactive({});
/** 「新目标」表单那句「当前 N 个在跑 / M 个在排队」的数据源:整棵树已经在手,不用再拉一次 */
const stat = computed(() => queueStat(props.goals));
/** 墙钟:running 那条的「12min」要自己走,否则不刷新页面就永远停在选中那一刻 */
const now = ref(Date.now());
let clock = null;
onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); });
onBeforeUnmount(() => clearInterval(clock));
function toggle(goalId) {
const next = new Set(collapsed.value);
if (next.has(goalId)) next.delete(goalId); else next.add(goalId);
collapsed.value = next; // Set 是引用类型,就地增删触发不了渲染
}
/** 空心圈 = 草稿:还没有任何东西发生过。实心 = 已经有过一次真实的状态转换 */
function dotStyle(s) {
return s.hollow
? { background: 'transparent', borderColor: s.color }
: { background: s.color, borderColor: s.color };
}
function startEdit(g) {
menu.value = '';
editing.value = g.goalId;
editTitle.value = g.title || '';
editDesc.value = g.description || '';
}
async function call(method, url, body) {
const r = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
// 服务端的 error / hint 原样透出(本目录惯例):409 的 hint 里写的是确切的自救路径
if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
return d;
}
async function guarded(goalId, fn) {
if (busy.value) return;
busy.value = true;
delete errors[goalId];
try {
await fn();
emit('refresh');
} catch (e) {
errors[goalId] = e.message;
} finally {
busy.value = false;
}
}
function saveEdit(g) {
const patch = { title: editTitle.value };
// 已下达的 goal 不带 description:数据层会 409,而这次编辑的本意只是改标题
if (!g.dispatchedAt) patch.description = editDesc.value;
return guarded(g.goalId, async () => {
await call('PATCH', `/api/company/${g.companyId}/goals/${g.goalId}`, patch);
editing.value = '';
});
}
function dispatchGoal(g) {
menu.value = '';
return guarded(g.goalId, async () => {
const d = await call('POST', `/api/company/${g.companyId}/goals/${g.goalId}/dispatch`);
// 起了 run(或入了队)就把选中态挪过去:用户点「下达全部」想看的就是它。
// 认不出 taskId 时不动选中态 —— 形状的解析只归 dispatchedTaskId 一处
const taskId = dispatchedTaskId(d);
if (taskId) emit('select', { goalId: g.goalId, taskId });
});
}
/**
* task 行的操作。每一个 key 对应一条**已经存在**的端点;置灰项在这里再挡一道,
* 因为「按钮 disabled」只是界面约定,真正的防线得在发请求之前(第 3 条:注释承诺的防线必须存在)。
*/
function runTaskAction(g, t, act) {
if (act.disabled || busy.value) return;
menu.value = '';
const cid = props.companyId;
return guarded(g.goalId, async () => {
if (act.key === 'stop') {
await call('POST', `/api/graph-runs/${t.runId}/stop`);
return;
}
if (act.key === 'release') {
const p = parseBlockedReason(t.blockedReason);
const deps = t.dependsOn || [];
const next = deps.filter((id) => id !== (p && p.upstreamId));
// 少一条才算真删掉。长度没变就说明这次 PATCH 什么都不会改,而接口照样回 200 ——
// 那正是「写了但没生效还不报错」的形态,宁可在这里直接报出来
if (next.length === deps.length) {
throw new Error(`放行失败:blocked 原因指向 ${p && p.upstreamId},但这条任务的依赖里没有它(依赖刚被改过?刷新再看)`);
}
await call('PATCH', `/api/company/${cid}/tasks/${t.taskId}`, { depends_on: next });
return;
}
if (act.key === 'start') {
// 抢到坑就跟过去看它跑(服务端回的 started 未必是这一条:坑是公司级的)
const d = await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`);
emit('select', { goalId: g.goalId, taskId: dispatchedTaskId(d) || t.taskId });
return;
}
if (act.key === 'cancel') {
// 两种来源同一条端点:queued 是「撤出队列」,blocked 是「放弃这条挂起的任务」。
// 服务端按 run 的**真实状态**分路收尾(queued→stopped / stopRun / 没有 run 就直接收),
// 所以前端不需要按 task 状态挑 URL —— 挑一次就是又一个复述服务端取值域的持有者。
//
// **不是归档**:收成「已取消」之后行还留在列表里、还能重跑。归档是另一个操作、另一条端点,
// 混成一个会让「我只是不想现在跑」变成「这条任务消失了」
await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`);
return;
}
if (act.key === 'rerun') {
const d = await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`);
// 重跑起的是**新的一条 run**,选中态要跟过去,否则右边还画着上一次那张已经收尾的图
emit('select', { goalId: g.goalId, taskId: dispatchedTaskId(d) || t.taskId });
return;
}
if (act.key === 'rerun-upstream') {
// 上游 id 由 taskActions 从 blocked_reason 里解出来并挂在动作上 —— 这里不再解一遍,
// 解析口径只归 parseBlockedReason 一处
if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了');
await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`);
emit('select', { goalId: g.goalId, taskId: act.upstreamId });
return;
}
// archive(归档):只盖 archived_at,从列表里移走
await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`);
});
}
function archiveGoal(g) {
menu.value = '';
return guarded(g.goalId, () => call('DELETE', `/api/company/${g.companyId}/goals/${g.goalId}`));
}
function onDispatched(d) {
if (d && d.taskId) emit('select', { goalId: d.goalId, taskId: d.taskId });
emit('refresh');
}
</script>
<style scoped>
/* 本页(GraphConsole)通篇硬编码浅色,这里跟着来 */
.pane { font-size: 12px; color: #1a1a1a; }
.tip { font-size: 11px; color: #999; line-height: 1.8; margin: 8px 2px; }
.tip.small { margin: 2px 0 6px 22px; }
.goals { list-style: none; margin: 0; padding: 0; }
.goal { border-bottom: 1px solid #f0f0f2; padding: 6px 0; }
.goal.ctx { background: #fbfcfe; }
.g-head { display: flex; align-items: center; gap: 5px; }
.caret { border: none; background: none; cursor: pointer; color: #bbb; font-size: 10px; padding: 0 2px; width: 14px; }
.g-title {
border: none; background: none; cursor: pointer; text-align: left; padding: 0;
font-size: 12px; font-weight: 700; color: #1a1a1a; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.g-prog { font-size: 10px; color: #999; white-space: nowrap; }
.more { border: none; background: none; cursor: pointer; color: #bbb; font-size: 13px; padding: 0 2px; line-height: 1; }
.more:hover { color: #666; }
.menu { display: flex; gap: 4px; margin: 4px 0 4px 21px; }
.menu button { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 2px 8px; cursor: pointer; color: #444; }
.menu button:disabled { opacity: .5; cursor: not-allowed; }
.menu .danger { color: #b91c1c; }
.edit { margin: 4px 0 6px 21px; display: flex; flex-direction: column; gap: 4px; }
.in { width: 100%; box-sizing: border-box; font: inherit; font-size: 11px; padding: 5px 7px; border: 1px solid #ddd; border-radius: 5px; background: #fff; color: #1a1a1a; }
.in:disabled { background: #f7f7f9; color: #999; }
.ta { resize: vertical; }
.e-acts { display: flex; gap: 5px; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 3px 10px; cursor: pointer; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.err { margin: 4px 0 4px 21px; font-size: 11px; color: #dc2626; line-height: 1.5; }
.degraded { margin: 4px 0 4px 21px; font-size: 10px; color: #a35b00; line-height: 1.5; }
.degraded em { font-style: normal; color: #b08050; }
.planning { margin: 4px 0 6px 21px; }
.p-txt { font-size: 11px; color: #2b6cb0; }
.skel { display: block; height: 22px; margin-top: 4px; border-radius: 5px; background: linear-gradient(90deg, #f2f2f4 25%, #e9e9ed 37%, #f2f2f4 63%); background-size: 400% 100%; animation: sk 1.4s ease infinite; }
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
.tasks { list-style: none; margin: 2px 0 0; padding: 0; }
.task { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; padding: 4px 6px 4px 15px; border-radius: 5px; cursor: pointer; }
.task:hover { background: #f4f6f9; }
/*
blocked 标红只落在**行**上(左侧红条 + 淡红底 + 红色原因行),圆点和状态标签仍是色板里的
橙 #e08a00:那个点与 WorkGraphCanvas 的 .dot、GraphRunPane 的 .run-status 是同一件事的两级,
改颜色会让画布上的橙节点和左栏的红任务看起来是两套状态(goal-task-model.js 顶部那条约定)。
行级强调既达到「一眼看见有东西卡住了」,又不动跨组件的颜色语义。
*/
.task.blocked { background: #fff5f5; box-shadow: inset 2px 0 0 #d33; }
.task.on { background: #eef4fb; box-shadow: inset 2px 0 0 #2b6cb0; }
/* 选中 + blocked:底色跟选中走(选中态要看得出来),红条保留(卡住这件事不能被选中盖掉) */
.task.blocked.on { background: #eef4fb; box-shadow: inset 2px 0 0 #d33; }
.t-blocked { flex-basis: 100%; margin-left: 14px; font-size: 10px; line-height: 1.5; color: #d33; }
.t-more { align-self: center; opacity: 0; }
.task:hover .t-more, .task.on .t-more { opacity: 1; }
.t-menu { flex-basis: 100%; margin: 2px 0 2px 14px; flex-wrap: wrap; }
.dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; box-sizing: border-box; margin-top: 4px; }
.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.t-body { min-width: 0; flex: 1; }
.t-title {
font-size: 12px; line-height: 1.4; color: #1a1a1a;
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dep { color: #c96a00; margin-right: 3px; cursor: help; }
.t-sub { font-size: 10px; color: #999; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st { font-weight: 600; }
</style>