Преглед изворни кода

fix(mobile): cache homepage summaries for the current day

彭峰 пре 1 недеља
родитељ
комит
91dabd104c

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

@@ -0,0 +1,112 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { firstValueFrom, of, Subject, throwError } from 'rxjs';
+import { HomeDailyCacheService } from './home-daily-cache.service';
+import { SessionService } from './session.service';
+
+const storageKey = 'xiaoshu.learning.homeDaily.v1';
+describe('HomeDailyCacheService', () => {
+  let cache: HomeDailyCacheService;
+  const user = signal({ userId: 100, groupId: 1 });
+  const token = signal('r:daily-cache-test');
+  beforeEach(() => {
+    localStorage.removeItem(storageKey);
+    user.set({ userId: 100, groupId: 1 });
+    token.set('r:daily-cache-test');
+    TestBed.configureTestingModule({ providers: [{ provide: SessionService, useValue: { user, token } }] });
+    cache = TestBed.inject(HomeDailyCacheService);
+  });
+  afterEach(() => localStorage.removeItem(storageKey));
+
+  it('reuses the same day after navigation and page reload, and forces a manual refresh', async () => {
+    const fetch = jasmine.createSpy('fetch').and.returnValue(of({ totalIncome: 25 }));
+    const first = await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+    expect(await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).toEqual(first);
+    const restored = TestBed.runInInjectionContext(() => new HomeDailyCacheService());
+    expect(await firstValueFrom(restored.load<{ totalIncome: number }>('stats', fetch))).toEqual(first);
+    expect(fetch).toHaveBeenCalledTimes(1);
+    fetch.and.returnValue(of({ totalIncome: 30 }));
+    expect((await firstValueFrom(restored.load<{ totalIncome: number }>('stats', fetch, true))).value.totalIncome).toBe(30);
+    expect(fetch).toHaveBeenCalledTimes(2);
+  });
+
+  it('separates Beijing days, accounts and roles', async () => {
+    jasmine.clock().install();
+    try {
+      const fetch = jasmine.createSpy('fetch').and.returnValue(of({ totalIncome: 25 }));
+      jasmine.clock().mockDate(new Date('2026-10-31T15:59:59Z'));
+      await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+      jasmine.clock().mockDate(new Date('2026-10-31T16:00:00Z'));
+      await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+      user.set({ userId: 300, groupId: 3 });
+      await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+      user.set({ userId: 300, groupId: 1 });
+      await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+      expect(fetch).toHaveBeenCalledTimes(4);
+    } finally { jasmine.clock().uninstall(); }
+  });
+
+  it('deduplicates concurrent requests and caches success even after navigating away', async () => {
+    const response = new Subject<{ totalIncome: number }>();
+    const fetch = jasmine.createSpy('fetch').and.returnValue(response);
+    const leaving = cache.load<{ totalIncome: number }>('stats', fetch).subscribe();
+    leaving.unsubscribe();
+    const second = firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch, true));
+    response.next({ totalIncome: 50 });
+    expect((await second).value.totalIncome).toBe(50);
+    expect((await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).value.totalIncome).toBe(50);
+    expect(fetch).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows the cached result immediately when returning during a manual refresh', async () => {
+    await firstValueFrom(cache.load('stats', () => of({ totalIncome: 25 })));
+    const response = new Subject<{ totalIncome: number }>();
+    const manual = firstValueFrom(cache.load('stats', () => response, true));
+    const fetch = jasmine.createSpy('fetch');
+    expect((await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).value.totalIncome).toBe(25);
+    expect(fetch).not.toHaveBeenCalled();
+    response.next({ totalIncome: 50 });
+    expect((await manual).value.totalIncome).toBe(50);
+  });
+
+  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');
+    expect(localStorage.getItem(storageKey)).toBeNull();
+    fetch.and.returnValue(of({ totalIncome: 0 }));
+    await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+    fetch.and.returnValue(throwError(() => new Error('offline')));
+    await expectAsync(firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch, true))).toBeRejectedWithError('offline');
+    expect((await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).value.totalIncome).toBe(0);
+  });
+
+  it('ignores an old account response and a request crossing midnight', async () => {
+    jasmine.clock().install();
+    try {
+      jasmine.clock().mockDate(new Date('2026-10-15T15:59:59Z'));
+      const old = new Subject<{ totalIncome: number }>();
+      const first = firstValueFrom(cache.load<{ totalIncome: number }>('stats', () => old), { defaultValue: null });
+      user.set({ userId: 301, groupId: 3 });
+      await firstValueFrom(cache.load<{ totalIncome: number }>('stats', () => of({ totalIncome: 80 })));
+      old.next({ totalIncome: 25 });
+      expect(await first).toBeNull();
+      const late = new Subject<{ totalIncome: number }>();
+      const crossing = firstValueFrom(cache.load<{ totalIncome: number }>('stats', () => late, true), { defaultValue: null });
+      jasmine.clock().mockDate(new Date('2026-10-15T16:00:00Z'));
+      late.next({ totalIncome: 90 });
+      expect(await crossing).toBeNull();
+      const fetch = jasmine.createSpy('fetch').and.returnValue(of({ totalIncome: 100 }));
+      expect((await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch))).value.totalIncome).toBe(100);
+      expect(fetch).toHaveBeenCalledTimes(1);
+    } finally { jasmine.clock().uninstall(); }
+  });
+
+  it('falls back to memory when storage is unavailable and ignores broken persisted data', async () => {
+    localStorage.setItem(storageKey, '{broken');
+    spyOn(Storage.prototype, 'setItem').and.throwError('blocked');
+    const fetch = jasmine.createSpy('fetch').and.returnValue(of({ totalIncome: 25 }));
+    await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+    await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));
+    expect(fetch).toHaveBeenCalledTimes(1);
+  });
+});

