Commit a09b519ba296ff97afb6a4c5f4ff0af3d74842bc
1 parent
5c7e5ce8
feat(task-board): 任务看板改造成可搜索/筛选/排序的表格视图
原来的 /task-board 挂的是 GoalTaskPane(折叠式目标→任务列表),字段少、 没有搜索筛选排序、点任务是整页跳转。GoalTaskPane 同时被 GraphConsole 左栏复用,原地重写会连带改掉那边,所以新做 TaskTable + TaskDetailModal 两个组件只给 TaskBoard 用。 - TaskTable:表格列(状态/描述/所属目标/负责Agent/优先级/创建·开始·结束 时间),搜索框 + 状态/目标/负责人筛选 + 点表头排序 - TaskDetailModal:点一行弹出详情,可编辑 + 增删改查(复用既有 taskActions()/PATCH/POST/DELETE 端点,没加新接口) - taskView() 补上 assigneeAgentId 字段(DB 列早就有,只是没读侧暴露) 已用真实登录态跑通端到端验证(建临时测试公司+任务,搜索/筛选/排序/ 增删改查全走了一遍,控制台零报错),确认 GraphConsole 侧栏未受影响。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XUvQ2xSFk7YoavE5SQENdw
Showing
4 changed files
with
576 additions
and
28 deletions
server/routes/company.js
| @@ -1022,6 +1022,7 @@ function taskView(t, progress = null, positions = null) { | @@ -1022,6 +1022,7 @@ function taskView(t, progress = null, positions = null) { | ||
| 1022 | queuePosition: (positions && positions.get(t.id)) || null, | 1022 | queuePosition: (positions && positions.get(t.id)) || null, |
| 1023 | blockedReason: t.blocked_reason, | 1023 | blockedReason: t.blocked_reason, |
| 1024 | reportTo: t.report_to, | 1024 | reportTo: t.report_to, |
| 1025 | + assigneeAgentId: t.assignee_agent_id || null, | ||
| 1025 | createdAt: t.created_at, | 1026 | createdAt: t.created_at, |
| 1026 | queuedAt: t.queued_at, | 1027 | queuedAt: t.queued_at, |
| 1027 | startedAt: t.started_at, | 1028 | startedAt: t.started_at, |
web/src/components/task-board/TaskDetailModal.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + 任务详情/新建弹窗 —— TaskTable.vue 专用。`task` 为 null 时是新建模式。 | ||
| 3 | + | ||
| 4 | + 增删改查落地方式: | ||
| 5 | + - 读:`task` prop 本身就是 taskView() 的完整形状,摊平自 TaskBoard 已经拉好的 goals 树, | ||
| 6 | + 不再单独请求(后端目前也没有单任务 GET 端点)。 | ||
| 7 | + - 改:PATCH /:cid/tasks/:taskId(title/priority 随时可改;description/mode 在 queuedAt | ||
| 8 | + 非空后会被服务端 409 拒绝,这里提前禁用输入框并给出同样的理由)。 | ||
| 9 | + - 增:POST /:cid/tasks,固定带 draft:true —— 先建草稿,不自动派发。 | ||
| 10 | + - 删:DELETE /:cid/tasks/:taskId(归档,运行中/排队中会 409,错误原样显示)。 | ||
| 11 | + - 状态流转(下达/开始/取消/重跑/强制放行/停止):`taskActions()` 给可执行操作, | ||
| 12 | + 这里的 runAction() 是 GoalTaskPane.vue 的 runTaskAction() 的等价物,端点表照抄不重新设计。 | ||
| 13 | +--> | ||
| 14 | +<template> | ||
| 15 | + <div class="overlay" @click.self="$emit('close')"> | ||
| 16 | + <div class="modal"> | ||
| 17 | + <header class="head"> | ||
| 18 | + <b>{{ isCreate ? '新建任务' : '任务详情' }}</b> | ||
| 19 | + <button class="x" @click="$emit('close')">×</button> | ||
| 20 | + </header> | ||
| 21 | + | ||
| 22 | + <div class="body"> | ||
| 23 | + <template v-if="!isCreate"> | ||
| 24 | + <div class="row"> | ||
| 25 | + <span class="dot" :style="dotStyle(taskStyle(task.status))"></span> | ||
| 26 | + {{ taskStyle(task.status).label }} | ||
| 27 | + <span v-if="queueBadge(task)" class="qb" :title="queueBadge(task).title">{{ queueBadge(task).text }}</span> | ||
| 28 | + <span class="tid">{{ task.taskId }}</span> | ||
| 29 | + </div> | ||
| 30 | + <p v-if="task.status === 'blocked'" class="blocked-hint">{{ blockedLabel(task) || '挂起,原因未知' }}</p> | ||
| 31 | + </template> | ||
| 32 | + | ||
| 33 | + <label class="field"> | ||
| 34 | + <span class="lbl">标题</span> | ||
| 35 | + <input v-model="form.title" class="in" :placeholder="isCreate ? '(留空则用描述自动生成)' : ''" /> | ||
| 36 | + </label> | ||
| 37 | + | ||
| 38 | + <label class="field"> | ||
| 39 | + <span class="lbl">任务描述{{ frozen ? '(已下达,不可改)' : '' }}</span> | ||
| 40 | + <textarea v-model="form.description" class="in ta" rows="4" :disabled="frozen" | ||
| 41 | + placeholder="要交给 agent 执行的完整描述"></textarea> | ||
| 42 | + </label> | ||
| 43 | + | ||
| 44 | + <div class="grid2"> | ||
| 45 | + <label class="field"> | ||
| 46 | + <span class="lbl">优先级</span> | ||
| 47 | + <input v-model="form.priority" class="in" placeholder="P1" /> | ||
| 48 | + </label> | ||
| 49 | + <label class="field"> | ||
| 50 | + <span class="lbl">执行模式{{ frozen ? '(已下达,不可改)' : '' }}</span> | ||
| 51 | + <select v-model="form.mode" class="in" :disabled="frozen"> | ||
| 52 | + <option value="manual">manual(需人点「立即开始」)</option> | ||
| 53 | + <option value="auto">auto(有空位自动开跑)</option> | ||
| 54 | + </select> | ||
| 55 | + </label> | ||
| 56 | + </div> | ||
| 57 | + | ||
| 58 | + <label v-if="isCreate" class="field"> | ||
| 59 | + <span class="lbl">所属目标</span> | ||
| 60 | + <select v-model="form.goalId" class="in"> | ||
| 61 | + <option value="">(自动新建一个同名目标)</option> | ||
| 62 | + <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option> | ||
| 63 | + </select> | ||
| 64 | + </label> | ||
| 65 | + | ||
| 66 | + <template v-if="!isCreate"> | ||
| 67 | + <div class="grid2"> | ||
| 68 | + <div class="field ro"><span class="lbl">所属目标</span><span>{{ goalTitle }}</span></div> | ||
| 69 | + <div class="field ro"><span class="lbl">负责 Agent</span><span>{{ assigneeLabel }}</span></div> | ||
| 70 | + </div> | ||
| 71 | + <div class="grid2"> | ||
| 72 | + <div class="field ro"><span class="lbl">创建时间</span><span>{{ fmt(task.createdAt) }}</span></div> | ||
| 73 | + <div class="field ro"><span class="lbl">下达时间</span><span>{{ fmt(task.queuedAt) }}</span></div> | ||
| 74 | + </div> | ||
| 75 | + <div class="grid2"> | ||
| 76 | + <div class="field ro"><span class="lbl">开始时间</span><span>{{ fmt(task.startedAt) }}</span></div> | ||
| 77 | + <div class="field ro"><span class="lbl">结束时间</span><span>{{ fmt(task.finishedAt) }}</span></div> | ||
| 78 | + </div> | ||
| 79 | + <div v-if="task.dependsOn && task.dependsOn.length" class="field ro"> | ||
| 80 | + <span class="lbl">依赖任务</span> | ||
| 81 | + <span>{{ task.dependsOn.join('、') }}</span> | ||
| 82 | + </div> | ||
| 83 | + <div v-if="task.budget" class="field ro"> | ||
| 84 | + <span class="lbl">预算</span> | ||
| 85 | + <span class="mono">{{ JSON.stringify(task.budget) }}</span> | ||
| 86 | + </div> | ||
| 87 | + <router-link class="graph-link" :to="`/${companyId}/graph-console?goal=${task.goalId}&task=${task.taskId}`"> | ||
| 88 | + 🕸️ 查看执行图 | ||
| 89 | + </router-link> | ||
| 90 | + </template> | ||
| 91 | + | ||
| 92 | + <p v-if="error" class="err">{{ error }}</p> | ||
| 93 | + </div> | ||
| 94 | + | ||
| 95 | + <footer class="foot"> | ||
| 96 | + <div class="actions" v-if="!isCreate"> | ||
| 97 | + <button v-if="task.status === 'draft'" class="btn" :disabled="busy" @click="enqueue">下达</button> | ||
| 98 | + <button | ||
| 99 | + v-for="act in actions" :key="act.key" | ||
| 100 | + class="btn" :class="{ danger: act.danger }" | ||
| 101 | + :disabled="act.disabled || busy" :title="act.title" | ||
| 102 | + @click="runAction(act)" | ||
| 103 | + >{{ act.label }}</button> | ||
| 104 | + </div> | ||
| 105 | + <div class="spacer"></div> | ||
| 106 | + <button class="btn ghost" @click="$emit('close')">取消</button> | ||
| 107 | + <button class="btn primary" :disabled="busy || !form.description.trim()" @click="save"> | ||
| 108 | + {{ isCreate ? '创建' : '保存' }} | ||
| 109 | + </button> | ||
| 110 | + </footer> | ||
| 111 | + </div> | ||
| 112 | + </div> | ||
| 113 | +</template> | ||
| 114 | + | ||
| 115 | +<script setup> | ||
| 116 | +import { ref, reactive, computed } from 'vue'; | ||
| 117 | +import { | ||
| 118 | + taskStyle, queueBadge, blockedLabel, taskActions, parseBlockedReason, | ||
| 119 | +} from '../graph-console/goal-task-model.js'; | ||
| 120 | + | ||
| 121 | +const props = defineProps({ | ||
| 122 | + companyId: { type: String, required: true }, | ||
| 123 | + task: { type: Object, default: null }, | ||
| 124 | + goals: { type: Array, default: () => [] }, | ||
| 125 | + agents: { type: Array, default: () => [] }, | ||
| 126 | +}); | ||
| 127 | +const emit = defineEmits(['close', 'saved']); | ||
| 128 | + | ||
| 129 | +const isCreate = computed(() => !props.task); | ||
| 130 | +/** 已经下达过(queuedAt 非空):description/mode 服务端会 409,界面提前禁用 */ | ||
| 131 | +const frozen = computed(() => !isCreate.value && !!props.task.queuedAt); | ||
| 132 | + | ||
| 133 | +const form = reactive({ | ||
| 134 | + title: props.task?.title || '', | ||
| 135 | + description: props.task?.description || '', | ||
| 136 | + priority: props.task?.priority || 'P1', | ||
| 137 | + mode: props.task?.mode || 'manual', | ||
| 138 | + goalId: '', | ||
| 139 | +}); | ||
| 140 | + | ||
| 141 | +const goalTitle = computed(() => { | ||
| 142 | + const t = props.task; | ||
| 143 | + if (!t) return ''; | ||
| 144 | + const g = (props.goals || []).find((x) => x.goalId === t.goalId); | ||
| 145 | + return (g && g.title) || t.goalId; | ||
| 146 | +}); | ||
| 147 | + | ||
| 148 | +const assigneeLabel = computed(() => { | ||
| 149 | + const t = props.task; | ||
| 150 | + if (!t || !t.assigneeAgentId) return '未分配'; | ||
| 151 | + if (t.assigneeAgentId === 'org-graph') return '组织图执行'; | ||
| 152 | + const a = (props.agents || []).find((x) => x.id === t.assigneeAgentId); | ||
| 153 | + return (a && a.name) || t.assigneeAgentId; | ||
| 154 | +}); | ||
| 155 | + | ||
| 156 | +const actions = computed(() => (props.task ? taskActions(props.task) : [])); | ||
| 157 | + | ||
| 158 | +const busy = ref(false); | ||
| 159 | +const error = ref(''); | ||
| 160 | + | ||
| 161 | +async function call(method, url, body) { | ||
| 162 | + const r = await fetch(url, { | ||
| 163 | + method, | ||
| 164 | + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, | ||
| 165 | + body: body === undefined ? undefined : JSON.stringify(body), | ||
| 166 | + }); | ||
| 167 | + const d = await r.json().catch(() => ({})); | ||
| 168 | + if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · ')); | ||
| 169 | + return d; | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +async function guarded(fn) { | ||
| 173 | + if (busy.value) return; | ||
| 174 | + busy.value = true; | ||
| 175 | + error.value = ''; | ||
| 176 | + try { | ||
| 177 | + await fn(); | ||
| 178 | + emit('saved'); | ||
| 179 | + } catch (e) { | ||
| 180 | + error.value = e.message; | ||
| 181 | + } finally { | ||
| 182 | + busy.value = false; | ||
| 183 | + } | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +function save() { | ||
| 187 | + const cid = props.companyId; | ||
| 188 | + if (isCreate.value) { | ||
| 189 | + return guarded(() => call('POST', `/api/company/${cid}/tasks`, { | ||
| 190 | + task: form.description.trim(), | ||
| 191 | + goal_id: form.goalId || undefined, | ||
| 192 | + priority: form.priority || undefined, | ||
| 193 | + mode: form.mode, | ||
| 194 | + draft: true, | ||
| 195 | + })); | ||
| 196 | + } | ||
| 197 | + const patch = { title: form.title, priority: form.priority }; | ||
| 198 | + if (!frozen.value) { | ||
| 199 | + patch.description = form.description; | ||
| 200 | + patch.mode = form.mode; | ||
| 201 | + } | ||
| 202 | + return guarded(() => call('PATCH', `/api/company/${cid}/tasks/${props.task.taskId}`, patch)); | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +function enqueue() { | ||
| 206 | + return guarded(() => call('POST', `/api/company/${props.companyId}/tasks/${props.task.taskId}/enqueue`)); | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +/** taskActions() 给的 key → 具体端点,逐条抄自 GoalTaskPane.vue 的 runTaskAction */ | ||
| 210 | +function runAction(act) { | ||
| 211 | + if (act.disabled || busy.value) return; | ||
| 212 | + const cid = props.companyId; | ||
| 213 | + const t = props.task; | ||
| 214 | + return guarded(async () => { | ||
| 215 | + if (act.key === 'stop') { | ||
| 216 | + await call('POST', `/api/graph-runs/${t.runId}/stop`); | ||
| 217 | + return; | ||
| 218 | + } | ||
| 219 | + if (act.key === 'release') { | ||
| 220 | + const p = parseBlockedReason(t.blockedReason); | ||
| 221 | + const deps = t.dependsOn || []; | ||
| 222 | + const next = deps.filter((id) => id !== (p && p.upstreamId)); | ||
| 223 | + if (next.length === deps.length) { | ||
| 224 | + throw new Error(`放行失败:blocked 原因指向 ${p && p.upstreamId},但这条任务的依赖里没有它(依赖刚被改过?刷新再看)`); | ||
| 225 | + } | ||
| 226 | + await call('PATCH', `/api/company/${cid}/tasks/${t.taskId}`, { depends_on: next }); | ||
| 227 | + return; | ||
| 228 | + } | ||
| 229 | + if (act.key === 'start') { | ||
| 230 | + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`); | ||
| 231 | + return; | ||
| 232 | + } | ||
| 233 | + if (act.key === 'cancel') { | ||
| 234 | + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`); | ||
| 235 | + return; | ||
| 236 | + } | ||
| 237 | + if (act.key === 'rerun') { | ||
| 238 | + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`); | ||
| 239 | + return; | ||
| 240 | + } | ||
| 241 | + if (act.key === 'rerun-upstream') { | ||
| 242 | + if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了'); | ||
| 243 | + await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`); | ||
| 244 | + return; | ||
| 245 | + } | ||
| 246 | + // archive —— 归档后这条 task 会从 goals 树里消失,父组件在 @saved 里发现找不到就会关掉弹窗 | ||
| 247 | + await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`); | ||
| 248 | + }); | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +function dotStyle(s) { | ||
| 252 | + return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color }; | ||
| 253 | +} | ||
| 254 | +function fmt(iso) { | ||
| 255 | + if (!iso) return '—'; | ||
| 256 | + try { return new Date(iso).toLocaleString('zh-CN'); } catch { return iso; } | ||
| 257 | +} | ||
| 258 | +</script> | ||
| 259 | + | ||
| 260 | +<style scoped> | ||
| 261 | +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; } | ||
| 262 | +.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); } | ||
| 263 | + | ||
| 264 | +.head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #eee; } | ||
| 265 | +.head b { font-size: 14px; } | ||
| 266 | +.x { border: none; background: none; font-size: 18px; cursor: pointer; color: #999; line-height: 1; } | ||
| 267 | +.x:hover { color: #333; } | ||
| 268 | + | ||
| 269 | +.body { padding: 14px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; font-size: 12px; } | ||
| 270 | +.row { display: flex; align-items: center; gap: 6px; color: #444; } | ||
| 271 | +.tid { margin-left: auto; color: #bbb; font-size: 11px; font-family: monospace; } | ||
| 272 | +.blocked-hint { margin: 0; color: #d33; font-size: 11px; } | ||
| 273 | +.qb { color: #7aa7dd; font-size: 11px; } | ||
| 274 | + | ||
| 275 | +.field { display: flex; flex-direction: column; gap: 4px; } | ||
| 276 | +.field.ro span:last-child { color: #333; } | ||
| 277 | +.lbl { font-size: 11px; color: #888; } | ||
| 278 | +.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; } | ||
| 279 | +.in:disabled { background: #f7f7f9; color: #999; } | ||
| 280 | +.ta { resize: vertical; font-family: inherit; } | ||
| 281 | +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } | ||
| 282 | +.mono { font-family: monospace; font-size: 11px; color: #555; } | ||
| 283 | +.graph-link { align-self: flex-start; font-size: 11px; color: #2b6cb0; text-decoration: none; } | ||
| 284 | +.graph-link:hover { text-decoration: underline; } | ||
| 285 | + | ||
| 286 | +.err { margin: 0; color: #dc2626; font-size: 11px; } | ||
| 287 | + | ||
| 288 | +.foot { display: flex; align-items: center; gap: 6px; padding: 12px 16px; border-top: 1px solid #eee; flex-wrap: wrap; } | ||
| 289 | +.actions { display: flex; gap: 6px; flex-wrap: wrap; } | ||
| 290 | +.spacer { flex: 1; } | ||
| 291 | +.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; } | ||
| 292 | +.btn:disabled { opacity: .5; cursor: not-allowed; } | ||
| 293 | +.btn.danger { color: #b91c1c; } | ||
| 294 | +.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; } | ||
| 295 | +.btn.ghost { background: transparent; } | ||
| 296 | +</style> |
web/src/components/task-board/TaskTable.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + 任务看板的表格视图 —— TaskBoard.vue 专用,不是 GoalTaskPane 的替代品(那个还在 | ||
| 3 | + GraphConsole 左栏服役,两边分开维护)。数据摊平自 goals[].tasks[],不额外请求。 | ||
| 4 | +--> | ||
| 5 | +<template> | ||
| 6 | + <div class="tt"> | ||
| 7 | + <div class="toolbar"> | ||
| 8 | + <input v-model="search" class="in search" placeholder="搜索任务标题 / 描述…" /> | ||
| 9 | + <select v-model="statusFilter" class="in sel"> | ||
| 10 | + <option value="">全部状态</option> | ||
| 11 | + <option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ taskStyle(s).label }}</option> | ||
| 12 | + </select> | ||
| 13 | + <select v-model="goalFilter" class="in sel"> | ||
| 14 | + <option value="">全部目标</option> | ||
| 15 | + <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option> | ||
| 16 | + </select> | ||
| 17 | + <select v-model="assigneeFilter" class="in sel"> | ||
| 18 | + <option value="">全部负责人</option> | ||
| 19 | + <option v-for="o in assigneeOptions" :key="o.value" :value="o.value">{{ o.label }}</option> | ||
| 20 | + </select> | ||
| 21 | + <span class="count">{{ sorted.length }} / {{ flatTasks.length }} 条</span> | ||
| 22 | + <button class="btn primary" @click="$emit('create')">+ 新建任务</button> | ||
| 23 | + </div> | ||
| 24 | + | ||
| 25 | + <div class="scroll"> | ||
| 26 | + <table> | ||
| 27 | + <thead> | ||
| 28 | + <tr> | ||
| 29 | + <th class="c-status" @click="toggleSort('status')">状态{{ sortArrow('status') }}</th> | ||
| 30 | + <th class="c-desc">任务描述</th> | ||
| 31 | + <th class="c-goal">所属目标</th> | ||
| 32 | + <th class="c-assignee">负责 Agent</th> | ||
| 33 | + <th class="c-priority" @click="toggleSort('priority')">优先级{{ sortArrow('priority') }}</th> | ||
| 34 | + <th class="c-time" @click="toggleSort('createdAt')">创建时间{{ sortArrow('createdAt') }}</th> | ||
| 35 | + <th class="c-time" @click="toggleSort('startedAt')">开始时间{{ sortArrow('startedAt') }}</th> | ||
| 36 | + <th class="c-time" @click="toggleSort('finishedAt')">结束时间{{ sortArrow('finishedAt') }}</th> | ||
| 37 | + </tr> | ||
| 38 | + </thead> | ||
| 39 | + <tbody> | ||
| 40 | + <tr v-if="loading && !flatTasks.length"><td colspan="8" class="empty">加载中…</td></tr> | ||
| 41 | + <tr v-else-if="!sorted.length"><td colspan="8" class="empty">没有匹配的任务</td></tr> | ||
| 42 | + <tr | ||
| 43 | + v-for="t in sorted" :key="t.taskId" | ||
| 44 | + class="row" :class="{ blocked: t.status === 'blocked' }" | ||
| 45 | + @click="$emit('select', t)" | ||
| 46 | + > | ||
| 47 | + <td class="c-status"> | ||
| 48 | + <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))"></span> | ||
| 49 | + {{ taskStyle(t.status).label }} | ||
| 50 | + <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span> | ||
| 51 | + </td> | ||
| 52 | + <td class="c-desc"> | ||
| 53 | + <div class="title">{{ t.title || '(无标题)' }}</div> | ||
| 54 | + <div v-if="t.description" class="desc" :title="t.description">{{ t.description }}</div> | ||
| 55 | + <div v-if="t.status === 'blocked'" class="blocked-line">{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}</div> | ||
| 56 | + </td> | ||
| 57 | + <td class="c-goal">{{ t.goalTitle }}</td> | ||
| 58 | + <td class="c-assignee">{{ assigneeLabel(t) }}</td> | ||
| 59 | + <td class="c-priority">{{ t.priority || '—' }}</td> | ||
| 60 | + <td class="c-time">{{ fmt(t.createdAt) }}</td> | ||
| 61 | + <td class="c-time">{{ fmt(t.startedAt) }}</td> | ||
| 62 | + <td class="c-time">{{ fmt(t.finishedAt) }}</td> | ||
| 63 | + </tr> | ||
| 64 | + </tbody> | ||
| 65 | + </table> | ||
| 66 | + </div> | ||
| 67 | + </div> | ||
| 68 | +</template> | ||
| 69 | + | ||
| 70 | +<script setup> | ||
| 71 | +import { ref, computed } from 'vue'; | ||
| 72 | +import { taskStyle, queueBadge, blockedLabel } from '../graph-console/goal-task-model.js'; | ||
| 73 | + | ||
| 74 | +const props = defineProps({ | ||
| 75 | + companyId: { type: String, required: true }, | ||
| 76 | + goals: { type: Array, default: () => [] }, | ||
| 77 | + agents: { type: Array, default: () => [] }, | ||
| 78 | + loading: { type: Boolean, default: false }, | ||
| 79 | +}); | ||
| 80 | +defineEmits(['select', 'create']); | ||
| 81 | + | ||
| 82 | +const STATUS_OPTIONS = ['draft', 'queued', 'running', 'completed', 'failed', 'blocked', 'cancelled']; | ||
| 83 | + | ||
| 84 | +const flatTasks = computed(() => { | ||
| 85 | + const out = []; | ||
| 86 | + for (const g of props.goals || []) { | ||
| 87 | + for (const t of g.tasks || []) out.push({ ...t, goalTitle: g.title || g.goalId }); | ||
| 88 | + } | ||
| 89 | + return out; | ||
| 90 | +}); | ||
| 91 | + | ||
| 92 | +/** 同 goal 下的兄弟任务,blockedLabel 解析「上游 #N 未完成」要用 */ | ||
| 93 | +function siblingsOf(t) { | ||
| 94 | + const g = (props.goals || []).find((x) => x.goalId === t.goalId); | ||
| 95 | + return (g && g.tasks) || []; | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +function dotStyle(s) { | ||
| 99 | + return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color }; | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +const agentNameById = computed(() => { | ||
| 103 | + const m = new Map(); | ||
| 104 | + for (const a of props.agents || []) m.set(a.id, a.name || a.id); | ||
| 105 | + return m; | ||
| 106 | +}); | ||
| 107 | + | ||
| 108 | +/** assignee_agent_id 是 null(还没派)/ 'org-graph'(被组织图接管,没有真实派单对象)/ 真实 role_name 三种 */ | ||
| 109 | +function assigneeLabel(t) { | ||
| 110 | + if (!t.assigneeAgentId) return '未分配'; | ||
| 111 | + if (t.assigneeAgentId === 'org-graph') return '组织图执行'; | ||
| 112 | + return agentNameById.value.get(t.assigneeAgentId) || t.assigneeAgentId; | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +const assigneeOptions = computed(() => { | ||
| 116 | + const seen = new Map(); // value -> label | ||
| 117 | + for (const t of flatTasks.value) { | ||
| 118 | + const v = t.assigneeAgentId || '__none__'; | ||
| 119 | + if (!seen.has(v)) seen.set(v, assigneeLabel(t)); | ||
| 120 | + } | ||
| 121 | + return [...seen.entries()].map(([value, label]) => ({ value, label })); | ||
| 122 | +}); | ||
| 123 | + | ||
| 124 | +const search = ref(''); | ||
| 125 | +const statusFilter = ref(''); | ||
| 126 | +const goalFilter = ref(''); | ||
| 127 | +const assigneeFilter = ref(''); | ||
| 128 | + | ||
| 129 | +const filtered = computed(() => { | ||
| 130 | + let list = flatTasks.value; | ||
| 131 | + const q = search.value.trim().toLowerCase(); | ||
| 132 | + if (q) list = list.filter((t) => (t.title || '').toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q)); | ||
| 133 | + if (statusFilter.value) list = list.filter((t) => t.status === statusFilter.value); | ||
| 134 | + if (goalFilter.value) list = list.filter((t) => t.goalId === goalFilter.value); | ||
| 135 | + if (assigneeFilter.value) list = list.filter((t) => (t.assigneeAgentId || '__none__') === assigneeFilter.value); | ||
| 136 | + return list; | ||
| 137 | +}); | ||
| 138 | + | ||
| 139 | +const sortKey = ref('createdAt'); | ||
| 140 | +const sortDir = ref('desc'); | ||
| 141 | + | ||
| 142 | +function toggleSort(key) { | ||
| 143 | + if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'; | ||
| 144 | + else { sortKey.value = key; sortDir.value = 'asc'; } | ||
| 145 | +} | ||
| 146 | +function sortArrow(key) { | ||
| 147 | + if (sortKey.value !== key) return ''; | ||
| 148 | + return sortDir.value === 'asc' ? ' ▲' : ' ▼'; | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +/** 状态按中文标签排序(用户比较的是眼睛看到的字,不是内部枚举值),其余字段原值比较 */ | ||
| 152 | +function sortValue(t, key) { | ||
| 153 | + if (key === 'status') return taskStyle(t.status).label; | ||
| 154 | + return t[key] == null ? '' : t[key]; | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +const sorted = computed(() => { | ||
| 158 | + const list = filtered.value.slice(); | ||
| 159 | + const dir = sortDir.value === 'asc' ? 1 : -1; | ||
| 160 | + list.sort((a, b) => { | ||
| 161 | + const av = sortValue(a, sortKey.value); | ||
| 162 | + const bv = sortValue(b, sortKey.value); | ||
| 163 | + if (av < bv) return -1 * dir; | ||
| 164 | + if (av > bv) return 1 * dir; | ||
| 165 | + return 0; | ||
| 166 | + }); | ||
| 167 | + return list; | ||
| 168 | +}); | ||
| 169 | + | ||
| 170 | +function fmt(iso) { | ||
| 171 | + if (!iso) return '—'; | ||
| 172 | + try { return new Date(iso).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } | ||
| 173 | + catch { return iso; } | ||
| 174 | +} | ||
| 175 | +</script> | ||
| 176 | + | ||
| 177 | +<style scoped> | ||
| 178 | +.tt { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; } | ||
| 179 | + | ||
| 180 | +.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | ||
| 181 | +.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; } | ||
| 182 | +.search { flex: 1 1 220px; min-width: 160px; } | ||
| 183 | +.sel { flex: 0 0 auto; } | ||
| 184 | +.count { font-size: 11px; color: #999; white-space: nowrap; } | ||
| 185 | +.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; } | ||
| 186 | +.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; } | ||
| 187 | + | ||
| 188 | +.scroll { flex: 1 1 auto; overflow: auto; border: 1px solid #eee; border-radius: 8px; } | ||
| 189 | +table { width: 100%; border-collapse: collapse; font-size: 12px; } | ||
| 190 | +thead th { | ||
| 191 | + position: sticky; top: 0; background: #fafafa; text-align: left; padding: 8px 10px; | ||
| 192 | + border-bottom: 1px solid #eee; font-weight: 600; color: #555; white-space: nowrap; cursor: pointer; user-select: none; | ||
| 193 | +} | ||
| 194 | +thead th:hover { color: #2b6cb0; } | ||
| 195 | +.c-status:not(th), td.c-status { cursor: default; } | ||
| 196 | +tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align: top; } | ||
| 197 | +.row { cursor: pointer; } | ||
| 198 | +.row:hover { background: #f7f9fc; } | ||
| 199 | +.row.blocked { background: #fffaf9; box-shadow: inset 2px 0 0 #d33; } | ||
| 200 | +.empty { text-align: center; color: #999; padding: 24px 0; } | ||
| 201 | + | ||
| 202 | +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; margin-right: 5px; vertical-align: middle; } | ||
| 203 | +.dot.pulse { animation: pulse 1.6s ease-in-out infinite; } | ||
| 204 | +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } | ||
| 205 | +.qb { margin-left: 6px; font-size: 10px; color: #7aa7dd; } | ||
| 206 | + | ||
| 207 | +.c-desc { min-width: 220px; max-width: 360px; } | ||
| 208 | +.title { font-weight: 600; color: #1a1a1a; } | ||
| 209 | +.desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 210 | +.blocked-line { margin-top: 2px; color: #d33; font-size: 11px; } | ||
| 211 | +.c-goal, .c-assignee { color: #555; white-space: nowrap; } | ||
| 212 | +.c-priority { white-space: nowrap; } | ||
| 213 | +.c-time { white-space: nowrap; color: #666; } | ||
| 214 | + | ||
| 215 | +@media (max-width: 900px) { | ||
| 216 | + .c-goal, .c-priority { display: none; } | ||
| 217 | +} | ||
| 218 | +</style> |
web/src/views/TaskBoard.vue
| 1 | <!-- | 1 | <!-- |
| 2 | 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。 | 2 | 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。 |
| 3 | 3 | ||
| 4 | - 在此之前 goal/task 只能在 GraphConsole 左栏的窄侧栏里看,这里把同一份组件 | ||
| 5 | - (GoalTaskPane + goal-task-model.js)铺满整页,数据层原样不动,不重写。 | ||
| 6 | - | ||
| 7 | - 点一条 task 不在本页画执行图(那是 GraphConsole 的活),而是跳过去 —— | ||
| 8 | - `/:id/graph-console?goal=...&task=...` 是 GraphConsole 已经支持的选中态复原路径 | ||
| 9 | - (见该文件 onMounted 里 `route.query.goal` / `route.query.task` 那两行),不新开一条协议。 | 4 | + 表格视图(TaskTable + TaskDetailModal),数据层沿用原来那份(GET /goals?withTasks=1, |
| 5 | + 20s 轮询),只是不再走 GoalTaskPane 那套折叠列表 —— GoalTaskPane 还在 GraphConsole | ||
| 6 | + 左栏服役,两边分开维护,改这里不会动到那边。 | ||
| 10 | --> | 7 | --> |
| 11 | <template> | 8 | <template> |
| 12 | <div class="board"> | 9 | <div class="board"> |
| @@ -20,43 +17,60 @@ | @@ -20,43 +17,60 @@ | ||
| 20 | </header> | 17 | </header> |
| 21 | 18 | ||
| 22 | <div class="body"> | 19 | <div class="body"> |
| 23 | - <GoalTaskPane | 20 | + <TaskTable |
| 24 | :company-id="companyId" | 21 | :company-id="companyId" |
| 25 | :goals="goals" | 22 | :goals="goals" |
| 23 | + :agents="agents" | ||
| 26 | :loading="goalsLoading" | 24 | :loading="goalsLoading" |
| 27 | - :selected-task-id="selectedTaskId" | ||
| 28 | - :selected-goal-id="selectedGoalId" | ||
| 29 | - :slots="slots" | ||
| 30 | - @select="goToDetail" | ||
| 31 | - @refresh="loadGoals" | 25 | + @select="openDetail" |
| 26 | + @create="openCreate" | ||
| 32 | /> | 27 | /> |
| 33 | </div> | 28 | </div> |
| 29 | + | ||
| 30 | + <TaskDetailModal | ||
| 31 | + v-if="showModal" | ||
| 32 | + :company-id="companyId" | ||
| 33 | + :task="modalTask" | ||
| 34 | + :goals="goals" | ||
| 35 | + :agents="agents" | ||
| 36 | + @close="closeModal" | ||
| 37 | + @saved="onSaved" | ||
| 38 | + /> | ||
| 34 | </div> | 39 | </div> |
| 35 | </template> | 40 | </template> |
| 36 | 41 | ||
| 37 | <script setup> | 42 | <script setup> |
| 38 | import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; | 43 | import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; |
| 39 | -import { useRoute, useRouter } from 'vue-router'; | ||
| 40 | -import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue'; | 44 | +import { useRoute } from 'vue-router'; |
| 45 | +import TaskTable from '@/components/task-board/TaskTable.vue'; | ||
| 46 | +import TaskDetailModal from '@/components/task-board/TaskDetailModal.vue'; | ||
| 41 | 47 | ||
| 42 | const route = useRoute(); | 48 | const route = useRoute(); |
| 43 | -const router = useRouter(); | ||
| 44 | const props = defineProps({ id: { type: String, default: '' } }); | 49 | const props = defineProps({ id: { type: String, default: '' } }); |
| 45 | const companyId = computed(() => props.id || route.params.id); | 50 | const companyId = computed(() => props.id || route.params.id); |
| 46 | 51 | ||
| 47 | const company = ref(null); | 52 | const company = ref(null); |
| 48 | const goals = ref([]); | 53 | const goals = ref([]); |
| 49 | const goalsLoading = ref(false); | 54 | const goalsLoading = ref(false); |
| 50 | -const slots = ref(null); | ||
| 51 | -const selectedGoalId = ref(''); | 55 | +const agents = ref([]); |
| 56 | + | ||
| 52 | const selectedTaskId = ref(''); | 57 | const selectedTaskId = ref(''); |
| 58 | +const showCreate = ref(false); | ||
| 59 | +const showModal = computed(() => showCreate.value || !!selectedTaskId.value); | ||
| 60 | +/** 从当前 goals 树里现查,而不是缓存点击那一刻的快照——刷新之后弹窗跟着显示最新状态 */ | ||
| 61 | +const modalTask = computed(() => { | ||
| 62 | + if (!selectedTaskId.value) return null; | ||
| 63 | + for (const g of goals.value) { | ||
| 64 | + const t = (g.tasks || []).find((x) => x.taskId === selectedTaskId.value); | ||
| 65 | + if (t) return t; | ||
| 66 | + } | ||
| 67 | + return null; | ||
| 68 | +}); | ||
| 53 | 69 | ||
| 54 | let treePoll = null; | 70 | let treePoll = null; |
| 55 | 71 | ||
| 56 | onMounted(async () => { | 72 | onMounted(async () => { |
| 57 | - selectedGoalId.value = String(route.query.goal || ''); | ||
| 58 | - selectedTaskId.value = String(route.query.task || ''); | ||
| 59 | - await Promise.all([loadCompany(), loadGoals()]); | 73 | + await Promise.all([loadCompany(), loadGoals(), loadAgents()]); |
| 60 | // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化 | 74 | // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化 |
| 61 | treePoll = setInterval(() => { loadGoals(); }, 20000); | 75 | treePoll = setInterval(() => { loadGoals(); }, 20000); |
| 62 | }); | 76 | }); |
| @@ -76,18 +90,37 @@ async function loadGoals() { | @@ -76,18 +90,37 @@ async function loadGoals() { | ||
| 76 | if (!r.ok) return; | 90 | if (!r.ok) return; |
| 77 | const j = await r.json(); | 91 | const j = await r.json(); |
| 78 | goals.value = j.goals || []; | 92 | goals.value = j.goals || []; |
| 79 | - slots.value = j.slots || null; | ||
| 80 | } catch { /* 轮询失败保留上一份树 */ } finally { | 93 | } catch { /* 轮询失败保留上一份树 */ } finally { |
| 81 | goalsLoading.value = false; | 94 | goalsLoading.value = false; |
| 82 | } | 95 | } |
| 83 | } | 96 | } |
| 84 | 97 | ||
| 85 | -/** 点一条 task:跳到执行控制台看它的图,选中态通过 query 带过去 */ | ||
| 86 | -function goToDetail({ goalId, taskId }) { | ||
| 87 | - selectedGoalId.value = goalId || ''; | ||
| 88 | - selectedTaskId.value = taskId || ''; | ||
| 89 | - if (!taskId) return; | ||
| 90 | - router.push({ path: `/${companyId.value}/graph-console`, query: { goal: goalId || '', task: taskId } }); | 98 | +/** agent 花名册不常变,拉一次即可,不用跟 goals 一起轮询 */ |
| 99 | +async function loadAgents() { | ||
| 100 | + try { | ||
| 101 | + const r = await fetch(`/api/company/${companyId.value}/agents`); | ||
| 102 | + if (r.ok) agents.value = await r.json(); | ||
| 103 | + } catch { /* 拉不到就退回显示 assignee 原始 id,不影响其余功能 */ } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +function openDetail(t) { | ||
| 107 | + showCreate.value = false; | ||
| 108 | + selectedTaskId.value = t.taskId; | ||
| 109 | +} | ||
| 110 | +function openCreate() { | ||
| 111 | + selectedTaskId.value = ''; | ||
| 112 | + showCreate.value = true; | ||
| 113 | +} | ||
| 114 | +function closeModal() { | ||
| 115 | + showCreate.value = false; | ||
| 116 | + selectedTaskId.value = ''; | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +/** 弹窗里任一增删改查动作成功后:重新拉 goals;新建 / 归档导致弹窗没有对象可显示时关掉它 */ | ||
| 120 | +async function onSaved() { | ||
| 121 | + await loadGoals(); | ||
| 122 | + if (showCreate.value) { closeModal(); return; } | ||
| 123 | + if (selectedTaskId.value && !modalTask.value) closeModal(); | ||
| 91 | } | 124 | } |
| 92 | </script> | 125 | </script> |
| 93 | 126 | ||
| @@ -104,7 +137,7 @@ function goToDetail({ goalId, taskId }) { | @@ -104,7 +137,7 @@ function goToDetail({ goalId, taskId }) { | ||
| 104 | .to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; } | 137 | .to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; } |
| 105 | .to-console:hover { text-decoration: underline; } | 138 | .to-console:hover { text-decoration: underline; } |
| 106 | 139 | ||
| 107 | -.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; max-width: 900px; width: 100%; margin: 0 auto; box-sizing: border-box; } | 140 | +.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; box-sizing: border-box; } |
| 108 | 141 | ||
| 109 | @media (max-width: 700px) { | 142 | @media (max-width: 700px) { |
| 110 | .body { padding: 12px; } | 143 | .body { padding: 12px; } |