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,58 +76,70 @@ | ||
| 76 | </p> | 76 | </p> |
| 77 | 77 | ||
| 78 | <ul class="tasks"> | 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 | </div> | 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 | <button | 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 | </ul> | 143 | </ul> |
| 132 | 144 | ||
| 133 | <p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p> | 145 | <p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p> |
| @@ -173,6 +185,14 @@ let clock = null; | @@ -173,6 +185,14 @@ let clock = null; | ||
| 173 | onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); }); | 185 | onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); }); |
| 174 | onBeforeUnmount(() => clearInterval(clock)); | 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 | function toggle(goalId) { | 196 | function toggle(goalId) { |
| 177 | const next = new Set(collapsed.value); | 197 | const next = new Set(collapsed.value); |
| 178 | if (next.has(goalId)) next.delete(goalId); else next.add(goalId); | 198 | if (next.has(goalId)) next.delete(goalId); else next.add(goalId); |
| @@ -378,6 +398,12 @@ function onDispatched(d) { | @@ -378,6 +398,12 @@ function onDispatched(d) { | ||
| 378 | .dot.pulse { animation: pulse 1.6s ease-in-out infinite; } | 398 | .dot.pulse { animation: pulse 1.6s ease-in-out infinite; } |
| 379 | @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } | 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 | .t-body { min-width: 0; flex: 1; } | 407 | .t-body { min-width: 0; flex: 1; } |
| 382 | .t-title { | 408 | .t-title { |
| 383 | font-size: 12px; line-height: 1.4; color: #1a1a1a; | 409 | font-size: 12px; line-height: 1.4; color: #1a1a1a; |
web/src/components/task-board/TaskTable.vue
| @@ -50,6 +50,9 @@ | @@ -50,6 +50,9 @@ | ||
| 50 | <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span> | 50 | <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span> |
| 51 | </td> | 51 | </td> |
| 52 | <td class="c-desc"> | 52 | <td class="c-desc"> |
| 53 | + <!-- 43 号文档:子任务是按验收条目物化出来的只读行,没有独立的 run, | ||
| 54 | + 表格本身是平铺+可排序的,不适合画树——用一行小字指回父任务就够了 --> | ||
| 55 | + <div v-if="t.parentTaskId" class="parent-line">↳ 子任务 · 属于「{{ parentTitle(t) }}」</div> | ||
| 53 | <div class="title">{{ t.title || '(无标题)' }}</div> | 56 | <div class="title">{{ t.title || '(无标题)' }}</div> |
| 54 | <div v-if="t.description" class="desc" :title="t.description">{{ t.description }}</div> | 57 | <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> | 58 | <div v-if="t.status === 'blocked'" class="blocked-line">{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}</div> |
| @@ -95,6 +98,12 @@ function siblingsOf(t) { | @@ -95,6 +98,12 @@ function siblingsOf(t) { | ||
| 95 | return (g && g.tasks) || []; | 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 | function dotStyle(s) { | 107 | function dotStyle(s) { |
| 99 | return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color }; | 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,6 +217,7 @@ tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align: | ||
| 208 | .title { font-weight: 600; color: #1a1a1a; } | 217 | .title { font-weight: 600; color: #1a1a1a; } |
| 209 | .desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 218 | .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; } | 219 | .blocked-line { margin-top: 2px; color: #d33; font-size: 11px; } |
| 220 | +.parent-line { color: #8a5cd6; font-size: 10px; margin-bottom: 2px; } | ||
| 211 | .c-goal, .c-assignee { color: #555; white-space: nowrap; } | 221 | .c-goal, .c-assignee { color: #555; white-space: nowrap; } |
| 212 | .c-priority { white-space: nowrap; } | 222 | .c-priority { white-space: nowrap; } |
| 213 | .c-time { white-space: nowrap; color: #666; } | 223 | .c-time { white-space: nowrap; color: #666; } |