|
|
@@ -0,0 +1,290 @@
|
|
|
+# 工作台首页「行动焦点」实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 把工作台首页从重复的大卡片墙改为以第一项待办为唯一主视觉的「行动焦点」布局,同时保留周期、团队、风险与截止信息。
|
|
|
+
|
|
|
+**架构:** 不改 store、API、路由或后端,只重排 `WorkspaceView.vue` 的语义结构和 scoped CSS。用源码契约测试锁定主行动、上下文、平面团队脉搏、高对比度文字和旧统计卡移除,再用现有前端全量测试与 Vite 生产构建验证。
|
|
|
+
|
|
|
+**技术栈:** Vue 3 `<script setup>`、Pinia workspace store、Vue Router、Vitest 源码契约测试、Vite。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+- 修改:`frontend/src/views/workspace/WorkspaceView.vue` — 首页语义结构、计算属性、响应式与视觉样式。
|
|
|
+- 修改:`frontend/src/__tests__/workspace-navigation-contract.test.js` — 行动焦点、团队跳转、旧卡片移除与对比度契约。
|
|
|
+
|
|
|
+### 任务 1:用失败契约锁定行动焦点信息层级
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`frontend/src/__tests__/workspace-navigation-contract.test.js`
|
|
|
+- 测试:`frontend/src/__tests__/workspace-navigation-contract.test.js`
|
|
|
+
|
|
|
+- [ ] **步骤 1:替换旧工作台卡片契约并新增视觉层级契约**
|
|
|
+
|
|
|
+删除不再使用的 `components` 常量,将该文件前两个测试替换为以下代码,保留后两个周期导航测试不变:
|
|
|
+
|
|
|
+```js
|
|
|
+it('makes the first todo the single primary action without a repeated stat-card row', () => {
|
|
|
+ const page = source('workspace/WorkspaceView.vue')
|
|
|
+ expect(page).toContain('workspace-action')
|
|
|
+ expect(page).toContain('workspace-action__title')
|
|
|
+ expect(page).toContain('firstTodo')
|
|
|
+ expect(page).toContain('firstTodoTarget')
|
|
|
+ expect(page).toContain(':to="firstTodoTarget"')
|
|
|
+ expect(page).toContain('YOUR NEXT ACTION')
|
|
|
+ expect(page).not.toContain('class="grid-3"')
|
|
|
+ expect(page).not.toContain('stat-card__top')
|
|
|
+})
|
|
|
+
|
|
|
+it('keeps team navigation in a flat pulse row and uses readable secondary text', () => {
|
|
|
+ const page = source('workspace/WorkspaceView.vue')
|
|
|
+ expect(page).toContain('workspace-pulse')
|
|
|
+ expect(page).toContain('workspace-context')
|
|
|
+ expect(page).toContain('teamOkrTarget')
|
|
|
+ expect(page).toContain('workspace-team-link')
|
|
|
+ expect(page).toContain('var(--color-text-secondary)')
|
|
|
+ expect(page).toContain('@media (max-width: 820px)')
|
|
|
+ expect(page).toContain('@media (max-width: 640px)')
|
|
|
+ expect(page).not.toContain('card mt-md')
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行定向测试并确认失败**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm test -- src/__tests__/workspace-navigation-contract.test.js
|
|
|
+```
|
|
|
+
|
|
|
+预期:FAIL;报告缺少 `workspace-action`、`workspace-pulse` 或仍存在 `class="grid-3"`。
|
|
|
+
|
|
|
+### 任务 2:实现已确认的「行动焦点」首页
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`frontend/src/views/workspace/WorkspaceView.vue`
|
|
|
+- 测试:`frontend/src/__tests__/workspace-navigation-contract.test.js`
|
|
|
+
|
|
|
+- [ ] **步骤 1:把页面模板替换为周期标题、主行动、上下文和团队脉搏**
|
|
|
+
|
|
|
+保留现有 `AsyncState` 外壳,将其内部 `template v-if="workspace.hasPeriod"` 替换为:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template v-if="workspace.hasPeriod">
|
|
|
+ <header class="workspace-header">
|
|
|
+ <div>
|
|
|
+ <span class="workspace-eyebrow">CURRENT PERFORMANCE CYCLE</span>
|
|
|
+ <h1>{{ periodTitle }}</h1>
|
|
|
+ </div>
|
|
|
+ <span class="workspace-phase">{{ phaseLabel }}</span>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div class="workspace-primary" :class="{ 'workspace-primary--solo': !workspace.teamSummary }">
|
|
|
+ <router-link v-if="firstTodo" :to="firstTodoTarget" class="workspace-action">
|
|
|
+ <div>
|
|
|
+ <span class="workspace-action__eyebrow">YOUR NEXT ACTION</span>
|
|
|
+ <h2 class="workspace-action__title">{{ firstTodo.label }}</h2>
|
|
|
+ <p>{{ firstTodoDescription }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="workspace-action__footer">
|
|
|
+ <span class="workspace-action__button">立即处理 <span aria-hidden="true">→</span></span>
|
|
|
+ <span>{{ workspace.todos.length }} 项待办</span>
|
|
|
+ </div>
|
|
|
+ </router-link>
|
|
|
+ <section v-else class="workspace-action workspace-action--empty">
|
|
|
+ <div>
|
|
|
+ <span class="workspace-action__eyebrow">YOUR NEXT ACTION</span>
|
|
|
+ <h2 class="workspace-action__title">当前暂无待办</h2>
|
|
|
+ <p>本周期暂时没有需要你处理的事项。</p>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <aside class="workspace-context">
|
|
|
+ <h2>周期上下文</h2>
|
|
|
+ <div class="workspace-context__group">
|
|
|
+ <span>近期节点</span>
|
|
|
+ <div v-if="workspace.deadlines.length">
|
|
|
+ <div v-for="deadline in workspace.deadlines" :key="deadline.label" class="workspace-context__row">
|
|
|
+ <strong>{{ deadline.label }}</strong>
|
|
|
+ <time :class="{ 'is-urgent': deadline.type === 'URGENT' }">{{ deadline.date }}</time>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <strong v-else>暂无近期节点</strong>
|
|
|
+ </div>
|
|
|
+ <div class="workspace-context__group">
|
|
|
+ <span>风险状态</span>
|
|
|
+ <strong v-if="!workspace.risks.length" class="is-healthy">正常 · 暂无风险</strong>
|
|
|
+ <div v-else>
|
|
|
+ <div v-for="risk in workspace.risks" :key="`${risk.label}-${risk.userName || ''}`" class="workspace-context__row">
|
|
|
+ <strong>{{ risk.label }}</strong>
|
|
|
+ <span v-if="risk.userName">{{ risk.userName }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="workspace.teamSummary" class="workspace-context__group">
|
|
|
+ <span>团队提交</span>
|
|
|
+ <strong>{{ workspace.teamSummary.okrSubmitted }} / {{ workspace.teamSummary.totalMembers }} 人</strong>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <section v-if="workspace.teamSummary" class="workspace-team">
|
|
|
+ <div class="workspace-section-head">
|
|
|
+ <h2>团队进展</h2>
|
|
|
+ <router-link :to="teamOkrTarget">查看团队 OKR <span aria-hidden="true">→</span></router-link>
|
|
|
+ </div>
|
|
|
+ <div class="workspace-pulse">
|
|
|
+ <router-link :to="teamOkrTarget" class="workspace-pulse__item workspace-team-link">
|
|
|
+ <strong>{{ workspace.teamSummary.totalMembers }}</strong><span>团队成员</span>
|
|
|
+ </router-link>
|
|
|
+ <div class="workspace-pulse__item"><strong>{{ workspace.teamSummary.okrSubmitted }}</strong><span>已提交 OKR</span></div>
|
|
|
+ <div class="workspace-pulse__item"><strong>{{ workspace.teamSummary.selfScoreSubmitted || 0 }}</strong><span>已完成自评</span></div>
|
|
|
+ <div class="workspace-pulse__item"><strong>{{ workspace.teamSummary.finalScoreCompleted || 0 }}</strong><span>已完成终评</span></div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div v-if="!workspace.risks.length" class="workspace-health"><i></i><span>当前没有需要优先处理的团队风险</span></div>
|
|
|
+</template>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:精简脚本并增加主行动计算属性**
|
|
|
+
|
|
|
+删除 `ref`、`watch`、`selectedPeriodId` 及对应周期监听;保留 `computed`。在 `phaseLabel` 后使用:
|
|
|
+
|
|
|
+```js
|
|
|
+const periodTitle = computed(() => workspace.currentPeriod?.name || '当前考核周期')
|
|
|
+const firstTodo = computed(() => workspace.todos[0] || null)
|
|
|
+const firstTodoTarget = computed(() => firstTodo.value?.targetUrl || '')
|
|
|
+const firstTodoDescription = computed(() => {
|
|
|
+ if (!firstTodo.value) return ''
|
|
|
+ return firstTodo.value.count
|
|
|
+ ? `${firstTodo.value.count} 项内容等待处理,进入对应工作区继续推进。`
|
|
|
+ : '进入对应工作区继续处理。'
|
|
|
+})
|
|
|
+const teamOkrTarget = computed(() => ({
|
|
|
+ path: '/okr/team',
|
|
|
+ query: workspace.periodId ? { periodId: workspace.periodId } : {}
|
|
|
+}))
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:用以下 scoped CSS 替换旧工作台样式**
|
|
|
+
|
|
|
+```css
|
|
|
+.workspace-view { max-width: 1120px; margin: 0 auto; }
|
|
|
+.workspace-header { display:flex; align-items:flex-end; justify-content:space-between; padding-bottom:var(--spacing-xl); border-bottom:1px solid var(--color-border); }
|
|
|
+.workspace-eyebrow,.workspace-action__eyebrow { display:block; font-size:10px; font-weight:700; letter-spacing:1.2px; }
|
|
|
+.workspace-eyebrow { color:var(--color-text-secondary); }
|
|
|
+.workspace-header h1 { margin:var(--spacing-sm) 0 0; color:var(--color-text); font-size:var(--font-size-2xl); line-height:1.2; }
|
|
|
+.workspace-phase { display:flex; align-items:center; gap:var(--spacing-sm); color:var(--color-text-secondary); font-size:var(--font-size-sm); }
|
|
|
+.workspace-phase::before { width:7px; height:7px; border-radius:50%; background:var(--color-accent); box-shadow:0 0 0 4px var(--color-accent-alpha); content:""; }
|
|
|
+.workspace-primary { display:grid; grid-template-columns:minmax(0,1.55fr) minmax(260px,.75fr); gap:var(--spacing-lg); margin-top:var(--spacing-xl); }
|
|
|
+.workspace-primary--solo { grid-template-columns:1fr; }
|
|
|
+.workspace-action { min-height:290px; display:flex; flex-direction:column; justify-content:space-between; padding:var(--spacing-2xl); overflow:hidden; position:relative; border-radius:var(--radius-md); background:var(--color-dark-surface); color:var(--color-white); text-decoration:none; }
|
|
|
+.workspace-action::after { width:260px; height:260px; position:absolute; top:-135px; right:-115px; border:42px solid var(--color-accent-alpha); border-radius:50%; content:""; pointer-events:none; }
|
|
|
+.workspace-action__eyebrow { color:#79e9ec; }
|
|
|
+.workspace-action__title { margin:var(--spacing-xl) 0 var(--spacing-sm); color:var(--color-white); font-size:32px; line-height:1.15; }
|
|
|
+.workspace-action p { margin:0; color:rgba(255,255,255,.72); font-size:var(--font-size-sm); }
|
|
|
+.workspace-action__footer { display:flex; align-items:center; justify-content:space-between; position:relative; z-index:1; color:rgba(255,255,255,.68); font-size:var(--font-size-xs); }
|
|
|
+.workspace-action__button { min-height:40px; display:inline-flex; align-items:center; gap:var(--spacing-sm); padding:0 var(--spacing-lg); border-radius:var(--radius-pill); background:var(--color-accent); color:#102f31; font-weight:700; }
|
|
|
+.workspace-action--empty { background:var(--color-brand-strong); }
|
|
|
+.workspace-context { padding:var(--spacing-xl); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-surface); }
|
|
|
+.workspace-context h2,.workspace-section-head h2 { margin:0; color:var(--color-text); font-size:var(--font-size-base); }
|
|
|
+.workspace-context__group { padding:var(--spacing-lg) 0; border-top:1px solid var(--color-border-soft); }
|
|
|
+.workspace-context__group:first-of-type { margin-top:var(--spacing-lg); }
|
|
|
+.workspace-context__group > span { display:block; margin-bottom:var(--spacing-sm); color:var(--color-text-secondary); font-size:var(--font-size-xs); }
|
|
|
+.workspace-context__group strong { color:var(--color-text); font-size:var(--font-size-sm); }
|
|
|
+.workspace-context__row { display:flex; justify-content:space-between; gap:var(--spacing-md); padding:var(--spacing-xs) 0; color:var(--color-text-secondary); font-size:var(--font-size-xs); }
|
|
|
+.workspace-context__row time { font-variant-numeric:tabular-nums; }
|
|
|
+.workspace-context .is-healthy { color:var(--color-green-text); }
|
|
|
+.workspace-context .is-urgent { color:var(--color-danger); }
|
|
|
+.workspace-team { margin-top:var(--spacing-2xl); }
|
|
|
+.workspace-section-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--spacing-lg); }
|
|
|
+.workspace-section-head a { color:var(--color-text-secondary); font-size:var(--font-size-xs); text-decoration:none; }
|
|
|
+.workspace-pulse { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-top:1px solid var(--color-border); border-bottom:1px solid var(--color-border); background:var(--color-surface); }
|
|
|
+.workspace-pulse__item { min-height:102px; display:flex; flex-direction:column; justify-content:center; padding:0 var(--spacing-xl); border-right:1px solid var(--color-border-soft); color:inherit; text-decoration:none; }
|
|
|
+.workspace-pulse__item:last-child { border-right:0; }
|
|
|
+.workspace-pulse__item strong { color:var(--color-text); font-size:var(--font-size-2xl); line-height:1; font-variant-numeric:tabular-nums; }
|
|
|
+.workspace-pulse__item span { margin-top:var(--spacing-sm); color:var(--color-text-secondary); font-size:var(--font-size-xs); }
|
|
|
+.workspace-team-link strong { color:var(--color-brand); }
|
|
|
+.workspace-team-link:hover { background:var(--color-accent-hover-bg); }
|
|
|
+.workspace-health { display:flex; align-items:center; gap:var(--spacing-sm); margin-top:var(--spacing-xl); color:var(--color-text-secondary); font-size:var(--font-size-xs); }
|
|
|
+.workspace-health i { width:6px; height:6px; border-radius:50%; background:var(--color-success); }
|
|
|
+@media (max-width: 820px) { .workspace-primary,.workspace-primary--solo { grid-template-columns:1fr; }.workspace-action { min-height:250px; }.workspace-pulse { grid-template-columns:repeat(2,minmax(0,1fr)); }.workspace-pulse__item:nth-child(2) { border-right:0; }.workspace-pulse__item:nth-child(-n+2) { border-bottom:1px solid var(--color-border-soft); } }
|
|
|
+@media (max-width: 640px) { .workspace-header { align-items:flex-start; gap:var(--spacing-lg); }.workspace-header h1 { font-size:var(--font-size-xl); }.workspace-action { min-height:230px; padding:var(--spacing-xl); }.workspace-action__title { font-size:var(--font-size-2xl); }.workspace-action__footer { align-items:flex-start; flex-direction:column; gap:var(--spacing-lg); }.workspace-context { padding:var(--spacing-lg); }.workspace-pulse__item { min-height:88px; padding:0 var(--spacing-lg); } }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行定向测试验证通过**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm test -- src/__tests__/workspace-navigation-contract.test.js
|
|
|
+```
|
|
|
+
|
|
|
+预期:4 个测试全部 PASS。
|
|
|
+
|
|
|
+- [ ] **步骤 5:提交行动焦点实现**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add frontend/src/views/workspace/WorkspaceView.vue frontend/src/__tests__/workspace-navigation-contract.test.js
|
|
|
+git commit -m "fix(frontend): 重构工作台行动焦点"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 3:全量回归与三断点视觉验收
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 验证:`frontend/src/views/workspace/WorkspaceView.vue`
|
|
|
+- 验证:`frontend/src/__tests__/workspace-navigation-contract.test.js`
|
|
|
+
|
|
|
+- [ ] **步骤 1:运行前端全量测试**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm test
|
|
|
+```
|
|
|
+
|
|
|
+预期:9 个测试文件、至少 43 个测试全部 PASS。
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行生产构建**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm run build
|
|
|
+```
|
|
|
+
|
|
|
+预期:Vite 构建成功;允许已有的大 chunk 提示,不允许编译错误。
|
|
|
+
|
|
|
+- [ ] **步骤 3:在 1440px、1024px、768px 检查真实页面**
|
|
|
+
|
|
|
+启动:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend
|
|
|
+npm run dev
|
|
|
+```
|
|
|
+
|
|
|
+依次检查三个视口,预期:
|
|
|
+
|
|
|
+- 1440px:主行动与上下文左右排列,团队脉搏四列,无顶部三卡。
|
|
|
+- 1024px:内容不拥挤,主行动与上下文仍可读,无文字截断。
|
|
|
+- 768px:主行动与上下文纵向排列,团队脉搏两列,无横向滚动。
|
|
|
+- 所有视口:标题、正文和上下文关键文字清晰;主行动按钮可见;团队链接携带当前 `periodId`。
|
|
|
+
|
|
|
+- [ ] **步骤 4:检查范围与工作区**
|
|
|
+
|
|
|
+运行:
|
|
|
+
|
|
|
+```bash
|
|
|
+git diff --check
|
|
|
+git status --short
|
|
|
+```
|
|
|
+
|
|
|
+预期:无空白错误;只有本计划明确列出的文件发生修改,或工作区在提交后干净。
|