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

fix(data): refresh learning data in real time

彭峰 2 дней назад
Родитель
Сommit
89aef36d2f
38 измененных файлов с 937 добавлено и 128 удалено
  1. 18 0
      deployment/systemd/README.md
  2. 6 0
      deployment/systemd/legacy-sync-worker.env.example
  3. 19 0
      deployment/systemd/xiaoshu-legacy-sync.service
  4. 3 0
      package.json
  5. 21 1
      projects/xiaoshu-admin/src/app/cloud-functions.service.ts
  6. 85 0
      projects/xiaoshu-admin/src/app/operations-live-refresh.service.spec.ts
  7. 71 0
      projects/xiaoshu-admin/src/app/operations-live-refresh.service.ts
  8. 1 0
      projects/xiaoshu-admin/src/app/operations.service.ts
  9. 17 8
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.ts
  10. 8 6
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts
  11. 5 4
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts
  12. 15 6
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts
  13. 3 2
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  14. 8 6
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  15. 14 7
      projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.ts
  16. 15 8
      projects/xiaoshu-admin/src/app/pages/operations-stores.component.ts
  17. 13 9
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  18. 4 3
      projects/xiaoshu-mobile/src/app/core/api.service.spec.ts
  19. 31 15
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  20. 85 0
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts
  21. 121 0
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts
  22. 2 2
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  23. 1 0
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss
  24. 38 5
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  25. 9 7
      projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.ts
  26. 2 0
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  27. 4 0
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss
  28. 45 11
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  29. 33 12
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts
  30. 11 0
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  31. 1 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html
  32. 2 0
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss
  33. 48 9
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  34. 3 2
      scripts/cloud/mobile-teaching.js
  35. 26 4
      scripts/deploy-admin-functions.mjs
  36. 12 0
      scripts/ensure-app-performance-indexes.mjs
  37. 93 0
      scripts/run-legacy-sync-worker.mjs
  38. 44 0
      scripts/tests/legacy-sync-worker.test.mjs

+ 18 - 0
deployment/systemd/README.md

@@ -0,0 +1,18 @@
+# 旧系统持续同步进程
+
+此目录用于在新系统服务器安装 5 秒增量同步进程。旧 IIS 读取桥必须先按
+[`legacy-sync-bridge/README.md`](../../legacy-sync-bridge/README.md) 部署并通过外部
+`/xiaoshu-sync/v1/health`、签名 manifest 和增量读取测试。
+
+1. 把仓库部署到 `/opt/xiaoshu-angular`。
+2. 将 `legacy-sync-worker.env.example` 复制到
+   `/etc/xiaoshu/legacy-sync-worker.env`,填写专用超级管理员会话令牌并设置权限为
+   `0600`。
+3. 将 `xiaoshu-legacy-sync.service` 复制到 `/etc/systemd/system/`,执行
+   `systemctl daemon-reload && systemctl enable --now xiaoshu-legacy-sync`。
+4. 用 `curl http://127.0.0.1:9087/health` 检查进程。最近一次成功超过 15 秒或连续失败时返回 503;单轮超过 10 秒会写入 journald 警告。
+5. 用运营后台同步状态核对每个数据集的游标、失败队列和旧库/新库数量,再开放前端实时状态。
+
+云函数环境需配置 `XIAOSHU_LEGACY_SYNC_BASE_URL`、
+`XIAOSHU_LEGACY_SYNC_KEY_ID`、`XIAOSHU_LEGACY_SYNC_SECRET`。首次上线保持
+`XIAOSHU_LEGACY_SYNC_WRITES_ENABLED` 关闭。

+ 6 - 0
deployment/systemd/legacy-sync-worker.env.example

@@ -0,0 +1,6 @@
+XIAOSHU_PARSE_APP_ID=7pIbDBJmKx_main
+XIAOSHU_FUNCTION_URL=https://server.xiaoshu.pro/api/functions
+XIAOSHU_SYNC_OPERATOR_TOKEN=replace-with-super-admin-session-token
+XIAOSHU_SYNC_INTERVAL_MS=5000
+XIAOSHU_SYNC_HEALTH_PORT=9087
+XIAOSHU_SYNC_DATASETS=learning-records,practice-records,memory-records,assessments,course-bindings,appointments,lessons

+ 19 - 0
deployment/systemd/xiaoshu-legacy-sync.service

@@ -0,0 +1,19 @@
+[Unit]
+Description=Xiaoshu legacy incremental sync worker
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/xiaoshu-angular
+EnvironmentFile=/etc/xiaoshu/legacy-sync-worker.env
+ExecStart=/usr/bin/node scripts/run-legacy-sync-worker.mjs
+Restart=always
+RestartSec=3
+User=xiaoshu
+Group=xiaoshu
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target

+ 3 - 0
package.json

@@ -33,6 +33,9 @@
     "sync-bridge:publish": "dotnet publish legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release -r win-x64 --self-contained true -o dist/legacy-sync-bridge-win-x64",
     "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",
     "sync-bridge:smoke": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release --no-restore && node scripts/smoke-legacy-sync-bridge.mjs",
+    "sync-worker:once": "node scripts/run-legacy-sync-worker.mjs --once",
+    "sync-worker:start": "node scripts/run-legacy-sync-worker.mjs",
+    "test:sync-worker": "node --test scripts/tests/legacy-sync-worker.test.mjs",
     "watch": "npm run watch:mobile",
     "watch:mobile": "ng build xiaoshu-mobile --watch --configuration development",
     "watch:admin": "ng build xiaoshu-admin --watch --configuration development",

+ 21 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -1,6 +1,6 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { firstValueFrom } from 'rxjs';
+import { firstValueFrom, Subject } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -13,8 +13,10 @@ export class CloudFunctionError extends Error {
 
 @Injectable({ providedIn: 'root' })
 export class CloudFunctionsService {
+  readonly dataChanges$ = new Subject<'local' | 'broadcast'>();
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(AdminSessionService);
+  private readonly dataChannel = this.createDataChannel();
 
   async admin<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
     const token = this.sessions.session()?.sessionToken;
@@ -25,6 +27,7 @@ export class CloudFunctionsService {
         { token, params: { operation, ...params } },
       ));
       if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败');
+      if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
     } catch (error) {
       if (error instanceof CloudFunctionError) throw error;
@@ -37,6 +40,22 @@ export class CloudFunctionsService {
     }
   }
 
+  private isMutation(operation: string): boolean {
+    return /\/(?:create|save|update|delete|recycle|restore|complete|adjust|post|commit|confirm|cancel|review|mark-paid|deactivate|run|retry|refresh|add-adjustment|exclude-line|import-commit)$/.test(operation);
+  }
+
+  private createDataChannel(): BroadcastChannel | null {
+    if (typeof globalThis.BroadcastChannel !== 'function') return null;
+    const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
+    channel.addEventListener('message', () => this.dataChanges$.next('broadcast'));
+    return channel;
+  }
+
+  private announceDataChange(): void {
+    this.dataChanges$.next('local');
+    this.dataChannel?.postMessage({ changedAt: new Date().toISOString() });
+  }
+
   async operations<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
@@ -46,6 +65,7 @@ export class CloudFunctionsService {
         { token, params: { operation, ...params } },
       ));
       if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败');
+      if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
     } catch (error) {
       if (error instanceof CloudFunctionError) throw error;

+ 85 - 0
projects/xiaoshu-admin/src/app/operations-live-refresh.service.spec.ts

@@ -0,0 +1,85 @@
+import { fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
+import { Subject } from 'rxjs';
+import { OperationsService } from './operations.service';
+import { OperationsLiveRefreshService } from './operations-live-refresh.service';
+import { ADMIN_SESSION_KEY } from './admin.constants';
+
+describe('OperationsLiveRefreshService', () => {
+  let changes: Subject<'local' | 'broadcast'>;
+  let request: jasmine.Spy;
+
+  beforeEach(() => {
+    changes = new Subject();
+    request = jasmine.createSpy('request');
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ sessionToken: 'test-session' }));
+    TestBed.configureTestingModule({
+      providers: [
+        OperationsLiveRefreshService,
+        { provide: OperationsService, useValue: { request, dataChanges$: changes } },
+      ],
+    });
+  });
+
+  afterEach(() => sessionStorage.removeItem(ADMIN_SESSION_KEY));
+
+  it('reloads visible data only after the lightweight version changes', fakeAsync(() => {
+    request.and.returnValues(Promise.resolve(version('v1')), Promise.resolve(version('v2')));
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(OperationsLiveRefreshService).watch(['learning'], refresh);
+    flushMicrotasks();
+    expect(refresh).not.toHaveBeenCalled();
+
+    tick(5_000);
+    flushMicrotasks();
+    expect(request).toHaveBeenCalledTimes(2);
+    expect(refresh).toHaveBeenCalledTimes(1);
+    stop();
+  }));
+
+  it('reloads immediately after local and cross-tab writes', () => {
+    request.and.returnValue(Promise.resolve(version('v1')));
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(OperationsLiveRefreshService).watch(['appointments'], refresh);
+
+    changes.next('local');
+    changes.next('broadcast');
+    expect(refresh).toHaveBeenCalledTimes(2);
+    stop();
+  });
+
+  it('stops checking after logout', fakeAsync(() => {
+    request.and.returnValue(Promise.resolve(version('v1')));
+    const stop = TestBed.inject(OperationsLiveRefreshService).watch(['learning'], () => undefined);
+    flushMicrotasks();
+    expect(request).toHaveBeenCalledTimes(1);
+    sessionStorage.removeItem(ADMIN_SESSION_KEY);
+    tick(10_000);
+    flushMicrotasks();
+    expect(request).toHaveBeenCalledTimes(1);
+    stop();
+  }));
+
+  it('retries a changed version while the page is busy', fakeAsync(() => {
+    request.and.returnValues(
+      Promise.resolve(version('v1')),
+      Promise.resolve(version('v2')),
+      Promise.resolve(version('v2')),
+    );
+    let busy = true;
+    const refresh = jasmine.createSpy('refresh').and.callFake(() => busy ? false : true);
+    const stop = TestBed.inject(OperationsLiveRefreshService).watch(['learning'], refresh);
+    flushMicrotasks();
+    tick(5_000);
+    flushMicrotasks();
+    expect(refresh).toHaveBeenCalledTimes(1);
+    busy = false;
+    tick(5_000);
+    flushMicrotasks();
+    expect(refresh).toHaveBeenCalledTimes(2);
+    stop();
+  }));
+});
+
+function version(value: string) {
+  return { version: value, scopeVersions: { learning: value }, refreshedAt: '2026-09-25T00:00:00.000Z', syncStatus: { state: 'current' as const } };
+}

+ 71 - 0
projects/xiaoshu-admin/src/app/operations-live-refresh.service.ts