+ 68 - 0
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts

@@ -0,0 +1,68 @@
+import { inject, Injectable } 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';
+export interface HomeSnapshot<T> { value: T; updatedAt: string; }
+
+/** Homepage snapshots only. Detail pages continue to read their current data. */
+@Injectable({ providedIn: 'root' })
+export class HomeDailyCacheService {
+  private readonly session = inject(SessionService);
+  private identity = '';
+  private token = '';
+  private values: Partial<Record<Section, HomeSnapshot<unknown>>> = {};
+  private pending = new Map<Section, Observable<HomeSnapshot<unknown>>>();
+
+  load<T>(section: Section, fetch: () => Observable<T>, force = false): Observable<HomeSnapshot<T>> {
+    const identity = this.ensureIdentity(), token = this.token;
+    const cached = this.values[section];
+    if (cached && !force) return of(cached as HomeSnapshot<T>);
+    const pending = this.pending.get(section);
+    if (pending) return pending as Observable<HomeSnapshot<T>>;
+    const request = defer(fetch).pipe(
+      take(1),
+      // 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('首页数据暂时无法加载');
+      }),
+      // Keep the successful read time, including after a page reload.
+      map(value => ({ value, updatedAt: new Date().toISOString() })),
+      tap(snapshot => {
+        this.values[section] = snapshot;
+        try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ identity, values: this.values })); }
+        catch { /* In-memory caching still works when storage is unavailable. */ }
+      }),
+      finalize(() => { if (this.pending.get(section) === request) this.pending.delete(section); }),
+      shareReplay({ bufferSize: 1, refCount: false }),
+    );
+    this.pending.set(section, request);
+    return request;
+  }
+
+  private ensureIdentity(): string {
+    const identity = `${this.session.user()?.userId}|${this.session.user()?.groupId}|${beijingToday()}`;
+    if (identity !== this.identity || this.token !== this.session.token()) {
+      this.identity = identity;
+      this.token = this.session.token();
+      this.values = {};
+      this.pending.clear();
+      try {
+        const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
+        if (saved?.identity === identity) {
+          for (const section of ['stats', 'review', 'balances'] as const) {
+            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;
+              this.values[section] = snapshot;
+            }
+          }
+        } else localStorage.removeItem(STORAGE_KEY);
+      } catch { /* Malformed or blocked storage is a cache miss. */ }
+    }
+    return identity;
+  }
+}

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

