Commit a09b519ba296ff97afb6a4c5f4ff0af3d74842bc

Authored by yibo
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
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; }