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 61 <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }">
62 62 ⚙️ SOP
63 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 66 </a>
67 67 <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }">
68 68 📁 Files
... ...
web/src/router/index.js
... ... @@ -36,7 +36,10 @@ const routes = [
36 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 43 name: 'CompanyDetail',
41 44 component: () => import('@/views/CompanyDashboard.vue'),
42 45 props: true
... ... @@ -50,9 +53,11 @@ const routes = [
50 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 43 <button class="btn-templates" @click="goToGraphConsole" style="border: none; background: transparent;">
44 44 🕸️ 执行控制台
45 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 51 <button class="btn-templates" @click="showTemplates = !showTemplates" style="border: none; background: transparent;">
47 52 📦 模板库 ({{ templates.length }})
48 53 </button>
... ... @@ -355,103 +360,6 @@
355 360 </div>
356 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 363 <!-- Tab: Files -->
456 364 <div v-if="activeTab === 'files'" class="tab-content">
457 365 <div class="files-layout">
... ... @@ -852,10 +760,6 @@ const companyAgents = ref([])
852 760 const pipelines = ref([])
853 761 const pipelineRuns = ref([])
854 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 763 const templates = ref([])
860 764 const activeTab = ref('overview')
861 765 const showTemplates = ref(false)
... ... @@ -1039,7 +943,6 @@ async function loadCompanyData(id, tab) {
1039 943 if (activeTab.value === 'overview') await loadCompanyStatus(id)
1040 944 if (activeTab.value === 'agents') { await loadCompanyAgents(id); await loadOrgGraph(id) }
1041 945 if (activeTab.value === 'pipelines') await loadPipelines(id)
1042   - if (activeTab.value === 'goals') await loadGoals(id)
1043 946 if (activeTab.value === 'files') await loadCompanyFiles(id)
1044 947 if (activeTab.value === 'messages') await loadMessages(id)
1045 948 if (activeTab.value === 'skills') await loadSkillsList(id)
... ... @@ -1064,6 +967,11 @@ function goToGraphConsole() {
1064 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 975 watch(() => route?.params, async (params) => {
1068 976 if (!params) return
1069 977 if (route.name === 'CompanyDetail' && params.id) {
... ... @@ -1216,54 +1124,6 @@ async function retryPipelineRun(runId) {
1216 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 1127 async function loadCompanyStatus(id) {
1268 1128 try {
1269 1129 const { data } = await axios.get(`/api/company/${id}/status`)
... ... @@ -1848,84 +1708,6 @@ function closeDropdown() {
1848 1708 .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); }
1849 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 1712 /* ═══ Modal ═══ */
1931 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>
... ...