面向 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 — 行动焦点、团队跳转、旧卡片移除与对比度契约。文件:
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')
})
运行:
cd frontend
npm test -- src/__tests__/workspace-navigation-contract.test.js
预期:FAIL;报告缺少 workspace-action、workspace-pulse 或仍存在 class="grid-3"。
文件:
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>
删除 ref、watch、selectedPeriodId 及对应周期监听;保留 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): 重构工作台行动焦点"
文件:
frontend/src/views/workspace/WorkspaceView.vue验证:frontend/src/__tests__/workspace-navigation-contract.test.js
[ ] 步骤 1:运行前端全量测试
运行:
cd frontend
npm test
预期:9 个测试文件、至少 43 个测试全部 PASS。
运行:
cd frontend
npm run build
预期:Vite 构建成功;允许已有的大 chunk 提示,不允许编译错误。
启动:
cd frontend
npm run dev
依次检查三个视口,预期:
所有视口:标题、正文和上下文关键文字清晰;主行动按钮可见;团队链接携带当前 periodId。
[ ] 步骤 4:检查范围与工作区
运行:
git diff --check
git status --short
预期:无空白错误;只有本计划明确列出的文件发生修改,或工作区在提交后干净。