operations-learning-reports.component.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { DropdownDirective } from '../shared/dropdown.directive';
  2. import { CommonModule } from '@angular/common';
  3. import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnInit, ViewChild, inject, signal } from '@angular/core';
  4. import { FormsModule } from '@angular/forms';
  5. import { ActivatedRoute, RouterLink } from '@angular/router';
  6. import html2pdf from 'html2pdf.js';
  7. import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
  8. import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models';
  9. import { OperationsService } from '../operations.service';
  10. const localMonth = (): string => {
  11. const now = new Date();
  12. return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
  13. };
  14. type ReportDatePicker = 'month' | 'from' | 'to';
  15. interface CalendarDay { day: number; date: string; }
  16. @Component({
  17. selector: 'app-operations-learning-reports',
  18. standalone: true,
  19. imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
  20. templateUrl: './operations-learning-reports.component.html',
  21. styleUrls: ['./operations-professional.scss', './operations-learning-reports.component.scss'],
  22. changeDetection: ChangeDetectionStrategy.OnPush,
  23. })
  24. export class OperationsLearningReportsComponent implements OnInit {
  25. @ViewChild('pdfContent') pdfContent?: ElementRef<HTMLElement>;
  26. readonly data = signal<LearningReportPage | null>(null);
  27. readonly detail = signal<LearningReportDetail | null>(null);
  28. readonly loading = signal(true);
  29. readonly searching = signal(false);
  30. readonly refreshing = signal(false);
  31. readonly detailLoading = signal(false);
  32. readonly exportingId = signal('');
  33. readonly error = signal('');
  34. readonly message = signal('');
  35. readonly page = signal(1);
  36. readonly pageSize = 20;
  37. readonly month = signal(localMonth());
  38. readonly dateFrom = signal('');
  39. readonly dateTo = signal('');
  40. readonly search = signal('');
  41. readonly coachSearch = signal('');
  42. readonly courseSearch = signal('');
  43. readonly status = signal('');
  44. readonly studentObjectId = signal('');
  45. readonly activePicker = signal<ReportDatePicker | null>(null);
  46. readonly calendarYear = signal(new Date().getFullYear());
  47. readonly calendarMonth = signal(new Date().getMonth() + 1);
  48. readonly monthPickerYear = signal(new Date().getFullYear());
  49. readonly weekDays = ['一', '二', '三', '四', '五', '六', '日'];
  50. readonly monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
  51. readonly today = new Date();
  52. readonly icons = { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, RotateCcw, Search, X };
  53. private readonly operations = inject(OperationsService);
  54. private readonly route = inject(ActivatedRoute);
  55. ngOnInit(): void {
  56. this.studentObjectId.set(this.route.snapshot.queryParamMap.get('studentObjectId') || '');
  57. const requestedReport = this.route.snapshot.queryParamMap.get('report') || '';
  58. void this.load(false).then(() => requestedReport ? this.open(requestedReport) : undefined);
  59. }
  60. private filters(): Record<string, unknown> {
  61. return {
  62. month: this.month(), dateFrom: this.dateFrom(), dateTo: this.dateTo(), status: this.status(),
  63. coachSearch: this.coachSearch(), courseSearch: this.courseSearch(), studentObjectId: this.studentObjectId(),
  64. };
  65. }
  66. async load(refresh = false): Promise<void> {
  67. if (refresh) this.refreshing.set(true); else this.loading.set(true);
  68. this.error.set(''); this.message.set('');
  69. try {
  70. const page = await this.operations.learningReports(this.page(), this.pageSize, this.search(), this.filters(), refresh);
  71. this.data.set(page);
  72. if (refresh) this.message.set(page.sourceStatus.state === 'synced' ? '学习记录已同步到最新水位' : page.sourceStatus.message);
  73. } catch (error) {
  74. this.error.set(error instanceof Error ? error.message : '学习报表加载失败');
  75. } finally {
  76. this.loading.set(false); this.refreshing.set(false);
  77. }
  78. }
  79. async applyFilters(): Promise<void> {
  80. if (this.searching()) return;
  81. this.activePicker.set(null); this.searching.set(true); this.page.set(1);
  82. try { await this.load(false); }
  83. finally { this.searching.set(false); }
  84. }
  85. async resetFilters(): Promise<void> {
  86. if (this.searching()) return;
  87. this.month.set(localMonth()); this.dateFrom.set(''); this.dateTo.set(''); this.search.set('');
  88. this.coachSearch.set(''); this.courseSearch.set(''); this.status.set(''); this.activePicker.set(null);
  89. await this.applyFilters();
  90. }
  91. @HostListener('document:click')
  92. closePicker(): void { this.activePicker.set(null); }
  93. @HostListener('document:keydown.escape')
  94. closePickerWithKeyboard(): void { this.closePicker(); }
  95. toggleMonthPicker(event: Event): void {
  96. event.stopPropagation();
  97. if (this.activePicker() === 'month') { this.activePicker.set(null); return; }
  98. const year = Number(this.month().slice(0, 4));
  99. this.monthPickerYear.set(year || new Date().getFullYear()); this.activePicker.set('month');
  100. }
  101. toggleDatePicker(target: 'from' | 'to', event: Event): void {
  102. event.stopPropagation();
  103. if (this.activePicker() === target) { this.activePicker.set(null); return; }
  104. const value = target === 'from' ? this.dateFrom() : this.dateTo();
  105. const base = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : `${this.month() || localMonth()}-01`;
  106. this.calendarYear.set(Number(base.slice(0, 4))); this.calendarMonth.set(Number(base.slice(5, 7))); this.activePicker.set(target);
  107. }
  108. selectMonth(monthIndex: number): void {
  109. this.month.set(`${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`); this.activePicker.set(null);
  110. }
  111. shiftMonthPickerYear(delta: number, event: Event): void { event.stopPropagation(); this.monthPickerYear.update((year) => year + delta); }
  112. shiftCalendar(delta: number, event: Event): void {
  113. event.stopPropagation();
  114. const next = new Date(this.calendarYear(), this.calendarMonth() - 1 + delta, 1);
  115. this.calendarYear.set(next.getFullYear()); this.calendarMonth.set(next.getMonth() + 1);
  116. }
  117. selectDate(date: string): void {
  118. if (this.activePicker() === 'from') {
  119. this.dateFrom.set(date); if (this.dateTo() && this.dateTo() < date) this.dateTo.set('');
  120. } else if (this.activePicker() === 'to') {
  121. this.dateTo.set(date); if (this.dateFrom() && this.dateFrom() > date) this.dateFrom.set('');
  122. }
  123. this.activePicker.set(null);
  124. }
  125. calendarDays(): Array<CalendarDay | null> {
  126. const year = this.calendarYear(), month = this.calendarMonth();
  127. const leading = (new Date(year, month - 1, 1).getDay() + 6) % 7;
  128. const count = new Date(year, month, 0).getDate();
  129. return [...Array.from({ length: leading }, () => null), ...Array.from({ length: count }, (_, index) => {
  130. const day = index + 1;
  131. return { day, date: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` };
  132. })];
  133. }
  134. monthLabel(): string { const [year, month] = this.month().split('-'); return year && month ? `${year}年${Number(month)}月` : '选择月份'; }
  135. monthOptionValue(monthIndex: number): string { return `${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`; }
  136. dateLabel(value: string): string { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.replace(/^(\d{4})-(\d{2})-(\d{2})$/, '$1年$2月$3日') : '选择日期'; }
  137. calendarTitle(): string { return `${this.calendarYear()}年 ${this.calendarMonth()}月`; }
  138. selectedDate(): string { return this.activePicker() === 'from' ? this.dateFrom() : this.dateTo(); }
  139. async changePage(next: number): Promise<void> {
  140. const totalPages = Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize));
  141. if (next < 1 || next > totalPages || next === this.page()) return;
  142. this.page.set(next); await this.load(false);
  143. }
  144. async open(targetId: string): Promise<void> {
  145. this.detailLoading.set(true); this.error.set('');
  146. try { this.detail.set(await this.operations.learningReportDetail(targetId)); }
  147. catch (error) { this.error.set(error instanceof Error ? error.message : '报表详情加载失败'); }
  148. finally { this.detailLoading.set(false); }
  149. }
  150. close(): void { this.detail.set(null); }
  151. async download(row: LearningReportListItem | LearningReportDetail): Promise<void> {
  152. if (!row.canExport || this.exportingId()) return;
  153. this.exportingId.set(row.targetId); this.error.set('');
  154. try {
  155. const report = await this.operations.learningReportExport(row.targetId);
  156. this.detail.set(report);
  157. await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
  158. await document.fonts?.ready;
  159. const element = this.pdfContent?.nativeElement;
  160. if (!element) throw new Error('报表打印区域尚未准备完成');
  161. const safeName = `${report.studentName}-${report.studyDate}-${report.generalId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
  162. let hasBlankTrailingPage = false;
  163. const worker = html2pdf().set({
  164. margin: [9, 9, 9, 9], filename: `小树陪练-单词学习报表-${safeName}.pdf`,
  165. image: { type: 'jpeg', quality: 1 },
  166. html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff' },
  167. jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
  168. pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-item', '.comment-card'] },
  169. }).from(element).toCanvas().get('canvas', (canvas) => {
  170. const pageHeight = Math.floor(canvas.width * (279 / 192));
  171. const trailingSlice = canvas.height % pageHeight;
  172. hasBlankTrailingPage = trailingSlice > 0 && trailingSlice < 32;
  173. }).toPdf();
  174. await worker.get('pdf', (pdf) => {
  175. const pageCount = pdf.internal.getNumberOfPages();
  176. if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
  177. }).save();
  178. this.message.set('学习报表 PDF 已生成');
  179. } catch (error) {
  180. this.error.set(error instanceof Error ? error.message : 'PDF 生成失败');
  181. } finally { this.exportingId.set(''); }
  182. }
  183. totalPages(): number { return Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize)); }
  184. trackReport(_index: number, row: LearningReportListItem): string { return row.targetId; }
  185. }