|
|
@@ -1,13 +1,13 @@
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
|
|
|
+import { ChangeDetectionStrategy, Component, HostListener, inject, OnInit, signal } from '@angular/core';
|
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
|
-import { BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, LucideAngularModule, RotateCcw, X, XCircle } from 'lucide-angular';
|
|
|
+import { ArrowLeft, BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, LucideAngularModule, RotateCcw, X, XCircle } from 'lucide-angular';
|
|
|
import { PlatformService } from '../../core/platform.service';
|
|
|
import { SessionService } from '../../core/session.service';
|
|
|
import { EnglishReadingService } from './english-reading.service';
|
|
|
import { ReadingCloudService } from './reading-cloud.service';
|
|
|
import { ReadingArticle, ReadingHistoryEntry, ReadingProgress, ReadingQuestion, ReadingRouteParams } from './reading.models';
|
|
|
-import { highlightWords, normalizeReadingParams, shuffle } from './reading.utils';
|
|
|
+import { highlightWords, normalizeReadingParams, normalizeReadingReturnUrl, shuffle } from './reading.utils';
|
|
|
|
|
|
interface ReadingDialog {
|
|
|
title: string;
|
|
|
@@ -27,13 +27,15 @@ interface ReadingDialog {
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
|
})
|
|
|
export class ReadingExerciseComponent implements OnInit {
|
|
|
- readonly icons = { BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, RotateCcw, X, XCircle };
|
|
|
+ readonly icons = { ArrowLeft, BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, RotateCcw, X, XCircle };
|
|
|
readonly optionLabels = 'ABCDEFGHIJK'.split('');
|
|
|
readonly article = signal<ReadingArticle | null>(null);
|
|
|
readonly questions = signal<ReadingQuestion[]>([]);
|
|
|
readonly loading = signal(true);
|
|
|
readonly progress = signal<ReadingProgress>({ task: 'article', message: '正在准备阅读内容' });
|
|
|
readonly submitted = signal(false);
|
|
|
+ readonly saving = signal(false);
|
|
|
+ readonly saveFailed = signal(false);
|
|
|
readonly score = signal(0);
|
|
|
readonly historyOpen = signal(false);
|
|
|
readonly historyLoading = signal(false);
|
|
|
@@ -69,6 +71,15 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
private readonly platform = inject(PlatformService);
|
|
|
|
|
|
get answeredCount(): number { return this.answers.filter(Boolean).length; }
|
|
|
+ get hasUnsavedAnswers(): boolean { return this.answeredCount > 0 && !this.submitted(); }
|
|
|
+ get primaryButtonLabel(): string {
|
|
|
+ if (this.submitted()) return '完成并返回';
|
|
|
+ if (this.saving()) return '正在保存';
|
|
|
+ if (this.saveFailed()) return '保存失败,重新保存';
|
|
|
+ if (!this.questions().length) return '暂无可提交题目';
|
|
|
+ if (this.answeredCount < this.questions().length) return '请完成全部题目';
|
|
|
+ return '提交并保存';
|
|
|
+ }
|
|
|
get articleHtml(): string {
|
|
|
const article = this.article();
|
|
|
return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
|
|
|
@@ -89,7 +100,7 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
}
|
|
|
|
|
|
selectOption(questionIndex: number, value: string): void {
|
|
|
- if (this.submitted()) return;
|
|
|
+ if (this.submitted() || this.saving()) return;
|
|
|
this.answers = [...this.answers];
|
|
|
this.answers[questionIndex] = value;
|
|
|
}
|
|
|
@@ -99,28 +110,48 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
}
|
|
|
|
|
|
async submit(): Promise<void> {
|
|
|
- if (this.submitted()) { this.reset(); return; }
|
|
|
+ if (this.submitted() || this.saving()) return;
|
|
|
if (this.answeredCount < this.questions().length) return;
|
|
|
+ const article = this.article();
|
|
|
+ if (!article) return;
|
|
|
const score = this.questions().filter((question, index) => this.isCorrect(question, index)).length;
|
|
|
- this.score.set(score);
|
|
|
- this.submitted.set(true);
|
|
|
- this.showScoreDialog(score, this.questions().length);
|
|
|
+ this.saving.set(true);
|
|
|
+ this.saveFailed.set(false);
|
|
|
try {
|
|
|
- const article = this.article();
|
|
|
- if (article) await this.reading.saveLog(article, this.questions(), this.answers, score);
|
|
|
- } catch (error) { this.showError(error); }
|
|
|
+ await this.reading.saveLog(article, this.questions(), this.answers, score);
|
|
|
+ this.score.set(score);
|
|
|
+ this.submitted.set(true);
|
|
|
+ this.platform.toast('答题记录已保存', 'success');
|
|
|
+ this.showScoreDialog(score, this.questions().length);
|
|
|
+ } catch (error) {
|
|
|
+ this.saveFailed.set(true);
|
|
|
+ this.showError(error);
|
|
|
+ } finally { this.saving.set(false); }
|
|
|
+ }
|
|
|
+
|
|
|
+ async primaryAction(): Promise<void> {
|
|
|
+ if (this.submitted()) {
|
|
|
+ await this.returnToOrigin();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await this.submit();
|
|
|
}
|
|
|
|
|
|
reset(): void {
|
|
|
+ if (this.saving()) return;
|
|
|
this.questions.set(shuffle(this.questions()).map((question) => ({ ...question, options: shuffle(question.options) })));
|
|
|
this.answers = new Array(this.questions().length).fill('');
|
|
|
this.score.set(0);
|
|
|
this.submitted.set(false);
|
|
|
+ this.saveFailed.set(false);
|
|
|
+ this.dialog.set(null);
|
|
|
}
|
|
|
|
|
|
async print(): Promise<void> {
|
|
|
const id = this.article()?.objectId;
|
|
|
- if (id) await this.router.navigate(['/words/reading/pdf', id],{queryParams:{studentId:this.params.studentId}});
|
|
|
+ if (id) await this.router.navigate(['/words/reading/pdf', id], {
|
|
|
+ queryParams: { ...this.params, wordList:this.params.wordList.join(',') },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
async createNewArticle(): Promise<void> {
|
|
|
@@ -164,10 +195,19 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
async selectHistory(entry: ReadingHistoryEntry): Promise<void> {
|
|
|
const values = entry.article.createOptions.params;
|
|
|
this.closeHistory();
|
|
|
- await this.router.navigate(['/words/reading'], {
|
|
|
- queryParams: { ...values, studentId:this.readingLearner()?.userId, sid:entry.article.objectId, wordList: values.wordList.join(','), t: Date.now() },
|
|
|
+ const navigated = await this.router.navigate(['/words/reading'], {
|
|
|
+ queryParams: {
|
|
|
+ ...values,
|
|
|
+ studentId:this.readingLearner()?.userId,
|
|
|
+ sourceStudyId:this.params.sourceStudyId,
|
|
|
+ returnUrl:this.params.returnUrl,
|
|
|
+ sid:entry.article.objectId,
|
|
|
+ wordList:values.wordList.join(','),
|
|
|
+ t:Date.now(),
|
|
|
+ },
|
|
|
replaceUrl: true,
|
|
|
});
|
|
|
+ if (!navigated) return;
|
|
|
this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false };
|
|
|
await this.load();
|
|
|
}
|
|
|
@@ -179,7 +219,7 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
this.retryCount += 1;
|
|
|
await this.load();
|
|
|
} else if (current?.action === 'back') {
|
|
|
- await this.goReadingPage();
|
|
|
+ await this.returnToOrigin();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -190,6 +230,8 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
this.questions.set([]);
|
|
|
this.answers = [];
|
|
|
this.submitted.set(false);
|
|
|
+ this.saving.set(false);
|
|
|
+ this.saveFailed.set(false);
|
|
|
this.loadingTip.set(this.tips[Math.floor(Math.random() * this.tips.length)]);
|
|
|
try {
|
|
|
await this.cloud.ensureUser(this.readingLearner(), this.params.phone);
|
|
|
@@ -221,14 +263,14 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
} catch (error) {
|
|
|
const message = error instanceof Error ? error.message : '阅读内容加载失败';
|
|
|
if (message.includes('Invalid session token')) {
|
|
|
- await this.goReadingPage();
|
|
|
+ await this.returnToOrigin();
|
|
|
} else {
|
|
|
this.error.set(message);
|
|
|
const canRetry = this.retryCount < this.maxRetry;
|
|
|
this.dialog.set({
|
|
|
title: '遇到问题',
|
|
|
message: `${message}${canRetry ? `(剩余重试次数:${this.maxRetry - this.retryCount})` : ''}`,
|
|
|
- buttonText: canRetry ? '立即重试' : '返回首页',
|
|
|
+ buttonText: canRetry ? '立即重试' : '返回原页面',
|
|
|
tone: 'error',
|
|
|
action: canRetry ? 'retry' : 'back',
|
|
|
});
|
|
|
@@ -265,11 +307,11 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
|
|
|
private showScoreDialog(score: number, total: number): void {
|
|
|
if (score === total) {
|
|
|
- this.dialog.set({ title: 'Great Job!', message: 'You completed all levels with perfect score!', buttonText: 'Awesome!', tone: 'success', action: 'close' });
|
|
|
+ this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n可以查看解析后完成返回。`, buttonText: '查看解析', tone: 'success', action: 'close' });
|
|
|
} else if (score === 0) {
|
|
|
- this.dialog.set({ title: 'Oops! Try Again', message: 'You missed all the questions this time.\nKeep practicing!', buttonText: "I'll Improve!", tone: 'error', action: 'close' });
|
|
|
+ this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n查看解析后可以重做,或完成返回。`, buttonText: '查看解析', tone: 'error', action: 'close' });
|
|
|
} else {
|
|
|
- this.dialog.set({ title: 'Good Effort!', message: `You got ${score} answers right!\nReview the mistakes to do better next time.`, buttonText: 'Think Twice...', tone: 'warning', action: 'close' });
|
|
|
+ this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n查看解析后可以重做,或完成返回。`, buttonText: '查看解析', tone: 'warning', action: 'close' });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -278,10 +320,20 @@ export class ReadingExerciseComponent implements OnInit {
|
|
|
this.remainingArticles.set(Math.max(0, 3 - history.length));
|
|
|
}
|
|
|
|
|
|
- private async goReadingPage(): Promise<void> {
|
|
|
- await this.router.navigate(['/words/go-reading'], {
|
|
|
- queryParams: { ...this.params, wordList: this.params.wordList.join(',') },
|
|
|
- });
|
|
|
+ async returnToOrigin(): Promise<void> {
|
|
|
+ await this.router.navigateByUrl(normalizeReadingReturnUrl(this.params.returnUrl), { replaceUrl: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ async canDeactivate(): Promise<boolean> {
|
|
|
+ if (!this.hasUnsavedAnswers) return true;
|
|
|
+ return this.platform.confirm('本次答案尚未提交保存,确定离开吗?');
|
|
|
+ }
|
|
|
+
|
|
|
+ @HostListener('window:beforeunload', ['$event'])
|
|
|
+ protectUnsavedAnswers(event: BeforeUnloadEvent): void {
|
|
|
+ if (!this.hasUnsavedAnswers) return;
|
|
|
+ event.preventDefault();
|
|
|
+ event.returnValue = '';
|
|
|
}
|
|
|
|
|
|
private async withTimeout<T>(task: Promise<T>, duration: number, name: string): Promise<T> {
|