Explorar o código

fix(frontend): 重构工作台行动焦点

wangkangyjy hai 4 días
pai
achega
90ecf85cdc

+ 19 - 6
frontend/src/__tests__/workspace-navigation-contract.test.js

@@ -3,21 +3,34 @@ import { readFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 
 const source = name => readFileSync(resolve(process.cwd(), `src/views/${name}`), 'utf8')
-const components = readFileSync(resolve(process.cwd(), 'src/styles/components.css'), 'utf8')
 
 describe('workspace navigation contracts', () => {
-  it('makes the todo metric follow the first actionable todo and keeps each todo URL', () => {
+  it('makes the first todo the primary action without legacy stat cards', () => {
     const page = source('workspace/WorkspaceView.vue')
-    expect(page).toContain('workspace-todo-metric')
+    expect(page).toContain('workspace-action')
+    expect(page).toContain('workspace-action__title')
+    expect(page).toContain('firstTodo')
     expect(page).toContain('firstTodoTarget')
-    expect(page).toContain(':to="todo.targetUrl"')
+    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')
+    expect(page).not.toContain('workspace-todo-metric')
+    expect(page).not.toContain('DRAFT_OKR')
   })
 
-  it('links team member metrics to the period-specific team OKR view', () => {
+  it('keeps period context and a responsive team pulse', () => {
     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(components).toMatch(/\.grid-3\s*\{[^}]*display:\s*grid/)
+    expect(page).toContain('团队进展')
+    expect(page).toContain('查看团队 OKR')
+    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')
   })
 
   it('uses the period passed from the workspace when opening team OKR', () => {

+ 262 - 110
frontend/src/views/workspace/WorkspaceView.vue

@@ -4,151 +4,303 @@
                 :empty="!workspace.hasPeriod" empty-message="暂无活跃考核周期"
                 @retry="workspace.refreshOverview()">
       <template v-if="workspace.hasPeriod">
-        <!-- Stat cards row -->
-        <div class="grid-3">
-          <router-link v-if="firstTodoTarget" :to="firstTodoTarget" class="stat-card workspace-todo-metric">
-            <div class="stat-card__top stat-card__top--accent"></div>
-            <div class="stat-card__num">{{ workspace.todos.length }}</div>
-            <div class="stat-card__label">待办事项</div>
-          </router-link>
-          <div v-else class="stat-card workspace-todo-metric is-disabled">
-            <div class="stat-card__top stat-card__top--accent"></div>
-            <div class="stat-card__num">0</div>
-            <div class="stat-card__label">待办事项</div>
+        <header class="workspace-heading">
+          <div>
+            <span class="workspace-heading__eyebrow">CURRENT PERIOD</span>
+            <h1>{{ periodTitle }}</h1>
           </div>
-          <router-link :to="teamOkrTarget" class="stat-card workspace-team-link" v-if="workspace.teamSummary">
-            <div class="stat-card__top stat-card__top--indigo"></div>
-            <div class="stat-card__num">{{ workspace.teamSummary.totalMembers }}</div>
-            <div class="stat-card__label">团队成员 · 已提交 {{ workspace.teamSummary.okrSubmitted }}</div>
+          <span class="workspace-heading__phase">{{ phaseLabel }}</span>
+        </header>
+
+        <div class="workspace-primary">
+          <router-link v-if="firstTodo" :to="firstTodoTarget" class="workspace-action">
+            <span class="workspace-action__eyebrow">YOUR NEXT ACTION</span>
+            <h2 class="workspace-action__title">{{ firstTodo.label }}</h2>
+            <p>{{ firstTodoDescription }}</p>
+            <span class="workspace-action__cta">
+              立即处理
+              <span aria-hidden="true">→</span>
+            </span>
           </router-link>
-          <div class="stat-card">
-            <div class="stat-card__top" :class="workspace.risks.length ? 'stat-card__top--orange' : 'stat-card__top--green'"></div>
-            <div class="stat-card__num">{{ workspace.risks.length }}</div>
-            <div class="stat-card__label">风险项</div>
-          </div>
-        </div>
+          <section v-else class="workspace-action workspace-action--empty">
+            <span class="workspace-action__eyebrow">YOUR NEXT ACTION</span>
+            <h2 class="workspace-action__title">当前没有待办</h2>
+            <p>本周期的个人行动已处理完毕,可以关注后续节点与团队进展。</p>
+          </section>
 
-        <!-- Todo + Risks row -->
-        <div class="grid-2 mt-md">
-          <!-- My todos -->
-          <div class="card">
-            <div class="s-head mb-sm">
-              <div class="s-head__left">
-                <h2 style="font-size:var(--font-size-xl);margin:0">我的待办</h2>
-              </div>
+          <section class="workspace-context">
+            <div class="workspace-context__heading">
+              <span>周期上下文</span>
+              <span>{{ workspace.currentPeriod?.startDate }} — {{ workspace.currentPeriod?.endDate }}</span>
             </div>
-            <div v-if="!workspace.todos.length" class="text-muted text-sm" style="padding:12px 0">暂无待办</div>
-            <router-link v-for="todo in workspace.todos" :key="todo.type"
-                         :to="todo.targetUrl" class="workspace-todo">
-              <span class="workspace-todo__label">{{ todo.label }}</span>
-              <span v-if="todo.count" class="tag tag-warn">{{ todo.count }}</span>
-            </router-link>
-          </div>
 
-          <!-- Risks -->
-          <div class="card">
-            <div class="s-head mb-sm">
-              <div class="s-head__left">
-                <h2 style="font-size:var(--font-size-xl);margin:0">风险预警</h2>
+            <div class="workspace-context__group">
+              <h2>截止节点</h2>
+              <div v-if="workspace.deadlines.length" class="workspace-context__list">
+                <div v-for="deadline in workspace.deadlines" :key="`${deadline.label}-${deadline.date}`"
+                     class="workspace-context__row">
+                  <span>{{ deadline.label }}</span>
+                  <time :class="{ 'is-urgent': deadline.type === 'URGENT' }">{{ deadline.date }}</time>
+                </div>
               </div>
+              <p v-else class="workspace-context__quiet">当前没有临近截止节点</p>
             </div>
-            <div v-if="!workspace.risks.length" class="text-muted text-sm" style="padding:12px 0">暂无风险</div>
-            <div v-for="r in workspace.risks" :key="r.label" class="workspace-risk">
-              <span class="tag tag-warn">{{ r.label }}</span>
-              <span class="workspace-risk__user" v-if="r.userName">{{ r.userName }}</span>
-            </div>
-            <!-- Deadlines -->
-            <div v-if="workspace.deadlines.length" class="mt-sm">
-              <div class="text-xs text-muted mb-sm">近期节点</div>
-              <div v-for="d in workspace.deadlines" :key="d.label" class="workspace-deadline">
-                <span>{{ d.label }}</span>
-                <span :class="{ 'text-danger': d.type === 'URGENT' }" class="mono">{{ d.date }}</span>
+
+            <div class="workspace-context__group">
+              <h2>风险状态</h2>
+              <div v-if="workspace.risks.length" class="workspace-context__list">
+                <div v-for="risk in workspace.risks" :key="`${risk.type}-${risk.label}-${risk.userName || ''}`"
+                     class="workspace-context__row workspace-context__risk">
+                  <span>{{ risk.label }}</span>
+                  <span v-if="risk.userName">{{ risk.userName }}</span>
+                </div>
               </div>
+              <p v-else class="workspace-health"><span aria-hidden="true"></span>暂无风险,周期运行正常</p>
             </div>
-          </div>
+
+            <div v-if="workspace.teamSummary" class="workspace-context__team">
+              团队已提交 OKR
+              <strong>{{ workspace.teamSummary.okrSubmitted }} / {{ workspace.teamSummary.totalMembers }}</strong>
+            </div>
+          </section>
         </div>
 
-        <!-- Team summary table -->
-        <div class="card mt-md" v-if="workspace.teamSummary">
-          <div class="s-head mb-sm">
-            <div class="s-head__left">
-              <h2 style="font-size:var(--font-size-xl);margin:0">团队概览</h2>
+        <section v-if="workspace.teamSummary" class="workspace-team">
+          <header class="workspace-team__heading">
+            <h2>团队进展</h2>
+            <router-link :to="teamOkrTarget" class="workspace-team-link">查看团队 OKR →</router-link>
+          </header>
+          <div class="workspace-pulse">
+            <div class="workspace-pulse__item">
+              <strong>{{ workspace.teamSummary.totalMembers }}</strong>
+              <span>团队成员</span>
             </div>
-          </div>
-          <div class="workspace-summary-grid">
-            <router-link :to="teamOkrTarget" class="workspace-summary__item workspace-team-link">
-              <span class="workspace-summary__value">{{ workspace.teamSummary.totalMembers }}</span>
-              <span class="workspace-summary__label">团队成员</span>
-            </router-link>
-            <div class="workspace-summary__item">
-              <span class="workspace-summary__value">{{ workspace.teamSummary.okrSubmitted }}</span>
-              <span class="workspace-summary__label">已提交 OKR</span>
+            <div class="workspace-pulse__item">
+              <strong>{{ workspace.teamSummary.okrSubmitted }}</strong>
+              <span>已提交 OKR</span>
             </div>
-            <div class="workspace-summary__item">
-              <span class="workspace-summary__value">{{ workspace.teamSummary.selfScoreSubmitted || 0 }}</span>
-              <span class="workspace-summary__label">已自评</span>
+            <div class="workspace-pulse__item">
+              <strong>{{ workspace.teamSummary.selfScoreSubmitted || 0 }}</strong>
+              <span>已完成自评</span>
             </div>
-            <div class="workspace-summary__item">
-              <span class="workspace-summary__value">{{ workspace.teamSummary.finalScoreCompleted || 0 }}</span>
-              <span class="workspace-summary__label">已完成终评</span>
+            <div class="workspace-pulse__item">
+              <strong>{{ workspace.teamSummary.finalScoreCompleted || 0 }}</strong>
+              <span>已完成终评</span>
             </div>
           </div>
-        </div>
+        </section>
       </template>
     </AsyncState>
   </div>
 </template>
 
 <script setup>
-import { computed, ref, watch } from 'vue'
+import { computed } from 'vue'
 import { useWorkspaceStore } from '@/stores/workspace'
 import AsyncState from '@/components/common/AsyncState.vue'
 
 const workspace = useWorkspaceStore()
 
-const selectedPeriodId = ref(null)
-
+const periodTitle = computed(() => workspace.currentPeriod?.name || '当前周期')
 const phaseLabel = computed(() => {
   const map = { DRAFT: '草稿', OKR_ALIGN: 'OKR 对齐', EXECUTING: '执行中', ASSESSING: '考评中', ARCHIVED: '已归档' }
-  return map[workspace.currentPhase] || workspace.currentPhase || ''
+  return map[workspace.currentPhase] || workspace.currentPhase || '进行中'
+})
+const firstTodo = computed(() => workspace.todos[0] || null)
+const firstTodoTarget = computed(() => firstTodo.value?.targetUrl || '')
+const firstTodoDescription = computed(() => {
+  return firstTodo.value?.count
+    ? `当前有 ${firstTodo.value.count} 项需要处理,请优先完成这一项。`
+    : '请优先完成这一项,推动本周期工作继续向前。'
 })
-const firstTodoTarget = computed(() => workspace.todos[0]?.targetUrl || '')
 const teamOkrTarget = computed(() => ({
   path: '/okr/team',
   query: workspace.periodId ? { periodId: workspace.periodId } : {}
 }))
-
-watch(() => workspace.currentPeriod, (p) => {
-  selectedPeriodId.value = p?.id || null
-}, { immediate: true })
-
 </script>
 
 <style scoped>
-.workspace-view { max-width: 1120px; margin: 0 auto; }
-.workspace-todo {
-  display: flex; align-items: center; justify-content: space-between;
-  padding: 10px 0; border-bottom: 1px solid var(--color-border-soft);
-  cursor: pointer; text-decoration: none; color: var(--color-text);
-  transition: color .15s;
-}
-.workspace-todo:hover { color: var(--color-dark); }
-.workspace-todo:last-child { border-bottom: none; }
-.workspace-todo__label { font-size: var(--font-size-sm); font-weight: 500; }
-.workspace-todo-metric, .workspace-team-link { display: block; text-decoration: none; color: inherit; }
-.workspace-todo-metric:not(.is-disabled), .workspace-team-link { cursor: pointer; }
-.workspace-todo-metric:not(.is-disabled):hover, .workspace-team-link:hover { border-color: var(--color-accent); }
-.workspace-todo-metric.is-disabled { cursor: default; }
-.workspace-risk { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--color-border-soft); }
-.workspace-risk:last-child { border-bottom: none; }
-.workspace-risk__user { font-size: var(--font-size-xs); color: var(--color-text-muted); }
-.workspace-deadline { display: flex; justify-content: space-between; padding: 6px 0; font-size: var(--font-size-sm); border-bottom: 1px solid var(--color-border-soft); }
-.workspace-deadline:last-child { border-bottom: none; }
-.workspace-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-sm); padding: var(--spacing-md) 0; }
-.workspace-summary__item { min-height: 92px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: var(--spacing-xs); text-align: center; border-right: 1px solid var(--color-border-soft); }
-.workspace-summary__item:last-child { border-right: none; }
-.workspace-summary__value { display: block; color: var(--color-text); font-size: var(--font-size-2xl); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
-.workspace-summary__label { display: block; color: var(--color-text-muted); font-size: var(--font-size-sm); line-height: 1.4; }
-@media (max-width: 640px) { .workspace-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-summary__item:nth-child(2) { border-right: none; }.workspace-summary__item:nth-child(-n+2) { border-bottom: 1px solid var(--color-border-soft); } }
-@media (max-width: 768px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }
+.workspace-view {
+  width: 100%;
+  max-width: 1120px;
+  margin: 0 auto;
+}
+
+.workspace-heading {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 24px;
+  margin-bottom: 24px;
+}
+
+.workspace-heading__eyebrow,
+.workspace-action__eyebrow {
+  display: block;
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: .14em;
+}
+
+.workspace-heading__eyebrow { color: var(--color-text-secondary); }
+.workspace-heading h1 { margin: 6px 0 0; font-size: 28px; line-height: 1.2; }
+.workspace-heading__phase {
+  padding: 6px 12px;
+  border: 1px solid var(--color-border);
+  border-radius: 999px;
+  color: var(--color-text-secondary);
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.workspace-primary {
+  display: grid;
+  grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr);
+  border: 1px solid var(--color-border);
+  background: var(--color-surface);
+}
+
+.workspace-action {
+  min-width: 0;
+  min-height: 368px;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding: 46px;
+  background: var(--color-dark);
+  color: var(--color-white);
+  text-decoration: none;
+}
+
+.workspace-action:hover { color: var(--color-white); }
+.workspace-action__eyebrow { color: rgba(255, 255, 255, .7); }
+.workspace-action__title {
+  max-width: 560px;
+  margin: 22px 0 12px;
+  color: var(--color-white);
+  font-size: clamp(32px, 4vw, 48px);
+  line-height: 1.08;
+}
+.workspace-action p {
+  max-width: 520px;
+  margin: 0;
+  color: rgba(255, 255, 255, .78);
+  font-size: 15px;
+  line-height: 1.75;
+}
+.workspace-action__cta {
+  display: inline-flex;
+  align-items: center;
+  gap: 20px;
+  margin-top: auto;
+  padding: 11px 16px;
+  background: var(--color-accent);
+  color: var(--color-dark-deeper);
+  font-size: 13px;
+  font-weight: 700;
+}
+.workspace-action--empty { cursor: default; }
+
+.workspace-context {
+  min-width: 0;
+  padding: 30px;
+  border-left: 1px solid var(--color-border);
+}
+.workspace-context__heading {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  padding-bottom: 18px;
+  border-bottom: 1px solid var(--color-border);
+  font-size: 12px;
+}
+.workspace-context__heading span:first-child { font-weight: 700; }
+.workspace-context__heading span:last-child { color: var(--color-text-secondary); text-align: right; }
+.workspace-context__group { padding: 20px 0; border-bottom: 1px solid var(--color-border-soft); }
+.workspace-context__group h2 {
+  margin: 0 0 12px;
+  color: var(--color-text-secondary);
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: .08em;
+}
+.workspace-context__list { display: grid; gap: 9px; }
+.workspace-context__row {
+  min-width: 0;
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  color: var(--color-text);
+  font-size: 13px;
+}
+.workspace-context__row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
+.workspace-context__row time,
+.workspace-context__risk span:last-child { color: var(--color-text-secondary); white-space: nowrap; }
+.workspace-context__row time.is-urgent { color: var(--color-danger); font-weight: 600; }
+.workspace-context__quiet,
+.workspace-health { margin: 0; color: var(--color-text-secondary); font-size: 13px; }
+.workspace-health { display: flex; align-items: center; gap: 8px; }
+.workspace-health span { width: 7px; height: 7px; border-radius: 50%; background: var(--color-success); }
+.workspace-context__team {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  padding-top: 20px;
+  color: var(--color-text-secondary);
+  font-size: 13px;
+}
+.workspace-context__team strong { color: var(--color-text); }
+
+.workspace-team { margin-top: 22px; }
+.workspace-team__heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  margin-bottom: 12px;
+}
+.workspace-team__heading h2 { margin: 0; font-size: 18px; }
+.workspace-team-link { color: var(--color-text-secondary); font-size: 13px; text-decoration: none; }
+.workspace-team-link:hover { color: var(--color-dark); }
+.workspace-pulse {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  border: 1px solid var(--color-border);
+  background: var(--color-surface);
+}
+.workspace-pulse__item {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  padding: 22px 26px;
+  border-right: 1px solid var(--color-border);
+  color: inherit;
+  text-decoration: none;
+}
+.workspace-pulse__item:last-child { border-right: 0; }
+.workspace-pulse__item strong { font-size: 24px; line-height: 1; font-variant-numeric: tabular-nums; }
+.workspace-pulse__item span { color: var(--color-text-secondary); font-size: 12px; }
+
+@media (max-width: 820px) {
+  .workspace-primary { grid-template-columns: 1fr; }
+  .workspace-action { min-height: 320px; }
+  .workspace-context { border-top: 1px solid var(--color-border); border-left: 0; }
+  .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); }
+}
+
+@media (max-width: 640px) {
+  .workspace-heading { align-items: flex-start; margin-bottom: 18px; }
+  .workspace-heading h1 { font-size: 23px; overflow-wrap: anywhere; }
+  .workspace-action { min-height: 300px; padding: 30px 24px; }
+  .workspace-action__title { margin-top: 18px; font-size: 32px; overflow-wrap: anywhere; }
+  .workspace-context { padding: 24px; }
+  .workspace-context__heading { flex-direction: column; gap: 5px; }
+  .workspace-context__heading span:last-child { text-align: left; }
+  .workspace-context__row { gap: 12px; }
+  .workspace-context__risk { align-items: flex-start; }
+  .workspace-pulse__item { padding: 20px; }
+}
 </style>