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

fix(mobile): dismiss companion sync warnings after refresh

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

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -1,5 +1,5 @@
 <section class="center-page">
-  @if (loading() || liveChecking() || liveSyncStatus().state !== 'current') {
+  @if (loading() || syncNoticeVisible()) {
     <div class="center-sync" [class.delayed]="liveSyncStatus().state !== 'current'" role="status"><lucide-icon [class.spin]="loading() || liveChecking()" [img]="icons.LoaderCircle" [size]="18" /><span>{{ liveStatusText() }}</span></div>
     }
     @if (!isCoach) {

+ 89 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.spec.ts

@@ -0,0 +1,89 @@
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { of, throwError } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
+import { PlatformService } from '../../core/platform.service';
+import { SessionService } from '../../core/session.service';
+import { CourseCenterPageComponent } from './course-center-page.component';
+
+describe('CourseCenterPageComponent live appointment refresh', () => {
+  let post: jasmine.Spy;
+  let watch: jasmine.Spy;
+  let overview: ReturnType<typeof companionOverview>;
+
+  beforeEach(() => {
+    overview = companionOverview('第一节伴学');
+    post = jasmine.createSpy('post').and.callFake(() => of({ result: overview }));
+    watch = jasmine.createSpy('watch').and.returnValue(() => undefined);
+    TestBed.configureTestingModule({
+      providers: [
+        provideRouter([]),
+        { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: { path: 'pages/home/cart' } }, queryParamMap: convertToParamMap({}) } } },
+        { provide: ApiService, useValue: { post } },
+        { provide: LiveDataRefreshService, useValue: { watch } },
+        { provide: SessionService, useValue: { user: signal({ userId: 42 }), groupId: signal(1) } },
+        { provide: PlatformService, useValue: { toast: jasmine.createSpy('toast') } },
+      ],
+    });
+  });
+
+  it('uses the successful appointment pull and reloads the list when the watched version changes', () => {
+    const fixture = TestBed.createComponent(CourseCenterPageComponent);
+    fixture.detectChanges();
+    const page = fixture.componentInstance;
+    const options = watch.calls.mostRecent().args[0];
+
+    expect(options.scopes).toEqual(['courses', 'appointments']);
+    expect(options.studentId()).toBe(42);
+    expect(page.schedules()[0]['Title']).toBe('第一节伴学');
+    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
+
+    options.onState({ checking: false, checkedAt: '', syncStatus: { state: 'unavailable' } });
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
+
+    overview = companionOverview('新增加的伴学');
+    options.onRefresh('version');
+    fixture.detectChanges();
+    expect(post.calls.mostRecent().args[1].refresh).toBeTrue();
+    expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
+    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
+
+    post.and.returnValue(throwError(() => new Error('读取失败')));
+    options.onRefresh('version');
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.center-sync')?.textContent).toContain('实时同步暂时不可用');
+    expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
+    fixture.destroy();
+  });
+
+  it('shows a failed direct pull briefly, then removes the warning', fakeAsync(() => {
+    overview = companionOverview('已保留的伴学', false);
+    const fixture = TestBed.createComponent(CourseCenterPageComponent);
+    fixture.detectChanges();
+    const page = fixture.componentInstance;
+    expect(page.liveSyncStatus().state).toBe('unavailable');
+    expect(fixture.nativeElement.querySelector('.center-sync')?.textContent).toContain('实时同步暂时不可用');
+
+    tick(6_000);
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
+    watch.calls.mostRecent().args[0].onState({ checking: false, checkedAt: '', syncStatus: { state: 'unavailable' } });
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
+
+    fixture.destroy();
+  }));
+});
+
+function companionOverview(title: string, directPullSucceeded = true) {
+  return {
+    courses: [],
+    schedules: [{ GeneralID: 1, Title: title, bxrq: '2026-09-25', dszt: 0 }],
+    refreshedAt: '2026-09-25T00:00:00.000Z',
+    syncStatus: { state: 'unavailable' as const },
+    legacyAppointmentSync: { state: (directPullSucceeded ? 'current' : 'unavailable') as 'current' | 'unavailable', complete: directPullSucceeded },
+  };
+}

