OkrDashboard.vue 16 KB
<template>
  <div class="okr-dashboard-page">
    <AppNav />
    <div class="okr-layout">
      <!-- 第1列: Goal 列表 -->
      <aside class="goal-sidebar">
        <div class="col-header">
          <span>🎯 OKR 目标</span>
          <span class="count-badge" v-if="goals.length">{{ goals.length }}</span>
        </div>
        <div class="goal-list">
          <div
            v-for="g in goals" :key="g.id"
            class="goal-card" :class="{ active: selectedGoalId === g.id }"
            @click="selectGoal(g.id)"
          >
            <div class="goal-card-header">
              <span class="goal-id">{{ g.id }}</span>
              <span class="goal-status" :class="g.status">{{ getDisplayStatus(g.status) }}</span>
            </div>
            <div class="goal-title" :title="g.title">{{ g.title }}</div>
            <div class="goal-progress-wrap">
              <div class="progress-bar"><div class="progress-fill" :style="{ width: (g.progress * 100) + '%' }"></div></div>
              <span class="progress-text">{{ Math.round(g.progress * 100) }}%</span>
            </div>
            <div class="goal-meta">
              <span class="goal-owner">👤 {{ g.owner }}</span>
              <span class="goal-counts">节点: {{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span>
            </div>
          </div>
          <div v-if="goals.length === 0" class="empty-state">当前没有目标</div>
        </div>
      </aside>

      <!-- 第2列: Goal 详情与节点树 -->
      <section class="goal-detail-panel" v-if="selectedGoal">
        <div class="goal-hero">
          <div class="hero-top">
            <span class="hero-id">{{ selectedGoal.id }}</span>
            <span class="hero-status" :class="selectedGoal.status">{{ getDisplayStatus(selectedGoal.status) }}</span>
            <span class="hero-owner">👤 负责人: {{ selectedGoal.owner }}</span>
          </div>
          <h1 class="hero-title">{{ selectedGoal.title }}</h1>
          <p class="hero-desc">{{ selectedGoal.description || '暂无详细描述...' }}</p>
          <div class="hero-progress-large" v-if="selectedGoalProgress">
            <div class="large-progress-top">
              <span class="large-progress-label">目标总体进度</span>
              <span class="large-progress-value">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span>
            </div>
            <div class="large-progress-bar">
              <div class="large-progress-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div>
            </div>
            <div class="large-progress-stats">
              <span>总叶子节点: {{ selectedGoalProgress.total }}</span>
              <span class="stat-done">已完成: {{ selectedGoalProgress.done }}</span>
              <span class="stat-inprogress">进行中: {{ selectedGoalProgress.inProgress }}</span>
              <span class="stat-pending">待开始: {{ selectedGoalProgress.pending }}</span>
            </div>
          </div>
        </div>

        <div class="nodes-area">
          <div class="nodes-header">
            <h3>OKR 拆解树配置</h3>
          </div>
          <div class="tree-container">
            <div
              v-for="node in sortedNodes" :key="node.id"
              class="tree-node"
              :class="'level-' + node.level"
            >
              <div class="node-line">
                <div class="node-prefix" :style="{ width: (node.level - 2) * 24 + 'px' }"></div>
                <div class="node-card" :class="[node.status, 'level-style-' + node.level]">
                  <div class="node-header">
                    <span class="node-id">{{ node.id }}</span>
                    <span class="node-type-badge">{{ getLevelName(node.level) }}</span>
                    <span class="node-status" :class="node.status">{{ getDisplayStatus(node.status) }}</span>
                  </div>
                  <div class="node-title">{{ node.title }}</div>
                  <div class="node-footer">
                    <span class="node-owner" v-if="node.owner">👤 {{ node.owner }}</span>
                    <span class="node-priority" v-if="node.priority" :class="node.priority">{{ node.priority }}</span>
                    <span class="node-task" v-if="node.taskId">🔗 {{ node.taskId }}</span>
                  </div>
                  <div class="node-summary" v-if="node.summary">
                    <strong>摘要:</strong> {{ node.summary }}
                  </div>
                  <div class="node-summary" v-if="node.output">
                    <strong>产出:</strong> {{ node.output }}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </section>
      
      <section class="goal-detail-panel empty-panel" v-else>
        <div class="empty-state-large">
          <div class="empty-icon">🎯</div>
          <h2>OKR 目标看板</h2>
          <p>请从左侧列表选择一个目标查看详细拆解树与进度</p>
        </div>
      </section>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import AppNav from '@/components/AppNav.vue'

const goals = ref([])
const selectedGoalId = ref(null)
const selectedGoal = ref(null)
const selectedGoalProgress = ref(null)

const statusMap = {
  'active': '运转中',
  'in_progress': '进行中',
  'pending': '待开始',
  'done': '已完成',
  'failed': '失败',
  'blocked': '已阻塞'
}

function getDisplayStatus(status) {
  return statusMap[status] || status
}

function getLevelName(level) {
  const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task'];
  return names[Math.min(level - 1, 4)];
}

async function loadGoals() {
  try {
    const res = await fetch('/api/okr')
    if (res.ok) {
      goals.value = await res.json()
      // 默认选中第一个
      if (goals.value.length > 0 && !selectedGoalId.value) {
        selectGoal(goals.value[0].id)
      }
    }
  } catch (err) {
    console.error('Failed to load goals:', err)
  }
}

async function selectGoal(id) {
  selectedGoalId.value = id
  try {
    const [goalRes, progRes] = await Promise.all([
      fetch(`/api/okr/${id}`),
      fetch(`/api/okr/${id}/progress`)
    ])
    if (goalRes.ok) selectedGoal.value = await goalRes.json()
    if (progRes.ok) selectedGoalProgress.value = await progRes.json()
  } catch (err) {
    console.error('Failed to load goal detail:', err)
  }
}

// 将扁平的nodes转为按树结构排序的列表,便于直接循环渲染并计算缩进
const sortedNodes = computed(() => {
  if (!selectedGoal.value || !selectedGoal.value.nodes) return []
  const nodes = selectedGoal.value.nodes
  
  // 给定一个父节点,递归获取所有后代,按ID自然排序确保层级
  const result = []
  const keys = Object.keys(nodes).sort((a, b) => {
    // 采用自然排序法,如 G-1.2 排在 G-1.10 前面
    const partsA = a.replace('G-','').split('.').map(Number)
    const partsB = b.replace('G-','').split('.').map(Number)
    for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
      const numA = partsA[i] || 0
      const numB = partsB[i] || 0
      if (numA !== numB) return numA - numB
    }
    return 0
  })

  for (const id of keys) {
    result.push({
      id,
      ...nodes[id],
      level: id.split('.').length
    })
  }
  return result
})

onMounted(() => {
  loadGoals()
  // 简单轮询刷新进度
  setInterval(() => {
    if (selectedGoalId.value) {
      selectGoal(selectedGoalId.value)
    } else {
      loadGoals()
    }
  }, 10000)
})
</script>

<style scoped>
.okr-dashboard-page {
  height: 100vh; display: flex; flex-direction: column;
  background: var(--bg-primary);
  color: var(--text-primary); font-family: 'Inter', -apple-system, sans-serif;
}
.okr-layout { flex: 1; display: flex; overflow: hidden; }

.col-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);
}
.count-badge {
  background: var(--bg-surface-active); color: var(--accent);
  padding: 2px 8px; border-radius: 12px; font-size: 11px;
}

