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

Stop five-second page refreshes and sync once after login

彭峰 21 часов назад
Родитель
Сommit
586c8d721f

+ 8 - 28
projects/xiaoshu-admin/src/app/operations-live-refresh.service.spec.ts

@@ -28,17 +28,17 @@ describe('OperationsLiveRefreshService', () => {
 
   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')));
+  it('checks on entry without polling or reloading the page afterward', fakeAsync(() => {
+    request.and.returnValue(Promise.resolve(version('v1')));
     const refresh = jasmine.createSpy('refresh');
     const stop = TestBed.inject(OperationsLiveRefreshService).watch(['learning'], refresh);
     flushMicrotasks();
     expect(refresh).not.toHaveBeenCalled();
 
-    tick(5_000);
+    tick(15_000);
     flushMicrotasks();
-    expect(request).toHaveBeenCalledTimes(2);
-    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(request).toHaveBeenCalledTimes(1);
+    expect(refresh).not.toHaveBeenCalled();
     stop();
   }));
 
@@ -65,27 +65,7 @@ describe('OperationsLiveRefreshService', () => {
     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();
-  }));
-
-  it('pauses a detached page and checks for changes when it is reattached', fakeAsync(() => {
+  it('does not check again when a detached page is reattached', 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);
@@ -100,8 +80,8 @@ describe('OperationsLiveRefreshService', () => {
     router.url = '/admin/members';
     routerEvents.next(new NavigationEnd(2, '/admin/schedule', '/admin/members'));
     flushMicrotasks();
-    expect(request).toHaveBeenCalledTimes(2);
-    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(request).toHaveBeenCalledTimes(1);
+    expect(refresh).not.toHaveBeenCalled();
     stop();
   }));
 });

+ 4 - 22
projects/xiaoshu-admin/src/app/operations-live-refresh.service.ts

@@ -1,8 +1,6 @@
-import { DOCUMENT } from '@angular/common';
-import { inject, Injectable, NgZone } from '@angular/core';
-import { NavigationEnd, Router } from '@angular/router';
+import { inject, Injectable } from '@angular/core';
+import { Router } from '@angular/router';
 import { Subscription } from 'rxjs';
-import { filter } from 'rxjs/operators';
 import { OperationsService } from './operations.service';
 import { ADMIN_SESSION_KEY } from './admin.constants';
 
@@ -18,8 +16,6 @@ interface OperationsLiveVersions {
 @Injectable({ providedIn: 'root' })
 export class OperationsLiveRefreshService {
   private readonly operations = inject(OperationsService);
-  private readonly document = inject(DOCUMENT);
-  private readonly zone = inject(NgZone);
   private readonly router = inject(Router);
 
   watch(scopes: OperationsLiveScope[], refresh: () => boolean | void): () => void {
@@ -36,10 +32,7 @@ export class OperationsLiveRefreshService {
         return false;
       }
     };
-    const visible = () => authenticated()
-      && this.router.url.split('?')[0] === ownerPath
-      && this.document.visibilityState !== 'hidden'
-      && (typeof navigator === 'undefined' || navigator.onLine !== false);
+    const visible = () => authenticated() && this.router.url.split('?')[0] === ownerPath;
     const check = async () => {
       if (stopped || checking || !visible()) return;
       checking = true;
@@ -49,7 +42,7 @@ export class OperationsLiveRefreshService {
         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.
+        // Current content stays visible; the next page entry or local change retries.
       } finally {
         checking = false;
       }
@@ -59,21 +52,10 @@ export class OperationsLiveRefreshService {
       refresh();
       void check();
     }) ?? new Subscription();
-    const resume = () => { if (visible()) void check(); };
-    const navigationChanges = this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(resume);
-    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();
-      navigationChanges.unsubscribe();
-      globalThis.clearInterval(timer);
-      this.document.removeEventListener('visibilitychange', resume);
-      globalThis.removeEventListener?.('pageshow', resume);
-      globalThis.removeEventListener?.('online', resume);
     };
   }
 }

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

