| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781 |
- import { CommonModule } from '@angular/common';
- import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
- import { FormsModule } from '@angular/forms';
- import { ActivatedRoute, Router } from '@angular/router';
- import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
- import { finalize, Subscription } from 'rxjs';
- import { ApiError, ApiService } from '../../core/api.service';
- import { canStartTeacherLedLearning } from '../../core/learning-access';
- import { AppUser, PageMeta } from '../../core/models';
- import { PlatformService } from '../../core/platform.service';
- 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;
- day: number;
- inMonth: boolean;
- isToday: boolean;
- count: number;
- finishedCount: number;
- plannedCount: number;
- }
- interface StudentOption {
- id: string;
- name: string;
- count: number;
- }
- interface MonthOption {
- value: string;
- label: string;
- count: number;
- }
- interface CompanionOverview {
- page?: {itemCount:number;pageCount:number}; dates?: Array<{date:string;finished:number;planned:number}>; studentOptions?:StudentOption[];
- courses: Item[];
- schedules: Item[];
- refreshedAt: string;
- }
- @Component({
- selector: 'app-course-center-page',
- standalone: true,
- imports: [CommonModule, FormsModule, LucideAngularModule],
- templateUrl: './course-center-page.component.html',
- styleUrl: './course-center-page.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush,
- })
- export class CourseCenterPageComponent implements OnInit, OnDestroy {
- private readonly route = inject(ActivatedRoute);
- readonly meta = this.route.snapshot.data['meta'] as PageMeta;
- readonly isCoach = ['pages/home/shop', 'pages/member/ke_xiao'].includes(this.meta.path);
- readonly isHistory = this.meta.path === 'pages/member/ke_xiao';
- readonly hasScheduleCalendar = !this.isHistory;
- readonly weekLabels = ['一', '二', '三', '四', '五', '六', '日'];
- readonly listPage=signal(1); readonly listPages=signal(1); readonly listTotal=signal(0); readonly serverDates=signal<Array<{date:string;finished:number;planned:number}>>([]); readonly serverStudents=signal<StudentOption[]>([]);
- private loadRequest?:Subscription; private loadId=0;
- readonly courses = signal<Item[]>([]);
- readonly schedules = signal<Item[]>([]);
- readonly scheduleFilter = signal<ScheduleFilter>('all');
- readonly scheduleRange = signal<ScheduleRange>('day');
- readonly selectedDate = signal(this.today());
- readonly selectedStudent = signal('all');
- readonly historyMonth = signal('all');
- readonly historyStudentQuery = signal('');
- readonly calendarMonth = signal(this.firstOfMonth(new Date()));
- readonly now = signal(Date.now());
- readonly loading = signal(false);
- readonly error = signal('');
- readonly studentOptions = computed(() => this.serverStudents());
- readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
- readonly historyFilterActive = computed(() => this.historyMonth() !== 'all' || Boolean(this.historyStudentQuery().trim()));
- readonly scopedSchedules = computed(() => this.schedules().filter((item) => this.matchesStudent(item)));
- readonly filteredSchedulePool = computed(() => this.scopedSchedules().filter((item) => this.matchesStatus(item)));
- readonly visibleSchedules = computed(() => this.filteredSchedulePool().filter((item) => this.matchesRange(item) && this.matchesHistoryFilters(item)));
- readonly pendingCount = computed(() => this.serverDates().filter(d=>d.date<new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'})).reduce((n,d)=>n+d.planned,0));
- readonly finishedCount = computed(() => this.serverDates().reduce((n,d)=>n+d.finished,0));
- readonly calendarDays = computed(() => this.buildCalendarDays());
- readonly calendarLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.calendarMonth()));
- readonly scheduleRangeLabel = computed(() => {
- if (this.scheduleRange() === 'all') return '全部日期';
- if (this.scheduleRange() === 'week') return `${this.selectedDate()} 所在周`;
- return this.selectedDate();
- });
- readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, Search, X };
- private readonly api = inject(ApiService);
- private readonly router = inject(Router);
- private readonly session = inject(SessionService);
- private readonly platform = inject(PlatformService);
- private nowTimer?: ReturnType<typeof setInterval>;
- private restoredScheduleState = false;
- private syncingQuery = false;
- private loadedRange: { start: string; end: string } | null = null;
- private loadedAll = false;
- private initialScheduleLoaded = false;
- private historyFilterInitialized = false;
- ngOnInit(): void {
- this.restoredScheduleState = this.restoreScheduleState();
- this.load();
- if (this.hasScheduleCalendar) this.nowTimer = setInterval(() => this.now.set(Date.now()), 60_000);
- }
- ngOnDestroy(): void {
- this.loadId++;this.loadRequest?.unsubscribe();
- if (this.nowTimer) clearInterval(this.nowTimer);
- }
- turnPage(delta:number):void{this.listPage.update(p=>Math.max(1,Math.min(this.listPages(),p+delta)));this.load(false,true);}
- load(refresh = false, keepPage=false): void {
- this.loadRequest?.unsubscribe(); const requestId=++this.loadId; if(!keepPage)this.listPage.set(1);
- const uid = this.session.user()?.userId;
- if (!uid) return;
- this.loading.set(true);
- this.error.set('');
- const today = this.today();
- const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
- this.loadRequest=this.api.post<CompanionOverview>('app_companion_overview', {
- uid,
- mode: this.isCoach ? 'coach' : 'student',
- history: this.isHistory,
- page:this.listPage(),pageSize:20,studentId:this.selectedStudent()==='all'?undefined:this.selectedStudent(),filter:this.scheduleFilter(),month:this.historyMonth()==='all'?undefined:this.historyMonth(),search:this.historyStudentQuery(),
- dateStart:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[0]:this.selectedDate(),
- dateEnd:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[1]:this.selectedDate(),
- rangeStart: range?.start,
- rangeEnd: range?.end,
- refresh,
- }).pipe(finalize(() => this.loading.set(false))).subscribe({
- next: ({ result }) => {
- if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount||0);this.serverDates.set(result.dates||[]);this.serverStudents.set(result.studentOptions||[]);
- this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
- this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
- this.loadedRange = range;
- this.loadedAll = !range;
- if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
- this.initialScheduleLoaded = true;
- if (this.restoredScheduleState) this.restoreScheduleScroll();
- },
- error: (error: ApiError) => this.error.set(error.message),
- });
- }
- groups(): Array<{ date: string; items: Item[] }> {
- const grouped = new Map<string, Item[]>();
- for (const item of this.sortedVisibleSchedules()) {
- const date = this.itemDate(item) || '未指定日期';
- grouped.set(date, [...(grouped.get(date) ?? []), item]);
- }
- return Array.from(grouped, ([date, items]) => ({ date, items }));
- }
- courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
- courseTitle(item: Item): string {
- const sourceTitle = String(item['Title'] ?? '').trim();
- if (sourceTitle.includes('@')) {
- const parts = sourceTitle.split('@');
- const stage = parts[0].slice(parts[0].lastIndexOf('|') + 1).trim();
- const name = String(parts.at(-1) ?? '').trim();
- if (stage && name) return `【${stage}】${name}`;
- if (name) return name;
- }
- const joinedTitle = String(item['Title2'] ?? '').trim();
- if (joinedTitle) return joinedTitle;
- const assignedUnit = this.assignedUnits(item)[0];
- return assignedUnit ? `${assignedUnit.name}学习内容` : '已绑定学习内容';
- }
- courseId(item: Item): string {
- return String(item['kcid'] ?? item['kcId'] ?? item['NodeID'] ?? item['nodeId'] ?? '');
- }
- courseBindingId(item: Item): string {
- return String(item['bindingId'] ?? item['id'] ?? item['bindingObjectId'] ?? '');
- }
- canOpenCourse(item: Item): boolean {
- return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
- }
- courseHint(item: Item): string {
- if (!this.canStartPointReading) return this.canOpenCourse(item) ? '点击查看老师分配的单词列表' : '暂未分配可查看的学习内容';
- const units = this.assignedUnits(item);
- if (units.length) return `${units.length} 个已分配单元,点击开始学习`;
- return this.canOpenCourse(item) ? '点击查看已分配的学习单元' : '暂未分配可学习内容';
- }
- openCourse(item: Item): void {
- const courseId = this.courseId(item);
- const bindingId = this.courseBindingId(item);
- if (!this.canOpenCourse(item)) {
- this.platform.toast('该课程暂未分配可学习内容', 'error');
- return;
- }
- void this.router.navigate(['/pages/stu/stu_list'], {
- queryParams: {
- nid: courseId || null,
- bindingId: bindingId || null,
- },
- });
- }
- get canStartPointReading(): boolean {
- return canStartTeacherLedLearning(this.session.groupId());
- }
- scheduleTitle(item: Item): string { return String(item['Title'] ?? item['ctitle'] ?? '排课'); }
- scheduleTime(item: Item): string { return String(item['sdsd'] ?? item['yysj'] ?? '--'); }
- scheduleSubtitle(item: Item): string { return String(item['Subtitle'] ?? item['plxm'] ?? item['honeyname'] ?? ''); }
- scheduleId(item: Item): unknown { return item['GeneralID'] ?? item['ID'] ?? item['id']; }
- statusText(item: Item): string {
- if(item['cancelled']===true)return '已取消';
- if (this.isOverdueUnfinished(item)) return '计划未完成';
- return ({ 0: '未开始', 10: '已开始', 11: '已上课', 20: '已评价', 30: '已结课' } as Record<number, string>)[this.rawStatus(item)] ?? '--';
- }
- statusClass(item: Item): string { return this.isOverdueUnfinished(item) ? 'status-11' : `status-${this.rawStatus(item)}`; }
- isFinished(item: Item): boolean { return item['cancelled']!==true && this.rawStatus(item) >= 30; }
- setScheduleFilter(filter: ScheduleFilter): void {
- this.scheduleFilter.set(filter);
- this.load();
- this.syncScheduleState();
- }
- setStudentFilter(value: string): void {
- this.selectedStudent.set(value || 'all');
- this.load();
- this.syncScheduleState();
- }
- setHistoryMonth(value: string): void { this.historyMonth.set(value || 'all'); this.load(); }
- setHistoryStudentQuery(value: string): void { this.historyStudentQuery.set(value); this.load(); }
- clearHistoryFilters(): void {
- this.historyMonth.set('all');
- this.historyStudentQuery.set('');
- this.load();
- }
- shiftCalendarMonth(amount: number): void {
- const month = this.calendarMonth();
- this.calendarMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
- this.syncScheduleState();
- this.load();
- }
- selectCalendarDay(day: CalendarDay): void { this.selectDate(day.date); }
- showToday(): void { this.selectDate(this.today()); }
- showTomorrow(): void { this.selectDate(this.tomorrow()); }
- showWeek(): void {
- this.scheduleRange.set('week');
- this.load();
- this.ensureSelectedDate();
- this.syncScheduleState();
- }
- showAllSchedules(): void {
- this.scheduleRange.set('all');
- this.syncScheduleState();
- this.load();
- }
- scheduleEmptyText(): string {
- const target = this.isCoach ? '排课' : '伴学记录';
- if (!this.schedules().length) return this.isCoach ? '暂无排课' : '暂无伴学记录';
- if (this.scheduleRange() === 'all') return `当前筛选下暂无${target}`;
- if (this.scheduleRange() === 'week') return `本周暂无${target}`;
- return `${this.selectedDate()} 暂无${target}`;
- }
- open(item: Item, event?: Event): void {
- if(item['cancelled']===true){this.platform.toast('该预约已取消','info');return;}
- 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 } });
- return;
- }
- if (this.isFutureUnfinished(item)) {
- this.platform.toast('课程未开始', 'error');
- return;
- }
- this.loadStudentAndNavigate(item, '/pages/shop/get_order', 'id');
- }
- report(item: Item, event: Event): void {
- event.stopPropagation();
- this.saveScheduleScroll(item, event);
- if (this.rawStatus(item) > 10) {
- void this.router.navigate(['/pages/shop/baobiao'], { queryParams: { sid: this.scheduleId(item) } });
- return;
- }
- this.loadStudentAndNavigate(item, '/pages/shop/baobiao', 'id');
- }
- private loadStudentAndNavigate(item: Item, path: string, queryKey: 'id' | 'sid'): void {
- const studentId = Number(item['szyh'] ?? 0);
- this.storeAppointmentSnapshot(item);
- if (!studentId) {
- void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
- return;
- }
- this.platform.loading.set(true);
- this.api.post<AppUser>('user_get', { uid: studentId }).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
- next: ({ result }) => {
- this.session.setStudent(result);
- void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
- },
- error: () => {
- this.platform.toast('学员信息暂时无法加载,已使用排课信息打开详情', 'error');
- void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
- },
- });
- }
- private buildStudentOptions(): StudentOption[] {
- if (!this.isCoach || this.isHistory) return [];
- const options = new Map<string, StudentOption>();
- for (const item of this.schedules()) {
- const id = this.studentId(item);
- const name = this.studentName(item);
- const current = options.get(id);
- if (current) current.count += 1;
- else options.set(id, { id, name, count: 1 });
- }
- return [...options.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
- }
- private buildHistoryMonthOptions(): MonthOption[] {
- if (!this.isHistory) return [];
- const counts = new Map<string, number>();
- for (const item of this.serverDates()) {
- const month = item.date.slice(0, 7);
- if (!/^\d{4}-\d{2}$/.test(month)) continue;
- counts.set(month, (counts.get(month) ?? 0) + item.finished + item.planned);
- }
- return [...counts.entries()]
- .sort(([left], [right]) => right.localeCompare(left))
- .map(([value, count]) => {
- const [year, month] = value.split('-').map(Number);
- return { value, count, label: `${year}年${month}月` };
- });
- }
- private assignedUnits(item: Item): Array<{ id: string; name: string }> {
- const raw = item['syjd'] ?? item['assignedUnits'];
- let source: unknown = raw;
- if (typeof raw === 'string' && raw.trim()) {
- try { source = JSON.parse(raw); } catch { return []; }
- }
- const entries = Array.isArray(source)
- ? source.map((value, index) => [String(index), value] as const)
- : source && typeof source === 'object'
- ? Object.entries(source)
- : [];
- const seen = new Set<string>();
- const units: Array<{ id: string; name: string }> = [];
- for (const [key, value] of entries) {
- const text = typeof value === 'object' && value
- ? String((value as Item)['name'] ?? (value as Item)['title'] ?? (value as Item)['NodeName'] ?? '')
- : String(value ?? '');
- const parts = text.split('@');
- const id = String((typeof value === 'object' && value ? (value as Item)['id'] ?? (value as Item)['nodeId'] : '') || (/^\d+$/.test(key) ? key : parts[0]) || '').trim();
- if (!/^\d+$/.test(id) || seen.has(id)) continue;
- seen.add(id);
- units.push({ id, name: String(parts[1] ?? parts[0] ?? `学习单元 ${id}`).trim() || `学习单元 ${id}` });
- }
- return units;
- }
- private buildCalendarDays(): CalendarDay[] {
- const month = this.calendarMonth();
- const year = month.getFullYear();
- const monthIndex = month.getMonth();
- const firstDay = new Date(year, monthIndex, 1, 12);
- const offset = (firstDay.getDay() + 6) % 7;
- const start = new Date(year, monthIndex, 1 - offset, 12);
- const today = this.today();
- const counts = new Map<string, { finished: number; planned: number }>();
- for(const row of this.serverDates()) counts.set(row.date,{finished:row.finished,planned:row.planned});
- return Array.from({ length: 42 }, (_, index) => {
- const date = new Date(start);
- date.setDate(start.getDate() + index);
- const key = this.dateKey(date);
- const count = counts.get(key);
- const finishedCount = count?.finished ?? 0;
- const plannedCount = count?.planned ?? 0;
- return {
- date: key,
- day: date.getDate(),
- inMonth: date.getMonth() === monthIndex,
- isToday: key === today,
- count: finishedCount + plannedCount,
- finishedCount,
- plannedCount,
- };
- });
- }
- private matchesStudent(item: Item): boolean {
- const selected = this.selectedStudent();
- return selected === 'all' || this.studentId(item) === selected;
- }
- private matchesStatus(item: Item): boolean {
- if (this.scheduleFilter() === 'finished') return this.isFinished(item);
- if (this.scheduleFilter() === 'pending') return this.isOverdueUnfinished(item);
- return true;
- }
- private matchesRange(item: Item): boolean {
- if (!this.hasScheduleCalendar || this.scheduleRange() === 'all') return true;
- const date = this.itemDate(item);
- if (!date) return false;
- if (this.scheduleRange() === 'week') {
- const [start, end] = this.weekRange(this.selectedDate());
- return date >= start && date <= end;
- }
- return date === this.selectedDate();
- }
- private matchesHistoryFilters(item: Item): boolean {
- if (!this.isHistory) return true;
- const selectedMonth = this.historyMonth();
- if (selectedMonth !== 'all' && !this.itemDate(item).startsWith(`${selectedMonth}-`)) return false;
- const query = this.historyStudentQuery().trim().toLocaleLowerCase('zh-CN');
- return !query || this.studentName(item).toLocaleLowerCase('zh-CN').includes(query);
- }
- private sortedVisibleSchedules(): Item[] {
- const list = [...this.visibleSchedules()];
- if (this.isHistory) return list;
- return list.sort((left, right) => this.lessonStartTime(left) - this.lessonStartTime(right));
- }
- private rawStatus(item: Item): number { return Number(item['dszt'] ?? 0); }
- private isPendingRaw(item: Item): boolean { return this.rawStatus(item) <= 10; }
- private isFutureUnfinished(item: Item): boolean {
- if (!this.isPendingRaw(item)) return false;
- const start = this.lessonStart(item);
- return Boolean(start && start.getTime() > this.now());
- }
- private isOverdueUnfinished(item: Item): boolean {
- if (!this.isPendingRaw(item)) return false;
- const end = this.lessonEnd(item);
- if (end) return end.getTime() < this.now();
- const date = this.itemDate(item);
- return Boolean(date && date < this.today());
- }
- private lessonStartTime(item: Item): number {
- return this.lessonStart(item)?.getTime() ?? Number.MAX_SAFE_INTEGER;
- }
- private lessonStart(item: Item): Date | null {
- const rawDateTime = this.dateTimeFromRaw(item['yysj'] ?? item['bxrq'] ?? item['yydate']);
- const date = this.itemDate(item);
- if (!date) return rawDateTime;
- const time = this.timeParts(item['sdsd'] ?? item['kssj'] ?? item['yysj']);
- if (!time) return rawDateTime ?? this.parseDate(date);
- const [year, month, day] = date.split('-').map(Number);
- return new Date(year, month - 1, day, time.hour, time.minute);
- }
- private lessonEnd(item: Item): Date | null {
- const start = this.lessonStart(item);
- if (!start) return null;
- return new Date(start.getTime() + this.lessonMinutes(item) * 60_000);
- }
- private lessonMinutes(item: Item): number {
- const text = `${item['Title'] ?? ''} ${item['Title2'] ?? ''} ${item['Subtitle'] ?? ''}`;
- const matched = text.match(/(\d{1,3})\s*分钟/);
- if (matched) return Number(matched[1]);
- const type = Number(item['dslx'] ?? 0);
- if (type === 1) return 30;
- if (type === 2) return 60;
- if (type === 3) return 30;
- return 0;
- }
- private timeParts(value: unknown): { hour: number; minute: number } | null {
- const matched = String(value ?? '').match(/(?:^|\s)(\d{1,2}):(\d{2})/);
- if (!matched) return null;
- const hour = Number(matched[1]);
- const minute = Number(matched[2]);
- if (hour > 23 || minute > 59) return null;
- return { hour, minute };
- }
- private dateTimeFromRaw(value: unknown): Date | null {
- const raw = String(value ?? '');
- const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?/);
- if (!matched) return null;
- return new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]), Number(matched[4] ?? 0), Number(matched[5] ?? 0));
- }
- private selectDate(date: string): void {
- this.selectedDate.set(date);
- this.scheduleRange.set('day');
- const value = this.parseDate(date);
- this.calendarMonth.set(new Date(value.getFullYear(), value.getMonth(), 1, 12));
- this.syncScheduleState();
- this.load();
- }
- private calendarWindow(): { start: string; end: string } {
- const month = this.calendarMonth();
- const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
- const offset = (first.getDay() + 6) % 7;
- const start = new Date(first);
- start.setDate(first.getDate() - offset);
- const end = new Date(start);
- end.setDate(start.getDate() + 41);
- return { start: this.dateKey(start), end: this.dateKey(end) };
- }
- private ensureSelectedDate(): void {
- if (!this.selectedDate()) this.selectedDate.set(this.today());
- }
- private itemDate(item: Item): string {
- return this.dateKey(item['bxrq'] ?? item['yydate'] ?? item['yysj'] ?? item['PubAddTime']);
- }
- private studentId(item: Item): string {
- return String(item['szyh'] ?? item['UserID'] ?? item['userId'] ?? this.studentName(item));
- }
- private studentName(item: Item): string {
- const explicit = item['honeyname'] ?? item['HoneyName'] ?? item['honeyName'] ?? item['xymz_t'] ?? item['studentName'];
- if (explicit) return String(explicit);
- const title = this.scheduleTitle(item);
- return title.includes('(') ? title.slice(0, title.indexOf('(')) : title || '未命名学员';
- }
- private weekRange(date: string): [string, string] {
- const start = this.parseDate(date);
- start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
- const end = new Date(start);
- end.setDate(start.getDate() + 6);
- return [this.dateKey(start), this.dateKey(end)];
- }
- private asList(value: unknown): Item[] {
- if (Array.isArray(value)) return value.filter((item): item is Item => Boolean(item) && typeof item === 'object');
- if (value && typeof value === 'object') {
- for (const nested of Object.values(value)) if (Array.isArray(nested)) return nested as Item[];
- }
- return [];
- }
- today(): string {
- const now = new Date();
- return this.dateKey(now);
- }
- tomorrow(): string {
- const date = this.parseDate(this.today());
- date.setDate(date.getDate() + 1);
- return this.dateKey(date);
- }
- private firstOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1, 12); }
- private parseDate(value: string): Date {
- const [year, month, day] = value.split('-').map(Number);
- return new Date(year || new Date().getFullYear(), (month || 1) - 1, day || 1, 12);
- }
- private dateKey(value: unknown): string {
- const raw = String(value ?? '');
- const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
- if (matched) return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
- const date = value instanceof Date ? value : new Date(raw);
- if (Number.isNaN(date.getTime())) return '';
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
- }
- private restoreScheduleState(): boolean {
- if (!this.hasScheduleCalendar) return false;
- const query = this.route.snapshot.queryParamMap;
- let restored = false;
- const status = query.get('csStatus') as ScheduleFilter | null;
- const range = query.get('csRange') as ScheduleRange | null;
- const date = query.get('csDate');
- const student = query.get('csStudent');
- const month = query.get('csMonth');
- if (status && ['all', 'pending', 'finished'].includes(status)) {
- this.scheduleFilter.set(status);
- restored = true;
- }
- if (range && ['day', 'week', 'all'].includes(range)) {
- this.scheduleRange.set(range);
- restored = true;
- }
- if (date && this.dateKey(date)) {
- this.selectedDate.set(this.dateKey(date));
- restored = true;
- }
- if (student) {
- this.selectedStudent.set(student);
- restored = true;
- }
- if (month) {
- const restoredMonth = this.parseMonth(month);
- if (restoredMonth) {
- this.calendarMonth.set(restoredMonth);
- restored = true;
- }
- } else if (date && this.dateKey(date)) {
- this.calendarMonth.set(this.firstOfMonth(this.parseDate(this.dateKey(date))));
- }
- return restored;
- }
- private syncScheduleState(): void {
- if (!this.hasScheduleCalendar || this.syncingQuery) return;
- this.syncingQuery = true;
- void this.router.navigate([], {
- relativeTo: this.route,
- queryParams: {
- csRange: this.scheduleRange(),
- csDate: this.selectedDate(),
- 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;
- try {
- sessionStorage.setItem(`xiaoshu.appointment.snapshot.${id}`, JSON.stringify(item));
- } catch {
- // Storage can be unavailable in restricted browser modes; navigation state still carries the snapshot.
- }
- }
- private monthKey(date: Date): string {
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
- }
- private parseMonth(value: string): Date | null {
- const matched = value.match(/^(\d{4})-(\d{1,2})$/);
- if (!matched) return null;
- return new Date(Number(matched[1]), Number(matched[2]) - 1, 1, 12);
- }
- }
|