GoalTaskPane.vue 18.3 KB
<!--
  目标·任务两级栏(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">
            <li
              v-for="t in g.tasks || []"
              :key="t.taskId"
              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>
          </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));

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

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