1
0
s202226701051 7 сар өмнө
parent
commit
c282d99ed0

+ 4 - 0
word-app/src/app/app.routes.ts

@@ -11,6 +11,10 @@ export const routes: Routes = [
     path: 'recite-words',
     loadComponent: () => import('./recite-words/recite-words.page').then( m => m.ReciteWordsPage)
   },
+  {
+    path: 'review-words',
+    loadComponent: () => import('./review-words/review-words.page').then( m => m.ReviewWordsPage)
+  },
   {
     path: 'spelling-practice',
     loadComponent: () => import('./spelling-practice/spelling-practice.page').then( m => m.SpellingPracticePage)

+ 3 - 1
word-app/src/app/recite-words/recite-words.page.html

@@ -200,7 +200,9 @@
             <div class="stat-label">学习单词</div>
           </div>
           <div class="stat-item">
-            <div class="stat-value">{{ studyTime }}</div>
+            <div class="stat-value stat-value--time">
+              <span class="time-number">{{ getStudyTimeNumber() }}</span><span class="time-unit">{{ getStudyTimeUnit() }}</span>
+            </div>
             <div class="stat-label">学习时间</div>
           </div>
           <div class="stat-item">

+ 74 - 19
word-app/src/app/recite-words/recite-words.page.scss

@@ -809,7 +809,7 @@ ion-button[disabled] {
   }
 
   .completion-subtitle {
-    color: $coral;
+    color: #41C2F8;
     font-weight: 600;
     font-size: 1rem;
   }
@@ -840,6 +840,34 @@ ion-button[disabled] {
         margin-bottom: 4px;
       }
 
+      .stat-value.stat-value--time {
+        display: inline-flex;
+        align-items: baseline;
+        gap: 2px;
+        background: none;
+        -webkit-background-clip: initial;
+        background-clip: initial;
+        -webkit-text-fill-color: initial;
+        color: $ocean-mid;
+
+        .time-number {
+          font-size: 1.8rem;
+          font-weight: 800;
+          line-height: 1.2;
+          color: $ocean-mid;
+          -webkit-text-fill-color: $ocean-mid;
+        }
+
+        .time-unit {
+          font-size: 1rem;
+          font-weight: 700;
+          line-height: 1;
+          opacity: 0.9;
+          color: $ocean-mid;
+          -webkit-text-fill-color: $ocean-mid;
+        }
+      }
+
       .stat-label {
         font-size: 1rem;
         color: $ocean-mid;
@@ -921,40 +949,67 @@ ion-button[disabled] {
 }
 
 .rest-button {
-  --background: #e3f2fd;
-  --color: $ocean-deep;
-  --border-radius: 30px;
+  --background: linear-gradient(135deg, #eef8ff, #d9f0ff);
+  --color: #{$ocean-deep};
+  --border-radius: 16px;
   --padding-top: 14px;
   --padding-bottom: 14px;
+  --border-width: 1.5px;
+  --border-style: solid;
+  --border-color: rgba($ocean-mid, 0.25);
   height: auto;
-  font-weight: 600;
-  font-size: 1.1rem;
+  font-weight: 700;
+  font-size: 1.06rem;
   margin: 0;
-  box-shadow: 0 2px 8px rgba(2, 136, 209, 0.15);
+  box-shadow: none;
+  --background-activated: rgba(0, 0, 0, 0.12);
+  --background-focused: rgba(0, 0, 0, 0.08);
+  --ripple-color: rgba(0, 0, 0, 0.16);
 }
 
 .hot-button {
-  --background: linear-gradient(135deg, $coral, #ff8a65);
-  --color: white;
-  --border-radius: 30px;
+  --background: #01579B;
+  --color: #{$pearl};
+  --border-radius: 16px;
   --padding-top: 14px;
   --padding-bottom: 14px;
+  --border-width: 0;
   height: auto;
-  font-weight: 600;
-  font-size: 1.1rem;
+  font-weight: 700;
+  font-size: 1.06rem;
   margin: 0;
-  box-shadow: 0 4px 12px rgba(255, 112, 67, 0.35);
+  box-shadow: none;
+  --background-activated: rgba(0, 0, 0, 0.12);
+  --background-focused: rgba(0, 0, 0, 0.08);
+  --ripple-color: rgba(0, 0, 0, 0.16);
 }
 
 .next-group-button {
-  --background: linear-gradient(135deg, $primary-ocean, $ocean-mid);
-  --color: white;
-  --border-radius: 30px;
+  --background: linear-gradient(135deg, #1db8ff, #0f90e1);
+  --color: #{$pearl};
+  --border-radius: 16px;
   --padding-top: 14px;
   --padding-bottom: 14px;
+  --border-width: 0;
   height: auto;
-  font-weight: 600;
-  font-size: 1.1rem;
+  font-weight: 800;
+  font-size: 1.08rem;
   margin: 0;
-  box-shadow: 0 4px 12px rgba(17, 177, 246, 0.35);
+  box-shadow: none;
+  --background-activated: rgba(0, 0, 0, 0.12);
+  --background-focused: rgba(0, 0, 0, 0.08);
+  --ripple-color: rgba(0, 0, 0, 0.16);
+}
+
+/* 修复结算页按钮四角出现白边:强制按圆角裁切内部层 */
+.group-completed-container .completion-actions .action-button {
+  background: #{$ocean-foam} !important;
+  border-radius: 16px;
+  overflow: hidden;
+}
+
+.group-completed-container .completion-actions .action-button::part(native) {
+  border-radius: 16px;
+  overflow: hidden;
+  clip-path: inset(0 round 16px);
 }

+ 8 - 2
word-app/src/app/recite-words/word-progress-storage.service.ts

@@ -118,7 +118,7 @@ export class WordProgressStorageService {
     } else {
       ladderStep = word.ladderStep ?? 0;
       lastReviewAt = word.lastReviewAt ? this.toParseDate(new Date(word.lastReviewAt)) : null;
-      nextReviewAt = word.nextReviewAt ? this.toParseDate(new Date(word.nextReviewAt)) : null;
+      nextReviewAt = word.nextReviewAt ? this.toParseDate(this.toLocalStartOfDay(new Date(word.nextReviewAt))) : null;
     }
 
     const payload: Record<string, unknown> = {
@@ -181,7 +181,13 @@ export class WordProgressStorageService {
 
   private addDays(d: Date, days: number): Date {
     const x = new Date(d.getTime());
-    x.setUTCDate(x.getUTCDate() + days);
+    x.setDate(x.getDate() + days);
+    return this.toLocalStartOfDay(x);
+  }
+
+  private toLocalStartOfDay(d: Date): Date {
+    const x = new Date(d.getTime());
+    x.setHours(0, 0, 0, 0);
     return x;
   }
 }

+ 209 - 0
word-app/src/app/review-words/review-words.page.html

@@ -0,0 +1,209 @@
+<ion-content [fullscreen]="true" class="word-story-content" #ionContent>
+  <div class="ocean-bg"></div>
+
+  <div class="floating-creatures">
+    <div class="creature fish-1">
+      <img src="assets/icon/鲨鱼.svg" alt="" class="fish-svg" />
+    </div>
+    <div class="creature fish-2">
+      <img src="assets/icon/鲨鱼.svg" alt="" class="fish-svg" />
+    </div>
+    <div class="creature seaweed-1">
+      <img src="assets/icon/线状海草2.svg" alt="" class="seaweed-svg" />
+    </div>
+    <div class="creature seaweed-2">
+      <img src="assets/icon/线状海草2.svg" alt="" class="seaweed-svg" />
+    </div>
+    <div class="creature jellyfish">
+      <img src="assets/icon/水母.svg" alt="" class="jellyfish-svg" />
+    </div>
+    <div class="creature bubble-1"></div>
+    <div class="creature bubble-2"></div>
+    <div class="creature bubble-3"></div>
+    <div class="creature starfish">
+      <svg viewBox="0 0 100 100" width="30" height="30">
+        <path d="M50,10 L60,35 L85,35 L65,50 L75,80 L50,65 L25,80 L35,50 L15,35 L40,35 Z" fill="#FFB74D" opacity="0.7" />
+      </svg>
+    </div>
+  </div>
+
+  <div class="word-story-container recite-story-inner">
+    <div class="nav-header">
+      <div class="back-btn" (click)="goBack()">
+        <ion-icon name="chevron-back-outline"></ion-icon>
+      </div>
+      <h1 class="page-title">
+        <span class="page-title-brand">
+          <img src="assets/icon/鲨鱼头部.svg" alt="" class="title-icon" />
+          <span class="page-title-text">复习</span>
+        </span>
+      </h1>
+      <div class="nav-header-end">
+        <div class="recite-progress" *ngIf="getProgressText()">{{ getProgressText() }}</div>
+        <div class="placeholder" *ngIf="!getProgressText()"></div>
+      </div>
+    </div>
+
+    <div class="loading-container" *ngIf="isLoading">
+      <ion-spinner name="crescent" color="primary"></ion-spinner>
+      <p>加载复习单词中...</p>
+    </div>
+
+    <div
+      class="word-container shell-card"
+      [class.word-container--word-detail]="showWordDetail"
+      [class.word-container--options]="!showWordDetail && shouldShowOptionsStage()"
+      [class.word-container--recognize]="!showWordDetail && !shouldShowOptionsStage()"
+      *ngIf="currentWord && !isLoading"
+    >
+      <div class="word-header">
+        <ng-container *ngIf="shouldHideWordInStageOne(); else normalWordTitle">
+          <div class="hidden-word-container">
+            <div *ngIf="!isAnswered && !isCorrectAnswerShown" class="blank-word"></div>
+            <h1 *ngIf="isAnswered || isCorrectAnswerShown" class="word-text">{{ currentWord.word }}</h1>
+          </div>
+        </ng-container>
+        <ng-template #normalWordTitle>
+          <h1 class="word-text">{{ currentWord.word }}</h1>
+        </ng-template>
+
+        <div class="phonetic-section">
+          <app-pronunciation-button
+            [word]="currentWord.word"
+            [isAmericanPronunciation]="isAmericanPronunciation"
+            [autoPlay]="true"
+            (pronunciationToggled)="isAmericanPronunciation = $event"
+          ></app-pronunciation-button>
+          <div class="phonetic-text">
+            {{ isAmericanPronunciation ? currentWord.phonetic : currentWord.phoneticUk || currentWord.phonetic }}
+          </div>
+        </div>
+      </div>
+
+      <ng-container *ngIf="!showWordDetail; else detailSection">
+        <div class="recite-mode">
+          <div class="hint-text">{{ getHintText() }}</div>
+
+          <div class="options-container" *ngIf="shouldShowOptionsStage()">
+            <ion-button
+              *ngFor="let option of reviewOptions"
+              expand="block"
+              fill="outline"
+              (click)="selectOption(option)"
+              (click)="playOptionSound(option)"
+              [class.answered]="isAnswered || isCorrectAnswerShown"
+              [class.correct]="option.isCorrect && (isAnswered || isCorrectAnswerShown)"
+              [class.wrong]="selectedOption === option && !option.isCorrect && (isAnswered || isCorrectAnswerShown)"
+              class="option-button"
+            >
+              <div class="option-content">
+                <div *ngIf="!(isAnswered || isCorrectAnswerShown)" class="option-normal">
+                  <div class="option-type">{{ getOptionType(option) }}</div>
+                  <div class="option-text">{{ getOptionDisplayText(option) }}</div>
+                </div>
+                <div *ngIf="isAnswered || isCorrectAnswerShown" class="option-normal">
+                  <div class="option-type">{{ getOptionEnglish(option) }}</div>
+                  <div class="option-text">{{ getOptionType(option) }} · {{ getOptionDisplayText(option) }}</div>
+                </div>
+              </div>
+            </ion-button>
+          </div>
+        </div>
+      </ng-container>
+
+      <ng-template #detailSection>
+        <div class="detail-mode">
+          <app-word-detail [word]="currentWord"></app-word-detail>
+        </div>
+      </ng-template>
+
+      <div class="button-container">
+        <ng-container *ngIf="shouldShowOptionsStage()">
+          <ion-button
+            *ngIf="!isAnswered || (isCorrect && !showWordDetail)"
+            expand="block"
+            fill="clear"
+            (click)="showAnswer()"
+            class="action-button action-button--text-ocean"
+          >
+            看答案
+          </ion-button>
+
+          <ion-button
+            *ngIf="showContinueButton && !showWordDetail"
+            expand="block"
+            fill="clear"
+            (click)="continueToNext()"
+            class="action-button action-button--text-ocean"
+          >
+            继续
+          </ion-button>
+        </ng-container>
+
+        <div class="recognition-buttons" *ngIf="!showWordDetail && !isAnswered && isRecognitionStage()">
+          <ion-button
+            expand="block"
+            fill="solid"
+            (click)="onRemember()"
+            class="action-button know-button ocean-know"
+          >
+            记得
+          </ion-button>
+
+          <ion-button
+            expand="block"
+            fill="solid"
+            (click)="onForget()"
+            class="action-button dont-know-button ocean-dont-know"
+          >
+            忘记
+          </ion-button>
+        </div>
+
+        <ion-button
+          *ngIf="showWordDetail"
+          expand="block"
+          fill="clear"
+          (click)="goToNextReview()"
+          class="action-button action-button--text-ocean"
+        >
+          下一个
+        </ion-button>
+      </div>
+    </div>
+
+    <div *ngIf="showReviewCompleted && !isLoading && reviewSummaryRows.length > 0" class="word-container shell-card word-container--spelling-summary">
+      <div class="summary-container">
+        <div class="words-list">
+          <ion-list>
+            <ion-item *ngFor="let row of reviewSummaryRows" class="word-item">
+              <ion-label class="word-text">
+                <h2>{{ row.word }}</h2>
+              </ion-label>
+              <div slot="end" class="wrong-count">
+                答错<span class="wrong-number">{{ row.wrongCount }}</span>次
+              </div>
+            </ion-item>
+          </ion-list>
+        </div>
+
+        <div class="summary-buttons">
+          <div class="button-row">
+            <ion-button fill="clear" color="medium" (click)="goToTab1()" class="action-button">
+              休息一下
+            </ion-button>
+            <ion-button fill="clear" color="medium" (click)="studyAnotherGroup()" class="action-button">
+              再学一组
+            </ion-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="no-data-container shell-card" *ngIf="!currentWord && !isLoading && (!showReviewCompleted || reviewSummaryRows.length === 0)">
+      <p>暂时没有需要复习的单词</p>
+      <p class="no-data-hint">先去学习一些新单词吧。</p>
+    </div>
+  </div>
+</ion-content>
+

+ 134 - 0
word-app/src/app/review-words/review-words.page.scss

@@ -0,0 +1,134 @@
+@use '../recite-words/recite-words.page.scss' as *;
+
+:host {
+  display: block;
+  height: 100%;
+}
+
+/* 保持答题区域底部按钮位高度稳定,避免作答后选项区整体位移 */
+.button-container {
+  min-height: 64px;
+}
+
+/* 复习页:作答前后保持选项尺寸不变 */
+ion-button.option-button,
+ion-button.option-button::part(native) {
+  height: 72px !important;
+  min-height: 72px !important;
+  max-height: 72px !important;
+}
+
+ion-button.option-button.correct,
+ion-button.option-button.wrong,
+ion-button.option-button.answered {
+  transform: none !important;
+  animation: none !important;
+}
+
+.option-content,
+.option-normal {
+  height: 100%;
+  min-height: 0;
+}
+
+/* 听音辨义:隐藏词面与显示词面共用同一占位,避免切换时整体上移 */
+/* 固定单词头部布局,避免第一步/第二步作答后整体上移 */
+.word-header {
+  min-height: 112px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-start;
+}
+
+.word-header .word-text {
+  height: 48px;
+  line-height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8px;
+}
+
+.word-header .phonetic-section {
+  height: 24px;
+  margin-top: 0;
+}
+
+.word-container.shell-card.word-container--spelling-summary {
+  height: 62vh;
+  overflow: hidden;
+  margin-top: 28px;
+}
+
+.summary-container {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.summary-container .words-list {
+  flex: 1;
+  overflow-y: auto;
+  margin: 16px 16px 0;
+  padding-bottom: 16px;
+}
+
+.summary-container .word-item {
+  --padding-start: 16px;
+  --padding-end: 16px;
+  --inner-padding-end: 0;
+  --background: transparent;
+  --border-color: transparent;
+  margin-bottom: 8px;
+}
+
+.summary-container .word-text h2 {
+  font-size: 1.4rem !important;
+  font-weight: 600;
+  color: $ocean-deep;
+  margin: 0;
+}
+
+.summary-container .wrong-count {
+  font-size: 1rem;
+  color: rgba($ocean-deep, 0.6);
+  font-weight: 500;
+}
+
+.summary-container .wrong-number {
+  color: $coral !important;
+}
+
+.summary-buttons {
+  margin-top: auto;
+  padding: 24px 16px 8px;
+}
+
+.summary-buttons .button-row {
+  display: flex;
+  gap: 16px;
+}
+
+.summary-buttons .action-button {
+  flex: 1;
+  --color: #{$ocean-deep};
+  --border-radius: 12px;
+  --padding-top: 16px;
+  --padding-bottom: 16px;
+  font-weight: 900;
+  font-size: 1.2rem;
+}
+
+.no-data-container p {
+  font-size: 1.1rem;
+  font-weight: 600;
+  line-height: 1.5;
+}
+
+.no-data-container .no-data-hint {
+  font-size: 0.98rem;
+  font-weight: 500;
+  line-height: 1.6;
+}
+

+ 18 - 0
word-app/src/app/review-words/review-words.page.spec.ts

@@ -0,0 +1,18 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReviewWordsPage } from './review-words.page';
+
+describe('ReviewWordsPage', () => {
+  let component: ReviewWordsPage;
+  let fixture: ComponentFixture<ReviewWordsPage>;
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ReviewWordsPage);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
+

+ 684 - 0
word-app/src/app/review-words/review-words.page.ts

@@ -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分钟';
+    }
+  }
+}
+