GoalTaskPane.vue 20.1 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415
<!--
  目标·任务两级栏(27-P3C §七.2)—— 控制台最左侧那一栏。

  **选中态永远落在 task 上**:org graph / work graph 都是 task 级的事实,选中一个 goal
  画不出图。点 goal 行只做展开收起(外加把「这个 goal 是当前上下文」告诉父组件),
  真正驱动右边三栏的是被选中的那条 task。

  状态点的颜色一个都不新造,全部来自 goal-task-model.js(那份又是照抄 WorkGraphCanvas 的 .dot):
  左栏这个点和画布上那个点说的是同一件事的两级,颜色对不上会让人以为是两套状态。
-->
<template>
  <div class="pane">
    <NewGoalForm
      :company-id="companyId"
      :queue-stat="stat"
      :slots="slots"
      @changed="$emit('refresh')"
      @dispatched="onDispatched"
    />

    <p v-if="loading && !goals.length" class="tip">加载中…</p>
    <p v-else-if="!goals.length" class="tip">
      还没有目标。点上面「+ 新目标」把一句需求交给这家公司 —— 它会被拆成若干条任务,逐条执行。
    </p>

    <ul class="goals">
      <li v-for="g in goals" :key="g.goalId" class="goal" :class="{ ctx: g.goalId === selectedGoalId }">
        <div class="g-head">
          <button class="caret" :title="collapsed.has(g.goalId) ? '展开' : '收起'" @click="toggle(g.goalId)">
            {{ collapsed.has(g.goalId) ? '▸' : '▾' }}
          </button>
          <span class="dot" :class="{ pulse: goalStyle(g.status).pulse }" :style="dotStyle(goalStyle(g.status))" />
          <button class="g-title" :title="`${goalStyle(g.status).label} · ${g.description || ''}`" @click="toggle(g.goalId)">
            {{ g.title || g.goalId }}
          </button>
          <span v-if="goalProgressLabel(g)" class="g-prog">{{ goalProgressLabel(g) }}</span>
          <button class="more" title="更多" @click="menu = menu === g.goalId ? '' : g.goalId">⋯</button>
        </div>

        <!-- ⋯ 菜单:编辑 / 下达全部 / 归档 -->
        <div v-if="menu === g.goalId" class="menu">
          <button @click="startEdit(g)">编辑</button>
          <button :disabled="busy" @click="dispatchGoal(g)">下达全部</button>
          <button class="danger" :disabled="busy" @click="archiveGoal(g)">归档</button>
        </div>

        <!-- 内联编辑:description 在 dispatched_at 之后是冻结字段(§四.4),这里直接禁用并说明,
             而不是让用户改完再吃一个 409 -->
        <div v-if="editing === g.goalId" class="edit">
          <input v-model="editTitle" class="in" placeholder="标题" />
          <textarea
            v-model="editDesc"
            class="in ta"
            rows="3"
            :disabled="!!g.dispatchedAt"
            :placeholder="g.dispatchedAt ? '已下达,描述已冻结(它是验收边界)' : '需求描述'"
          />
          <div class="e-acts">
            <button class="btn primary" :disabled="busy" @click="saveEdit(g)">保存</button>
            <button class="btn ghost" :disabled="busy" @click="editing = ''">取消</button>
          </div>
        </div>

        <p v-if="errors[g.goalId]" class="err">{{ errors[g.goalId] }}</p>

        <template v-if="!collapsed.has(g.goalId)">
          <!-- 拆分中:goal 下一条 task 都还没有,给骨架屏而不是「暂无任务」——
               后者会让人以为拆分已经失败了 -->
          <div v-if="g.status === 'planning'" class="planning">
            <span class="p-txt">正在拆分…</span>
            <i class="skel" /><i class="skel" />
          </div>

          <p v-else-if="g.planningError" class="degraded">
            自动拆分失败,已降级为单任务。<em>{{ g.planningError }}</em>
          </p>

          <ul class="tasks">
            <template v-for="t in topLevel(g.tasks)" :key="t.taskId">
              <li
                class="task"
                :class="{ on: t.taskId === selectedTaskId, blocked: t.status === 'blocked' }"
                @click="$emit('select', { goalId: g.goalId, taskId: t.taskId })"
              >
                <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))" />
                <div class="t-body">
                  <div class="t-title">
                    <span
                      v-if="(t.dependsOn || []).length"
                      class="dep"
                      :title="dependsLabel(t, g.tasks || [])"
                    >⇢</span>{{ t.title || t.taskId }}
                  </div>
                  <div class="t-sub">
                    <!-- 排队中的行把状态标签换成位次:「排队中」谁都看得出来,「前面还有几个」才是
                         用户点完下达之后真正在问的问题。位次是服务端算的,前端不排(见 queueBadge) -->
                    <span
                      v-if="queueBadge(t)"
                      class="st queue"
                      :style="{ color: taskStyle(t.status).color }"
                      :title="queueBadge(t).title"
                    >{{ queueBadge(t).text }}</span>
                    <span v-else class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span>
                    <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span>
                  </div>
                  <!-- blocked 的原因单独一行并标红:它是**要人动手**的状态,混在灰色第二排里等于没说 -->
                  <div v-if="blockedLabel(t, g.tasks || [])" class="t-blocked" :title="t.blockedReason">
                    ⚠ {{ blockedLabel(t, g.tasks || []) }}
                  </div>
                </div>
                <button
                  v-if="taskActions(t).length"
                  class="more t-more"
                  title="更多"
                  @click.stop="menu = menu === t.taskId ? '' : t.taskId"
                >⋯</button>

                <!-- 每条 task 的操作菜单。做不成的(重跑)置灰并说明,不给能点却必然 4xx 的入口 -->
                <div v-if="menu === t.taskId" class="menu t-menu" @click.stop>
                  <button
                    v-for="a in taskActions(t)"
                    :key="a.key"
                    :class="{ danger: a.danger }"
                    :disabled="busy || a.disabled"
                    :title="a.title"
                    @click="runTaskAction(g, t, a)"
                  >{{ a.label }}</button>
                </div>
              </li>

              <!-- 43 号文档:itemized 任务按验收条目物化出的子任务,只读展示——没有独立的 run,
                   不接操作菜单/选中态,状态由 coder-validator 逐条上报(tasks.status 那套枚举)。
                   子任务不会再递归拆一层(createTask 校验过),所以这里只用一层 <ul>,不需要
                   FileNode.vue 那种自递归组件。 -->
              <ul v-if="childrenOf(g.tasks, t.taskId).length" class="subtasks">
                <li v-for="st in childrenOf(g.tasks, t.taskId)" :key="st.taskId" class="subtask">
                  <span class="dot sm" :class="{ pulse: taskStyle(st.status).pulse }" :style="dotStyle(taskStyle(st.status))" />
                  <span class="st-title">{{ st.title || st.taskId }}</span>
                  <span class="st-status" :style="{ color: taskStyle(st.status).color }">{{ taskStyle(st.status).label }}</span>
                </li>
              </ul>
            </template>
          </ul>

          <p v-if="g.status !== 'planning' && !(g.tasks || []).length" class="tip small">这个目标下还没有任务。</p>
        </template>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';
