|
|
@@ -0,0 +1,1664 @@
|
|
|
+# 前端去 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 {` 块,在其闭合 `}` 之前追加:
|
|
|
+
|
|
|
+```css
|
|
|
+ /* ===== 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 通用类**
|
|
|
+
|
|
|
+```css
|
|
|
+/* ===== 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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>` 中,找到侧边栏背景色定义(当前硬编码 `#2b1f99` 或 `var(--color-brand)`),改为:
|
|
|
+
|
|
|
+```css
|
|
|
+.clinical-sidebar {
|
|
|
+ background: var(--color-sidebar-bg);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:菜单项去 emoji + 细化选中态**
|
|
|
+
|
|
|
+在 `<template>` 中,找到侧边栏菜单的 `<el-menu-item>` 列表。删除每个 item 文本中的 emoji(如 `📅 考核周期` → `考核周期`),改为在文字前加几何图标 span。示例(考核周期):
|
|
|
+
|
|
|
+```html
|
|
|
+<el-menu-item index="/period">
|
|
|
+ <span class="nav-ico">▦</span>
|
|
|
+ <template #title>考核周期</template>
|
|
|
+</el-menu-item>
|
|
|
+```
|
|
|
+
|
|
|
+几何图标对照表(统一用 Unicode 几何字符,不引入图标库):
|
|
|
+- 考核周期 → `▦`
|
|
|
+- 我的 OKR → `◎`
|
|
|
+- 团队 OKR → `⬡`
|
|
|
+- OKR 对齐 → `⇄`
|
|
|
+- 绩效打分 → `▱`
|
|
|
+- 绩效沟通 → `◠`
|
|
|
+- 绩效结果 → `⊞`
|
|
|
+- 组织管理 → `⬢`
|
|
|
+- 系统日志 → `≣`
|
|
|
+- 维度配置 → `⌗`
|
|
|
+- KPI 模板 → `⊟`
|
|
|
+- 通知 → `✉`(去掉,改 `⌧`)
|
|
|
+
|
|
|
+在 `<style scoped>` 追加:
|
|
|
+
|
|
|
+```css
|
|
|
+.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>` 追加:
|
|
|
+
|
|
|
+```css
|
|
|
+.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 · 绩效":
|
|
|
+
|
|
|
+```html
|
|
|
+<div class="brand-sub">OKR · 绩效</div>
|
|
|
+```
|
|
|
+
|
|
|
+```css
|
|
|
+.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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 脚本依赖)。结构:
|
|
|
+
|
|
|
+```html
|
|
|
+<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 全局提供):
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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>` 的响应式状态区,新增抽屉状态:
|
|
|
+
|
|
|
+```js
|
|
|
+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` 区域,替换为:
|
|
|
+
|
|
|
+```html
|
|
|
+<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 数据):
|
|
|
+
|
|
|
+```js
|
|
|
+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:
|
|
|
+
|
|
|
+```css
|
|
|
+.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` 区域:
|
|
|
+
|
|
|
+```html
|
|
|
+<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`,仅新增状态色点映射):
|
|
|
+
|
|
|
+```js
|
|
|
+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 末尾追加抽屉:
|
|
|
+
|
|
|
+```html
|
|
|
+<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`(对接现有方法),补充方法:
|
|
|
+
|
|
|
+```js
|
|
|
+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 行加一个「指派给我」小标签:
|
|
|
+
|
|
|
+```html
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+```css
|
|
|
+.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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```html
|
|
|
+<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:
|
|
|
+
|
|
|
+```html
|
|
|
+<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(基于现有数据,确认字段名后再写):
|
|
|
+
|
|
|
+```js
|
|
|
+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` 均为现有变量,先读现有代码确认名称与类型。
|
|
|
+
|
|
|
+- [ ] **步骤 3:底部固定总览栏(含自评差异提示)**
|
|
|
+
|
|
|
+在 `scoring-body` 末尾追加:
|
|
|
+
|
|
|
+```html
|
|
|
+<!-- 底部总览栏 -->
|
|
|
+<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` 复用现有,仅新增差异相关):
|
|
|
+
|
|
|
+```js
|
|
|
+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>` 追加:
|
|
|
+
|
|
|
+```css
|
|
|
+@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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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/confirmedAt`、`data.selfScore.total`。自评表单态(`showSelfForm` 为 true)**保持现状不动**。
|
|
|
+
|
|
|
+- [ ] **步骤 1:template 顶部改 data-toolbar**
|
|
|
+
|
|
|
+替换 `page-hero`(仅结果态部分):
|
|
|
+
|
|
|
+```html
|
|
|
+<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 区):
|
|
|
+
|
|
|
+```html
|
|
|
+<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(历史趋势兜底):
|
|
|
+
|
|
|
+```js
|
|
|
+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` 逻辑,仅调整样式类):
|
|
|
+
|
|
|
+```html
|
|
|
+<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)+ 历史趋势**
|
|
|
+
|
|
|
+```html
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+补充方法:
|
|
|
+
|
|
|
+```js
|
|
|
+function barHeight(score) {
|
|
|
+ if (!score) return 10
|
|
|
+ return Math.max(10, Math.min(48, (score / 100) * 48))
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:历史数据兜底加载(前端轮询,无新后端接口)**
|
|
|
+
|
|
|
+在 `loadData` 或 `onMounted` 中追加(方案一:轮询最近 4 个归档周期):
|
|
|
+
|
|
|
+```js
|
|
|
+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 依赖)及新样式。追加新样式:
|
|
|
+
|
|
|
+```css
|
|
|
+.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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 已用新结构替代):
|
|
|
+
|
|
|
+```js
|
|
|
+// 删除这些行:
|
|
|
+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'))` 之后,追加:
|
|
|
+
|
|
|
+```js
|
|
|
+// 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 = ...` 等定义后,新增:
|
|
|
+
|
|
|
+```js
|
|
|
+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 的那段)中,追加检查:
|
|
|
+
|
|
|
+```js
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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.currentValue`、`data.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 是基线,任何改动不得触碰。
|