瀏覽代碼

feat(frontend): MyOkrView 表格化重构(工具栏 + O/KR 表格 + 侧边抽屉)

wangkangyjy 2 周之前
父節點
當前提交
07b1a05a66
共有 1 個文件被更改,包括 216 次插入261 次删除
  1. 216 261
      frontend/src/views/okr/MyOkrView.vue

+ 216 - 261
frontend/src/views/okr/MyOkrView.vue

@@ -1,193 +1,175 @@
 <template>
   <div class="app-page okr-workbench">
-    <div class="page-hero">
-      <div class="hero-info">
-        <h2>我的 OKR</h2>
-        <p>本周期目标与关键结果</p>
-      </div>
+    <!-- v2 工具栏顶栏(替代旧版标题区)-->
+    <div class="data-toolbar">
+      <span class="data-toolbar__title">我的 OKR</span>
+      <template v-if="period">
+        <span class="data-toolbar__sep">|</span>
+        <span class="data-toolbar__meta">{{ period.name }}</span>
+        <span class="status-label">
+          <span class="status-label__dot" :class="periodStatusDotClass"></span>
+          {{ periodStatusLabel }}
+        </span>
+        <span class="data-toolbar__spacer"></span>
+        <span class="data-toolbar__meta" v-if="daysLeft !== null">距周期结束 {{ daysLeft }} 天</span>
+        <el-button v-if="period?.status === 'OKR_ALIGN' && myOkr" size="small" @click="startEdit">编辑 OKR</el-button>
+        <el-button v-else-if="period?.status === 'OKR_ALIGN' && !myOkr && canSubmit" type="primary" size="small" @click="openCreateForm">制定 OKR</el-button>
+        <el-button v-else-if="period?.status === 'ASSESSING'" type="warning" size="small" @click="$router.push('/scores/my')">去完成自评</el-button>
+      </template>
     </div>
 
     <PeriodBanner v-if="periods.length" :periods="periods" v-model="selectedPeriodId" />
-    <div v-if="!selectedPeriodId" class="empty-big">暂无进行中的考核周期</div>
+    <div v-if="!selectedPeriodId" class="okr-empty">暂无进行中的考核周期</div>
 
     <template v-else>
-      <div class="state-banner" :class="'banner-'+period?.status">
-        <span class="banner-icon">{{ stateIcon }}</span>
-        <span class="banner-text">{{ stateBanner }}</span>
-      </div>
-
-      <!-- ByteDance rules + Position template -->
-      <div class="rules-card okr-standard-panel med-panel">
-        <div class="rules-title">OKR 规则 · {{ auth.user?.position || '通用岗位' }}</div>
-        <div class="rules-grid">
-          <div class="rule-item"><b>O</b>:定性方向,15-30字,不含数字指标</div>
-          <div class="rule-item"><b>KR</b>:2~5条,可量化结果,权重之和=100</div>
-          <div class="rule-item"><b>对齐</b>:下级O必须承接上级O方向</div>
-          <div class="rule-item"><b>打分</b>:逐条 KR 0-1 分,以交付结果为依据</div>
+      <!-- 我的 OKR 表格视图(OKR_ALIGN 有 myOkr / EXECUTING)-->
+      <div v-if="(period?.status === 'OKR_ALIGN' || period?.status === 'EXECUTING') && myOkr" class="data-table">
+        <div class="data-table__header" style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;">
+          <span></span>
+          <span>关键结果</span>
+          <span>权重</span>
+          <span>进度</span>
+          <span>当前/目标</span>
+          <span>状态</span>
         </div>
-        <div v-if="positionTemplate" class="position-ref">
-          <div class="pr-title">你的岗位指标参考({{ positionTemplate.name }})</div>
-          <div class="pr-items">
-            <el-tag v-for="(item, i) in parseKpiItems(positionTemplate.itemsJson)" :key="i"
-              size="small" effect="plain" type="info" round>
-              {{ item.name }}(满分{{ item.maxScore }})
-            </el-tag>
-          </div>
-          <div class="pr-hint">OKR 目标应与这些 KPI 指标方向一致</div>
+        <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id"
+             class="data-table__row data-table__row--clickable"
+             style="grid-template-columns: 36px 1fr 56px 72px 120px 80px;"
+             @click="openKrDrawer(kr)">
+          <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
+          <span style="color: var(--color-text);">
+            {{ kr.title }}
+            <span v-if="kr.sourceKrId" class="kr-source-tag">承接上级</span>
+            <span v-if="kr.assignedToName" class="kr-source-tag">指派给 {{ kr.assignedToName }}</span>
+          </span>
+          <span>{{ kr.weight }}</span>
+          <span style="color: var(--color-brand); font-weight: 600;">{{ krPercent(kr) }}%</span>
+          <span style="color: var(--color-text-secondary);">{{ kr.currentValue }}/{{ kr.targetValue }}</span>
+          <span class="status-label" style="font-size: 11px;">
+            <span class="status-dot" :class="krStatusDotClass(kr)"></span>
+            {{ krStatusLabel(kr.status) }}
+          </span>
         </div>
       </div>
 
