course-center-page.component.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { ActivatedRoute, Router } from '@angular/router';
  5. import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
  6. import { finalize, Subscription } from 'rxjs';
  7. import { ApiError, ApiService } from '../../core/api.service';
  8. import { canStartTeacherLedLearning } from '../../core/learning-access';
  9. import { AppUser, PageMeta } from '../../core/models';
  10. import { PlatformService } from '../../core/platform.service';
  11. import { SessionService } from '../../core/session.service';
  12. type Item = Record<string, unknown>;
  13. type ScheduleFilter = 'all' | 'pending' | 'finished';
  14. type ScheduleRange = 'day' | 'week' | 'all';
  15. const SCROLL_STATE_TTL = 30 * 60 * 1000;
  16. interface CalendarDay {
  17. date: string;
  18. day: number;
  19. inMonth: boolean;
  20. isToday: boolean;
  21. count: number;
  22. finishedCount: number;
  23. plannedCount: number;
  24. }
  25. interface StudentOption {
  26. id: string;
  27. name: string;
  28. count: number;
  29. }
  30. interface MonthOption {
  31. value: string;
  32. label: string;
  33. count: number;
  34. }
  35. interface CompanionOverview {
  36. page?: {itemCount:number;pageCount:number}; dates?: Array<{date:string;finished:number;planned:number}>; studentOptions?:StudentOption[];
  37. courses: Item[];
  38. schedules: Item[];
  39. refreshedAt: string;
  40. }
  41. @Component({
  42. selector: 'app-course-center-page',
  43. standalone: true,
  44. imports: [CommonModule, FormsModule, LucideAngularModule],
  45. templateUrl: './course-center-page.component.html',
  46. styleUrl: './course-center-page.component.scss',
  47. changeDetection: ChangeDetectionStrategy.OnPush,
  48. })
  49. export class CourseCenterPageComponent implements OnInit, OnDestroy {
  50. private readonly route = inject(ActivatedRoute);
  51. readonly meta = this.route.snapshot.data['meta'] as PageMeta;
  52. readonly isCoach = ['pages/home/shop', 'pages/member/ke_xiao'].includes(this.meta.path);
  53. readonly isHistory = this.meta.path === 'pages/member/ke_xiao';
  54. readonly hasScheduleCalendar = !this.isHistory;
  55. readonly weekLabels = ['一', '二', '三', '四', '五', '六', '日'];
  56. 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[]>([]);
  57. private loadRequest?:Subscription; private loadId=0;
  58. readonly courses = signal<Item[]>([]);
  59. readonly schedules = signal<Item[]>([]);
  60. readonly scheduleFilter = signal<ScheduleFilter>('all');
  61. readonly scheduleRange = signal<ScheduleRange>('day');
  62. readonly selectedDate = signal(this.today());
  63. readonly selectedStudent = signal('all');
  64. readonly historyMonth = signal('all');
  65. readonly historyStudentQuery = signal('');
  66. readonly calendarMonth = signal(this.firstOfMonth(new Date()));
  67. readonly now = signal(Date.now());
  68. readonly loading = signal(false);
  69. readonly error = signal('');
  70. readonly studentOptions = computed(() => this.serverStudents());
  71. readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
  72. readonly historyFilterActive = computed(() => this.historyMonth() !== 'all' || Boolean(this.historyStudentQuery().trim()));
  73. readonly scopedSchedules = computed(() => this.schedules().filter((item) => this.matchesStudent(item)));
  74. readonly filteredSchedulePool = computed(() => this.scopedSchedules().filter((item) => this.matchesStatus(item)));
  75. readonly visibleSchedules = computed(() => this.filteredSchedulePool().filter((item) => this.matchesRange(item) && this.matchesHistoryFilters(item)));
  76. readonly pendingCount = computed(() => this.serverDates().filter(d=>d.date<new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'})).reduce((n,d)=>n+d.planned,0));
  77. readonly finishedCount = computed(() => this.serverDates().reduce((n,d)=>n+d.finished,0));
  78. readonly calendarDays = computed(() => this.buildCalendarDays());
  79. readonly calendarLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.calendarMonth()));
  80. readonly scheduleRangeLabel = computed(() => {
  81. if (this.scheduleRange() === 'all') return '全部日期';
  82. if (this.scheduleRange() === 'week') return `${this.selectedDate()} 所在周`;
  83. return this.selectedDate();
  84. });
  85. readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, Search, X };
  86. private readonly api = inject(ApiService);
  87. private readonly router = inject(Router);
  88. private readonly session = inject(SessionService);
  89. private readonly platform = inject(PlatformService);
  90. private nowTimer?: ReturnType<typeof setInterval>;
  91. private restoredScheduleState = false;
  92. private syncingQuery = false;
  93. private loadedRange: { start: string; end: string } | null = null;
  94. private loadedAll = false;
  95. private initialScheduleLoaded = false;
  96. private historyFilterInitialized = false;
  97. ngOnInit(): void {
  98. this.restoredScheduleState = this.restoreScheduleState();
  99. this.load();
  100. if (this.hasScheduleCalendar) this.nowTimer = setInterval(() => this.now.set(Date.now()), 60_000);
  101. }
  102. ngOnDestroy(): void {
  103. this.loadId++;this.loadRequest?.unsubscribe();
  104. if (this.nowTimer) clearInterval(this.nowTimer);
  105. }
  106. turnPage(delta:number):void{this.listPage.update(p=>Math.max(1,Math.min(this.listPages(),p+delta)));this.load(false,true);}
  107. load(refresh = false, keepPage=false): void {
  108. this.loadRequest?.unsubscribe(); const requestId=++this.loadId; if(!keepPage)this.listPage.set(1);
  109. const uid = this.session.user()?.userId;
  110. if (!uid) return;
  111. this.loading.set(true);
  112. this.error.set('');
  113. const today = this.today();
  114. const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
  115. this.loadRequest=this.api.post<CompanionOverview>('app_companion_overview', {
  116. uid,
  117. mode: this.isCoach ? 'coach' : 'student',
  118. history: this.isHistory,
  119. 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(),
  120. dateStart:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[0]:this.selectedDate(),
  121. dateEnd:this.isHistory||this.scheduleRange()==='all'?undefined:this.scheduleRange()==='week'?this.weekRange(this.selectedDate())[1]:this.selectedDate(),
  122. rangeStart: range?.start,
  123. rangeEnd: range?.end,
  124. refresh,
  125. }).pipe(finalize(() => this.loading.set(false))).subscribe({
  126. next: ({ result }) => {
  127. 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||[]);
  128. this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
  129. this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
  130. this.loadedRange = range;
  131. this.loadedAll = !range;
  132. if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectedDate.set(today);
  133. this.initialScheduleLoaded = true;
  134. if (this.restoredScheduleState) this.restoreScheduleScroll();
  135. },
  136. error: (error: ApiError) => this.error.set(error.message),
  137. });
  138. }
  139. groups(): Array<{ date: string; items: Item[] }> {
  140. const grouped = new Map<string, Item[]>();
  141. for (const item of this.sortedVisibleSchedules()) {
  142. const date = this.itemDate(item) || '未指定日期';
  143. grouped.set(date, [...(grouped.get(date) ?? []), item]);
  144. }
  145. return Array.from(grouped, ([date, items]) => ({ date, items }));
  146. }
  147. 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'])] || ''); }
  148. courseTitle(item: Item): string {
  149. const sourceTitle = String(item['Title'] ?? '').trim();
  150. if (sourceTitle.includes('@')) {
  151. const parts = sourceTitle.split('@');
  152. const stage = parts[0].slice(parts[0].lastIndexOf('|') + 1).trim();
  153. const name = String(parts.at(-1) ?? '').trim();
  154. if (stage && name) return `【${stage}】${name}`;
  155. if (name) return name;
  156. }
  157. const joinedTitle = String(item['Title2'] ?? '').trim();
  158. if (joinedTitle) return joinedTitle;
  159. const assignedUnit = this.assignedUnits(item)[0];
  160. return assignedUnit ? `${assignedUnit.name}学习内容` : '已绑定学习内容';
  161. }
  162. courseId(item: Item): string {
  163. return String(item['kcid'] ?? item['kcId'] ?? item['NodeID'] ?? item['nodeId'] ?? '');
  164. }
  165. courseBindingId(item: Item): string {
  166. return String(item['bindingId'] ?? item['id'] ?? item['bindingObjectId'] ?? '');
  167. }
  168. canOpenCourse(item: Item): boolean {
  169. return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
  170. }
  171. courseHint(item: Item): string {
  172. if (!this.canStartPointReading) return this.canOpenCourse(item) ? '点击查看老师分配的单词列表' : '暂未分配可查看的学习内容';
  173. const units = this.assignedUnits(item);
  174. if (units.length) return `${units.length} 个已分配单元,点击开始学习`;
  175. return this.canOpenCourse(item) ? '点击查看已分配的学习单元' : '暂未分配可学习内容';
  176. }
  177. openCourse(item: Item): void {
  178. const courseId = this.courseId(item);
  179. const bindingId = this.courseBindingId(item);
  180. if (!this.canOpenCourse(item)) {
  181. this.platform.toast('该课程暂未分配可学习内容', 'error');
  182. return;
  183. }
  184. void this.router.navigate(['/pages/stu/stu_list'], {
  185. queryParams: {
  186. nid: courseId || null,
  187. bindingId: bindingId || null,
  188. },
  189. });
  190. }
  191. get canStartPointReading(): boolean {
  192. return canStartTeacherLedLearning(this.session.groupId());
  193. }
  194. scheduleTitle(item: Item): string { return String(item['Title'] ?? item['ctitle'] ?? '排课'); }
  195. scheduleTime(item: Item): string { return String(item['sdsd'] ?? item['yysj'] ?? '--'); }
  196. scheduleSubtitle(item: Item): string { return String(item['Subtitle'] ?? item['plxm'] ?? item['honeyname'] ?? ''); }
  197. scheduleId(item: Item): unknown { return item['GeneralID'] ?? item['ID'] ?? item['id']; }
  198. statusText(item: Item): string {
  199. if(item['cancelled']===true)return '已取消';
  200. if (this.isOverdueUnfinished(item)) return '计划未完成';
  201. return ({ 0: '未开始', 10: '已开始', 11: '已上课', 20: '已评价', 30: '已结课' } as Record<number, string>)[this.rawStatus(item)] ?? '--';
  202. }
  203. statusClass(item: Item): string { return this.isOverdueUnfinished(item) ? 'status-11' : `status-${this.rawStatus(item)}`; }
  204. isFinished(item: Item): boolean { return item['cancelled']!==true && this.rawStatus(item) >= 30; }
  205. setScheduleFilter(filter: ScheduleFilter): void {
  206. this.scheduleFilter.set(filter);
  207. this.load();
  208. this.syncScheduleState();
  209. }
  210. setStudentFilter(value: string): void {
  211. this.selectedStudent.set(value || 'all');
  212. this.load();
  213. this.syncScheduleState();
  214. }
  215. setHistoryMonth(value: string): void { this.historyMonth.set(value || 'all'); this.load(); }
  216. setHistoryStudentQuery(value: string): void { this.historyStudentQuery.set(value); this.load(); }
  217. clearHistoryFilters(): void {
  218. this.historyMonth.set('all');
  219. this.historyStudentQuery.set('');
  220. this.load();
  221. }
  222. shiftCalendarMonth(amount: number): void {
  223. const month = this.calendarMonth();
  224. this.calendarMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
  225. this.syncScheduleState();
  226. this.load();
  227. }
  228. selectCalendarDay(day: CalendarDay): void { this.selectDate(day.date); }
  229. showToday(): void { this.selectDate(this.today()); }
  230. showTomorrow(): void { this.selectDate(this.tomorrow()); }
  231. showWeek(): void {
  232. this.scheduleRange.set('week');
  233. this.load();
  234. this.ensureSelectedDate();
  235. this.syncScheduleState();
  236. }
  237. showAllSchedules(): void {
  238. this.scheduleRange.set('all');
  239. this.syncScheduleState();
  240. this.load();
  241. }
  242. scheduleEmptyText(): string {
  243. const target = this.isCoach ? '排课' : '伴学记录';
  244. if (!this.schedules().length) return this.isCoach ? '暂无排课' : '暂无伴学记录';
  245. if (this.scheduleRange() === 'all') return `当前筛选下暂无${target}`;
  246. if (this.scheduleRange() === 'week') return `本周暂无${target}`;
  247. return `${this.selectedDate()} 暂无${target}`;
  248. }
  249. open(item: Item, event?: Event): void {
  250. if(item['cancelled']===true){this.platform.toast('该预约已取消','info');return;}
  251. this.saveScheduleScroll(item, event);
  252. if (!this.isCoach) {
  253. this.storeAppointmentSnapshot(item);
  254. void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
  255. return;
  256. }
  257. if (this.isFutureUnfinished(item)) {
  258. this.platform.toast('课程未开始', 'error');
  259. return;
  260. }
  261. this.loadStudentAndNavigate(item, '/pages/shop/get_order', 'id');
  262. }
  263. report(item: Item, event: Event): void {
  264. event.stopPropagation();
  265. this.saveScheduleScroll(item, event);
  266. if (this.rawStatus(item) > 10) {
  267. void this.router.navigate(['/pages/shop/baobiao'], { queryParams: { sid: this.scheduleId(item) } });
  268. return;
  269. }
  270. this.loadStudentAndNavigate(item, '/pages/shop/baobiao', 'id');
  271. }
  272. private loadStudentAndNavigate(item: Item, path: string, queryKey: 'id' | 'sid'): void {
  273. const studentId = Number(item['szyh'] ?? 0);
  274. this.storeAppointmentSnapshot(item);
  275. if (!studentId) {
  276. void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
  277. return;
  278. }
  279. this.platform.loading.set(true);
  280. this.api.post<AppUser>('user_get', { uid: studentId }).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
  281. next: ({ result }) => {
  282. this.session.setStudent(result);
  283. void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
  284. },
  285. error: () => {
  286. this.platform.toast('学员信息暂时无法加载,已使用排课信息打开详情', 'error');
  287. void this.router.navigate([path], { queryParams: { [queryKey]: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
  288. },
  289. });
  290. }
  291. private buildStudentOptions(): StudentOption[] {
  292. if (!this.isCoach || this.isHistory) return [];
  293. const options = new Map<string, StudentOption>();
  294. for (const item of this.schedules()) {
  295. const id = this.studentId(item);
  296. const name = this.studentName(item);
  297. const current = options.get(id);
  298. if (current) current.count += 1;
  299. else options.set(id, { id, name, count: 1 });
  300. }
  301. return [...options.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
  302. }
  303. private buildHistoryMonthOptions(): MonthOption[] {
  304. if (!this.isHistory) return [];
  305. const counts = new Map<string, number>();
  306. for (const item of this.serverDates()) {
  307. const month = item.date.slice(0, 7);
  308. if (!/^\d{4}-\d{2}$/.test(month)) continue;
  309. counts.set(month, (counts.get(month) ?? 0) + item.finished + item.planned);
  310. }
  311. return [...counts.entries()]
  312. .sort(([left], [right]) => right.localeCompare(left))
  313. .map(([value, count]) => {
  314. const [year, month] = value.split('-').map(Number);
  315. return { value, count, label: `${year}年${month}月` };
  316. });
  317. }
  318. private assignedUnits(item: Item): Array<{ id: string; name: string }> {
  319. const raw = item['syjd'] ?? item['assignedUnits'];
  320. let source: unknown = raw;
  321. if (typeof raw === 'string' && raw.trim()) {
  322. try { source = JSON.parse(raw); } catch { return []; }
  323. }
  324. const entries = Array.isArray(source)
  325. ? source.map((value, index) => [String(index), value] as const)
  326. : source && typeof source === 'object'
  327. ? Object.entries(source)
  328. : [];
  329. const seen = new Set<string>();
  330. const units: Array<{ id: string; name: string }> = [];
  331. for (const [key, value] of entries) {
  332. const text = typeof value === 'object' && value
  333. ? String((value as Item)['name'] ?? (value as Item)['title'] ?? (value as Item)['NodeName'] ?? '')
  334. : String(value ?? '');
  335. const parts = text.split('@');
  336. const id = String((typeof value === 'object' && value ? (value as Item)['id'] ?? (value as Item)['nodeId'] : '') || (/^\d+$/.test(key) ? key : parts[0]) || '').trim();
  337. if (!/^\d+$/.test(id) || seen.has(id)) continue;
  338. seen.add(id);
  339. units.push({ id, name: String(parts[1] ?? parts[0] ?? `学习单元 ${id}`).trim() || `学习单元 ${id}` });
  340. }
  341. return units;
  342. }
  343. private buildCalendarDays(): CalendarDay[] {
  344. const month = this.calendarMonth();
  345. const year = month.getFullYear();
  346. const monthIndex = month.getMonth();
  347. const firstDay = new Date(year, monthIndex, 1, 12);
  348. const offset = (firstDay.getDay() + 6) % 7;
  349. const start = new Date(year, monthIndex, 1 - offset, 12);
  350. const today = this.today();
  351. const counts = new Map<string, { finished: number; planned: number }>();
  352. for(const row of this.serverDates()) counts.set(row.date,{finished:row.finished,planned:row.planned});
  353. return Array.from({ length: 42 }, (_, index) => {
  354. const date = new Date(start);
  355. date.setDate(start.getDate() + index);
  356. const key = this.dateKey(date);
  357. const count = counts.get(key);
  358. const finishedCount = count?.finished ?? 0;
  359. const plannedCount = count?.planned ?? 0;
  360. return {
  361. date: key,
  362. day: date.getDate(),
  363. inMonth: date.getMonth() === monthIndex,
  364. isToday: key === today,
  365. count: finishedCount + plannedCount,
  366. finishedCount,
  367. plannedCount,
  368. };
  369. });
  370. }
  371. private matchesStudent(item: Item): boolean {
  372. const selected = this.selectedStudent();
  373. return selected === 'all' || this.studentId(item) === selected;
  374. }
  375. private matchesStatus(item: Item): boolean {
  376. if (this.scheduleFilter() === 'finished') return this.isFinished(item);
  377. if (this.scheduleFilter() === 'pending') return this.isOverdueUnfinished(item);
  378. return true;
  379. }
  380. private matchesRange(item: Item): boolean {
  381. if (!this.hasScheduleCalendar || this.scheduleRange() === 'all') return true;
  382. const date = this.itemDate(item);
  383. if (!date) return false;
  384. if (this.scheduleRange() === 'week') {
  385. const [start, end] = this.weekRange(this.selectedDate());
  386. return date >= start && date <= end;
  387. }
  388. return date === this.selectedDate();
  389. }
  390. private matchesHistoryFilters(item: Item): boolean {
  391. if (!this.isHistory) return true;
  392. const selectedMonth = this.historyMonth();
  393. if (selectedMonth !== 'all' && !this.itemDate(item).startsWith(`${selectedMonth}-`)) return false;
  394. const query = this.historyStudentQuery().trim().toLocaleLowerCase('zh-CN');
  395. return !query || this.studentName(item).toLocaleLowerCase('zh-CN').includes(query);
  396. }
  397. private sortedVisibleSchedules(): Item[] {
  398. const list = [...this.visibleSchedules()];
  399. if (this.isHistory) return list;
  400. return list.sort((left, right) => this.lessonStartTime(left) - this.lessonStartTime(right));
  401. }
  402. private rawStatus(item: Item): number { return Number(item['dszt'] ?? 0); }
  403. private isPendingRaw(item: Item): boolean { return this.rawStatus(item) <= 10; }
  404. private isFutureUnfinished(item: Item): boolean {
  405. if (!this.isPendingRaw(item)) return false;
  406. const start = this.lessonStart(item);
  407. return Boolean(start && start.getTime() > this.now());
  408. }
  409. private isOverdueUnfinished(item: Item): boolean {
  410. if (!this.isPendingRaw(item)) return false;
  411. const end = this.lessonEnd(item);
  412. if (end) return end.getTime() < this.now();
  413. const date = this.itemDate(item);
  414. return Boolean(date && date < this.today());
  415. }
  416. private lessonStartTime(item: Item): number {
  417. return this.lessonStart(item)?.getTime() ?? Number.MAX_SAFE_INTEGER;
  418. }
  419. private lessonStart(item: Item): Date | null {
  420. const rawDateTime = this.dateTimeFromRaw(item['yysj'] ?? item['bxrq'] ?? item['yydate']);
  421. const date = this.itemDate(item);
  422. if (!date) return rawDateTime;
  423. const time = this.timeParts(item['sdsd'] ?? item['kssj'] ?? item['yysj']);
  424. if (!time) return rawDateTime ?? this.parseDate(date);
  425. const [year, month, day] = date.split('-').map(Number);
  426. return new Date(year, month - 1, day, time.hour, time.minute);
  427. }
  428. private lessonEnd(item: Item): Date | null {
  429. const start = this.lessonStart(item);
  430. if (!start) return null;
  431. return new Date(start.getTime() + this.lessonMinutes(item) * 60_000);
  432. }
  433. private lessonMinutes(item: Item): number {
  434. const text = `${item['Title'] ?? ''} ${item['Title2'] ?? ''} ${item['Subtitle'] ?? ''}`;
  435. const matched = text.match(/(\d{1,3})\s*分钟/);
  436. if (matched) return Number(matched[1]);
  437. const type = Number(item['dslx'] ?? 0);
  438. if (type === 1) return 30;
  439. if (type === 2) return 60;
  440. if (type === 3) return 30;
  441. return 0;
  442. }
  443. private timeParts(value: unknown): { hour: number; minute: number } | null {
  444. const matched = String(value ?? '').match(/(?:^|\s)(\d{1,2}):(\d{2})/);
  445. if (!matched) return null;
  446. const hour = Number(matched[1]);
  447. const minute = Number(matched[2]);
  448. if (hour > 23 || minute > 59) return null;
  449. return { hour, minute };
  450. }
  451. private dateTimeFromRaw(value: unknown): Date | null {
  452. const raw = String(value ?? '');
  453. const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?/);
  454. if (!matched) return null;
  455. return new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]), Number(matched[4] ?? 0), Number(matched[5] ?? 0));
  456. }
  457. private selectDate(date: string): void {
  458. this.selectedDate.set(date);
  459. this.scheduleRange.set('day');
  460. const value = this.parseDate(date);
  461. this.calendarMonth.set(new Date(value.getFullYear(), value.getMonth(), 1, 12));
  462. this.syncScheduleState();
  463. this.load();
  464. }
  465. private calendarWindow(): { start: string; end: string } {
  466. const month = this.calendarMonth();
  467. const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
  468. const offset = (first.getDay() + 6) % 7;
  469. const start = new Date(first);
  470. start.setDate(first.getDate() - offset);
  471. const end = new Date(start);
  472. end.setDate(start.getDate() + 41);
  473. return { start: this.dateKey(start), end: this.dateKey(end) };
  474. }
  475. private ensureSelectedDate(): void {
  476. if (!this.selectedDate()) this.selectedDate.set(this.today());
  477. }
  478. private itemDate(item: Item): string {
  479. return this.dateKey(item['bxrq'] ?? item['yydate'] ?? item['yysj'] ?? item['PubAddTime']);
  480. }
  481. private studentId(item: Item): string {
  482. return String(item['szyh'] ?? item['UserID'] ?? item['userId'] ?? this.studentName(item));
  483. }
  484. private studentName(item: Item): string {
  485. const explicit = item['honeyname'] ?? item['HoneyName'] ?? item['honeyName'] ?? item['xymz_t'] ?? item['studentName'];
  486. if (explicit) return String(explicit);
  487. const title = this.scheduleTitle(item);
  488. return title.includes('(') ? title.slice(0, title.indexOf('(')) : title || '未命名学员';
  489. }
  490. private weekRange(date: string): [string, string] {
  491. const start = this.parseDate(date);
  492. start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
  493. const end = new Date(start);
  494. end.setDate(start.getDate() + 6);
  495. return [this.dateKey(start), this.dateKey(end)];
  496. }
  497. private asList(value: unknown): Item[] {
  498. if (Array.isArray(value)) return value.filter((item): item is Item => Boolean(item) && typeof item === 'object');
  499. if (value && typeof value === 'object') {
  500. for (const nested of Object.values(value)) if (Array.isArray(nested)) return nested as Item[];
  501. }
  502. return [];
  503. }
  504. today(): string {
  505. const now = new Date();
  506. return this.dateKey(now);
  507. }
  508. tomorrow(): string {
  509. const date = this.parseDate(this.today());
  510. date.setDate(date.getDate() + 1);
  511. return this.dateKey(date);
  512. }
  513. private firstOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1, 12); }
  514. private parseDate(value: string): Date {
  515. const [year, month, day] = value.split('-').map(Number);
  516. return new Date(year || new Date().getFullYear(), (month || 1) - 1, day || 1, 12);
  517. }
  518. private dateKey(value: unknown): string {
  519. const raw = String(value ?? '');
  520. const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  521. if (matched) return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
  522. const date = value instanceof Date ? value : new Date(raw);
  523. if (Number.isNaN(date.getTime())) return '';
  524. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  525. }
  526. private restoreScheduleState(): boolean {
  527. if (!this.hasScheduleCalendar) return false;
  528. const query = this.route.snapshot.queryParamMap;
  529. let restored = false;
  530. const status = query.get('csStatus') as ScheduleFilter | null;
  531. const range = query.get('csRange') as ScheduleRange | null;
  532. const date = query.get('csDate');
  533. const student = query.get('csStudent');
  534. const month = query.get('csMonth');
  535. if (status && ['all', 'pending', 'finished'].includes(status)) {
  536. this.scheduleFilter.set(status);
  537. restored = true;
  538. }
  539. if (range && ['day', 'week', 'all'].includes(range)) {
  540. this.scheduleRange.set(range);
  541. restored = true;
  542. }
  543. if (date && this.dateKey(date)) {
  544. this.selectedDate.set(this.dateKey(date));
  545. restored = true;
  546. }
  547. if (student) {
  548. this.selectedStudent.set(student);
  549. restored = true;
  550. }
  551. if (month) {
  552. const restoredMonth = this.parseMonth(month);
  553. if (restoredMonth) {
  554. this.calendarMonth.set(restoredMonth);
  555. restored = true;
  556. }
  557. } else if (date && this.dateKey(date)) {
  558. this.calendarMonth.set(this.firstOfMonth(this.parseDate(this.dateKey(date))));
  559. }
  560. return restored;
  561. }
  562. private syncScheduleState(): void {
  563. if (!this.hasScheduleCalendar || this.syncingQuery) return;
  564. this.syncingQuery = true;
  565. void this.router.navigate([], {
  566. relativeTo: this.route,
  567. queryParams: {
  568. csRange: this.scheduleRange(),
  569. csDate: this.selectedDate(),
  570. csStudent: this.selectedStudent(),
  571. csStatus: this.scheduleFilter(),
  572. csMonth: this.monthKey(this.calendarMonth()),
  573. csScroll: null,
  574. csScrollAt: null,
  575. csAnchor: null,
  576. csAnchorTop: null,
  577. },
  578. queryParamsHandling: 'merge',
  579. replaceUrl: true,
  580. }).finally(() => { this.syncingQuery = false; });
  581. }
  582. private saveScheduleScroll(item?: Item, event?: Event): void {
  583. if (!this.hasScheduleCalendar) return;
  584. const article = event?.target instanceof Element ? event.target.closest<HTMLElement>('[data-schedule-id]') : null;
  585. const state = {
  586. top: this.scrollTop(),
  587. at: Date.now(),
  588. itemId: item ? String(this.scheduleId(item) ?? '') : '',
  589. itemTop: article?.getBoundingClientRect().top,
  590. };
  591. this.replaceScrollQuery(state);
  592. try {
  593. sessionStorage.setItem(this.scheduleScrollKey(), JSON.stringify(state));
  594. } catch {
  595. // Ignore storage failures; navigation still works without scroll restoration.
  596. }
  597. }
  598. private restoreScheduleScroll(): void {
  599. if (!this.hasScheduleCalendar) return;
  600. const state = this.readScrollState();
  601. if (!state) return;
  602. const apply = () => {
  603. const element = document.scrollingElement ?? document.documentElement;
  604. const max = Math.max(0, element.scrollHeight - element.clientHeight);
  605. window.scrollTo({ top: Math.min(state.top, max), behavior: 'auto' });
  606. this.restoreScheduleAnchor(state);
  607. };
  608. setTimeout(() => {
  609. requestAnimationFrame(() => {
  610. apply();
  611. setTimeout(apply, 80);
  612. setTimeout(apply, 220);
  613. setTimeout(apply, 600);
  614. setTimeout(apply, 1200);
  615. setTimeout(apply, 1800);
  616. });
  617. });
  618. }
  619. private restoreScheduleAnchor(state: { itemId?: string; itemTop?: number }): void {
  620. if (!state.itemId || !Number.isFinite(state.itemTop)) return;
  621. const article = Array.from(document.querySelectorAll<HTMLElement>('[data-schedule-id]'))
  622. .find((element) => element.getAttribute('data-schedule-id') === state.itemId);
  623. if (!article) return;
  624. window.scrollBy({ top: article.getBoundingClientRect().top - Number(state.itemTop), behavior: 'auto' });
  625. }
  626. private readScrollState(): { top: number; at: number; itemId?: string; itemTop?: number } | null {
  627. const queryState = this.scrollStateFromQuery();
  628. if (queryState) return queryState;
  629. try {
  630. const parsed = JSON.parse(sessionStorage.getItem(this.scheduleScrollKey()) ?? 'null') as { top?: unknown; at?: unknown; itemId?: unknown; itemTop?: unknown } | null;
  631. const top = Number(parsed?.top);
  632. const at = Number(parsed?.at);
  633. if (!Number.isFinite(top) || !Number.isFinite(at) || Date.now() - at > SCROLL_STATE_TTL) return null;
  634. const itemTop = Number(parsed?.itemTop);
  635. return {
  636. top,
  637. at,
  638. itemId: typeof parsed?.itemId === 'string' ? parsed.itemId : undefined,
  639. itemTop: Number.isFinite(itemTop) ? itemTop : undefined,
  640. };
  641. } catch {
  642. return null;
  643. }
  644. }
  645. private scrollTop(): number {
  646. return window.scrollY || document.scrollingElement?.scrollTop || document.documentElement.scrollTop || 0;
  647. }
  648. private replaceScrollQuery(state: { top: number; at: number; itemId?: string; itemTop?: number }): void {
  649. const url = new URL(window.location.href);
  650. url.searchParams.set('csScroll', String(Math.round(state.top)));
  651. url.searchParams.set('csScrollAt', String(Math.round(state.at)));
  652. if (state.itemId) url.searchParams.set('csAnchor', state.itemId);
  653. if (Number.isFinite(state.itemTop)) url.searchParams.set('csAnchorTop', String(Math.round(Number(state.itemTop))));
  654. history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
  655. }
  656. private scrollStateFromQuery(): { top: number; at: number; itemId?: string; itemTop?: number } | null {
  657. const query = this.route.snapshot.queryParamMap;
  658. const top = Number(query.get('csScroll'));
  659. const at = Number(query.get('csScrollAt'));
  660. if (!Number.isFinite(top) || !Number.isFinite(at) || Date.now() - at > SCROLL_STATE_TTL) return null;
  661. const itemTop = Number(query.get('csAnchorTop'));
  662. return {
  663. top,
  664. at,
  665. itemId: query.get('csAnchor') ?? undefined,
  666. itemTop: Number.isFinite(itemTop) ? itemTop : undefined,
  667. };
  668. }
  669. private scheduleScrollKey(): string {
  670. return [
  671. 'xiaoshu.course-center.scroll',
  672. this.meta.path,
  673. this.session.user()?.userId ?? 0,
  674. this.scheduleRange(),
  675. this.selectedDate(),
  676. this.selectedStudent(),
  677. this.scheduleFilter(),
  678. this.monthKey(this.calendarMonth()),
  679. ].join('.');
  680. }
  681. private storeAppointmentSnapshot(item: Item): void {
  682. const id = this.scheduleId(item);
  683. if (id === undefined || id === null || id === '') return;
  684. try {
  685. sessionStorage.setItem(`xiaoshu.appointment.snapshot.${id}`, JSON.stringify(item));
  686. } catch {
  687. // Storage can be unavailable in restricted browser modes; navigation state still carries the snapshot.
  688. }
  689. }
  690. private monthKey(date: Date): string {
  691. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
  692. }
  693. private parseMonth(value: string): Date | null {
  694. const matched = value.match(/^(\d{4})-(\d{1,2})$/);
  695. if (!matched) return null;
  696. return new Date(Number(matched[1]), Number(matched[2]) - 1, 1, 12);
  697. }
  698. }