Просмотр исходного кода

feat: add OKR alignment tree view and parent OKR selector

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wangkangyjy 2 месяцев назад
Родитель
Сommit
6413132a7c

+ 5 - 1
frontend/src/api/index.js

@@ -87,7 +87,11 @@ export const okrApi = {
   getSubordinates: periodId => api.get(`/okr/period/${periodId}/subordinates`).then(r => r.data),
   getPendingReviews: periodId => api.get(`/okr/period/${periodId}/pending-reviews`).then(r => r.data),
   getAllOkrs: periodId => api.get(`/okr/period/${periodId}/all`).then(r => r.data),
-  getDetail: id => api.get(`/okr/${id}`).then(r => r.data)
+  getDetail: id => api.get(`/okr/${id}`).then(r => r.data),
+  getTree: periodId => api.get(`/okr/period/${periodId}/tree`).then(r => r.data),
+  getOrphans: periodId => api.get(`/okr/period/${periodId}/orphans`).then(r => r.data),
+  setAlignment: (id, parentObjectiveId) => api.post(`/okr/${id}/align`, { parentObjectiveId }),
+  getChildren: id => api.get(`/okr/${id}/children`).then(r => r.data)
 }
 
 // KPI API

+ 4 - 0
frontend/src/components/MainLayout.vue

@@ -32,6 +32,10 @@
             <el-icon><UserFilled /></el-icon>
             <span>团队 OKR</span>
           </el-menu-item>
+          <el-menu-item index="/okr/alignment">
+            <el-icon><Connection /></el-icon>
+            <span>OKR 对齐</span>
+          </el-menu-item>
           <el-menu-item index="/scores/my">
             <el-icon><DataAnalysis /></el-icon>
             <span>我的绩效</span>

+ 1 - 0
frontend/src/router/index.js