@@ -0,0 +1,71 @@
+import { DOCUMENT } from '@angular/common';
+import { inject, Injectable, NgZone } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { OperationsService } from './operations.service';
+import { ADMIN_SESSION_KEY } from './admin.constants';
+
+export type OperationsLiveScope = 'learning' | 'courses' | 'appointments' | 'reading' | 'review' | 'account';
+
+interface OperationsLiveVersions {
+  version: string;
+  scopeVersions: Partial<Record<OperationsLiveScope, string>>;
+  refreshedAt: string;
+  syncStatus: { state: 'current' | 'delayed' | 'unavailable'; lastSuccessAt?: string; lagSeconds?: number | null; pending?: number };
+}
+
+@Injectable({ providedIn: 'root' })
+export class OperationsLiveRefreshService {
+  private readonly operations = inject(OperationsService);
+  private readonly document = inject(DOCUMENT);
+  private readonly zone = inject(NgZone);
+
+  watch(scopes: OperationsLiveScope[], refresh: () => boolean | void): () => void {
+    let stopped = false;
+    let checking = false;
+    let version = '';
+    const authenticated = () => {
+      try {
+        const stored = globalThis.sessionStorage?.getItem(ADMIN_SESSION_KEY);
+        return Boolean(stored && JSON.parse(stored)?.sessionToken);
+      } catch {
+        return false;
+      }
+    };
+    const visible = () => authenticated()
+      && this.document.visibilityState !== 'hidden'
+      && (typeof navigator === 'undefined' || navigator.onLine !== false);
+    const check = async () => {
+      if (stopped || checking || !visible()) return;
+      checking = true;
+      try {
+        const result = await this.operations.request<OperationsLiveVersions>('ops/live/versions', { scopes });
+        const nextVersion = result?.version || version;
+        const changed = Boolean(version && nextVersion && nextVersion !== version);
+        if (!changed || refresh() !== false) version = nextVersion;
+      } catch {
+        // Current content stays visible; each later interval retries the lightweight check.
+      } finally {
+        checking = false;
+      }
+    };
+    const changes = this.operations.dataChanges$?.subscribe(() => {
+      if (!visible()) return;
+      refresh();
+      void check();
+    }) ?? new Subscription();
+    const resume = () => { if (visible()) void check(); };
+    this.document.addEventListener('visibilitychange', resume);
+    globalThis.addEventListener?.('pageshow', resume);
+    globalThis.addEventListener?.('online', resume);
+    const timer = this.zone.runOutsideAngular(() => globalThis.setInterval(() => this.zone.run(() => void check()), 5_000));
+    void check();
+    return () => {
+      stopped = true;
+      changes.unsubscribe();
+      globalThis.clearInterval(timer);
+      this.document.removeEventListener('visibilitychange', resume);
+      globalThis.removeEventListener?.('pageshow', resume);
+      globalThis.removeEventListener?.('online', resume);
+    };
+  }
+}

+ 1 - 0
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -7,6 +7,7 @@ export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments
 @Injectable({ providedIn: 'root' })
 export class OperationsService {
   private readonly functions = inject(CloudFunctionsService);
+  readonly dataChanges$ = this.functions.dataChanges$;
 
   dashboard(): Promise<OperationsDashboardData> {
     return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');

+ 17 - 8
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.ts

@@ -1,10 +1,11 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
 import { RouterLink } from '@angular/router';
 import { ArrowRight, CircleAlert, LoaderCircle, LucideAngularModule, RefreshCw } from 'lucide-angular';
 import { OperationsDashboardData, OperationsDashboardMetric, ScheduleTrendData, ScheduleTrendPeriod } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { AdminSessionService } from '../admin-session.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 @Component({
   selector: 'app-admin-dashboard',
@@ -14,7 +15,7 @@ import { AdminSessionService } from '../admin-session.service';
   styleUrls: ['./admin-dashboard.component.scss', './admin-dashboard-trend.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AdminDashboardComponent implements OnInit {
+export class AdminDashboardComponent implements OnInit, OnDestroy {
   @ViewChild('trendScroller') private trendScroller?: ElementRef<HTMLElement>;
   readonly data = signal<OperationsDashboardData | null>(null);
   readonly loading = signal(true);
@@ -28,17 +29,25 @@ export class AdminDashboardComponent implements OnInit {
   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 liveRefresh = inject(OperationsLiveRefreshService);
   private readonly sessions = inject(AdminSessionService);
-  ngOnInit(): void { void this.load(); }
-  async load(): Promise<void> {
-    this.loading.set(true); this.error.set('');
+  private stopLiveRefresh?: () => void;
+  private loadRequest = 0;
+  ngOnInit(): void {
+    void this.load();
+    this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'review', 'account'], () => { if (this.loading() || this.trendLoading()) return false; void this.load(true); return true; });
+  }
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+  async load(background = false): Promise<void> {
+    const request = ++this.loadRequest;
+    if (!background) this.loading.set(true); this.error.set('');
     try {
-      const data = await this.operations.dashboard(); this.data.set(data); this.sessions.applyIdentity(data.identity);
+      const data = await this.operations.dashboard(); if (request !== this.loadRequest) return; 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); }
+    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 selectTrendPeriod(period: ScheduleTrendPeriod): Promise<void> {

+ 8 - 6
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts

@@ -1,22 +1,24 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, HostListener, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import { ArrowLeft, Building2, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, GraduationCap, LucideAngularModule, Phone, Plus, RefreshCw, Star, Trash2, UserRound, WalletCards } from 'lucide-angular';
 import { CoachDetailData, ScheduleAppointment } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 type MonthSection = 'schedule' | 'lessons' | 'payroll';
 const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
 
 @Component({selector:'app-operations-coach-detail',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
-export class OperationsCoachDetailComponent implements OnInit{
- readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly currentMonth=CURRENT_MONTH;readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
- ngOnInit():void{void this.load();}
- async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh),current=this.data();if(!current){this.data.set(detail);this.scheduleMonth.set(detail.payrollMonth);this.lessonsMonth.set(detail.payrollMonth);this.payrollMonth.set(detail.payrollMonth);this.lessonsPayroll.set(detail.currentPayroll);}else{this.data.set({...detail,appointments:this.scheduleMonth()===detail.payrollMonth?detail.appointments:current.appointments,lessons:this.lessonsMonth()===detail.payrollMonth?detail.lessons:current.lessons,payrolls:this.payrollMonth()===detail.payrollMonth?detail.payrolls:current.payrolls});if(this.lessonsMonth()===detail.payrollMonth)this.lessonsPayroll.set(detail.currentPayroll);}this.summaryMonth.set(detail.payrollMonth);}catch(error){this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{this.loading.set(false);}}
- async changeSectionMonth(section:MonthSection,value:string):Promise<void>{if(!/^\d{4}-\d{2}$/.test(value)||value===this.sectionMonth(section))return;const previous=this.sectionMonth(section);this.setSectionMonth(section,value);this.sectionLoading.set(section);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,value,false);this.data.update((current)=>{if(!current)return detail;if(section==='schedule')return{...current,appointments:detail.appointments};if(section==='lessons')return{...current,lessons:detail.lessons,reviews:detail.reviews};return{...current,payrolls:detail.payrolls};});if(section==='lessons')this.lessonsPayroll.set(detail.currentPayroll);}catch(error){this.setSectionMonth(section,previous);this.error.set(error instanceof Error?error.message:'月份数据加载失败');}finally{this.sectionLoading.set(null);}}
+export class OperationsCoachDetailComponent implements OnInit,OnDestroy{
+ readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly currentMonth=CURRENT_MONTH;readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly liveRefresh=inject(OperationsLiveRefreshService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';private stopLiveRefresh?:()=>void;private loadRequest=0;
+ ngOnInit():void{void this.load();this.stopLiveRefresh=this.liveRefresh.watch(['appointments','review','account'],()=>{if(this.loading()||this.sectionLoading()||this.saving())return false;void this.load(false,true);return true;});}
+ ngOnDestroy():void{this.stopLiveRefresh?.();}
+ async load(refresh=false,background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh);if(request!==this.loadRequest)return;const current=this.data();if(!current){this.data.set(detail);this.scheduleMonth.set(detail.payrollMonth);this.lessonsMonth.set(detail.payrollMonth);this.payrollMonth.set(detail.payrollMonth);this.lessonsPayroll.set(detail.currentPayroll);}else{this.data.set({...detail,appointments:this.scheduleMonth()===detail.payrollMonth?detail.appointments:current.appointments,lessons:this.lessonsMonth()===detail.payrollMonth?detail.lessons:current.lessons,payrolls:this.payrollMonth()===detail.payrollMonth?detail.payrolls:current.payrolls});if(this.lessonsMonth()===detail.payrollMonth)this.lessonsPayroll.set(detail.currentPayroll);}this.summaryMonth.set(detail.payrollMonth);}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 changeSectionMonth(section:MonthSection,value:string):Promise<void>{if(!/^\d{4}-\d{2}$/.test(value)||value===this.sectionMonth(section))return;this.loadRequest++;const previous=this.sectionMonth(section);this.setSectionMonth(section,value);this.sectionLoading.set(section);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,value,false);this.data.update((current)=>{if(!current)return detail;if(section==='schedule')return{...current,appointments:detail.appointments};if(section==='lessons')return{...current,lessons:detail.lessons,reviews:detail.reviews};return{...current,payrolls:detail.payrolls};});if(section==='lessons')this.lessonsPayroll.set(detail.currentPayroll);}catch(error){this.setSectionMonth(section,previous);this.error.set(error instanceof Error?error.message:'月份数据加载失败');}finally{this.sectionLoading.set(null);}}
  @HostListener('document:click') closeMonthPicker():void{this.activeMonthPicker.set(null);}
  @HostListener('document:keydown.escape') closeMonthPickerWithKeyboard():void{this.closeMonthPicker();}
  toggleMonthPicker(section:MonthSection,event:Event):void{event.stopPropagation();if(this.loading()||this.isSectionLoading(section))return;if(this.activeMonthPicker()===section){this.activeMonthPicker.set(null);return;}const year=Number(this.sectionMonth(section).slice(0,4));this.monthPickerYear.set(year||new Date().getFullYear());this.activeMonthPicker.set(section);}

+ 5 - 4
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts

@@ -1,18 +1,19 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
 import { ChevronLeft, ChevronRight, Eye, GraduationCap, LoaderCircle, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, X } from 'lucide-angular';
 import { CoachPage, CoachSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 @Component({selector:'app-operations-coaches',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
-export class OperationsCoachesComponent implements OnInit{
+export class OperationsCoachesComponent implements OnInit,OnDestroy{
  readonly page=signal<CoachPage|null>(null);readonly queryInput=signal('');readonly statusInput=signal('all');readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly querying=signal(false);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
  readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');
- readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,LoaderCircle,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,X};private readonly operations=inject(OperationsService);
- ngOnInit():void{void this.load(1);}async load(page=1,refresh=false,action:'load'|'query'|'refresh'='load'):Promise<void>{this.loading.set(true);this.querying.set(action==='query');this.refreshing.set(action==='refresh');this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);this.querying.set(false);this.refreshing.set(false);}}
+ readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,LoaderCircle,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,X};private readonly operations=inject(OperationsService);private readonly liveRefresh=inject(OperationsLiveRefreshService);private stopLiveRefresh?:()=>void;private loadRequest=0;
+ ngOnInit():void{void this.load(1);this.stopLiveRefresh=this.liveRefresh.watch(['appointments','account','review'],()=>{if(this.loading()||this.querying()||this.refreshing()||this.saving())return false;void this.load(this.page()?.page||1,false,'load',true);return true;});}ngOnDestroy():void{this.stopLiveRefresh?.();}async load(page=1,refresh=false,action:'load'|'query'|'refresh'='load',background=false):Promise<void>{const request=++this.loadRequest;if(!background)this.loading.set(true);this.querying.set(!background&&action==='query');this.refreshing.set(!background&&action==='refresh');this.error.set('');try{const result=await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh);if(request===this.loadRequest)this.page.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);this.querying.set(false);this.refreshing.set(false);}}}
  applyFilters():void{this.query.set(this.queryInput().trim());this.status.set(this.statusInput());void this.load(1,false,'query');}
  reset():void{this.queryInput.set('');this.statusInput.set('all');this.query.set('');this.status.set('all');void this.load(1,false,'query');}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
  openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}

