|
|
@@ -1,11 +1,11 @@
|
|
|
import { DropdownDirective } from '../shared/dropdown.directive';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
|
|
|
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, computed, 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 { LearningReportDetail, LearningReportListItem, LearningReportPage, LearningReportWord } from '../admin.models';
|
|
|
import { OperationsService } from '../operations.service';
|
|
|
import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
|
|
|
|
|
|
@@ -14,6 +14,32 @@ const localMonth = (): string => {
|
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
|
};
|
|
|
|
|
|
+type ReportVariant = 'full' | 'english' | 'chinese';
|
|
|
+const reviewDays = [1, 2, 3, 5, 7, 9, 11, 14, 17, 21];
|
|
|
+export function shuffleReportWords(words: LearningReportWord[], previous: LearningReportWord[] = words): LearningReportWord[] {
|
|
|
+ const shuffled = [...words];
|
|
|
+ for (let index = shuffled.length - 1; index > 0; index--) {
|
|
|
+ const next = Math.floor(Math.random() * (index + 1));
|
|
|
+ [shuffled[index], shuffled[next]] = [shuffled[next], shuffled[index]];
|
|
|
+ }
|
|
|
+ // Avoid showing the exact same sequence on consecutive visits.
|
|
|
+ if (shuffled.length > 1 && shuffled.every((word, index) => word.index === previous[index]?.index && word.generalId === previous[index]?.generalId)) {
|
|
|
+ shuffled.push(shuffled.shift()!);
|
|
|
+ }
|
|
|
+ return shuffled;
|
|
|
+}
|
|
|
+
|
|
|
+const reportTitles: Record<ReportVariant, string> = { full: '单词学习报表', english: '英文填中文复习表', chinese: '中文填英文复习表' };
|
|
|
+
|
|
|
+export function printableReviewSchedule(report: LearningReportDetail): Array<{ day: number; date: string; inferred: boolean }> {
|
|
|
+ const base = /^\d{4}-\d{2}-\d{2}$/.test(report.studyDate) ? new Date(`${report.studyDate}T00:00:00Z`) : null;
|
|
|
+ const validBase = base && !Number.isNaN(base.getTime()) && base.toISOString().slice(0, 10) === report.studyDate;
|
|
|
+ return reviewDays.map((day) => {
|
|
|
+ const stored = report.reviewSchedule?.find((item) => item.day === day)?.date;
|
|
|
+ return { day, date: stored || (validBase ? new Date(base.getTime() + day * 86400000).toISOString().slice(0, 10) : ''), inferred: !stored };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
type ReportDatePicker = 'month' | 'from' | 'to';
|
|
|
interface CalendarDay { day: number; date: string; }
|
|
|
|
|
|
@@ -30,6 +56,13 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
|
|
|
|
|
|
readonly data = signal<LearningReportPage | null>(null);
|
|
|
readonly detail = signal<LearningReportDetail | null>(null);
|
|
|
+ readonly reportVariant = signal<ReportVariant>('full');
|
|
|
+ private readonly worksheetWords = signal<LearningReportWord[]>([]);
|
|
|
+ readonly displayWords = computed(() => this.reportVariant() === 'full' ? (this.detail()?.words || []) : this.worksheetWords());
|
|
|
+ readonly reportTitle = computed(() => reportTitles[this.reportVariant()]);
|
|
|
+ readonly reviewSchedule = computed(() => this.detail() ? printableReviewSchedule(this.detail()!) : []);
|
|
|
+ readonly hasInferredReviewDates = computed(() => this.reviewSchedule().some((item) => item.inferred));
|
|
|
+ readonly learnedWords = computed(() => this.displayWords().map((word) => this.reportVariant() === 'chinese' ? (word.meaning || '释义缺失') : word.word).join(';') || '暂无单词明细');
|
|
|
readonly loading = signal(true);
|
|
|
readonly searching = signal(false);
|
|
|
readonly refreshing = signal(false);
|
|
|
@@ -172,19 +205,37 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
async open(targetId: string): Promise<void> {
|
|
|
+ if (this.exportingId() || this.detailLoading()) return;
|
|
|
+ this.reportVariant.set('full');
|
|
|
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); }
|
|
|
+ close(): void { if (!this.exportingId()) this.detail.set(null); }
|
|
|
+
|
|
|
+ selectVariant(variant: ReportVariant): void {
|
|
|
+ if (this.exportingId() || this.detailLoading()) return;
|
|
|
+ if (variant !== 'full') {
|
|
|
+ const words = this.detail()?.words || [];
|
|
|
+ this.worksheetWords.set(shuffleReportWords(words, this.worksheetWords().length ? this.worksheetWords() : words));
|
|
|
+ }
|
|
|
+ this.reportVariant.set(variant);
|
|
|
+ }
|
|
|
|
|
|
- async download(row: LearningReportListItem | LearningReportDetail): Promise<void> {
|
|
|
- if (!row.canExport || this.exportingId()) return;
|
|
|
+ async download(row: LearningReportListItem | LearningReportDetail, variant: ReportVariant = 'full'): Promise<void> {
|
|
|
+ if (!row.canExport || this.exportingId() || this.detailLoading()) return;
|
|
|
this.exportingId.set(row.targetId); this.error.set('');
|
|
|
try {
|
|
|
const report = await this.operations.learningReportExport(row.targetId);
|
|
|
+ if (variant !== 'full') {
|
|
|
+ const previous = this.worksheetWords();
|
|
|
+ const fresh = new Map(report.words.map((word) => [`${word.generalId}:${word.index}`, word]));
|
|
|
+ const sameWords = previous.length === report.words.length && previous.every((word) => fresh.has(`${word.generalId}:${word.index}`));
|
|
|
+ this.worksheetWords.set(sameWords ? previous.map((word) => fresh.get(`${word.generalId}:${word.index}`)!) : shuffleReportWords(report.words));
|
|
|
+ }
|
|
|
+ this.reportVariant.set(variant);
|
|
|
this.detail.set(report);
|
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
|
await document.fonts?.ready;
|
|
|
@@ -193,11 +244,11 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
|
|
|
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`,
|
|
|
+ margin: [9, 9, 9, 9], filename: `小树陪练-${reportTitles[variant]}-${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'] },
|
|
|
+ pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-table', '.worksheet-hint', '.pdf-footer', '.comment-card'] },
|
|
|
}).from(element).toCanvas().get('canvas', (canvas) => {
|
|
|
const pageHeight = Math.floor(canvas.width * (279 / 192));
|
|
|
const trailingSlice = canvas.height % pageHeight;
|
|
|
@@ -207,7 +258,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
|
|
|
const pageCount = pdf.internal.getNumberOfPages();
|
|
|
if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
|
|
|
}).save();
|
|
|
- this.message.set('学习报表 PDF 已生成');
|
|
|
+ this.message.set(`${reportTitles[variant]} PDF 已生成`);
|
|
|
} catch (error) {
|
|
|
this.error.set(error instanceof Error ? error.message : 'PDF 生成失败');
|
|
|
} finally { this.exportingId.set(''); }
|