-      <!-- OKR_ALIGN Phase -->
-      <template v-if="period?.status === 'OKR_ALIGN'">
-        <div v-if="myOkr" class="my-okr-section">
-          <div class="section-head">
-            <span class="section-badge personal">我的 OKR</span>
-            <span class="section-title">{{ myOkr.title }}</span>
-            <el-tag :type="myOkr.status === 'APPROVED' ? 'success' : myOkr.status === 'SUBMITTED' ? 'warning' : 'danger'"
-              size="large" effect="dark" round>
-              {{ myOkr.status === 'APPROVED' ? '已通过' : myOkr.status === 'SUBMITTED' ? '待审核' : '已驳回' }}
-            </el-tag>
-            <el-button v-if="period?.status === 'OKR_ALIGN'" size="small" type="primary" plain @click="startEdit" round>
-              编辑 OKR
-            </el-button>
-          </div>
-          <div v-if="myOkr.alignedObjectives?.length" class="aligned-hint">
-            已对齐上级:<el-tag v-for="a in myOkr.alignedObjectives" :key="a.id" size="small" effect="plain">{{ a.userName }}: {{ truncate(a.title, 35) }}</el-tag>
-          </div>
-          <div class="okr-card" :class="'card-'+myOkr.status">
-            <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id" class="kr-display">
-              <div class="kr-display-num">KR{{ i+1 }}</div>
-              <div class="kr-display-text">{{ kr.title }}</div>
-              <div class="kr-display-meta">
-                <span class="kr-target">目标 {{ kr.targetValue }}</span>
-                <el-tag v-if="kr.sourceKrId" size="small" effect="plain" type="info" round>承接上级</el-tag>
-                <el-tag v-if="kr.assignedToName" size="small" effect="plain" type="warning" round>指派给 {{ kr.assignedToName }}</el-tag>
-              </div>
-            </div>
-          </div>
-          <div v-if="myOkr.status === 'REJECTED'" class="rejected-hint">
-            你的 OKR 已被驳回,请修改后重新提交。
-            <el-button type="primary" size="small" @click="startEdit" round style="margin-left:12px">修改并重新提交</el-button>
-          </div>
-        </div>
+      <!-- OKR 被驳回提示 -->
+      <div v-if="period?.status === 'OKR_ALIGN' && myOkr?.status === 'REJECTED'" class="state-banner banner-REJECTED">
+        你的 OKR 已被驳回,请修改后重新提交。
+        <el-button type="primary" size="small" @click="startEdit" style="margin-left:12px">修改并重新提交</el-button>
+      </div>
 
+      <!-- OKR_ALIGN 空态:无 myOkr -->
+      <template v-if="period?.status === 'OKR_ALIGN' && !myOkr">
+        <div v-if="!canSubmit" class="state-banner banner-waiting">
+          等待上级先制定 OKR · {{ superiorName }} 完成并审核通过后,你可对齐并提交。
+        </div>
         <template v-else>
-          <div v-if="!canSubmit" class="hint-card waiting">
-            <div class="hint-icon"><el-icon :size="28"><Clock /></el-icon></div>
-            <div class="hint-content">
-              <div class="hint-title">等待上级先制定 OKR</div>
-              <div class="hint-desc">{{ superiorName }} 需要先完成 OKR 制定并通过审核,然后你才能对齐并提交自己的 OKR。</div>
-            </div>
-          </div>
-
-          <template v-else>
-            <div v-if="superiorOkrs.length" class="section">
-              <div class="section-head">
-                <span class="section-badge superior">上级</span>
-                <span class="section-title">{{ superiorName }} 的 OKR</span>
-                <span class="section-hint">你的 O 必须承接上级 O 的核心方向</span>
+          <!-- 上级 OKR 表格化(对齐参考)-->
+          <div v-if="superiorOkrs.length" style="margin-top: 20px;">
+            <div class="section-band">上级 OKR · 对齐参考</div>
+            <div class="data-table">
+              <div class="data-table__header" style="grid-template-columns: 36px 1fr 120px;">
+                <span></span>
+                <span>关键结果</span>
+                <span>指派</span>
               </div>
