TaskDetailModal.vue
12.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
<!--
任务详情/新建弹窗 —— TaskTable.vue 专用。`task` 为 null 时是新建模式。
增删改查落地方式:
- 读:`task` prop 本身就是 taskView() 的完整形状,摊平自 TaskBoard 已经拉好的 goals 树,
不再单独请求(后端目前也没有单任务 GET 端点)。
- 改:PATCH /:cid/tasks/:taskId(title/priority 随时可改;description/mode 在 queuedAt
非空后会被服务端 409 拒绝,这里提前禁用输入框并给出同样的理由)。
- 增:POST /:cid/tasks,固定带 draft:true —— 先建草稿,不自动派发。
- 删:DELETE /:cid/tasks/:taskId(归档,运行中/排队中会 409,错误原样显示)。
- 状态流转(下达/开始/取消/重跑/强制放行/停止):`taskActions()` 给可执行操作,
这里的 runAction() 是 GoalTaskPane.vue 的 runTaskAction() 的等价物,端点表照抄不重新设计。
-->
<template>
<div class="overlay" @click.self="$emit('close')">
<div class="modal">
<header class="head">
<b>{{ isCreate ? '新建任务' : '任务详情' }}</b>
<button class="x" @click="$emit('close')">×</button>
</header>
<div class="body">
<template v-if="!isCreate">
<div class="row">
<span class="dot" :style="dotStyle(taskStyle(task.status))"></span>
{{ taskStyle(task.status).label }}
<span v-if="queueBadge(task)" class="qb" :title="queueBadge(task).title">{{ queueBadge(task).text }}</span>
<span class="tid">{{ task.taskId }}</span>
</div>
<p v-if="task.status === 'blocked'" class="blocked-hint">{{ blockedLabel(task) || '挂起,原因未知' }}</p>
</template>
<label class="field">
<span class="lbl">标题</span>
<input v-model="form.title" class="in" :placeholder="isCreate ? '(留空则用描述自动生成)' : ''" />
</label>
<label class="field">
<span class="lbl">任务描述{{ frozen ? '(已下达,不可改)' : '' }}</span>
<textarea v-model="form.description" class="in ta" rows="4" :disabled="frozen"
placeholder="要交给 agent 执行的完整描述"></textarea>
</label>
<div class="grid2">
<label class="field">
<span class="lbl">优先级</span>
<input v-model="form.priority" class="in" placeholder="P1" />
</label>
<label class="field">
<span class="lbl">执行模式{{ frozen ? '(已下达,不可改)' : '' }}</span>
<select v-model="form.mode" class="in" :disabled="frozen">
<option value="manual">manual(需人点「立即开始」)</option>
<option value="auto">auto(有空位自动开跑)</option>
</select>
</label>
</div>
<label v-if="isCreate" class="field">
<span class="lbl">所属目标</span>
<select v-model="form.goalId" class="in">
<option value="">(自动新建一个同名目标)</option>
<option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option>
</select>
</label>
<template v-if="!isCreate">
<div class="grid2">
<div class="field ro"><span class="lbl">所属目标</span><span>{{ goalTitle }}</span></div>
<div class="field ro"><span class="lbl">负责 Agent</span><span>{{ assigneeLabel }}</span></div>
</div>
<div class="grid2">
<div class="field ro"><span class="lbl">创建时间</span><span>{{ fmt(task.createdAt) }}</span></div>
<div class="field ro"><span class="lbl">下达时间</span><span>{{ fmt(task.queuedAt) }}</span></div>
</div>
<div class="grid2">
<div class="field ro"><span class="lbl">开始时间</span><span>{{ fmt(task.startedAt) }}</span></div>
<div class="field ro"><span class="lbl">结束时间</span><span>{{ fmt(task.finishedAt) }}</span></div>
</div>
<div v-if="task.dependsOn && task.dependsOn.length" class="field ro">
<span class="lbl">依赖任务</span>
<span>{{ task.dependsOn.join('、') }}</span>
</div>
<div v-if="task.budget" class="field ro">
<span class="lbl">预算</span>
<span class="mono">{{ JSON.stringify(task.budget) }}</span>
</div>
<router-link class="graph-link" :to="`/${companyId}/graph-console?goal=${task.goalId}&task=${task.taskId}`">
🕸️ 查看执行图
</router-link>
</template>
<p v-if="error" class="err">{{ error }}</p>
</div>
<footer class="foot">
<div class="actions" v-if="!isCreate">
<button v-if="task.status === 'draft'" class="btn" :disabled="busy" @click="enqueue">下达</button>
<button
v-for="act in actions" :key="act.key"
class="btn" :class="{ danger: act.danger }"
:disabled="act.disabled || busy" :title="act.title"
@click="runAction(act)"
>{{ act.label }}</button>
</div>
<div class="spacer"></div>
<button class="btn ghost" @click="$emit('close')">取消</button>
<button class="btn primary" :disabled="busy || !form.description.trim()" @click="save">
{{ isCreate ? '创建' : '保存' }}
</button>
</footer>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import {
taskStyle, queueBadge, blockedLabel, taskActions, parseBlockedReason,
} from '../graph-console/goal-task-model.js';
const props = defineProps({
companyId: { type: String, required: true },
task: { type: Object, default: null },
goals: { type: Array, default: () => [] },
agents: { type: Array, default: () => [] },
});
const emit = defineEmits(['close', 'saved']);
const isCreate = computed(() => !props.task);
/** 已经下达过(queuedAt 非空):description/mode 服务端会 409,界面提前禁用 */
const frozen = computed(() => !isCreate.value && !!props.task.queuedAt);
const form = reactive({
title: props.task?.title || '',
description: props.task?.description || '',
priority: props.task?.priority || 'P1',
mode: props.task?.mode || 'manual',
goalId: '',
});
const goalTitle = computed(() => {
const t = props.task;
if (!t) return '';
const g = (props.goals || []).find((x) => x.goalId === t.goalId);
return (g && g.title) || t.goalId;
});
const assigneeLabel = computed(() => {
const t = props.task;
if (!t || !t.assigneeAgentId) return '未分配';
if (t.assigneeAgentId === 'org-graph') return '组织图执行';
const a = (props.agents || []).find((x) => x.id === t.assigneeAgentId);
return (a && a.name) || t.assigneeAgentId;
});
const actions = computed(() => (props.task ? taskActions(props.task) : []));
const busy = ref(false);
const error = ref('');
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(() => ({}));
if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
return d;
}
async function guarded(fn) {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await fn();
emit('saved');
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
function save() {
const cid = props.companyId;
if (isCreate.value) {
return guarded(() => call('POST', `/api/company/${cid}/tasks`, {
task: form.description.trim(),
goal_id: form.goalId || undefined,
priority: form.priority || undefined,
mode: form.mode,
draft: true,
}));
}
const patch = { title: form.title, priority: form.priority };
if (!frozen.value) {
patch.description = form.description;
patch.mode = form.mode;
}
return guarded(() => call('PATCH', `/api/company/${cid}/tasks/${props.task.taskId}`, patch));
}
function enqueue() {
return guarded(() => call('POST', `/api/company/${props.companyId}/tasks/${props.task.taskId}/enqueue`));
}
/** taskActions() 给的 key → 具体端点,逐条抄自 GoalTaskPane.vue 的 runTaskAction */
function runAction(act) {
if (act.disabled || busy.value) return;
const cid = props.companyId;
const t = props.task;
return guarded(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));
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') {
await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`);
return;
}
if (act.key === 'cancel') {
await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`);
return;
}
if (act.key === 'rerun') {
await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`);
return;
}
if (act.key === 'rerun-upstream') {
if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了');
await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`);
return;
}
// archive —— 归档后这条 task 会从 goals 树里消失,父组件在 @saved 里发现找不到就会关掉弹窗
await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`);
});
}
function dotStyle(s) {
return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
}
function fmt(iso) {
if (!iso) return '—';
try { return new Date(iso).toLocaleString('zh-CN'); } catch { return iso; }
}
</script>
<style scoped>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { width: min(560px, 92vw); max-height: 88vh; display: flex; flex-direction: column; background: #fff; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,.2); }
.head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #eee; }
.head b { font-size: 14px; }
.x { border: none; background: none; font-size: 18px; cursor: pointer; color: #999; line-height: 1; }
.x:hover { color: #333; }
.body { padding: 14px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; font-size: 12px; }
.row { display: flex; align-items: center; gap: 6px; color: #444; }
.tid { margin-left: auto; color: #bbb; font-size: 11px; font-family: monospace; }
.blocked-hint { margin: 0; color: #d33; font-size: 11px; }
.qb { color: #7aa7dd; font-size: 11px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field.ro span:last-child { color: #333; }
.lbl { font-size: 11px; color: #888; }
.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; }
.in:disabled { background: #f7f7f9; color: #999; }
.ta { resize: vertical; font-family: inherit; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mono { font-family: monospace; font-size: 11px; color: #555; }
.graph-link { align-self: flex-start; font-size: 11px; color: #2b6cb0; text-decoration: none; }
.graph-link:hover { text-decoration: underline; }
.err { margin: 0; color: #dc2626; font-size: 11px; }
.foot { display: flex; align-items: center; gap: 6px; padding: 12px 16px; border-top: 1px solid #eee; flex-wrap: wrap; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.spacer { flex: 1; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.danger { color: #b91c1c; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
</style>