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