-              <div class="okr-grid">
-                <div v-for="o in superiorOkrs" :key="o.id" class="okr-card superior-card">
-                  <div class="okr-card-head">
-                    <span class="o-tag superior">O</span>
-                    <span class="o-title">{{ o.title }}</span>
-                  </div>
-                  <div v-for="(kr, i) in o.keyResults" :key="kr.id"
-                    class="mini-kr" :class="{ 'mine': kr.assignedTo === auth.user?.id }">
-                    <span class="mini-kr-num">KR{{ i+1 }}</span>
-                    <span class="mini-kr-text">{{ kr.title }}</span>
-                    <el-tag v-if="kr.assignedTo === auth.user?.id" size="small" type="warning" effect="plain" round>指派给我</el-tag>
-                    <span class="mini-val">{{ kr.targetValue }}</span>
-                  </div>
+              <template v-for="o in superiorOkrs" :key="o.id">
+                <div v-for="(kr, i) in o.keyResults" :key="kr.id"
+                     class="data-table__row"
+                     style="grid-template-columns: 36px 1fr 120px;">
+                  <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
+                  <span style="color: var(--color-text);">{{ kr.title }}</span>
+                  <span>
+                    <span v-if="kr.assignedTo === auth.user?.id" class="kr-source-tag kr-source-tag--mine">指派给我</span>
+                  </span>
                 </div>
-              </div>
-            </div>
-            <div v-else class="hint-card top-level">
-              <div class="hint-icon">
-                <el-icon :size="28"><Aim /></el-icon>
-              </div>
-              <div class="hint-content">
-                <div class="hint-title">你是最高层级,制定公司级 OKR</div>
-                <div class="hint-desc">你的目标将决定下属的 OKR 方向。1个O + 3条KR,团队成员将基于此对齐。</div>
-              </div>
-            </div>
-            <div class="action-row">
-              <el-button type="primary" size="large" @click="openCreateForm" round>制定我的 OKR</el-button>
+              </template>
             </div>
-          </template>
+          </div>
+          <div v-else class="state-banner banner-top-level">
+            你是最高层级 · 制定公司级 OKR(1个O + 3条KR,团队将基于此对齐)
+          </div>
         </template>
       </template>
 
-      <!-- EXECUTING Phase -->
-      <template v-if="period?.status === 'EXECUTING' && myOkr">
-        <div class="section">
-          <div class="section-head">
-            <span class="section-badge personal">我的 OKR</span>
-            <span class="section-title">{{ myOkr.title }}</span>
-            <el-tag type="success" size="large" effect="dark" round>已锁定</el-tag>
+      <!-- EXECUTING 锁定提示 -->
+      <div v-if="period?.status === 'EXECUTING' && myOkr" class="state-banner banner-EXECUTING" style="margin-top: 12px;">
+        OKR 已锁定 · 仅可更新进度和标记阻塞,点击 KR 行在抽屉中更新。
+      </div>
+
+      <!-- 待审核 OKR 表格化 -->
+      <div v-if="pendingReviews.length" style="margin-top: 20px;">
+        <div class="section-band">待审核 OKR · {{ pendingReviews.length }} 条</div>
+        <div class="data-table">
+          <div class="data-table__header" style="grid-template-columns: 36px 1fr 100px 120px;">
+            <span></span>
+            <span>关键结果</span>
+            <span>提交人</span>
+            <span>操作</span>
           </div>
