import { DropdownDirective } from '../shared/dropdown.directive'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnInit, ViewChild, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import html2pdf from 'html2pdf.js'; import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular'; import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models'; import { OperationsService } from '../operations.service'; const localMonth = (): string => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; }; type ReportDatePicker = 'month' | 'from' | 'to'; interface CalendarDay { day: number; date: string; } @Component({ selector: 'app-operations-learning-reports', standalone: true, imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule], templateUrl: './operations-learning-reports.component.html', styleUrls: ['./operations-professional.scss', './operations-learning-reports.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class OperationsLearningReportsComponent implements OnInit { @ViewChild('pdfContent') pdfContent?: ElementRef; readonly data = signal(null); readonly detail = signal(null); readonly loading = signal(true); readonly searching = signal(false); readonly refreshing = signal(false); readonly detailLoading = signal(false); readonly exportingId = signal(''); readonly error = signal(''); readonly message = signal(''); readonly page = signal(1); readonly pageSize = 20; readonly month = signal(localMonth()); readonly dateFrom = signal(''); readonly dateTo = signal(''); readonly search = signal(''); readonly coachSearch = signal(''); readonly courseSearch = signal(''); readonly status = signal(''); readonly studentObjectId = signal(''); readonly activePicker = signal(null); readonly calendarYear = signal(new Date().getFullYear()); readonly calendarMonth = signal(new Date().getMonth() + 1); readonly monthPickerYear = signal(new Date().getFullYear()); readonly weekDays = ['一', '二', '三', '四', '五', '六', '日']; readonly monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; readonly today = new Date(); readonly icons = { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, RotateCcw, Search, X }; private readonly operations = inject(OperationsService); private readonly route = inject(ActivatedRoute); ngOnInit(): void { this.studentObjectId.set(this.route.snapshot.queryParamMap.get('studentObjectId') || ''); const requestedReport = this.route.snapshot.queryParamMap.get('report') || ''; void this.load(false).then(() => requestedReport ? this.open(requestedReport) : undefined); } private filters(): Record { return { month: this.month(), dateFrom: this.dateFrom(), dateTo: this.dateTo(), status: this.status(), coachSearch: this.coachSearch(), courseSearch: this.courseSearch(), studentObjectId: this.studentObjectId(), }; } async load(refresh = false): Promise { if (refresh) this.refreshing.set(true); else this.loading.set(true); this.error.set(''); this.message.set(''); try { const page = await this.operations.learningReports(this.page(), this.pageSize, this.search(), this.filters(), refresh); this.data.set(page); if (refresh) this.message.set(page.sourceStatus.state === 'synced' ? '学习记录已同步到最新水位' : page.sourceStatus.message); } catch (error) { this.error.set(error instanceof Error ? error.message : '学习报表加载失败'); } finally { this.loading.set(false); this.refreshing.set(false); } } async applyFilters(): Promise { if (this.searching()) return; this.activePicker.set(null); this.searching.set(true); this.page.set(1); try { await this.load(false); } finally { this.searching.set(false); } } async resetFilters(): Promise { if (this.searching()) return; this.month.set(localMonth()); this.dateFrom.set(''); this.dateTo.set(''); this.search.set(''); this.coachSearch.set(''); this.courseSearch.set(''); this.status.set(''); this.activePicker.set(null); await this.applyFilters(); } @HostListener('document:click') closePicker(): void { this.activePicker.set(null); } @HostListener('document:keydown.escape') closePickerWithKeyboard(): void { this.closePicker(); } toggleMonthPicker(event: Event): void { event.stopPropagation(); if (this.activePicker() === 'month') { this.activePicker.set(null); return; } const year = Number(this.month().slice(0, 4)); this.monthPickerYear.set(year || new Date().getFullYear()); this.activePicker.set('month'); } toggleDatePicker(target: 'from' | 'to', event: Event): void { event.stopPropagation(); if (this.activePicker() === target) { this.activePicker.set(null); return; } const value = target === 'from' ? this.dateFrom() : this.dateTo(); const base = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : `${this.month() || localMonth()}-01`; this.calendarYear.set(Number(base.slice(0, 4))); this.calendarMonth.set(Number(base.slice(5, 7))); this.activePicker.set(target); } selectMonth(monthIndex: number): void { this.month.set(`${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`); this.activePicker.set(null); } shiftMonthPickerYear(delta: number, event: Event): void { event.stopPropagation(); this.monthPickerYear.update((year) => year + delta); } shiftCalendar(delta: number, event: Event): void { event.stopPropagation(); const next = new Date(this.calendarYear(), this.calendarMonth() - 1 + delta, 1); this.calendarYear.set(next.getFullYear()); this.calendarMonth.set(next.getMonth() + 1); } selectDate(date: string): void { if (this.activePicker() === 'from') { this.dateFrom.set(date); if (this.dateTo() && this.dateTo() < date) this.dateTo.set(''); } else if (this.activePicker() === 'to') { this.dateTo.set(date); if (this.dateFrom() && this.dateFrom() > date) this.dateFrom.set(''); } this.activePicker.set(null); } calendarDays(): Array { const year = this.calendarYear(), month = this.calendarMonth(); const leading = (new Date(year, month - 1, 1).getDay() + 6) % 7; const count = new Date(year, month, 0).getDate(); return [...Array.from({ length: leading }, () => null), ...Array.from({ length: count }, (_, index) => { const day = index + 1; return { day, date: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` }; })]; } monthLabel(): string { const [year, month] = this.month().split('-'); return year && month ? `${year}年${Number(month)}月` : '选择月份'; } monthOptionValue(monthIndex: number): string { return `${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`; } dateLabel(value: string): string { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.replace(/^(\d{4})-(\d{2})-(\d{2})$/, '$1年$2月$3日') : '选择日期'; } calendarTitle(): string { return `${this.calendarYear()}年 ${this.calendarMonth()}月`; } selectedDate(): string { return this.activePicker() === 'from' ? this.dateFrom() : this.dateTo(); } async changePage(next: number): Promise { const totalPages = Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize)); if (next < 1 || next > totalPages || next === this.page()) return; this.page.set(next); await this.load(false); } async open(targetId: string): Promise { this.detailLoading.set(true); this.error.set(''); try { this.detail.set(await this.operations.learningReportDetail(targetId)); } catch (error) { this.error.set(error instanceof Error ? error.message : '报表详情加载失败'); } finally { this.detailLoading.set(false); } } close(): void { this.detail.set(null); } async download(row: LearningReportListItem | LearningReportDetail): Promise { if (!row.canExport || this.exportingId()) return; this.exportingId.set(row.targetId); this.error.set(''); try { const report = await this.operations.learningReportExport(row.targetId); this.detail.set(report); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); await document.fonts?.ready; const element = this.pdfContent?.nativeElement; if (!element) throw new Error('报表打印区域尚未准备完成'); const safeName = `${report.studentName}-${report.studyDate}-${report.generalId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_'); let hasBlankTrailingPage = false; const worker = html2pdf().set({ margin: [9, 9, 9, 9], filename: `小树陪练-单词学习报表-${safeName}.pdf`, image: { type: 'jpeg', quality: 1 }, html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-item', '.comment-card'] }, }).from(element).toCanvas().get('canvas', (canvas) => { const pageHeight = Math.floor(canvas.width * (279 / 192)); const trailingSlice = canvas.height % pageHeight; hasBlankTrailingPage = trailingSlice > 0 && trailingSlice < 32; }).toPdf(); await worker.get('pdf', (pdf) => { const pageCount = pdf.internal.getNumberOfPages(); if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount); }).save(); this.message.set('学习报表 PDF 已生成'); } catch (error) { this.error.set(error instanceof Error ? error.message : 'PDF 生成失败'); } finally { this.exportingId.set(''); } } totalPages(): number { return Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize)); } trackReport(_index: number, row: LearningReportListItem): string { return row.targetId; } }