GoalTaskPane.vue 11.8 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" @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 }"
              @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">
                  <span class="st" :style="{ color: taskStyle(t.status).color }">{{ taskStyle(t.status).label }}</span>
                  <span v-if="taskSubtitle(t, now)"> · {{ taskSubtitle(t, now) }}</span>
                </div>
              </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, onMounted, onBeforeUnmount } from 'vue';
import NewGoalForm from './NewGoalForm.vue';
import {
  goalStyle, taskStyle, goalProgressLabel, taskSubtitle, dependsLabel,
} from './goal-task-model.js';

defineProps({
  companyId: { type: String, required: true },
  goals: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
  selectedTaskId: { type: String, default: '' },
  selectedGoalId: { type: String, default: '' },
});
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({});

/** 墙钟: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 就把选中态挪到那条 task 上:用户点「下达全部」想看的就是它
    if (d.started?.taskId) emit('select', { goalId: g.goalId, taskId: d.started.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; gap: 6px; align-items: flex-start; padding: 4px 6px 4px 15px; border-radius: 5px; cursor: pointer; }
.task:hover { background: #f4f6f9; }
.task.on { background: #eef4fb; box-shadow: inset 2px 0 0 #2b6cb0; }

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