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

fix(admin): refine dashboard trend layout and responsive chart

彭峰 2 дней назад
Родитель
Сommit
f752fc0be9

+ 43 - 21
projects/xiaoshu-admin/src/app/pages/admin-dashboard-trend.component.scss

@@ -16,6 +16,34 @@
   border-bottom: 1px solid #edf1ef;
   border-bottom: 1px solid #edf1ef;
 }
 }
 
 
+.trend-header-actions {
+  display: grid;
+  justify-items: end;
+  gap: 9px;
+}
+
+.selected-day {
+  display: flex;
+  align-items: baseline;
+  justify-content: flex-end;
+  gap: 7px;
+  color: #758780;
+  white-space: nowrap;
+}
+
+.selected-day span,
+.selected-day small {
+  color: #758780;
+  font-size: 11px;
+  font-weight: 500;
+  letter-spacing: 0;
+}
+
+.selected-day strong {
+  color: #176c55;
+  font-size: 14px;
+}
+
 .trend-card > header span {
 .trend-card > header span {
   color: #258b68;
   color: #258b68;
   font-size: 10px;
   font-size: 10px;
@@ -70,7 +98,7 @@
 
 
 .trend-summary {
 .trend-summary {
   display: grid;
   display: grid;
-  grid-template-columns: minmax(128px, .9fr) repeat(4, minmax(126px, 1fr));
+  grid-template-columns: repeat(4, minmax(0, 1fr));
   align-items: stretch;
   align-items: stretch;
   gap: 0;
   gap: 0;
   margin: 18px 22px 10px;
   margin: 18px 22px 10px;
@@ -117,14 +145,6 @@
   font-weight: 750;
   font-weight: 750;
 }
 }
 
 
-.trend-summary .selected-date {
-  background: #eef7f3;
-}
-
-.trend-summary .selected-date strong {
-  font-size: 17px;
-}
-
 .trend-summary .trend-stat::before {
 .trend-summary .trend-stat::before {
   position: absolute;
   position: absolute;
   inset: 0 auto 0 0;
   inset: 0 auto 0 0;
@@ -220,7 +240,7 @@
 .trend-scroller {
 .trend-scroller {
   overflow-x: auto;
   overflow-x: auto;
   overflow-y: hidden;
   overflow-y: hidden;
-  margin: 0 12px;
+  margin: 0 22px;
   border-radius: 12px;
   border-radius: 12px;
   cursor: grab;
   cursor: grab;
   overscroll-behavior-inline: contain;
   overscroll-behavior-inline: contain;
@@ -244,8 +264,7 @@
 
 
 .trend-chart {
 .trend-chart {
   display: block;
   display: block;
-  height: 246px;
-  min-width: 100%;
+  height: 290px;
 }
 }
 
 
 .grid-line {
 .grid-line {
@@ -361,18 +380,11 @@
     border-bottom: 1px solid #e3eae7;
     border-bottom: 1px solid #e3eae7;
   }
   }
 
 
-  .trend-summary .selected-date {
-    grid-column: 1 / -1;
+  .trend-summary > :nth-child(even) {
     border-right: 0;
     border-right: 0;
   }
   }
 
 
-  .trend-summary > :nth-child(3),
-  .trend-summary > :nth-child(5) {
-    border-right: 0;
-  }
-
-  .trend-summary > :nth-child(4),
-  .trend-summary > :nth-child(5) {
+  .trend-summary > :nth-child(n+3) {
     border-bottom: 0;
     border-bottom: 0;
   }
   }
 }
 }
@@ -388,6 +400,16 @@
     width: 100%;
     width: 100%;
   }
   }
 
 
+  .trend-header-actions {
+    justify-items: stretch;
+  }
+
+  .selected-day {
+    justify-content: flex-start;
+    flex-wrap: wrap;
+    white-space: normal;
+  }
+
   .trend-periods button {
   .trend-periods button {
     flex: 1;
     flex: 1;
   }
   }

+ 13 - 9
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.html

@@ -18,10 +18,15 @@
   <section class="trend-card" aria-labelledby="schedule-trend-title" aria-describedby="schedule-trend-description">
   <section class="trend-card" aria-labelledby="schedule-trend-title" aria-describedby="schedule-trend-description">
     <header>
     <header>
       <div><span>OPERATIONS PULSE</span><h2 id="schedule-trend-title">运营数据总览</h2><p id="schedule-trend-description">每日排课与学员趋势、陪练人力和预估工资统一查看</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>
-        <button type="button" [class.active]="trendPeriod()==='year'" (click)="selectTrendPeriod('year')">年</button>
+      <div class="trend-header-actions">
+        <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>
+          <button type="button" [class.active]="trendPeriod()==='year'" (click)="selectTrendPeriod('year')">年</button>
+        </div>
+        @if(selectedTrendPoint();as selected){
+          <div class="selected-day"><span>{{selected.isToday?'今日运营':'当日运营'}}</span><strong>{{selected.label}}</strong><small>已完课 {{selected.completedCount}} 节</small></div>
+        }
       </div>
       </div>
     </header>
     </header>
     @if(trendLoading()){
     @if(trendLoading()){
@@ -32,7 +37,6 @@
     @if(trend();as chart){
     @if(trend();as chart){
       <div class="trend-summary overview-metrics">
       <div class="trend-summary overview-metrics">
         @if(selectedTrendPoint();as selected){
         @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 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>
           <div class="trend-stat students"><span>{{selected.isToday?'今日学员':'当日学员'}}</span><strong>{{selected.studentCount | number}}</strong><small>位不同学员</small></div>
         }
         }
@@ -45,7 +49,7 @@
       </div>
       </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 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="可横向滑动的每日排课折线图">
       <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+' 位学员'">
+        <svg class="trend-chart" [attr.viewBox]="'0 0 '+chartWidth()+' 290'" [style.width.px]="chartWidth()" role="img" [attr.aria-label]="chart.periodLabel+'排课趋势,共 '+chart.totalCourseCount+' 节课程、'+chart.totalStudentCount+' 位学员'">
           @for(gridValue of gridValues();track $index){
           @for(gridValue of gridValues();track $index){
             <line class="grid-line" x1="54" [attr.y1]="chartY(gridValue)" [attr.x2]="chartWidth()-36" [attr.y2]="chartY(gridValue)"/>
             <line class="grid-line" x1="54" [attr.y1]="chartY(gridValue)" [attr.x2]="chartWidth()-36" [attr.y2]="chartY(gridValue)"/>
             <text class="axis-label" x="42" [attr.y]="chartY(gridValue)+4" text-anchor="end">{{gridValue}}</text>
             <text class="axis-label" x="42" [attr.y]="chartY(gridValue)+4" text-anchor="end">{{gridValue}}</text>
@@ -53,13 +57,13 @@
           <polyline class="trend-line students-line" fill="none" [attr.points]="chartLine('studentCount')"/>
           <polyline class="trend-line students-line" fill="none" [attr.points]="chartLine('studentCount')"/>
           <polyline class="trend-line courses-line" fill="none" [attr.points]="chartLine('courseCount')"/>
           <polyline class="trend-line courses-line" fill="none" [attr.points]="chartLine('courseCount')"/>
           @for(point of chart.points;track point.key;let index=$index){
           @for(point of chart.points;track point.key;let index=$index){
-            @if(selectedTrendIndex()===index){<line class="selected-guide" [attr.x1]="chartX(index)" y1="48" [attr.x2]="chartX(index)" y2="214"/>}
+            @if(selectedTrendIndex()===index){<line class="selected-guide" [attr.x1]="chartX(index)" y1="50" [attr.x2]="chartX(index)" y2="248"/>}
             <g class="trend-point" [class.selected]="selectedTrendIndex()===index" tabindex="0" role="button" [attr.aria-label]="point.label+',排课'+point.courseCount+'节,学员'+point.studentCount+'人'" (mouseenter)="selectTrendPoint(index)" (click)="selectTrendPoint(index)" (keydown)="selectTrendPointByKey($event,index)">
             <g class="trend-point" [class.selected]="selectedTrendIndex()===index" tabindex="0" role="button" [attr.aria-label]="point.label+',排课'+point.courseCount+'节,学员'+point.studentCount+'人'" (mouseenter)="selectTrendPoint(index)" (click)="selectTrendPoint(index)" (keydown)="selectTrendPointByKey($event,index)">
-              <rect class="point-column" [attr.x]="chartX(index)-26" y="44" width="52" height="190" rx="8"/>
+              <rect class="point-column" [attr.x]="chartX(index)-26" y="48" width="52" height="222" rx="8"/>
               <circle class="point-hit" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="17"/>
               <circle class="point-hit" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="17"/>
               <circle class="student-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.studentCount)" r="4"/>
               <circle class="student-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.studentCount)" r="4"/>
               <circle class="course-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="5"/>
               <circle class="course-point" [attr.cx]="chartX(index)" [attr.cy]="chartY(point.courseCount)" r="5"/>
-              <text class="date-label" [attr.x]="chartX(index)" y="232" text-anchor="middle">{{point.shortLabel}}</text>
+              <text class="date-label" [attr.x]="chartX(index)" y="270" text-anchor="middle">{{point.shortLabel}}</text>
             </g>
             </g>
           }
           }
         </svg>
         </svg>

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


+ 16 - 4
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.ts

@@ -16,7 +16,18 @@ import { OperationsLiveRefreshService } from '../operations-live-refresh.service
   changeDetection: ChangeDetectionStrategy.OnPush,
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 })
 export class AdminDashboardComponent implements OnInit, OnDestroy {
 export class AdminDashboardComponent implements OnInit, OnDestroy {
-  @ViewChild('trendScroller') private trendScroller?: ElementRef<HTMLElement>;
+  @ViewChild('trendScroller') set trendScrollerElement(element: ElementRef<HTMLElement> | undefined) {
+    this.chartResizeObserver?.disconnect();
+    this.trendScroller = element;
+    if (!element) return;
+    this.chartViewportWidth.set(element.nativeElement.clientWidth);
+    this.chartResizeObserver = new ResizeObserver(([entry]) => {
+      this.chartViewportWidth.set(Math.round(entry.contentRect.width));
+    });
+    this.chartResizeObserver.observe(element.nativeElement);
+  }
+  private trendScroller?: ElementRef<HTMLElement>;
+  private chartResizeObserver?: ResizeObserver;
   readonly data = signal<OperationsDashboardData | null>(null);
   readonly data = signal<OperationsDashboardData | null>(null);
   readonly loading = signal(true);
   readonly loading = signal(true);
   readonly error = signal('');
   readonly error = signal('');
@@ -26,7 +37,8 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
   readonly trendError = signal('');
   readonly trendError = signal('');
   readonly selectedTrendIndex = signal(0);
   readonly selectedTrendIndex = signal(0);
   readonly selectedTrendPoint = computed(() => this.trend()?.points[this.selectedTrendIndex()] ?? null);
   readonly selectedTrendPoint = computed(() => this.trend()?.points[this.selectedTrendIndex()] ?? null);
-  readonly chartWidth = computed(() => Math.max(720, (this.trend()?.points.length ?? 0) * 58 + 84));
+  readonly chartViewportWidth = signal(0);
+  readonly chartWidth = computed(() => Math.max(720, this.chartViewportWidth(), (this.trend()?.points.length ?? 0) * 58 + 84));
   readonly icons = { ArrowRight, CircleAlert, LoaderCircle, RefreshCw };
   readonly icons = { ArrowRight, CircleAlert, LoaderCircle, RefreshCw };
   private readonly operations = inject(OperationsService);
   private readonly operations = inject(OperationsService);
   private readonly liveRefresh = inject(OperationsLiveRefreshService);
   private readonly liveRefresh = inject(OperationsLiveRefreshService);
@@ -42,7 +54,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
     } else void this.load();
     } else void this.load();
     this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'review', 'account'], () => { if (this.loading() || this.trendLoading()) return false; void this.load(true); return true; });
     this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'review', 'account'], () => { if (this.loading() || this.trendLoading()) return false; void this.load(true); return true; });
   }
   }
-  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); this.chartResizeObserver?.disconnect(); }
 
 
   onOperationsRouteAttach(): void {
   onOperationsRouteAttach(): void {
     const snapshot = this.operations.dashboardSnapshot();
     const snapshot = this.operations.dashboardSnapshot();
@@ -80,7 +92,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
   }
   }
 
 
   chartX(index: number): number { const count = this.trend()?.points.length ?? 0; return 54 + index * ((this.chartWidth() - 90) / Math.max(1, count - 1)); }
   chartX(index: number): number { const count = this.trend()?.points.length ?? 0; return 54 + index * ((this.chartWidth() - 90) / Math.max(1, count - 1)); }
-  chartY(value: number): number { return 204 - (value / Math.max(1, this.trend()?.maxValue ?? 1)) * 148; }
+  chartY(value: number): number { return 238 - (value / Math.max(1, this.trend()?.maxValue ?? 1)) * 176; }
   chartLine(metric: 'courseCount' | 'studentCount'): string { return (this.trend()?.points ?? []).map((point, index) => `${this.chartX(index)},${this.chartY(point[metric])}`).join(' '); }
   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]; }
   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; }
   dashboardMetric(key: string): OperationsDashboardMetric | null { return this.data()?.metrics.find((metric) => metric.key === key) ?? null; }

+ 2 - 9
projects/xiaoshu-admin/src/app/pages/operations-overview-metrics.scss

@@ -141,18 +141,11 @@ app-operations-learning-reports .report-metrics.overview-metrics > article > sma
   overflow-wrap: normal;
   overflow-wrap: normal;
 }
 }
 
 
-/* The selected day is context; the four live figures are the metrics. */
+/* The selected day sits beside the period control; the four figures share one row. */
 app-admin-dashboard .trend-summary.overview-metrics {
 app-admin-dashboard .trend-summary.overview-metrics {
   grid-template-columns: repeat(4, minmax(0, 1fr));
   grid-template-columns: repeat(4, minmax(0, 1fr));
 }
 }
 
 
-app-admin-dashboard .trend-summary.overview-metrics .selected-date {
-  grid-column: 1 / -1;
-  min-height: 58px;
-  border-right: 0;
-  border-bottom: 1px solid #e3eae7;
-}
-
 app-admin-dashboard .trend-summary.overview-metrics .trend-stat {
 app-admin-dashboard .trend-summary.overview-metrics .trend-stat {
   display: grid;
   display: grid;
   grid-template-columns: minmax(0, 1fr) auto;
   grid-template-columns: minmax(0, 1fr) auto;
@@ -222,7 +215,7 @@ app-admin-dashboard .welcome h2 {
     border-right: 0;
     border-right: 0;
   }
   }
 
 
-  app-admin-dashboard .trend-summary.overview-metrics .trend-stat:nth-child(-n+3) {
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat:nth-child(-n+2) {
     border-bottom: 1px solid #e3eae7;
     border-bottom: 1px solid #e3eae7;
   }
   }
 }
 }

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