面向 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 节),不改动现有 tokenfrontend/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(口径不变)文件:
修改: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;
运行: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(表格/工具栏/状态点/区段带)"
文件:
修改: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 等)"
文件:
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> 中,找到侧边栏背景色定义(当前硬编码 #2b1f99 或 var(--color-brand)),改为:
.clinical-sidebar {
background: var(--color-sidebar-bg);
}
在 <template> 中,找到侧边栏菜单的 <el-menu-item> 列表。删除每个 item 文本中的 emoji(如 📅 考核周期 → 考核周期),改为在文字前加几何图标 span。示例(考核周期):
<el-menu-item index="/period">
<span class="nav-ico">▦</span>
<template #title>考核周期</template>
</el-menu-item>
几何图标对照表(统一用 Unicode 几何字符,不引入图标库):
▦◎⬡⇄▱◠⊞⬢≣⌗⊟✉(去掉,改 ⌧)在 <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;
}
找到菜单分组标题(如"考评管理"、"系统管理"对应的 <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;
}
找到侧边栏顶部品牌区,将副标题"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;
}
找到顶栏的 <el-tag> 角色标签,把 round 属性去掉(如有),确认无 round。
运行:cd frontend && npm run dev
打开 http://localhost:5173,登录后检查:侧边栏无 emoji、选中态是 2px 左边框、分组标题是大写小字、底色更沉。
运行: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、细化选中态、加深背景"
文件:
修改: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>
替换 <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; }
}
检查 template/style 中是否还有 radial-gradient,如有则删除。
运行:cd frontend && npm run dev
打开 http://localhost:5173/login,检查:全屏左右分栏、左侧深色品牌区、右侧下划线输入框、focus 时下边框变青色。
运行: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 改全屏左右分栏 + 下划线式输入框"
文件:
frontend/src/views/okr/MyOkrView.vue(template 重构为 data-toolbar + data-table + 抽屉)前置说明(关键数据结构): 先读现有代码确认。当前数据结构是扁平的:
myOkr.keyResults[](KR 直接挂在 myOkr 下,不是 objectives[].keyResults[])id/title/currentValue/targetValue/weight/status 字段(已确认)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 列表实现(更简单,符合现有数据)。
在 <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
}
找到当前 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); }
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 是现有方法,直接复用,不要重造。
现有 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)。
找到当前 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;
}
在 <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 及上述新样式。
全文搜索 effect="dark" 和 round,移除这些属性。状态展示统一改用 .status-dot。
运行:cd frontend && npm run dev
切换 period 状态(或用测试账号),检查:
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 表格 + 侧边抽屉)"
文件:
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].scoredimScores = ref([]),元素是 {id, name, maxScore, score}(第 326 行)→ 绑定 dimScores[i].scoredimensions = 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>
替换原 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)')
}
注意:okrRecords、selfKrScore、selfDimScore、selfDimDesc、krScores、dimScores、bonusScore 均为现有变量,先读现有代码确认名称与类型。
在 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 状态管理)。
在 <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;
}
}
删除 <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 及新样式。
全文移除 effect="dark" 和 round。
运行:cd frontend && npm run dev
进入评分页,检查:三列对照、区段色带、底部总览栏(含自评差异)、小屏降级。
运行: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 三列对照重构(证据·自评·终评 + 总览栏)"
文件:
frontend/src/views/scoring/MyResultView.vue(template 重构 result 态,保留 self-form 态不动)前置说明: 现有数据字段:data.finalScore.total/grade/okrScore/kpiScore/bonus/penalty/confirmedAt、data.selfScore.total。自评表单态(showSelfForm 为 true)保持现状不动。
替换 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>
替换 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'
}
在 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))
}
在 loadData 或 onMounted 中追加(方案一:轮询最近 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。
删除 <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; }
运行:cd frontend && npm run dev
进入我的绩效页,检查:无圆环、左大数字 + 右构成条、自评vs终评对照、历史趋势、扁平操作按钮。
运行: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 删等级圆环,改数据卡 + 构成条 + 历史趋势"
文件:
修改: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('...')
在 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)')
在 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 检查附近),把禁用模式检查加进去。
检查 migratedMarkers 数组,若有 okr-standard-panel 之外的、v2 已删除的类名,移除。保留 okr-workbench/review-workbench/result-workbench/clinical-shell/clinical-sidebar/internal-login(这些 v2 仍保留)。
运行:cd frontend && node scripts/verify-medtech-theme.mjs
预期:PASS(所有 v2 断言通过,无 legacy 模式残留)
若有 FAIL,逐条排查:是页面未改干净(回去修页面),还是断言写错(修脚本)。
运行: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 模式)"
文件:
修改:backend/src/main/resources/static/(构建产物覆盖)
[ ] 步骤 1:前端构建
运行:cd frontend && npm run build
预期:构建成功,frontend/dist/ 生成。
运行:
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 发现的问题)。
运行:grep -oE 'assets/index-[A-Za-z0-9_-]+\.(js|css)' backend/src/main/resources/static/index.html | sort -u
预期:输出 1 个 js + 1 个 css,且文件存在于 assets/ 目录。
运行:ls backend/src/main/resources/static/assets/ | wc -l,对比构建产物数量。确认无未被 index.html 引用的孤儿文件。
运行:cd backend && mvn spring-boot:run
打开 http://localhost:18080,登录后逐一访问 5 个样板页,确认渲染正常、无控制台报错。
运行: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 部署)"
文件: 无(对照规格第 5 节验收标准人工核对)
逐条核对:
page-hero / section-badge / grade-ring / score-hero / hint-card / conic-gradient(verify 脚本保证)data-toolbar + data-table,KR 更新走抽屉compare-grid-3 + section-band + review-summary-bar(含自评差异)metric-bar + 历史趋势,无圆环[ ] 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 即可完整回退,不影响后端与业务逻辑。
执行此计划的代理请注意:
kr.currentValue、data.finalScore.okrScore)是基于代码探查的推断,可能与实际有出入。okr-workbench/review-workbench/result-workbench/clinical-shell/clinical-sidebar/internal-login 必须保留。