فهرست منبع

fix(mobile): reuse daily cache across companion and account tabs

彭峰 1 هفته پیش
والد
کامیت
db321d6e54

+ 39 - 1
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.spec.ts

@@ -1,5 +1,5 @@
 import { signal } from '@angular/core';
-import { TestBed } from '@angular/core/testing';
+import { fakeAsync, tick, TestBed } from '@angular/core/testing';
 import { firstValueFrom, of, Subject, throwError } from 'rxjs';
 import { HomeDailyCacheService } from './home-daily-cache.service';
 import { SessionService } from './session.service';
@@ -69,6 +69,44 @@ describe('HomeDailyCacheService', () => {
     expect((await manual).value.totalIncome).toBe(50);
   });
 
+  it('keeps account and companion filter/page snapshots separate after a browser reload', async () => {
+    const fetch = jasmine.createSpy('fetch').and.callFake((id: string) => of({ courses: [], schedules: [{ id }] }));
+    for (const key of ['companion:today-page1', 'companion:today-page2', 'companion:tomorrow-student2'] as const) {
+      await firstValueFrom(cache.load(key, () => fetch(key)));
+    }
+    await firstValueFrom(cache.load('account', () => of({ profile: { userId: 100 } })));
+    const restored = TestBed.runInInjectionContext(() => new HomeDailyCacheService());
+    expect((await firstValueFrom(restored.load<{ courses: unknown[]; schedules: { id: string }[] }>('companion:today-page1', () => fetch('unexpected')))).value.schedules[0].id).toBe('companion:today-page1');
+    expect((await firstValueFrom(restored.load<{ courses: unknown[]; schedules: { id: string }[] }>('companion:today-page2', () => fetch('unexpected')))).value.schedules[0].id).toBe('companion:today-page2');
+    expect(fetch).toHaveBeenCalledTimes(3);
+    const accountFetch = jasmine.createSpy('accountFetch');
+    expect((await firstValueFrom(restored.load<{ profile: { userId: number } }>('account', accountFetch))).value.profile.userId).toBe(100);
+    expect(accountFetch).not.toHaveBeenCalled();
+  });
+
+  it('refreshes only on a visible day transition and removes its timer/listener on leave', fakeAsync(() => {
+    const visibility = spyOnProperty(document, 'visibilityState', 'get').and.returnValue('visible');
+    const refresh = jasmine.createSpy('refresh');
+    const stop = cache.watchDay(refresh);
+    document.dispatchEvent(new Event('visibilitychange'));
+    expect(refresh).not.toHaveBeenCalled();
+    const now = Date.now(), midnight = (Math.floor((now + 8 * 3600000) / 86400000) + 1) * 86400000 - 8 * 3600000;
+    tick(midnight - now + 101);
+    expect(refresh).toHaveBeenCalledTimes(1);
+    document.dispatchEvent(new Event('visibilitychange'));
+    expect(refresh).toHaveBeenCalledTimes(1);
+    visibility.and.returnValue('hidden');
+    tick(86400000);
+    expect(refresh).toHaveBeenCalledTimes(1);
+    visibility.and.returnValue('visible');
+    document.dispatchEvent(new Event('visibilitychange'));
+    expect(refresh).toHaveBeenCalledTimes(2);
+    stop();
+    tick(86400000);
+    document.dispatchEvent(new Event('visibilitychange'));
+    expect(refresh).toHaveBeenCalledTimes(2);
+  }));
+
   it('does not cache failures and retains the last success after a failed manual refresh', async () => {
     const fetch = jasmine.createSpy('fetch').and.returnValue(throwError(() => new Error('offline')));
     await expectAsync(firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).toBeRejectedWithError('offline');

+ 40 - 5
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts

@@ -1,16 +1,19 @@
-import { inject, Injectable } from '@angular/core';
+import { inject, Injectable, NgZone } from '@angular/core';
 import { defer, filter, finalize, map, Observable, of, shareReplay, take, tap } from 'rxjs';
 import { SessionService } from './session.service';
 import { beijingToday } from './teacher-today-review.service';
 
 const STORAGE_KEY = 'xiaoshu.learning.homeDaily.v1';
-type Section = 'stats' | 'review' | 'balances';
+type Section = 'stats' | 'review' | 'balances' | 'account' | `companion:${string}`;
+const FIXED_SECTIONS = ['stats', 'review', 'balances', 'account'];
+const MAX_COMPANION_PAGES = 80;
 export interface HomeSnapshot<T> { value: T; updatedAt: string; }
 
-/** Homepage snapshots only. Detail pages continue to read their current data. */
+/** Daily read snapshots for main tabs. Detail pages keep their current-data checks. */
 @Injectable({ providedIn: 'root' })
 export class HomeDailyCacheService {
   private readonly session = inject(SessionService);
+  private readonly zone = inject(NgZone);
   private identity = '';
   private token = '';
   private values: Partial<Record<Section, HomeSnapshot<unknown>>> = {};
@@ -27,12 +30,13 @@ export class HomeDailyCacheService {
       // Ignore responses belonging to a previous account, login or Beijing day.
       filter(() => this.ensureIdentity() === identity && this.token === token),
       tap(value => {
-        if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('首页数据暂时无法加载');
+        if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('页面数据暂时无法加载');
       }),
       // Keep the successful read time, including after a page reload.
       map(value => ({ value, updatedAt: new Date().toISOString() })),
       tap(snapshot => {
         this.values[section] = snapshot;
+        this.trimCompanionPages();
         try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ identity, values: this.values })); }
         catch { /* In-memory caching still works when storage is unavailable. */ }
       }),
