Commit b9a45b4112a5852ecf63fd4eaedbcaa8072d2439

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