-          <div class="rule-hint">OKR 已锁定,仅可更新进度和标记阻塞,不可修改目标与关键结果。</div>
-          <div class="okr-card card-APPROVED" style="margin-top:12px">
-            <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id" class="kr-progress-row">
-              <div class="kr-progress-head">
-                <span class="kr-progress-num">KR{{ i+1 }}</span>
-                <span class="kr-progress-text">{{ kr.title }}</span>
-                <span class="kr-progress-val">{{ kr.currentValue }}/{{ kr.targetValue }}</span>
-              </div>
-              <el-progress :percentage="krPercent(kr)" :color="krColor(kr.status)" :stroke-width="8" />
-              <div class="kr-progress-ctrl">
-                <span class="kr-status-tag">{{ krStatusLabel(kr.status) }}</span>
-                <el-input-number v-model="kr._newValue" :min="0" :max="kr.targetValue*2" size="small" controls-position="right" style="width:100px" />
-                <el-select v-model="kr._newStatus" size="small" style="width:100px">
-                  <el-option label="进行中" value="IN_PROGRESS" /><el-option label="已完成" value="COMPLETED" />
-                  <el-option label="阻塞" value="BLOCKED" /><el-option label="未开始" value="NOT_STARTED" />
-                </el-select>
-                <el-button size="small" type="primary" @click="updateProgress(kr)" round :disabled="kr.status === 'COMPLETED'">更新</el-button>
-              </div>
-              <div v-if="kr.status === 'BLOCKED'" class="blocked-hint">
-                阻塞项,请说明原因与所需支持。上级已收到提醒。
-              </div>
+          <template v-for="o in pendingReviews" :key="o.id">
+            <div v-for="(kr, i) in o.keyResults" :key="kr.id"
+                 class="data-table__row"
+                 style="grid-template-columns: 36px 1fr 100px 120px;">
+              <span style="color: var(--color-accent); font-weight: 700;">KR{{ i+1 }}</span>
+              <span style="color: var(--color-text);">{{ kr.title }}</span>
+              <span v-if="i === 0" :rowspan="o.keyResults.length" style="color: var(--color-text-secondary);">{{ o.userName }}</span>
+              <span v-else></span>
+              <span v-if="i === 0">
+                <el-button size="small" type="success" @click="doReview(o.id, true)">通过</el-button>
+                <el-button size="small" type="danger" @click="doReview(o.id, false)">驳回</el-button>
+              </span>
             </div>
-          </div>
+          </template>
         </div>
-      </template>
-
-      <div v-if="period?.status === 'ASSESSING'" class="action-row">
-        <el-button type="warning" size="large" @click="$router.push('/scores/my')" round>去完成绩效自评</el-button>
       </div>
+    </template>
 
-      <!-- Pending Reviews -->
-      <div v-if="pendingReviews.length" class="section">
-        <div class="section-head">
-          <span class="section-badge review">{{ pendingReviews.length }}</span>
-          <span class="section-title">待审核 OKR</span>
-          <span class="section-hint">请优先校验方向对齐度,其次校验 KR 合理性</span>
+    <!-- 侧边抽屉:KR 详情 / 进度更新 -->
+    <el-drawer v-model="drawerVisible" title="关键结果详情" size="420px" direction="rtl">
+      <div v-if="drawerKr" class="side-drawer__content">
+        <div class="uppercase-label">关键结果</div>
+        <div style="margin: 4px 0 16px; font-weight: 600; color: var(--color-text);">{{ drawerKr.title }}</div>
+
+        <div class="metric-bar">
+          <div class="metric-bar__head">
+            <span class="metric-bar__label">当前进度</span>
+            <span class="metric-bar__value">{{ drawerKr.currentValue }} / {{ drawerKr.targetValue }}
+              <span class="metric-bar__value--muted">({{ krPercent(drawerKr) }}%)</span>
+            </span>
+          </div>
+          <div class="metric-bar__track">
+            <div class="metric-bar__fill" :style="{ width: krPercent(drawerKr) + '%', background: 'var(--color-accent)' }"></div>
+          </div>
         </div>
