TaskBoard.vue 3.92 KB
<!--
  任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。

  在此之前 goal/task 只能在 GraphConsole 左栏的窄侧栏里看,这里把同一份组件
  (GoalTaskPane + goal-task-model.js)铺满整页,数据层原样不动,不重写。

  点一条 task 不在本页画执行图(那是 GraphConsole 的活),而是跳过去 ——
  `/:id/graph-console?goal=...&task=...` 是 GraphConsole 已经支持的选中态复原路径
  (见该文件 onMounted 里 `route.query.goal` / `route.query.task` 那两行),不新开一条协议。
-->
<template>
  <div class="board">
    <header class="top">
      <div class="who">
        <router-link :to="`/${companyId}/overview`" class="back">←</router-link>
        <b>{{ company?.name || companyId }}</b>
        <span class="sep">任务看板</span>
      </div>
      <router-link :to="`/${companyId}/graph-console`" class="to-console">🕸️ 执行控制台</router-link>
    </header>

    <div class="body">
      <GoalTaskPane
        :company-id="companyId"
        :goals="goals"
        :loading="goalsLoading"
        :selected-task-id="selectedTaskId"
        :selected-goal-id="selectedGoalId"
        :slots="slots"
        @select="goToDetail"
        @refresh="loadGoals"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';

const route = useRoute();
const router = useRouter();
const props = defineProps({ id: { type: String, default: '' } });
const companyId = computed(() => props.id || route.params.id);

const company = ref(null);
const goals = ref([]);
const goalsLoading = ref(false);
const slots = ref(null);
const selectedGoalId = ref('');
const selectedTaskId = ref('');

let treePoll = null;

onMounted(async () => {
  selectedGoalId.value = String(route.query.goal || '');
  selectedTaskId.value = String(route.query.task || '');
  await Promise.all([loadCompany(), loadGoals()]);
  // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化
  treePoll = setInterval(() => { loadGoals(); }, 20000);
});
onBeforeUnmount(() => clearInterval(treePoll));

async function loadCompany() {
  try {
    const r = await fetch(`/api/company/${companyId.value}`);
    if (r.ok) company.value = await r.json();
  } catch { /* 页头名字掉了不影响功能 */ }
}

async function loadGoals() {
  goalsLoading.value = true;
  try {
    const r = await fetch(`/api/company/${companyId.value}/goals?withTasks=1`);
    if (!r.ok) return;
    const j = await r.json();
    goals.value = j.goals || [];
    slots.value = j.slots || null;
  } catch { /* 轮询失败保留上一份树 */ } finally {
    goalsLoading.value = false;
  }
}

/** 点一条 task:跳到执行控制台看它的图,选中态通过 query 带过去 */
function goToDetail({ goalId, taskId }) {
  selectedGoalId.value = goalId || '';
  selectedTaskId.value = taskId || '';
  if (!taskId) return;
  router.push({ path: `/${companyId.value}/graph-console`, query: { goal: goalId || '', task: taskId } });
}
</script>

<style scoped>
.board { display: flex; flex-direction: column; height: 100vh; background: #fff; color: #1a1a1a; }

.top {
  display: flex; align-items: center; justify-content: space-between;
  padding: 10px 16px; border-bottom: 1px solid #eee; flex: 0 0 auto;
}
.who { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.back { text-decoration: none; color: #666; font-size: 16px; }
.sep { color: #999; }
.to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; }
.to-console:hover { text-decoration: underline; }

.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; max-width: 900px; width: 100%; margin: 0 auto; box-sizing: border-box; }

@media (max-width: 700px) {
  .body { padding: 12px; }
}
</style>