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,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> |