|
|
@@ -0,0 +1,127 @@
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
+import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, signal } from '@angular/core';
|
|
|
+import { FormsModule } from '@angular/forms';
|
|
|
+import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
|
+import html2pdf from 'html2pdf.js';
|
|
|
+import { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, 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')}`;
|
|
|
+};
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-operations-learning-reports',
|
|
|
+ standalone: true,
|
|
|
+ imports: [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<HTMLElement>;
|
|
|
+
|
|
|
+ readonly data = signal<LearningReportPage | null>(null);
|
|
|
+ readonly detail = signal<LearningReportDetail | null>(null);
|
|
|
+ readonly loading = signal(true);
|
|
|
+ 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 icons = { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, 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<string, unknown> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> { this.page.set(1); await this.load(false); }
|
|
|
+ async changePage(next: number): Promise<void> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ 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<void>((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; }
|
|
|
+}
|