|
|
@@ -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;
|