2026-06-20-medtech-ui-revamp-v2.md 60 KB

前端去 AI 味改造 v2 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 在 v1 已清理的 token 基础上,重构 5 个样板页(MainLayout / LoginView / MyOkrView / ScoringEditView / MyResultView)的信息架构,消除营销式 hero/胶囊/引导卡/圆环,达成 D 风格(AI 科研外壳 + 临床数据内核)。

架构: theme.css 新增数据表格/工具栏/状态色点等增量 token(只增不改);components.css 新增 .data-toolbar/.data-table/.compare-grid-3/.section-band/.metric-bar/.side-drawer/.status-dot/.underline-input 通用类;5 个样板页逐页重写 <template>(视觉 token 复用,template 结构重构);verify 脚本同步更新断言。所有改动限定在前端,不动后端/API/数据库。

技术栈: Vue 3 + Element Plus + Vite,CSS 变量 token 系统,scripts/verify-medtech-theme.mjs + scripts/verify-scoring-contract.mjs 守卫。

对应规格: docs/superpowers/specs/2026-06-20-medtech-ui-revamp-v2-design.md

口径基线(v1 已确认,本次不改): OKR 满分 60 + 绩效满分 40 = 100。


文件结构

创建

  • 无新文件(所有改造在现有文件内进行)

修改

  • frontend/src/styles/theme.css — 新增 v2 增量 token(第 1.2 节),不改动现有 token
  • frontend/src/styles/components.css — 新增 8 个通用组件类(第 1.3 节)
  • frontend/src/components/MainLayout.vue — 侧边栏去 emoji + 加深底 + 细化选中
  • frontend/src/views/auth/LoginView.vue — 全屏分栏 + 下划线输入
  • frontend/src/views/okr/MyOkrView.vue — 工具栏 + 表格化 O/KR + 侧边抽屉(改动最大)
  • frontend/src/views/scoring/ScoringEditView.vue — 三列对照(证据·自评·终评)+ 底部总览栏
  • frontend/src/views/scoring/MyResultView.vue — 删圆环,数据卡 + 构成条 + 历史趋势
  • frontend/scripts/verify-medtech-theme.mjs — 更新断言(移除旧类检查,新增 v2 类检查 + 禁用模式)

不改动

  • frontend/scripts/verify-scoring-contract.mjs(口径不变)
  • 后端任何文件
  • 其余 ~10 个页面(本次不处理)

任务 1:theme.css 增量 token

文件:

  • 修改:frontend/src/styles/theme.css(在 :root 块末尾追加,不改现有 token)

  • [ ] 步骤 1:在 theme.css 的 :root { } 末尾追加 v2 token

