Commit b9a45b4112a5852ecf63fd4eaedbcaa8072d2439
1 parent
81e28e56
feat(task-decomposition): 看板展示子任务(43 号文档 Phase 4)
GoalTaskPane.vue(GraphConsole 左栏)按 parentTaskId 分层渲染,子任务 只读展示(没有独立 run,不接操作菜单);TaskTable.vue(TaskBoard,当天 另一个并发 session 重写的表格视图)用一行「↳ 子任务·属于「…」」指回 父任务,不强行画树——它是可排序表格,硬套树形会跟排序打架。 taskView() 已在上一个提交里补上 parentTaskId/acceptanceMode 字段输出。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7xYyvv3pzuV9diMVUH5QL
Showing
2 changed files
with
86 additions
and
50 deletions
web/src/components/graph-console/GoalTaskPane.vue
| ... | ... | @@ -76,58 +76,70 @@ |
| 76 | 76 | </p> |
| 77 | 77 | |
| 78 | 78 | <ul class="tasks"> |
| 79 | - <li | |
| 80 | - v-for="t in g.tasks || []" | |
| 81 | - :key="t.taskId" | |
| 82 | - class="task" | |
| 83 | - :class="{ on: t.taskId === selectedTaskId, blocked: t.status === 'blocked' }" | |
| 84 | - @click="$emit('select', { goalId: g.goalId, taskId: t.taskId })" | |
| 85 | - > | |
| 86 | - <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))" /> | |
| 87 | - <div class="t-body"> | |
| 88 | - <div class="t-title"> | |
| 89 | - <span | |
| 90 | - v-if="(t.dependsOn || []).length" | |
| 91 | - class="dep" | |
| 92 | - :title="dependsLabel(t, g.tasks || [])" | |
| 93 | - >⇢</span>{{ t.title || t.taskId }} | |
| 79 | + <template v-for="t in topLevel(g.tasks)" :key="t.taskId"> | |
| 80 | + <li | |
| 81 | + class="task" | |
| 82 | + :class="{ on: t.taskId === selectedTaskId, blocked: t.status === 'blocked' }" | |
| 83 | + @click="$emit('select', { goalId: g.goalId, taskId: t.taskId })" | |
| 84 | + > | |
| 85 | + <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))" /> | |
| 86 | + <div class="t-body"> | |
| 87 | + <div class="t-title"> | |
| 88 | + <span | |
| 89 | + v-if="(t.dependsOn || []).length" | |
| 90 | + class="dep" | |
| 91 | + :title="dependsLabel(t, g.tasks || [])" | |
| 92 | + >⇢</span>{{ t.title || t.taskId }} | |
| 93 | + </div> | |
| 94 | + <div class="t-sub"> | |
| 95 | + <!-- 排队中的行把状态标签换成位次:「排队中」谁都看得出来,「前面还有几个」才是 | |
| 96 | + 用户点完下达之后真正在问的问题。位次是服务端算的,前端不排(见 queueBadge) --> | |
| 97 | + <span | |
| 98 | + v-if="queueBadge(t)" | |
| 99 | + class="st queue" | |
| 100 | + :style="{ color: taskStyle(t.status).color }" | |
| 101 | + :title="queueBadge(t).title" | |
| 102 | + >{{ queueBadge(t).text }}</span> | |
| 103 | + <span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span> | |
| 104 | + <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span> | |
| 105 | + </div> | |
| 106 | + <!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 --> | |
| 107 | + <div v-if="blockedLabel(t, g.tasks || [])" class="t-blocked" :title="t.blockedReason"> | |
| 108 | + ⚠ {{ blockedLabel(t, g.tasks || []) }} | |
| 109 | + </div> | |
| 94 | 110 | </div> |
| 95 | - <div class="t-sub"> | |
| 96 | - <!-- 排队中的行把状态标签换成位次:「排队中」谁都看得出来,「前面还有几个」才是 | |
| 97 | - 用户点完下达之后真正在问的问题。位次是服务端算的,前端不排(见 queueBadge) --> | |
| 98 | - <span | |
| 99 | - v-if="queueBadge(t)" | |
| 100 | - class="st queue" | |
| 101 | - :style="{ color: taskStyle(t.status).color }" | |
| 102 | - :title="queueBadge(t).title" | |
| 103 | - >{{ queueBadge(t).text }}</span> | |
| 104 | - <span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span> | |
| 105 | - <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span> | |
| 106 | - </div> | |
| 107 | - <!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 --> | |
| 108 | - <div v-if="blockedLabel(t, g.tasks || [])" class="t-blocked" :title="t.blockedReason"> | |
| 109 | - ⚠ {{ blockedLabel(t, g.tasks || []) }} | |
| 110 | - </div> | |
| 111 | - </div> | |
| 112 | - <button | |
| 113 | - v-if="taskActions(t).length" | |
| 114 | - class="more t-more" | |
| 115 | - title="更多" | |
| 116 | - @click.stop="menu = menu === t.taskId ? '' : t.taskId" | |
| 117 | - >⋯</button> | |
| 118 | - | |
| 119 | - <!-- 每条 task 的操作菜单。做不成的(重跑)置灰并说明,不给能点却必然 4xx 的入口 --> | |
| 120 | - <div v-if="menu === t.taskId" class="menu t-menu" @click.stop> | |
| 121 | 111 | <button |
| 122 | - v-for="a in taskActions(t)" | |
| 123 | - :key="a.key" | |
| 124 | - :class="{ danger: a.danger }" | |
| 125 | - :disabled="busy || a.disabled" | |
| 126 | - :title="a.title" | |
| 127 | - @click="runTaskAction(g, t, a)" | |
| 128 | - >{{ a.label }}</button> | |
| 129 | - </div> | |
| 130 | - </li> | |
| 112 | + v-if="taskActions(t).length" | |
| 113 | + class="more t-more" | |
| 114 | + title="更多" | |
| 115 | + @click.stop="menu = menu === t.taskId ? '' : t.taskId" | |
| 116 | + >⋯</button> | |
| 117 | + | |
| 118 | + <!-- 每条 task 的操作菜单。做不成的(重跑)置灰并说明,不给能点却必然 4xx 的入口 --> | |
| 119 | + <div v-if="menu === t.taskId" class="menu t-menu" @click.stop> | |
| 120 | + <button | |
| 121 | + v-for="a in taskActions(t)" | |
| 122 | + :key="a.key" | |
| 123 | + :class="{ danger: a.danger }" | |
| 124 | + :disabled="busy || a.disabled" | |
| 125 | + :title="a.title" | |
| 126 | + @click="runTaskAction(g, t, a)" | |
| 127 | + >{{ a.label }}</button> | |
| 128 | + </div> | |
| 129 | + </li> | |
| 130 | + | |
| 131 | + <!-- 43 号文档:itemized 任务按验收条目物化出的子任务,只读展示——没有独立的 run, | |
| 132 | + 不接操作菜单/选中态,状态由 coder-validator 逐条上报(tasks.status 那套枚举)。 | |
| 133 | + 子任务不会再递归拆一层(createTask 校验过),所以这里只用一层 <ul>,不需要 | |
| 134 | + FileNode.vue 那种自递归组件。 --> | |
| 135 | + <ul v-if="childrenOf(g.tasks, t.taskId).length" class="subtasks"> | |
| 136 | + <li v-for="st in childrenOf(g.tasks, t.taskId)" :key="st.taskId" class="subtask"> | |
| 137 | + <span class="dot sm" :class="{ pulse: taskStyle(st.status).pulse }" :style="dotStyle(taskStyle(st.status))" /> | |
| 138 | + <span class="st-title">{{ st.title || st.taskId }}</span> | |
| 139 | + <span class="st-status" :style="{ color: taskStyle(st.status).color }">{{ taskStyle(st.status).label }}</span> | |
| 140 | + </li> | |
| 141 | + </ul> | |
| 142 | + </template> | |
| 131 | 143 | </ul> |
| 132 | 144 | |
| 133 | 145 | <p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p> |
| ... | ... | @@ -173,6 +185,14 @@ let clock = null; |
| 173 | 185 | onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); }); |
| 174 | 186 | onBeforeUnmount(() => clearInterval(clock)); |
| 175 | 187 | |
| 188 | +/** 43 号文档:g.tasks 是摊平的(父任务 + 子任务同一个数组),这里按 parentTaskId 分两层 */ | |
| 189 | +function topLevel(tasks) { | |
| 190 | + return (tasks || []).filter((t) => !t.parentTaskId); | |
| 191 | +} | |
| 192 | +function childrenOf(tasks, parentTaskId) { | |
| 193 | + return (tasks || []).filter((t) => t.parentTaskId === parentTaskId); | |
| 194 | +} | |
| 195 | + | |
| 176 | 196 | function toggle(goalId) { |
| 177 | 197 | const next = new Set(collapsed.value); |
| 178 | 198 | if (next.has(goalId)) next.delete(goalId); else next.add(goalId); |
| ... | ... | @@ -378,6 +398,12 @@ function onDispatched(d) { |
| 378 | 398 | .dot.pulse { animation: pulse 1.6s ease-in-out infinite; } |
| 379 | 399 | @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } |
| 380 | 400 | |
| 401 | +.subtasks { list-style: none; margin: 0 0 2px; padding: 0 0 0 27px; } | |
| 402 | +.subtask { display: flex; align-items: center; gap: 5px; padding: 2px 6px; font-size: 11px; } | |
| 403 | +.subtask .dot.sm { margin-top: 0; width: 6px; height: 6px; } | |
| 404 | +.st-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #555; } | |
| 405 | +.st-status { font-size: 10px; font-weight: 600; white-space: nowrap; } | |
| 406 | + | |
| 381 | 407 | .t-body { min-width: 0; flex: 1; } |
| 382 | 408 | .t-title { |
| 383 | 409 | font-size: 12px; line-height: 1.4; color: #1a1a1a; | ... | ... |
web/src/components/task-board/TaskTable.vue
| ... | ... | @@ -50,6 +50,9 @@ |
| 50 | 50 | <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span> |
| 51 | 51 | </td> |
| 52 | 52 | <td class="c-desc"> |
| 53 | + <!-- 43 号文档:子任务是按验收条目物化出来的只读行,没有独立的 run, | |
| 54 | + 表格本身是平铺+可排序的,不适合画树——用一行小字指回父任务就够了 --> | |
| 55 | + <div v-if="t.parentTaskId" class="parent-line">↳ 子任务 · 属于「{{ parentTitle(t) }}」</div> | |
| 53 | 56 | <div class="title">{{ t.title || '(无标题)' }}</div> |
| 54 | 57 | <div v-if="t.description" class="desc" :title="t.description">{{ t.description }}</div> |
| 55 | 58 | <div v-if="t.status === 'blocked'" class="blocked-line">{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}</div> |
| ... | ... | @@ -95,6 +98,12 @@ function siblingsOf(t) { |
| 95 | 98 | return (g && g.tasks) || []; |
| 96 | 99 | } |
| 97 | 100 | |
| 101 | +/** 43 号文档:子任务行指回父任务标题——父任务恒在同一个 goal 下,查兄弟列表就够了 */ | |
| 102 | +function parentTitle(t) { | |
| 103 | + const parent = siblingsOf(t).find((x) => x.taskId === t.parentTaskId); | |
| 104 | + return (parent && (parent.title || parent.taskId)) || t.parentTaskId; | |
| 105 | +} | |
| 106 | + | |
| 98 | 107 | function dotStyle(s) { |
| 99 | 108 | return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color }; |
| 100 | 109 | } |
| ... | ... | @@ -208,6 +217,7 @@ tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align: |
| 208 | 217 | .title { font-weight: 600; color: #1a1a1a; } |
| 209 | 218 | .desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 210 | 219 | .blocked-line { margin-top: 2px; color: #d33; font-size: 11px; } |
| 220 | +.parent-line { color: #8a5cd6; font-size: 10px; margin-bottom: 2px; } | |
| 211 | 221 | .c-goal, .c-assignee { color: #555; white-space: nowrap; } |
| 212 | 222 | .c-priority { white-space: nowrap; } |
| 213 | 223 | .c-time { white-space: nowrap; color: #666; } | ... | ... |