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 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; }
... ...