-        <div class="okr-grid">
-          <div v-for="o in pendingReviews" :key="o.id" class="okr-card review-card">
-            <div class="okr-card-head">
-              <span class="o-tag personal">O</span>
-              <span class="o-title">{{ o.title }}</span>
-              <el-tag effect="plain" type="warning" round>{{ o.userName }}</el-tag>
-            </div>
-            <div v-if="o.alignedObjectives?.length" class="aligned-hint">
-              对齐:<el-tag v-for="a in o.alignedObjectives" :key="a.id" size="small" effect="plain">{{ a.userName }}: {{ truncate(a.title, 25) }}</el-tag>
-            </div>
-            <div v-for="(kr, i) in o.keyResults" :key="kr.id" class="review-kr">
-              <span class="review-kr-num">KR{{ i+1 }}</span>
-              <span class="review-kr-text">{{ kr.title }}</span>
-              <span class="review-kr-val">{{ kr.targetValue }}</span>
-              <el-tag v-if="kr.assignedToName" size="small" effect="dark" type="warning" round>@{{ kr.assignedToName }}</el-tag>
-            </div>
-            <div class="review-actions">
-              <el-button size="small" type="success" @click="doReview(o.id, true)" round>通过</el-button>
-              <el-button size="small" type="danger" @click="doReview(o.id, false)" round>驳回</el-button>
-            </div>
+
+        <!-- 进度更新(仅 EXECUTING 阶段)-->
+        <template v-if="period?.status === 'EXECUTING' && drawerKr.status !== 'COMPLETED'">
+          <div class="uppercase-label" style="margin-top: 20px;">更新进度</div>
+          <div style="font-size: 11px; color: var(--color-text-muted); margin: 4px 0 8px;">
+            当前值:{{ drawerKr.currentValue }}
+          </div>
+          <el-input-number v-model="drawerKr._newValue" :min="0" :max="drawerKr.targetValue*2" style="width: 100%;"
+                           :placeholder="'新值(当前 ' + drawerKr.currentValue + ')'" />
+          <div class="uppercase-label" style="margin-top: 16px;">状态</div>
+          <el-select v-model="drawerKr._newStatus" style="width: 100%; margin-top: 4px;">
+            <el-option label="进行中" value="IN_PROGRESS" />
+            <el-option label="阻塞" value="BLOCKED" />
+            <el-option label="已完成" value="COMPLETED" />
+            <el-option label="未开始" value="NOT_STARTED" />
+          </el-select>
+          <el-button type="primary" style="width: 100%; margin-top: 16px;"
+                     :disabled="drawerKr._newValue == null" @click="submitDrawerProgress">保存进度</el-button>
+          <div v-if="drawerKr.status === 'BLOCKED'" style="margin-top: 12px; font-size: 11px; color: var(--color-warning);">
+            阻塞项,请说明原因与所需支持。上级已收到提醒。
           </div>
+        </template>
+        <div v-else-if="drawerKr.status === 'COMPLETED'" class="uppercase-label"
+             style="margin-top: 20px; color: var(--color-success);">
+          该 KR 已完成
         </div>
       </div>
-    </template>
+    </el-drawer>
 
     <!-- Create OKR Dialog -->
     <el-dialog v-model="showForm" :title="editingOkr ? '编辑 OKR' : '制定个人 OKR'" width="720px" top="4vh" :close-on-click-modal="false">
@@ -202,7 +184,7 @@
         <div class="dkr-title">你的岗位指标参考({{ positionTemplate.name }})</div>
         <div class="dkr-tags">
           <el-tag v-for="(item, i) in parseKpiItems(positionTemplate.itemsJson)" :key="i"
-            size="small" type="info" effect="plain" round>
+            size="small" type="info" effect="plain">
             {{ item.name }}({{ item.maxScore }}分)
           </el-tag>
         </div>
@@ -217,7 +199,7 @@
             <span class="align-head-title">对齐上级 OKR</span>
             <span class="align-head-hint">· 勾选你要承接的关键结果,将自动创建引用条目</span>
           </div>
-          <el-tag v-if="mandatoryKrCount" type="warning" effect="dark" round size="small">
+          <el-tag v-if="mandatoryKrCount" type="warning" effect="plain" size="small">
             {{ mandatoryKrCount }} 项指派给我
           </el-tag>
         </div>
@@ -244,7 +226,7 @@
               </div>
               <div class="align-kr-right">
                 <span class="align-kr-target">目标 {{ kr.targetValue }}</span>
-                <el-tag v-if="kr.assignedTo === auth.user?.id" type="warning" effect="dark" size="small" round>
+                <el-tag v-if="kr.assignedTo === auth.user?.id" type="warning" effect="plain" size="small">
                   指派给我
                 </el-tag>
                 <span v-else-if="isKrAligned(kr.id)" class="align-check">已选</span>
@@ -307,7 +289,7 @@
             </el-select>
           </div>
         </div>
