|
|
@@ -0,0 +1,684 @@
|
|
|
+import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
+import { Router } from '@angular/router';
|
|
|
+import { HttpClient, HttpClientModule } from '@angular/common/http';
|
|
|
+import { IonButton, IonContent, IonIcon, IonSpinner, IonList, IonItem, IonLabel } from '@ionic/angular/standalone';
|
|
|
+import { addIcons } from 'ionicons';
|
|
|
+import { chevronBackOutline } from 'ionicons/icons';
|
|
|
+
|
|
|
+import { PronunciationButtonComponent } from '../pronunciation-button/pronunciation-button.component';
|
|
|
+import { WordDetailComponent } from '../word-detail/word-detail.component';
|
|
|
+import { WordProcessingService } from '../recite-words/word-processing.service';
|
|
|
+import { WordDetailService } from '../word-detail/word-detail.service';
|
|
|
+import { WordProgressStorageService } from '../recite-words/word-progress-storage.service';
|
|
|
+import { Word, WordOption } from '../recite-words/word-interfaces';
|
|
|
+
|
|
|
+import { firstValueFrom } from 'rxjs';
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-review-words',
|
|
|
+ templateUrl: './review-words.page.html',
|
|
|
+ styleUrls: ['./review-words.page.scss'],
|
|
|
+ standalone: true,
|
|
|
+ imports: [
|
|
|
+ CommonModule,
|
|
|
+ IonContent,
|
|
|
+ IonButton,
|
|
|
+ IonIcon,
|
|
|
+ IonSpinner,
|
|
|
+ IonList,
|
|
|
+ IonItem,
|
|
|
+ IonLabel,
|
|
|
+ HttpClientModule,
|
|
|
+ PronunciationButtonComponent,
|
|
|
+ WordDetailComponent
|
|
|
+ ],
|
|
|
+ providers: [WordProcessingService, WordDetailService]
|
|
|
+})
|
|
|
+export class ReviewWordsPage implements OnInit, OnDestroy {
|
|
|
+ @ViewChild(IonContent) ionContent!: IonContent;
|
|
|
+ @ViewChild(PronunciationButtonComponent) pronunciationButton?: PronunciationButtonComponent;
|
|
|
+ private readonly WORDS_PER_GROUP = 5;
|
|
|
+
|
|
|
+ words: Word[] = [];
|
|
|
+ reviewQueue: Word[] = [];
|
|
|
+ currentWord: Word | null = null;
|
|
|
+
|
|
|
+ // 复习视图状态
|
|
|
+ isLoading = true;
|
|
|
+ isAnswered = false;
|
|
|
+ isCorrect = false;
|
|
|
+ isCorrectAnswerShown = false;
|
|
|
+ showContinueButton = false;
|
|
|
+ answeredWordKey: string | null = null;
|
|
|
+ showWordDetail = false;
|
|
|
+ showReviewCompleted = false;
|
|
|
+ isPersisting = false;
|
|
|
+ selectedOption: WordOption | null = null;
|
|
|
+ reviewOptions: WordOption[] = [];
|
|
|
+ completedGroupWords: Word[] = [];
|
|
|
+ reviewSummaryRows: Array<{ word: string; wrongCount: number }> = [];
|
|
|
+ completedWordsCount = 0;
|
|
|
+ studyTime = '0分钟';
|
|
|
+ masteryRate = 0;
|
|
|
+ private groupStartTime: Date | null = null;
|
|
|
+
|
|
|
+ // 循环复习状态(0:选义 1:听音辨义 2:记得/忘记)
|
|
|
+ currentReviewIndex = 0;
|
|
|
+ private reviewStageMap = new Map<string, number>();
|
|
|
+ private pendingStageMap = new Map<string, number>();
|
|
|
+ private relearnWordKeys = new Set<string>();
|
|
|
+ private reviewWrongAttempts = new Map<string, number>();
|
|
|
+
|
|
|
+ // 进度展示:通过数/总数
|
|
|
+ reviewedCount = 0;
|
|
|
+ totalReviewWords = 0;
|
|
|
+
|
|
|
+ isAmericanPronunciation = true;
|
|
|
+
|
|
|
+ // 云端:当前登录用户 id
|
|
|
+ private dbUserId: string | null = null;
|
|
|
+ // 云端:wordList.objectId
|
|
|
+ private dbListObjectId: string | null = null;
|
|
|
+ // 复习间隔(天),来自 ladderPreset
|
|
|
+ private dbIntervalDays: number[] = [];
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private router: Router,
|
|
|
+ private http: HttpClient,
|
|
|
+ private cdr: ChangeDetectorRef,
|
|
|
+ private wordProcessingService: WordProcessingService,
|
|
|
+ private wordDetailService: WordDetailService,
|
|
|
+ private wordProgressStorage: WordProgressStorageService
|
|
|
+ ) {
|
|
|
+ addIcons({ chevronBackOutline });
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnInit() {
|
|
|
+ this.loadWordsAndPrepareQueue();
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ // no-op
|
|
|
+ }
|
|
|
+
|
|
|
+ private scrollToTop(duration: number = 0) {
|
|
|
+ setTimeout(() => {
|
|
|
+ if (this.ionContent) {
|
|
|
+ this.ionContent.scrollToTop(duration);
|
|
|
+ }
|
|
|
+ }, duration === 0 ? 50 : 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private playCurrentWordPronunciation(delay: number = 300): void {
|
|
|
+ setTimeout(() => {
|
|
|
+ if (this.pronunciationButton) {
|
|
|
+ this.pronunciationButton.playPronunciation();
|
|
|
+ }
|
|
|
+ }, delay);
|
|
|
+ }
|
|
|
+
|
|
|
+ getProgressText(): string {
|
|
|
+ if (this.totalReviewWords <= 0) return '';
|
|
|
+ return `${this.reviewedCount}/${this.totalReviewWords}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ getHintText(): string {
|
|
|
+ const stage = this.getCurrentReviewStage();
|
|
|
+ if (stage === 0) {
|
|
|
+ return '先回想词义再选择,想不起来「看答案」';
|
|
|
+ }
|
|
|
+ if (stage === 1) {
|
|
|
+ return '根据发音选择正确的意思,想不起来「看答案」';
|
|
|
+ }
|
|
|
+ return '看单词回想词意:想起来选「记得」,想不起来选「忘记」';
|
|
|
+ }
|
|
|
+
|
|
|
+ goBack() {
|
|
|
+ this.router.navigate(['/tabs/tab1']);
|
|
|
+ }
|
|
|
+
|
|
|
+ goToTab1(): void {
|
|
|
+ this.goBack();
|
|
|
+ }
|
|
|
+
|
|
|
+ studyAnotherGroup(): void {
|
|
|
+ void this.loadWordsAndPrepareQueue();
|
|
|
+ }
|
|
|
+
|
|
|
+ goToHome(): void {
|
|
|
+ this.goToTab1();
|
|
|
+ }
|
|
|
+
|
|
|
+ continuePractice(): void {
|
|
|
+ this.router.navigate(['/recite-words']);
|
|
|
+ }
|
|
|
+
|
|
|
+ startNextGroup(): void {
|
|
|
+ this.studyAnotherGroup();
|
|
|
+ }
|
|
|
+
|
|
|
+ async loadWordsAndPrepareQueue() {
|
|
|
+ this.isLoading = true;
|
|
|
+ this.showReviewCompleted = false;
|
|
|
+ this.currentWord = null;
|
|
|
+ this.reviewQueue = [];
|
|
|
+ this.completedGroupWords = [];
|
|
|
+ this.reviewSummaryRows = [];
|
|
|
+ this.completedWordsCount = 0;
|
|
|
+ this.studyTime = '0分钟';
|
|
|
+ this.masteryRate = 0;
|
|
|
+ this.groupStartTime = new Date();
|
|
|
+ this.reviewedCount = 0;
|
|
|
+ this.totalReviewWords = 0;
|
|
|
+ this.currentReviewIndex = 0;
|
|
|
+ this.reviewStageMap.clear();
|
|
|
+ this.pendingStageMap.clear();
|
|
|
+ this.relearnWordKeys.clear();
|
|
|
+ this.reviewWrongAttempts.clear();
|
|
|
+ this.isAnswered = false;
|
|
|
+ this.isCorrect = false;
|
|
|
+ this.isCorrectAnswerShown = false;
|
|
|
+ this.showContinueButton = false;
|
|
|
+ this.showWordDetail = false;
|
|
|
+ this.selectedOption = null;
|
|
|
+ this.reviewOptions = [];
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = await firstValueFrom(
|
|
|
+ this.http.get('assets/words_cet6.txt', { responseType: 'text' })
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!data) {
|
|
|
+ this.words = [];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsedWords = this.wordProcessingService.parseWords(data);
|
|
|
+ // 复用背单词页逻辑:先提取干扰项,再统一生成四选一选项
|
|
|
+ this.wordProcessingService.extractAllDefinitionsForDistractions(data);
|
|
|
+ const validWords: Word[] = [];
|
|
|
+ for (const word of parsedWords) {
|
|
|
+ const options = this.wordProcessingService.generateOptions(word);
|
|
|
+ if (options.length === 4) {
|
|
|
+ word.options = options;
|
|
|
+ validWords.push(word);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.words = validWords;
|
|
|
+
|
|
|
+ await this.mergeServerProgressIntoWords();
|
|
|
+
|
|
|
+ // 复习队列:
|
|
|
+ // 1) 已完成新学阶段(studyCount>=3 或 isNewDone=true)
|
|
|
+ // 2) nextReviewAt 到期(缺失/格式异常时按到期处理,提升容错)
|
|
|
+ const now = Date.now();
|
|
|
+ const debugStats = {
|
|
|
+ total: this.words.length,
|
|
|
+ notDone: 0,
|
|
|
+ notDue: 0,
|
|
|
+ due: 0
|
|
|
+ };
|
|
|
+
|
|
|
+ this.reviewQueue = this.words
|
|
|
+ .filter((w) => {
|
|
|
+ if (!w) return false;
|
|
|
+
|
|
|
+ const doneNewPhase = (w.studyCount ?? 0) >= 3 || !!w.isNewDone;
|
|
|
+ if (!doneNewPhase) {
|
|
|
+ debugStats.notDone++;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 阶梯毕业:当 ladderStep 达到 intervalDays.length,不再进入复习
|
|
|
+ if (this.dbIntervalDays.length > 0 && (w.ladderStep ?? 0) >= this.dbIntervalDays.length) {
|
|
|
+ debugStats.notDue++;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 缺失 nextReviewAt:直接进入复习
|
|
|
+ if (!w.nextReviewAt) {
|
|
|
+ debugStats.due++;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const t = new Date(w.nextReviewAt).getTime();
|
|
|
+ // nextReviewAt 非法:按到期处理,避免因为脏数据导致“一个都刷不出来”
|
|
|
+ if (Number.isNaN(t)) {
|
|
|
+ debugStats.due++;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ const isDue = t <= now;
|
|
|
+ if (isDue) {
|
|
|
+ debugStats.due++;
|
|
|
+ } else {
|
|
|
+ debugStats.notDue++;
|
|
|
+ }
|
|
|
+ return isDue;
|
|
|
+ })
|
|
|
+ .sort((a, b) => {
|
|
|
+ const at = a.nextReviewAt ? new Date(a.nextReviewAt).getTime() : 0;
|
|
|
+ const bt = b.nextReviewAt ? new Date(b.nextReviewAt).getTime() : 0;
|
|
|
+ return at - bt;
|
|
|
+ })
|
|
|
+ .slice(0, this.WORDS_PER_GROUP);
|
|
|
+
|
|
|
+ console.log('[ReviewWords] 队列筛选结果', {
|
|
|
+ ...debugStats,
|
|
|
+ queue: this.reviewQueue.length,
|
|
|
+ dbUserId: this.dbUserId,
|
|
|
+ dbListObjectId: this.dbListObjectId
|
|
|
+ });
|
|
|
+
|
|
|
+ this.totalReviewWords = this.reviewQueue.length;
|
|
|
+ this.completedGroupWords = [...this.reviewQueue];
|
|
|
+
|
|
|
+ // 取第一只进入当前界面
|
|
|
+ if (this.reviewQueue.length > 0) {
|
|
|
+ this.currentWord = this.reviewQueue[this.currentReviewIndex];
|
|
|
+ this.prepareOptionsForCurrentWord();
|
|
|
+ this.loadWordDetailsForCurrentWord();
|
|
|
+ // 进入单词:自动发音(与背单词页一致)
|
|
|
+ this.playCurrentWordPronunciation(300);
|
|
|
+ } else {
|
|
|
+ this.showReviewCompleted = true;
|
|
|
+ this.buildReviewSummaryRows();
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[ReviewWords] 加载失败', e);
|
|
|
+ this.showReviewCompleted = true;
|
|
|
+ this.buildReviewSummaryRows();
|
|
|
+ } finally {
|
|
|
+ this.isLoading = false;
|
|
|
+ // 确保视图更新
|
|
|
+ this.cdr.detectChanges();
|
|
|
+ this.scrollToTop();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async mergeServerProgressIntoWords(): Promise<void> {
|
|
|
+ this.dbUserId = await this.wordProgressStorage.getCurrentUserId();
|
|
|
+ this.dbListObjectId = null;
|
|
|
+ this.dbIntervalDays = [];
|
|
|
+
|
|
|
+ if (!this.dbUserId) {
|
|
|
+ // 未登录:没有云端进度可以复习
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const ctx = await this.wordProgressStorage.resolveWordListContext();
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ this.dbListObjectId = ctx.listObjectId;
|
|
|
+ this.dbIntervalDays = ctx.intervalDays;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const map = await this.wordProgressStorage.loadProgressByList(this.dbUserId, this.dbListObjectId);
|
|
|
+ for (const w of this.words) {
|
|
|
+ const row = map.get(w.word.trim().toLowerCase());
|
|
|
+ if (row) {
|
|
|
+ this.wordProgressStorage.applyProgressRowToWord(w, row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[ReviewWords] 合并云端进度失败', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadWordDetailsForCurrentWord(): void {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+
|
|
|
+ // 避免重复请求
|
|
|
+ if (this.currentWord.phrases || this.currentWord.relWords || this.currentWord.sentences || this.currentWord.synonyms) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.wordDetailService
|
|
|
+ .fetchWordDetails(this.currentWord.word)
|
|
|
+ .subscribe({
|
|
|
+ next: (details) => {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ this.currentWord.phrases = details.phrases;
|
|
|
+ this.currentWord.relWords = details.relWords;
|
|
|
+ this.currentWord.sentences = details.sentences;
|
|
|
+ this.currentWord.synonyms = details.synonyms;
|
|
|
+ this.cdr.detectChanges();
|
|
|
+ },
|
|
|
+ error: (err) => console.error('[ReviewWords] 获取详情失败', err)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ getCurrentReviewStage(): number {
|
|
|
+ if (!this.currentWord) return 0;
|
|
|
+ const key = this.getWordKey(this.currentWord);
|
|
|
+ const stage = this.reviewStageMap.get(key);
|
|
|
+ if (typeof stage === 'number') return stage;
|
|
|
+ return this.relearnWordKeys.has(key) ? 0 : 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ isRecognitionStage(): boolean {
|
|
|
+ return this.getCurrentReviewStage() >= 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 前两步作答后到进入详情前,保持选项区可见,避免“选项先消失”
|
|
|
+ shouldShowOptionsStage(): boolean {
|
|
|
+ if (!this.isRecognitionStage()) return true;
|
|
|
+ return (this.isAnswered || this.isCorrectAnswerShown) && !this.showWordDetail;
|
|
|
+ }
|
|
|
+
|
|
|
+ isRelearningCurrentWord(): boolean {
|
|
|
+ if (!this.currentWord) return false;
|
|
|
+ return this.relearnWordKeys.has(this.getWordKey(this.currentWord));
|
|
|
+ }
|
|
|
+
|
|
|
+ shouldHideWordInStageOne(): boolean {
|
|
|
+ // 与背单词一致:进入听音辨义阶段后,初始即遮挡词面
|
|
|
+ return this.getCurrentReviewStage() === 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ isCurrentWordAnswered(): boolean {
|
|
|
+ if (!this.currentWord || !this.answeredWordKey) return false;
|
|
|
+ return this.answeredWordKey === this.getWordKey(this.currentWord) && (this.isAnswered || this.isCorrectAnswerShown);
|
|
|
+ }
|
|
|
+
|
|
|
+ getOptionType(option: WordOption): string {
|
|
|
+ return option.type;
|
|
|
+ }
|
|
|
+
|
|
|
+ getOptionDisplayText(option: WordOption): string {
|
|
|
+ return option.displayText || this.wordProcessingService.truncateToSingleLine(option.text);
|
|
|
+ }
|
|
|
+
|
|
|
+ getOptionEnglish(option: WordOption): string {
|
|
|
+ return option.english;
|
|
|
+ }
|
|
|
+
|
|
|
+ private getWordKey(word: Word): string {
|
|
|
+ return word.word.trim().toLowerCase();
|
|
|
+ }
|
|
|
+
|
|
|
+ private prepareOptionsForCurrentWord(): void {
|
|
|
+ if (!this.currentWord) {
|
|
|
+ this.reviewOptions = [];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.currentWord.options && this.currentWord.options.length === 4) {
|
|
|
+ this.reviewOptions = this.wordProcessingService.shuffleArray([...this.currentWord.options]);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const generated = this.wordProcessingService.generateOptions(this.currentWord);
|
|
|
+ if (generated.length === 4) {
|
|
|
+ this.currentWord.options = generated;
|
|
|
+ this.reviewOptions = this.wordProcessingService.shuffleArray([...generated]);
|
|
|
+ } else {
|
|
|
+ this.reviewOptions = [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ selectOption(option: WordOption): void {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ if (this.isAnswered || this.isCorrectAnswerShown || this.showWordDetail) return;
|
|
|
+ const key = this.getWordKey(this.currentWord);
|
|
|
+ if (!this.relearnWordKeys.has(key) || this.isRecognitionStage()) return;
|
|
|
+
|
|
|
+ const currentStage = this.getCurrentReviewStage();
|
|
|
+ this.isCorrect = !!option.isCorrect;
|
|
|
+ this.selectedOption = option;
|
|
|
+ this.isAnswered = true;
|
|
|
+ this.answeredWordKey = key;
|
|
|
+ this.isPersisting = true;
|
|
|
+
|
|
|
+ if (this.isCorrect) {
|
|
|
+ // 与背单词前两步一致:答对后短暂停留再进详情
|
|
|
+ const nextStage = Math.min(currentStage + 1, 2);
|
|
|
+ this.persistReviewAttempt(nextStage, false, true, false, false);
|
|
|
+ setTimeout(() => {
|
|
|
+ this.showWordDetail = true;
|
|
|
+ this.scrollToTop(300);
|
|
|
+ // 进入详情:自动发音(与背单词页一致)
|
|
|
+ this.playCurrentWordPronunciation(350);
|
|
|
+ }, 1500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 与背单词一致:答错后显示正确答案与继续按钮
|
|
|
+ this.isCorrectAnswerShown = true;
|
|
|
+ this.showContinueButton = true;
|
|
|
+ this.persistReviewAttempt(0, false, false, false, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ onRemember() {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ if (this.isAnswered || this.showWordDetail) return;
|
|
|
+ if (!this.isRecognitionStage()) return;
|
|
|
+
|
|
|
+ this.isCorrect = true;
|
|
|
+ this.isAnswered = true;
|
|
|
+ this.answeredWordKey = this.getWordKey(this.currentWord);
|
|
|
+ this.showWordDetail = true;
|
|
|
+ this.isPersisting = true;
|
|
|
+ this.persistReviewAttempt(3, true, true, false, true);
|
|
|
+ this.scrollToTop();
|
|
|
+ }
|
|
|
+
|
|
|
+ onForget() {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ if (this.isAnswered || this.showWordDetail) return;
|
|
|
+ if (!this.isRecognitionStage()) return;
|
|
|
+
|
|
|
+ this.isCorrect = false;
|
|
|
+ this.isAnswered = true;
|
|
|
+ this.answeredWordKey = this.getWordKey(this.currentWord);
|
|
|
+ this.showWordDetail = true;
|
|
|
+ this.isPersisting = true;
|
|
|
+ const key = this.getWordKey(this.currentWord);
|
|
|
+ const enterRelearn = !this.relearnWordKeys.has(key);
|
|
|
+ this.persistReviewAttempt(0, false, false, enterRelearn, true);
|
|
|
+ this.scrollToTop();
|
|
|
+ }
|
|
|
+
|
|
|
+ showAnswer(): void {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ if (this.isAnswered || this.isCorrectAnswerShown || this.showWordDetail) return;
|
|
|
+ if (this.isRecognitionStage()) return;
|
|
|
+
|
|
|
+ const key = this.getWordKey(this.currentWord);
|
|
|
+ if (!this.relearnWordKeys.has(key)) return;
|
|
|
+
|
|
|
+ this.isAnswered = true;
|
|
|
+ this.isCorrect = false;
|
|
|
+ this.isCorrectAnswerShown = true;
|
|
|
+ this.showContinueButton = true;
|
|
|
+ this.answeredWordKey = key;
|
|
|
+ this.selectedOption = this.currentWord.options.find((opt) => opt.isCorrect) || null;
|
|
|
+ this.isPersisting = true;
|
|
|
+ this.persistReviewAttempt(0, false, false, false, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ continueToNext(): void {
|
|
|
+ if (!this.showContinueButton) return;
|
|
|
+ this.showWordDetail = true;
|
|
|
+ this.scrollToTop(300);
|
|
|
+ // 进入详情:自动发音(与背单词页一致)
|
|
|
+ this.playCurrentWordPronunciation(350);
|
|
|
+ }
|
|
|
+
|
|
|
+ playOptionSound(option: WordOption): void {
|
|
|
+ if (!option.english || !this.pronunciationButton) return;
|
|
|
+ this.pronunciationButton.playPronunciation(option.english);
|
|
|
+ }
|
|
|
+
|
|
|
+ private persistReviewAttempt(
|
|
|
+ nextStage: number,
|
|
|
+ passed: boolean,
|
|
|
+ stepCorrect: boolean,
|
|
|
+ enterRelearn: boolean,
|
|
|
+ goDetailAfterPersist: boolean
|
|
|
+ ): void {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+
|
|
|
+ const wordRef = this.currentWord;
|
|
|
+ const prevStudyCount = wordRef.studyCount ?? 0;
|
|
|
+ const now = new Date();
|
|
|
+ const key = this.getWordKey(wordRef);
|
|
|
+
|
|
|
+ if (passed) {
|
|
|
+ const prevStep = wordRef.ladderStep ?? 0;
|
|
|
+ const terminalStep = this.dbIntervalDays.length;
|
|
|
+ const nextStep = prevStep + 1;
|
|
|
+
|
|
|
+ wordRef.studyCount = 3;
|
|
|
+ wordRef.isMastered = true;
|
|
|
+ wordRef.isNewDone = true;
|
|
|
+ wordRef.lastReviewAt = now.toISOString();
|
|
|
+
|
|
|
+ if (nextStep >= terminalStep) {
|
|
|
+ wordRef.ladderStep = terminalStep;
|
|
|
+ wordRef.nextReviewAt = undefined;
|
|
|
+ } else {
|
|
|
+ const intervalDays = this.dbIntervalDays[nextStep] ?? this.dbIntervalDays[0] ?? 1;
|
|
|
+ wordRef.ladderStep = nextStep;
|
|
|
+ wordRef.nextReviewAt = this.addDaysIso(now, intervalDays);
|
|
|
+ }
|
|
|
+ } else if (stepCorrect) {
|
|
|
+ // 前两轮答对:推进到下一阶段(仍未通过整词复习)
|
|
|
+ wordRef.studyCount = nextStage;
|
|
|
+ wordRef.isMastered = false;
|
|
|
+ wordRef.isNewDone = false;
|
|
|
+ wordRef.lastReviewAt = now.toISOString();
|
|
|
+ wordRef.nextReviewAt = undefined;
|
|
|
+ } else {
|
|
|
+ // 选错或忘记:清零并重新开始三阶段
|
|
|
+ wordRef.studyCount = 0;
|
|
|
+ wordRef.isMastered = false;
|
|
|
+ wordRef.isNewDone = false;
|
|
|
+ wordRef.ladderStep = 0;
|
|
|
+ wordRef.lastReviewAt = undefined;
|
|
|
+ wordRef.nextReviewAt = undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 先更新本地状态,允许立刻点“下一个”(不阻塞UI)
|
|
|
+ if (passed) {
|
|
|
+ this.reviewStageMap.set(key, 3);
|
|
|
+ this.pendingStageMap.delete(key);
|
|
|
+ this.relearnWordKeys.delete(key);
|
|
|
+ this.reviewedCount = Math.min(this.reviewedCount + 1, this.totalReviewWords);
|
|
|
+ } else if (stepCorrect) {
|
|
|
+ // 前两步答对:延迟到“下一个”时再切阶段,避免当前页面布局突变
|
|
|
+ this.pendingStageMap.set(key, nextStage);
|
|
|
+ } else {
|
|
|
+ this.reviewStageMap.set(key, 0);
|
|
|
+ this.pendingStageMap.delete(key);
|
|
|
+ this.incrementWrongAttempt(wordRef.word);
|
|
|
+ if (enterRelearn) {
|
|
|
+ this.relearnWordKeys.add(key);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (goDetailAfterPersist) {
|
|
|
+ this.showWordDetail = true;
|
|
|
+ // 进入详情:自动发音(与背单词页一致)
|
|
|
+ this.playCurrentWordPronunciation(350);
|
|
|
+ }
|
|
|
+
|
|
|
+ const afterPersist = () => {
|
|
|
+ this.isPersisting = false;
|
|
|
+ this.cdr.detectChanges();
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!this.dbUserId || !this.dbListObjectId || this.dbIntervalDays.length === 0) {
|
|
|
+ afterPersist();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.wordProgressStorage
|
|
|
+ .upsertWordProgress(wordRef, this.dbUserId, this.dbListObjectId, this.dbIntervalDays, prevStudyCount)
|
|
|
+ .catch((e) => console.error('[ReviewWords] upsert 失败', e))
|
|
|
+ .finally(() => afterPersist());
|
|
|
+ }
|
|
|
+
|
|
|
+ private addDaysIso(d: Date, days: number): string {
|
|
|
+ const x = new Date(d.getTime());
|
|
|
+ x.setDate(x.getDate() + days);
|
|
|
+ x.setHours(0, 0, 0, 0);
|
|
|
+ return x.toISOString();
|
|
|
+ }
|
|
|
+
|
|
|
+ goToNextReview() {
|
|
|
+ if (!this.currentWord) return;
|
|
|
+ if (!this.showWordDetail) return;
|
|
|
+
|
|
|
+ const key = this.getWordKey(this.currentWord);
|
|
|
+ const pendingStage = this.pendingStageMap.get(key);
|
|
|
+ if (typeof pendingStage === 'number') {
|
|
|
+ this.reviewStageMap.set(key, pendingStage);
|
|
|
+ this.pendingStageMap.delete(key);
|
|
|
+ }
|
|
|
+ const stage = this.reviewStageMap.get(key) ?? 0;
|
|
|
+
|
|
|
+ // 通过第三阶段后,移出本次复习队列
|
|
|
+ if (stage >= 3) {
|
|
|
+ this.reviewQueue = this.reviewQueue.filter((w) => this.getWordKey(w) !== key);
|
|
|
+ this.reviewStageMap.delete(key);
|
|
|
+ this.pendingStageMap.delete(key);
|
|
|
+ if (this.currentReviewIndex >= this.reviewQueue.length) {
|
|
|
+ this.currentReviewIndex = 0;
|
|
|
+ }
|
|
|
+ } else if (this.reviewQueue.length > 0) {
|
|
|
+ this.currentReviewIndex = (this.currentReviewIndex + 1) % this.reviewQueue.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.showWordDetail = false;
|
|
|
+ this.isAnswered = false;
|
|
|
+ this.isCorrect = false;
|
|
|
+ this.isCorrectAnswerShown = false;
|
|
|
+ this.showContinueButton = false;
|
|
|
+ this.selectedOption = null;
|
|
|
+ this.answeredWordKey = null;
|
|
|
+
|
|
|
+ if (this.reviewQueue.length <= 0) {
|
|
|
+ this.currentWord = null;
|
|
|
+ this.showReviewCompleted = true;
|
|
|
+ this.buildReviewSummaryRows();
|
|
|
+ this.scrollToTop();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.currentWord = this.reviewQueue[this.currentReviewIndex];
|
|
|
+ this.prepareOptionsForCurrentWord();
|
|
|
+ this.loadWordDetailsForCurrentWord();
|
|
|
+ this.showReviewCompleted = false;
|
|
|
+ this.scrollToTop();
|
|
|
+ // 进入下一词:自动发音(与背单词页一致)
|
|
|
+ this.playCurrentWordPronunciation(300);
|
|
|
+ }
|
|
|
+
|
|
|
+ private incrementWrongAttempt(word: string): void {
|
|
|
+ const key = word.trim().toLowerCase();
|
|
|
+ this.reviewWrongAttempts.set(key, (this.reviewWrongAttempts.get(key) ?? 0) + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildReviewSummaryRows(): void {
|
|
|
+ this.reviewSummaryRows = this.completedGroupWords.map((w) => {
|
|
|
+ const key = this.getWordKey(w);
|
|
|
+ return {
|
|
|
+ word: w.word,
|
|
|
+ wrongCount: this.reviewWrongAttempts.get(key) ?? 0
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ this.completedWordsCount = this.completedGroupWords.length;
|
|
|
+ this.masteryRate = this.completedWordsCount > 0 ? 100 : 0;
|
|
|
+ if (this.groupStartTime) {
|
|
|
+ const diffMs = Date.now() - this.groupStartTime.getTime();
|
|
|
+ const diffMins = Math.floor(diffMs / 60000);
|
|
|
+ this.studyTime = diffMins < 1 ? '<1分钟' : `${diffMins}分钟`;
|
|
|
+ } else {
|
|
|
+ this.studyTime = '0分钟';
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|