+ 15 - 6
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts

@@ -1,12 +1,13 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnInit, ViewChild, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import html2pdf from 'html2pdf.js';
 import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
 import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 const localMonth = (): string => {
   const now = new Date();
@@ -24,7 +25,7 @@ interface CalendarDay { day: number; date: string; }
   styleUrls: ['./operations-professional.scss', './operations-learning-reports.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class OperationsLearningReportsComponent implements OnInit {
+export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   @ViewChild('pdfContent') pdfContent?: ElementRef<HTMLElement>;
 
   readonly data = signal<LearningReportPage | null>(null);
@@ -56,14 +57,20 @@ export class OperationsLearningReportsComponent implements OnInit {
   readonly icons = { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, RotateCcw, Search, X };
 
   private readonly operations = inject(OperationsService);
+  private readonly liveRefresh = inject(OperationsLiveRefreshService);
   private readonly route = inject(ActivatedRoute);
+  private stopLiveRefresh?: () => void;
+  private loadRequest = 0;
 
   ngOnInit(): void {
     this.studentObjectId.set(this.route.snapshot.queryParamMap.get('studentObjectId') || '');
     const requestedReport = this.route.snapshot.queryParamMap.get('report') || '';
     void this.load(false).then(() => requestedReport ? this.open(requestedReport) : undefined);
+    this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'reading', 'review'], () => { if (this.loading() || this.searching() || this.refreshing() || this.detailLoading()) return false; void this.load(false, true); return true; });
   }
 
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+
   private filters(): Record<string, unknown> {
     return {
       month: this.month(), dateFrom: this.dateFrom(), dateTo: this.dateTo(), status: this.status(),
@@ -71,17 +78,19 @@ export class OperationsLearningReportsComponent implements OnInit {
     };
   }
 
-  async load(refresh = false): Promise<void> {
-    if (refresh) this.refreshing.set(true); else this.loading.set(true);
+  async load(refresh = false, background = false): Promise<void> {
+    const request = ++this.loadRequest;
+    if (!background) { if (refresh) this.refreshing.set(true); else this.loading.set(true); }
     this.error.set(''); this.message.set('');
     try {
       const page = await this.operations.learningReports(this.page(), this.pageSize, this.search(), this.filters(), refresh);
+      if (request !== this.loadRequest) return;
       this.data.set(page);
       if (refresh) this.message.set(page.sourceStatus.state === 'synced' ? '学习记录已同步到最新水位' : page.sourceStatus.message);
     } catch (error) {
-      this.error.set(error instanceof Error ? error.message : '学习报表加载失败');
+      if (request === this.loadRequest) this.error.set(error instanceof Error ? error.message : '学习报表加载失败');
     } finally {
-      this.loading.set(false); this.refreshing.set(false);
+      if (!background && request === this.loadRequest) { this.loading.set(false); this.refreshing.set(false); }
     }
   }
 

Разница между файлами не показана из-за своего большого размера
+ 3 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


+ 8 - 6
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -2,12 +2,13 @@ import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { ActivatedRoute } from '@angular/router';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, LucideAngularModule, Plus, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
@@ -125,11 +126,11 @@ export function scheduleCoachBusy(
 }
 
 @Component({ selector:'app-operations-schedule', standalone:true, imports: [AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
-export class OperationsScheduleComponent implements OnInit {
+export class OperationsScheduleComponent implements OnInit, OnDestroy {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
-  readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService);
+  readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService); private readonly liveRefresh=inject(OperationsLiveRefreshService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
   readonly visibleAppointments=computed(()=>(this.data()?.appointments||[]).filter(item=>scheduleMatchesStatus(item,this.statusFilter())&&matchesScheduleSearch(this.scheduleSearch(),[item.studentName,item.coachName,item.courseName,item.studentId,item.coachId,item.courseId,item.generalId])));
   readonly visibleCoaches=computed(()=>scheduleCoachesWithAppointments(this.data()?.coaches||[],this.visibleAppointments()));
@@ -141,7 +142,7 @@ export class OperationsScheduleComponent implements OnInit {
   readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
   readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
   readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
-  private initialRange=true; private saveKey=''; private saveFingerprint='';
+  private initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
   previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
   canSavePreview():boolean{return Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
@@ -149,9 +150,10 @@ export class OperationsScheduleComponent implements OnInit {
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
   rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
-  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);}});}
+  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?.();}
   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():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
+  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());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());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);}}
   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);}

+ 14 - 7
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.ts

@@ -1,12 +1,13 @@
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, HostListener, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import { Building2, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, GraduationCap, RefreshCw, Search, UserRound, Users, WalletCards } from 'lucide-angular';
 import { LucideAngularModule } from 'lucide-angular';
 import { ScheduleAppointment, StoreDetailData } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 type StoreDetailTab = 'members' | 'coaches' | 'schedule' | 'courses';
 
@@ -20,7 +21,7 @@ const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/S
   styleUrls: ['./operations-professional.scss', './operations-store-detail.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class OperationsStoreDetailComponent implements OnInit {
+export class OperationsStoreDetailComponent implements OnInit, OnDestroy {
   readonly data = signal<StoreDetailData | null>(null);
   readonly loading = signal(true);
   readonly error = signal('');
@@ -53,9 +54,13 @@ export class OperationsStoreDetailComponent implements OnInit {
   });
 
   private readonly operations = inject(OperationsService);
+  private readonly liveRefresh = inject(OperationsLiveRefreshService);
+  private stopLiveRefresh?: () => void;
+  private loadRequest = 0;
   readonly objectId = inject(ActivatedRoute).snapshot.paramMap.get('objectId') || '';
 
-  ngOnInit(): void { void this.load(); }
+  ngOnInit(): void { void this.load(); this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'review', 'account'], () => { if (this.loading()) return false; void this.load(false, true); return true; }); }
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
 
   @HostListener('document:click')
   closeMonthPicker(): void { this.monthPickerOpen.set(false); }
@@ -94,17 +99,19 @@ export class OperationsStoreDetailComponent implements OnInit {
     return `${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`;
   }
 