@@ -43,6 +47,34 @@ export class HomeDailyCacheService {
     return request;
   }
 
+  /** No polling: refresh once on a visible Beijing-day transition. */
+  watchDay(onDayChange: (today: string, previous: string) => void): () => void {
+    let day = beijingToday();
+    let timer: ReturnType<typeof setTimeout>;
+    const check = () => {
+      const today = beijingToday();
+      if (document.visibilityState === 'visible' && today !== day) {
+        const previous = day;
+        day = today;
+        this.zone.run(() => onDayChange(today, previous));
+      }
+    };
+    const schedule = () => {
+      const now = Date.now();
+      const next = (Math.floor((now + 8 * 3600000) / 86400000) + 1) * 86400000 - 8 * 3600000;
+      timer = this.zone.runOutsideAngular(() => setTimeout(() => { check(); schedule(); }, next - now + 100));
+    };
+    document.addEventListener('visibilitychange', check);
+    schedule();
+    return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', check); };
+  }
+
+  private trimCompanionPages(): void {
+    const pages = (Object.keys(this.values) as Section[]).filter(key => key.startsWith('companion:'));
+    pages.sort((a, b) => Date.parse(this.values[a]!.updatedAt) - Date.parse(this.values[b]!.updatedAt));
+    for (const key of pages.slice(0, Math.max(0, pages.length - MAX_COMPANION_PAGES))) delete this.values[key];
+  }
+
   private ensureIdentity(): string {
     const identity = `${this.session.user()?.userId}|${this.session.user()?.groupId}|${beijingToday()}`;
     if (identity !== this.identity || this.token !== this.session.token()) {
@@ -53,11 +85,14 @@ export class HomeDailyCacheService {
       try {
         const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
         if (saved?.identity === identity) {
-          for (const section of ['stats', 'review', 'balances'] as const) {
+          for (const section of Object.keys(saved.values || {}) as Section[]) {
+            if (!FIXED_SECTIONS.includes(section) && !section.startsWith('companion:')) continue;
             const snapshot = saved.values?.[section];
             if (snapshot?.value && typeof snapshot.value === 'object' && !Array.isArray(snapshot.value) && Number.isFinite(Date.parse(snapshot.updatedAt))) {
               if (section === 'review' && (!Array.isArray(snapshot.value.items) || !Array.isArray(snapshot.value.dates) || !snapshot.value.summary)) continue;
+              if (section.startsWith('companion:') && (!Array.isArray(snapshot.value.courses) || !Array.isArray(snapshot.value.schedules))) continue;
               this.values[section] = snapshot;
+              this.trimCompanionPages();
             }
           }
         } else localStorage.removeItem(STORAGE_KEY);

+ 35 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -378,6 +378,41 @@ describe('home learning modules', () => {
     });
   }
 
+  for (const groupId of [1, 3]) {
+    it(`caches companion and account tabs for role ${groupId} and refreshes only on request`, async () => {
+      user.set({ userId: groupId === 3 ? 300 : 100, groupId });
+      session.groupId.set(groupId);
+      const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+      const post = spyOn(api, 'post').and.callThrough();
+      const count = (action: string) => post.calls.allArgs().filter(([name]) => name === action).length;
+      const harness = await RouterTestingHarness.create();
+      const route = groupId === 3 ? '/pages/home/shop' : '/pages/home/cart';
+      await harness.navigateByUrl(route);
+      await harness.navigateByUrl('/pages/home/ucenter');
+      const before = { companion: count('app_companion_overview'), account: count('app_account_overview'), versions: count('app_live_versions') };
+      document.dispatchEvent(new Event('visibilitychange'));
+      await harness.navigateByUrl(route);
+      harness.fixture.detectChanges();
+      expect(count('app_companion_overview')).toBe(before.companion);
+      expect(harness.routeNativeElement?.querySelector('.center-sync')).toBeNull();
+      const center = harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+      center.showTomorrow();
+      expect(count('app_companion_overview')).toBe(before.companion + 1);
+      center.showToday();
+      expect(count('app_companion_overview')).toBe(before.companion + 1);
+      center.load(true);
+      expect(count('app_companion_overview')).toBe(before.companion + 2);
+      await harness.navigateByUrl('/pages/home/ucenter');
+      harness.fixture.detectChanges();
+      expect(count('app_account_overview')).toBe(before.account);
+      expect(harness.routeNativeElement?.textContent).not.toContain('正在同步...');
+      (harness.routeNativeElement?.querySelector('.account-refresh') as HTMLButtonElement).click();
+      harness.fixture.detectChanges();
+      expect(count('app_account_overview')).toBe(before.account + 1);
+      expect(count('app_live_versions')).toBe(before.versions);
+    });
+  }
+
   it('loads the review calendar once per login and refreshes only on an explicit request', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const originalPost = api.post.bind(api);

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

@@ -13,7 +13,7 @@
 
     <section class="schedule-band">
       <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ (loading()||error())&&!schedules().length ? '—' : isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
-      <nav class="range-tabs" aria-label="课表操作"><button type="button" (click)="load(true)">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
+      <nav class="range-tabs" aria-label="课表操作"><button type="button" (click)="load(true)" [disabled]="loading()">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
       @if (isHistory) {
         <section class="history-filter-panel" aria-label="销课记录筛选">
           <div class="history-filter-grid">

+ 18 - 6
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -5,7 +5,8 @@ import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
-import { finalize, Subscription } from 'rxjs';
+import { finalize, map, Subscription } from 'rxjs';
+import { HomeDailyCacheService } from '../../core/home-daily-cache.service';
 import { ApiError, ApiService } from '../../core/api.service';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
 import { LiveDataRefreshService, LiveSyncStatus } from '../../core/live-data-refresh.service';
@@ -102,6 +103,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, Search, X };
 
   private readonly api = inject(ApiService);
+  private readonly dailyCache = inject(HomeDailyCacheService);
   private readonly zone = inject(NgZone);
   private readonly liveData = inject(LiveDataRefreshService);
   private readonly router = inject(Router);
@@ -123,12 +125,20 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
     this.load();
-    this.stopLiveRefresh = this.liveData.watch({
+    this.stopLiveRefresh = this.isHistory ? this.liveData.watch({
       scopes: ['courses', 'appointments'],
       studentId: () => this.isCoach && this.selectedStudent() === 'all' ? undefined : Number(this.selectedStudent() === 'all' ? this.session.user()?.userId : this.selectedStudent()) || undefined,
       appointmentPolling: true,
       onRefresh: () => this.load(true, true, true),
       onState: (state) => { this.liveChecking.set(state.checking); this.updateSyncStatus(state.syncStatus); },
+    }) : this.dailyCache.watchDay((today, previous) => {
+      this.now.set(Date.now());
+      if (this.selectedDate() === previous) {
+        this.selectedDate.set(today);
+        this.calendarMonth.set(this.firstOfMonth(this.parseDate(today)));
+        this.syncScheduleState();
+      }
+      this.load(false, true);
     });
     if (this.hasScheduleCalendar) this.nowTimer = this.zone.runOutsideAngular(() => setInterval(() => this.zone.run(() => this.now.set(Date.now())), 60_000));
   }
@@ -154,7 +164,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     }
     const today = this.today();
     const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
-    this.loadRequest=this.api.post<CompanionOverview>('app_companion_overview', {
+    const params = {
       uid,
       mode: this.isCoach ? 'coach' : 'student',
       history: this.isHistory,
@@ -163,12 +173,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       dateEnd:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[1]:this.selectedDate(),
       rangeStart: range?.start,
       rangeEnd: range?.end,
-      refresh,
       source: 'database',
-    }).pipe(finalize(() => {
+    };
+    const fetch = () => this.api.post<CompanionOverview>('app_companion_overview', { ...params, refresh }).pipe(map(({ result }) => result));
+    const request = this.isHistory ? fetch() : this.dailyCache.load(`companion:${JSON.stringify(params)}`, fetch, refresh).pipe(map(({ value }) => value));
+    this.loadRequest = request.pipe(finalize(() => {
       if (!background && requestId === this.loadId) this.loading.set(false);
     })).subscribe({
-      next: ({ result }) => {
+      next: (result) => {
         if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount||0);this.serverDates.set(result.dates||[]);this.serverStudents.set(result.studentOptions||[]);
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.html

@@ -4,7 +4,7 @@
   @if (session.isLoggedIn()) { <a routerLink="/pages/member/settings" aria-label="个人设置" title="个人设置"><lucide-icon [img]="icons.Settings" [size]="21" /></a> } @else { <a class="login-link" routerLink="/pages/member/wxauth">前去登录<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a> }
 </header>
 
-@if (session.isLoggedIn()) { <div class="account-live-status" [class.delayed]="liveSyncStatus().state !== 'current'" role="status"><lucide-icon [class.spin]="liveChecking()" [img]="icons.LoaderCircle" [size]="14" /><span>{{ liveStatusText() }}</span></div> }
+@if (session.isLoggedIn()) { <div class="account-live-status" [class.delayed]="liveSyncStatus().state !== 'current'" role="status"><lucide-icon [class.spin]="liveChecking()" [img]="icons.LoaderCircle" [size]="14" /><span>{{ liveStatusText() }}</span><button class="account-refresh" type="button" (click)="retryOverview()" [disabled]="coachPanelLoading() || companionLoading() || antiForgetLoading()" aria-label="刷新我的数据"><lucide-icon [img]="icons.RefreshCw" [size]="16" [class.spin]="coachPanelLoading() || companionLoading() || antiForgetLoading()" />刷新</button></div> }
 
 @if (session.isLoggedIn()) {
   @if (overviewError()) { <section class="account-state error" role="alert"><lucide-icon [img]="icons.RefreshCw" [size]="22" /><span>{{ overviewError() }}</span><button type="button" (click)="retryOverview()">重新加载</button></section> }

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss

@@ -8,6 +8,8 @@
 .profile-header > a { display: flex; align-items: center; color: white; text-decoration: none; gap: 2px; }
 .login-link { font-size: 13px; }
 .account-live-status { display: flex; min-height: 28px; align-items: center; justify-content: center; margin: 6px 14px -4px; color: var(--ink-soft); font-size: 10px; gap: 5px; }
+.account-refresh { display: inline-flex; min-height: 36px; align-items: center; gap: 4px; margin-left: 8px; padding: 0 10px; color: var(--brand-deep); background: transparent; border: 0; font: inherit; cursor: pointer; }
+.account-refresh:disabled { opacity: .6; cursor: wait; }
 .account-live-status.delayed { color: #8d570b; }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }

+ 16 - 11
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -2,11 +2,12 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LoaderCircle, LogOut, LucideAngularModule, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { finalize, map, Subscription } from 'rxjs';
 import { APP_VERSION } from '../../core/app.constants';
 import { ApiService } from '../../core/api.service';
 import { AntiForgetHistorySummary } from '../../core/anti-forget';
-import { LiveDataRefreshService, LiveSyncStatus } from '../../core/live-data-refresh.service';
+import { LiveSyncStatus } from '../../core/live-data-refresh.service';
+import { HomeDailyCacheService } from '../../core/home-daily-cache.service';
 import { AppUser } from '../../core/models';
 import { SessionService } from '../../core/session.service';
 import { buildAccountMembership, CompanionLearningSummary } from './account-membership';
@@ -74,7 +75,8 @@ export class AccountPageComponent implements OnInit, OnDestroy {
   ]);
 
   private readonly api = inject(ApiService);
-  private readonly liveData = inject(LiveDataRefreshService);
+  private readonly dailyCache = inject(HomeDailyCacheService);
+  private overviewRequest?: Subscription;
   private readonly router = inject(Router);
   private stopLiveRefresh?: () => void;
   private overviewRequestId = 0;
@@ -83,15 +85,14 @@ export class AccountPageComponent implements OnInit, OnDestroy {
     const uid = this.session.user()?.userId;
     if (!uid) return;
     this.loadAccountOverview(false);
-    this.stopLiveRefresh = this.liveData.watch({
-      scopes: ['appointments', 'review', 'account'],
-      studentId: () => Number(this.session.user()?.userId || 0) || undefined,
-      onRefresh: () => this.loadAccountOverview(true, true),
-      onState: (state) => { this.liveChecking.set(state.checking); this.liveSyncStatus.set(state.syncStatus); },
-    });
+    this.stopLiveRefresh = this.dailyCache.watchDay(() => this.loadAccountOverview(false));
   }
 
-  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+  ngOnDestroy(): void {
+    this.stopLiveRefresh?.();
+    this.overviewRequestId++;
+    this.overviewRequest?.unsubscribe();
+  }
 
   loadAntiForget(): void { this.loadAccountOverview(true); }
   retryOverview(): void { this.loadAccountOverview(true); }
@@ -99,6 +100,7 @@ export class AccountPageComponent implements OnInit, OnDestroy {
   private loadAccountOverview(refresh: boolean, background = false): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
+    this.overviewRequest?.unsubscribe();
     const requestId = ++this.overviewRequestId;
     const coach = Number(this.session.groupId()) === 3;
     if (!background) {
@@ -108,7 +110,10 @@ export class AccountPageComponent implements OnInit, OnDestroy {
       this.antiForgetError.set('');
       this.overviewError.set('');
     }
-    this.api.post<AccountOverview>('app_account_overview', { uid, refresh, source:'database' }).pipe(
+    this.overviewRequest = this.dailyCache.load('account', () =>
+      this.api.post<AccountOverview>('app_account_overview', { uid, refresh, source: 'database' }).pipe(map(({ result }) => result)), refresh,
+    ).pipe(
+      map(({ value }) => ({ result: value })),
       finalize(() => {
         if (background || requestId !== this.overviewRequestId) return;
         this.coachPanelLoading.set(false);