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

fix: 恢复排课列表滚动位置

彭峰 2 месяцев назад
Родитель
Сommit
ef85d80bf6

+ 1 - 1
src/app/features/course-center/course-center-page.component.html

@@ -89,7 +89,7 @@
           <h3>{{ group.date }}</h3>
           <div class="schedule-list">
             @for (item of group.items; track $index) {
-              <article (click)="open(item)">
+              <article [attr.data-schedule-id]="scheduleId(item)" (click)="open(item, $event)">
                 <time>{{ scheduleTime(item) }}</time>
                 <div class="schedule-copy"><strong>{{ scheduleTitle(item) }}</strong>@if (scheduleSubtitle(item)) { <span>{{ scheduleSubtitle(item) }}</span> }</div>
                 <span class="status" [class]="'status ' + statusClass(item)">{{ statusText(item) }}</span>

+ 117 - 3
src/app/features/course-center/course-center-page.component.ts

@@ -12,6 +12,7 @@ import { SessionService } from '../../core/session.service';
 type Item = Record<string, unknown>;
 type ScheduleFilter = 'all' | 'pending' | 'finished';
 type ScheduleRange = 'day' | 'week' | 'all';
+const SCROLL_STATE_TTL = 30 * 60 * 1000;
 
 interface CalendarDay {
   date: string;
@@ -101,6 +102,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
           next: ({ result }) => {
             this.schedules.set(this.asList(result));
             if (!this.isHistory && !this.restoredScheduleState) this.selectDate(today);
+            if (this.restoredScheduleState) this.restoreScheduleScroll();
           },
           error: (error: ApiError) => this.error.set(error.message),
         });
@@ -116,6 +118,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       this.courses.set(this.asList(courses.result));
       this.schedules.set(this.asList(schedules.result));
       if (!this.restoredScheduleState) this.selectDate(today);
+      if (this.restoredScheduleState) this.restoreScheduleScroll();
       const failures = [courses.retmsg, schedules.retmsg].filter(Boolean);
       if (failures.length) this.error.set(`部分数据加载失败:${failures.join(';')}`);
     });
@@ -210,7 +213,8 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return `${this.selectedDate()} 暂无${target}`;
   }
 
