Commit 5108d67f5c1a19bd10d50576d41e04734d41af8f
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
Showing
5 changed files
with
133 additions
and
586 deletions
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> | ... | ... |