Ver código fonte

fix(admin): refresh stale scheduling data after member enrollment

彭峰 1 dia atrás
pai
commit
c6e3620561

+ 30 - 0
docs/qa/new-member-scheduling-cache-2026-10-15.md

@@ -0,0 +1,30 @@
+# 新会员开课充值后排课入口失效修复
+
+## 原因与复现
+
+排课中心使用 `OperationsRouteReuseStrategy` 保留页面实例。原组件仅在 `ngOnInit` 读取 `studentId`,也没有实现重新挂载回调。进入过排课中心后,从会员详情点击“为该学员排课”,路由变化但原页面不会再次初始化,未打开排课表单。全新页面或整页刷新则正常。
+
+另一个缓存问题是:后台的业务变更通知只给当前可见页面刷新,离开排课中心后新增会员、充值、开课,保留的排课候选仍是旧数据。
+
+真实浏览器复现(本地管理端 4300 连接线上接口):先进入排课中心,再进入会员详情,点击为该学员排课。修复前排课请求只有 1 次,表单数量 0;修复后请求 2 次,表单数量 1,并自动选择目标会员。
+
+## 改动
+
+- 记录离开页面期间的业务写入;回到排课中心时按需更新会员、课程和余额数据。
+- 页面重新挂载以及排课入口参数变化时,处理目标会员;合并同一次导航的挂载、参数事件。
+- 从会员详情明确发起排课时先获取最新数据,再选择会员与已开课程。同一会员再次发起也可打开。
+- 普通切换且没有业务写入时继续复用数据,不增加轮询。
+- 更新失败时保留错误,重新进入可以重试;销毁后取消订阅。
+
+## 验证
+
+- 线上只读核对最近新会员 1935:身份正常,3 项有效开课;30 分钟课时 40、60 分钟课时 20、陪练时长 40。排课查询可读到本人及课程,规则有效。
+- 线上预检:单词课 30 分钟,返回可排 1 节、冲突 0,预留 0。没有进行正式排课。
+- 使用线上保存函数在强制回滚的事务中验证:成功创建 1 条预约后整体回滚。没有提交预约、预留或扣课记录(业务序列分配会产生正常空号)。
+- 修复后浏览器经过相同跨页面路径,能看到 3 门课程,预检 HTTP 200、可排 1 节,“确认保存”可点击;未点击真实保存。
+- 排课组件 28 项测试通过,包括离开期间写入、无写入返回、重复同一会员入口、读取失败重试,以及已有预检/取消测试。
+- 管理端生产构建通过,存在原有样式体积与 CommonJS 警告。
+
+## 交付边界
+
+本次仅修改管理端组件及测试,不变更云函数或数据库业务规则。本地管理端已验证;本记录不代表独立生产前端站点已发布。私人诊断材料保存在本机受限目录,不纳入仓库。

+ 48 - 2
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -1,5 +1,7 @@
-import { TestBed } from '@angular/core/testing';
-import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { BehaviorSubject, Subject } from 'rxjs';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
+import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
 import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, scheduleCoursesForStudent, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { OperationsService } from '../operations.service';
@@ -284,3 +286,47 @@ describe('排课表单预检', () => {
     expect(component.date()).toBe('2026-10-04');expect(component.startTime()).toBe('09:00');expect(component.recurrence()).toBe('once');expect(component.occurrences()).toBe(1);
   });
 });
+
+describe('排课页面复用后的会员与开课数据', () => {
+  let changes: Subject<'local' | 'broadcast'>;
+  let params: BehaviorSubject<ParamMap>;
+  let route: { snapshot: { queryParamMap: ParamMap }; queryParamMap: BehaviorSubject<ParamMap> };
+  let schedule: jasmine.Spy;
+  let fixture: ComponentFixture<OperationsScheduleComponent>;
+  const empty = (): ScheduleCalendarData => ({ members: [], courses: [], appointments: [], coaches: [], unassigned: [], dataDateTo: '' } as unknown as ScheduleCalendarData);
+  const current = (): ScheduleCalendarData => ({ ...empty(), members: [{objectId:'new-member',userId:1935,displayName:'新学员',mobileMasked:'',readOnly:false}], courses: [{bindingId:'new-binding',courseId:447,courseName:'新开单词课',memberIds:[1935],mappingConfirmed:true,supportedDurations:[30]}] } as ScheduleCalendarData);
+  beforeEach(async () => {
+    changes = new Subject(); params = new BehaviorSubject(convertToParamMap({}));
+    route = {snapshot:{queryParamMap:params.value},queryParamMap:params};
+    schedule = jasmine.createSpy('schedule').and.callFake(async () => empty());
+    await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[
+      {provide:OperationsService,useValue:{schedule,dataChanges$:changes}},
+      {provide:ActivatedRoute,useValue:route},
+      {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}},
+    ]}).compileComponents();
+    fixture=TestBed.createComponent(OperationsScheduleComponent);fixture.detectChanges();await fixture.whenStable();
+  });
+  afterEach(()=>fixture.destroy());
+  function navigateToMember():void {route.snapshot.queryParamMap=convertToParamMap({studentId:'1935'});params.next(route.snapshot.queryParamMap);}
+  it('离开期间新增会员、充值和开课后,返回更新候选;普通返回不重复读取', fakeAsync(() => {
+    fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(1);
+    changes.next('local');changes.next('broadcast');schedule.and.callFake(async()=>current());
+    fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
+    expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.data()?.members[0].userId).toBe(1935);
+    fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();expect(schedule).toHaveBeenCalledTimes(2);
+  }));
+  it('缓存页面收到会员排课入口时先读取新数据再选择会员,同一会员再次进入也可打开', fakeAsync(() => {
+    schedule.and.callFake(async()=>current());
+    fixture.componentInstance.onOperationsRouteAttach();navigateToMember();flushMicrotasks();
+    expect(schedule).toHaveBeenCalledTimes(2);expect(fixture.componentInstance.drawerOpen()).toBeTrue();
+    expect(fixture.componentInstance.studentId()).toBe(1935);expect(fixture.componentInstance.bindingId()).toBe('new-binding');
+    fixture.componentInstance.closeDrawer();fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
+    expect(schedule).toHaveBeenCalledTimes(3);expect(fixture.componentInstance.drawerOpen()).toBeTrue();
+  }));
+  it('读取失败时不拿旧候选假装已选择新会员,返回重试可恢复', fakeAsync(() => {
+    schedule.and.rejectWith(new Error('读取失败'));navigateToMember();flushMicrotasks();
+    expect(fixture.componentInstance.error()).toBe('读取失败');expect(fixture.componentInstance.drawerOpen()).toBeFalse();
+    schedule.and.callFake(async()=>current());fixture.componentInstance.onOperationsRouteAttach();flushMicrotasks();
+    expect(fixture.componentInstance.studentId()).toBe(1935);
+  }));
+});

