| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- 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<HTMLElement>;
- readonly data = signal<LearningReportPage | null>(null);
- readonly detail = signal<LearningReportDetail | null>(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<ReportDatePicker | null>(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<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> {
- 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<void> {
- 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<CalendarDay | null> {
- 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<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; }
- }
|