2026-07-16-workspace-action-focus-redesign.md 15 KB

工作台首页「行动焦点」实现计划

面向 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 常量,将该文件前两个测试替换为以下代码,保留后两个周期导航测试不变:

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:运行定向测试并确认失败

运行:

cd frontend
npm test -- src/__tests__/workspace-navigation-contract.test.js

预期:FAIL;报告缺少 workspace-actionworkspace-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" 替换为:

<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:精简脚本并增加主行动计算属性

删除 refwatchselectedPeriodId 及对应周期监听;保留 computed。在 phaseLabel 后使用:

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 替换旧工作台样式

    .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:运行定向测试验证通过

运行:

cd frontend
npm test -- src/__tests__/workspace-navigation-contract.test.js

预期:4 个测试全部 PASS。

  • [ ] 步骤 5:提交行动焦点实现

    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:运行前端全量测试

运行:

cd frontend
npm test

预期:9 个测试文件、至少 43 个测试全部 PASS。

  • 步骤 2:运行生产构建

运行:

cd frontend
npm run build

预期:Vite 构建成功;允许已有的大 chunk 提示,不允许编译错误。

  • 步骤 3:在 1440px、1024px、768px 检查真实页面

启动:

cd frontend
npm run dev

依次检查三个视口,预期:

  • 1440px:主行动与上下文左右排列,团队脉搏四列,无顶部三卡。
  • 1024px:内容不拥挤,主行动与上下文仍可读,无文字截断。
  • 768px:主行动与上下文纵向排列,团队脉搏两列,无横向滚动。
  • 所有视口:标题、正文和上下文关键文字清晰;主行动按钮可见;团队链接携带当前 periodId

  • [ ] 步骤 4:检查范围与工作区

运行:

git diff --check
git status --short

预期:无空白错误;只有本计划明确列出的文件发生修改,或工作区在提交后干净。