Просмотр исходного кода

refactor(admin): consolidate dashboard operations overview

彭峰 1 месяц назад
Родитель
Сommit
2031089719

+ 62 - 0
design-qa.md

@@ -49,6 +49,68 @@ final result: passed
 
 ---
 
+# Design QA:运营工作台指标与趋势合并
+
+## Evidence
+
+- Source visual truth: `/var/folders/yx/cq4gbk4x6w702x_slllw80mw0000gn/T/TemporaryItems/NSIRD_screencaptureui_LIT86c/截屏2026-08-20 12.44.07.png`.
+- Source dimensions: 3052 × 384 px;内容为调整前的四张独立运营指标卡。
+- Implementation route: `http://127.0.0.1:54535/admin/dashboard`.
+- Desktop implementation screenshot: `/private/tmp/xiaoshu-dashboard-desktop.png`.
+- Mobile implementation screenshot: `/private/tmp/xiaoshu-dashboard-mobile.png`.
+- Focused implementation crop: `/private/tmp/xiaoshu-dashboard-summary-desktop.png`.
+- Side-by-side comparison: `/private/tmp/xiaoshu-dashboard-comparison.png`.
+- Viewports: 1440 × 900 CSS px and 390 × 844 CSS px, device pixel ratio 1.
+- Focused implementation dimensions: 1082 × 94 px;桌面运营摘要实际 CSS 尺寸为 1082 × 94。
+- State: authenticated operations manager, weekly trend selected, realtime scheduling source available.
+- Density normalization: source crop resized to the focused implementation width and vertically centered;对比的是信息密度与层级,调整后结构按用户要求有意不再保持四卡布局。
+
+## Full-View Comparison
+
+- 调整前四张大卡片与折线图重复展示课程、学员、陪练和工资;调整后独立卡片区已移除,核心数据进入同一运营总览容器。
+- 桌面端在 1128 px 主内容宽度内形成单行摘要;页面宽度与视口同为 1440 px,没有横向页面溢出。
+- 手机端摘要为两列布局,页面宽度与视口同为 390 px;折线图保留 720 px 可滑动内容宽度,不挤压页面主体。
+
+## Focused Comparison
+
+- 对照图左侧为四张旧指标卡,右侧为新的紧凑运营摘要和趋势标题。
+- 所选日期、排课节数、学员人数、在岗陪练和预估工资按照“日期上下文 → 当日业务 → 人力与成本”的顺序排列。
+- 在岗陪练与工资仍保留可点击入口;课程与学员数字由当前折线点驱动,不再单独重复。
+
+## Findings
+
+- No actionable P0, P1, or P2 differences remain.
+- Fonts and typography: 沿用管理后台现有中文字体栈与字重;数字、标签、辅助信息层级清晰,在 390 px 下无截断或非预期换行。
+- Spacing and layout rhythm: 桌面摘要高度从四张 144 px 大卡片收敛为 94 px;手机端形成 1 个日期上下文区和 2 × 2 指标网格,间距与分隔线一致。
+- Colors and visual tokens: 延续绿色排课、蓝色学员、琥珀陪练和玫红工资语义色;背景、边框、圆角和焦点色与现有后台一致。
+- Image quality and asset fidelity: 本次无新增位图资产;箭头继续使用项目 Lucide 图标,折线保持矢量 SVG 清晰度。
+- Copy and content: 标题改为“运营数据总览”,说明文案明确覆盖排课、学员、人力与工资;数据源状态和日期范围仍保留。
+- Accessibility: 摘要链接具有业务化 aria-label;周/月/年可操作;折线点保留键盘焦点、左右键和 Enter/Space 操作。
+
+## Interaction And Responsive Verification
+
+- 周视图:7 个每日数据点,今日选择状态正确。
+- 月视图:在线数据成功返回并切换。
+- 年视图:在线数据按天返回 232 个数据点,可横向滑动。
+- 在岗陪练和本月预估工资链接保持可导航。
+- 桌面与手机控制台错误:0。
+- 管理后台专项测试:124 项通过;工作台聚焦测试:3 项通过。
+
+## Comparison History
+
+1. Earlier state: 四张独立指标卡占据大量首屏空间,并与折线图中的课程和学员数据重复。
+2. First fix: 删除独立指标卡,将五项摘要放入趋势容器顶部,并让课程、学员随选中数据点变化。
+3. Visual review finding: 在岗陪练链接前出现一段多余引号文本。
+4. Final fix: 清除模板中的多余文本,重新在 1440 × 900 和 390 × 844 捕获;无页面横向溢出、无控制台错误,摘要为预期四项业务指标加日期上下文。
+
+## Follow-up Polish
+
+- P3: 年视图包含较多每日数据点,当前通过横向滑动查看;未来可增加月份快速定位,但不影响本次运营总览目标。
+
+final result: passed
+
+---
+
 # Design QA: 排课中心筛选工具栏收紧
 
 ## Evidence

