Procházet zdrojové kódy

fix(mobile): keep review calendar stable while loading dates

彭峰 před 1 měsícem
rodič
revize
4c41ad7dad

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

@@ -27,6 +27,7 @@ class LearningModulesApiService {
   completionResponse = new Subject<ApiEnvelope<unknown>>();
   completionResponse = new Subject<ApiEnvelope<unknown>>();
   lastAppointmentRecordParams: Record<string, unknown> = {};
   lastAppointmentRecordParams: Record<string, unknown> = {};
   studyUpdates: Record<string, unknown>[] = [];
   studyUpdates: Record<string, unknown>[] = [];
+  reviewPageResponse: Subject<ApiEnvelope<unknown>> | null = null;
   unitRows: Record<string, unknown>[] = [
   unitRows: Record<string, unknown>[] = [
     { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
     { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
     { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
     { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
@@ -227,6 +228,7 @@ class LearningModulesApiService {
     }
     }
     if (action === 'app_review_page') {
     if (action === 'app_review_page') {
       this.lastMemoryParams=params;
       this.lastMemoryParams=params;
+      if (this.reviewPageResponse) return this.reviewPageResponse as Observable<ApiEnvelope<T>>;
       const items=[
       const items=[
         { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
         { GeneralID: 100, Title: '张雅茜@历史任务', kc_title: '历史复习', kywsj: '2026-05-15 09:00', learned_at: '2026-05-01T01:15:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
         { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
         { GeneralID: 101, Title: '张雅茜@任务', kc_title: '初中英语复习', kywsj: '2026-08-01 09:00', learned_at: '2026-07-20T02:20:00.000Z', fxzt: 1, yhid: 100, xxjlid: 900 },
@@ -318,6 +320,46 @@ describe('home learning modules', () => {
     expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
     expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
   });
   });
 
 
+  it('keeps the anti-forget page visible while a selected date loads locally', async () => {
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/stu_calendar');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+
+    const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    component.memoryMonth.set(new Date(2026, 8, 1, 12));
+    component.memorySelectedDate.set('2026-09-11');
+    harness.fixture.detectChanges();
+
+    const response = new Subject<ApiEnvelope<unknown>>();
+    api.reviewPageResponse = response;
+    (harness.routeNativeElement?.querySelector('[aria-label^="2026-09-12"]') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+
+    expect(api.lastMemoryParams['date']).toBe('2026-09-12');
+    expect(harness.routeNativeElement?.querySelector('.memory-overview')).not.toBeNull();
+    expect(harness.routeNativeElement?.querySelector('.memory-calendar')).not.toBeNull();
+    expect(harness.routeNativeElement?.querySelector('.memory-list-loading')?.textContent).toContain('正在加载当日复习');
+
+    response.next({
+      retcode: 1,
+      result: {
+        items: [{ GeneralID: 120, Title: '张雅茜@任务', kc_title: '9月12日复习', kywsj: '2026-09-12 09:00', fxzt: 0, yhid: 100, xxjlid: 920 }],
+        page: { itemCount: 1, pageCount: 1 },
+        summary: { total: 1, completed: 0, due: 0, today: 0, income: 0 },
+        dates: [{ date: '2026-09-12', total: 1, completed: 0 }],
+      },
+    });
+    response.complete();
+    api.reviewPageResponse = null;
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+
+    expect(harness.routeNativeElement?.querySelector('.memory-list-loading')).toBeNull();
+    expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('9月12日复习');
+  });
+
   it('renders source-shaped data for all four home entries', async () => {
   it('renders source-shaped data for all four home entries', async () => {
     const harness = await RouterTestingHarness.create();
     const harness = await RouterTestingHarness.create();
 
 

+ 9 - 5
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -345,7 +345,7 @@
         </div>
         </div>
       </figure>
       </figure>
       <div class="memory-stats">
       <div class="memory-stats">
-        <span><strong>{{ memoryCompletedCount() }} / {{ memoryFilteredItems().length }}</strong><small>任务完成</small></span>
+        <span><strong>{{ memoryCompletedCount() }} / {{ memoryTotalCount() }}</strong><small>任务完成</small></span>
         <span><strong>{{ memoryDueCount() }}</strong><small>到期待复习</small></span>
         <span><strong>{{ memoryDueCount() }}</strong><small>到期待复习</small></span>
         <span><strong>{{ memoryCalendar().scheduledDays }}</strong><small>本月安排天数</small></span>
         <span><strong>{{ memoryCalendar().scheduledDays }}</strong><small>本月安排天数</small></span>
       </div>
       </div>
@@ -380,9 +380,13 @@
     </section>
     </section>
 
 
     <section class="memory-date-tasks" aria-live="polite">
     <section class="memory-date-tasks" aria-live="polite">
-      <header><div><span>当日复习</span><strong>{{ memorySelectedLabel() }}</strong></div><b>{{ memorySelectedTasks().length }} 项</b></header>
+      <header><div><span>当日复习</span><strong>{{ memorySelectedLabel() }}</strong></div><b>@if (calendarLoading()) { <span class="memory-loading-label"><lucide-icon class="spin" [img]="icons.RefreshCw" [size]="15" />正在加载</span> } @else { {{ memorySelectedTasks().length }} 项 }</b></header>
       <div class="calendar-list">
       <div class="calendar-list">
-        @for (item of memorySelectedTasks(); track $index) {
+        @if (calendarLoading()) {
+          <div class="memory-list-loading" role="status"><lucide-icon class="spin" [img]="icons.RefreshCw" [size]="24" /><strong>正在加载当日复习</strong><span>日历和页面其他内容可以继续查看</span></div>
+        } @else if (calendarError()) {
+          <div class="memory-list-loading error" role="alert"><lucide-icon [img]="icons.CircleHelp" [size]="24" /><strong>当日复习加载失败</strong><span>{{ calendarError() }}</span><button type="button" (click)="retryCalendar()">重新加载</button></div>
+        } @else { @for (item of memorySelectedTasks(); track $index) {
           <article [class.ready]="calendarReady(item)" [class.completed]="calendarCompleted(item)">
           <article [class.ready]="calendarReady(item)" [class.completed]="calendarCompleted(item)">
             <span class="task-state"><i></i>{{ memoryTaskStatus(item) }}</span>
             <span class="task-state"><i></i>{{ memoryTaskStatus(item) }}</span>
             <div class="task-copy"><span class="task-heading"><em class="review-sequence">第 {{ calendarReviewNumber(item) }} 次复习</em><strong>{{ calendarTitle(item) }}</strong></span><small class="task-context">{{ calendarStudent(item) }}<em>复习日期 {{ calendarDate(item) }}</em></small><small class="first-learned-at">{{ calendarFirstLearnedAt(item) }}</small></div>
             <div class="task-copy"><span class="task-heading"><em class="review-sequence">第 {{ calendarReviewNumber(item) }} 次复习</em><strong>{{ calendarTitle(item) }}</strong></span><small class="task-context">{{ calendarStudent(item) }}<em>复习日期 {{ calendarDate(item) }}</em></small><small class="first-learned-at">{{ calendarFirstLearnedAt(item) }}</small></div>
@@ -390,7 +394,7 @@
           </article>
           </article>
         } @empty {
         } @empty {
           <div class="memory-empty"><lucide-icon [img]="icons.Clock3" [size]="26" /><strong>当天没有复习任务</strong><span>{{ canStartPointReading ? '选择有颜色标记的日期查看关联学员任务' : '可以选择有颜色标记的日期查看计划' }}</span></div>
           <div class="memory-empty"><lucide-icon [img]="icons.Clock3" [size]="26" /><strong>当天没有复习任务</strong><span>{{ canStartPointReading ? '选择有颜色标记的日期查看关联学员任务' : '可以选择有颜色标记的日期查看计划' }}</span></div>
-        }
+        } }
       </div>
       </div>
     </section>
     </section>
   } @else if (isRecordPage) {
   } @else if (isRecordPage) {
@@ -486,7 +490,7 @@
   } @else {
   } @else {
     <div class="empty-state"><div><lucide-icon [img]="icons.BookOpen" [size]="34" /><p>暂无学习内容</p></div></div>
     <div class="empty-state"><div><lucide-icon [img]="icons.BookOpen" [size]="34" /><p>暂无学习内容</p></div></div>
   }
   }
-@if(isCalendarPage){<nav class="mobile-pagination"><button (click)="turnReviewPage(-1)" [disabled]="loading()||reviewListPage()<=1">上一页</button><span>共 {{reviewPageData()?.page?.itemCount||0}} 项 · 第 {{reviewListPage()}} 页</span><button (click)="turnReviewPage(1)" [disabled]="loading()||reviewListPage()>=(reviewPageData()?.page?.pageCount||1)">下一页</button></nav>}</section>
+@if(isCalendarPage){<nav class="mobile-pagination"><button (click)="turnReviewPage(-1)" [disabled]="calendarLoading()||reviewListPage()<=1">上一页</button><span>共 {{reviewPageData()?.page?.itemCount||0}} 项 · 第 {{reviewListPage()}} 页</span><button (click)="turnReviewPage(1)" [disabled]="calendarLoading()||reviewListPage()>=(reviewPageData()?.page?.pageCount||1)">下一页</button></nav>}</section>
 
 
 <ng-template #stageComplete let-title="title" let-text="text">
 <ng-template #stageComplete let-title="title" let-text="text">
   <div class="report compact-report">
   <div class="report compact-report">

+ 27 - 5
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -126,6 +126,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly correct = signal(0);
   readonly correct = signal(0);
   readonly unknown = signal<Word[]>([]);
   readonly unknown = signal<Word[]>([]);
   readonly loading = signal(false);
   readonly loading = signal(false);
+  readonly calendarLoading = signal(false);
+  readonly calendarError = signal('');
   readonly submitting = signal(false);
   readonly submitting = signal(false);
   readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
   readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
   readonly error = signal('');
   readonly error = signal('');
@@ -247,6 +249,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const [year, month, day] = value.split('-').map(Number);
     const [year, month, day] = value.split('-').map(Number);
     return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12));
     return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12));
   });
   });
+  readonly memoryTotalCount = computed(() => this.reviewPageData()?.summary.total||0);
   readonly memoryCompletedCount = computed(() => this.reviewPageData()?.summary.completed||0);
   readonly memoryCompletedCount = computed(() => this.reviewPageData()?.summary.completed||0);
   readonly memoryDueCount = computed(() => this.reviewPageData()?.summary.due||0);
   readonly memoryDueCount = computed(() => this.reviewPageData()?.summary.due||0);
   readonly memoryProgress=computed(()=>{const s=this.reviewPageData()?.summary;return s?.total?Math.round(s.completed/s.total*100):0;});
   readonly memoryProgress=computed(()=>{const s=this.reviewPageData()?.summary;return s?.total?Math.round(s.completed/s.total*100):0;});
@@ -265,6 +268,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly wordPreviewPageSize = 30;
   private readonly wordPreviewPageSize = 30;
   private examTimer: ReturnType<typeof setInterval> | null = null;
   private examTimer: ReturnType<typeof setInterval> | null = null;
   private antiStartedAt = Date.now();
   private antiStartedAt = Date.now();
+  private calendarRequest?: Subscription;
 
 
   ngOnInit(): void {
   ngOnInit(): void {
     if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading) {
     if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading) {
@@ -280,7 +284,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
   }
 
 
   ngOnDestroy(): void {
   ngOnDestroy(): void {
-    this.calendarRequestId++;this.selectionRequest?.unsubscribe();
+    this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe();
     this.stopExamTimer();
     this.stopExamTimer();
     this.platform.stopAudio();
     this.platform.stopAudio();
   }
   }
@@ -412,7 +416,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       return;
       return;
     }
     }
     if (this.isCalendarPage) {
     if (this.isCalendarPage) {
-      this.loadCalendar();
+      this.loadCalendar(true);
       return;
       return;
     }
     }
     if (this.isAntiForgetPage) {
     if (this.isAntiForgetPage) {
@@ -956,6 +960,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
   }
 
 
   selectMemoryDay(day: AntiForgetCalendarDay): void {
   selectMemoryDay(day: AntiForgetCalendarDay): void {
+    if (day.date === this.memorySelectedDate() && day.inCurrentMonth) return;
     this.memorySelectedDate.set(day.date);
     this.memorySelectedDate.set(day.date);
     this.reviewListPage.set(1);this.loadCalendar();
     this.reviewListPage.set(1);this.loadCalendar();
     if (!day.inCurrentMonth) {
     if (!day.inCurrentMonth) {
@@ -1536,9 +1541,24 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
   }
 
 
   turnReviewPage(delta:number):void{this.reviewListPage.update(p=>Math.max(1,Math.min(this.reviewPageData()?.page.pageCount||1,p+delta)));this.loadCalendar();}
   turnReviewPage(delta:number):void{this.reviewListPage.update(p=>Math.max(1,Math.min(this.reviewPageData()?.page.pageCount||1,p+delta)));this.loadCalendar();}
-  private loadCalendar(): void {
-    const requestId=++this.calendarRequestId;this.loading.set(true);
-    this.api.post<ReviewPage>('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date:this.memorySelectedDate()||this.dateOnly(),page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{if(requestId===this.calendarRequestId)this.loading.set(false);})).subscribe({next:({result})=>{if(requestId!==this.calendarRequestId)return;this.reviewPageData.set(result);this.calendarItems.set(result.items);if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly());},error:(e:ApiError)=>{if(requestId===this.calendarRequestId)this.error.set(e.message);}});
+  retryCalendar(): void { this.loadCalendar(); }
+
+  private loadCalendar(initial = false): void {
+    const requestId=++this.calendarRequestId;
+    this.calendarRequest?.unsubscribe();
+    this.calendarError.set('');
+    if (initial) this.loading.set(true); else this.calendarLoading.set(true);
+    this.calendarRequest = this.api.post<ReviewPage>('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date:this.memorySelectedDate()||this.dateOnly(),page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{
+      if(requestId!==this.calendarRequestId)return;
+      if(initial)this.loading.set(false);else this.calendarLoading.set(false);
+    })).subscribe({next:({result})=>{
+      if(requestId!==this.calendarRequestId)return;
+      this.reviewPageData.set(result);this.calendarItems.set(result.items);
+      if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly());
+    },error:(e:ApiError)=>{
+      if(requestId!==this.calendarRequestId)return;
+      if(initial)this.error.set(e.message);else this.calendarError.set(e.message);
+    }});
   }
   }
   private calendarRequestId=0;
   private calendarRequestId=0;
 
 
@@ -1791,6 +1811,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.wordPreviewPage.set(1);
     this.wordPreviewPage.set(1);
     this.wordPreviewTotal.set(0);
     this.wordPreviewTotal.set(0);
     this.wordPreviewLoading.set(false);
     this.wordPreviewLoading.set(false);
+    this.calendarLoading.set(false);
+    this.calendarError.set('');
     this.selected.set(new Set());
     this.selected.set(new Set());
     this.selectionRevealed.set(new Set());
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});
     this.selectionAssessment.set({});

+ 11 - 0
projects/xiaoshu-mobile/src/styles.scss

@@ -88,6 +88,17 @@ a { -webkit-tap-highlight-color: transparent; }
 
 
 button { cursor: pointer; }
 button { cursor: pointer; }
 
 
+.spin { animation: spin .8s linear infinite; }
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+app-learning-page .memory-loading-label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
+app-learning-page .memory-list-loading { display: flex; min-height: 150px; align-items: center; justify-content: center; padding: 28px 20px; color: var(--brand-strong); text-align: center; flex-direction: column; gap: 7px; }
+app-learning-page .memory-list-loading strong { color: var(--ink); font-size: 14px; }
+app-learning-page .memory-list-loading span { color: var(--ink-soft); font-size: 11px; }
+app-learning-page .memory-list-loading.error { color: #b14f45; }
+app-learning-page .memory-list-loading button { min-height: 36px; margin-top: 4px; padding: 0 14px; color: var(--brand-strong); font-weight: 700; background: white; border: 1px solid var(--line); border-radius: 7px; }
+
 a,
 a,
 button,
 button,
 input,
 input,