-        <el-button v-if="newOkr.krs.length < 5" @click="newOkr.krs.push({ title: '', targetValue: 100, weight: 0, assignedTo: null })" size="small" round style="margin-top:8px">
+        <el-button v-if="newOkr.krs.length < 5" @click="newOkr.krs.push({ title: '', targetValue: 100, weight: 0, assignedTo: null })" size="small" style="margin-top:8px">
           新增 KR
         </el-button>
       </div>
@@ -318,7 +300,7 @@
 
       <template #footer>
         <el-button @click="showForm = false">取消</el-button>
-        <el-button type="primary" size="large" @click="handleSubmit" :loading="submitting" round>
+        <el-button type="primary" size="large" @click="handleSubmit" :loading="submitting">
           {{ editingOkr ? '保存修改' : '提交审核' }}
         </el-button>
       </template>
@@ -515,15 +497,6 @@ function initAlignment() {
     }
   }
 }
-const stateIcon = computed(() => ({ DRAFT: '', OKR_ALIGN: '', EXECUTING: '', ASSESSING: '', ARCHIVED: '' }[period.value?.status] || ''))
-const stateBanner = computed(() => ({
-  DRAFT: '本周期尚未发布。',
-  OKR_ALIGN: 'OKR 对齐阶段 — 自上而下:上级先制定,下级对齐后制定。1O+3KR,不可偏离上级方向。',
-  EXECUTING: '执行阶段 — OKR 已锁定。请每周更新 KR 进度,标记阻塞时说明原因。',
-  ASSESSING: '考评阶段 — 前往"我的绩效"完成自评,基于实际交付结果打分。',
-  ARCHIVED: '本周期已归档。'
-}[period.value?.status] || ''))
-
 onMounted(async () => {
   periods.value = await periodApi.list()
   // Respect periodId from query param first, then default to first active
@@ -652,104 +625,86 @@ async function updateProgress(kr) {
   ElMessage.success('进度已更新'); load()
 }
 
+// v2 侧边抽屉:KR 详情 / 进度更新
+const drawerVisible = ref(false)
+const drawerKr = ref(null)
+
+function openKrDrawer(kr) {
+  // 复制一份并初始化 updateProgress 机制所需的临时字段
+  drawerKr.value = { ...kr, _newValue: kr.currentValue, _newStatus: kr.status }
+  drawerVisible.value = true
+}
+
+async function submitDrawerProgress() {
+  if (!drawerKr.value) return
+  await updateProgress(drawerKr.value)
+  drawerVisible.value = false
+}
+
+// v2 工具栏状态显示
+const periodStatusLabel = computed(() => {
+  const map = { DRAFT: '草稿', OKR_ALIGN: '对齐中', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
+  return map[period.value?.status] || '—'
+})
+const periodStatusDotClass = computed(() => {
+  const map = { EXECUTING: 'status-label__dot--active', OKR_ALIGN: 'status-label__dot--warn', ASSESSING: 'status-label__dot--warn', ARCHIVED: 'status-label__dot--info' }
+  return map[period.value?.status] || ''
+})
+const daysLeft = computed(() => {
+  if (!period.value?.endDate) return null
+  const diff = Math.ceil((new Date(period.value.endDate) - new Date()) / 86400000)
+  return diff > 0 ? diff : 0
+})
+function krStatusDotClass(kr) {
+  if (kr.status === 'BLOCKED') return 'status-dot--warn'
+  if (kr.status === 'COMPLETED') return 'status-dot--active'
+  return 'status-dot--info'
+}
+
 async function doReview(id, approved) {
   await okrApi.review(id, { approved, remark: '' })
   ElMessage.success(approved ? '已通过' : '已驳回'); load()
 }
 
 function krPercent(kr) { if (!kr.targetValue || kr.targetValue === 0) return 0; return Math.min((kr.currentValue / kr.targetValue) * 100, 100) }
-function krColor(s) { return { COMPLETED: 'var(--color-success)', BLOCKED: 'var(--color-warning)', IN_PROGRESS: 'var(--color-accent)', NOT_STARTED: 'var(--color-text-muted)' }[s] || 'var(--color-text-muted)' }
 function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进行中', COMPLETED: '已完成', BLOCKED: '阻塞' }[s] || s }
-function truncate(s, n) { if (!s) return ''; return s.length > n ? s.slice(0, n) + '...' : s }
 </script>
 
 <style scoped>
 .page { width: 100%; }
-.page-hero { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
-.hero-info h2 { font-size: var(--font-size-2xl); font-weight: 700; color: var(--color-text); margin: 0; }
-.hero-info p { font-size: var(--font-size-base); color: var(--color-text-secondary); margin: 4px 0 0; }
-.empty-big { display: flex; align-items: center; justify-content: center; height: 300px; font-size: var(--font-size-lg); color: var(--color-text-muted); }
 
-.state-banner { padding: 14px 20px; border-radius: var(--radius-md); margin-bottom: 16px; display: flex; align-items: center; gap: 10px; font-size: var(--font-size-base); }
-.banner-OKR_ALIGN { background: var(--color-surface-tint-info); color: var(--color-medical); border: 1px solid var(--color-accent-soft); }
+/* v2 状态标签色点变体 */
+.status-label__dot--active { background: var(--color-success); }
+.status-label__dot--warn { background: var(--color-warning); }
+.status-label__dot--info { background: var(--color-text-muted); }
+.status-dot--active { background: var(--color-success); }
+.status-dot--warn { background: var(--color-warning); }
+.status-dot--info { background: var(--color-text-muted); }
+
+.okr-empty {
+  display: flex; align-items: center; justify-content: center;
+  height: 240px; color: var(--color-text-muted); font-size: var(--font-size-base);
+}
+
+/* 状态提示横幅(v2,驳回/等待/锁定提示)*/
+.state-banner {
+  padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 12px;
+  display: flex; align-items: center; gap: 10px; font-size: var(--font-size-sm);
+}
 .banner-EXECUTING { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
-.banner-ASSESSING { background: var(--color-surface-tint-warning); color: var(--color-warning); border: 1px solid var(--color-border-soft); }
-
-.rules-card { background: var(--color-surface); border-radius: var(--radius-md); padding: 14px 20px; margin-bottom: 20px; box-shadow: var(--shadow-panel); border: 1px solid var(--color-border-soft); }
-.rules-title { font-weight: 700; color: var(--color-brand); font-size: var(--font-size-base); margin-bottom: 8px; }
-.rules-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
-.rule-item { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
-
-.position-ref { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border-soft); }
-.pr-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--color-brand); margin-bottom: 6px; }
-.pr-items { display: flex; flex-wrap: wrap; gap: 6px; }
-.pr-hint { font-size: 11px; color: var(--color-text-muted); margin-top: 6px; }
-
-.section { margin-bottom: 36px; }
-.section-head { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--color-border-soft); }
-.section-badge { padding: 4px 12px; border-radius: var(--radius-sm); font-size: var(--font-size-sm); font-weight: 700; color: var(--color-brand); letter-spacing: 0; background: var(--color-surface-tint-brand); border-left: 2px solid var(--color-brand); }
-.section-badge.personal { color: var(--color-medical); background: var(--color-surface-tint-info); border-left-color: var(--color-accent); }
-.section-badge.superior { color: var(--color-brand); background: var(--color-surface-tint-brand); border-left-color: var(--color-brand); }
-.section-badge.review { color: var(--color-warning); background: var(--color-surface-tint-warning); border-left-color: var(--color-warning); }
-.section-title { font-size: var(--font-size-xl); font-weight: 700; color: var(--color-text); }
-.section-hint { font-size: var(--font-size-sm); color: var(--color-text-muted); }
-
-.hint-card { display: flex; align-items: flex-start; gap: 20px; background: var(--color-surface); border-radius: var(--radius-md); padding: 28px 32px; box-shadow: var(--shadow-panel); margin-bottom: 24px; }
-.hint-card.waiting { border: 1px solid var(--color-border-soft); background: var(--color-surface-tint-warning); }
-.hint-card.top-level { border: 1px solid var(--color-border-soft); background: var(--color-surface-tint-success); }
-.hint-icon { font-size: 36px; }
-.hint-title { font-size: var(--font-size-lg); font-weight: 700; color: var(--color-text); margin-bottom: 4px; }
-.hint-desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); line-height: 1.6; }
-.action-row { margin-bottom: 24px; }
-
-.rule-hint { font-size: var(--font-size-xs); color: var(--color-warning); background: var(--color-surface-tint-warning); padding: 8px 14px; border-radius: var(--radius-sm); margin-bottom: 8px; }
-
-.okr-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
-.okr-card { background: var(--color-surface); border-radius: var(--radius-md); padding: 20px 24px; box-shadow: var(--shadow-panel); border: 1px solid var(--color-border-soft); }
-.okr-card.superior-card { border-left: 4px solid var(--color-brand); }
-.okr-card.card-APPROVED { border-left: 4px solid var(--color-success); }
-.okr-card.card-SUBMITTED { border-left: 4px solid var(--color-warning); }
-.okr-card.card-REJECTED { border-left: 4px solid var(--color-danger); background: var(--color-surface-tint-danger); }
-.okr-card.review-card { border-left: 4px solid var(--color-warning); }
-.okr-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
-.o-tag { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); font-size: var(--font-size-base); font-weight: 700; color: var(--color-white); }
-.o-tag.superior { background: var(--color-brand); }
-.o-tag.personal { background: var(--color-accent); }
-.o-title { font-size: 15px; font-weight: 600; color: var(--color-text); flex: 1; }
-
-.aligned-hint { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; font-size: var(--font-size-xs); color: var(--color-text-muted); flex-wrap: wrap; }
-
-.mini-kr { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-sm); color: var(--color-text-secondary); padding: 4px 6px; border-radius: var(--radius-sm); }
-.mini-kr.mine { background: var(--color-surface-tint-warning); border: 1px solid var(--color-border-soft); }
-.mini-kr-num { font-size: 11px; font-weight: 700; color: var(--color-brand); width: 32px; }
-.mini-kr-text { flex: 1; }
-.mini-val { color: var(--color-text-muted); }
-
-
-.kr-display { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--color-border-soft); }
-.kr-display:first-child { border-top: none; }
-.kr-display-num { font-size: var(--font-size-xs); font-weight: 700; color: var(--color-accent); width: 36px; }
-.kr-display-text { flex: 1; font-size: var(--font-size-base); color: var(--color-text); }
-.kr-display-meta { display: flex; align-items: center; gap: 8px; }
-.kr-target { font-size: var(--font-size-sm); color: var(--color-text-muted); }
-
-.kr-progress-row { padding: 16px 0; border-top: 1px solid var(--color-border-soft); }
-.kr-progress-row:first-child { border-top: none; }
-.kr-progress-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
-.kr-progress-num { font-size: var(--font-size-xs); font-weight: 700; color: var(--color-accent); width: 36px; }
-.kr-progress-text { flex: 1; font-size: var(--font-size-base); color: var(--color-text); }
-.kr-progress-val { font-size: var(--font-size-sm); color: var(--color-text-muted); }
-.kr-progress-ctrl { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
-.kr-status-tag { font-size: 11px; color: var(--color-text-secondary); width: 60px; }
-.blocked-hint { font-size: var(--font-size-xs); color: var(--color-warning); margin-top: 6px; }
-
-.review-kr { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; }
-.review-kr-num { font-size: var(--font-size-xs); font-weight: 700; color: var(--color-warning); width: 32px; }
-.review-kr-text { flex: 1; color: var(--color-text-secondary); }
-.review-kr-val { color: var(--color-text-muted); }
-.review-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border-soft); }
-
-.rejected-hint { padding: 14px; background: var(--color-surface-tint-danger); border-radius: var(--radius-md); font-size: var(--font-size-sm); color: var(--color-danger); margin-top: 12px; }
+.banner-REJECTED { background: var(--color-surface-tint-danger); color: var(--color-danger); border: 1px solid var(--color-border-soft); }
+.banner-waiting { background: var(--color-surface-tint-warning); color: var(--color-warning); border: 1px solid var(--color-border-soft); }
+.banner-top-level { background: var(--color-surface-tint-success); color: var(--color-success); border: 1px solid var(--color-border-soft); }
+
+/* KR 来源标签(承接上级/指派)*/
+.kr-source-tag {
+  display: inline-block; margin-left: 6px; padding: 1px 6px;
+  background: var(--color-surface-tint-info); color: var(--color-medical);
+  border-radius: 3px; font-size: 10px; font-weight: 500;
+}
+.kr-source-tag--mine {
+  background: var(--color-surface-tint-warning); color: var(--color-warning);
+}
 
 /* Dialog */
 .dialog-rules { background: var(--color-surface-tint-info); border-radius: var(--radius-md); padding: 14px 18px; margin-bottom: 16px; }