-  open(item: Item): void {
+  open(item: Item, event?: Event): void {
+    this.saveScheduleScroll(item, event);
     if (!this.isCoach) {
       this.storeAppointmentSnapshot(item);
       void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
@@ -225,7 +229,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   report(item: Item, event: Event): void {
     event.stopPropagation();
-    this.syncScheduleState();
+    this.saveScheduleScroll(item, event);
     if (this.rawStatus(item) > 10) {
       void this.router.navigate(['/pages/shop/baobiao'], { queryParams: { sid: this.scheduleId(item) } });
       return;
@@ -235,7 +239,6 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   private loadStudentAndNavigate(item: Item, path: string, queryKey: 'id' | 'sid'): void {
     const studentId = Number(item['szyh'] ?? 0);
-    this.syncScheduleState();
     this.storeAppointmentSnapshot(item);
     if (!studentId) {
       void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
@@ -507,12 +510,123 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
         csStudent: this.selectedStudent(),
         csStatus: this.scheduleFilter(),
         csMonth: this.monthKey(this.calendarMonth()),
+        csScroll: null,
+        csScrollAt: null,
+        csAnchor: null,
+        csAnchorTop: null,
       },
       queryParamsHandling: 'merge',
       replaceUrl: true,
     }).finally(() => { this.syncingQuery = false; });
   }
 
+  private saveScheduleScroll(item?: Item, event?: Event): void {
+    if (!this.hasScheduleCalendar) return;
+    const article = event?.target instanceof Element ? event.target.closest<HTMLElement>('[data-schedule-id]') : null;
+    const state = {
+      top: this.scrollTop(),
+      at: Date.now(),
+      itemId: item ? String(this.scheduleId(item) ?? '') : '',
+      itemTop: article?.getBoundingClientRect().top,
+    };
+    this.replaceScrollQuery(state);
+    try {
+      sessionStorage.setItem(this.scheduleScrollKey(), JSON.stringify(state));
+    } catch {
+      // Ignore storage failures; navigation still works without scroll restoration.
+    }
+  }
+
+  private restoreScheduleScroll(): void {
+    if (!this.hasScheduleCalendar) return;
+    const state = this.readScrollState();
+    if (!state) return;
+    const apply = () => {
+      const element = document.scrollingElement ?? document.documentElement;
+      const max = Math.max(0, element.scrollHeight - element.clientHeight);
+      window.scrollTo({ top: Math.min(state.top, max), behavior: 'auto' });
+      this.restoreScheduleAnchor(state);
+    };
+    setTimeout(() => {
+      requestAnimationFrame(() => {
+        apply();
+        setTimeout(apply, 80);
+        setTimeout(apply, 220);
+        setTimeout(apply, 600);
+        setTimeout(apply, 1200);
+        setTimeout(apply, 1800);
+      });
+    });
+  }
+
+  private restoreScheduleAnchor(state: { itemId?: string; itemTop?: number }): void {
+    if (!state.itemId || !Number.isFinite(state.itemTop)) return;
+    const article = Array.from(document.querySelectorAll<HTMLElement>('[data-schedule-id]'))
+      .find((element) => element.getAttribute('data-schedule-id') === state.itemId);
+    if (!article) return;
+    window.scrollBy({ top: article.getBoundingClientRect().top - Number(state.itemTop), behavior: 'auto' });
+  }
+
+  private readScrollState(): { top: number; at: number; itemId?: string; itemTop?: number } | null {
+    const queryState = this.scrollStateFromQuery();
+    if (queryState) return queryState;
+    try {
+      const parsed = JSON.parse(sessionStorage.getItem(this.scheduleScrollKey()) ?? 'null') as { top?: unknown; at?: unknown; itemId?: unknown; itemTop?: unknown } | null;
+      const top = Number(parsed?.top);
+      const at = Number(parsed?.at);
+      if (!Number.isFinite(top) || !Number.isFinite(at) || Date.now() - at > SCROLL_STATE_TTL) return null;
+      const itemTop = Number(parsed?.itemTop);
+      return {
+        top,
+        at,
+        itemId: typeof parsed?.itemId === 'string' ? parsed.itemId : undefined,
+        itemTop: Number.isFinite(itemTop) ? itemTop : undefined,
+      };
+    } catch {
+      return null;
+    }
+  }
+
+  private scrollTop(): number {
+    return window.scrollY || document.scrollingElement?.scrollTop || document.documentElement.scrollTop || 0;
+  }
+
+  private replaceScrollQuery(state: { top: number; at: number; itemId?: string; itemTop?: number }): void {
+    const url = new URL(window.location.href);
+    url.searchParams.set('csScroll', String(Math.round(state.top)));
+    url.searchParams.set('csScrollAt', String(Math.round(state.at)));
+    if (state.itemId) url.searchParams.set('csAnchor', state.itemId);
+    if (Number.isFinite(state.itemTop)) url.searchParams.set('csAnchorTop', String(Math.round(Number(state.itemTop))));
+    history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
+  }
+
+  private scrollStateFromQuery(): { top: number; at: number; itemId?: string; itemTop?: number } | null {
+    const query = this.route.snapshot.queryParamMap;
+    const top = Number(query.get('csScroll'));
+    const at = Number(query.get('csScrollAt'));
+    if (!Number.isFinite(top) || !Number.isFinite(at) || Date.now() - at > SCROLL_STATE_TTL) return null;
+    const itemTop = Number(query.get('csAnchorTop'));
+    return {
+      top,
+      at,
+      itemId: query.get('csAnchor') ?? undefined,
+      itemTop: Number.isFinite(itemTop) ? itemTop : undefined,
+    };
+  }
+
+  private scheduleScrollKey(): string {
+    return [
+      'xiaoshu.course-center.scroll',
+      this.meta.path,
+      this.session.user()?.userId ?? 0,
+      this.scheduleRange(),
+      this.selectedDate(),
+      this.selectedStudent(),
+      this.scheduleFilter(),
+      this.monthKey(this.calendarMonth()),
+    ].join('.');
+  }
+
   private storeAppointmentSnapshot(item: Item): void {
     const id = this.scheduleId(item);
     if (id === undefined || id === null || id === '') return;