@@ -39,6 +39,7 @@ class LearningModulesApiService {
   ];
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'app_account_overview') return this.post<T>(action, params);
     if (action === 'content_list' && params['orders'] === 'NEWID()') {
       return this.result<T>(Array.from({length: 8}, (_, i) => ({GeneralID: 9000 + i, Title: `external-${i}`, sy: `外部释义 ${i}`})));
     }
@@ -301,6 +302,7 @@ describe('home learning modules', () => {
 
   beforeEach(() => {
     sessionStorage.removeItem('xiaoshu.learning.teacherTodayReview');
+    localStorage.removeItem('xiaoshu.learning.homeDaily.v1');
     spyOn(window, 'confirm').and.returnValue(true);
     // Route tests do not depend on remote audio availability or its error-toast timer.
     spyOn(HTMLMediaElement.prototype, 'play').and.returnValue(Promise.resolve());
@@ -348,6 +350,34 @@ describe('home learning modules', () => {
     expect(count()).toBe(2);
   });
 
+  for (const groupId of [1, 3]) {
+    it(`caches homepage overview for role ${groupId} until an explicit refresh`, 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 get = spyOn(api, 'get').and.callThrough();
+      const count = (action: string) => (action === 'app_account_overview' ? get.calls.allArgs() : post.calls.allArgs()).filter(([name]) => name === action).length;
+      const harness = await RouterTestingHarness.create();
+      await harness.navigateByUrl('/pages/home/index');
+      const first = { stats: count('e_order_tongji'), review: count('app_review_page'), balances: count('app_account_overview') };
+      document.dispatchEvent(new Event('visibilitychange'));
+      await harness.navigateByUrl('/pages/member/starshop');
+      await harness.navigateByUrl('/pages/home/index');
+      harness.fixture.detectChanges();
+      expect(count('e_order_tongji')).toBe(first.stats);
+      expect(count('app_review_page')).toBe(first.review);
+      expect(count('app_account_overview')).toBe(first.balances);
+      expect(harness.routeNativeElement?.textContent).not.toContain('正在同步学习计划');
+      const button = harness.routeNativeElement?.querySelector(groupId === 3 ? '.coach-hero .coach-refresh' : '.student-home-refresh') as HTMLButtonElement;
+      button.click();
+      harness.fixture.detectChanges();
+      expect(count('e_order_tongji')).toBe(first.stats + 1);
+      expect(count('app_review_page')).toBe(first.review + (groupId === 1 ? 1 : 0));
+      expect(count('app_account_overview')).toBe(first.balances + (groupId === 1 ? 1 : 0));
+    });
+  }
+
   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 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -61,6 +61,7 @@
       <div class="memory-title-row">
         <h1 id="memory-title">21天抗遗忘计划</h1>
         <a routerLink="/pages/stu/stu_calendar" aria-label="查看抗遗忘计划详情" title="查看计划详情"><lucide-icon [img]="icons.Info" [size]="18" /></a>
+        <button class="coach-refresh student-home-refresh" type="button" (click)="retryStudentHome()" [disabled]="antiForgetLoading() || statsLoading() || balanceLoading()" aria-label="刷新学习数据"><lucide-icon [img]="icons.RefreshCw" [size]="20" [class.spin]="antiForgetLoading() || statsLoading() || balanceLoading()" /></button>
       </div>
 
       @if (antiForgetLoading()) {

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

@@ -8,6 +8,7 @@
 
 .memory-hero, .coach-hero { overflow: hidden; position: relative; color: white; background: linear-gradient(135deg, var(--brand-deep), var(--brand-strong)); border-radius: var(--radius-lg); box-shadow: var(--shadow-brand); }
 .memory-title-row { display: flex; align-items: center; padding: 20px 22px 0; gap: 8px; }
+.student-home-refresh { margin-left: auto; }
 .memory-title-row h1 { margin: 0; font-size: 20px; letter-spacing: 0; }
 .memory-title-row a { display: grid; width: 32px; height: 32px; color: rgb(255 255 255 / 78%); place-items: center; }
 .memory-content { display: grid; grid-template-columns: minmax(145px, .95fr) minmax(150px, 1.05fr); padding: 4px 20px 18px; gap: 16px; }
@@ -146,7 +147,7 @@
   .ring-center strong { font-size: 24px; }
   .ring-legend { min-height: 42px; gap: 6px; }
   .today-panel { padding-left: 10px; }
-  .today-panel h2 { position: absolute; top: 15px; left: calc(50% + 7px); margin: 0; font-size: 15px; }
+  .today-panel h2 { margin: 8px 0; font-size: 15px; }
   .task-row { min-height: 32px; font-size: 12px; gap: 6px; }
   .continue-button { min-height: 42px; font-size: 14px; }
   .memory-footer { min-height: 42px; padding: 6px 14px; }

+ 72 - 64
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -9,7 +9,7 @@ import { antiForgetCompleted, antiForgetDate, buildAntiForgetView, findAntiForge
 import { ApiService } from '../../core/api.service';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
-import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
+import { HomeDailyCacheService } from '../../core/home-daily-cache.service';
 
 type Item = Record<string, unknown>;
 
@@ -64,62 +64,56 @@ export class HomePageComponent implements OnInit, OnDestroy {
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
   private readonly platform = inject(PlatformService);
-  private readonly liveData = inject(LiveDataRefreshService);
+  private readonly dailyCache = inject(HomeDailyCacheService);
+  private readonly requests = new Subscription();
   private readonly zone=inject(NgZone);
   private readonly todayReviews = inject(TeacherTodayReviewService);
   private reviewRequest?: Subscription;
   private midnightTimer?: ReturnType<typeof setTimeout>;
-  private readonly resumeToday=()=>{
-    if(document.visibilityState==='visible') {
-      const uid=this.session.user()?.userId;
-      if(uid && (!this.isCoach() || this.reviewLoadedDate !== beijingToday())) this.loadAntiForget(uid);
-    }
+  private homeLoadedDate = '';
+  private readonly resumeToday = () => {
+    if (document.visibilityState === 'visible' && this.homeLoadedDate !== beijingToday()) this.loadDailyHome();
   };
-  private reviewLoadedDate = '';
-  private scheduleTodayRefresh():void{
-    if(this.midnightTimer)clearTimeout(this.midnightTimer);
-    const now=Date.now(),next=(Math.floor((now+8*3600000)/86400000)+1)*86400000-8*3600000;
-    this.midnightTimer=this.zone.runOutsideAngular(()=>setTimeout(()=>this.zone.run(()=>{const uid=this.session.user()?.userId;if(uid)this.loadAntiForget(uid);this.retryCoachStats();this.scheduleTodayRefresh();}),next-now+100));
-  }
-  private stopLiveRefresh?: () => void;
 
-  ngOnInit(): void {
+  private loadDailyHome(): void {
     const uid = this.session.user()?.userId;
-    if (uid) {
+    if (!uid) return;
+    this.homeLoadedDate = beijingToday();
+    this.loadStats(uid);
+    if (!this.isCoach()) this.loadBalances(uid);
+    this.loadAntiForget(uid);
+  }
+
+  private scheduleTodayRefresh(): void {
+    if (this.midnightTimer) clearTimeout(this.midnightTimer);
+    const now = Date.now(), next = (Math.floor((now + 8 * 3600000) / 86400000) + 1) * 86400000 - 8 * 3600000;
+    this.midnightTimer = this.zone.runOutsideAngular(() => setTimeout(() => this.zone.run(() => {
+      this.resumeToday();
       this.scheduleTodayRefresh();
-      document.addEventListener('visibilitychange',this.resumeToday);
-      this.loadStats(uid);
-      if(!this.isCoach())this.loadBalances(uid);
-      this.loadAntiForget(uid);
-      this.stopLiveRefresh = this.liveData.watch({
-        scopes: ['appointments', 'review', 'account'],
-        studentId: () => Number(this.session.user()?.userId) || undefined,
-        onRefresh: () => {
-          const currentId = this.session.user()?.userId;
-          if (!currentId) return;
-          this.loadStats(currentId, true);
-          if(!this.isCoach())this.loadBalances(currentId);
-          if (!this.isCoach()) this.loadAntiForget(currentId, true);
-        },
-      });
-    }
+    }), next - now + 100));
+  }
+
+  ngOnInit(): void {
+    this.loadDailyHome();
+    this.scheduleTodayRefresh();
+    document.addEventListener('visibilitychange', this.resumeToday);
   }
 
   ngOnDestroy(): void {
-    this.stopLiveRefresh?.();
+    this.requests.unsubscribe();
     this.reviewRequest?.unsubscribe();
-    if(this.midnightTimer)clearTimeout(this.midnightTimer);
-    document.removeEventListener('visibilitychange',this.resumeToday);
+    if (this.midnightTimer) clearTimeout(this.midnightTimer);
+    document.removeEventListener('visibilitychange', this.resumeToday);
   }
 
   retryCoachStats(): void {
     const uid = this.session.user()?.userId;
-    if (uid) this.loadStats(uid);
+    if (uid) this.loadStats(uid, true);
   }
 
   retryAntiForget(): void {
     const uid = this.session.user()?.userId;
-    if (uid) this.loadAntiForget(uid, false, true);
+    if (uid) this.loadAntiForget(uid, true);
   }
 
   coachReviewStudent(item: Item): string {
@@ -167,44 +161,58 @@ export class HomePageComponent implements OnInit, OnDestroy {
     });
   }
 
-  private loadStats(uid: string | number, background = false): void {
-    if (!background) {
-      this.statsLoading.set(true);
-      this.coachStatsError.set('');
-    }
-    this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(
-      finalize(() => { if (!background) this.statsLoading.set(false); }),
-    ).subscribe({
-      next: ({ result }) => {this.stats.set(result || {});this.statsAvailable.set(Boolean(result));this.coachStatsError.set('');},
-      error: () => {
-        this.coachStatsError.set(this.statsAvailable()?'统计暂时无法更新,当前显示上次结果':'统计暂时无法加载');
-      },
-    });
+  private loadStats(uid: string | number, force = false): void {
+    this.statsLoading.set(true);
+    this.coachStatsError.set('');
+    this.requests.add(this.dailyCache.load('stats', () =>
+      this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(map(({ result }) => result)), force,
+    ).pipe(finalize(() => this.statsLoading.set(false))).subscribe({
+      next: ({ value }) => { this.stats.set(value); this.statsAvailable.set(true); this.coachStatsError.set(''); },
+      error: () => this.coachStatsError.set(this.statsAvailable() ? '统计暂时无法更新,当前显示上次结果' : '统计暂时无法加载'),
+    }));
   }
 
-  private loadAntiForget(uid: string | number, background = false, force = false): void {
+  private loadAntiForget(uid: string | number, force = false): void {
     this.reviewRequest?.unsubscribe();
-    if (!background) {
-      this.antiForgetLoading.set(true);
-      this.antiForgetError.set('');
-    }
+    this.antiForgetLoading.set(true);
+    this.antiForgetError.set('');
     const request = this.isCoach() ? this.todayReviews.load(force) :
-      this.api.post<ReviewPage>('app_review_page', { uid, page: 1, pageSize: 20, status: 'pending' }).pipe(map(({ result }) => result));
-    this.reviewRequest = request.pipe(
-      finalize(() => { if (!background) this.antiForgetLoading.set(false); }),
-    ).subscribe({
-      next: (result) => {
+      this.dailyCache.load('review', () => this.api.post<ReviewPage>('app_review_page', {
+        uid, page: 1, pageSize: 20, status: 'pending',
+      }).pipe(map(({ result }) => result)), force).pipe(map(snapshot => snapshot.value));
+    this.reviewRequest = request.pipe(finalize(() => this.antiForgetLoading.set(false))).subscribe({
+      next: result => {
         this.reviewData.set(result);
         this.antiForgetItems.set(result.items);
-        this.reviewLoadedDate = beijingToday();
         this.antiForgetError.set('');
       },
-      error: () => { this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
+      error: () => this.antiForgetError.set('抗遗忘计划暂时无法加载'),
     });
   }
 
-  retryStudentStats():void { const uid=this.session.user()?.userId;if(uid){this.loadStats(uid);this.loadBalances(uid);} }
-  private loadBalances(uid:string|number):void {this.balanceLoading.set(true);this.api.get<{profile:Record<string,unknown>}>('app_account_overview',{uid}).pipe(finalize(()=>this.balanceLoading.set(false))).subscribe({next:({result})=>{this.balances.set(result.profile);this.balanceError.set('');this.balanceRefreshedAt.set(new Date().toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit'}));},error:()=>this.balanceError.set('课时余额暂时无法更新')});}
+  retryStudentHome(): void {
+    this.retryStudentStats();
+    this.retryAntiForget();
+  }
+
+  retryStudentStats(): void {
+    const uid = this.session.user()?.userId;
+    if (uid) { this.loadStats(uid, true); this.loadBalances(uid, true); }
+  }
+
+  private loadBalances(uid: string | number, force = false): void {
+    this.balanceLoading.set(true);
+    this.requests.add(this.dailyCache.load('balances', () =>
+      this.api.get<{ profile: Record<string, unknown> }>('app_account_overview', { uid, refresh: true }).pipe(map(({ result }) => result.profile)), force,
+    ).pipe(finalize(() => this.balanceLoading.set(false))).subscribe({
+      next: ({ value, updatedAt }) => {
+        this.balances.set(value);
+        this.balanceError.set('');
+        this.balanceRefreshedAt.set(new Date(updatedAt).toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit' }));
+      },
+      error: () => this.balanceError.set('课时余额暂时无法更新'),
+    }));
+  }
   stat(key:string):string|number {const value=this.stats()[key];return value!=null&&Number.isFinite(Number(value))?Number(value):'—';}
   balance(...keys:string[]):string|number {const p=this.balances();if(!p)return '—';for(const key of keys)if(p[key]!=null&&p[key]!==''&&Number.isFinite(Number(p[key])))return Number(p[key]);return '—';}
 

+ 2 - 0
scripts/check-teacher-earnings-browser.mjs

@@ -61,6 +61,8 @@ try{
  checks.push('销课45元、合法零元、待核对分开显示;360/390宽度无横向溢出且固定导航可见');
  for(const mode of ['legacy','split','zero']){
   incomeMode=mode;await page.goto(origin+'/pages/home/index');await page.locator('.coach-memory-list article').waitFor();
+  await page.getByRole('button',{name:'刷新业务数据',exact:true}).click();
+  await page.waitForFunction(()=>!document.querySelector('.coach-hero .coach-refresh')?.disabled);
   await page.getByRole('button',{name:'显示累计收入'}).click();
   const expected=mode==='legacy'?'暂不可用':mode==='zero'?'¥0.00':'¥123.00';
   await page.waitForFunction(value=>document.querySelector('.commission-amount')?.textContent?.trim()===value,expected);