@@ -15,6 +15,7 @@ const routes = [
       { path: 'periods', name: 'Periods', component: () => import('../views/period/PeriodListView.vue'), meta: { title: '考核周期' } },
       { path: 'okr/my', name: 'MyOkr', component: () => import('../views/okr/MyOkrView.vue'), meta: { title: '我的 OKR' }, props: route => ({ periodId: route.query.periodId ? Number(route.query.periodId) : null }) },
       { path: 'okr/team', name: 'TeamOkr', component: () => import('../views/okr/TeamOkrView.vue'), meta: { title: '团队 OKR' } },
+      { path: 'okr/alignment', name: 'OkrAlignment', component: () => import('../views/okr/AlignmentView.vue'), meta: { title: 'OKR 对齐' } },
       { path: 'scores/my', name: 'MyScores', component: () => import('../views/scoring/MyResultView.vue'), meta: { title: '我的绩效' } },
       { path: 'scores/history', name: 'ScoreHistory', component: () => import('../views/scoring/HistoryView.vue'), meta: { title: '历史绩效' } },
       { path: 'scoring', name: 'Scoring', component: () => import('../views/scoring/ScoringListView.vue'), meta: { title: '绩效打分' } },

+ 115 - 0
frontend/src/views/okr/AlignmentView.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="alignment-page">
+    <div class="alignment-header">
+      <h3>OKR 对齐视图</h3>
+      <el-select v-model="selectedPeriodId" placeholder="选择周期" @change="loadTree" style="width:240px">
+        <el-option v-for="p in periods" :key="p.id" :label="p.name" :value="p.id" />
+      </el-select>
+    </div>
+
+    <el-row :gutter="20" v-if="selectedPeriodId">
+      <el-col :span="8">
+        <el-card header="对齐树" class="tree-card">
+          <el-tree
+            :data="treeData" :props="{ children: 'children', label: 'title' }"
+            node-key="id" highlight-current
+            @node-click="onNodeClick"
+          >
+            <template #default="{ data }">
+              <div class="tree-node">
+                <el-tag size="small" :type="levelTag(data.level)">{{ levelLabel(data.level) }}</el-tag>
+                <span class="tree-title">{{ data.title }}</span>
+                <el-progress :percentage="data.progress || 0" :stroke-width="4" style="width:60px" />
+              </div>
+            </template>
+          </el-tree>
+          <div v-if="orphans.length > 0" style="margin-top:12px">
+            <el-divider />
+            <el-tag type="warning">孤立目标 (未对齐)</el-tag>
+            <div v-for="o in orphans" :key="o.id" class="orphan-item">
+              {{ o.userName }} - {{ o.title }}
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="16">
+        <el-card v-if="selectedNode">
+          <template #header>{{ selectedNode.title }}</template>
+          <div class="detail-meta">
+            <el-tag>{{ levelLabel(selectedNode.level) }}</el-tag>
+            <span>负责人: {{ selectedNode.userName }}</span>
+            <span>进度: {{ selectedNode.progress || 0 }}%</span>
+          </div>
+          <el-table :data="selectedNode.keyResults || []" style="margin-top:16px">
+            <el-table-column prop="title" label="KR" />
+            <el-table-column label="进度" width="160">
+              <template #default="{ row }">
+                <el-progress :percentage="krProgress(row)" :stroke-width="6" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="weight" label="权重" width="60" />
+            <el-table-column label="状态" width="90">
+              <template #default="{ row }">
+                <el-tag size="small">{{ row.status }}</el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+        <el-empty v-else description="请选择左侧 OKR 节点查看详情" />
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { okrApi, periodApi } from '../../api'
+
+const periods = ref([])
+const selectedPeriodId = ref(null)
+const treeData = ref([])
+const orphans = ref([])
+const selectedNode = ref(null)
+
+function levelLabel(l) {
+  const m = { COMPANY: '公司', DEPARTMENT: '部门', TEAM: '团队', INDIVIDUAL: '个人' }
+  return m[l] || l
+}
+function levelTag(l) {
+  return l === 'COMPANY' ? '' : l === 'DEPARTMENT' ? 'warning' : l === 'TEAM' ? 'success' : 'info'
+}
+function krProgress(kr) {
+  return kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0
+}
+
+async function loadTree() {
+  const [tree, orphanList] = await Promise.all([
+    okrApi.getTree(selectedPeriodId.value),
+    okrApi.getOrphans(selectedPeriodId.value)
+  ])
+  treeData.value = tree
+  orphans.value = orphanList
+}
+
+function onNodeClick(node) {
+  selectedNode.value = node
+}
+
+onMounted(async () => {
+  periods.value = await periodApi.list()
+  if (periods.value.length > 0) {
+    selectedPeriodId.value = periods.value[0].id
+    await loadTree()
+  }
+})
+</script>
+
+<style scoped>
+.alignment-page { max-width: 1200px; margin: 0 auto; }
+.alignment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.tree-card { max-height: calc(100vh - 200px); overflow-y: auto; }
+.tree-node { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+.tree-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.orphan-item { padding: 4px 0; font-size: 12px; color: #999; }
+.detail-meta { display: flex; gap: 12px; align-items: center; font-size: 13px; color: #666; }
+</style>

+ 33 - 2
frontend/src/views/okr/MyOkrView.vue

@@ -260,6 +260,20 @@
         <el-input v-model="newOkr.title" :placeholder="currentHints.oHint" size="large" maxlength="35" show-word-limit />
       </div>
 
+      <!-- Parent objective alignment -->
+      <div class="parent-align-section">
+        <div class="parent-align-label">对齐目标</div>
+        <el-tree-select
+          v-model="newOkr.parentObjectiveId"
+          :data="alignmentOptions"
+          :props="{ children: 'children', label: 'title', value: 'id' }"
+          placeholder="选择对齐的上级目标(可选)"
+          clearable
+          check-strictly
+          style="width:100%"
+        />
+      </div>
+
       <!-- Dynamic KRs with source reference -->
       <div class="kr-section">
         <div class="kr-section-title">
@@ -332,6 +346,16 @@ const showForm = ref(false)
 const editingOkr = ref(false)
 const submitting = ref(false)
 
+const alignmentOptions = ref([])
+
+async function loadAlignmentOptions() {
+  try {
+    alignmentOptions.value = await okrApi.getTree(selectedPeriodId.value)
+  } catch {
+    alignmentOptions.value = []
+  }
+}
+
 // Position-specific OKR hints
 const okrHints = {
   'CEO / 总经理': {
@@ -403,7 +427,8 @@ const newOkr = reactive({
     { title: '', targetValue: 100, weight: 33, assignedTo: null },
     { title: '', targetValue: 100, weight: 33, assignedTo: null }
   ],
-  alignedIds: []
+  alignedIds: [],
+  parentObjectiveId: null
 })
 
 const weightSum = computed(() => newOkr.krs.reduce((s, k) => s + (k.weight || 0), 0))
@@ -530,6 +555,7 @@ async function load() {
   try { myOkr.value = await okrApi.getMyOkr(pid) } catch { myOkr.value = null }
   try { const r = await okrApi.canSubmit(pid); canSubmit.value = r.canSubmit } catch { canSubmit.value = false }
   try { pendingReviews.value = await okrApi.getPendingReviews(pid) } catch { pendingReviews.value = [] }
+  loadAlignmentOptions()
   if (myOkr.value) {
     myOkr.value.keyResults?.forEach(kr => { kr._newValue = kr.currentValue; kr._newStatus = kr.status })
   }
@@ -593,7 +619,8 @@ async function handleSubmit() {
       periodId: selectedPeriodId.value,
       title: newOkr.title,
       keyResults: filledKrs.map(k => ({ title: k.title, targetValue: k.targetValue, weight: k.weight, assignedTo: k.assignedTo, sourceKrId: k._sourceKrId || null })),
-      alignedObjectiveIds: newOkr.alignedIds
+      alignedObjectiveIds: newOkr.alignedIds,
+      parentObjectiveId: newOkr.parentObjectiveId || null
     }
     if (editingOkr.value && myOkr.value) {
       await okrApi.update(myOkr.value.id, payload)
@@ -615,6 +642,7 @@ function resetForm() {
     { title: '', targetValue: 100, weight: 33, assignedTo: null }
   ]
   newOkr.alignedIds = []
+  newOkr.parentObjectiveId = null
 }
 
 async function updateProgress(kr) {
@@ -756,6 +784,9 @@ function truncate(s, n) { if (!s) return ''; return s.length > n ? s.slice(0, n)
 .align-footer { padding: 12px 22px; background: #f9fafb; border-top: 1px solid #f3f4f6; font-size: 13px; color: #6b7280; text-align: center; }
 .align-footer strong { color: #2b1f99; }
 
+.parent-align-section { margin-bottom: 20px; }
+.parent-align-label { font-weight: 600; font-size: 15px; color: #1a1a2e; margin-bottom: 8px; }
+
 .o-section { margin-bottom: 20px; }
 .o-label { font-weight: 600; font-size: 15px; color: #1a1a2e; margin-bottom: 8px; }
 .o-hint { font-size: 12px; color: #aaa; font-weight: 400; }