+ 47 - 3
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,6 +1,7 @@
 import { LessonRecordDetailComponent } from './lesson-record-detail.component';
 import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
+import { skip, Subscription } from 'rxjs';
 import { ActivatedRoute } from '@angular/router';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
@@ -166,10 +167,53 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
     }catch(error){this.detailError.set(error instanceof Error?error.message:'取消排课失败');}
     finally{this.saving.set(false);}
   }
-  ngOnInit():void{void this.load().then(()=>{const id=Number(this.route.snapshot.queryParamMap.get('studentId'));const member=this.data()?.members.find(m=>m.userId===id);if(member){this.openCreate();this.chooseMember(member);}});this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});}
-  ngOnDestroy():void{this.stopLiveRefresh?.();}
+  private readonly navigationSubscriptions = new Subscription();
+  private dataRevision = 0;
+  private loadedRevision = -1;
+  private activationQueued = false;
+  private attachedIntent = false;
+  private handledStudentId = 0;
+  private destroyed = false;
+  private activationRequest = 0;
+
+  ngOnInit():void {
+    // Detached menu views must remember writes made on the member/credit pages.
+    this.navigationSubscriptions.add(this.operations.dataChanges$?.subscribe(() => this.dataRevision++));
+    this.navigationSubscriptions.add(this.route.queryParamMap?.pipe(skip(1)).subscribe(() => this.queueActivation()));
+    void this.activateSchedule();
+    this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});
+  }
+  onOperationsRouteAttach():void {
+    this.attachedIntent = true;
+    this.queueActivation();
+  }
+  private queueActivation():void {
+    if (this.activationQueued || this.destroyed) return;
+    this.activationQueued = true;
+    // The router installs the new query parameters after retrieving a cached view.
+    queueMicrotask(() => {
+      this.activationQueued = false;
+      if (!this.destroyed) void this.activateSchedule();
+    });
+  }
+  private async activateSchedule():Promise<void> {
+    const request = ++this.activationRequest;
+    const id = Number(this.route.snapshot.queryParamMap.get('studentId'));
+    const selectMember = id > 0 && (this.attachedIntent || id !== this.handledStudentId);
+    this.attachedIntent = false;
+    if (!this.data() || this.loadedRevision !== this.dataRevision || selectMember) await this.load();
+    if (this.destroyed || request !== this.activationRequest || this.error()) return;
+    this.handledStudentId = id;
+    if (!selectMember) return;
+    const member = this.data()?.members.find(row => row.userId === id);
+    if (!member) { this.error.set('未找到该学员的最新排课资料,请刷新后重试'); return; }
+    this.openCreate();
+    this.chooseMember(member);
+  }
+  ngOnDestroy():void {this.destroyed=true;this.navigationSubscriptions.unsubscribe();this.stopLiveRefresh?.();}
+
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
-  async load(background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter(),!background);if(request!==this.loadRequest)return;if(!background&&this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range(),latest=await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter(),true);if(request!==this.loadRequest)return;this.data.set(latest);this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}
+  async load(background=false):Promise<void>{const request=++this.loadRequest,revision=this.dataRevision;if(!background)this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter(),!background);if(request!==this.loadRequest)return;if(!background&&this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range(),latest=await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter(),true);if(request!==this.loadRequest)return;this.data.set(latest);this.loadedRevision=revision;this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);this.loadedRevision=revision;}}catch(error){if(request===this.loadRequest)this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{if(!background&&request===this.loadRequest)this.loading.set(false);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
   showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
   async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}