+ 33 - 3
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -46,6 +46,7 @@ interface CompanionOverview {
   refreshedAt: string;
   version?: string;
   syncStatus?: LiveSyncStatus;
+  legacyAppointmentSync?: LiveSyncStatus & { complete?: boolean };
 }
 
 @Component({
@@ -80,6 +81,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly liveChecking = signal(false);
   readonly refreshedAt = signal('');
   readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
+  readonly syncNoticeVisible = signal(false);
   readonly error = signal('');
   readonly studentOptions = computed(() => this.serverStudents().map(student => ({...student, id: String(student.id), name: String(student.name || '').trim() || `学员 ${student.id}`})));
   readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
@@ -111,6 +113,8 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private initialScheduleLoaded = false;
   private historyFilterInitialized = false;
   private stopLiveRefresh?: () => void;
+  private syncNoticeTimer?: ReturnType<typeof setTimeout>;
+  private hasFreshLegacyAppointments = false;
 
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
@@ -119,13 +123,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       scopes: ['courses', 'appointments'],
       studentId: () => Number(this.selectedStudent() === 'all' ? this.session.user()?.userId : this.selectedStudent()) || undefined,
       onRefresh: () => this.load(true, true, true),
-      onState: (state) => { this.liveChecking.set(state.checking); this.liveSyncStatus.set(state.syncStatus); },
+      onState: (state) => { this.liveChecking.set(state.checking); this.updateSyncStatus(state.syncStatus); },
     });
     if (this.hasScheduleCalendar) this.nowTimer = setInterval(() => this.now.set(Date.now()), 60_000);
   }
 
   ngOnDestroy(): void {
     this.stopLiveRefresh?.();
+    if (this.syncNoticeTimer) clearTimeout(this.syncNoticeTimer);
     this.loadId++;this.loadRequest?.unsubscribe();
     if (this.nowTimer) clearInterval(this.nowTimer);
   }
@@ -160,7 +165,10 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
         this.refreshedAt.set(result.refreshedAt || new Date().toISOString());
-        if (result.syncStatus) this.liveSyncStatus.set(result.syncStatus);
+        if (!this.isCoach) this.hasFreshLegacyAppointments = result.legacyAppointmentSync?.state === 'current' && result.legacyAppointmentSync.complete !== false;
+        if (result.syncStatus || this.hasFreshLegacyAppointments) {
+          this.updateSyncStatus(result.syncStatus || { state: 'current' }, refresh && !background);
+        }
         this.loadedRange = range;
         this.loadedAll = !range;
         if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
@@ -169,12 +177,34 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       },
       error: (error: ApiError) => {
         if (requestId !== this.loadId) return;
-        if (background) this.liveSyncStatus.set({ state: 'unavailable' });
+        if (background) {
+          this.hasFreshLegacyAppointments = false;
+          this.updateSyncStatus({ state: 'unavailable' });
+        }
         else this.error.set(error.message);
       },
     });
   }
 
+  private updateSyncStatus(status: LiveSyncStatus, forceNotice = false): void {
+    // A successful direct appointment pull is fresher than the separate legacy bridge checkpoint.
+    const next = this.hasFreshLegacyAppointments && status.state !== 'current' ? { state: 'current' as const } : status;
+    const previous = this.liveSyncStatus().state;
+    this.liveSyncStatus.set(next);
+    if (next.state === 'current') {
+      this.syncNoticeVisible.set(false);
+      if (this.syncNoticeTimer) clearTimeout(this.syncNoticeTimer);
+      this.syncNoticeTimer = undefined;
+    } else if (previous !== next.state || forceNotice) {
+      this.syncNoticeVisible.set(true);
+      if (this.syncNoticeTimer) clearTimeout(this.syncNoticeTimer);
+      this.syncNoticeTimer = setTimeout(() => {
+        this.syncNoticeVisible.set(false);
+        this.syncNoticeTimer = undefined;
+      }, 6_000);
+    }
+  }
+
   liveStatusText(): string {
     const status = this.liveSyncStatus();
     if (status.state === 'delayed') return status.lagSeconds == null ? '数据同步延迟,正在追平' : `数据同步延迟 ${status.lagSeconds} 秒,正在追平`;