|
@@ -1,47 +1,87 @@
|
|
|
import { lessonStudySources } from './lesson-study-sources';
|
|
import { lessonStudySources } from './lesson-study-sources';
|
|
|
import { CommonModule } from '@angular/common';
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
|
|
|
|
|
|
|
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
|
|
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
|
-import { BookOpen, BrainCircuit, ClipboardList, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
|
|
|
|
|
-import { finalize } from 'rxjs';
|
|
|
|
|
|
|
+import { BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
|
|
|
|
|
+import { finalize, firstValueFrom } from 'rxjs';
|
|
|
import { ApiService } from '../../core/api.service';
|
|
import { ApiService } from '../../core/api.service';
|
|
|
import { LessonFeedbackComponent } from './lesson-feedback.component';
|
|
import { LessonFeedbackComponent } from './lesson-feedback.component';
|
|
|
import { lessonDateLabel } from './lesson-display';
|
|
import { lessonDateLabel } from './lesson-display';
|
|
|
|
|
|
|
|
-interface Report {
|
|
|
|
|
- order: Record<string, unknown>;
|
|
|
|
|
- feedback: Record<string, unknown> | null;
|
|
|
|
|
- records: Array<Record<string, unknown>>;
|
|
|
|
|
- antiForget: {total: number; completed: number};
|
|
|
|
|
- refreshedAt: string;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+import { SessionService } from '../../core/session.service';
|
|
|
|
|
+import { LessonReportData, LessonPdfReport, lessonPdfReport } from './lesson-report-pdf';
|
|
|
|
|
+import { LessonReportPdfComponent } from './lesson-report-pdf.component';
|
|
|
|
|
+import { LessonReportDownloadService } from './lesson-report-download.service';
|
|
|
|
|
+
|
|
|
@Component({
|
|
@Component({
|
|
|
- standalone: true, imports: [CommonModule, RouterLink, LucideAngularModule, LessonFeedbackComponent],
|
|
|
|
|
|
|
+ standalone: true, imports: [CommonModule, RouterLink, LucideAngularModule, LessonFeedbackComponent, LessonReportPdfComponent],
|
|
|
templateUrl: './lesson-report.component.html', styleUrl: './lesson-report.component.scss',
|
|
templateUrl: './lesson-report.component.html', styleUrl: './lesson-report.component.scss',
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
})
|
|
})
|
|
|
export class LessonReportComponent implements OnInit {
|
|
export class LessonReportComponent implements OnInit {
|
|
|
- readonly report = signal<Report | null>(null);
|
|
|
|
|
|
|
+ readonly report = signal<LessonReportData | null>(null);
|
|
|
readonly sources = signal<Array<{label: string; count: number}>>([]);
|
|
readonly sources = signal<Array<{label: string; count: number}>>([]);
|
|
|
readonly loading = signal(false);
|
|
readonly loading = signal(false);
|
|
|
readonly error = signal('');
|
|
readonly error = signal('');
|
|
|
- readonly icons = {BookOpen, BrainCircuit, ClipboardList, RefreshCw, Star};
|
|
|
|
|
|
|
+ readonly icons = {BookOpen, BrainCircuit, ClipboardList, Download, LoaderCircle, RefreshCw, Star};
|
|
|
readonly dateLabel = lessonDateLabel;
|
|
readonly dateLabel = lessonDateLabel;
|
|
|
|
|
+ readonly exporting = signal(false);
|
|
|
|
|
+ readonly exportError = signal('');
|
|
|
|
|
+ readonly exportMessage = signal('');
|
|
|
|
|
+ readonly pdfReports = signal<LessonPdfReport[]>([]);
|
|
|
|
|
+ readonly session = inject(SessionService);
|
|
|
|
|
+ @ViewChild('pdfContent') pdfContent?: ElementRef<HTMLElement>;
|
|
|
|
|
+ private readonly changeDetector = inject(ChangeDetectorRef);
|
|
|
|
|
+ private readonly downloader = inject(LessonReportDownloadService);
|
|
|
private readonly api = inject(ApiService);
|
|
private readonly api = inject(ApiService);
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly route = inject(ActivatedRoute);
|
|
|
get orderId(): string { return this.route.snapshot.queryParamMap.get('sid') || this.route.snapshot.queryParamMap.get('id') || ''; }
|
|
get orderId(): string { return this.route.snapshot.queryParamMap.get('sid') || this.route.snapshot.queryParamMap.get('id') || ''; }
|
|
|
ngOnInit(): void { this.load(); }
|
|
ngOnInit(): void { this.load(); }
|
|
|
load(): void {
|
|
load(): void {
|
|
|
- if (this.loading()) return;
|
|
|
|
|
|
|
+ if (this.loading() || this.exporting()) return;
|
|
|
if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
|
|
if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
|
|
|
this.loading.set(true); this.error.set('');
|
|
this.loading.set(true); this.error.set('');
|
|
|
- this.api.get<Report>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
|
|
|
|
|
|
|
+ this.api.get<LessonReportData>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
|
|
|
next: r => {
|
|
next: r => {
|
|
|
this.report.set(r.result); this.sources.set([]);
|
|
this.report.set(r.result); this.sources.set([]);
|
|
|
this.api.get<any>('app_lesson_study', {orderId:this.orderId}).subscribe({next: ({result}) => this.sources.set(lessonStudySources(result)), error: () => this.sources.set([])});
|
|
this.api.get<any>('app_lesson_study', {orderId:this.orderId}).subscribe({next: ({result}) => this.sources.set(lessonStudySources(result)), error: () => this.sources.set([])});
|
|
|
}, error: e => this.error.set(e.message),
|
|
}, error: e => this.error.set(e.message),
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
+ async downloadPdf(): Promise<void> {
|
|
|
|
|
+ if (this.exporting() || this.loading() || !this.report() || this.session.groupId() !== 3) return;
|
|
|
|
|
+ this.exportError.set(''); this.exportMessage.set('');
|
|
|
|
|
+ if (/micromessenger/i.test(navigator.userAgent)) {
|
|
|
|
|
+ this.exportError.set('请点击微信右上角菜单,选择“在浏览器中打开”,再下载 PDF。');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.exporting.set(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { result: report } = await firstValueFrom(this.api.get<LessonReportData>('app_lesson_report', {id:this.orderId}));
|
|
|
|
|
+ if (report.order['cancelled'] || ![30, 40].includes(Number(report.order['dszt']))) throw new Error('请先完成课程,再下载学习报表。');
|
|
|
|
|
+ if (!report.records.length) throw new Error('本次课程暂无学习记录可导出。');
|
|
|
|
|
+ const reports: LessonPdfReport[] = [];
|
|
|
|
|
+ for (const record of report.records) {
|
|
|
|
|
+ const id = record['generalId'] || record['GeneralID'];
|
|
|
|
|
+ if (!id) throw new Error('学习记录编号缺失,请刷新后重试。');
|
|
|
|
|
+ const { result: words } = await firstValueFrom(this.api.get<Array<Record<string, unknown>>>('e_record_detail', {id, studentId:report.order['szyh']}));
|
|
|
|
|
+ if (!Array.isArray(words) || !words.length) throw new Error('学习记录缺少单词明细,暂不能生成报表。');
|
|
|
|
|
+ reports.push(lessonPdfReport(report, record, words));
|
|
|
|
|
+ }
|
|
|
|
|
+ this.pdfReports.set(reports);
|
|
|
|
|
+ this.changeDetector.detectChanges();
|
|
|
|
|
+ await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
|
|
|
+ const element = this.pdfContent?.nativeElement;
|
|
|
|
|
+ if (!element) throw new Error('报表打印区域尚未准备完成');
|
|
|
|
|
+ const safeName = `${reports[0].studentName}-${reports[0].studyDate}-${this.orderId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
|
|
|
|
|
+ await this.downloader.save(element, `小树陪练-单词学习报表-${safeName}.pdf`);
|
|
|
|
|
+ this.exportMessage.set('PDF 已生成,可在浏览器下载中查看并发送给学生。');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ this.exportError.set(error instanceof Error ? error.message : 'PDF 生成失败,请重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.exporting.set(false); this.pdfReports.set([]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
count(field: 'learned' | 'ygg'): number {
|
|
count(field: 'learned' | 'ygg'): number {
|
|
|
return (this.report()?.records || []).reduce((sum, row) => sum + (Number(row[field]) || 0), 0);
|
|
return (this.report()?.records || []).reduce((sum, row) => sum + (Number(row[field]) || 0), 0);
|
|
|
}
|
|
}
|