Commit 5108d67f5c1a19bd10d50576d41e04734d41af8f

Authored by yibo
1 parent a00590ad

feat(web): 独立任务看板页面,废弃 OKR 前端(P2)

新增 /:id/task-board 路由,复用 GoalTaskPane/goal-task-model 的既有
逻辑铺满整页;废弃 /okr 路由与 OkrDashboard.vue,清掉 CompanyDashboard
里挂着"goals"名字、实际渲染旧 OKR 树的那个 tab;导航入口换成
"任务看板"。按 39 号设计文档 §二 P2 落地,后端 OKR 子系统本次不动。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwG4b7Nq1NLR9eeE3M9Us9
web/src/components/AppNav.vue
@@ -61,8 +61,8 @@ @@ -61,8 +61,8 @@
61 <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }"> 61 <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }">
62 ⚙️ SOP 62 ⚙️ SOP
63 </a> 63 </a>
64 - <a :href="`/${currentCompany.id}/goals`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }">  
65 - 🎯 OKR 64 + <a :href="`/${currentCompany.id}/task-board`" target="_blank" class="nav-link" :class="{ active: $route.path.endsWith('/task-board') }">
  65 + 🗂️ 任务看板
66 </a> 66 </a>
67 <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }"> 67 <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }">
68 📁 Files 68 📁 Files
web/src/router/index.js
@@ -36,7 +36,10 @@ const routes = [ @@ -36,7 +36,10 @@ const routes = [
36 component: () => import('@/views/CompanyDashboard.vue'), 36 component: () => import('@/views/CompanyDashboard.vue'),
37 }, 37 },
38 { 38 {
39 - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs|workspace)', 39 + // 39 号文档 §二 P2:'goals'/'okr' 两个 tab 值已摘掉 —— 前者是 OKR 树的历史命名残留
  40 + // (真正的 goal/task 看板走下面 'task-board' 独立路由,不挤在这条 :tab 正则里),
  41 + // 后者是随 /okr 一起废弃的孤儿页面。
  42 + path: '/:id/:tab(overview|agents|pipelines|sop|team|files|messages|skills|jobs|workspace)',
40 name: 'CompanyDetail', 43 name: 'CompanyDetail',
41 component: () => import('@/views/CompanyDashboard.vue'), 44 component: () => import('@/views/CompanyDashboard.vue'),
42 props: true 45 props: true
@@ -50,9 +53,11 @@ const routes = [ @@ -50,9 +53,11 @@ const routes = [
50 props: true, 53 props: true,
51 }, 54 },
52 { 55 {
53 - path: '/okr',  
54 - name: 'OkrDashboard',  
55 - component: () => import('@/views/OkrDashboard.vue'), 56 + // 39 号文档 §二 P2:goal/task 两级看板独立页,同样不挤进 :tab 正则(信息密度高,值得一整页)。
  57 + path: '/:id/task-board',
  58 + name: 'TaskBoard',
  59 + component: () => import('@/views/TaskBoard.vue'),
  60 + props: true,
56 }, 61 },
57 // 新增独立页面 62 // 新增独立页面
58 { 63 {
web/src/views/CompanyDashboard.vue
@@ -43,6 +43,11 @@ @@ -43,6 +43,11 @@
43 <button class="btn-templates" @click="goToGraphConsole" style="border: none; background: transparent;"> 43 <button class="btn-templates" @click="goToGraphConsole" style="border: none; background: transparent;">
44 🕸️ 执行控制台 44 🕸️ 执行控制台
45 </button> 45 </button>
  46 + <!-- 39 号文档 §二 P2:goal/task 看板独立页入口,同一处放法(不挤进 :tab 白名单,
  47 + 按 goToGraphConsole 的先例给一个跳转按钮)。 -->
  48 + <button class="btn-templates" @click="goToTaskBoard" style="border: none; background: transparent;">
  49 + 🗂️ 任务看板
  50 + </button>
46 <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;"> 51 <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;">
47 📦 模板库 ({{ templates.length }}) 52 📦 模板库 ({{ templates.length }})
48 </button> 53 </button>
@@ -355,103 +360,6 @@ @@ -355,103 +360,6 @@
355 </div> 360 </div>
356 </div> 361 </div>
357 362
358 - <!-- Tab: Goals (Full OKR Tree) -->  
359 - <div v-if="activeTab === 'goals'" class="tab-content">  
360 - <div class="okr-layout">  
361 - <!-- Left: Goal List -->  
362 - <div class="okr-sidebar">  
363 - <div class="okr-sidebar-header">  
364 - <span>🎯 OKR 目标</span>  
365 - <span class="okr-count" v-if="goals.length">{{ goals.length }}</span>  
366 - </div>  
367 - <div class="okr-goal-list">  
368 - <div  
369 - v-for="g in goals" :key="g.id"  
370 - class="okr-goal-card" :class="{ active: selectedGoalId === g.id }"  
371 - @click="selectGoalDetail(g.id)"  
372 - >  
373 - <div class="okr-goal-top">  
374 - <span class="okr-gid">{{ g.id }}</span>  
375 - <span class="okr-gstatus" :class="g.status">{{ okrStatusLabel(g.status) }}</span>  
376 - </div>  
377 - <div class="okr-gtitle">{{ g.title }}</div>  
378 - <div class="okr-gprogress">  
379 - <div class="okr-pbar"><div class="okr-pfill" :style="{ width: (g.progress * 100) + '%' }"></div></div>  
380 - <span class="okr-ppct">{{ Math.round(g.progress * 100) }}%</span>  
381 - </div>  
382 - <div class="okr-gmeta">  
383 - <span>👤 {{ g.owner }}</span>  
384 - <span>{{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span>  
385 - </div>  
386 - </div>  
387 - <div v-if="goals.length === 0" class="empty-mini">暂无目标</div>  
388 - </div>  
389 - </div>  
390 -  
391 - <!-- Right: Goal Detail + Node Tree -->  
392 - <div class="okr-detail" v-if="selectedGoalDetail">  
393 - <div class="okr-hero">  
394 - <div class="okr-hero-row">  
395 - <span class="okr-hero-id">{{ selectedGoalDetail.id }}</span>  
396 - <span class="okr-hero-status" :class="selectedGoalDetail.status">{{ okrStatusLabel(selectedGoalDetail.status) }}</span>  
397 - <span class="okr-hero-owner">👤 {{ selectedGoalDetail.owner }}</span>  
398 - </div>  
399 - <h2 class="okr-hero-title">{{ selectedGoalDetail.title }}</h2>  
400 - <p class="okr-hero-desc">{{ selectedGoalDetail.description || '暂无详细描述' }}</p>  
401 - <div class="okr-hero-progress" v-if="selectedGoalProgress">  
402 - <div class="okr-hp-top">  
403 - <span>目标总体进度</span>  
404 - <span class="okr-hp-pct">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span>  
405 - </div>  
406 - <div class="okr-hp-bar"><div class="okr-hp-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div></div>  
407 - <div class="okr-hp-stats">  
408 - <span>总: {{ selectedGoalProgress.total }}</span>  
409 - <span class="stat-d">✅ {{ selectedGoalProgress.done }}</span>  
410 - <span class="stat-p">🔄 {{ selectedGoalProgress.inProgress }}</span>  
411 - <span class="stat-w">⏳ {{ selectedGoalProgress.pending }}</span>  
412 - </div>  
413 - </div>  
414 - </div>  
415 - <div class="okr-tree-area">  
416 - <h3 class="okr-tree-title">OKR 拆解树</h3>  
417 - <div class="okr-tree">  
418 - <div  
419 - v-for="node in okrSortedNodes" :key="node.id"  
420 - class="okr-tnode"  
421 - >  
422 - <div class="okr-tnode-line">  
423 - <div class="okr-tnode-indent" :style="{ width: (node.level - 2) * 24 + 'px' }"></div>  
424 - <div class="okr-tnode-card" :class="[node.status, 'okr-lv-' + node.level]">  
425 - <div class="okr-tnode-hdr">  
426 - <span class="okr-nid">{{ node.id }}</span>  
427 - <span class="okr-ntype">{{ okrLevelName(node.level) }}</span>  
428 - <span class="okr-nstatus" :class="node.status">{{ okrStatusLabel(node.status) }}</span>  
429 - </div>  
430 - <div class="okr-ntitle">{{ node.title }}</div>  
431 - <div class="okr-nfooter">  
432 - <span class="okr-nowner" v-if="node.owner">👤 {{ node.owner }}</span>  
433 - <span class="okr-nprio" v-if="node.priority" :class="node.priority">{{ node.priority }}</span>  
434 - <span class="okr-ntask" v-if="node.taskId">🔗 {{ node.taskId }}</span>  
435 - </div>  
436 - <div class="okr-nsummary" v-if="node.summary"><strong>摘要:</strong> {{ node.summary }}</div>  
437 - <div class="okr-nsummary" v-if="node.output"><strong>产出:</strong> {{ node.output }}</div>  
438 - </div>  
439 - </div>  
440 - </div>  
441 - <div v-if="okrSortedNodes.length === 0" class="empty-mini">暂无 OKR 节点</div>  
442 - </div>  
443 - </div>  
444 - </div>  
445 - <div class="okr-detail okr-empty" v-else>  
446 - <div class="empty-state-large">  
447 - <div style="font-size:48px; margin-bottom:16px; opacity:0.8">🎯</div>  
448 - <h3>OKR 目标看板</h3>  
449 - <p>从左侧选择一个目标查看拆解树与进度</p>  
450 - </div>  
451 - </div>  
452 - </div>  
453 - </div>  
454 -  
455 <!-- Tab: Files --> 363 <!-- Tab: Files -->
456 <div v-if="activeTab === 'files'" class="tab-content"> 364 <div v-if="activeTab === 'files'" class="tab-content">
457 <div class="files-layout"> 365 <div class="files-layout">
@@ -852,10 +760,6 @@ const companyAgents = ref([]) @@ -852,10 +760,6 @@ const companyAgents = ref([])
852 const pipelines = ref([]) 760 const pipelines = ref([])
853 const pipelineRuns = ref([]) 761 const pipelineRuns = ref([])
854 const selectedPipeline = ref(null) 762 const selectedPipeline = ref(null)
855 -const goals = ref([])  
856 -const selectedGoalId = ref(null)  
857 -const selectedGoalDetail = ref(null)  
858 -const selectedGoalProgress = ref(null)  
859 const templates = ref([]) 763 const templates = ref([])
860 const activeTab = ref('overview') 764 const activeTab = ref('overview')
861 const showTemplates = ref(false) 765 const showTemplates = ref(false)
@@ -1039,7 +943,6 @@ async function loadCompanyData(id, tab) { @@ -1039,7 +943,6 @@ async function loadCompanyData(id, tab) {
1039 if (activeTab.value === 'overview') await loadCompanyStatus(id) 943 if (activeTab.value === 'overview') await loadCompanyStatus(id)
1040 if (activeTab.value === 'agents') { await loadCompanyAgents(id); await loadOrgGraph(id) } 944 if (activeTab.value === 'agents') { await loadCompanyAgents(id); await loadOrgGraph(id) }
1041 if (activeTab.value === 'pipelines') await loadPipelines(id) 945 if (activeTab.value === 'pipelines') await loadPipelines(id)
1042 - if (activeTab.value === 'goals') await loadGoals(id)  
1043 if (activeTab.value === 'files') await loadCompanyFiles(id) 946 if (activeTab.value === 'files') await loadCompanyFiles(id)
1044 if (activeTab.value === 'messages') await loadMessages(id) 947 if (activeTab.value === 'messages') await loadMessages(id)
1045 if (activeTab.value === 'skills') await loadSkillsList(id) 948 if (activeTab.value === 'skills') await loadSkillsList(id)
@@ -1064,6 +967,11 @@ function goToGraphConsole() { @@ -1064,6 +967,11 @@ function goToGraphConsole() {
1064 router.push(`/${selectedCompany.value.id}/graph-console`) 967 router.push(`/${selectedCompany.value.id}/graph-console`)
1065 } 968 }
1066 969
  970 +function goToTaskBoard() {
  971 + if (!selectedCompany.value) return
  972 + router.push(`/${selectedCompany.value.id}/task-board`)
  973 +}
  974 +
1067 watch(() => route?.params, async (params) => { 975 watch(() => route?.params, async (params) => {
1068 if (!params) return 976 if (!params) return
1069 if (route.name === 'CompanyDetail' && params.id) { 977 if (route.name === 'CompanyDetail' && params.id) {
@@ -1216,54 +1124,6 @@ async function retryPipelineRun(runId) { @@ -1216,54 +1124,6 @@ async function retryPipelineRun(runId) {
1216 } catch (e) { alert(`❌ 重试失败: ${e.message}`) } 1124 } catch (e) { alert(`❌ 重试失败: ${e.message}`) }
1217 } 1125 }
1218 1126
1219 -async function loadGoals(id) {  
1220 - try {  
1221 - const { data } = await axios.get(`/api/company/${id}/okr`)  
1222 - goals.value = data  
1223 - // Auto-select first goal  
1224 - if (data.length > 0 && !selectedGoalId.value) {  
1225 - selectGoalDetail(data[0].id)  
1226 - }  
1227 - } catch (e) { console.error(e) }  
1228 -}  
1229 -  
1230 -async function selectGoalDetail(goalId) {  
1231 - if (!selectedCompany.value) return  
1232 - selectedGoalId.value = goalId  
1233 - try {  
1234 - const [goalRes, progRes] = await Promise.all([  
1235 - axios.get(`/api/company/${selectedCompany.value.id}/okr/${goalId}`),  
1236 - axios.get(`/api/company/${selectedCompany.value.id}/okr/${goalId}/progress`)  
1237 - ])  
1238 - selectedGoalDetail.value = goalRes.data  
1239 - selectedGoalProgress.value = progRes.data  
1240 - } catch (e) { console.error(e) }  
1241 -}  
1242 -  
1243 -function okrStatusLabel(status) {  
1244 - const map = { active: '运转中', in_progress: '进行中', pending: '待开始', done: '已完成', failed: '失败', blocked: '已阻塞' }  
1245 - return map[status] || status  
1246 -}  
1247 -  
1248 -function okrLevelName(level) {  
1249 - const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task']  
1250 - return names[Math.min(level - 1, 4)]  
1251 -}  
1252 -  
1253 -const okrSortedNodes = computed(() => {  
1254 - if (!selectedGoalDetail.value?.nodes) return []  
1255 - const nodes = selectedGoalDetail.value.nodes  
1256 - const keys = Object.keys(nodes).sort((a, b) => {  
1257 - const partsA = a.replace('G-','').split('.').map(Number)  
1258 - const partsB = b.replace('G-','').split('.').map(Number)  
1259 - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {  
1260 - if ((partsA[i] || 0) !== (partsB[i] || 0)) return (partsA[i] || 0) - (partsB[i] || 0)  
1261 - }  
1262 - return 0  
1263 - })  
1264 - return keys.map(id => ({ id, ...nodes[id], level: id.split('.').length }))  
1265 -})  
1266 -  
1267 async function loadCompanyStatus(id) { 1127 async function loadCompanyStatus(id) {
1268 try { 1128 try {
1269 const { data } = await axios.get(`/api/company/${id}/status`) 1129 const { data } = await axios.get(`/api/company/${id}/status`)
@@ -1848,84 +1708,6 @@ function closeDropdown() { @@ -1848,84 +1708,6 @@ function closeDropdown() {
1848 .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); } 1708 .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); }
1849 .run-actions { margin-top: 8px; } 1709 .run-actions { margin-top: 8px; }
1850 1710
1851 -/* ═══ OKR Full Layout ═══ */  
1852 -.okr-layout { display: flex; height: calc(100vh - 140px); overflow: hidden; }  
1853 -.okr-sidebar { width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary); background: var(--bg-overlay); display: flex; flex-direction: column; }  
1854 -.okr-sidebar-header { padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary); border-bottom: 1px solid var(--border-primary); display: flex; align-items: center; justify-content: space-between; background: var(--bg-secondary); }  
1855 -.okr-count { background: var(--bg-surface-active); color: var(--accent); padding: 2px 8px; border-radius: 12px; font-size: 11px; }  
1856 -.okr-goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }  
1857 -.okr-goal-card { padding: 12px 14px; border-radius: 10px; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--border-primary); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); }  
1858 -.okr-goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); }  
1859 -.okr-goal-card.active { background: var(--accent-bg); border-color: var(--border-active); box-shadow: 0 0 0 1px var(--accent) inset; }  
1860 -.okr-goal-top { display: flex; justify-content: space-between; margin-bottom: 6px; }  
1861 -.okr-gid { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); }  
1862 -.okr-gstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }  
1863 -.okr-gstatus.active { background: rgba(59,130,246,0.15); color: #60a5fa; }  
1864 -.okr-gstatus.done { background: rgba(16,185,129,0.15); color: #34d399; }  
1865 -.okr-gtitle { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; }  
1866 -.okr-gprogress { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }  
1867 -.okr-pbar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; }  
1868 -.okr-pfill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; }  
1869 -.okr-ppct { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; }  
1870 -.okr-gmeta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); }  
1871 -  
1872 -/* OKR Detail Panel */  
1873 -.okr-detail { flex: 1; overflow-y: auto; background: var(--bg-primary); display: flex; flex-direction: column; }  
1874 -.okr-detail.okr-empty { align-items: center; justify-content: center; background: var(--bg-overlay); }  
1875 -.okr-detail.okr-empty h3 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); }  
1876 -.okr-detail.okr-empty p { color: var(--text-muted); font-size: 13px; }  
1877 -  
1878 -.okr-hero { padding: 30px 40px; border-bottom: 1px solid var(--border-primary); background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%); }  
1879 -.okr-hero-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }  
1880 -.okr-hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); }  
1881 -.okr-hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; }  
1882 -.okr-hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }  
1883 -.okr-hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }  
1884 -.okr-hero-owner { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-left: auto; background: var(--bg-surface); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border-primary); }  
1885 -.okr-hero-title { font-size: 26px; font-weight: 800; margin: 0 0 10px; letter-spacing: -0.5px; }  
1886 -.okr-hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; }  
1887 -  
1888 -.okr-hero-progress { margin-top: 24px; max-width: 600px; background: var(--bg-surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border-primary); }  
1889 -.okr-hp-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; font-size: 13px; font-weight: 600; color: var(--text-secondary); }  
1890 -.okr-hp-pct { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; }  
1891 -.okr-hp-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; }  
1892 -.okr-hp-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 4px; transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); }  
1893 -.okr-hp-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); }  
1894 -.okr-hp-stats .stat-d { color: #34d399; }  
1895 -.okr-hp-stats .stat-p { color: #60a5fa; }  
1896 -.okr-hp-stats .stat-w { color: var(--text-muted); }  
1897 -  
1898 -/* OKR Tree */  
1899 -.okr-tree-area { padding: 30px 40px; max-width: 1000px; }  
1900 -.okr-tree-title { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); }  
1901 -.okr-tree { display: flex; flex-direction: column; gap: 6px; }  
1902 -.okr-tnode-line { display: flex; width: 100%; }  
1903 -.okr-tnode-indent { position: relative; }  
1904 -.okr-tnode-indent::before { content: ''; position: absolute; top: -16px; bottom: 50%; left: -14px; width: 14px; border-left: 1.5px solid var(--border-active); border-bottom: 1.5px solid var(--border-active); border-bottom-left-radius: 6px; z-index: 1; opacity: 0.3; }  
1905 -.okr-tnode-card { flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 8px; padding: 12px 16px; transition: all 0.2s; }  
1906 -.okr-tnode-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); }  
1907 -.okr-tnode-card.okr-lv-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); }  
1908 -.okr-tnode-card.okr-lv-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); }  
1909 -.okr-tnode-hdr { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }  
1910 -.okr-nid { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); }  
1911 -.okr-ntype { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 10px; background: rgba(255,255,255,0.05); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }  
1912 -.okr-nstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; }  
1913 -.okr-nstatus.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); }  
1914 -.okr-nstatus.in_progress { background: rgba(59,130,246,0.1); color: #60a5fa; border-color: rgba(59,130,246,0.2); animation: okr-pulse 2s infinite; }  
1915 -.okr-nstatus.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); }  
1916 -@keyframes okr-pulse { 0% { box-shadow: 0 0 0 0 rgba(96,165,250,0.2); } 70% { box-shadow: 0 0 0 4px rgba(96,165,250,0); } 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0); } }  
1917 -.okr-ntitle { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; }  
1918 -.okr-nfooter { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 4px; }  
1919 -.okr-nowner { font-size: 11px; font-weight: 500; color: var(--accent-light); display: flex; align-items: center; gap: 4px; background: var(--accent-bg); padding: 2px 8px; border-radius: 10px; }  
1920 -.okr-nprio { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }  
1921 -.okr-nprio.P0 { color: #ef4444; }  
1922 -.okr-nprio.P1 { color: #f59e0b; }  
1923 -.okr-nprio.P2 { color: var(--text-muted); }  
1924 -.okr-ntask { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--blue-text); background: rgba(59,130,246,0.1); padding: 2px 6px; border-radius: 4px; }  
1925 -.okr-nsummary { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border-primary); font-size: 11px; color: var(--text-tertiary); line-height: 1.5; }  
1926 -.okr-nsummary strong { color: var(--text-secondary); }  
1927 -  
1928 -.empty-state-large { text-align: center; color: var(--text-muted); }  
1929 1711
1930 /* ═══ Modal ═══ */ 1712 /* ═══ Modal ═══ */
1931 .modal-overlay { 1713 .modal-overlay {
web/src/views/OkrDashboard.vue deleted
1 -<template>  
2 - <div class="okr-dashboard-page">  
3 - <AppNav />  
4 - <div class="okr-layout">  
5 - <!-- 第1列: Goal 列表 -->  
6 - <aside class="goal-sidebar">  
7 - <div class="col-header">  
8 - <span>🎯 OKR 目标</span>  
9 - <span class="count-badge" v-if="goals.length">{{ goals.length }}</span>  
10 - </div>  
11 - <div class="goal-list">  
12 - <div  
13 - v-for="g in goals" :key="g.id"  
14 - class="goal-card" :class="{ active: selectedGoalId === g.id }"  
15 - @click="selectGoal(g.id)"  
16 - >  
17 - <div class="goal-card-header">  
18 - <span class="goal-id">{{ g.id }}</span>  
19 - <span class="goal-status" :class="g.status">{{ getDisplayStatus(g.status) }}</span>  
20 - </div>  
21 - <div class="goal-title" :title="g.title">{{ g.title }}</div>  
22 - <div class="goal-progress-wrap">  
23 - <div class="progress-bar"><div class="progress-fill" :style="{ width: (g.progress * 100) + '%' }"></div></div>  
24 - <span class="progress-text">{{ Math.round(g.progress * 100) }}%</span>  
25 - </div>  
26 - <div class="goal-meta">  
27 - <span class="goal-owner">👤 {{ g.owner }}</span>  
28 - <span class="goal-counts">节点: {{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span>  
29 - </div>  
30 - </div>  
31 - <div v-if="goals.length === 0" class="empty-state">当前没有目标</div>  
32 - </div>  
33 - </aside>  
34 -  
35 - <!-- 第2列: Goal 详情与节点树 -->  
36 - <section class="goal-detail-panel" v-if="selectedGoal">  
37 - <div class="goal-hero">  
38 - <div class="hero-top">  
39 - <span class="hero-id">{{ selectedGoal.id }}</span>  
40 - <span class="hero-status" :class="selectedGoal.status">{{ getDisplayStatus(selectedGoal.status) }}</span>  
41 - <span class="hero-owner">👤 负责人: {{ selectedGoal.owner }}</span>  
42 - </div>  
43 - <h1 class="hero-title">{{ selectedGoal.title }}</h1>  
44 - <p class="hero-desc">{{ selectedGoal.description || '暂无详细描述...' }}</p>  
45 - <div class="hero-progress-large" v-if="selectedGoalProgress">  
46 - <div class="large-progress-top">  
47 - <span class="large-progress-label">目标总体进度</span>  
48 - <span class="large-progress-value">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span>  
49 - </div>  
50 - <div class="large-progress-bar">  
51 - <div class="large-progress-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div>  
52 - </div>  
53 - <div class="large-progress-stats">  
54 - <span>总叶子节点: {{ selectedGoalProgress.total }}</span>  
55 - <span class="stat-done">已完成: {{ selectedGoalProgress.done }}</span>  
56 - <span class="stat-inprogress">进行中: {{ selectedGoalProgress.inProgress }}</span>  
57 - <span class="stat-pending">待开始: {{ selectedGoalProgress.pending }}</span>  
58 - </div>  
59 - </div>  
60 - </div>  
61 -  
62 - <div class="nodes-area">  
63 - <div class="nodes-header">  
64 - <h3>OKR 拆解树配置</h3>  
65 - </div>  
66 - <div class="tree-container">  
67 - <div  
68 - v-for="node in sortedNodes" :key="node.id"  
69 - class="tree-node"  
70 - :class="'level-' + node.level"  
71 - >  
72 - <div class="node-line">  
73 - <div class="node-prefix" :style="{ width: (node.level - 2) * 24 + 'px' }"></div>  
74 - <div class="node-card" :class="[node.status, 'level-style-' + node.level]">  
75 - <div class="node-header">  
76 - <span class="node-id">{{ node.id }}</span>  
77 - <span class="node-type-badge">{{ getLevelName(node.level) }}</span>  
78 - <span class="node-status" :class="node.status">{{ getDisplayStatus(node.status) }}</span>  
79 - </div>  
80 - <div class="node-title">{{ node.title }}</div>  
81 - <div class="node-footer">  
82 - <span class="node-owner" v-if="node.owner">👤 {{ node.owner }}</span>  
83 - <span class="node-priority" v-if="node.priority" :class="node.priority">{{ node.priority }}</span>  
84 - <span class="node-task" v-if="node.taskId">🔗 {{ node.taskId }}</span>  
85 - </div>  
86 - <div class="node-summary" v-if="node.summary">  
87 - <strong>摘要:</strong> {{ node.summary }}  
88 - </div>  
89 - <div class="node-summary" v-if="node.output">  
90 - <strong>产出:</strong> {{ node.output }}  
91 - </div>  
92 - </div>  
93 - </div>  
94 - </div>  
95 - </div>  
96 - </div>  
97 - </section>  
98 -  
99 - <section class="goal-detail-panel empty-panel" v-else>  
100 - <div class="empty-state-large">  
101 - <div class="empty-icon">🎯</div>  
102 - <h2>OKR 目标看板</h2>  
103 - <p>请从左侧列表选择一个目标查看详细拆解树与进度</p>  
104 - </div>  
105 - </section>  
106 - </div>  
107 - </div>  
108 -</template>  
109 -  
110 -<script setup>  
111 -import { ref, computed, onMounted } from 'vue'  
112 -import AppNav from '@/components/AppNav.vue'  
113 -  
114 -const goals = ref([])  
115 -const selectedGoalId = ref(null)  
116 -const selectedGoal = ref(null)  
117 -const selectedGoalProgress = ref(null)  
118 -  
119 -const statusMap = {  
120 - 'active': '运转中',  
121 - 'in_progress': '进行中',  
122 - 'pending': '待开始',  
123 - 'done': '已完成',  
124 - 'failed': '失败',  
125 - 'blocked': '已阻塞'  
126 -}  
127 -  
128 -function getDisplayStatus(status) {  
129 - return statusMap[status] || status  
130 -}  
131 -  
132 -function getLevelName(level) {  
133 - const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task'];  
134 - return names[Math.min(level - 1, 4)];  
135 -}  
136 -  
137 -async function loadGoals() {  
138 - try {  
139 - const res = await fetch('/api/okr')  
140 - if (res.ok) {  
141 - goals.value = await res.json()  
142 - // 默认选中第一个  
143 - if (goals.value.length > 0 && !selectedGoalId.value) {  
144 - selectGoal(goals.value[0].id)  
145 - }  
146 - }  
147 - } catch (err) {  
148 - console.error('Failed to load goals:', err)  
149 - }  
150 -}  
151 -  
152 -async function selectGoal(id) {  
153 - selectedGoalId.value = id  
154 - try {  
155 - const [goalRes, progRes] = await Promise.all([  
156 - fetch(`/api/okr/${id}`),  
157 - fetch(`/api/okr/${id}/progress`)  
158 - ])  
159 - if (goalRes.ok) selectedGoal.value = await goalRes.json()  
160 - if (progRes.ok) selectedGoalProgress.value = await progRes.json()  
161 - } catch (err) {  
162 - console.error('Failed to load goal detail:', err)  
163 - }  
164 -}  
165 -  
166 -// 将扁平的nodes转为按树结构排序的列表,便于直接循环渲染并计算缩进  
167 -const sortedNodes = computed(() => {  
168 - if (!selectedGoal.value || !selectedGoal.value.nodes) return []  
169 - const nodes = selectedGoal.value.nodes  
170 -  
171 - // 给定一个父节点,递归获取所有后代,按ID自然排序确保层级  
172 - const result = []  
173 - const keys = Object.keys(nodes).sort((a, b) => {  
174 - // 采用自然排序法,如 G-1.2 排在 G-1.10 前面  
175 - const partsA = a.replace('G-','').split('.').map(Number)  
176 - const partsB = b.replace('G-','').split('.').map(Number)  
177 - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {  
178 - const numA = partsA[i] || 0  
179 - const numB = partsB[i] || 0  
180 - if (numA !== numB) return numA - numB  
181 - }  
182 - return 0  
183 - })  
184 -  
185 - for (const id of keys) {  
186 - result.push({  
187 - id,  
188 - ...nodes[id],  
189 - level: id.split('.').length  
190 - })  
191 - }  
192 - return result  
193 -})  
194 -  
195 -onMounted(() => {  
196 - loadGoals()  
197 - // 简单轮询刷新进度  
198 - setInterval(() => {  
199 - if (selectedGoalId.value) {  
200 - selectGoal(selectedGoalId.value)  
201 - } else {  
202 - loadGoals()  
203 - }  
204 - }, 10000)  
205 -})  
206 -</script>  
207 -  
208 -<style scoped>  
209 -.okr-dashboard-page {  
210 - height: 100vh; display: flex; flex-direction: column;  
211 - background: var(--bg-primary);  
212 - color: var(--text-primary); font-family: 'Inter', -apple-system, sans-serif;  
213 -}  
214 -.okr-layout { flex: 1; display: flex; overflow: hidden; }  
215 -  
216 -.col-header {  
217 - padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary);  
218 - border-bottom: 1px solid var(--border-primary);  
219 - display: flex; align-items: center; justify-content: space-between;  
220 - background: var(--bg-secondary);  
221 -}  
222 -.count-badge {  
223 - background: var(--bg-surface-active); color: var(--accent);  
224 - padding: 2px 8px; border-radius: 12px; font-size: 11px;  
225 -}  
226 -  
227 -/* ═══ Left Sidebar: Goals ═══ */  
228 -.goal-sidebar {  
229 - width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary);  
230 - background: var(--bg-overlay); display: flex; flex-direction: column;  
231 -}  
232 -.goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }  
233 -.goal-card {  
234 - padding: 12px 14px; border-radius: 10px; cursor: pointer;  
235 - background: var(--bg-surface); border: 1px solid var(--border-primary);  
236 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);  
237 -}  
238 -.goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); }  
239 -.goal-card.active {  
240 - background: var(--accent-bg); border-color: var(--border-active);  
241 - box-shadow: 0 0 0 1px var(--accent) inset;  
242 -}  
243 -  
244 -.goal-card-header { display: flex; justify-content: space-between; margin-bottom: 6px; }  
245 -.goal-id { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); }  
246 -.goal-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }  
247 -.goal-status.active { background: rgba(59,130,246,0.15); color: #60a5fa; }  
248 -.goal-status.done { background: rgba(16,185,129,0.15); color: #34d399; }  
249 -  
250 -.goal-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; }  
251 -  
252 -.goal-progress-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }  
253 -.progress-bar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; }  
254 -.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; }  
255 -.progress-text { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; }  
256 -  
257 -.goal-meta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); }  
258 -  
259 -.empty-state { text-align: center; padding: 40px 20px; color: var(--text-faint); font-size: 12px; }  
260 -  
261 -/* ═══ Right Panel: Details ═══ */  
262 -.goal-detail-panel {  
263 - flex: 1; overflow-y: auto; background: var(--bg-primary);  
264 - display: flex; flex-direction: column;  
265 -}  
266 -.goal-detail-panel.empty-panel { align-items: center; justify-content: center; background: var(--bg-overlay); }  
267 -  
268 -.empty-state-large { text-align: center; color: var(--text-muted); }  
269 -.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.8; }  
270 -.empty-state-large h2 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); }  
271 -  
272 -.goal-hero {  
273 - padding: 30px 40px; border-bottom: 1px solid var(--border-primary);  
274 - background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%);  
275 -}  
276 -.hero-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }  
277 -.hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); }  
278 -.hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; }  
279 -.hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }  
280 -.hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }  
281 -  
282 -.hero-owner { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-left: auto; background: var(--bg-surface); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border-primary); }  
283 -.hero-title { font-size: 28px; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.5px; }  
284 -.hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; }  
285 -  
286 -.hero-progress-large { margin-top: 24px; max-width: 600px; background: var(--bg-surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border-primary); }  
287 -.large-progress-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; }  
288 -.large-progress-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }  
289 -.large-progress-value { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; }  
290 -.large-progress-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; }  
291 -.large-progress-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 4px; transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); }  
292 -.large-progress-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); }  
293 -.stat-done { color: #34d399; }  
294 -.stat-inprogress { color: #60a5fa; }  
295 -.stat-pending { color: var(--text-muted); }  
296 -  
297 -/* ═══ Nodes Tree ═══ */  
298 -.nodes-area { padding: 30px 40px; max-width: 1000px; }  
299 -.nodes-header h3 { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); }  
300 -  
301 -.tree-container { display: flex; flex-direction: column; gap: 6px; }  
302 -.tree-node { display: flex; flex-direction: column; }  
303 -.node-line { display: flex; width: 100%; }  
304 -.node-prefix { position: relative; }  
305 -/* 连线占位 */  
306 -.node-prefix::before {  
307 - content: ''; position: absolute; top: -16px; bottom: 50%; left: -14px;  
308 - width: 14px; border-left: 1.5px solid var(--border-active); border-bottom: 1.5px solid var(--border-active);  
309 - border-bottom-left-radius: 6px; z-index: 1; opacity: 0.3;  
310 -}  
311 -  
312 -.node-card {  
313 - flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary);  
314 - border-radius: 8px; padding: 12px 16px; position: relative;  
315 - transition: all 0.2s;  
316 -}  
317 -.node-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); }  
318 -  
319 -/* Level Styles */  
320 -.node-card.level-style-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); }  
321 -.node-card.level-style-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); }  
322 -.node-card.level-style-4 { background: var(--bg-surface); }  
323 -  
324 -.node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }  
325 -.node-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); }  
326 -.node-type-badge { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 10px; background: rgba(255,255,255,0.05); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }  
327 -  
328 -.node-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; }  
329 -.node-status.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); }  
330 -.node-status.in_progress { background: rgba(59,130,246,0.1); color: #60a5fa; border-color: rgba(59,130,246,0.2); animation: pulse-light 2s infinite; }  
331 -.node-status.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); }  
332 -  
333 -@keyframes pulse-light {  
334 - 0% { box-shadow: 0 0 0 0 rgba(96,165,250,0.2); }  
335 - 70% { box-shadow: 0 0 0 4px rgba(96,165,250,0); }  
336 - 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0); }  
337 -}  
338 -  
339 -.node-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; }  
340 -  
341 -.node-footer { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 6px; }  
342 -.node-owner { font-size: 11px; font-weight: 500; color: var(--accent-light); display: flex; align-items: center; gap: 4px; background: var(--accent-bg); padding: 2px 8px; border-radius: 10px; }  
343 -.node-priority { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }  
344 -.node-priority.P0 { color: #ef4444; }  
345 -.node-priority.P1 { color: #f59e0b; }  
346 -.node-priority.P2 { color: var(--text-muted); }  
347 -.node-task { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--blue-text); background: rgba(59,130,246,0.1); padding: 2px 6px; border-radius: 4px; }  
348 -  
349 -.node-summary { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border-primary); font-size: 11px; color: var(--text-tertiary); line-height: 1.5; }  
350 -.node-summary strong { color: var(--text-secondary); }  
351 -  
352 -</style>  
web/src/views/TaskBoard.vue 0 → 100644
  1 +<!--
  2 + 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。
  3 +
  4 + 在此之前 goal/task 只能在 GraphConsole 左栏的窄侧栏里看,这里把同一份组件
  5 + (GoalTaskPane + goal-task-model.js)铺满整页,数据层原样不动,不重写。
  6 +
  7 + 点一条 task 不在本页画执行图(那是 GraphConsole 的活),而是跳过去 ——
  8 + `/:id/graph-console?goal=...&task=...` 是 GraphConsole 已经支持的选中态复原路径
  9 + (见该文件 onMounted 里 `route.query.goal` / `route.query.task` 那两行),不新开一条协议。
  10 +-->
  11 +<template>
  12 + <div class="board">
  13 + <header class="top">
  14 + <div class="who">
  15 + <router-link :to="`/${companyId}/overview`" class="back">←</router-link>
  16 + <b>{{ company?.name || companyId }}</b>
  17 + <span class="sep">任务看板</span>
  18 + </div>
  19 + <router-link :to="`/${companyId}/graph-console`" class="to-console">🕸️ 执行控制台</router-link>
  20 + </header>
  21 +
  22 + <div class="body">
  23 + <GoalTaskPane
  24 + :company-id="companyId"
  25 + :goals="goals"
  26 + :loading="goalsLoading"
  27 + :selected-task-id="selectedTaskId"
  28 + :selected-goal-id="selectedGoalId"
  29 + :slots="slots"
  30 + @select="goToDetail"
  31 + @refresh="loadGoals"
  32 + />
  33 + </div>
  34 + </div>
  35 +</template>
  36 +
  37 +<script setup>
  38 +import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
  39 +import { useRoute, useRouter } from 'vue-router';
  40 +import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';
  41 +
  42 +const route = useRoute();
  43 +const router = useRouter();
  44 +const props = defineProps({ id: { type: String, default: '' } });
  45 +const companyId = computed(() => props.id || route.params.id);
  46 +
  47 +const company = ref(null);
  48 +const goals = ref([]);
  49 +const goalsLoading = ref(false);
  50 +const slots = ref(null);
  51 +const selectedGoalId = ref('');
  52 +const selectedTaskId = ref('');
  53 +
  54 +let treePoll = null;
  55 +
  56 +onMounted(async () => {
  57 + selectedGoalId.value = String(route.query.goal || '');
  58 + selectedTaskId.value = String(route.query.task || '');
  59 + await Promise.all([loadCompany(), loadGoals()]);
  60 + // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化
  61 + treePoll = setInterval(() => { loadGoals(); }, 20000);
  62 +});
  63 +onBeforeUnmount(() => clearInterval(treePoll));
  64 +
  65 +async function loadCompany() {
  66 + try {
  67 + const r = await fetch(`/api/company/${companyId.value}`);
  68 + if (r.ok) company.value = await r.json();
  69 + } catch { /* 页头名字掉了不影响功能 */ }
  70 +}
  71 +
  72 +async function loadGoals() {
  73 + goalsLoading.value = true;
  74 + try {
  75 + const r = await fetch(`/api/company/${companyId.value}/goals?withTasks=1`);
  76 + if (!r.ok) return;
  77 + const j = await r.json();
  78 + goals.value = j.goals || [];
  79 + slots.value = j.slots || null;
  80 + } catch { /* 轮询失败保留上一份树 */ } finally {
  81 + goalsLoading.value = false;
  82 + }
  83 +}
  84 +
  85 +/** 点一条 task:跳到执行控制台看它的图,选中态通过 query 带过去 */
  86 +function goToDetail({ goalId, taskId }) {
  87 + selectedGoalId.value = goalId || '';
  88 + selectedTaskId.value = taskId || '';
  89 + if (!taskId) return;
  90 + router.push({ path: `/${companyId.value}/graph-console`, query: { goal: goalId || '', task: taskId } });
  91 +}
  92 +</script>
  93 +
  94 +<style scoped>
  95 +.board { display: flex; flex-direction: column; height: 100vh; background: #fff; color: #1a1a1a; }
  96 +
  97 +.top {
  98 + display: flex; align-items: center; justify-content: space-between;
  99 + padding: 10px 16px; border-bottom: 1px solid #eee; flex: 0 0 auto;
  100 +}
  101 +.who { display: flex; align-items: center; gap: 8px; font-size: 13px; }
  102 +.back { text-decoration: none; color: #666; font-size: 16px; }
  103 +.sep { color: #999; }
  104 +.to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; }
  105 +.to-console:hover { text-decoration: underline; }
  106 +
  107 +.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; max-width: 900px; width: 100%; margin: 0 auto; box-sizing: border-box; }
  108 +
  109 +@media (max-width: 700px) {
  110 + .body { padding: 12px; }
  111 +}
  112 +</style>