+ 124 - 25
src/app/admin/pages/admin-dashboard-trend.component.scss

@@ -70,23 +70,35 @@
 
 .trend-summary {
   display: grid;
-  grid-template-columns: 150px 150px minmax(220px, 1fr) auto;
+  grid-template-columns: minmax(128px, .9fr) repeat(4, minmax(126px, 1fr));
   align-items: stretch;
-  gap: 10px;
-  padding: 16px 22px 10px;
+  gap: 0;
+  margin: 18px 22px 10px;
+  overflow: hidden;
+  border: 1px solid #e3eae7;
+  border-radius: 14px;
+  background: #f9fbfa;
 }
 
-.trend-summary > div:not(.trend-legend) {
+.trend-summary > div,
+.trend-summary > a {
+  position: relative;
   display: grid;
   align-content: center;
-  min-height: 76px;
-  padding: 11px 14px;
-  border-radius: 12px;
-  background: #f7faf9;
+  min-height: 92px;
+  padding: 14px 18px;
+  border-right: 1px solid #e3eae7;
+  color: #21342e;
+  text-decoration: none;
+}
+
+.trend-summary > :last-child {
+  border-right: 0;
 }
 
 .trend-summary span,
-.trend-summary small {
+.trend-summary small,
+.trend-summary a small {
   color: #7b8984;
   font-size: 11px;
 }
@@ -94,23 +106,96 @@
 .trend-summary strong {
   margin-top: 2px;
   color: #21342e;
-  font-size: 24px;
+  font-size: 25px;
+  line-height: 1.15;
+}
+
+.trend-summary strong i {
+  margin-right: 2px;
+  font-size: .65em;
+  font-style: normal;
+  font-weight: 750;
 }
 
-.trend-summary .selected {
-  border: 1px solid #dcebe5;
-  background: #f1f8f5;
+.trend-summary .selected-date {
+  background: #eef7f3;
 }
 
-.trend-summary .selected strong {
+.trend-summary .selected-date strong {
   font-size: 17px;
 }
 
+.trend-summary .trend-stat::before {
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: #258b68;
+  content: '';
+}
+
+.trend-summary .trend-stat.students::before {
+  background: #4d83bd;
+}
+
+.trend-summary .trend-stat.coaches::before {
+  background: #c18a2c;
+}
+
+.trend-summary .trend-stat.pay::before {
+  background: #b45b72;
+}
+
+.trend-summary a {
+  padding-right: 40px;
+  transition: background-color .16s ease;
+}
+
+.trend-summary a:hover {
+  background: #f2f7f5;
+}
+
+.trend-summary a lucide-icon {
+  position: absolute;
+  right: 15px;
+  top: 50%;
+  color: #258b68;
+  transform: translateY(-50%);
+}
+
+.trend-summary a:focus-visible {
+  z-index: 1;
+  outline: 3px solid rgba(37, 139, 104, .22);
+  outline-offset: -3px;
+}
+
+.trend-chart-heading {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 12px 22px 5px;
+}
+
+.trend-chart-heading > div:first-child {
+  display: grid;
+  gap: 3px;
+}
+
+.trend-chart-heading strong {
+  color: #283b35;
+  font-size: 14px;
+}
+
+.trend-chart-heading > div:first-child span {
+  color: #84918d;
+  font-size: 11px;
+}
+
 .trend-legend {
   display: flex;
   align-items: center;
   gap: 14px;
-  padding: 0 4px;
+  padding: 0 4px 2px;
 }
 
 .trend-legend span {
@@ -271,12 +356,24 @@
     grid-template-columns: repeat(2, minmax(0, 1fr));
   }
 
-  .trend-summary .selected {
+  .trend-summary > div,
+  .trend-summary > a {
+    border-bottom: 1px solid #e3eae7;
+  }
+
+  .trend-summary .selected-date {
     grid-column: 1 / -1;
+    border-right: 0;
+  }
+
+  .trend-summary > :nth-child(3),
+  .trend-summary > :nth-child(5) {
+    border-right: 0;
   }
 
-  .trend-legend {
-    justify-content: flex-end;
+  .trend-summary > :nth-child(4),
+  .trend-summary > :nth-child(5) {
+    border-bottom: 0;
   }
 }
 
@@ -297,21 +394,23 @@
 
   .trend-summary {
     grid-template-columns: 1fr 1fr;
-    padding: 14px 16px 8px;
+    margin: 14px 16px 8px;
   }
 
-  .trend-summary > div:not(.trend-legend) {
-    min-height: 68px;
-    padding: 9px 11px;
+  .trend-summary > div,
+  .trend-summary > a {
+    min-height: 82px;
+    padding: 12px;
   }
 
   .trend-summary strong {
     font-size: 21px;
   }
 
-  .trend-legend {
-    grid-column: 1 / -1;
-    justify-content: flex-start;
+  .trend-chart-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    padding: 12px 16px 5px;
   }
 
   .trend-scroller {

+ 14 - 14
src/app/admin/pages/admin-dashboard.component.html

@@ -14,17 +14,9 @@
     <a routerLink="/admin/schedule">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
   </section>
 
-  <section class="metric-grid" aria-label="核心运营指标">
-    @for (metric of dashboard.metrics; track metric.key) {
-      <a [routerLink]="metric.route" class="metric-card" [class]="'metric-card ' + metric.tone">
-        <span>{{ metric.label }}</span><strong>{{ metric.value | number }}</strong><small>{{ metric.hint }}</small><lucide-icon [img]="icons.ArrowRight" [size]="18" />
-      </a>
-    }
-  </section>
-
-  <section class="trend-card" aria-labelledby="schedule-trend-title">
+  <section class="trend-card" aria-labelledby="schedule-trend-title" aria-describedby="schedule-trend-description">
     <header>
-      <div><span>SCHEDULE TREND</span><h2 id="schedule-trend-title">每日排课趋势</h2><p>滑动图表并选择数据点,查看每天的排课节数与学员人数</p></div>
+      <div><span>OPERATIONS PULSE</span><h2 id="schedule-trend-title">运营数据总览</h2><p id="schedule-trend-description">每日排课与学员趋势、陪练人力和预估工资统一查看</p></div>
       <div class="trend-periods" aria-label="排课趋势时间维度">
         <button type="button" [class.active]="trendPeriod()==='week'" (click)="selectTrendPeriod('week')">周</button>
         <button type="button" [class.active]="trendPeriod()==='month'" (click)="selectTrendPeriod('month')">月</button>
@@ -38,11 +30,19 @@
     }@else{
     @if(trend();as chart){
       <div class="trend-summary">
-        <div><span>今日排课</span><strong>{{chart.todayCourseCount}}</strong><small>节课程</small></div>
-        <div><span>今日学员</span><strong>{{chart.todayStudentCount}}</strong><small>位不同学员</small></div>
-        @if(selectedTrendPoint();as selected){<div class="selected"><span>{{selected.label}}</span><strong>{{selected.courseCount}} 节 · {{selected.studentCount}} 人</strong><small>已完课 {{selected.completedCount}} 节</small></div>}
-        <div class="trend-legend"><span class="courses">排课节数</span><span class="students">学员人数</span></div>
+        @if(selectedTrendPoint();as selected){
+          <div class="selected-date"><span>{{selected.isToday?'今日运营':'当日运营'}}</span><strong>{{selected.label}}</strong><small>已完课 {{selected.completedCount}} 节</small></div>
+          <div class="trend-stat courses"><span>{{selected.isToday?'今日排课':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
+          <div class="trend-stat students"><span>{{selected.isToday?'今日学员':'当日学员'}}</span><strong>{{selected.studentCount | number}}</strong><small>位不同学员</small></div>
+        }
+        @if(dashboardMetric('coaches');as coaches){
+          <a class="trend-stat coaches" [routerLink]="coaches.route" [attr.aria-label]="coaches.label+' '+coaches.value"><span>{{coaches.label}}</span><strong>{{coaches.value | number}}</strong><small>人力详情</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
+        }
+        @if(dashboardMetric('pay');as pay){
+          <a class="trend-stat pay" [routerLink]="pay.route" [attr.aria-label]="pay.label+' '+pay.value+' 元'"><span>{{pay.label}}</span><strong><i>¥</i>{{pay.value | number:'1.0-2'}}</strong><small>查看工资结算</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
+        }
       </div>
+      <div class="trend-chart-heading"><div><strong>每日排课趋势</strong><span>滑动图表或选择数据点查看每天数据</span></div><div class="trend-legend"><span class="courses">排课节数</span><span class="students">学员人数</span></div></div>
       <div #trendScroller class="trend-scroller" tabindex="0" aria-label="可横向滑动的每日排课折线图">
         <svg class="trend-chart" [attr.viewBox]="'0 0 '+chartWidth()+' 246'" [style.width.px]="chartWidth()" role="img" [attr.aria-label]="chart.periodLabel+'排课趋势,共 '+chart.totalCourseCount+' 节课程、'+chart.totalStudentCount+' 位学员'">
           @for(gridValue of gridValues();track $index){

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/admin-dashboard.component.scss


+ 21 - 1
src/app/admin/pages/admin-dashboard.component.spec.ts

@@ -1,4 +1,5 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsService } from '../operations.service';
 import { AdminDashboardComponent } from './admin-dashboard.component';
@@ -13,7 +14,12 @@ describe('AdminDashboardComponent', () => {
     sessions = jasmine.createSpyObj<AdminSessionService>('AdminSessionService', ['applyIdentity']);
     operations.dashboard.and.resolveTo({
       identity: { objectId: 'admin-1', username: 'ops', displayName: '运营经理', roles: ['admin', 'ops-manager'], isSuperAdmin: false, operationsRole: 'ops-manager' },
-      date: '2026-08-20', metrics: [], alerts: [], scheduleTrend: {
+      date: '2026-08-20', metrics: [
+        { key: 'members', label: '在学会员', value: 27, hint: '当前有正式学习任务', route: '/admin/members', tone: 'blue' },
+        { key: 'today', label: '今日课程', value: 37, hint: '今天计划进行的课程', route: '/admin/schedule', tone: 'amber' },
+        { key: 'coaches', label: '在岗陪练', value: 39, hint: '实时业务账号', route: '/admin/coaches', tone: 'green' },
+        { key: 'pay', label: '本月预估工资', value: 120, hint: '按有效上课记录估算', route: '/admin/payroll', tone: 'rose' }
+      ], alerts: [], scheduleTrend: {
         period: 'week', periodLabel: '近 7 天', dateFrom: '2026-08-14', dateTo: '2026-08-20',
         points: Array.from({ length: 7 }, (_, index) => ({ key: `2026-08-${14 + index}`, label: `8月${14 + index}日`, shortLabel: `周${index}`, courseCount: index + 1, studentCount: index, completedCount: index, isToday: index === 6 })),
         todayCourseCount: 7, todayStudentCount: 6, totalCourseCount: 28, totalStudentCount: 12, maxValue: 7,
@@ -27,6 +33,7 @@ describe('AdminDashboardComponent', () => {
       refreshedAt: '2026-08-20T04:00:00.000Z', liveSourceAvailable: true, liveSourceMessage: ''
     });
     await TestBed.configureTestingModule({ imports: [AdminDashboardComponent], providers: [
+      provideRouter([]),
       { provide: OperationsService, useValue: operations },
       { provide: AdminSessionService, useValue: sessions }
     ] }).compileComponents();
@@ -49,4 +56,17 @@ describe('AdminDashboardComponent', () => {
     expect(fixture.componentInstance.trend()?.period).toBe('month');
     expect(fixture.componentInstance.chartLine('courseCount')).toContain(',');
   });
+
+  it('merges daily course data into the trend and keeps only coach and pay overview links', async () => {
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    const element: HTMLElement = fixture.nativeElement;
+    expect(element.querySelector('.metric-grid')).toBeNull();
+    expect(element.querySelector('.trend-stat.coaches')?.textContent).toContain('39');
+    expect(element.querySelector('.trend-stat.pay')?.textContent).toContain('120');
+    expect(element.querySelector('.trend-stat.courses')?.textContent).toContain('7');
+    expect(element.querySelector('.trend-stat.students')?.textContent).toContain('6');
+    expect(element.querySelectorAll('.trend-stat')).toHaveSize(4);
+  });
 });

+ 2 - 1
src/app/admin/pages/admin-dashboard.component.ts

@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
 import { RouterLink } from '@angular/router';
 import { ArrowRight, CircleAlert, LoaderCircle, LucideAngularModule, RefreshCw } from 'lucide-angular';
-import { OperationsDashboardData, ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
+import { OperationsDashboardData, OperationsDashboardMetric, ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { AdminSessionService } from '../admin-session.service';
 
@@ -60,6 +60,7 @@ export class AdminDashboardComponent implements OnInit {
   chartY(value: number): number { return 204 - (value / Math.max(1, this.trend()?.maxValue ?? 1)) * 148; }
   chartLine(metric: 'courseCount' | 'studentCount'): string { return (this.trend()?.points ?? []).map((point, index) => `${this.chartX(index)},${this.chartY(point[metric])}`).join(' '); }
   gridValues(): number[] { const max = Math.max(1, this.trend()?.maxValue ?? 1); return [max, Math.round(max * .75), Math.round(max * .5), Math.round(max * .25), 0]; }
+  dashboardMetric(key: string): OperationsDashboardMetric | null { return this.data()?.metrics.find((metric) => metric.key === key) ?? null; }
 
   private async loadTrend(period: ScheduleTrendPeriod): Promise<void> {
     this.trendLoading.set(true); this.trendError.set('');

Некоторые файлы не были показаны из-за большого количества измененных файлов