import NewGoalForm from './NewGoalForm.vue';
import {
  goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel,
  queueBadge, blockedLabel, parseBlockedReason, taskActions, queueStat, dispatchedTaskId,
} from './goal-task-model.js';

const props = defineProps({
  companyId: { type: String, required: true },
  goals: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
  selectedTaskId: { type: String, default: '' },
  selectedGoalId: { type: String, default: '' },
  /** 执行位 `{ max, used, free }`,服务端原样给的;没给就是 null(见 goal-task-model.readSlots) */
  slots: { type: Object, default: null },
});
const emit = defineEmits(['select', 'refresh']);

const collapsed = ref(new Set());
const menu = ref('');
const editing = ref('');
const editTitle = ref('');
const editDesc = ref('');
const busy = ref(false);
const errors = reactive({});

/** 「新目标」表单那句「当前 N 个在跑 / M 个在排队」的数据源:整棵树已经在手,不用再拉一次 */
const stat = computed(() => queueStat(props.goals));

/** 墙钟:running 那条的「12min」要自己走,否则不刷新页面就永远停在选中那一刻 */
const now = ref(Date.now());
let clock = null;
onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); });
onBeforeUnmount(() => clearInterval(clock));

/** 43 号文档:g.tasks 是摊平的(父任务 + 子任务同一个数组),这里按 parentTaskId 分两层 */
function topLevel(tasks) {
  return (tasks || []).filter((t) => !t.parentTaskId);
}
function childrenOf(tasks, parentTaskId) {
  return (tasks || []).filter((t) => t.parentTaskId === parentTaskId);
}

function toggle(goalId) {
  const next = new Set(collapsed.value);
  if (next.has(goalId)) next.delete(goalId); else next.add(goalId);
  collapsed.value = next; // Set 是引用类型,就地增删触发不了渲染
}

/** 空心圈 = 草稿:还没有任何东西发生过。实心 = 已经有过一次真实的状态转换 */
function dotStyle(s) {
  return s.hollow
    ? { background: 'transparent', borderColor: s.color }
    : { background: s.color, borderColor: s.color };
}

function startEdit(g) {
  menu.value = '';
  editing.value = g.goalId;
  editTitle.value = g.title || '';
  editDesc.value = g.description || '';
}