/* ═══ Left Sidebar: Goals ═══ */
.goal-sidebar {
  width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary);
  background: var(--bg-overlay); display: flex; flex-direction: column;
}
.goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.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);
}
.goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); }
.goal-card.active {
  background: var(--accent-bg); border-color: var(--border-active);
  box-shadow: 0 0 0 1px var(--accent) inset;
}

.goal-card-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
.goal-id { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); }
.goal-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; }
.goal-status.active { background: rgba(59,130,246,0.15); color: #60a5fa; }
.goal-status.done { background: rgba(16,185,129,0.15); color: #34d399; }

.goal-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; }

.goal-progress-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.progress-bar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; }
.progress-text { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; }

.goal-meta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); }

.empty-state { text-align: center; padding: 40px 20px; color: var(--text-faint); font-size: 12px; }

/* ═══ Right Panel: Details ═══ */
.goal-detail-panel {
  flex: 1; overflow-y: auto; background: var(--bg-primary);
  display: flex; flex-direction: column;
}
.goal-detail-panel.empty-panel { align-items: center; justify-content: center; background: var(--bg-overlay); }

.empty-state-large { text-align: center; color: var(--text-muted); }
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.8; }
.empty-state-large h2 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); }

.goal-hero {
  padding: 30px 40px; border-bottom: 1px solid var(--border-primary);
  background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%);
}
.hero-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); }
.hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; }
.hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
.hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }

.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); }
.hero-title { font-size: 28px; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.5px; }
.hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; }

.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); }
.large-progress-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; }
.large-progress-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
.large-progress-value { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; }
.large-progress-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; }
.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); }
.large-progress-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); }
.stat-done { color: #34d399; }
.stat-inprogress { color: #60a5fa; }
.stat-pending { color: var(--text-muted); }

/* ═══ Nodes Tree ═══ */
.nodes-area { padding: 30px 40px; max-width: 1000px; }
.nodes-header h3 { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); }

.tree-container { display: flex; flex-direction: column; gap: 6px; }
.tree-node { display: flex; flex-direction: column; }
.node-line { display: flex; width: 100%; }
.node-prefix { position: relative; }
/* 连线占位 */
.node-prefix::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;
}

.node-card {
  flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary);
  border-radius: 8px; padding: 12px 16px; position: relative;
  transition: all 0.2s;
}
.node-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); }

/* Level Styles */
.node-card.level-style-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); }
.node-card.level-style-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); }
.node-card.level-style-4 { background: var(--bg-surface); }

.node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.node-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); }
.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; }

.node-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; }
.node-status.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); }
.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; }
.node-status.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); }

@keyframes pulse-light {
  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); }
}

.node-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; }

.node-footer { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 6px; }
.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; }
.node-priority { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.node-priority.P0 { color: #ef4444; }
.node-priority.P1 { color: #f59e0b; }
.node-priority.P2 { color: var(--text-muted); }
.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; }

.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; }
.node-summary strong { color: var(--text-secondary); }

</style>