-  async load(refresh = false): Promise<void> {
-    this.loading.set(true);
+  async load(refresh = false, background = false): Promise<void> {
+    const request = ++this.loadRequest;
+    if (!background) this.loading.set(true);
     this.error.set('');
     try {
       const detail = await this.operations.storeDetail(this.objectId, this.month(), refresh);
+      if (request !== this.loadRequest) return;
       this.data.set(detail);
       this.month.set(detail.month);
     } catch (error) {
-      this.error.set(error instanceof Error ? error.message : '门店经营数据加载失败');
+      if (request === this.loadRequest) this.error.set(error instanceof Error ? error.message : '门店经营数据加载失败');
     } finally {
-      this.loading.set(false);
+      if (!background && request === this.loadRequest) this.loading.set(false);
     }
   }
 

+ 15 - 8
projects/xiaoshu-admin/src/app/pages/operations-stores.component.ts

@@ -1,12 +1,13 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
 import { Building2, ChevronLeft, ChevronRight, Eye, Pencil, Plus, RefreshCw, Search, Trash2, Users, X } from 'lucide-angular';
 import { LucideAngularModule } from 'lucide-angular';
 import { StorePage, StoreSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 @Component({
   selector: 'app-operations-stores',
@@ -16,7 +17,7 @@ import { OperationsService } from '../operations.service';
   styleUrls: ['./operations-professional.scss', './operations-stores.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class OperationsStoresComponent implements OnInit {
+export class OperationsStoresComponent implements OnInit, OnDestroy {
   readonly page = signal<StorePage | null>(null);
   readonly query = signal('');
   readonly status = signal('all');
@@ -39,18 +40,24 @@ export class OperationsStoresComponent implements OnInit {
 
   readonly icons = { Building2, ChevronLeft, ChevronRight, Eye, Pencil, Plus, RefreshCw, Search, Trash2, Users, X };
   private readonly operations = inject(OperationsService);
+  private readonly liveRefresh = inject(OperationsLiveRefreshService);
+  private stopLiveRefresh?: () => void;
+  private loadRequest = 0;
 
-  ngOnInit(): void { void this.load(1); }
+  ngOnInit(): void { void this.load(1); this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'account'], () => { if (this.loading() || this.saving()) return false; void this.load(this.page()?.page || 1, true); return true; }); }
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
 
-  async load(page = 1): Promise<void> {
-    this.loading.set(true);
+  async load(page = 1, background = false): Promise<void> {
+    const request = ++this.loadRequest;
+    if (!background) this.loading.set(true);
     this.error.set('');
     try {
-      this.page.set(await this.operations.stores(page, 20, this.query(), { status: this.status() }));
+      const result = await this.operations.stores(page, 20, this.query(), { status: this.status() });
+      if (request === this.loadRequest) this.page.set(result);
     } catch (error) {
-      this.error.set(error instanceof Error ? error.message : '门店数据加载失败');
+      if (request === this.loadRequest) this.error.set(error instanceof Error ? error.message : '门店数据加载失败');
     } finally {
-      this.loading.set(false);
+      if (!background && request === this.loadRequest) this.loading.set(false);
     }
   }
 

+ 13 - 9
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -1,6 +1,6 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { Router, RouterLink } from '@angular/router';
 import { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, Upload, UserPlus, Users, X } from 'lucide-angular';
@@ -9,6 +9,7 @@ import { MemberAgentOption, MemberImportPreview, MemberListSort, MemberPage } fr
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { OperationsService } from '../operations.service';
+import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 
 @Component({
   selector: 'app-operations-users', standalone: true,
@@ -16,7 +17,7 @@ import { OperationsService } from '../operations.service';
   templateUrl: './operations-users.component.html', styleUrl: './operations-users.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class OperationsUsersComponent implements OnInit {
+export class OperationsUsersComponent implements OnInit, OnDestroy {
   readonly page = signal<MemberPage | null>(null);
   readonly query = signal(''); readonly status = signal('all'); readonly learningStatus = signal('all'); readonly risk = signal('all');
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
@@ -24,21 +25,24 @@ export class OperationsUsersComponent implements OnInit {
   readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
   readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
-  private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
+  private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService); private readonly liveRefresh = inject(OperationsLiveRefreshService); private stopLiveRefresh?: () => void;
+  private loadRequest = 0;
 
   readonly canCreate = computed(() => this.sessions.session()?.operationsRole !== 'ops-auditor');
   readonly canBulkImport = computed(() => this.sessions.session()?.operationsRole === 'ops-manager');
   readonly filteredCreateAgents = computed(() => { const keyword = this.createAgentSearch().trim().toLowerCase(); return (this.page()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
-  ngOnInit(): void { void this.load(1); }
+  ngOnInit(): void { void this.load(1); this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'account'], () => { if (this.loading() || this.searching() || this.refreshing() || this.saving()) return false; void this.load(this.page()?.page ?? 1, false, true); return true; }); }
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
   private filters(): Record<string, unknown> { return { status: this.status(), learningStatus: this.learningStatus(), risk: this.risk(), parentUserId: this.parentUserId(), registeredFrom: this.registeredFrom(), registeredTo: this.registeredTo() }; }
   private sort(): MemberListSort { const [field, direction] = this.sortValue().split(':') as [MemberListSort['field'], MemberListSort['direction']]; return { field, direction }; }
 
-  async load(page = this.page()?.page ?? 1, refresh = false): Promise<void> {
-    this.loading.set(true); this.error.set('');
-    try { const result = await this.operations.users(page, 20, this.query(), this.filters(), this.sort(), refresh); this.page.set(result); this.lastRefreshedAt.set(result.refreshedAt || new Date().toISOString()); }
-    catch (error) { this.error.set(error instanceof Error ? error.message : '会员列表加载失败'); }
-    finally { this.loading.set(false); }
+  async load(page = this.page()?.page ?? 1, refresh = false, background = false): Promise<void> {
+    const request = ++this.loadRequest;
+    if (!background) this.loading.set(true); this.error.set('');
+    try { const result = await this.operations.users(page, 20, this.query(), this.filters(), this.sort(), refresh); if (request !== this.loadRequest) return; this.page.set(result); this.lastRefreshedAt.set(result.refreshedAt || new Date().toISOString()); }
+    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 searchMembers(): Promise<void> {
     if (this.searching()) return;

+ 4 - 3
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -40,7 +40,7 @@ describe('ApiService', () => {
     expect(actual).toEqual([{ ID: 1 }]);
   });
 
-  it('shares short-lived overview reads and bypasses the cache for an explicit refresh', () => {
+  it('coalesces concurrent overview reads but fetches fresh data after completion', () => {
     sessionToken = 'r:session-token';
     const first: unknown[] = [];
     api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
@@ -51,7 +51,8 @@ describe('ApiService', () => {
     expect(first.length).toBe(2);
 
     api.post('app_account_overview', { uid: 42 }).subscribe((value) => first.push(value.result));
-    http.expectNone(API_CONFIG.cloudFunctionUrl);
+    const next = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    next.flush({ retcode: 0, result: { profile: { userId: 42, version: 2 } } });
     expect(first.length).toBe(3);
 
     api.post('app_account_overview', { uid: 42, refresh: true }).subscribe();
@@ -347,5 +348,5 @@ describe('teaching request reliability',()=>{
  afterEach(()=>http.verify());
  for(const action of ['e_order_complete_feedback','vote_add','unit_record_update','e_get_21list_tj'])it(action+' carries the current session',()=>{api.post(action,{uid:1761}).subscribe();const req=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(req.request.body.token).toBe('r:teacher');req.flush({retcode:0,result:{}});});
  it('does not automatically retry a write and retains its request key for manual retry',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('e_add_words',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('e_add_words',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
- it('invalidates account and learning summaries after a successful teaching write',()=>{api.post('app_learning_overview',{uid:1761}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post('app_learning_overview',{uid:1761}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});});
+ it('fetches a fresh learning summary after a successful teaching write',()=>{const results:Array<{records:Array<{id:number}>}>=[];api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});expect(results).toEqual([{records:[]},{records:[{id:1}]}]);});
 });

+ 31 - 15
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -1,7 +1,7 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { catchError, from, map, Observable, retry, shareReplay, switchMap, tap, throwError, timer } from 'rxjs';
+import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer } from 'rxjs';
 import { API_CONFIG } from './app.constants';
 import { requiresCloudSession } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
@@ -18,8 +18,10 @@ export class ApiService {
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
   private readonly router = inject(Router, { optional: true });
-  private readonly overviewCache = new Map<string, { expiresAt: number; response: Observable<ApiEnvelope<unknown>> }>();
+  readonly dataChanges$ = new Subject<{ source: 'local' | 'broadcast'; changedAt: string }>();
+  private readonly overviewRequests = new Map<string, Observable<ApiEnvelope<unknown>>>();
   private readonly cachedOverviewActions = new Set(['app_learning_overview', 'app_companion_overview', 'app_account_overview']);
+  private readonly dataChannel = this.createDataChannel();
   private sessionRedirecting = false;
   private readonly pendingWrites=new Map<string,string>();
 
@@ -49,7 +51,13 @@ export class ApiService {
         'Content-Type': 'application/json',
         'X-Parse-Application-Id': API_CONFIG.parseAppId,
       },
-    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => { if (!this.isReadAction(action)) this.invalidateOverviews(); this.pendingWrites.delete(requestIdentity); }));
+    }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
+      if (!this.isReadAction(action)) {
+        this.invalidateOverviews();
+        this.announceDataChange();
+      }
+      this.pendingWrites.delete(requestIdentity);
+    }));
   }
 
   upload(file: File): Observable<ApiEnvelope<string>> {
@@ -67,11 +75,11 @@ export class ApiService {
     return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
   }
 
-  invalidateOverviews(): void { this.overviewCache.clear(); }
+  invalidateOverviews(): void { this.overviewRequests.clear(); }
 
   private isReadAction(action: string): boolean {
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
-      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_review_page', 'app_lesson_report'].includes(action);
+      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report'].includes(action);
   }
 
   private normalize<T>(input: unknown): ApiEnvelope<T> {
@@ -115,24 +123,32 @@ export class ApiService {
 
   private callWithOverviewCache<T>(action: string, params: Record<string, unknown>, sessionToken: string): Observable<ApiEnvelope<T>> {
     if (!this.cachedOverviewActions.has(action)) return this.callCloud<T>(action, params, sessionToken);
-    const now = Date.now();
-    for (const [key, entry] of this.overviewCache) if (entry.expiresAt <= now) this.overviewCache.delete(key);
     const cacheParams = { ...params };
     delete cacheParams['refresh'];
     const key = `${this.sessionCacheIdentity()}|${this.parseSessionToken()}|${action}|${this.stableJson(cacheParams)}`;
-    const cached = this.overviewCache.get(key);
-    if (params['refresh'] !== true && cached?.expiresAt && cached.expiresAt > now) return cached.response as Observable<ApiEnvelope<T>>;
+    const pending = this.overviewRequests.get(key);
+    if (pending) return pending as Observable<ApiEnvelope<T>>;
     const response = this.callCloud<T>(action, params, sessionToken).pipe(
-      catchError((error) => {
-        this.overviewCache.delete(key);
-        return throwError(() => error);
-      }),
+      finalize(() => this.overviewRequests.delete(key)),
       shareReplay({ bufferSize: 1, refCount: false }),
     );
-    this.overviewCache.set(key, { expiresAt: now + 120_000, response: response as Observable<ApiEnvelope<unknown>> });
+    this.overviewRequests.set(key, response as Observable<ApiEnvelope<unknown>>);
     return response;
   }
 
+  private createDataChannel(): BroadcastChannel | null {
+    if (typeof globalThis.BroadcastChannel !== 'function') return null;
+    const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
+    channel.addEventListener('message', () => this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }));
+    return channel;
+  }
+
+  private announceDataChange(): void {
+    const event = { source: 'local' as const, changedAt: new Date().toISOString() };
+    this.dataChanges$.next(event);
+    this.dataChannel?.postMessage(event);
+  }
+
   private sessionCacheIdentity(): string {
     const current = this.sessions.user();
     return String(current?.['objectId'] || current?.userId || this.parseSessionToken().slice(-24) || 'anonymous');
@@ -173,7 +189,7 @@ export class ApiService {
   }
 
   private expireSession(): void {
-    this.overviewCache.clear();
+    this.overviewRequests.clear();
     this.sessions.logout();
     if (!this.router || this.sessionRedirecting) return;
     this.sessionRedirecting = true;

+ 85 - 0
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts

@@ -0,0 +1,85 @@
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { of, Subject } from 'rxjs';
+import { ApiService } from './api.service';
+import { LiveDataRefreshService, LiveVersions } from './live-data-refresh.service';
+import { SessionService } from './session.service';
+
+describe('LiveDataRefreshService', () => {
+  let changes: Subject<{ source: 'local' | 'broadcast'; changedAt: string }>;
+  let post: jasmine.Spy;
+  let loggedIn: boolean;
+
+  beforeEach(() => {
+    changes = new Subject();
+    post = jasmine.createSpy('post');
+    loggedIn = true;
+    TestBed.configureTestingModule({
+      providers: [
+        LiveDataRefreshService,
+        { provide: ApiService, useValue: { post, dataChanges$: changes } },
+        { provide: SessionService, useValue: { isLoggedIn: () => loggedIn } },
+      ],
+    });
+  });
+
+  it('refreshes full data only after a five-second version change', fakeAsync(() => {
+    post.and.returnValues(
+      of(envelope('learning=v1')),
+      of(envelope('learning=v2')),
+    );
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(LiveDataRefreshService).watch({
+      scopes: ['learning'],
+      studentId: () => 42,
+      onRefresh: refresh,
+    });
+
+    expect(post).toHaveBeenCalledTimes(1);
+    expect(refresh).not.toHaveBeenCalled();
+    tick(5_000);
+    expect(post).toHaveBeenCalledTimes(2);
+    expect(refresh).toHaveBeenCalledOnceWith('version');
+    stop();
+  }));
+
+  it('refreshes immediately after a local or another-tab write', () => {
+    post.and.returnValue(of(envelope('review=v1')));
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(LiveDataRefreshService).watch({
+      scopes: ['review'],
+      studentId: () => 42,
+      onRefresh: refresh,
+    });
+
+    changes.next({ source: 'local', changedAt: new Date().toISOString() });
+    changes.next({ source: 'broadcast', changedAt: new Date().toISOString() });
+    expect(refresh.calls.allArgs()).toEqual([['local-write'], ['broadcast']]);
+    stop();
+  });
+
+  it('stops checking after logout', fakeAsync(() => {
+    post.and.returnValue(of(envelope('learning=v1')));
+    const stop = TestBed.inject(LiveDataRefreshService).watch({
+      scopes: ['learning'],
+      studentId: () => 42,
+      onRefresh: () => undefined,
+    });
+    expect(post).toHaveBeenCalledTimes(1);
+    loggedIn = false;
+    tick(10_000);
+    expect(post).toHaveBeenCalledTimes(1);
+    stop();
+  }));
+});
+
+function envelope(version: string): { retcode: number; result: LiveVersions } {
+  return {
+    retcode: 0,
+    result: {
+      version,
+      scopeVersions: { learning: version, review: version },
+      refreshedAt: '2026-09-25T00:00:00.000Z',
+      syncStatus: { state: 'current', lagSeconds: 0, pending: 0 },
+    },
+  };
+}

+ 121 - 0
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts

@@ -0,0 +1,121 @@
+import { DOCUMENT } from '@angular/common';
+import { inject, Injectable, NgZone } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { ApiService } from './api.service';
+import { SessionService } from './session.service';
+
+export type LiveDataScope = 'learning' | 'courses' | 'appointments' | 'reading' | 'review' | 'account';
+
+export interface LiveSyncStatus {
+  state: 'current' | 'delayed' | 'unavailable';
+  lastSuccessAt?: string;
+  lagSeconds?: number | null;
+  pending?: number;
+}
+
+export interface LiveVersions {
+  version: string;
+  scopeVersions: Partial<Record<LiveDataScope, string>>;
+  refreshedAt: string;
+  syncStatus: LiveSyncStatus;
+}
+
+export interface LiveRefreshState {
+  checking: boolean;
+  checkedAt: string;
+  syncStatus: LiveSyncStatus;
+}
+
+interface LiveRefreshOptions {
+  scopes: LiveDataScope[];
+  studentId: () => number | undefined;
+  onRefresh: (reason: 'version' | 'local-write' | 'broadcast') => void;
+  onState?: (state: LiveRefreshState) => void;
+}
+
+@Injectable({ providedIn: 'root' })
+export class LiveDataRefreshService {
+  private readonly api = inject(ApiService);
+  private readonly document = inject(DOCUMENT);
+  private readonly zone = inject(NgZone);
+  private readonly session = inject(SessionService);
+
+  watch(options: LiveRefreshOptions): () => void {
+    let disposed = false;
+    let checking = false;
+    let identity = '';
+    let version = '';
+    let request: Subscription | undefined;
+    const state: LiveRefreshState = {
+      checking: false,
+      checkedAt: '',
+      syncStatus: { state: 'current' },
+    };
+    const publish = (patch: Partial<LiveRefreshState>) => {
+      Object.assign(state, patch);
+      options.onState?.({ ...state, syncStatus: { ...state.syncStatus } });
+    };
+    const canCheck = () => this.session.isLoggedIn()
+      && this.document.visibilityState !== 'hidden'
+      && (typeof navigator === 'undefined' || navigator.onLine !== false);
+    const check = () => {
+      if (disposed || checking || !canCheck()) return;
+      const studentId = options.studentId();
+      const nextIdentity = `${studentId ?? ''}|${options.scopes.join(',')}`;
+      if (nextIdentity !== identity) {
+        identity = nextIdentity;
+        version = '';
+      }
+      checking = true;
+      publish({ checking: true });
+      const response = this.api.post<LiveVersions>('app_live_versions', {
+        studentId,
+        scopes: options.scopes,
+      });
+      if (!response || typeof response.subscribe !== 'function') {
+        checking = false;
+        publish({ checking: false, syncStatus: { ...state.syncStatus, state: 'unavailable' } });
+        return;
+      }
+      request = response.subscribe({
+        next: ({ result }) => {
+          if (disposed) return;
+          const changed = Boolean(version && result.version && version !== result.version);
+          version = result.version || version;
+          checking = false;
+          publish({ checking: false, checkedAt: result.refreshedAt, syncStatus: result.syncStatus });
+          if (changed) options.onRefresh('version');
+        },
+        error: () => {
+          checking = false;
+          publish({ checking: false, checkedAt: new Date().toISOString(), syncStatus: { ...state.syncStatus, state: 'unavailable' } });
+        },
+      });
+    };
+    const refreshAfterChange = (source: 'local-write' | 'broadcast') => {
+      if (disposed || !canCheck()) return;
+      version = '';
+      options.onRefresh(source);
+      check();
+    };
+    const dataChanges = this.api.dataChanges$?.subscribe((event) => refreshAfterChange(event.source === 'local' ? 'local-write' : 'broadcast')) ?? new Subscription();
+    const onVisible = () => { if (this.document.visibilityState !== 'hidden') check(); };
+    const onPageShow = () => check();
+    const onOnline = () => check();
+    this.document.addEventListener('visibilitychange', onVisible);
+    globalThis.addEventListener?.('pageshow', onPageShow);
+    globalThis.addEventListener?.('online', onOnline);
+    const timer = this.zone.runOutsideAngular(() => globalThis.setInterval(() => this.zone.run(check), 5_000));
+    check();
+
+    return () => {
+      disposed = true;
+      request?.unsubscribe();
+      dataChanges.unsubscribe();
+      globalThis.clearInterval(timer);
+      this.document.removeEventListener('visibilitychange', onVisible);
+      globalThis.removeEventListener?.('pageshow', onPageShow);
+      globalThis.removeEventListener?.('online', onOnline);
+    };
+  }
+}

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

@@ -1,6 +1,6 @@
 <section class="center-page">
-  @if (loading()) {
-    <div class="center-sync" role="status"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="18" /><span>正在同步最新课程,可先浏览已加载内容</span></div>
+  @if (loading() || liveChecking() || liveSyncStatus().state !== 'current') {
+    <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) {
       <section class="course-band">

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

@@ -85,6 +85,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .band-empty { margin: 0; padding: 28px 16px; color: var(--ink-soft); text-align: center; }
 .center-state { display: flex; min-height: 240px; align-items: center; justify-content: center; color: var(--ink-soft); flex-direction: column; gap: 10px; }
 .center-sync { display: flex; min-height: 42px; align-items: center; justify-content: center; margin: 10px 14px 0; padding: 9px 14px; color: var(--brand-strong); background: color-mix(in srgb, var(--brand-pale) 72%, white); border: 1px solid color-mix(in srgb, var(--brand) 26%, white); border-radius: 12px; font-size: 12px; gap: 8px; }
+.center-sync.delayed { color: #8d570b; background: #fff7e9; border-color: #e7bd72; }
 .center-state.error { min-height: 140px; margin: 12px 14px 0; padding: 20px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }

+ 38 - 5
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -7,6 +7,7 @@ import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, Graduatio
 import { finalize, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
+import { LiveDataRefreshService, LiveSyncStatus } from '../../core/live-data-refresh.service';
 import { AppUser, PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
@@ -43,6 +44,8 @@ interface CompanionOverview {
   courses: Item[];
   schedules: Item[];
   refreshedAt: string;
+  version?: string;
+  syncStatus?: LiveSyncStatus;
 }
 
 @Component({
@@ -74,6 +77,9 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly calendarMonth = signal(this.firstOfMonth(new Date()));
   readonly now = signal(Date.now());
   readonly loading = signal(false);
+  readonly liveChecking = signal(false);
+  readonly refreshedAt = signal('');
+  readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   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());
@@ -93,6 +99,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 liveData = inject(LiveDataRefreshService);
   private readonly router = inject(Router);
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
@@ -103,26 +110,36 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private loadedAll = false;
   private initialScheduleLoaded = false;
   private historyFilterInitialized = false;
+  private stopLiveRefresh?: () => void;
 
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
     this.load();
+    this.stopLiveRefresh = this.liveData.watch({
+      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); },
+    });
     if (this.hasScheduleCalendar) this.nowTimer = setInterval(() => this.now.set(Date.now()), 60_000);
   }
 
   ngOnDestroy(): void {
+    this.stopLiveRefresh?.();
     this.loadId++;this.loadRequest?.unsubscribe();
     if (this.nowTimer) clearInterval(this.nowTimer);
   }
 
   turnPage(delta:number):void{this.listPage.update(p=>Math.max(1,Math.min(this.listPages(),p+delta)));this.load(false,true);}
 
-  load(refresh = false, keepPage=false): void {
+  load(refresh = false, keepPage=false, background=false): void {
     this.loadRequest?.unsubscribe(); const requestId=++this.loadId; if(!keepPage)this.listPage.set(1);
     const uid = this.session.user()?.userId;
     if (!uid) return;
-    this.loading.set(true);
-    this.error.set('');
+    if (!background) {
+      this.loading.set(true);
+      this.error.set('');
+    }
     const today = this.today();
     const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
     this.loadRequest=this.api.post<CompanionOverview>('app_companion_overview', {
@@ -135,21 +152,37 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       rangeStart: range?.start,
       rangeEnd: range?.end,
       refresh,
-    }).pipe(finalize(() => this.loading.set(false))).subscribe({
+    }).pipe(finalize(() => {
+      if (!background && requestId === this.loadId) this.loading.set(false);
+    })).subscribe({
       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 : []);
+        this.refreshedAt.set(result.refreshedAt || new Date().toISOString());
+        if (result.syncStatus) this.liveSyncStatus.set(result.syncStatus);
         this.loadedRange = range;
         this.loadedAll = !range;
         if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
         this.initialScheduleLoaded = true;
         if (this.restoredScheduleState) this.restoreScheduleScroll();
       },
-      error: (error: ApiError) => this.error.set(error.message),
+      error: (error: ApiError) => {
+        if (requestId !== this.loadId) return;
+        if (background) this.liveSyncStatus.set({ state: 'unavailable' });
+        else this.error.set(error.message);
+      },
     });
   }
 
+  liveStatusText(): string {
+    const status = this.liveSyncStatus();
+    if (status.state === 'delayed') return status.lagSeconds == null ? '数据同步延迟,正在追平' : `数据同步延迟 ${status.lagSeconds} 秒,正在追平`;
+    if (status.state === 'unavailable') return '实时同步暂时不可用,已保留当前内容';
+    if (this.loading() || this.liveChecking()) return '正在核对最新课程,可先浏览已加载内容';
+    return this.refreshedAt() ? `数据已更新 · ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(new Date(this.refreshedAt()))}` : '正在核对最新课程';
+  }
+
   groups(): Array<{ date: string; items: Item[] }> {
     const grouped = new Map<string, Item[]>();
     for (const item of this.sortedVisibleSchedules()) {

+ 9 - 7
projects/xiaoshu-mobile/src/app/features/finance/finance-page.component.ts

@@ -4,23 +4,25 @@ import { ActivatedRoute } from '@angular/router';
 import { finalize, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { SessionService } from '../../core/session.service';
+import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
 interface CreditAccount { account: string; label: string; historyType: number; balance: number; reserved: number; available: number; unit: string }
 @Component({selector:'app-finance-page',standalone:true,imports:[CommonModule],templateUrl:'./finance-page.component.html',styleUrl:'./finance-page.component.scss',changeDetection:ChangeDetectionStrategy.OnPush})
 export class FinancePageComponent implements OnInit, OnDestroy {
   readonly accounts=signal<CreditAccount[]>([]); readonly logs=signal<Record<string,unknown>[]>([]);
   readonly selected=signal('Purse'); readonly page=signal(1); readonly pages=signal(1); readonly loading=signal(false); readonly error=signal('');
   private readonly api=inject(ApiService); private readonly session=inject(SessionService); private readonly route=inject(ActivatedRoute);
-  private request?:Subscription; private requestId=0;
-  ngOnInit():void { const route=this.route.snapshot;this.selected.set(route.queryParamMap.get('account')|| (route.routeConfig?.path?.endsWith('/coin')?'SilverCoin':route.routeConfig?.path?.endsWith('/point')?'UserPoint':'Purse'));this.load(); }
-  ngOnDestroy():void{this.requestId++;this.request?.unsubscribe();}
+  private readonly liveData=inject(LiveDataRefreshService);
+  private request?:Subscription; private requestId=0; private stopLiveRefresh?:()=>void;
+  ngOnInit():void { const route=this.route.snapshot;this.selected.set(route.queryParamMap.get('account')|| (route.routeConfig?.path?.endsWith('/coin')?'SilverCoin':route.routeConfig?.path?.endsWith('/point')?'UserPoint':'Purse'));this.load();this.stopLiveRefresh=this.liveData.watch({scopes:['account'],studentId:()=>Number(this.session.user()?.userId)||undefined,onRefresh:()=>this.load(true)}); }
+  ngOnDestroy():void{this.requestId++;this.request?.unsubscribe();this.stopLiveRefresh?.();}
   choose(account:string):void{this.selected.set(account);this.page.set(1);this.load();}
   turn(delta:number):void{this.page.update(p=>Math.max(1,Math.min(this.pages(),p+delta)));this.load();}
-  load():void{
-    this.request?.unsubscribe();const id=++this.requestId;this.loading.set(true);this.error.set('');
+  load(background=false):void{
+    this.request?.unsubscribe();const id=++this.requestId;if(!background){this.loading.set(true);this.error.set('');}
     this.request=this.api.get<{accounts:CreditAccount[]}>('app_credit_overview',{uid:this.session.user()?.userId}).subscribe({next:({result})=>{
       if(id!==this.requestId)return;this.accounts.set(result.accounts);const current=result.accounts.find(a=>a.account===this.selected());if(!current){this.error.set('课时账户不存在');this.loading.set(false);return;}
-      this.request=this.api.get<unknown>('user_point_list',{uid:this.session.user()?.userId,stype:current.historyType,cpage:this.page(),psize:20}).pipe(finalize(()=>{if(id===this.requestId)this.loading.set(false);})).subscribe({next:r=>{if(id!==this.requestId)return;this.logs.set(Array.isArray(r.result)?r.result:[]);this.pages.set(Number(r.page?.pageCount)||1);},error:(e:ApiError)=>this.error.set(e.message)});
-    },error:(e:ApiError)=>{this.error.set(e.message);this.loading.set(false);}});
+      this.request=this.api.get<unknown>('user_point_list',{uid:this.session.user()?.userId,stype:current.historyType,cpage:this.page(),psize:20}).pipe(finalize(()=>{if(!background&&id===this.requestId)this.loading.set(false);})).subscribe({next:r=>{if(id!==this.requestId)return;this.logs.set(Array.isArray(r.result)?r.result:[]);this.pages.set(Number(r.page?.pageCount)||1);},error:(e:ApiError)=>{if(!background)this.error.set(e.message);}});
+    },error:(e:ApiError)=>{if(!background){this.error.set(e.message);this.loading.set(false);}}});
   }
   amount(value:unknown):number{return Number(value)||0;}
   date(value:unknown):string{return value?new Date(String(value)).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'—';}

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

@@ -4,6 +4,8 @@
   @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()) {
   @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> }
   <section class="membership-card" aria-label="账号与学习权益">

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

@@ -7,6 +7,10 @@
 .profile-copy span { font-size: 12px; opacity: .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-live-status.delayed { color: #8d570b; }
+.spin { animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
 .account-state { display: flex; min-height: 64px; align-items: center; margin: 12px 14px 0; padding: 11px 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); gap: 9px; }
 .account-state span { min-width: 0; flex: 1; color: var(--danger); font-size: 12px; }
 .account-state lucide-icon { color: var(--danger); }

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

@@ -1,11 +1,12 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+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, LogOut, LucideAngularModule, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound } from 'lucide-angular';
+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 { 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 { AppUser } from '../../core/models';
 import { SessionService } from '../../core/session.service';
 import { buildAccountMembership, CompanionLearningSummary } from './account-membership';
@@ -28,6 +29,8 @@ interface AccountOverview {
   companionSummary?: Pick<CompanionLearningSummary, 'completedSessions' | 'completedMinutes'>;
   antiForgetSummary?: AntiForgetHistorySummary;
   refreshedAt: string;
+  version?: string;
+  syncStatus?: LiveSyncStatus;
 }
 
 @Component({
@@ -38,7 +41,7 @@ interface AccountOverview {
   styleUrl: './account-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AccountPageComponent implements OnInit {
+export class AccountPageComponent implements OnInit, OnDestroy {
   readonly session = inject(SessionService);
   readonly version = APP_VERSION;
   readonly coachPanel = signal<CoachPanelSummary>({ todaySchedules: 0, completedLessons: 0, reviewIncome: 0, reviewRecords: 0, totalLessons: 0, totalHours: 0, course30: 0, course60: 0, trialLessons: 0 });
@@ -48,10 +51,13 @@ export class AccountPageComponent implements OnInit {
   readonly antiForgetError = signal('');
   readonly avatarFailed = signal(false);
   readonly companionLoading = signal(false);
+  readonly liveChecking = signal(false);
+  readonly refreshedAt = signal('');
+  readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   readonly antiForgetSummary = signal<AntiForgetHistorySummary>({ total: 0, completed: 0, progress: 0, todayTotal: 0, todayCompleted: 0, duePending: 0, scheduled: 0, scheduledDays: 0 });
   readonly membership = computed(() => buildAccountMembership(this.session.user()));
   readonly companionSummary = signal<CompanionLearningSummary>({ completedSessions: 0, completedMinutes: 0, durationLabel: '0 小时' });
-  readonly icons = { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LogOut, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound };
+  readonly icons = { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LoaderCircle, LogOut, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound };
   readonly services = computed(() => Number(this.session.groupId()) === 3 ? [
     { path: '/pages/home/shop', label: '陪练排课', icon: CalendarDays },
     { path: '/pages/member/ke_xiao', label: '销课记录', icon: CheckCircle2 },
@@ -65,35 +71,52 @@ export class AccountPageComponent implements OnInit {
   ]);
 
   private readonly api = inject(ApiService);
+  private readonly liveData = inject(LiveDataRefreshService);
   private readonly router = inject(Router);
+  private stopLiveRefresh?: () => void;
+  private overviewRequestId = 0;
 
   ngOnInit(): void {
     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); },
+    });
   }
 
+  ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+
   loadAntiForget(): void { this.loadAccountOverview(true); }
   retryOverview(): void { this.loadAccountOverview(true); }
 
-  private loadAccountOverview(refresh: boolean): void {
+  private loadAccountOverview(refresh: boolean, background = false): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
+    const requestId = ++this.overviewRequestId;
     const coach = Number(this.session.groupId()) === 3;
-    this.coachPanelLoading.set(coach);
-    this.companionLoading.set(!coach);
-    this.antiForgetLoading.set(!coach);
-    this.antiForgetError.set('');
-    this.overviewError.set('');
+    if (!background) {
+      this.coachPanelLoading.set(coach);
+      this.companionLoading.set(!coach);
+      this.antiForgetLoading.set(!coach);
+      this.antiForgetError.set('');
+      this.overviewError.set('');
+    }
     this.api.post<AccountOverview>('app_account_overview', { uid, refresh }).pipe(
       finalize(() => {
+        if (background || requestId !== this.overviewRequestId) return;
         this.coachPanelLoading.set(false);
         this.companionLoading.set(false);
         this.antiForgetLoading.set(false);
       }),
     ).subscribe({
       next: ({ result }) => {
-        if (!result) return;
+        if (!result || requestId !== this.overviewRequestId) return;
+        this.refreshedAt.set(result.refreshedAt || new Date().toISOString());
+        if (result.syncStatus) this.liveSyncStatus.set(result.syncStatus);
         this.session.patchUser(result.profile ?? {});
         if (result.coachPanel) this.coachPanel.set(result.coachPanel);
         if (result.companionSummary) {
@@ -103,6 +126,11 @@ export class AccountPageComponent implements OnInit {
         if (result.antiForgetSummary) this.antiForgetSummary.set(result.antiForgetSummary);
       },
       error: () => {
+        if (requestId !== this.overviewRequestId) return;
+        if (background) {
+          this.liveSyncStatus.set({ state: 'unavailable' });
+          return;
+        }
         this.overviewError.set(coach ? '老师工作数据暂时无法加载' : '账号数据暂时无法加载');
         if (!coach) this.antiForgetError.set('抗遗忘数据暂时无法加载');
       },
@@ -111,6 +139,12 @@ export class AccountPageComponent implements OnInit {
 
   logout(): void { this.session.logout(); void this.router.navigateByUrl('/pages/member/wxauth'); }
   avatarUrl(): string { return this.api.assetUrl(this.session.user()?.userFace); }
+  liveStatusText(): string {
+    const status = this.liveSyncStatus();
+    if (status.state === 'delayed') return '数据同步延迟,正在追平';
+    if (status.state === 'unavailable') return '实时同步暂时不可用';
+    return this.refreshedAt() ? `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(new Date(this.refreshedAt()))}` : '正在核对最新数据';
+  }
 
   private formatMinutes(minutes: number): string {
     if (!minutes) return '0 小时';

+ 33 - 12
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -1,6 +1,6 @@
 import { ReviewPage, reviewOverview } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, LucideAngularModule, RefreshCw, Search, Sparkles, UserRound, Zap } from 'lucide-angular';
 import { finalize } from 'rxjs';
@@ -8,6 +8,7 @@ import { antiForgetCompleted, antiForgetDate, buildAntiForgetView, findAntiForge
 import { ApiError, 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';
 
 type Item = Record<string, unknown>;
 
@@ -19,7 +20,7 @@ type Item = Record<string, unknown>;
   styleUrl: './home-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class HomePageComponent implements OnInit {
+export class HomePageComponent implements OnInit, OnDestroy {
   readonly session = inject(SessionService);
   readonly stats = signal<Record<string, number>>({});
   readonly statsLoading = signal(false);
@@ -46,17 +47,33 @@ export class HomePageComponent implements OnInit {
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
   private readonly platform = inject(PlatformService);
+  private readonly liveData = inject(LiveDataRefreshService);
+  private stopLiveRefresh?: () => void;
 
   ngOnInit(): void {
     const uid = this.session.user()?.userId;
     if (uid) {
       this.loadStats(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);
+          this.loadAntiForget(currentId, true);
+        },
+      });
     }
     if (this.isCoach()) return;
     this.loadRecommendations();
   }
 
+  ngOnDestroy(): void {
+    this.stopLiveRefresh?.();
+  }
+
   retryCoachStats(): void {
     const uid = this.session.user()?.userId;
     if (uid) this.loadStats(uid);
@@ -138,27 +155,31 @@ export class HomePageComponent implements OnInit {
     });
   }
 
-  private loadStats(uid: string | number): void {
-    this.statsLoading.set(true);
-    this.coachStatsError.set('');
+  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(() => this.statsLoading.set(false)),
+      finalize(() => { if (!background) this.statsLoading.set(false); }),
     ).subscribe({
       next: ({ result }) => this.stats.set(result || {}),
       error: () => {
-        if (this.isCoach()) this.coachStatsError.set('今日业务数据暂时无法加载');
+        if (!background && this.isCoach()) this.coachStatsError.set('今日业务数据暂时无法加载');
       },
     });
   }
 
-  private loadAntiForget(uid: string | number): void {
-    this.antiForgetLoading.set(true);
-    this.antiForgetError.set('');
+  private loadAntiForget(uid: string | number, background = false): void {
+    if (!background) {
+      this.antiForgetLoading.set(true);
+      this.antiForgetError.set('');
+    }
     this.api.post<ReviewPage>('app_review_page', { uid, page:1,pageSize:20,status:'pending' }).pipe(
-      finalize(() => this.antiForgetLoading.set(false)),
+      finalize(() => { if (!background) this.antiForgetLoading.set(false); }),
     ).subscribe({
       next: ({ result }) => {this.reviewData.set(result);this.antiForgetItems.set(result.items);},
-      error: () => this.antiForgetError.set('抗遗忘计划暂时无法加载'),
+      error: () => { if (!background) this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
     });
   }
 

+ 11 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -44,6 +44,7 @@ import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core
 import { isLearningStage, learningReturnUrl } from '../../core/learning-navigation';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
+import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
 
 type SourceItem = Record<string, unknown>;
 
@@ -265,10 +266,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly api = inject(ApiService);
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
+  private readonly liveData = inject(LiveDataRefreshService);
   private readonly wordPreviewPageSize = 30;
   private examTimer: ReturnType<typeof setInterval> | null = null;
   private antiStartedAt = Date.now();
   private calendarRequest?: Subscription;
+  private stopLiveRefresh?: () => void;
 
   ngOnInit(): void {
     if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading) {
@@ -281,10 +284,18 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       return;
     }
     this.load();
+    if (this.isCalendarPage) {
+      this.stopLiveRefresh = this.liveData.watch({
+        scopes: ['review', 'account'],
+        studentId: () => Number(this.memoryStudentId() || this.session.user()?.userId) || undefined,
+        onRefresh: () => this.loadCalendar(),
+      });
+    }
   }
 
   ngOnDestroy(): void {
     this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe();
+    this.stopLiveRefresh?.();
     this.stopExamTimer();
     this.platform.stopAudio();
   }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -48,7 +48,7 @@
     <section class="learning-record-card" aria-labelledby="learning-record-title">
       <header>
         <div><span id="learning-record-title">学习记录</span><strong>{{ selectedCalendarLabel() }}</strong></div>
-        <div class="record-actions"><span>{{ serverTotal() }} 条@if (selectedSchedules().length) { · {{ selectedSchedules().length }} 个计划 }</span>@if (selectedDate) { <button type="button" (click)="showAllRecords()">显示全部</button> }</div>
+        <div class="record-actions"><span>{{ serverTotal() }} 条@if (selectedSchedules().length) { · {{ selectedSchedules().length }} 个计划 }</span><small class="live-update-status" [class.delayed]="liveSyncStatus().state !== 'current'"><lucide-icon [class.spin]="liveChecking() || backgroundRefreshing()" [img]="icons.LoaderCircle" [size]="13" />{{ liveStatusText() }}</small>@if (selectedDate) { <button type="button" (click)="showAllRecords()">显示全部</button> }</div>
       </header>
       @if (visibleItems().length) {
         <div class="record-list">

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -61,6 +61,8 @@
 .learning-record-card > header span { color: var(--ink-soft); font-size: 11px; }
 .learning-record-card > header strong { overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }
 .record-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
+.live-update-status { display: inline-flex; align-items: center; color: var(--ink-soft); font-size: 10px; font-weight: 650; gap: 4px; white-space: nowrap; }
+.live-update-status.delayed { color: #a15c00; }
 .record-actions > button, .record-empty button { min-height: 34px; padding: 0 11px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 32%, var(--line)); border-radius: var(--radius-sm); font-size: 11px; font-weight: 650; }
 .record-list { background: white; }
 .record-pagination { display: grid; grid-template-columns: 96px auto 96px; align-items: center; justify-content: center; padding: 14px 16px; background: var(--surface-muted); border-top: 1px solid var(--line); gap: 12px; }

+ 48 - 9
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -9,6 +9,7 @@ import { catchError, finalize, forkJoin, of, Subscription } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { extractReadingWords } from '../../core/external-reading';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
+import { LiveDataRefreshService, LiveSyncStatus } from '../../core/live-data-refresh.service';
 import { AppUser, PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
@@ -34,6 +35,8 @@ interface LearningOverview {
   recordDateCounts: Record<string, number>;
   scheduleDateCounts: Record<string, { planned: number; missed: number }>;
   refreshedAt: string;
+  version?: string;
+  syncStatus?: LiveSyncStatus;
 }
 
 @Component({
@@ -45,7 +48,7 @@ interface LearningOverview {
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class StudyRecordsPageComponent implements OnInit, OnDestroy {
-  ngOnDestroy():void{this.historyRequestId++;this.historyRequest?.unsubscribe();}
+  ngOnDestroy():void{this.stopLiveRefresh?.();this.historyRequestId++;this.historyRequest?.unsubscribe();}
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
   readonly items = signal<Item[]>([]);
   readonly courses = signal<Item[]>([]);
@@ -55,6 +58,10 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   readonly studentsLoading = signal(false);
   readonly studentsError = signal('');
   readonly loading = signal(false);
+  readonly backgroundRefreshing = signal(false);
+  readonly liveChecking = signal(false);
+  readonly refreshedAt = signal('');
+  readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   readonly error = signal('');
   readonly totalIncome = signal(0);
   readonly activeTab = signal(0);
@@ -120,12 +127,15 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
   private readonly api = inject(ApiService);
+  private readonly liveData = inject(LiveDataRefreshService);
   private readonly platform = inject(PlatformService);
   readonly session = inject(SessionService);
   private syncingHistoryQuery = false;
+  private stopLiveRefresh?: () => void;
 
   ngOnInit(): void {
     this.restoreHistoryState();
+    if (this.meta.path === 'pages/his/his') this.startLiveRefresh();
     if (this.meta.path === 'pages/his/his' && this.session.groupId() === 3) {
       this.loadStudents();
       if (this.studentId) this.load();
@@ -134,14 +144,14 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     this.load();
   }
 
-  load(refresh = false): void {
+  load(refresh = false, background = false): void {
     const uid = this.session.user()?.userId;
     if (!uid) return;
-    this.loading.set(true);
-    this.error.set('');
+    if (background) this.backgroundRefreshing.set(true);
+    else { this.loading.set(true); this.error.set(''); }
     if (this.meta.path === 'pages/member/myorder_appeal') { this.loadAppeals(uid); return; }
     if (['pages/member/ke_fuxi','pages/member/ghis'].includes(this.meta.path)) { this.loadIncome(uid); return; }
-    if (this.meta.path === 'pages/his/his') { this.loadLearningHistory(uid, refresh); return; }
+    if (this.meta.path === 'pages/his/his') { this.loadLearningHistory(uid, refresh, background); return; }
 
     const params: Record<string, unknown> = { uid, cpage: this.recordPage(), psize: this.recordPageSize };
     if (this.meta.path === 'pages/member/myke') Object.assign(params, { nid: 28, myfield2: `yhid=${uid}`, orders: 'id=DESC', cksl: 1 });
@@ -454,7 +464,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     return mobile || `学员 ${this.studentValue(student)}`;
   }
 
-  private loadLearningHistory(uid: number, refresh = false): void {
+  private loadLearningHistory(uid: number, refresh = false, background = false): void {
     this.historyRequest?.unsubscribe();const requestId=++this.historyRequestId;
     const targetUid = this.studentId || (this.session.groupId() === 3 ? 0 : uid);
     if (!targetUid) {
@@ -464,7 +474,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
       this.overviewReadingRecord.set(null);
       this.recordDateCounts.set({});
       this.scheduleDateCounts.set({});
-      this.loading.set(false);
+      if (background) this.backgroundRefreshing.set(false); else this.loading.set(false);
       return;
     }
     const calendarRange = this.datePickerRange();
@@ -476,7 +486,10 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
       calendarStart: calendarRange.start,
       calendarEnd: calendarRange.end,
       refresh,
-    }).pipe(finalize(() => this.loading.set(false))).subscribe({
+    }).pipe(finalize(() => {
+      if (requestId !== this.historyRequestId) return;
+      if (background) this.backgroundRefreshing.set(false); else this.loading.set(false);
+    })).subscribe({
       next: ({ result }) => {
         if(requestId!==this.historyRequestId)return;
         const overview = result ?? {} as LearningOverview;
@@ -490,8 +503,34 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
         this.scheduleDateCounts.set(overview.scheduleDateCounts ?? {});
         this.courses.set(Array.isArray(overview.courses) ? overview.courses : []);
         this.coursesExpanded.set(false);
+        this.refreshedAt.set(overview.refreshedAt || new Date().toISOString());
+        if (overview.syncStatus) this.liveSyncStatus.set(overview.syncStatus);
+      },
+      error: (error: ApiError) => {
+        if (requestId !== this.historyRequestId) return;
+        if (background) this.liveSyncStatus.update((status) => ({ ...status, state: 'unavailable' }));
+        else this.error.set(error.message);
+      },
+    });
+  }
+
+  liveStatusText(): string {
+    const status = this.liveSyncStatus();
+    if (status.state === 'delayed') return status.lagSeconds == null ? '数据同步延迟' : `数据同步延迟 ${status.lagSeconds} 秒`;
+    if (status.state === 'unavailable') return '实时同步暂时不可用';
+    if (!this.refreshedAt()) return '正在核对最新数据';
+    return `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(new Date(this.refreshedAt()))}`;
+  }
+
+  private startLiveRefresh(): void {
+    this.stopLiveRefresh = this.liveData.watch({
+      scopes: ['learning', 'courses', 'appointments', 'reading'],
+      studentId: () => this.studentId || Number(this.session.user()?.userId || 0) || undefined,
+      onRefresh: () => this.load(true, true),
+      onState: (state) => {
+        this.liveChecking.set(state.checking);
+        this.liveSyncStatus.set(state.syncStatus);
       },
-      error: (error: ApiError) => this.error.set(error.message),
     });
   }
 

+ 3 - 2
scripts/cloud/mobile-teaching.js

@@ -8,7 +8,7 @@ async function appCreditOverview(input,current) {
   if(!owner)fail(404,'学员不存在');
   const data=legacyData(owner),reserved=await Psql.query('SELECT cost->>\'account\' AS account,SUM((cost->>\'amount\')::numeric) AS amount FROM "CourseCreditReservation" r CROSS JOIN LATERAL jsonb_array_elements(r."creditCosts") cost WHERE r."company"=$1 AND r."studentId"=$2 AND r."state"=\'reserved\' GROUP BY cost->>\'account\'',[DEFAULT_COMPANY_ID,uid]);
   const labels={Purse:'30 分钟课时',SilverCoin:'60 分钟课时',UserExp:'体验课时',UserPoint:'陪练时长'};
-  return {studentId:uid,accounts:Object.entries(labels).map(([account,label],i)=>{const balance=number(data[account]),held=number(reserved.find(r=>r.account===account)?.amount);return{account,label,historyType:i+1,balance,reserved:held,available:balance-held,unit:account==='UserPoint'?'小时':'次'};}),...mobileSource()};
+  return {studentId:uid,accounts:Object.entries(labels).map(([account,label],i)=>{const balance=number(data[account]),held=number(reserved.find(r=>r.account===account)?.amount);return{account,label,historyType:i+1,balance,reserved:held,available:balance-held,unit:account==='UserPoint'?'小时':'次'};}),...mobileSource(),...await appLiveVersionSnapshot(uid,['account'],current)};
 }
 async function appLessonReport(input,current) {
   const order=await orderDetailRow(input.id||input.orderId,current);if(!order)fail(404,'预约不存在');
@@ -36,7 +36,8 @@ async function appReviewPage(input,current) {
   if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
   const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
   const rows=await Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",COALESCE(u."realName",u."nickname",u."username") AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewReward" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+' ORDER BY m."kywsj" DESC,m."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
-  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,...mobileSource()};
+  const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
+  return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,income:coach?await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0},dates,students,...mobileSource(),...await appLiveVersionSnapshot(versionTarget,['review','account'],current)};
 }
 
 function mobileCommandKey(input) {const key=String(input.requestKey||'');return /^[A-Za-z0-9:_-]{10,160}$/.test(key)?key:null;}

Разница между файлами не показана из-за своего большого размера
+ 26 - 4
scripts/deploy-admin-functions.mjs


+ 12 - 0
scripts/ensure-app-performance-indexes.mjs

@@ -33,21 +33,33 @@ const statements = [
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node" ON "CommonModel" ("company","modelId","nodeId")',
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node_order" ON "CommonModel" ("company","modelId","nodeId","orderId","generalId")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_date" ON "DailyStudyRecord" ("company","userId","dqrq")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_updated" ON "DailyStudyRecord" ("company","userId","updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_date_user" ON "DailyStudyRecord" ("company","dqrq","userId")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_coach_date" ON "DailyStudyRecord" ("company","pl","dqrq")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_appointment" ON "DailyStudyRecord" ("company","dsid")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_id" ON "DailyStudyRecord" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_time" ON "CourseAppointment" ("company","szyh","yysj")',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_updated" ON "CourseAppointment" ("company","szyh","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_updated" ON "CourseAppointment" ("company","pl","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_assistant_updated" ON "CourseAppointment" ("company","fxpl","updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_time" ON "CourseAppointment" ("company","pl","yysj")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_effective_date" ON "CourseAppointment" ("company","szyh",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_effective_date" ON "CourseAppointment" ("company","pl",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_id" ON "CourseAppointment" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_id" ON "CourseBinding" ("company","yhid","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_updated" ON "CourseBinding" ("company","yhid","updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_time" ON "MemoryPracticeRecord" ("company","yhid","kywsj")',
+  'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_updated" ON "MemoryPracticeRecord" ("company","yhid","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_updated" ON "MemoryPracticeRecord" ("company","plid","updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_status" ON "MemoryPracticeRecord" ("company","plid","fxzt")',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_id" ON "MemoryPracticeRecord" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_practice_company_user_word_updated" ON "PracticeRecord" ("company",(CAST("yhid" AS text)),(CAST("scid" AS text)),"updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_time" ON "LessonRecord" ("company","jsmz","lessonAt")',
+  'CREATE INDEX IF NOT EXISTS "idx_lesson_company_student_updated" ON "LessonRecord" ("company","xymz","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_updated" ON "LessonRecord" ("company","jsmz","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_reservation_company_student_updated" ON "CourseCreditReservation" ("company","studentId","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_reading_item_user_updated" ON "SurveyItem" ("user","type","updatedAt" DESC)',
+  'CREATE INDEX IF NOT EXISTS "idx_reading_log_user_updated" ON "SurveyLog" ("user","updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_node_company_node" ON "Node" ("company","nodeId")',
   'CREATE INDEX IF NOT EXISTS "idx_vocab_company_id" ON "VocabularyWord" ("company","id")',
 ];

+ 93 - 0
scripts/run-legacy-sync-worker.mjs

@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+
+import { createServer } from 'node:http';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+const SESSION_TOKEN = String(process.env.XIAOSHU_SYNC_OPERATOR_TOKEN || '').trim();
+const INTERVAL_MS = Math.max(5_000, Number(process.env.XIAOSHU_SYNC_INTERVAL_MS || 5_000));
+const HEALTH_PORT = Math.max(0, Number(process.env.XIAOSHU_SYNC_HEALTH_PORT || 9087));
+const DATASETS = String(process.env.XIAOSHU_SYNC_DATASETS || 'learning-records,practice-records,memory-records,assessments,course-bindings,appointments,lessons')
+  .split(',').map((value) => value.trim()).filter(Boolean);
+const ONCE = process.argv.includes('--once');
+
+if (!SESSION_TOKEN) throw new Error('缺少 XIAOSHU_SYNC_OPERATOR_TOKEN;同步运行器必须使用超级管理员会话');
+if (!DATASETS.length) throw new Error('XIAOSHU_SYNC_DATASETS 不能为空');
+
+const status = {
+  startedAt: new Date().toISOString(),
+  running: false,
+  lastStartedAt: '',
+  lastFinishedAt: '',
+  lastSuccessAt: '',
+  consecutiveFailures: 0,
+  slowTicks: 0,
+  datasets: {},
+};
+
+async function call(params) {
+  const response = await fetch(`${FUNCTION_URL}/xiaoshu/ops/gateway-v3`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID },
+    body: JSON.stringify({ token: SESSION_TOKEN, params }),
+    signal: AbortSignal.timeout(90_000),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.success !== true) throw new Error(`${response.status}: ${payload.message || payload.error || '同步云函数执行失败'}`);
+  return payload.data;
+}
+
+async function syncDataset(dataset) {
+  const startedAt = Date.now();
+  try {
+    const result = await call({ operation: 'ops/sync/run', dataset, maxPages: 2, pageSize: 500 });
+    status.datasets[dataset] = { ok: true, elapsedMs: Date.now() - startedAt, ...result };
+    return result;
+  } catch (error) {
+    status.datasets[dataset] = { ok: false, elapsedMs: Date.now() - startedAt, error: String(error?.message || error).slice(0, 500) };
+    throw error;
+  }
+}
+
+async function tick() {
+  if (status.running) return;
+  const tickStartedAt = Date.now();
+  status.running = true;
+  status.lastStartedAt = new Date().toISOString();
+  const results = await Promise.allSettled(DATASETS.map(syncDataset));
+  const failures = results.filter((result) => result.status === 'rejected');
+  status.lastFinishedAt = new Date().toISOString();
+  status.running = false;
+  if (failures.length) {
+    status.consecutiveFailures += 1;
+    console.error(`[legacy-sync] ${status.lastFinishedAt} ${failures.length}/${DATASETS.length} 个数据集失败`);
+    process.exitCode = ONCE ? 1 : 0;
+  } else {
+    status.consecutiveFailures = 0;
+    status.lastSuccessAt = status.lastFinishedAt;
+    const elapsedMs = Date.now() - tickStartedAt;
+    if (elapsedMs > 10_000) {
+      status.slowTicks += 1;
+      console.warn(`[legacy-sync] ${status.lastFinishedAt} 同步耗时 ${elapsedMs}ms,已超过 10 秒目标`);
+    } else {
+      status.slowTicks = 0;
+    }
+    console.log(`[legacy-sync] ${status.lastFinishedAt} ${DATASETS.length} 个数据集同步完成,耗时 ${elapsedMs}ms`);
+  }
+}
+
+if (!ONCE && HEALTH_PORT) {
+  createServer((request, response) => {
+    if (request.url !== '/health') {
+      response.writeHead(404).end();
+      return;
+    }
+    const lastSuccessAge = status.lastSuccessAt ? Math.floor((Date.now() - new Date(status.lastSuccessAt).getTime()) / 1000) : null;
+    const healthy = lastSuccessAge !== null && lastSuccessAge <= 15 && status.consecutiveFailures === 0;
+    response.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+    response.end(JSON.stringify({ healthy, lastSuccessAge, intervalMs: INTERVAL_MS, ...status }));
+  }).listen(HEALTH_PORT, '127.0.0.1', () => console.log(`[legacy-sync] 健康检查监听 http://127.0.0.1:${HEALTH_PORT}/health`));
+}
+
+await tick();
+if (!ONCE) setInterval(() => void tick(), INTERVAL_MS);

+ 44 - 0
scripts/tests/legacy-sync-worker.test.mjs

@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createServer } from 'node:http';
+import test from 'node:test';
+
+test('one worker tick requests every configured data set exactly once', async () => {
+  const requests = [];
+  const server = createServer((request, response) => {
+    let body = '';
+    request.setEncoding('utf8');
+    request.on('data', (chunk) => { body += chunk; });
+    request.on('end', () => {
+      requests.push({ url: request.url, body: JSON.parse(body) });
+      response.writeHead(200, { 'Content-Type': 'application/json' });
+      response.end(JSON.stringify({ success: true, data: { processed: 0, failures: 0, hasMore: false } }));
+    });
+  });
+  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  assert(address && typeof address === 'object');
+
+  const datasets = ['learning-records', 'appointments', 'lessons'];
+  const child = spawn(process.execPath, ['scripts/run-legacy-sync-worker.mjs', '--once'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      XIAOSHU_FUNCTION_URL: `http://127.0.0.1:${address.port}`,
+      XIAOSHU_SYNC_OPERATOR_TOKEN: 'test-session',
+      XIAOSHU_SYNC_DATASETS: datasets.join(','),
+      XIAOSHU_SYNC_HEALTH_PORT: '0',
+    },
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+  let stderr = '';
+  child.stderr.on('data', (chunk) => { stderr += chunk; });
+  const exitCode = await new Promise((resolve) => child.on('close', resolve));
+  await new Promise((resolve) => server.close(resolve));
+
+  assert.equal(exitCode, 0, stderr);
+  assert.deepEqual(requests.map((entry) => entry.url), datasets.map(() => '/xiaoshu/ops/gateway-v3'));
+  assert.deepEqual(requests.map((entry) => entry.body.params.dataset).sort(), datasets.slice().sort());
+  assert(requests.every((entry) => entry.body.params.operation === 'ops/sync/run'));
+  assert(requests.every((entry) => entry.body.token === 'test-session'));
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов