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

  表格视图(TaskTable + TaskDetailModal),数据层沿用原来那份(GET /goals?withTasks=1,
  20s 轮询),只是不再走 GoalTaskPane 那套折叠列表 —— GoalTaskPane 还在 GraphConsole
  左栏服役,两边分开维护,改这里不会动到那边。
-->
<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">
      <TaskTable
        :company-id="companyId"
        :goals="goals"
        :agents="agents"
        :loading="goalsLoading"
        @select="openDetail"
        @create="openCreate"
      />
    </div>

    <TaskDetailModal
      v-if="showModal"
      :company-id="companyId"
      :task="modalTask"
      :goals="goals"
      :agents="agents"
      @close="closeModal"
      @saved="onSaved"
    />
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import TaskTable from '@/components/task-board/TaskTable.vue';
import TaskDetailModal from '@/components/task-board/TaskDetailModal.vue';

const route = useRoute();
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 agents = ref([]);

const selectedTaskId = ref('');
const showCreate = ref(false);
const showModal = computed(() => showCreate.value || !!selectedTaskId.value);
/** 从当前 goals 树里现查,而不是缓存点击那一刻的快照——刷新之后弹窗跟着显示最新状态 */
const modalTask = computed(() => {
  if (!selectedTaskId.value) return null;
  for (const g of goals.value) {
    const t = (g.tasks || []).find((x) => x.taskId === selectedTaskId.value);
    if (t) return t;
  }
  return null;
});

let treePoll = null;

onMounted(async () => {
  await Promise.all([loadCompany(), loadGoals(), loadAgents()]);
  // 与 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 || [];
  } catch { /* 轮询失败保留上一份树 */ } finally {
    goalsLoading.value = false;
  }
}

/** agent 花名册不常变,拉一次即可,不用跟 goals 一起轮询 */
async function loadAgents() {
  try {
    const r = await fetch(`/api/company/${companyId.value}/agents`);
    if (r.ok) agents.value = await r.json();
  } catch { /* 拉不到就退回显示 assignee 原始 id,不影响其余功能 */ }
}

function openDetail(t) {
  showCreate.value = false;
  selectedTaskId.value = t.taskId;
}
function openCreate() {
  selectedTaskId.value = '';
  showCreate.value = true;
}
function closeModal() {
  showCreate.value = false;
  selectedTaskId.value = '';
}

/** 弹窗里任一增删改查动作成功后:重新拉 goals;新建 / 归档导致弹窗没有对象可显示时关掉它 */
async function onSaved() {
  await loadGoals();
  if (showCreate.value) { closeModal(); return; }
  if (selectedTaskId.value && !modalTask.value) closeModal();
}
</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; box-sizing: border-box; }

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