async function call(method, url, body) {
  const r = await fetch(url, {
    method,
    headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  // 服务端的 error / hint 原样透出(本目录惯例):409 的 hint 里写的是确切的自救路径
  if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
  return d;
}

async function guarded(goalId, fn) {
  if (busy.value) return;
  busy.value = true;
  delete errors[goalId];
  try {
    await fn();
    emit('refresh');
  } catch (e) {
    errors[goalId] = e.message;
  } finally {
    busy.value = false;
  }
}

function saveEdit(g) {
  const patch = { title: editTitle.value };
  // 已下达的 goal 不带 description:数据层会 409,而这次编辑的本意只是改标题
  if (!g.dispatchedAt) patch.description = editDesc.value;
  return guarded(g.goalId, async () => {
    await call('PATCH', `/api/company/${g.companyId}/goals/${g.goalId}`, patch);
    editing.value = '';
  });
}

function dispatchGoal(g) {
  menu.value = '';
  return guarded(g.goalId, async () => {
    const d = await call('POST', `/api/company/${g.companyId}/goals/${g.goalId}/dispatch`);
    // 起了 run(或入了队)就把选中态挪过去:用户点「下达全部」想看的就是它。
    // 认不出 taskId 时不动选中态 —— 形状的解析只归 dispatchedTaskId 一处
    const taskId = dispatchedTaskId(d);
    if (taskId) emit('select', { goalId: g.goalId, taskId });
  });
}

/**
 * task 行的操作。每一个 key 对应一条**已经存在**的端点;置灰项在这里再挡一道,
 * 因为「按钮 disabled」只是界面约定,真正的防线得在发请求之前(第 3 条:注释承诺的防线必须存在)。
 */
function runTaskAction(g, t, act) {
  if (act.disabled || busy.value) return;
  menu.value = '';
  const cid = props.companyId;
  return guarded(g.goalId, async () => {
    if (act.key === 'stop') {
      await call('POST', `/api/graph-runs/${t.runId}/stop`);
      return;
    }
    if (act.key === 'release') {
      const p = parseBlockedReason(t.blockedReason);
      const deps = t.dependsOn || [];
      const next = deps.filter((id) => id !== (p && p.upstreamId));
      // 少一条才算真删掉。长度没变就说明这次 PATCH 什么都不会改,而接口照样回 200 ——
      // 那正是「写了但没生效还不报错」的形态,宁可在这里直接报出来
      if (next.length === deps.length) {
        throw new Error(`放行失败:blocked 原因指向 ${p && p.upstreamId},但这条任务的依赖里没有它(依赖刚被改过?刷新再看)`);
      }
      await call('PATCH', `/api/company/${cid}/tasks/${t.taskId}`, { depends_on: next });
      return;
    }
    if (act.key === 'start') {
      // 抢到坑就跟过去看它跑(服务端回的 started 未必是这一条:坑是公司级的)
      const d = await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`);
      emit('select', { goalId: g.goalId, taskId: dispatchedTaskId(d) || t.taskId });
      return;
    }
    if (act.key === 'cancel') {
      // 两种来源同一条端点:queued 是「撤出队列」,blocked 是「放弃这条挂起的任务」。
      // 服务端按 run 的**真实状态**分路收尾(queued→stopped / stopRun / 没有 run 就直接收),
      // 所以前端不需要按 task 状态挑 URL —— 挑一次就是又一个复述服务端取值域的持有者。
      //
      // **不是归档**:收成「已取消」之后行还留在列表里、还能重跑。归档是另一个操作、另一条端点,
      // 混成一个会让「我只是不想现在跑」变成「这条任务消失了」
      await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`);
      return;
    }
    if (act.key === 'rerun') {
      const d = await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`);
      // 重跑起的是**新的一条 run**,选中态要跟过去,否则右边还画着上一次那张已经收尾的图
      emit('select', { goalId: g.goalId, taskId: dispatchedTaskId(d) || t.taskId });
      return;
    }
    if (act.key === 'rerun-upstream') {
      // 上游 id 由 taskActions 从 blocked_reason 里解出来并挂在动作上 —— 这里不再解一遍,
      // 解析口径只归 parseBlockedReason 一处
      if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了');
      await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`);
      emit('select', { goalId: g.goalId, taskId: act.upstreamId });
      return;
    }
    // archive(归档):只盖 archived_at,从列表里移走
    await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`);
  });
}

function archiveGoal(g) {
  menu.value = '';
  return guarded(g.goalId, () => call('DELETE', `/api/company/${g.companyId}/goals/${g.goalId}`));
}

function onDispatched(d) {
  if (d && d.taskId) emit('select', { goalId: d.goalId, taskId: d.taskId });
  emit('refresh');
}
</script>

<style scoped>
/* 本页(GraphConsole)通篇硬编码浅色,这里跟着来 */
.pane { font-size: 12px; color: #1a1a1a; }
.tip { font-size: 11px; color: #999; line-height: 1.8; margin: 8px 2px; }
.tip.small { margin: 2px 0 6px 22px; }

.goals { list-style: none; margin: 0; padding: 0; }
.goal { border-bottom: 1px solid #f0f0f2; padding: 6px 0; }
.goal.ctx { background: #fbfcfe; }

.g-head { display: flex; align-items: center; gap: 5px; }
.caret { border: none; background: none; cursor: pointer; color: #bbb; font-size: 10px; padding: 0 2px; width: 14px; }
.g-title {
  border: none; background: none; cursor: pointer; text-align: left; padding: 0;
  font-size: 12px; font-weight: 700; color: #1a1a1a; flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.g-prog { font-size: 10px; color: #999; white-space: nowrap; }
.more { border: none; background: none; cursor: pointer; color: #bbb; font-size: 13px; padding: 0 2px; line-height: 1; }
.more:hover { color: #666; }

.menu { display: flex; gap: 4px; margin: 4px 0 4px 21px; }
.menu button { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 2px 8px; cursor: pointer; color: #444; }
.menu button:disabled { opacity: .5; cursor: not-allowed; }
.menu .danger { color: #b91c1c; }

.edit { margin: 4px 0 6px 21px; display: flex; flex-direction: column; gap: 4px; }
.in { width: 100%; box-sizing: border-box; font: inherit; font-size: 11px; padding: 5px 7px; border: 1px solid #ddd; border-radius: 5px; background: #fff; color: #1a1a1a; }
.in:disabled { background: #f7f7f9; color: #999; }
.ta { resize: vertical; }
.e-acts { display: flex; gap: 5px; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 5px; font-size: 11px; padding: 3px 10px; cursor: pointer; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: .5; cursor: not-allowed; }

.err { margin: 4px 0 4px 21px; font-size: 11px; color: #dc2626; line-height: 1.5; }
.degraded { margin: 4px 0 4px 21px; font-size: 10px; color: #a35b00; line-height: 1.5; }
.degraded em { font-style: normal; color: #b08050; }

.planning { margin: 4px 0 6px 21px; }
.p-txt { font-size: 11px; color: #2b6cb0; }
.skel { display: block; height: 22px; margin-top: 4px; border-radius: 5px; background: linear-gradient(90deg, #f2f2f4 25%, #e9e9ed 37%, #f2f2f4 63%); background-size: 400% 100%; animation: sk 1.4s ease infinite; }
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }

.tasks { list-style: none; margin: 2px 0 0; padding: 0; }
.task { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; padding: 4px 6px 4px 15px; border-radius: 5px; cursor: pointer; }
.task:hover { background: #f4f6f9; }
/*
  blocked 标红只落在**行**上(左侧红条 + 淡红底 + 红色原因行),圆点和状态标签仍是色板里的
  橙 #e08a00:那个点与 WorkGraphCanvas 的 .dot、GraphRunPane 的 .run-status 是同一件事的两级,
  改颜色会让画布上的橙节点和左栏的红任务看起来是两套状态(goal-task-model.js 顶部那条约定)。
  行级强调既达到「一眼看见有东西卡住了」,又不动跨组件的颜色语义。
*/
.task.blocked { background: #fff5f5; box-shadow: inset 2px 0 0 #d33; }
.task.on { background: #eef4fb; box-shadow: inset 2px 0 0 #2b6cb0; }
/* 选中 + blocked:底色跟选中走(选中态要看得出来),红条保留(卡住这件事不能被选中盖掉) */
.task.blocked.on { background: #eef4fb; box-shadow: inset 2px 0 0 #d33; }
.t-blocked { flex-basis: 100%; margin-left: 14px; font-size: 10px; line-height: 1.5; color: #d33; }
.t-more { align-self: center; opacity: 0; }
.task:hover .t-more, .task.on .t-more { opacity: 1; }
.t-menu { flex-basis: 100%; margin: 2px 0 2px 14px; flex-wrap: wrap; }

.dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; box-sizing: border-box; margin-top: 4px; }
.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }

.subtasks { list-style: none; margin: 0 0 2px; padding: 0 0 0 27px; }
.subtask { display: flex; align-items: center; gap: 5px; padding: 2px 6px; font-size: 11px; }
.subtask .dot.sm { margin-top: 0; width: 6px; height: 6px; }
.st-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #555; }
.st-status { font-size: 10px; font-weight: 600; white-space: nowrap; }

.t-body { min-width: 0; flex: 1; }
.t-title {
  font-size: 12px; line-height: 1.4; color: #1a1a1a;
  display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dep { color: #c96a00; margin-right: 3px; cursor: help; }
.t-sub { font-size: 10px; color: #999; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st { font-weight: 600; }
</style>