打开 frontend/src/styles/theme.css,找到 :root { 块,在其闭合 } 之前追加:

  /* ===== v2 增量 token(信息架构重构,只增不改)===== */

  /* 数据表格系统 */
  --table-row-height: 40px;
  --table-row-height-compact: 32px;
  --table-header-bg: var(--color-surface-subtle, #f8fafc);
  --table-row-hover: var(--color-surface-muted, #f3f6f9);
  --table-row-group-bg: var(--color-surface-tint-brand, rgba(43, 31, 153, 0.03));
  --table-border: var(--color-border-soft, #edf2f6);

  /* 工具栏顶栏(替代 page-hero)*/
  --toolbar-height: 48px;
  --toolbar-bg: var(--color-surface-subtle, #f8fafc);
  --toolbar-border: var(--color-border, #dfe7ee);

  /* 状态色点(替代 effect="dark" round tag)*/
  --dot-size: 6px;

  /* 区段色带标题 */
  --section-band-bg: var(--color-surface-tint-info, rgba(58, 212, 216, 0.08));
  --section-band-color: var(--color-medical, #0d7a5f);
  --section-band-font-size: 11px;
  --section-band-letter-spacing: 0.3px;

  /* 大写小字标签(表头/字段名)*/
  --label-uppercase: uppercase;
  --label-letter-spacing: 0.5px;
  --label-font-size: 10px;

  /* 侧边栏 v2 加深 */
  --color-sidebar-bg: #1d166b;

  /* 登录页品牌区渐变 */
  --color-login-brand-from: #1d166b;
  --color-login-brand-to: #120d4a;
  • 步骤 2:运行 verify 脚本确认未破坏现有断言

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:PASS(现有 token 全部保留,新 token 不影响断言)。若 FAIL,检查是否误删了现有 token。

  • [ ] 步骤 3:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/styles/theme.css
    git commit -m "feat(frontend): 新增 v2 信息架构重构 token(表格/工具栏/状态点/区段带)"
    

任务 2:components.css 新增通用组件类

文件:

  • 修改:frontend/src/styles/components.css(文件末尾追加 8 个类,不改现有类)

  • [ ] 步骤 1:在 components.css 末尾追加 v2 通用类

    /* ===== v2 信息架构通用组件 ===== */
    
    /* 工具栏顶栏(替代 page-hero)*/
    .data-toolbar {
    display: flex;
    align-items: center;
    gap: 12px;
    height: var(--toolbar-height);
    padding: 0 16px;
    background: var(--toolbar-bg);
    border-bottom: 1px solid var(--toolbar-border);
    font-size: var(--font-size-base, 14px);
    }
    .data-toolbar__title {
    font-size: 13px;
    font-weight: 600;
    color: var(--color-text, #18212f);
    letter-spacing: 0.2px;
    }
    .data-toolbar__sep {
    color: var(--toolbar-border);
    }
    .data-toolbar__meta {
    color: var(--color-text-secondary, #5f6b7a);
    font-size: 12px;
    }
    .data-toolbar__spacer { margin-left: auto; }
    
    /* 状态色点(替代彩色 tag)*/
    .status-dot {
    display: inline-block;
    width: var(--dot-size);
    height: var(--dot-size);
    border-radius: 50%;
    vertical-align: middle;
    }
    .status-dot--active { background: var(--color-grade-success, #2e9d65); }
    .status-dot--warn { background: var(--color-grade-warn, #c98219); }
    .status-dot--danger { background: var(--color-grade-danger, #c94a4a); }
    .status-dot--info { background: var(--color-text-muted, #c0c6cf); }
    
    /* 状态标签(色点 + 文字)*/
    .status-label {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 11px;
    font-weight: 500;
    }
    .status-label__dot {
    width: var(--dot-size);
    height: var(--dot-size);
    border-radius: 50%;
    }
    
    /* 大写小字标签 */
    .uppercase-label {
    text-transform: var(--label-uppercase);
    letter-spacing: var(--label-letter-spacing);
    font-size: var(--label-font-size);
    font-weight: 600;
    color: var(--color-text-muted, #8a97a8);
    }
    
    /* 区段色带标题 */
    .section-band {
    padding: 7px 14px;
    background: var(--section-band-bg);
    color: var(--section-band-color);
    font-size: var(--section-band-font-size);
    font-weight: 700;
    letter-spacing: var(--section-band-letter-spacing);
    border-bottom: 1px solid var(--table-border);
    }
    
    /* 表格化数据容器 */
    .data-table {
    width: 100%;
    font-size: 12px;
    background: var(--color-surface, #fff);
    }
    .data-table__header {
    display: grid;
    padding: 7px 16px;
    background: var(--table-header-bg);
    border-bottom: 1px solid var(--toolbar-border);
    text-transform: var(--label-uppercase);
    letter-spacing: var(--label-letter-spacing);
    font-size: var(--label-font-size);
    font-weight: 600;
    color: var(--color-text-muted, #8a97a8);
    }
    .data-table__row {
    display: grid;
    align-items: center;
    padding: 7px 16px;
    border-bottom: 1px solid var(--table-border);
    min-height: var(--table-row-height-compact);
    }
    .data-table__row--group {
    background: var(--table-row-group-bg);
    font-weight: 600;
    min-height: var(--table-row-height);
    }
    .data-table__row--child {
    padding-left: 44px;
    color: var(--color-text-secondary, #5f6b7a);
    }
    .data-table__row--child::before {
    content: '└';
    position: absolute;
    margin-left: -20px;
    color: var(--color-text-muted, #c0c6cf);
    }
    .data-table__row:hover {
    background: var(--table-row-hover);
    }
    .data-table__row--clickable {
    cursor: pointer;
    }
    
    /* 三列对照网格(评分页)*/
    .compare-grid-3 {
    display: grid;
    grid-template-columns: 1.4fr 70px 1fr;
    align-items: center;
    padding: 7px 14px;
    border-bottom: 1px solid var(--table-border);
    min-height: var(--table-row-height-compact);
    }
    .compare-grid-3__self {
    text-align: center;
    font-weight: 700;
    font-size: 13px;
    }
    .compare-grid-3__header {
    display: grid;
    grid-template-columns: 1.4fr 70px 1fr;
    padding: 6px 14px;
    background: var(--table-header-bg);
    border-bottom: 1px solid var(--toolbar-border);
    text-transform: var(--label-uppercase);
    letter-spacing: var(--label-letter-spacing);
    font-size: var(--label-font-size);
    font-weight: 600;
    color: var(--color-text-muted, #8a97a8);
    }
    
    /* 水平数据条(构成展示)*/
    .metric-bar {
    margin-bottom: 10px;
    }
    .metric-bar__head {
    display: flex;
    justify-content: space-between;
    margin-bottom: 4px;
    font-size: 12px;
    }
    .metric-bar__label { color: var(--color-text, #18212f); font-weight: 500; }
    .metric-bar__value { color: var(--color-text, #18212f); font-weight: 600; }
    .metric-bar__value--muted { color: var(--color-text-muted, #8a97a8); font-weight: 400; }
    .metric-bar__track {
    height: 5px;
    background: var(--table-border, #f3f6f9);
    border-radius: 3px;
    overflow: hidden;
    }
    .metric-bar__fill {
    height: 100%;
    border-radius: 3px;
    }
    
    /* 侧边抽屉容器(KR 详情/进度更新)*/
    .side-drawer__content {
    padding: 0;
    }
    
    /* 底部固定总览栏(评分页)*/
    .review-summary-bar {
    position: sticky;
    bottom: 0;
    display: flex;
    align-items: center;
    gap: 16px;
    padding: 10px 14px;
    background: var(--toolbar-bg);
    border-top: 2px solid var(--color-brand, #2b1f99);
    z-index: 10;
    }
    .review-summary-bar__score {
    font-size: 20px;
    font-weight: 700;
    color: var(--color-brand-dark, #1d166b);
    }
    
    /* 下划线式输入框(登录页,覆盖 Element Plus)*/
    .underline-input :deep(.el-input__wrapper) {
    background: transparent;
    box-shadow: none;
    border-radius: 0;
    border-bottom: 1px solid var(--toolbar-border);
    padding: 4px 0;
    }
    .underline-input :deep(.el-input__wrapper.is-focus),
    .underline-input :deep(.el-input__wrapper:hover) {
    border-bottom-color: var(--color-accent, #3ad4d8);
    box-shadow: none;
    }
    .underline-input :deep(.el-input__inner) {
    border: none;
    }
    
    /* 历史趋势 mini bar chart */
    .history-trend {
    display: flex;
    align-items: flex-end;
    gap: 16px;
    height: 48px;
    }
    .history-trend__item {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    }
    .history-trend__bar {
    width: 24px;
    border-radius: 2px;
    background: var(--color-text-muted, #c0c6cf);
    }
    .history-trend__bar--current {
    background: var(--color-brand, #2b1f99);
    }
    
  • [ ] 步骤 2:运行 verify 脚本确认未破坏断言

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:PASS

  • [ ] 步骤 3:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/styles/components.css
    git commit -m "feat(frontend): 新增 v2 通用组件类(data-toolbar/data-table/compare-grid-3 等)"
    

任务 3:MainLayout 侧边栏改造

文件:

  • 修改:frontend/src/components/MainLayout.vue(侧边栏 template + style)
  • 修改:frontend/scripts/verify-medtech-theme.mjs:75--color-sidebar-bg token 检查,可选)

  • [ ] 步骤 1:侧边栏背景色 token 切换

frontend/src/components/MainLayout.vue<style scoped> 中,找到侧边栏背景色定义(当前硬编码 #2b1f99var(--color-brand)),改为:

.clinical-sidebar {
  background: var(--color-sidebar-bg);
}
  • 步骤 2:菜单项去 emoji + 细化选中态

<template> 中,找到侧边栏菜单的 <el-menu-item> 列表。删除每个 item 文本中的 emoji(如 📅 考核周期考核周期),改为在文字前加几何图标 span。示例(考核周期):

<el-menu-item index="/period">
  <span class="nav-ico">▦</span>
  <template #title>考核周期</template>
</el-menu-item>

几何图标对照表(统一用 Unicode 几何字符,不引入图标库):

  • 考核周期 →
  • 我的 OKR →
  • 团队 OKR →
  • OKR 对齐 →
  • 绩效打分 →
  • 绩效沟通 →
  • 绩效结果 →
  • 组织管理 →
  • 系统日志 →
  • 维度配置 →
  • KPI 模板 →
  • 通知 → (去掉,改

<style scoped> 追加:

.nav-ico {
  display: inline-block;
  width: 18px;
  margin-right: 8px;
  color: rgba(255, 255, 255, 0.4);
  font-size: 14px;
  text-align: center;
}
.el-menu-item.is-active .nav-ico {
  color: var(--color-accent);
}
.clinical-sidebar .el-menu-item.is-active {
  border-left: 2px solid var(--color-accent) !important;
  background: rgba(58, 212, 216, 0.08) !important;
}
.clinical-sidebar .el-menu-item {
  border-left: 2px solid transparent;
}
  • 步骤 3:分组标题改大写小字

找到菜单分组标题(如"考评管理"、"系统管理"对应的 <el-menu-item-group> title 或独立 label),将文字改为大写小字样式。在 <style scoped> 追加:

.clinical-sidebar .el-menu-item-group__title,
.sidebar-group-label {
  text-transform: uppercase;
  letter-spacing: 0.8px;
  font-size: 10px;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.35);
  padding: 10px 16px 4px;
}
  • 步骤 4:品牌区副标题改大写小字

找到侧边栏顶部品牌区,将副标题"OKR 绩效管理"改为大写小字"OKR · 绩效":

<div class="brand-sub">OKR · 绩效</div>
.brand-sub {
  color: rgba(255, 255, 255, 0.45);
  font-size: 9px;
  letter-spacing: 0.8px;
  text-transform: uppercase;
}
  • 步骤 5:顶栏角色 tag 改方角

找到顶栏的 <el-tag> 角色标签,把 round 属性去掉(如有),确认无 round

  • 步骤 6:启动 dev server 人工目测

运行:cd frontend && npm run dev 打开 http://localhost:5173,登录后检查:侧边栏无 emoji、选中态是 2px 左边框、分组标题是大写小字、底色更沉。

  • 步骤 7:运行 verify 脚本

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:PASS(clinical-shell/clinical-sidebar 断言仍在)

  • [ ] 步骤 8:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/components/MainLayout.vue
    git commit -m "feat(frontend): MainLayout 侧边栏去 emoji、细化选中态、加深背景"
    

任务 4:LoginView 全屏分栏改造

文件:

  • 修改:frontend/src/views/auth/LoginView.vue(template 重构为左右分栏 + 下划线输入)

  • [ ] 步骤 1:template 改为全屏左右分栏

<template> 根容器改为全屏 grid 两栏(左深色品牌区 1.2fr + 右白表单 1fr)。保留现有 internal-login 类名(verify 脚本依赖)。结构:

<template>
  <div class="internal-login">
    <!-- 左:深色品牌区 -->
    <div class="login-brand">
      <div class="login-brand__top">
        <div class="brand-logo">医</div>
        <span class="brand-name">医梦AI</span>
      </div>
      <div class="login-brand__main">
        <div class="brand-title">绩效运营工作台</div>
        <div class="brand-desc">
          目标对齐 · 进度可追溯<br>
          结果可解释 · 医疗级数据严谨
        </div>
        <!-- 心电波形装饰,保留 -->
        <svg class="brand-wave" viewBox="0 0 220 24" ...>...</svg>
      </div>
      <!-- 分子节点 SVG,右下角,opacity 0.08 -->
      <svg class="brand-molecule" viewBox="0 0 180 150" ...>...</svg>
    </div>

    <!-- 右:白表单区 -->
    <div class="login-form">
      <div class="uppercase-label">Internal Access</div>
      <h1 class="login-form__title">账号登录</h1>

      <div class="form-field">
        <label class="uppercase-label">用户名</label>
        <el-input v-model="form.username" class="underline-input" placeholder="请输入用户名" />
      </div>
      <div class="form-field">
        <label class="uppercase-label">密码</label>
        <el-input v-model="form.password" type="password" class="underline-input" placeholder="请输入密码" show-password @keyup.enter="handleLogin" />
      </div>

      <el-button type="primary" :loading="loading" class="login-submit" @click="handleLogin">登 录</el-button>

      <div class="form-tip uppercase-label">账号由 HR 管理员创建</div>
    </div>
  </div>
</template>
  • 步骤 2:style 改为全屏分栏 + 下划线输入

替换 <style scoped> 中布局相关样式(保留表单逻辑、.underline-input 由 components.css 全局提供):

.internal-login {
  display: grid;
  grid-template-columns: 1.2fr 1fr;
  min-height: 100vh;
  width: 100%;
  background: #fff;
}

/* 左侧深色品牌区 */
.login-brand {
  position: relative;
  overflow: hidden;
  background: linear-gradient(135deg, var(--color-login-brand-from), var(--color-login-brand-to));
  padding: 28px 32px;
  color: #fff;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.login-brand__top {
  display: flex;
  align-items: center;
  gap: 8px;
  position: relative;
  z-index: 1;
}
.brand-logo {
  width: 28px;
  height: 28px;
  background: var(--color-accent);
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-login-brand-from);
  font-weight: 700;
  font-size: 12px;
}
.brand-name {
  color: #fff;
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0.3px;
}
.login-brand__main {
  position: relative;
  z-index: 1;
}
.brand-title {
  font-size: 18px;
  font-weight: 600;
  color: #fff;
  letter-spacing: 0;
}
.brand-desc {
  color: rgba(255, 255, 255, 0.55);
  font-size: 11px;
  margin-top: 6px;
  line-height: 1.7;
}
.brand-wave {
  margin-top: 12px;
  width: 100px;
  height: 12px;
  opacity: 0.6;
}
.brand-molecule {
  position: absolute;
  right: -10px;
  bottom: -10px;
  width: 140px;
  height: 120px;
  opacity: 0.08;
}

/* 右侧白表单区 */
.login-form {
  padding: 32px 40px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  max-width: 420px;
  margin: 0 auto;
  width: 100%;
}
.login-form__title {
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text, #18212f);
  margin: 6px 0 24px;
  letter-spacing: 0.3px;
}
.form-field {
  margin-bottom: 20px;
}
.form-field .uppercase-label {
  display: block;
  margin-bottom: 4px;
}
.login-submit {
  width: 100%;
  border-radius: 4px;
  letter-spacing: 0.5px;
  font-weight: 500;
}
.form-tip {
  text-align: center;
  margin-top: 16px;
  color: var(--color-text-muted, #8a97a8);
  font-size: 9px;
  letter-spacing: 1px;
}

@media (max-width: 768px) {
  .internal-login { grid-template-columns: 1fr; }
  .login-brand { display: none; }
}
  • 步骤 3:确认无 radial-gradient(verify 脚本禁止)

检查 template/style 中是否还有 radial-gradient,如有则删除。

  • 步骤 4:dev server 目测

运行:cd frontend && npm run dev 打开 http://localhost:5173/login,检查:全屏左右分栏、左侧深色品牌区、右侧下划线输入框、focus 时下边框变青色。

  • 步骤 5:运行 verify 脚本

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:PASS(internal-login 仍在,无 radial-gradient

  • [ ] 步骤 6:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/views/auth/LoginView.vue
    git commit -m "feat(frontend): LoginView 改全屏左右分栏 + 下划线式输入框"
    

任务 5:MyOkrView 表格化改造(最复杂)

文件:

  • 修改:frontend/src/views/okr/MyOkrView.vue(template 重构为 data-toolbar + data-table + 抽屉)

前置说明(关键数据结构): 先读现有代码确认。当前数据结构是扁平的

  • myOkr.keyResults[](KR 直接挂在 myOkr 下,不是 objectives[].keyResults[])
  • 每条 KR 有 id/title/currentValue/targetValue/weight/status 字段(已确认)
  • 上级 OKR 是 superiorOkr.value.objectives[].keyResults[](这个是嵌套的,见第 96 行 o.keyResults
  • 现有方法:krPercent(kr)krColor(kr.status)krStatusLabel(kr.status)updateProgress(kr)(内部用 kr._newValue/kr._newStatus,第 650 行)
  • 保留所有业务逻辑。只重构展示结构。okr-workbench 类名保留(verify 依赖)。

重要:因为我的 OKR 是扁平 KR 列表(无 O 分组),表格化时直接列出 KR 行即可,不需要 O 分组行。若需按 parentObjectiveId 分组,则先 group by 该字段。下面步骤 3 按扁平 KR 列表实现(更简单,符合现有数据)。

  • 步骤 1:新增侧边抽屉状态

<script setup> 的响应式状态区,新增抽屉状态:

import { ref, computed } from 'vue'

// 抽屉:当前选中的 KR(用于详情/进度更新)
const drawerKr = ref(null)
const drawerVisible = ref(false)

function openKrDrawer(kr, objective) {
  drawerKr.value = { ...kr, _objectiveTitle: objective.title }
  drawerVisible.value = true
}
  • 步骤 2:template 顶部改为 data-toolbar

找到当前 page-hero/hero-info/period-banner 区域,替换为:

<div class="okr-workbench">
  <!-- 工具栏顶栏(替代 hero)-->
  <div class="data-toolbar">
    <span class="data-toolbar__title">我的 OKR</span>
    <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="canCreateOkr" type="primary" size="small" @click="openEditDialog">制定 OKR</el-button>
    <el-button v-else-if="canEditOkr" size="small" @click="openEditDialog">编辑 OKR</el-button>
    <el-button v-if="period?.status === 'ASSESSING' && !selfScoreDone" size="small" @click="goSelfScore">去完成自评</el-button>
  </div>

  <!-- 下方表格区 -->
  ...(步骤 3-5 填充)
</div>

<script setup> 补充这些 computed(基于现有 period/myOkr 数据):

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' }
  // 注意:status-label__dot 的颜色需在 style 中按 class 定义
  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
})

status-label__dot 颜色在 components.css 或本页 style:

.status-label__dot--active { background: var(--color-grade-success, #2e9d65); }
.status-label__dot--warn { background: var(--color-grade-warn, #c98219); }
.status-label__dot--info { background: var(--color-text-muted, #c0c6cf); }
  • 步骤 3:表格头 + 我的 OKR 表格行(扁平 KR 列表)

myOkr.keyResults 是扁平列表(已确认),直接列出 KR 行,无需 O 分组。替换原 okr-card 区域:

<div class="data-table" v-if="myOkr?.keyResults?.length">
  <!-- 表头 -->
  <div class="data-table__header" style="grid-template-columns: 28px 1fr 44px 70px 110px 56px;">
    <span></span>
    <span>关键结果</span>
    <span>权重</span>
    <span>进度</span>
    <span>当前/目标</span>
    <span>状态</span>
  </div>

  <!-- KR 行(扁平列表)-->
  <div v-for="(kr, i) in myOkr.keyResults" :key="kr.id"
       class="data-table__row data-table__row--clickable"
       style="grid-template-columns: 28px 1fr 44px 70px 110px 56px;"
       @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>
    <span>{{ kr.weight }}</span>
    <span style="color: var(--color-brand); font-weight: 600;">{{ krPercent(kr) }}%</span>
    <span>{{ kr.currentValue }}/{{ kr.targetValue }}</span>
    <span class="status-label" style="font-size: 11px;">
      <span class="status-dot" :class="krStatusClass(kr)"></span>
      {{ krStatusLabel(kr.status) }}
    </span>
  </div>
</div>

<!-- 空态(无 OKR)-->
<div v-else-if="period?.status === 'OKR_ALIGN'" class="okr-empty">
  <div class="uppercase-label" style="text-align: center; padding: 48px;">
    尚未制定 OKR · 点击右上角「制定 OKR」开始
  </div>
</div>

补充 computed(复用现有 krPercent,仅新增状态色点映射):

function krStatusClass(kr) {
  if (kr.status === 'BLOCKED') return 'status-dot--warn'
  if (kr.status === 'COMPLETED' || krPercent(kr) >= 100) return 'status-dot--active'
  return 'status-dot--info'
}

注意krPercent/krStatusLabel 是现有方法,直接复用,不要重造。

  • 步骤 4:侧边抽屉(KR 详情 + 进度更新)

现有 updateProgress(kr) 方法(第 650 行)内部用 kr._newValue/kr._newStatus/okrApi.updateProgress(kr.id, {newValue, status, remark})。抽屉要对接这个现有机制,不重造。在 template 末尾追加抽屉:

<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;">{{ 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>

    <!-- 进度更新(仅 EXECUTING 阶段,复用现有 updateProgress 机制)-->
    <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-top: 4px; margin-bottom: 8px;">
        当前值:{{ drawerKr.currentValue }}
      </div>
      <el-input-number v-model="drawerKr._newValue" :min="0" 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-select>
      <el-button type="primary" style="width: 100%; margin-top: 16px;"
                 :disabled="drawerKr._newValue == null" @click="submitDrawerProgress">保存进度</el-button>
    </template>
    <div v-else-if="drawerKr.status === 'COMPLETED'" class="uppercase-label"
         style="margin-top: 20px; color: var(--color-grade-success);">
      ✓ 该 KR 已完成
    </div>
  </div>
</el-drawer>

抽屉初始化时设置 _newValue/_newStatus(对接现有方法),补充方法:

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)  // 调用现有方法(内部读 _newValue/_newStatus)
  drawerVisible.value = false
  await loadData()
}

注意updateProgress(kr) 是现有 async 方法(第 650 行),其内部已调用 okrApi.updateProgress(kr.id, {newValue: kr._newValue, status: kr._newStatus, remark: ''}) 并刷新数据。抽屉只负责设置 _newValue/_newStatus 后调用它。先读第 650-660 行确认完整签名与副作用(是否会自动 loadData)。

  • 步骤 5:上级 OKR 与待审核区表格化(OKR_ALIGN 阶段)

找到当前 superior-okr / pending-reviews 展示区,改为同样的 data-table 结构。上级 OKR 的 KR 行加一个「指派给我」小标签:

<div v-if="superiorOkr?.objectives?.length" style="margin-top: 24px;">
  <div class="section-band">上级 OKR · 对齐参考</div>
  <div class="data-table">
    <!-- 同上 data-table__header + 遍历 superiorOkr.objectives -->
    <!-- KR 行若 assignedToMe 则加标签 -->
    <span v-if="kr.assignedToMe" class="assign-tag">指派给我</span>
  </div>
</div>
.assign-tag {
  display: inline-block;
  background: var(--section-band-bg);
  color: var(--section-band-color);
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 10px;
  margin-left: 8px;
}
  • 步骤 6:删除所有 page-hero / section-badge / hint-card / okr-card 样式

<style scoped> 中,删除以下类定义(它们不再被 template 引用):

  • .page-hero / .hero-info / .hero-title / .hero-subtitle
  • .section-badge 及其修饰符(.personal / .superior / .review
  • .section-head
  • .hint-card 及子元素
  • .okr-card / .okr-grid / .okr-item
  • .rules-card

保留 .okr-workbench(verify 依赖)、.okr-empty.assign-tag.side-drawer__content 及上述新样式。

  • 步骤 7:删除 template 中所有 effect="dark" round 的 el-tag

全文搜索 effect="dark"round,移除这些属性。状态展示统一改用 .status-dot

  • 步骤 8:dev server 目测全部状态

运行:cd frontend && npm run dev 切换 period 状态(或用测试账号),检查:

  • OKR_ALIGN 有 myOkr:表格只读 + 工具栏「编辑」按钮
  • OKR_ALIGN 无 myOkr:空态 + 工具栏「制定」按钮 + 上级 OKR 表格
  • EXECUTING:表格 + 点 KR 行打开抽屉可更新进度
  • ASSESSING:表格只读 + 工具栏「去完成自评」

  • [ ] 步骤 9:运行 verify 脚本

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:okr-workbench 断言通过。注意:当前脚本还检查 okr-standard-panel(第 90 行附近),需在任务 7 中同步移除此断言。本任务先确保 okr-workbench 在。

  • [ ] 步骤 10:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/views/okr/MyOkrView.vue
    git commit -m "feat(frontend): MyOkrView 表格化重构(工具栏 + O/KR 表格 + 侧边抽屉)"
    

任务 6:ScoringEditView 三列对照改造

文件:

  • 修改:frontend/src/views/scoring/ScoringEditView.vue(template 重构为 compare-grid-3 + section-band + 底部总览栏)

前置说明(关键数据结构): 先读现有代码确认。当前变量结构:

  • krScores = ref([]),元素是 {title, score}(第 321 行 kr.value.map(kr => ({title: kr.title, score: 0.6})))→ 绑定 krScores[i].score
  • dimScores = ref([]),元素是 {id, name, maxScore, score}(第 326 行)→ 绑定 dimScores[i].score
  • dimensions = ref([])(第 233 行),从 dimensionApi.list() 加载
  • 预览总分 = computedTotal(computed,第 256 行,不是 computedPreviewTotal)
  • 等级 = computedGrade(已有,第 261 行,S/A/B/C)
  • 加减分 = form.bonus / form.penalty(reactive form,第 232 行 const form = reactive({bonus:0, penalty:0, remark:''})
  • 自评数据:selfKrScore(i) / selfDimScore(name) / selfDimDesc(name) 均为现有函数
  • okrRecords = computed(() => data.value.okrRecords || [])(第 236 行)
  • 保留所有评分逻辑。保留 review-workbench 类名(内部 evidence-panel/decision-panel 替换为三列对照)。

  • [ ] 步骤 1:template 顶部改 data-toolbar

替换 page-hero

<div class="review-workbench">
  <div class="data-toolbar">
    <span class="data-toolbar__title">绩效考评</span>
    <span class="data-toolbar__sep">|</span>
    <span class="data-toolbar__meta">{{ data.targetUser?.name }} · {{ data.targetUser?.role }}</span>
    <span class="data-toolbar__sep">|</span>
    <span class="data-toolbar__meta">{{ data.period?.name }}</span>
    <span class="data-toolbar__spacer"></span>
    <el-button v-if="data.finalScore && data.finalScore.status !== 'PUBLISHED'"
               type="primary" size="small" @click="submitFinal">提交终评</el-button>
  </div>
  ...(步骤 2-4 填充)
</div>
  • 步骤 2:三列对照表头 + OKR 区段

替换原 evidence-panel/decision-panel:

<div class="scoring-body">
  <!-- 对照表头 -->
  <div class="compare-grid-3__header">
    <span>证据 · 员工自评</span>
    <span style="text-align: center;">自评分</span>
    <span>你的终评</span>
  </div>

  <!-- OKR 区段 -->
  <div class="section-band">OKR 目标达成 · 满分 60</div>

  <div v-for="(kr, i) in okrRecords[0]?.keyResults || []" :key="'kr'+i"
       class="compare-grid-3">
    <!-- 左:证据 -->
    <div>
      <div style="color: var(--color-text); font-weight: 500;">KR{{ i+1 }} {{ kr.title }}</div>
      <div style="color: var(--color-text-muted); font-size: 11px; margin-top: 2px;">
        实际 {{ kr.currentValue }}/{{ kr.targetValue }} · 完成率
        <span style="color: var(--color-brand); font-weight: 600;">{{ krCompletionPct(kr) }}%</span>
      </div>
    </div>
    <!-- 中:自评分 -->
    <div class="compare-grid-3__self" :style="{ color: selfKrScoreColor(i) }">
      {{ selfKrScore(i) !== null ? selfKrScore(i) : '—' }}
    </div>
    <!-- 右:终评下拉(绑定 krScores[i].score)-->
    <div>
      <el-select v-model="krScores[i].score" size="small" style="width: 140px;" @change="markDirty" placeholder="选择评级">
        <el-option v-for="g in gradeOptions" :key="g.value" :label="g.label" :value="g.value" />
      </el-select>
    </div>
  </div>

  <!-- 绩效区段 -->
  <div class="section-band" style="margin-top: 8px; border-top: 1px solid var(--table-border);">绩效维度 · 满分 40</div>

  <div v-for="(dim, i) in dimensions" :key="'dim'+i" class="compare-grid-3">
    <div>
      <div style="color: var(--color-text); font-weight: 500;">{{ dim.name }}</div>
      <div v-if="selfDimDesc(dim.name)" style="color: var(--color-text-muted); font-size: 11px; margin-top: 2px;">
        {{ selfDimDesc(dim.name) }}
      </div>
    </div>
    <div class="compare-grid-3__self" :style="{ color: selfDimScore(dim.name) !== null ? 'var(--color-text-secondary)' : 'var(--color-text-muted)' }">
      {{ selfDimScore(dim.name) !== null ? selfDimScore(dim.name) : '—' }}
    </div>
    <div>
      <el-input-number v-model="dimScores[i].score" :min="0" :max="dim.maxScore || 10" size="small" style="width: 140px;" @change="markDirty" />
    </div>
  </div>

  <!-- 加减分(绑定 form.bonus / form.penalty)-->
  <div class="compare-grid-3">
    <div style="color: var(--color-text); font-weight: 500;">加分(满分内)</div>
    <div class="compare-grid-3__self" style="color: var(--color-grade-warn, #c98219);">
      +{{ data.selfScore?.bonus || 0 }}
    </div>
    <div>
      <el-input-number v-model="form.bonus" :min="0" :max="10" size="small" style="width: 140px;" @change="markDirty" />
    </div>
  </div>
  <div class="compare-grid-3">
    <div style="color: var(--color-text); font-weight: 500;">扣分</div>
    <div class="compare-grid-3__self" style="color: var(--color-text-muted);">
      -{{ data.selfScore?.penalty || 0 }}
    </div>
    <div>
      <el-input-number v-model="form.penalty" :min="0" :max="10" size="small" style="width: 140px;" @change="markDirty" />
    </div>
  </div>

  ...(步骤 3 底部总览栏)
</div>

补充 computed/method(基于现有数据,确认字段名后再写):

const gradeOptions = [
  { label: '卓越 1.0', value: 1.0 },
  { label: '优秀 0.9', value: 0.9 },
  { label: '良好 0.8', value: 0.8 },
  { label: '达标 0.6', value: 0.6 },
  { label: '不达标 0.3', value: 0.3 },
]
function krCompletionPct(rec) {
  if (!rec.targetValue) return 0
  return Math.min(100, Math.round((rec.currentValue / rec.targetValue) * 100))
}
function selfKrScoreColor(i) {
  const s = selfKrScore(i)
  return s === null ? 'var(--color-text-muted)' : (s >= 0.8 ? 'var(--color-grade-success, #0d7a5f)' : 'var(--color-text-secondary)')
}

注意okrRecordsselfKrScoreselfDimScoreselfDimDesckrScoresdimScoresbonusScore 均为现有变量,先读现有代码确认名称与类型。

  • 步骤 3:底部固定总览栏(含自评差异提示)

scoring-body 末尾追加:

<!-- 底部总览栏 -->
<div class="review-summary-bar">
  <span class="uppercase-label">终评总分</span>
  <span class="review-summary-bar__score">
    {{ computedTotal?.toFixed(1) || '0.0' }}
    <span style="font-size: 11px; color: var(--color-text-muted); font-weight: 400;">/ 100</span>
  </span>
  <span class="status-label">
    <span class="status-dot" :class="previewGradeDot"></span>
    {{ computedGrade }}
  </span>
  <span style="margin-left: auto;" class="uppercase-label" :style="{ color: diffColor }">
    自评 {{ (data.selfScore?.total || 0).toFixed(1) }} · 差异 {{ scoreDiff >= 0 ? '+' : '' }}{{ scoreDiff.toFixed(1) }}
  </span>
  <el-tooltip v-if="Math.abs(scoreDiff) >= 10" content="打分偏差较大,建议复核">
    <span style="color: var(--color-grade-danger, #c94a4a); cursor: help; font-size: 12px;">!</span>
  </el-tooltip>
</div>

补充 computed(computedTotal/computedGrade 复用现有,仅新增差异相关):

const scoreDiff = computed(() => {
  const preview = computedTotal.value || 0
  const selfT = data.value.selfScore?.total || 0
  return Math.round((preview - selfT) * 10) / 10
})
const diffColor = computed(() => {
  return Math.abs(scoreDiff.value) >= 10 ? 'var(--color-grade-danger, #c94a4a)' : 'var(--color-text-muted, #8a97a8)'
})
const previewGradeDot = computed(() => {
  const t = computedTotal.value || 0
  if (t >= 75) return 'status-dot--active'
  if (t >= 60) return 'status-dot--warn'
  return 'status-dot--danger'
})

注意computedTotal(第 256 行)和 computedGrade(第 261 行,返回 S/A/B/C)是现有 computed,直接复用。markDirty 若不存在则新增 const markDirty = () => { /* 触发响应式更新,computed 会自动重算 */ } 或删除 @change="markDirty"(el-select/el-input-number 的 v-model 已触发响应式,computed 自动更新,markDirty 可能多余——先读代码确认是否已有 dirty 状态管理)。

  • 步骤 4:响应式降级(<1280px)

<style scoped> 追加:

@media (max-width: 1279px) {
  .compare-grid-3,
  .compare-grid-3__header {
    grid-template-columns: 1fr;
    gap: 6px;
  }
  .compare-grid-3__self {
    text-align: left;
  }
}
  • 步骤 5:删除旧样式

删除 <style scoped> 中:.page-hero / .evidence-panel / .decision-panel / .okr-record-card / .orr-kr-card / .self-block / .self-total-bar / .result-preview 及相关。保留 .review-workbench.scoring-body 及新样式。

  • 步骤 6:删除 template 中所有 effect="dark" round

全文移除 effect="dark"round

  • 步骤 7:dev server 目测

运行:cd frontend && npm run dev 进入评分页,检查:三列对照、区段色带、底部总览栏(含自评差异)、小屏降级。

  • 步骤 8:运行两个 verify 脚本

运行:cd frontend && node scripts/verify-medtech-theme.mjs && node scripts/verify-scoring-contract.mjs 预期:评分契约 PASS(口径不变);theme 脚本中 review-workbench 通过,但 evidence-panel/decision-panel 断言会 FAIL —— 这是预期的,将在任务 7 移除。

  • [ ] 步骤 9:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/views/scoring/ScoringEditView.vue
    git commit -m "feat(frontend): ScoringEditView 三列对照重构(证据·自评·终评 + 总览栏)"
    

任务 7:MyResultView 删圆环 + 数据视图

文件:

  • 修改:frontend/src/views/scoring/MyResultView.vue(template 重构 result 态,保留 self-form 态不动)

前置说明: 现有数据字段:data.finalScore.total/grade/okrScore/kpiScore/bonus/penalty/confirmedAtdata.selfScore.total。自评表单态(showSelfForm 为 true)保持现状不动

  • 步骤 1:template 顶部改 data-toolbar

替换 page-hero(仅结果态部分):

<div class="result-workbench">
  <div class="data-toolbar">
    <span class="data-toolbar__title">我的绩效</span>
    <span class="data-toolbar__sep">|</span>
    <span class="data-toolbar__meta">{{ data.period?.name }}</span>
    <span class="data-toolbar__spacer"></span>
    <el-button size="small" @click="goHistory">历史记录</el-button>
  </div>
  ...(步骤 2-4 填充)
</div>
  • 步骤 2:result-grid 主结果区(左大数字 + 右构成条)

替换 score-hero(grade-ring 区):

<div v-if="data.finalScore" class="result-grid">
  <!-- 左:分数 + 等级 -->
  <div class="result-grid__main">
    <div class="uppercase-label">本期终评</div>
    <div class="result-score">{{ data.finalScore.total }}</div>
    <div class="result-score-max">/ 100 分</div>
    <div class="result-grade-tag">
      <span class="status-dot" :class="gradeDotClass(data.finalScore.grade)"></span>
      <span class="result-grade-letter">{{ data.finalScore.grade }}</span>
      <span class="result-grade-desc">{{ gradeDesc(data.finalScore.grade) }}</span>
    </div>
    <div class="result-vs-last">
      <div class="uppercase-label">较上期</div>
      <div :style="{ color: vsLastColor }">{{ vsLastText }}</div>
    </div>
  </div>

  <!-- 右:构成条 -->
  <div class="result-grid__breakdown">
    <div class="uppercase-label" style="margin-bottom: 12px;">得分构成</div>

    <div class="metric-bar">
      <div class="metric-bar__head">
        <span class="metric-bar__label">OKR 目标达成</span>
        <span class="metric-bar__value">{{ data.finalScore.okrScore }}<span class="metric-bar__value--muted"> / 60</span></span>
      </div>
      <div class="metric-bar__track"><div class="metric-bar__fill" :style="{ width: (data.finalScore.okrScore/60*100)+'%', background: 'var(--color-brand)' }"></div></div>
    </div>

    <div class="metric-bar">
      <div class="metric-bar__head">
        <span class="metric-bar__label">绩效维度</span>
        <span class="metric-bar__value">{{ data.finalScore.kpiScore }}<span class="metric-bar__value--muted"> / 40</span></span>
      </div>
      <div class="metric-bar__track"><div class="metric-bar__fill" :style="{ width: (data.finalScore.kpiScore/40*100)+'%', background: 'var(--color-accent)' }"></div></div>
    </div>

    <div class="metric-bar">
      <div class="metric-bar__head">
        <span class="metric-bar__label">专项加分</span>
        <span class="metric-bar__value" style="color: var(--color-grade-success);">+{{ data.finalScore.bonus }}</span>
      </div>
      <div class="metric-bar__track"><div class="metric-bar__fill" :style="{ width: Math.min(100, data.finalScore.bonus/5*100)+'%', background: 'var(--color-grade-success)' }"></div></div>
    </div>

    <div class="metric-bar">
      <div class="metric-bar__head">
        <span class="metric-bar__label">专项扣分</span>
        <span class="metric-bar__value" style="color: var(--color-grade-danger);">-{{ data.finalScore.penalty }}</span>
      </div>
      <div class="metric-bar__track"><div class="metric-bar__fill" :style="{ width: Math.min(100, data.finalScore.penalty/5*100)+'%', background: 'var(--color-grade-danger)' }"></div></div>
    </div>
  </div>
</div>

补充 computed/method(历史趋势兜底):

const historyList = ref([])
const vsLastText = computed(() => {
  if (!historyList.value.length) return '首次考评'
  const last = historyList.value[historyList.value.length - 1]
  const diff = (data.value.finalScore?.total || 0) - (last.total || 0)
  if (diff > 0) return `▲ +${diff.toFixed(1)} 分`
  if (diff < 0) return `▼ ${diff.toFixed(1)} 分`
  return '持平'
})
const vsLastColor = computed(() => {
  if (!historyList.value.length) return 'var(--color-text-muted)'
  const last = historyList.value[historyList.value.length - 1]
  const diff = (data.value.finalScore?.total || 0) - (last.total || 0)
  return diff > 0 ? 'var(--color-grade-success)' : (diff < 0 ? 'var(--color-grade-danger)' : 'var(--color-text-muted)')
})
function gradeDotClass(g) {
  const map = { S: 'status-dot--active', A: 'status-dot--active', B: 'status-dot--warn', C: 'status-dot--warn', D: 'status-dot--danger' }
  return map[g] || 'status-dot--info'
}
  • 步骤 3:自评 vs 终评对照行

在 result-grid 下方(保留现有 compare-row 逻辑,仅调整样式类):

<div v-if="data.selfScore && data.finalScore" class="result-compare">
  <div class="uppercase-label" style="margin-bottom: 8px;">自评 vs 终评</div>
  <div class="result-compare-grid">
    <div>
      <div class="uppercase-label">OKR</div>
      <div><span style="color: var(--color-text-secondary);">自评 {{ data.selfScore.okrScore?.toFixed(1) }}</span> → <span style="color: var(--color-brand); font-weight: 600;">终评 {{ data.finalScore.okrScore?.toFixed(1) }}</span></div>
    </div>
    <div>
      <div class="uppercase-label">绩效</div>
      <div><span style="color: var(--color-text-secondary);">自评 {{ data.selfScore.kpiScore?.toFixed(1) }}</span> → <span style="color: var(--color-brand); font-weight: 600;">终评 {{ data.finalScore.kpiScore?.toFixed(1) }}</span></div>
    </div>
    <div>
      <div class="uppercase-label">总分</div>
      <div><span style="color: var(--color-text-secondary);">自评 {{ data.selfScore.total?.toFixed(1) }}</span> → <span style="color: var(--color-brand); font-weight: 600;">终评 {{ data.finalScore.total?.toFixed(1) }}</span></div>
    </div>
  </div>
</div>

注意data.selfScore 是否含 okrScore/kpiScore 拆分字段需先读现有代码确认;若无拆分则只显示总分对照。

  • [ ] 步骤 4:操作栏(扁平化,删 emoji)+ 历史趋势

    <div class="result-actions">
    <el-button v-if="data.finalScore && !data.finalScore.confirmedAt"
             class="result-action--primary" @click="confirmResult">确认结果</el-button>
    <el-button v-else-if="data.finalScore?.confirmedAt" disabled>已确认</el-button>
    <el-button @click="goAppeal">提起申诉</el-button>
    <el-button @click="goFeedback">查看面谈记录</el-button>
    </div>
    
    <!-- 历史趋势 -->
    <div class="result-trend">
    <div class="uppercase-label" style="margin-bottom: 12px;">历史趋势({{ Math.max(1, historyList.length) + 1 }} 期)</div>
    <div v-if="historyList.length" class="history-trend">
    <div v-for="(h, i) in historyList" :key="i" class="history-trend__item">
      <div class="history-trend__bar" :style="{ height: barHeight(h.total) + 'px' }"></div>
      <span class="uppercase-label">{{ h.periodName }}</span>
      <span style="font-size: 10px; color: var(--color-text-secondary);">{{ h.total?.toFixed(1) }}</span>
    </div>
    <!-- 本期高亮 -->
    <div class="history-trend__item">
      <div class="history-trend__bar history-trend__bar--current" :style="{ height: barHeight(data.finalScore?.total) + 'px' }"></div>
      <span class="uppercase-label" style="color: var(--color-brand); font-weight: 600;">{{ data.period?.name }}</span>
      <span style="font-size: 10px; color: var(--color-brand); font-weight: 600;">{{ data.finalScore?.total?.toFixed(1) }}</span>
    </div>
    </div>
    <div v-else class="uppercase-label" style="padding: 24px; text-align: center;">暂无历史成绩</div>
    </div>
    

补充方法:

function barHeight(score) {
  if (!score) return 10
  return Math.max(10, Math.min(48, (score / 100) * 48))
}
  • 步骤 5:历史数据兜底加载(前端轮询,无新后端接口)

loadDataonMounted 中追加(方案一:轮询最近 4 个归档周期):

async function loadHistory() {
  try {
    const periods = await periodApi.list({ status: 'ARCHIVED', pageSize: 4 })
    const archived = periods.data?.records || periods.records || []
    const results = []
    for (const p of archived) {
      try {
        const r = await scoreApi.getMyResult(p.id)
        if (r.data?.finalScore?.total != null) {
          results.push({ periodName: p.name, total: r.data.finalScore.total })
        }
      } catch { /* 跳过无数据的周期 */ }
    }
    historyList.value = results
  } catch {
    historyList.value = []  // 方案二:失败则隐藏趋势
  }
}

注意periodApi.list 的参数与返回结构需先读 frontend/src/api/index.js 确认;scoreApi.getMyResult 已确认存在。若 periodApi.list 不支持 status 过滤,则取全部后在前端过滤 ARCHIVED。

  • 步骤 6:删除旧样式

删除 <style scoped> 中:.page-hero / .score-hero / .grade-ring / .grade-letter / .grade-ring.grade-* / .score-breakdown / .compare-row(旧版)。保留 .result-workbench(verify 依赖)及新样式。追加新样式:

.result-grid {
  display: grid;
  grid-template-columns: 200px 1fr;
  border-bottom: 1px solid var(--toolbar-border);
}
.result-grid__main {
  padding: 20px 18px;
  border-right: 1px solid var(--table-border);
  background: var(--table-header-bg);
}
.result-score {
  font-size: 40px;
  font-weight: 700;
  color: var(--color-text, #18212f);
  line-height: 1;
  margin-top: 6px;
}
.result-score-max { color: var(--color-text-muted); font-size: 11px; margin-top: 2px; }
.result-grade-tag {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: 12px;
  padding: 4px 10px;
  background: var(--section-band-bg);
  border-radius: 4px;
}
.result-grade-letter { color: var(--section-band-color); font-weight: 700; font-size: 13px; }
.result-grade-desc { color: var(--section-band-color); font-size: 11px; }
.result-vs-last { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--table-border); }
.result-vs-last div:last-child { font-weight: 600; font-size: 13px; margin-top: 2px; }
.result-grid__breakdown { padding: 16px 18px; }
.result-compare {
  padding: 12px 18px;
  border-bottom: 1px solid var(--table-border);
  background: var(--table-header-bg);
}
.result-compare-grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 12px;
  font-size: 11px;
}
.result-actions {
  display: flex;
  gap: 8px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--toolbar-border);
}
.result-action--primary {
  background: var(--table-row-group-bg);
  border-color: var(--table-border);
  font-weight: 500;
}
.result-trend { padding: 12px 18px; }
  • 步骤 7:dev server 目测

运行:cd frontend && npm run dev 进入我的绩效页,检查:无圆环、左大数字 + 右构成条、自评vs终评对照、历史趋势、扁平操作按钮。

  • 步骤 8:运行 verify 脚本

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:result-workbench 通过。grade-ring/score-hero 断言需在任务 8 移除(若当前脚本有检查的话,先确认)。

  • [ ] 步骤 9:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/src/views/scoring/MyResultView.vue
    git commit -m "feat(frontend): MyResultView 删等级圆环,改数据卡 + 构成条 + 历史趋势"
    

任务 8:更新 verify-medtech-theme.mjs 断言

文件:

  • 修改:frontend/scripts/verify-medtech-theme.mjs(移除旧类断言,新增 v2 类断言 + 禁用模式)

  • [ ] 步骤 1:移除已废弃的类断言

在脚本中,找到以下断言行并删除(v2 已用新结构替代):

// 删除这些行:
if (!myOkr.includes('okr-standard-panel')) failures.push('...')  // 第 90 行附近
if (!scoringEdit.includes('evidence-panel')) failures.push('...')
if (!scoringEdit.includes('decision-panel')) failures.push('...')
  • 步骤 2:新增 v2 类断言

if (!scoringList.includes('scoring-worklist')) 之后,追加:

// v2 信息架构断言
if (!myOkr.includes('data-toolbar')) failures.push('MyOkrView must use data-toolbar (replaced page-hero)')
if (!myOkr.includes('data-table')) failures.push('MyOkrView must use data-table (table-based O/KR)')
if (!myOkr.includes('side-drawer__content') && !myOkr.includes('el-drawer')) failures.push('MyOkrView must use side drawer for KR detail/progress')

if (!scoringEdit.includes('compare-grid-3')) failures.push('ScoringEditView must use compare-grid-3 (3-column comparison)')
if (!scoringEdit.includes('section-band')) failures.push('ScoringEditView must use section-band')
if (!scoringEdit.includes('review-summary-bar')) failures.push('ScoringEditView must use review-summary-bar')

const myResult = readFileSync(join(root, 'src/views/scoring/MyResultView.vue'), 'utf8')
if (!myResult.includes('metric-bar')) failures.push('MyResultView must use metric-bar (replaced grade-ring)')
if (myResult.includes('conic-gradient')) failures.push('MyResultView must not use conic-gradient (grade-ring removed)')

if (!login.includes('underline-input')) failures.push('LoginView must use underline-input')
if (!login.includes('login-brand')) failures.push('LoginView must use login-brand (full-screen split)')
  • 步骤 3:新增禁用模式(全 src 扫描)

const bannedEmoji = ... 等定义后,新增:

const bannedLegacyPatterns = [
  { re: /page-hero/, msg: '禁止 page-hero(改用 data-toolbar)' },
  { re: /section-badge/, msg: '禁止 section-badge 胶囊徽章' },
  { re: /grade-ring/, msg: '禁止 grade-ring 圆环' },
  { re: /score-hero/, msg: '禁止 score-hero 渐变区' },
  { re: /hint-card/, msg: '禁止 hint-card 大引导卡' },
  { re: /conic-gradient/, msg: '禁止 conic-gradient' },
]

for (const file of allVueFiles) 循环(遍历所有 .vue 的那段)中,追加检查:

for (const { re, msg } of bannedLegacyPatterns) {
  if (re.test(text)) failures.push(`${relativePath}: ${msg}`)
}

注意:需要先读现有脚本,找到遍历 .vue 文件的循环位置(通常在 emoji/decor 检查附近),把禁用模式检查加进去。

  • 步骤 4:移除 migratedMarkers 中的废弃类(可选,避免误报)

检查 migratedMarkers 数组,若有 okr-standard-panel 之外的、v2 已删除的类名,移除。保留 okr-workbench/review-workbench/result-workbench/clinical-shell/clinical-sidebar/internal-login(这些 v2 仍保留)。

  • 步骤 5:运行脚本验证全部通过

运行:cd frontend && node scripts/verify-medtech-theme.mjs 预期:PASS(所有 v2 断言通过,无 legacy 模式残留)

若有 FAIL,逐条排查:是页面未改干净(回去修页面),还是断言写错(修脚本)。

  • 步骤 6:运行评分契约脚本

运行:cd frontend && node scripts/verify-scoring-contract.mjs 预期:PASS(口径 OKR 60 + 绩效 40 不变)

  • [ ] 步骤 7:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add frontend/scripts/verify-medtech-theme.mjs
    git commit -m "test(frontend): 更新 verify 脚本断言(v2 信息架构类 + 禁用 legacy 模式)"
    

任务 9:构建 + 部署同步

文件:

  • 修改:backend/src/main/resources/static/(构建产物覆盖)

  • [ ] 步骤 1:前端构建

运行:cd frontend && npm run build 预期:构建成功,frontend/dist/ 生成。

  • 步骤 2:复制产物到 backend static

运行:

cd /Users/destiny/dev/emoon/projects/okr-performance
rm -rf backend/src/main/resources/static/assets
cp -r frontend/dist/* backend/src/main/resources/static/

注意:删除旧 assets 后整体复制,避免孤儿文件(v1 发现的问题)。

  • 步骤 3:确认 index.html 引用正确

运行:grep -oE 'assets/index-[A-Za-z0-9_-]+\.(js|css)' backend/src/main/resources/static/index.html | sort -u 预期:输出 1 个 js + 1 个 css,且文件存在于 assets/ 目录。

  • 步骤 4:清理孤儿文件

运行:ls backend/src/main/resources/static/assets/ | wc -l,对比构建产物数量。确认无未被 index.html 引用的孤儿文件。

  • 步骤 5:后端启动冒烟测试(可选)

运行:cd backend && mvn spring-boot:run 打开 http://localhost:18080,登录后逐一访问 5 个样板页,确认渲染正常、无控制台报错。

  • 步骤 6:运行两个 verify 脚本最终确认

运行:cd frontend && node scripts/verify-medtech-theme.mjs && node scripts/verify-scoring-contract.mjs 预期:两个脚本均 PASS。

  • [ ] 步骤 7:Commit

    cd /Users/destiny/dev/emoon/projects/okr-performance
    git add backend/src/main/resources/static/
    git commit -m "build: 同步前端 v2 构建产物到 backend static(单 JAR 部署)"
    

任务 10:验收对照清单

文件: 无(对照规格第 5 节验收标准人工核对)

  • 步骤 1:信息架构验收

逐条核对:

  • 5 页无 page-hero / section-badge / grade-ring / score-hero / hint-card / conic-gradient(verify 脚本保证)
  • MyOkrView 用 data-toolbar + data-table,KR 更新走抽屉
  • ScoringEditView 用 compare-grid-3 + section-band + review-summary-bar(含自评差异)
  • MyResultView 用 metric-bar + 历史趋势,无圆环
  • MainLayout 侧边栏无 emoji,2px 选中边框
  • [ ] LoginView 全屏分栏 + 下划线输入

  • [ ] 步骤 2:视觉规范验收

  • [ ] 无硬编码 hex(除 SVG 描边)

  • [ ] 无 emoji、无渐变胶囊、无营销口号

  • [ ] 状态用 status-dot,无 effect="dark" round

  • [ ] 区段用 section-band

  • [ ] 步骤 3:数据契约验收

  • [ ] 评分口径 OKR 60 + 绩效 40(verify-scoring-contract.mjs PASS)

  • [ ] 自评/终评/结果展示口径一致

  • [ ] 步骤 4:构建部署验收

  • [ ] verify-medtech-theme.mjs PASS

  • [ ] verify-scoring-contract.mjs PASS

  • [ ] npm run build 成功

  • [ ] 产物已同步 backend/static,无孤儿文件

  • [ ] 步骤 5:全页面人工回归(5 个样板页 + 登录)

登录后逐页操作,确认业务功能未受影响(OKR 编辑/进度更新、评分提交、结果确认、申诉、面谈查看)。


风险与回退

风险 缓解
表格化后小屏挤压 任务 6 步骤 4 预设 <1280px 降级
历史趋势数据缺失 任务 7 步骤 5 前端轮询,失败隐藏趋势区
抽屉与现有 dialog 冲突 抽屉只承载进度更新,编辑 OKR 仍走现有 dialog
字段名与计划不符 每个任务都标注「先读现有代码确认字段名」
verify 断言与页面不一致 任务 8 最后统一更新脚本

回退:所有改动在 5 页 + theme.css + components.css + verify 脚本 + static 产物范围内。git revert 对应 commit 即可完整回退,不影响后端与业务逻辑。


自检清单(执行前阅读)

执行此计划的代理请注意:

  1. 先读后写:每个涉及现有页面的任务,动手前必须先 Read 该文件,确认字段名/方法名/现有结构,再按计划改造。计划中的字段名(如 kr.currentValuedata.finalScore.okrScore)是基于代码探查的推断,可能与实际有出入。
  2. 保留 verify 依赖的类名okr-workbench/review-workbench/result-workbench/clinical-shell/clinical-sidebar/internal-login 必须保留。
  3. 每任务一 commit:按计划节奏频繁 commit,便于回退。
  4. verify 脚本先行:任务 1-7 完成后,任务 8 之前,部分 legacy 断言会 FAIL(因为页面已改但脚本未更新)——这是预期的,不要在任务 1-7 中改脚本。
  5. 口径不动:OKR 60 + 绩效 40 是基线,任何改动不得触碰。