@@ -22,11 +22,8 @@ describe('LiveDataRefreshService', () => {
     });
   });
 
-  it('refreshes full data only after a five-second version change', fakeAsync(() => {
-    post.and.returnValues(
-      of(envelope('learning=v1')),
-      of(envelope('learning=v2')),
-    );
+  it('checks on entry without polling or reloading the page afterward', fakeAsync(() => {
+    post.and.returnValue(of(envelope('learning=v1')));
     const refresh = jasmine.createSpy('refresh');
     const stop = TestBed.inject(LiveDataRefreshService).watch({
       scopes: ['learning'],
@@ -36,9 +33,9 @@ describe('LiveDataRefreshService', () => {
 
     expect(post).toHaveBeenCalledTimes(1);
     expect(refresh).not.toHaveBeenCalled();
-    tick(5_000);
-    expect(post).toHaveBeenCalledTimes(2);
-    expect(refresh).toHaveBeenCalledOnceWith('version');
+    tick(15_000);
+    expect(post).toHaveBeenCalledTimes(1);
+    expect(refresh).not.toHaveBeenCalled();
     stop();
   }));
 

+ 2 - 18
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts

@@ -1,5 +1,4 @@
-import { DOCUMENT } from '@angular/common';
-import { inject, Injectable, NgZone } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
 import { Subscription } from 'rxjs';
 import { ApiService } from './api.service';
 import { SessionService } from './session.service';
@@ -36,8 +35,6 @@ interface LiveRefreshOptions {
 @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 {
@@ -55,9 +52,7 @@ export class LiveDataRefreshService {
       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 canCheck = () => this.session.isLoggedIn();
     const check = () => {
       if (disposed || checking || !canCheck()) return;
       const studentId = options.studentId();
@@ -99,23 +94,12 @@ export class LiveDataRefreshService {
       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);
     };
   }
 }

+ 52 - 0
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.spec.ts

@@ -0,0 +1,52 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { of, Subject } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+import { AuthService } from '../../core/auth.service';
+import { LiveVersions } from '../../core/live-data-refresh.service';
+import { AppUser } from '../../core/models';
+import { PlatformService } from '../../core/platform.service';
+import { SessionService } from '../../core/session.service';
+import { LoginPageComponent } from './login-page.component';
+
+describe('LoginPageComponent', () => {
+  it('syncs once after login before opening the requested page', async () => {
+    const user = { userId: 42, sessionToken: 'r:session' } as AppUser;
+    const sync = new Subject<{ retcode: number; result: LiveVersions }>();
+    const post = jasmine.createSpy('post').and.returnValue(sync.asObservable());
+    const navigateByUrl = jasmine.createSpy('navigateByUrl').and.resolveTo(true);
+    const toast = jasmine.createSpy('toast');
+    let loggedIn = false;
+    const setLogin = jasmine.createSpy('setLogin').and.callFake(() => { loggedIn = true; });
+    await TestBed.configureTestingModule({
+      imports: [LoginPageComponent],
+      providers: [
+        { provide: ApiService, useValue: { post } },
+        { provide: AuthService, useValue: { login: () => of(user) } },
+        { provide: SessionService, useValue: { setLogin, isLoggedIn: () => loggedIn } },
+        { provide: PlatformService, useValue: { loading: signal(false), toast } },
+        { provide: Router, useValue: { navigateByUrl } },
+        { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({ returnUrl: '/pages/home/shop' }) } } },
+      ],
+    }).overrideComponent(LoginPageComponent, { set: { template: '' } }).compileComponents();
+    const page = TestBed.createComponent(LoginPageComponent).componentInstance;
+    page.account = 'student';
+    page.password = '123456';
+    page.privacyChecked = true;
+    page.submit();
+
+    expect(setLogin).toHaveBeenCalledOnceWith(user);
+    expect(post).toHaveBeenCalledOnceWith('app_live_versions', {
+      studentId: 42,
+      scopes: ['learning', 'courses', 'appointments', 'reading', 'review', 'account'],
+    });
+    expect(navigateByUrl).not.toHaveBeenCalled();
+
+    sync.next({ retcode: 0, result: {
+      version: 'v1', scopeVersions: {}, refreshedAt: '', syncStatus: { state: 'current' },
+    } });
+    sync.complete();
+    expect(navigateByUrl).toHaveBeenCalledOnceWith('/pages/home/shop');
+  });
+});

+ 31 - 14
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.ts

@@ -3,12 +3,13 @@ import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal }
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import { Eye, EyeOff, Leaf, LockKeyhole, LucideAngularModule, MessageSquareText, Phone, UserPlus } from 'lucide-angular';
-import { finalize } from 'rxjs';
+import { finalize, timeout } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { AuthService } from '../../core/auth.service';
 import { AppUser } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
+import { LiveVersions } from '../../core/live-data-refresh.service';
 
 @Component({
   selector: 'app-login-page',
@@ -117,14 +118,10 @@ export class LoginPageComponent implements OnInit, OnDestroy {
   private loginRequest(action: 'user_login_passwd' | 'user_login_mobile', payload: Record<string, unknown>): void {
     this.platform.loading.set(true);
     this.auditMessage.set('');
-    this.auth.login(action, payload).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
-      next: (user) => {
-        this.session.setLogin(user);
-        this.platform.toast('登录成功', 'success');
-        const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
-        void this.router.navigateByUrl(returnUrl || '/pages/home/index');
-      },
+    this.auth.login(action, payload).subscribe({
+      next: (user) => this.completeLogin(user, false),
       error: (error: ApiError) => {
+        this.platform.loading.set(false);
         if (error.message.includes('审核中')) this.auditMessage.set(error.message);
         this.platform.toast(error.message, 'error');
       },
@@ -133,13 +130,33 @@ export class LoginPageComponent implements OnInit, OnDestroy {
 
   private registerRequest(action: 'user_register' | 'user_register_mobile', payload: Record<string, unknown>): void {
     this.platform.loading.set(true);
-    this.api.post<AppUser>(action, payload).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
-      next: ({ result }) => {
-        this.session.setLogin(result);
-        this.platform.toast('注册成功', 'success');
-        void this.router.navigateByUrl('/pages/home/index');
+    this.api.post<AppUser>(action, payload).subscribe({
+      next: ({ result }) => this.completeLogin(result, true),
+      error: (error: ApiError) => {
+        this.platform.loading.set(false);
+        this.platform.toast(`注册失败:${error.message}`, 'error');
       },
-      error: (error: ApiError) => this.platform.toast(`注册失败:${error.message}`, 'error'),
+    });
+  }
+
+  private completeLogin(user: AppUser, registered: boolean): void {
+    this.session.setLogin(user);
+    if (!this.session.isLoggedIn()) {
+      this.platform.loading.set(false);
+      this.platform.toast('登录状态无效,请重新登录', 'error');
+      return;
+    }
+    const enter = (synced: boolean) => {
+      this.platform.toast(synced ? (registered ? '注册成功' : '登录成功') : '登录成功,数据同步暂不可用,请稍后手动刷新', synced ? 'success' : 'error');
+      const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
+      void this.router.navigateByUrl(registered ? '/pages/home/index' : returnUrl || '/pages/home/index');
+    };
+    this.api.post<LiveVersions>('app_live_versions', {
+      studentId: Number(user.userId),
+      scopes: ['learning', 'courses', 'appointments', 'reading', 'review', 'account'],
+    }).pipe(timeout({ first: 45_000 }), finalize(() => this.platform.loading.set(false))).subscribe({
+      next: ({ result }) => enter(result.syncStatus?.state !== 'unavailable'),
+      error: () => enter(false),
     });
   }