Browse Source

feat(admin): add scheduling trend chart

彭峰 1 tháng trước cách đây
mục cha
commit
ebbcc17de1

+ 14 - 1
scripts/deploy-admin-functions.mjs

@@ -2895,11 +2895,24 @@ async function professionalScheduleDataLocal(context,input) {
   }
   const unassigned=appointments.filter((row)=>!row.coachId);return{...data,refreshedAt:new Date().toISOString(),totalAppointments:Math.max(history.totalAppointments,live.total),dataDateFrom:history.dataDateFrom,dataDateTo:history.dataDateTo,appointments,unassigned,coaches:coaches.sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),liveSourceAvailable:live.available,liveSourceMessage:live.message,liveSourceCount:hydratedLive.length};
 }
-async function professionalDashboard(context,input) { const data=await dashboard(context,input),date=businessDate(),snapshot=await appointmentSnapshot(context,input,date),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&businessDate(row.startsAt)===date),coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},snapshot),estimatedPay=coachPage.items.reduce((sum,row)=>sum+number(row.estimatedPay),0),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row.key==='pay'?{...row,value:estimatedPay}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message}; }
+function addBusinessDays(dateKey,days) { return businessDate(new Date(new Date(dateKey+'T12:00:00+08:00').getTime()+days*86400000)); }
+function scheduleTrendRange(period,today=businessDate()) { if(!['week','month','year'].includes(period))fail(400,'排课趋势时间维度无效');if(period==='week')return{dateFrom:addBusinessDays(today,-6),dateTo:today,periodLabel:'近 7 天'};if(period==='month')return{dateFrom:today.slice(0,8)+'01',dateTo:today,periodLabel:'本月'};return{dateFrom:today.slice(0,4)+'-01-01',dateTo:today,periodLabel:'本年'}; }
+function scheduleTrendFromSnapshot(snapshot,period,today=businessDate()) {
+  const range=scheduleTrendRange(period,today),buckets=[],weekdays=['周日','周一','周二','周三','周四','周五','周六'];
+  if(period==='year'){const year=today.slice(0,4),monthCount=number(today.slice(5,7));for(let month=1;month<=monthCount;month++){const key=year+'-'+String(month).padStart(2,'0');buckets.push({key,label:year+'年'+month+'月',shortLabel:month+'月'});}}
+  else{for(let key=range.dateFrom;key<=range.dateTo;key=addBusinessDays(key,1)){const month=number(key.slice(5,7)),day=number(key.slice(8,10)),weekday=new Date(key+'T12:00:00+08:00').getDay();buckets.push({key,label:month+'月'+day+'日',shortLabel:period==='week'?weekdays[weekday]:day+'日'});}}
+  const byKey=new Map(buckets.map((bucket)=>[bucket.key,{courseCount:0,completedCount:0,students:new Set()}])),allStudents=new Set(),todayStudents=new Set();let todayCourseCount=0;
+  for(const row of snapshot.appointments){if(row.cancelled||!row.startsAt)continue;const date=businessDate(row.startsAt);if(date<range.dateFrom||date>range.dateTo)continue;const key=period==='year'?date.slice(0,7):date,bucket=byKey.get(key);if(!bucket)continue;bucket.courseCount++;if(row.statusCode>=30)bucket.completedCount++;if(row.studentId){bucket.students.add(row.studentId);allStudents.add(row.studentId);}if(date===today){todayCourseCount++;if(row.studentId)todayStudents.add(row.studentId);}}
+  const points=buckets.map((bucket)=>{const value=byKey.get(bucket.key);return{...bucket,courseCount:value.courseCount,studentCount:value.students.size,completedCount:value.completedCount,isToday:period==='year'?bucket.key===today.slice(0,7):bucket.key===today};}),totalCourseCount=points.reduce((sum,point)=>sum+point.courseCount,0),maxValue=Math.max(1,...points.flatMap((point)=>[point.courseCount,point.studentCount]));
+  return{period,periodLabel:range.periodLabel,dateFrom:range.dateFrom,dateTo:range.dateTo,points,todayCourseCount,todayStudentCount:todayStudents.size,totalCourseCount,totalStudentCount:allStudents.size,maxValue,refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message};
+}
+async function scheduleTrendData(context,input) { const period=text(input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),snapshot=await appointmentSnapshot(context,input,range.dateFrom);return scheduleTrendFromSnapshot(snapshot,period,today); }
+async function professionalDashboard(context,input) { const data=await dashboard(context,input),date=businessDate(),range=scheduleTrendRange('week',date),snapshot=await appointmentSnapshot(context,input,range.dateFrom),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&row.startsAt&&businessDate(row.startsAt)===date),currentLiveRows=snapshot.live.allItems.filter((row)=>row.startsAt&&businessDate(row.startsAt)>=date),coachSnapshot={...snapshot,live:{...snapshot.live,allItems:currentLiveRows},appointments:mergeAppointments(snapshot.stored,currentLiveRows)},coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},coachSnapshot),estimatedPay=coachPage.items.reduce((sum,row)=>sum+number(row.estimatedPay),0),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row.key==='pay'?{...row,value:estimatedPay}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,scheduleTrend:scheduleTrendFromSnapshot(snapshot,'week',date),refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message}; }
 async function handler(request,response) {
   try {
     const input=inputOf(request); const operation=text(input.operation); const context=await auth(request); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};
     if(operation==='ops/dashboard/summary') return response.json({success:true,data:await professionalDashboard(context,input)});
+    if(operation==='ops/dashboard/schedule-trend') return response.json({success:true,data:await scheduleTrendData(context,input)});
     if(operation==='ops/coaches/list') return response.json({success:true,data:await professionalCoachRows(context,input)});
     if(operation==='ops/coaches/detail') return response.json({success:true,data:await professionalCoachDetail(context,input,text(input.objectId))});
     if(operation==='ops/coaches/create') return response.json({success:true,data:await createCoach(context,input,payload)});

+ 5 - 0
scripts/smoke-operations-functions.mjs

@@ -40,6 +40,11 @@ try {
   const ops = (params, expectedStatus = 200) => call('xiaoshu/ops/gateway-v3', token, params, expectedStatus);
   const dashboard = (await ops({ operation: 'ops/dashboard/summary' })).data;
   if (dashboard.identity?.operationsRole !== 'ops-manager' || !Array.isArray(dashboard.metrics) || dashboard.metrics.length < 4) throw new Error('运营工作台返回结构无效');
+  if (dashboard.scheduleTrend?.period !== 'week' || dashboard.scheduleTrend?.points?.length !== 7) throw new Error('工作台没有返回最近 7 天排课趋势');
+  for (const period of ['month', 'year']) {
+    const trend = (await ops({ operation: 'ops/dashboard/schedule-trend', period })).data;
+    if (trend.period !== period || !trend.points?.length || typeof trend.todayCourseCount !== 'number' || typeof trend.todayStudentCount !== 'number' || !trend.refreshedAt) throw new Error(`${period} 排课趋势返回结构无效`);
+  }
 
   const users = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
   if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');

+ 29 - 0
src/app/admin/admin.models.ts

@@ -117,6 +117,34 @@ export interface OperationsDashboardMetric {
   tone: 'blue' | 'green' | 'amber' | 'rose';
 }
 
+export type ScheduleTrendPeriod = 'week' | 'month' | 'year';
+
+export interface ScheduleTrendPoint {
+  key: string;
+  label: string;
+  shortLabel: string;
+  courseCount: number;
+  studentCount: number;
+  completedCount: number;
+  isToday: boolean;
+}
+
+export interface ScheduleTrendData {
+  period: ScheduleTrendPeriod;
+  periodLabel: string;
+  dateFrom: string;
+  dateTo: string;
+  points: ScheduleTrendPoint[];
+  todayCourseCount: number;
+  todayStudentCount: number;
+  totalCourseCount: number;
+  totalStudentCount: number;
+  maxValue: number;
+  refreshedAt: string;
+  liveSourceAvailable: boolean;
+  liveSourceMessage: string;
+}
+
 export interface OperationsDashboardData {
   identity: AdminIdentity;
   date: string;
@@ -125,6 +153,7 @@ export interface OperationsDashboardData {
   refreshedAt?: string;
   liveSourceAvailable?: boolean;
   liveSourceMessage?: string;
+  scheduleTrend?: ScheduleTrendData;
 }
 
 export interface OperationsUser extends Record<string, unknown> {

+ 5 - 1
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -12,6 +12,10 @@ export class OperationsService {
     return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');
   }
 
+  scheduleTrend(period: ScheduleTrendPeriod): Promise<ScheduleTrendData> {
+    return this.functions.operations<ScheduleTrendData>('ops/dashboard/schedule-trend', { period });
+  }
+
   request<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
     return this.functions.operations<T>(operation, params);
   }

+ 325 - 0
src/app/admin/pages/admin-dashboard-trend.component.scss

@@ -0,0 +1,325 @@
+.trend-card {
+  margin-top: 20px;
+  overflow: hidden;
+  border: 1px solid #e3e9e7;
+  border-radius: 18px;
+  background: #fff;
+  box-shadow: 0 10px 32px rgba(25, 58, 48, .05);
+}
+
+.trend-card > header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 21px 22px 17px;
+  border-bottom: 1px solid #edf1ef;
+}
+
+.trend-card > header span {
+  color: #258b68;
+  font-size: 10px;
+  font-weight: 850;
+  letter-spacing: .16em;
+}
+
+.trend-card h2 {
+  margin: 5px 0 4px;
+  font-size: 19px;
+}
+
+.trend-card header p {
+  margin: 0;
+  color: #82908b;
+  font-size: 12px;
+}
+
+.trend-periods {
+  display: flex;
+  flex: 0 0 auto;
+  gap: 4px;
+  padding: 4px;
+  border-radius: 12px;
+  background: #edf3f0;
+}
+
+.trend-periods button {
+  min-width: 50px;
+  min-height: 36px;
+  padding: 0 14px;
+  border: 0;
+  border-radius: 9px;
+  color: #66756f;
+  background: transparent;
+  font-weight: 750;
+  cursor: pointer;
+}
+
+.trend-periods button.active {
+  color: #176c55;
+  background: #fff;
+  box-shadow: 0 3px 10px rgba(26, 74, 58, .12);
+}
+
+.trend-periods button:focus-visible,
+.trend-scroller:focus-visible,
+.trend-point:focus-visible {
+  outline: 3px solid rgba(37, 139, 104, .2);
+  outline-offset: 2px;
+}
+
+.trend-summary {
+  display: grid;
+  grid-template-columns: 150px 150px minmax(220px, 1fr) auto;
+  align-items: stretch;
+  gap: 10px;
+  padding: 16px 22px 10px;
+}
+
+.trend-summary > div:not(.trend-legend) {
+  display: grid;
+  align-content: center;
+  min-height: 76px;
+  padding: 11px 14px;
+  border-radius: 12px;
+  background: #f7faf9;
+}
+
+.trend-summary span,
+.trend-summary small {
+  color: #7b8984;
+  font-size: 11px;
+}
+
+.trend-summary strong {
+  margin-top: 2px;
+  color: #21342e;
+  font-size: 24px;
+}
+
+.trend-summary .selected {
+  border: 1px solid #dcebe5;
+  background: #f1f8f5;
+}
+
+.trend-summary .selected strong {
+  font-size: 17px;
+}
+
+.trend-legend {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 0 4px;
+}
+
+.trend-legend span {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  white-space: nowrap;
+}
+
+.trend-legend span::before {
+  width: 18px;
+  height: 3px;
+  border-radius: 999px;
+  background: #258b68;
+  content: '';
+}
+
+.trend-legend .students::before {
+  background: #4d83bd;
+}
+
+.trend-scroller {
+  overflow-x: auto;
+  overflow-y: hidden;
+  margin: 0 12px;
+  border-radius: 12px;
+  cursor: grab;
+  overscroll-behavior-inline: contain;
+  scroll-snap-type: x proximity;
+  touch-action: pan-x;
+}
+
+.trend-scroller:active {
+  cursor: grabbing;
+}
+
+.trend-scroller::-webkit-scrollbar {
+  height: 8px;
+}
+
+.trend-scroller::-webkit-scrollbar-thumb {
+  border: 2px solid #fff;
+  border-radius: 999px;
+  background: #cbd8d3;
+}
+
+.trend-chart {
+  display: block;
+  height: 246px;
+  min-width: 100%;
+}
+
+.grid-line {
+  stroke: #e8eeeb;
+  stroke-width: 1;
+}
+
+.axis-label,
+.date-label {
+  fill: #899792;
+  font-size: 10px;
+  user-select: none;
+}
+
+.trend-line {
+  stroke-linecap: round;
+  stroke-linejoin: round;
+  stroke-width: 3;
+}
+
+.courses-line {
+  stroke: #258b68;
+}
+
+.students-line {
+  stroke: #4d83bd;
+  stroke-dasharray: 6 5;
+  stroke-width: 2.5;
+}
+
+.selected-guide {
+  stroke: #b9cec6;
+  stroke-dasharray: 3 4;
+}
+
+.point-column,
+.point-hit {
+  fill: transparent;
+}
+
+.trend-point {
+  cursor: pointer;
+  scroll-snap-align: center;
+}
+
+.course-point {
+  fill: #fff;
+  stroke: #258b68;
+  stroke-width: 3;
+}
+
+.student-point {
+  fill: #fff;
+  stroke: #4d83bd;
+  stroke-width: 2.5;
+}
+
+.trend-point.selected .course-point,
+.trend-point:hover .course-point {
+  fill: #258b68;
+  stroke: #dff2eb;
+  stroke-width: 5;
+}
+
+.trend-point.selected .student-point,
+.trend-point:hover .student-point {
+  fill: #4d83bd;
+}
+
+.trend-point.selected .date-label {
+  fill: #176c55;
+  font-weight: 800;
+}
+
+.trend-card > footer {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 10px 22px 16px;
+  color: #84918d;
+  font-size: 11px;
+}
+
+.trend-state {
+  display: flex;
+  min-height: 280px;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  color: #7b8984;
+}
+
+.trend-state.error {
+  color: #a33c2c;
+}
+
+.trend-state button {
+  min-height: 34px;
+  padding: 0 12px;
+  border: 1px solid #e0d5d1;
+  border-radius: 9px;
+  color: inherit;
+  background: #fff;
+}
+
+@media (max-width: 900px) {
+  .trend-summary {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .trend-summary .selected {
+    grid-column: 1 / -1;
+  }
+
+  .trend-legend {
+    justify-content: flex-end;
+  }
+}
+
+@media (max-width: 620px) {
+  .trend-card > header {
+    align-items: stretch;
+    flex-direction: column;
+    padding: 18px 16px 14px;
+  }
+
+  .trend-periods {
+    width: 100%;
+  }
+
+  .trend-periods button {
+    flex: 1;
+  }
+
+  .trend-summary {
+    grid-template-columns: 1fr 1fr;
+    padding: 14px 16px 8px;
+  }
+
+  .trend-summary > div:not(.trend-legend) {
+    min-height: 68px;
+    padding: 9px 11px;
+  }
+
+  .trend-summary strong {
+    font-size: 21px;
+  }
+
+  .trend-legend {
+    grid-column: 1 / -1;
+    justify-content: flex-start;
+  }
+
+  .trend-scroller {
+    margin: 0 4px;
+  }
+
+  .trend-card > footer {
+    flex-direction: column;
+    padding: 8px 16px 14px;
+  }
+}

+ 46 - 0
src/app/admin/pages/admin-dashboard.component.html

@@ -22,6 +22,52 @@
     }
   </section>
 
+  <section class="trend-card" aria-labelledby="schedule-trend-title">
+    <header>
+      <div><span>SCHEDULE TREND</span><h2 id="schedule-trend-title">每日排课趋势</h2><p>滑动图表并选择数据点,查看每天的排课节数与学员人数</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>
+    </header>
+    @if(trendLoading()){
+      <div class="trend-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24"/>正在汇总排课趋势…</div>
+    }@else if(trendError()){
+      <div class="trend-state error" role="alert">{{trendError()}}<button type="button" (click)="selectTrendPeriod(trendPeriod())">重新加载</button></div>
+    }@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>
+      </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){
+            <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>
+          }
+          <polyline class="trend-line students-line" fill="none" [attr.points]="chartLine('studentCount')"/>
+          <polyline class="trend-line courses-line" fill="none" [attr.points]="chartLine('courseCount')"/>
+          @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"/>}
+            <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"/>
+              <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="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>
+            </g>
+          }
+        </svg>
+      </div>
+      <footer><span>{{chart.dateFrom}} 至 {{chart.dateTo}}</span><span>{{chart.liveSourceAvailable?'实时排课已同步':'当前使用迁移库备用数据'}}</span></footer>
+    }@else{<div class="trend-state">暂无排课趋势数据</div>}
+    }
+  </section>
+
   <section class="attention">
     <header><div><lucide-icon [img]="icons.CircleAlert" [size]="20" /><h2>需要关注</h2></div><span>点击指标进入对应处理页面</span></header>
     <div>

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

@@ -9,11 +9,22 @@ describe('AdminDashboardComponent', () => {
   let sessions: jasmine.SpyObj<AdminSessionService>;
 
   beforeEach(async () => {
-    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['dashboard']);
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['dashboard', 'scheduleTrend']);
     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: []
+      date: '2026-08-20', metrics: [], 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,
+        refreshedAt: '2026-08-20T04:00:00.000Z', liveSourceAvailable: true, liveSourceMessage: ''
+      }
+    });
+    operations.scheduleTrend.and.resolveTo({
+      period: 'month', periodLabel: '本月', dateFrom: '2026-08-01', dateTo: '2026-08-20',
+      points: [{ key: '2026-08-20', label: '8月20日', shortLabel: '20日', courseCount: 12, studentCount: 9, completedCount: 5, isToday: true }],
+      todayCourseCount: 12, todayStudentCount: 9, totalCourseCount: 12, totalStudentCount: 9, maxValue: 12,
+      refreshedAt: '2026-08-20T04:00:00.000Z', liveSourceAvailable: true, liveSourceMessage: ''
     });
     await TestBed.configureTestingModule({ imports: [AdminDashboardComponent], providers: [
       { provide: OperationsService, useValue: operations },
@@ -27,4 +38,15 @@ describe('AdminDashboardComponent', () => {
     await fixture.whenStable();
     expect(sessions.applyIdentity).toHaveBeenCalledWith(jasmine.objectContaining({ operationsRole: 'ops-manager' }));
   });
+
+  it('loads the default weekly schedule trend and switches to month', async () => {
+    fixture.detectChanges();
+    await fixture.whenStable();
+    expect(fixture.componentInstance.trend()?.points.length).toBe(7);
+    expect(fixture.componentInstance.selectedTrendPoint()?.courseCount).toBe(7);
+    await fixture.componentInstance.selectTrendPeriod('month');
+    expect(operations.scheduleTrend).toHaveBeenCalledWith('month');
+    expect(fixture.componentInstance.trend()?.period).toBe('month');
+    expect(fixture.componentInstance.chartLine('courseCount')).toContain(',');
+  });
 });

+ 48 - 4
src/app/admin/pages/admin-dashboard.component.ts

@@ -1,8 +1,8 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+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 } from '../admin.models';
+import { OperationsDashboardData, ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { AdminSessionService } from '../admin-session.service';
 
@@ -11,21 +11,65 @@ import { AdminSessionService } from '../admin-session.service';
   standalone: true,
   imports: [CommonModule, RouterLink, LucideAngularModule],
   templateUrl: './admin-dashboard.component.html',
-  styleUrl: './admin-dashboard.component.scss',
+  styleUrls: ['./admin-dashboard.component.scss', './admin-dashboard-trend.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AdminDashboardComponent implements OnInit {
+  @ViewChild('trendScroller') private trendScroller?: ElementRef<HTMLElement>;
   readonly data = signal<OperationsDashboardData | null>(null);
   readonly loading = signal(true);
   readonly error = signal('');
+  readonly trend = signal<ScheduleTrendData | null>(null);
+  readonly trendPeriod = signal<ScheduleTrendPeriod>('week');
+  readonly trendLoading = signal(false);
+  readonly trendError = signal('');
+  readonly selectedTrendIndex = signal(0);
+  readonly selectedTrendPoint = computed(() => this.trend()?.points[this.selectedTrendIndex()] ?? null);
+  readonly chartWidth = computed(() => Math.max(720, (this.trend()?.points.length ?? 0) * 58 + 84));
   readonly icons = { ArrowRight, CircleAlert, LoaderCircle, RefreshCw };
   private readonly operations = inject(OperationsService);
   private readonly sessions = inject(AdminSessionService);
   ngOnInit(): void { void this.load(); }
   async load(): Promise<void> {
     this.loading.set(true); this.error.set('');
-    try { const data = await this.operations.dashboard(); this.data.set(data); this.sessions.applyIdentity(data.identity); }
+    try {
+      const data = await this.operations.dashboard(); this.data.set(data); this.sessions.applyIdentity(data.identity);
+      if (this.trendPeriod() === 'week' && data.scheduleTrend) this.applyTrend(data.scheduleTrend);
+      else if (this.trendPeriod() !== 'week') await this.loadTrend(this.trendPeriod());
+    }
     catch (error) { this.error.set(error instanceof Error ? error.message : '运营工作台加载失败'); }
     finally { this.loading.set(false); }
   }
+
+  async selectTrendPeriod(period: ScheduleTrendPeriod): Promise<void> {
+    if (this.trendPeriod() === period && this.trend()?.period === period && !this.trendError()) return;
+    this.trendPeriod.set(period);
+    if (period === 'week' && this.data()?.scheduleTrend) { this.applyTrend(this.data()!.scheduleTrend!); return; }
+    await this.loadTrend(period);
+  }
+
+  selectTrendPoint(index: number): void { this.selectedTrendIndex.set(index); }
+
+  selectTrendPointByKey(event: KeyboardEvent, index: number): void {
+    if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.selectTrendPoint(index); }
+    if (event.key === 'ArrowLeft') { event.preventDefault(); this.selectTrendPoint(Math.max(0, index - 1)); }
+    if (event.key === 'ArrowRight') { event.preventDefault(); this.selectTrendPoint(Math.min((this.trend()?.points.length ?? 1) - 1, index + 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; }
+  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]; }
+
+  private async loadTrend(period: ScheduleTrendPeriod): Promise<void> {
+    this.trendLoading.set(true); this.trendError.set('');
+    try { this.applyTrend(await this.operations.scheduleTrend(period)); }
+    catch (error) { this.trendError.set(error instanceof Error ? error.message : '排课趋势加载失败'); }
+    finally { this.trendLoading.set(false); }
+  }
+
+  private applyTrend(data: ScheduleTrendData): void {
+    this.trend.set(data); this.selectedTrendIndex.set(Math.max(0, data.points.length - 1));
+    setTimeout(() => this.trendScroller?.nativeElement.scrollTo({ left: this.trendScroller.nativeElement.scrollWidth, behavior: 'smooth' }));
+  }
 }