Pārlūkot izejas kodu

优化背单词页面

s202226701051 7 mēneši atpakaļ
vecāks
revīzija
aae7fc3453

+ 35 - 26
word-app/src/app/pronunciation-button/pronunciation-button.component.scss

@@ -1,48 +1,57 @@
-/* 发音切换按钮 - 缩小尺寸 */
+/* 发音切换 — 海洋主题(与背单词/单词匹配一致) */
+$ocean-deep: #01579b;
+$ocean-mid: #0288d1;
+$ocean-surface: #81d4fa;
+$ocean-foam: #e1f5fe;
+
 .pronunciation-toggle-btn {
   display: flex;
   align-items: center;
   justify-content: center;
-  gap: 3px;
-  width: 44px;
-  height: 24px;
-  border: 1px solid #000;
-  border-radius: 12px;
-  background: transparent;
+  gap: 4px;
+  width: 46px;
+  height: 26px;
+  border: 2px solid rgba($ocean-mid, 0.45);
+  border-radius: 14px;
+  background: linear-gradient(145deg, rgba($ocean-foam, 0.95) 0%, #ffffff 55%, rgba($ocean-foam, 0.85) 100%);
+  box-shadow: 0 2px 8px rgba(2, 136, 209, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.9);
   cursor: pointer;
   padding: 0 6px;
-  transition: all 0.2s ease;
+  transition:
+    border-color 0.2s ease,
+    box-shadow 0.2s ease,
+    background 0.2s ease;
   box-sizing: border-box;
-  
-  /* 按钮内部文字 - 缩小字号 */
+
   .pronunciation-label {
     font-size: 0.75rem;
-    color: #000;
-    font-weight: 500;
+    color: $ocean-deep;
+    font-weight: 700;
     line-height: 1;
+    letter-spacing: 0.02em;
   }
-  
-  /* 喇叭图标 - 缩小尺寸 */
+
   .speaker-icon {
-    font-size: 1rem;
-    color: #000;
+    font-size: 1.05rem;
+    color: $ocean-mid;
     margin: 0;
     line-height: 1;
   }
-  
-  /* 悬停效果 */
+
   &:hover {
-    background: rgba(0, 0, 0, 0.05);
+    border-color: rgba($ocean-mid, 0.75);
+    background: linear-gradient(145deg, #ffffff 0%, rgba($ocean-foam, 0.98) 100%);
+    box-shadow: 0 3px 12px rgba(2, 136, 209, 0.18);
   }
-  
-  /* 按下效果 */
+
   &:active {
-    background: rgba(0, 0, 0, 0.1);
+    background: rgba($ocean-foam, 0.95);
+    box-shadow: 0 1px 4px rgba(2, 136, 209, 0.15);
   }
-  
-  /* 禁用状态 */
+
   &:disabled {
-    opacity: 0.5;
+    opacity: 0.45;
     cursor: not-allowed;
+    border-color: rgba($ocean-mid, 0.25);
   }
-}
+}

+ 33 - 9
word-app/src/app/pronunciation-button/pronunciation-button.component.ts

@@ -26,6 +26,8 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
   private hasPlayedInitialAudio: boolean = false; // 是否已经播放过初始音频
   private componentInitialized: boolean = false; // 组件是否已初始化
   private lastPlayTime: number = 0; // 上次播放时间
+  /** 待执行的自动播放定时器,避免与手动播放、快速换词叠加 */
+  private autoPlayTimeoutId: ReturnType<typeof setTimeout> | null = null;
 
   constructor() {
     addIcons({ volumeMediumOutline });
@@ -40,7 +42,7 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
   ngAfterViewInit(): void {
     // 组件视图初始化后,如果是第一次显示且启用了自动播放,播放发音
     if (this.autoPlay && this.word && !this.hasPlayedInitialAudio) {
-      setTimeout(() => {
+      this.scheduleAutoPlay(() => {
         this.playPronunciation();
         this.hasPlayedInitialAudio = true;
       }, this.autoPlayDelay);
@@ -62,7 +64,7 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
         
         // 如果启用了自动播放,播放新单词的发音
         if (this.autoPlay && currentWord) {
-          setTimeout(() => {
+          this.scheduleAutoPlay(() => {
             this.playPronunciation();
             this.hasPlayedInitialAudio = true;
           }, this.autoPlayDelay);
@@ -74,7 +76,7 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
     if (changes['autoPlay'] && changes['autoPlay'].currentValue === true) {
       // 如果之前因为 autoPlay 为 false 而没有播放,现在播放
       if (!this.hasPlayedInitialAudio && this.word) {
-        setTimeout(() => {
+        this.scheduleAutoPlay(() => {
           this.playPronunciation();
           this.hasPlayedInitialAudio = true;
         }, this.autoPlayDelay);
@@ -83,6 +85,7 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
   }
 
   ngOnDestroy(): void {
+    this.clearAutoPlayTimeout();
     this.stopAudio();
   }
 
@@ -91,11 +94,10 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
    */
   replayAudio(): void {
     console.log('手动触发重新播放');
+    this.clearAutoPlayTimeout();
     this.stopAudio();
     if (this.word) {
-      setTimeout(() => {
-        this.playPronunciation();
-      }, 100);
+      this.scheduleAutoPlay(() => this.playPronunciation(), 100);
     }
   }
 
@@ -103,14 +105,13 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
    * 切换发音类型
    */
   togglePronunciation(): void {
+    this.clearAutoPlayTimeout();
     this.stopAudio();
     this.isAmericanPronunciation = !this.isAmericanPronunciation;
     this.pronunciationToggled.emit(this.isAmericanPronunciation);
     
     // 立即播放切换后的发音
-    setTimeout(() => {
-      this.playPronunciation();
-    }, 100);
+    this.scheduleAutoPlay(() => this.playPronunciation(), 100);
   }
 
   /**
@@ -125,6 +126,9 @@ export class PronunciationButtonComponent implements OnInit, OnDestroy, OnChange
       console.error('没有单词可以播放发音');
       return;
     }
+
+    // 主动播放时取消尚未触发的自动播放,避免短时间內两次出声
+    this.clearAutoPlayTimeout();
     
     // 防止频繁播放
     const now = Date.now();
@@ -189,9 +193,29 @@ private getAudioUrl(word?: string): string {
     if (this.audioElement) {
       this.audioElement.pause();
       this.audioElement.currentTime = 0;
+      try {
+        this.audioElement.removeAttribute('src');
+        this.audioElement.load();
+      } catch {
+        /* ignore */
+      }
       this.audioElement = null;
     }
   }
 
+  private clearAutoPlayTimeout(): void {
+    if (this.autoPlayTimeoutId != null) {
+      clearTimeout(this.autoPlayTimeoutId);
+      this.autoPlayTimeoutId = null;
+    }
+  }
+
+  private scheduleAutoPlay(fn: () => void, delay: number): void {
+    this.clearAutoPlayTimeout();
+    this.autoPlayTimeoutId = setTimeout(() => {
+      this.autoPlayTimeoutId = null;
+      fn();
+    }, delay);
+  }
   
 }

+ 203 - 248
word-app/src/app/recite-words/recite-words.page.html

@@ -1,271 +1,226 @@
-<ion-header [translucent]="true">
-  <ion-toolbar>
-    <ion-buttons slot="start">
-      <ion-button (click)="goBack()">
-        <ion-icon name="chevron-back-outline"></ion-icon>
-      </ion-button>
-    </ion-buttons>   
-    <!-- 进度显示 -->
-    <ion-buttons slot="end">
-      <ion-button fill="clear" size="small">
-        <span class="progress-text">{{ getProgressText() }}</span>
-      </ion-button>
-    </ion-buttons>
-  </ion-toolbar>
-</ion-header>
+<ion-content [fullscreen]="true" class="word-story-content" #ionContent>
+  <div class="ocean-bg"></div>
 
-<ion-content [fullscreen]="true" class="ion-padding" #ionContent>
-  <!-- 显示加载状态 -->
-  <div class="loading-container" *ngIf="isLoading">
-    <ion-spinner name="crescent"></ion-spinner>
-    <p>加载单词中...</p>
+  <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-container" *ngIf="currentWord && !isLoading && !showGroupCompleted">
-    <!-- 英语单词 - 根据学习次数动态显示 -->
-    <div class="word-header">
-      <!-- 根据studyCount显示不同内容 -->
-      <ng-container [ngSwitch]="currentWord.studyCount">
-        <!-- 模式0和模式2:显示完整单词 -->
-        <h1 *ngSwitchCase="0" class="word-text">{{ currentWord.word }}</h1>
-        <h1 *ngSwitchCase="2" class="word-text">{{ currentWord.word }}</h1>
-        
-        <!-- 模式1:隐藏单词,显示空白 -->
-        <div *ngSwitchCase="1" class="hidden-word-container">
-          <!-- 不显示问号,而是显示空白 -->
-          <div *ngIf="!isAnswered" class="blank-word"></div>
-          <h1 *ngIf="isAnswered" class="word-text">{{ currentWord.word }}</h1>
-        </div>
-        
-        <!-- 默认情况 -->
-        <h1 *ngSwitchDefault class="word-text">{{ currentWord.word }}</h1>
-      </ng-container>
-      
-      <!-- 按照图片布局:圆形按钮在前,音标在后 -->
-      <div class="phonetic-section">
-        <!-- 使用自定义发音按钮组件 -->
-        <app-pronunciation-button
-          [word]="currentWord.word"
-          [isAmericanPronunciation]="isAmericanPronunciation"
-          [autoPlay]="getAutoPlay()"
-          (pronunciationToggled)="onPronunciationToggled($event)">
-        </app-pronunciation-button>
-        
-        <!-- 音标显示 -->
-        <!-- 模式0和2:显示音标 -->
-        <div *ngIf="currentWord.studyCount !== 1" class="phonetic-text">
-          {{ isAmericanPronunciation ? currentWord.phonetic : currentWord.phoneticUk || currentWord.phonetic }}
-        </div>
-        
-        <!-- 模式1:总是显示音标,即使单词隐藏 -->
-        <div *ngIf="currentWord.studyCount === 1" class="phonetic-text">
-          {{ isAmericanPronunciation ? currentWord.phonetic : currentWord.phoneticUk || currentWord.phonetic }}
-        </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>
 
-    <!-- 根据状态显示不同内容 -->
-    <ng-container *ngIf="!showWordDetail; else detailSection">
-      <!-- 正常背单词模式:显示提示词和选项 -->
-      <div class="recite-mode">
-        <!-- 提示文字 - 根据studyCount动态显示 -->
-        <div class="hint-text">{{ getHintText() }}</div>
+    <!-- 显示加载状态 -->
+    <div class="loading-container" *ngIf="isLoading">
+      <ion-spinner name="crescent" color="primary"></ion-spinner>
+      <p>加载单词中...</p>
+    </div>
 
-        <!-- 选项区域 - 模式2不显示选项 -->
-        <div class="options-container" *ngIf="currentWord.studyCount < 2 ">
-          <ion-button 
-            *ngFor="let option of wordOptions; let i = index"
-            expand="block" 
-            fill="outline"
-            [color]="getOptionColor(option)"
-           
-            (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-answered">
-                <!-- 正确答案的显示 -->
-                <div *ngIf="option.isCorrect" class="option-correct">
-                  <div class="english-word">{{ getOptionEnglish(option) }}</div>
-                  <div class="definition-line">
-                    <span class="option-type">{{ getOptionType(option) }}</span>
-                    <span class="option-text">{{ getOptionDisplayText(option) }}</span>
-                  </div>
-                </div>
-                
-                <!-- 错误答案的显示(如果是用户选择的错误答案) -->
-                <div *ngIf="selectedOption === option && !option.isCorrect" class="option-wrong">
-                  <div class="english-word">{{ getOptionEnglish(option) }}</div>
-                  <div class="definition-line">
-                    <span class="option-type">{{ getOptionType(option) }}</span>
-                    <span class="option-text">{{ getOptionDisplayText(option) }}</span>
-                  </div>
+    <!-- 显示单词内容 -->
+    <div
+      class="word-container shell-card"
+      [class.word-container--word-detail]="showWordDetail"
+      [class.word-container--options]="!showWordDetail && currentWord.studyCount !== 2"
+      [class.word-container--recognize]="!showWordDetail && currentWord.studyCount === 2"
+      *ngIf="currentWord && !isLoading && !showGroupCompleted">
+      <!-- 英语单词 - 根据学习次数动态显示 -->
+      <div class="word-header">
+        <!-- 根据studyCount显示不同内容 -->
+        <ng-container [ngSwitch]="currentWord.studyCount">
+          <!-- 模式0和模式2:显示完整单词 -->
+          <h1 *ngSwitchCase="0" class="word-text">{{ currentWord.word }}</h1>
+          <h1 *ngSwitchCase="2" class="word-text">{{ currentWord.word }}</h1>
+
+          <!-- 模式1:隐藏单词,显示空白 -->
+          <div *ngSwitchCase="1" class="hidden-word-container">
+            <div *ngIf="!isAnswered" class="blank-word"></div>
+            <h1 *ngIf="isAnswered" class="word-text">{{ currentWord.word }}</h1>
+          </div>
+
+          <!-- 默认情况 -->
+          <h1 *ngSwitchDefault class="word-text">{{ currentWord.word }}</h1>
+        </ng-container>
+
+        <!-- 按照图片布局:圆形按钮在前,音标在后 -->
+        <div class="phonetic-section">
+          <app-pronunciation-button
+            [word]="currentWord.word"
+            [isAmericanPronunciation]="isAmericanPronunciation"
+            [autoPlay]="getAutoPlay()"
+            (pronunciationToggled)="onPronunciationToggled($event)">
+          </app-pronunciation-button>
+
+          <div *ngIf="currentWord.studyCount !== 1" class="phonetic-text">
+            {{ isAmericanPronunciation ? currentWord.phonetic : currentWord.phoneticUk || currentWord.phonetic }}
+          </div>
+
+          <div *ngIf="currentWord.studyCount === 1" 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="currentWord.studyCount < 2">
+            <ion-button
+              *ngFor="let option of wordOptions; let i = index"
+              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="!option.isCorrect && selectedOption !== option" class="option-other-wrong">
-                  <div class="english-word">{{ getOptionEnglish(option) }}</div>
-                  <div class="definition-line">
-                    <span class="option-type">{{ getOptionType(option) }}</span>
-                    <span class="option-text">{{ getOptionDisplayText(option) }}</span>
-                  </div>
+
+                <!-- 与未选完全相同的 class 与两行结构:上单词、下「词性 · 释义」,单行省略,高度不变 -->
+                <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>
-            </div>
-          </ion-button>
+            </ion-button>
+          </div>
         </div>
-      </div>
-    </ng-container>
+      </ng-container>
 
-    <!-- 单词详情模式模板 -->
-    <ng-template #detailSection>
-      <div class="detail-mode">
-        <!-- 单词详情组件 -->
-<!-- 单词详情组件 -->
-<app-word-detail 
-  [word]="currentWord">
-</app-word-detail>
-      </div>
-    </ng-template>
+      <ng-template #detailSection>
+        <div class="detail-mode">
+          <app-word-detail [word]="currentWord"></app-word-detail>
+        </div>
+      </ng-template>
 
-    <!-- 统一的按钮区域 -->
-<div class="button-container">
-  <!-- 模式0和模式1的按钮 -->
-  <ng-container *ngIf="currentWord.studyCount == 0 || currentWord.studyCount == 1 ">
-    <!-- 看答案按钮(未回答时显示) -->
-    <ion-button 
-      *ngIf="!isAnswered || (isCorrect && !showWordDetail)"
-      expand="block" 
-      fill="clear"
-      color="medium"
-      (click)="showAnswer()"
-      class="action-button">
-      看答案
-    </ion-button>
+      <div class="button-container">
+        <ng-container *ngIf="currentWord.studyCount == 0 || currentWord.studyCount == 1">
+          <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"
-      color="medium"
-      (click)="continueToNext()"
-      class="action-button">
-      继续
-    </ion-button>
+          <ion-button
+            *ngIf="showContinueButton && !showWordDetail"
+            expand="block"
+            fill="clear"
+            (click)="continueToNext()"
+            class="action-button action-button--text-ocean">
+            继续
+          </ion-button>
 
-    <!-- 下一个单词按钮(显示单词详情后显示) -->
-    <ion-button 
-      *ngIf="showWordDetail"
-      expand="block" 
-      fill="clear"
-      color="medium"
-      (click)="goToNextStudy()"
-      class="action-button">
-      下一个
-    </ion-button>
-  </ng-container>
-  
-<!-- 模式2的按钮(认识/不认识) -->
-<ng-container *ngIf="currentWord.studyCount === 2">
-  <div class="recognition-buttons" *ngIf="!isAnswered">
-    <ion-button 
-      expand="block" 
-      fill="clear"
-      (click)="recognizeWord(true)"
-      class="action-button know-button">
-      认识
-    </ion-button>
-    
-    <ion-button 
-      expand="block" 
-      fill="clear"
-      (click)="recognizeWord(false)"
-      class="action-button dont-know-button">
-      不认识
-    </ion-button>
-  </div>
-   <!-- 下一个单词按钮(显示单词详情后显示) -->
-  <ion-button 
-    *ngIf="showWordDetail"
-    expand="block" 
-    fill="clear"
-    color="medium"
-    (click)="goToNextStudy()"
-    class="action-button">
-    下一个
-  </ion-button>
-</ng-container>
-</div>
-  </div>
+          <ion-button
+            *ngIf="showWordDetail"
+            expand="block"
+            fill="clear"
+            (click)="goToNextStudy()"
+            class="action-button action-button--text-ocean">
+            下一个
+          </ion-button>
+        </ng-container>
 
-  <!-- 单词组完成页面 -->
-  <div class="group-completed-container" *ngIf="showGroupCompleted && !isLoading">
-    <div class="completion-card">
-<div class="completion-icon">
-  <ion-icon name="trophy" style="color: #FFD700;"></ion-icon>
-</div>
-      <h2 class="completion-title">太棒了!</h2>
-      <p class="completion-subtitle">本组单词已完成学习</p>
-      
-      <div class="completion-stats">
-        <div class="stat-item">
-          <div class="stat-value">{{ completedWordsCount }}</div>
-          <div class="stat-label">学习单词</div>
-        </div>
-        <div class="stat-item">
-          <div class="stat-value">{{ studyTime }}</div>
-          <div class="stat-label">学习时间</div>
-        </div>
-        <div class="stat-item">
-          <div class="stat-value">{{ masteryRate }}%</div>
-          <div class="stat-label">掌握率</div>
-        </div>
+        <ng-container *ngIf="currentWord.studyCount === 2">
+          <div class="recognition-buttons" *ngIf="!isAnswered">
+            <ion-button expand="block" fill="solid" (click)="recognizeWord(true)" class="action-button know-button ocean-know">
+              认识
+            </ion-button>
+
+            <ion-button expand="block" fill="solid" (click)="recognizeWord(false)" class="action-button dont-know-button ocean-dont-know">
+              不认识
+            </ion-button>
+          </div>
+          <ion-button
+            *ngIf="showWordDetail"
+            expand="block"
+            fill="clear"
+            (click)="goToNextStudy()"
+            class="action-button action-button--text-ocean">
+            下一个
+          </ion-button>
+        </ng-container>
       </div>
-      
-      <div class="completion-actions">
-        <ion-button 
-  expand="block" 
-  fill="solid" 
-  class="rest-button"
-  (click)="goToHome()">
-  休息一下
-</ion-button>
+    </div>
 
-<ion-button 
-  expand="block" 
-  fill="solid" 
-  class="hot-button"
-  (click)="continuePractice()">
-  趁热打铁
-</ion-button>
+    <!-- 单词组完成页面 -->
+    <div class="group-completed-container" *ngIf="showGroupCompleted && !isLoading">
+      <div class="completion-card shell-card">
+        <div class="completion-icon">
+          <ion-icon name="trophy" class="trophy-icon"></ion-icon>
+        </div>
+        <h2 class="completion-title">太棒了!</h2>
+        <p class="completion-subtitle">本组单词已完成学习</p>
+
+        <div class="completion-stats">
+          <div class="stat-item">
+            <div class="stat-value">{{ completedWordsCount }}</div>
+            <div class="stat-label">学习单词</div>
+          </div>
+          <div class="stat-item">
+            <div class="stat-value">{{ studyTime }}</div>
+            <div class="stat-label">学习时间</div>
+          </div>
+          <div class="stat-item">
+            <div class="stat-value">{{ masteryRate }}%</div>
+            <div class="stat-label">掌握率</div>
+          </div>
+        </div>
 
-<ion-button 
-  expand="block" 
-  fill="solid" 
-  class="next-group-button"
-  (click)="startNextGroup()">
-  再学一组
-</ion-button>
+        <div class="completion-actions">
+          <ion-button expand="block" fill="solid" class="rest-button" (click)="goToHome()">休息一下</ion-button>
+          <ion-button expand="block" fill="solid" class="hot-button" (click)="continuePractice()">趁热打铁</ion-button>
+          <ion-button expand="block" fill="solid" class="next-group-button" (click)="startNextGroup()">再学一组</ion-button>
+        </div>
       </div>
     </div>
-  </div>
 
-  <!-- 如果没有单词数据 -->
-  <div class="no-data-container" *ngIf="!currentWord && !isLoading && !showGroupCompleted">
-    <p>没有找到单词数据</p>
-    <p>请检查 assets/words.txt 文件是否存在且格式正确</p>
+    <!-- 如果没有单词数据 -->
+    <div class="no-data-container shell-card" *ngIf="!currentWord && !isLoading && !showGroupCompleted">
+      <p>没有找到单词数据</p>
+      <p class="no-data-hint">请检查 assets/words_cet6.txt 文件是否存在且格式正确</p>
+    </div>
   </div>
-</ion-content>
+</ion-content>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 654 - 267
word-app/src/app/recite-words/recite-words.page.scss


+ 173 - 47
word-app/src/app/recite-words/recite-words.page.ts

@@ -12,16 +12,13 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Router } from '@angular/router';
 import { WordDetailService } from '../word-detail/word-detail.service';
-import { 
-  IonButton, 
-  IonContent, 
-  IonHeader, 
-  IonToolbar,
+import {
+  IonButton,
+  IonContent,
   IonSpinner,
   IonIcon,
-  IonButtons,
   AlertController,
-  NavController 
+  NavController
 } from '@ionic/angular/standalone';
 import { HttpClient, HttpClientModule } from '@angular/common/http';
 import { addIcons } from 'ionicons';
@@ -43,6 +40,7 @@ import { PronunciationButtonComponent } from '../pronunciation-button/pronunciat
 import { WordDetailComponent } from '../word-detail/word-detail.component'; 
 import { WordProcessingService } from './word-processing.service';
 import { WordGroupService } from './word-group.service';
+import { WordProgressStorageService } from './word-progress-storage.service';
 import { Word, WordOption } from './word-interfaces';
 import { finalize } from 'rxjs/operators';  // 导入finalize操作符
 
@@ -66,10 +64,15 @@ addIcons({
   styleUrls: ['./recite-words.page.scss'],
   standalone: true,
   imports: [
-    IonContent, IonHeader, IonToolbar, 
-    CommonModule, FormsModule, IonButton, HttpClientModule, 
-    IonSpinner, IonIcon, IonButtons, PronunciationButtonComponent, 
-    WordDetailComponent, 
+    IonContent,
+    CommonModule,
+    FormsModule,
+    IonButton,
+    HttpClientModule,
+    IonSpinner,
+    IonIcon,
+    PronunciationButtonComponent,
+    WordDetailComponent
   ],
   providers: [WordProcessingService, WordGroupService,WordDetailService]
 })
@@ -116,6 +119,13 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
 
   wordOptions: WordOption[] | null = null;  // 当前显示的选项列表
 
+  /** 云端进度:当前登录用户 id */
+  private dbUserId: string | null = null;
+  /** 云端进度:wordList.objectId */
+  private dbListObjectId: string | null = null;
+  /** 云端进度:复习间隔(天),来自 wordReviewLadderPreset */
+  private dbIntervalDays: number[] = [];
+
   constructor(
     private http: HttpClient,  // HTTP客户端,用于加载单词文件
     private router: Router,  // 路由服务,用于页面导航
@@ -125,6 +135,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     private wordGroupService: WordGroupService,  // 单词组服务(管理学习进度)
     private navCtrl: NavController, // 导航控制器,用于页面导航
      private wordDetailService: WordDetailService, // 单词详情服务(获取单词详细信息)
+    private wordProgressStorage: WordProgressStorageService,
   ) {}
 
   /**
@@ -150,6 +161,11 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     }
   }
 
+  /**
+   * 进度在作答当下保存;离开页面不再承担写库职责。
+   */
+  async ionViewWillLeave() {}
+
   /**
    * Angular 生命周期钩子:视图初始化完成后调用
    * 滚动页面到顶部
@@ -236,10 +252,6 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
    * @param option 选项对象
    * @returns 颜色字符串
    */
-  getOptionColor(option: WordOption): string {
-    return 'medium';
-  }
-
   /**
    * 获取选项词性
    * @param option 选项对象
@@ -297,6 +309,8 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
         console.warn('未知的学习模式:', studyCount);
         break;
     }
+
+    this.persistAnsweredProgressNow();
   }
 
   /**
@@ -392,6 +406,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     }
     
     this.selectedOption = this.currentWord?.options.find((opt: WordOption) => opt.isCorrect) || null;
+    this.persistAnsweredProgressNow();
 
      setTimeout(() => {
     if (this.pronunciationButton) {
@@ -452,6 +467,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     this.isAnswered = true;
     this.isCorrect = know; // 认识视为正确,不认识视为错误
     this.showWordDetail = true; // 直接显示单词详情  
+    this.persistAnsweredProgressNow();
     // 延迟播放发音
     setTimeout(() => {
       if (this.pronunciationButton) {
@@ -462,20 +478,68 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
   }
 
   // ==================== 通用导航方法 ====================
+
+  /**
+   * 作答当下立即持久化(不依赖「下一个」或离开页面)。
+   * 注意:这里用“模拟更新后的 studyCount”写库,不改当前页面展示状态。
+   */
+  private persistAnsweredProgressNow(): void {
+    if (!this.currentWord || !this.isAnswered) {
+      return;
+    }
+    if (!this.dbUserId || !this.dbListObjectId || this.dbIntervalDays.length === 0) {
+      return;
+    }
+
+    const wordRef = this.currentWord;
+    const prevStudyCount = wordRef.studyCount;
+    const nextStudyCount = this.isCorrect ? Math.min(prevStudyCount + 1, 3) : 0;
+
+    const saveWord: Word = {
+      ...wordRef,
+      studyCount: nextStudyCount,
+      isMastered: nextStudyCount >= 3
+    };
+
+    this.wordProgressStorage
+      .upsertWordProgress(
+        saveWord,
+        this.dbUserId,
+        this.dbListObjectId,
+        this.dbIntervalDays,
+        prevStudyCount
+      )
+      .then(() => {
+        // 回写 objectId,后续写入走 update 而不是重复 create
+        if (this.currentWord === wordRef && saveWord.progressObjectId) {
+          this.currentWord.progressObjectId = saveWord.progressObjectId;
+        }
+      })
+      .catch((e) => console.error('[WordProgress] 作答即时保存失败', e));
+  }
+
+  /**
+   * 仅当当前词已作答(选题 / 认识·不认识 / 「看答案」)时,按结果更新 studyCount。
+   * @returns 更新前的 studyCount 与词引用,供云端 upsert;未作答返回 null。
+   */
+  private applyAnsweredWordStudyUpdate(): { wordRef: Word; prevStudyCount: number } | null {
+    if (!this.currentWord || !this.isAnswered) {
+      return null;
+    }
+    const wordRef = this.currentWord;
+    const prevStudyCount = wordRef.studyCount;
+    this.wordGroupService.updateWordStudyCount(wordRef, this.isCorrect);
+    return { wordRef, prevStudyCount };
+  }
   
   /**
    * 进入下一个学习阶段
    * 处理从一个单词到下一个单词的过渡
    */
   goToNextStudy() {
-    // 在进入下一个单词前,更新当前单词的学习次数
-    if (this.currentWord) {
-      // 根据当前回答是否正确来更新学习次数
-      if (this.isCorrect !== undefined) {
-        this.wordGroupService.updateWordStudyCount(this.currentWord!, this.isCorrect);
-      }
-    }
-    
+    // 云端已在作答当下保存;这里只做本地学习次数更新和切词
+    this.applyAnsweredWordStudyUpdate();
+
     // 重置状态
     this.resetCurrentWordState();
     
@@ -490,13 +554,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
 
        this.loadWordDetails();// 加载单词详情
 
-      
-      // 手动触发重新播放发音
-      setTimeout(() => {
-        if (this.pronunciationButton) {
-          this.pronunciationButton.replayAudio();
-        }
-      }, 100);
+      // 发音由 app-pronunciation-button 在 [word] 变化时自动播放,此处再 replay 会与子组件定时播放叠成「重音」
     } else {
       // 所有单词都完成了
       this.completeGroup();
@@ -603,12 +661,12 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
   
   /**
    * 加载单词
-   * 从assets/words.txt文件加载单词数据
+   * 从assets/words_cet6.txt文件加载单词数据
    */
   loadWords() {
     this.isLoading = true;
-    this.http.get('assets/words.txt', { responseType: 'text' }).subscribe({
-      next: (data) => {
+    this.http.get('assets/words_cet6.txt', { responseType: 'text' }).subscribe({
+      next: async (data) => {
         console.log('成功加载单词文件');
         
         // 使用服务解析单词
@@ -635,6 +693,8 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
           }
           
           this.words = validWords;
+
+          await this.mergeServerProgressIntoWords();
           
           if (this.words.length > 0) {
             console.log(`成功为 ${this.words.length} 个单词生成有效选项`);
@@ -651,6 +711,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
         
         this.isLoading = false;
         this.scrollToTop();
+        this.cdr.detectChanges();
       },
       error: (err) => {
         console.error('加载单词文件失败:', err);
@@ -661,6 +722,74 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     });
   }
 
+  /**
+   * 已登录且库中存在 wordList(assetKey=words_cet6) 时,合并 wordUserProgress 到 this.words
+   */
+  private async mergeServerProgressIntoWords(): Promise<void> {
+    this.dbUserId = await this.wordProgressStorage.getCurrentUserId();
+    this.dbListObjectId = null;
+    this.dbIntervalDays = [];
+
+    if (!this.dbUserId) {
+      console.log('[WordProgress] 未登录,不同步云端进度');
+      return;
+    }
+
+    const ctx = await this.wordProgressStorage.resolveWordListContext();
+    if (!ctx) {
+      console.warn('[WordProgress] 未配置 wordList 或查询失败,不同步云端进度');
+      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);
+        }
+      }
+      console.log('[WordProgress] 已合并云端进度');
+    } catch (e) {
+      console.error('[WordProgress] 加载云端进度失败', e);
+    }
+  }
+
+  /**
+   * 「再学一遍」:本地清零进度并同步到云端(快照写入,清空复习阶梯与时间)
+   */
+  private async resetAllWordsProgressAndSync(): Promise<void> {
+    this.words.forEach((word) => {
+      word.studyCount = 0;
+      word.isMastered = false;
+      word.isNewDone = false;
+      word.ladderStep = 0;
+      word.lastReviewAt = undefined;
+      word.nextReviewAt = undefined;
+    });
+
+    if (this.dbUserId && this.dbListObjectId && this.dbIntervalDays.length > 0) {
+      for (const w of this.words) {
+        try {
+          await this.wordProgressStorage.upsertWordProgress(
+            w,
+            this.dbUserId,
+            this.dbListObjectId,
+            this.dbIntervalDays,
+            undefined
+          );
+        } catch (e) {
+          console.error('[WordProgress] 重置同步失败', w.word, e);
+        }
+      }
+    }
+
+    this.startNextGroup();
+  }
+
   // ==================== 完成页面操作方法 ====================
   
   /**
@@ -735,14 +864,7 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
         {
           text: '再学一遍',
           handler: () => {
-            // 重置所有单词的学习次数
-            this.words.forEach(word => {
-              word.studyCount = 0;
-              word.isMastered = false;
-            });
-            
-            // 重新开始
-            this.startNextGroup();
+            void this.resetAllWordsProgressAndSync();
           }
         }
       ]
@@ -807,6 +929,15 @@ export class ReciteWordsPage implements OnInit, AfterViewInit, OnDestroy {
     }
   }
 
+  getStudyTimeNumber(): string {
+    const matched = this.studyTime.match(/^(.+?)(分钟)$/);
+    return matched ? matched[1] : this.studyTime;
+  }
+
+  getStudyTimeUnit(): string {
+    return this.studyTime.includes('分钟') ? '分钟' : '';
+  }
+
  /**
  * 加载当前单词的详细信息
  * 包括例句、短语、派生词、近义词
@@ -825,9 +956,7 @@ private loadWordDetails(): void {
 
   console.log(`获取单词 "${this.currentWord.word}" 的详细信息...`);
   
-  // 显示加载状态(可选)
-  this.isLoading = true;
-  
+  // 勿使用全局 isLoading:会隐藏整块背单词 UI、销毁发音组件,返回后又重建并再次自动发音,造成叠音/重音
   this.wordDetailService.fetchWordDetails(this.currentWord.word).subscribe({
     next: (details) => {
       console.log(`单词 "${this.currentWord?.word}" 详细信息获取成功`);
@@ -842,12 +971,9 @@ private loadWordDetails(): void {
         // 手动触发变更检测
         this.cdr.detectChanges();
       }
-      
-      this.isLoading = false;
     },
     error: (error) => {
       console.error(`获取单词 "${this.currentWord?.word}" 详细信息失败:`, error);
-      this.isLoading = false;
     }
   });
 }

+ 24 - 5
word-app/src/app/recite-words/word-group.service.ts

@@ -30,11 +30,30 @@ export class WordGroupService {
         createdAt: new Date()
       };
     }
-    
-    // 按学习次数排序(次数少的优先)
-    const sortedWords = [...availableWords].sort((a, b) => {
-      return a.studyCount - b.studyCount;
-    });
+
+    // 在原文列表中的顺序,用于 studyCount 相同时稳定排序
+    const wordIndex = new Map<Word, number>();
+    words.forEach((w, i) => wordIndex.set(w, i));
+
+    // 已开始背但未完成(1~2 轮)的词优先入组,避免再次被大量 0 进度词挤出前 5 个
+    const inProgress = availableWords
+      .filter((w) => w.studyCount > 0 && w.studyCount < 3)
+      .sort((a, b) => {
+        const d = a.studyCount - b.studyCount;
+        if (d !== 0) {
+          return d;
+        }
+        return (wordIndex.get(a) ?? 0) - (wordIndex.get(b) ?? 0);
+      });
+
+    const fresh = availableWords
+      .filter((w) => w.studyCount === 0)
+      .sort((a, b) => (wordIndex.get(a) ?? 0) - (wordIndex.get(b) ?? 0));
+
+    const picked = new Set<Word>([...inProgress, ...fresh]);
+    const remainder = availableWords.filter((w) => !picked.has(w));
+
+    const sortedWords = [...inProgress, ...fresh, ...remainder];
     
     // 取前5个或更少
     const groupWords = sortedWords.slice(0, this.WORDS_PER_GROUP);

+ 11 - 0
word-app/src/app/recite-words/word-interfaces.ts

@@ -81,6 +81,17 @@ export interface Word {
   groupId?: number;       // 可选:所属单词组的ID
   isMastered?: boolean;   // 可选:是否已掌握(达到3次学习后自动标记为true)
 
+  /** Parse wordUserProgress 的 objectId,用于更新云端进度 */
+  progressObjectId?: string | null;
+  /** 是否完成新学阶段(与 studyCount>=3 一致,云端 isNewDone) */
+  isNewDone?: boolean;
+  /** 复习阶梯下标(云端 ladderStep) */
+  ladderStep?: number;
+  /** 上次复习时间 ISO 字符串 */
+  lastReviewAt?: string;
+  /** 下次复习时间 ISO 字符串 */
+  nextReviewAt?: string;
+
   phrases?: WordPhrase[];           // 短语列表,
   relWords?: WordDerivativeGroup[]; // 派生词/同根词列表
   sentences?: WordSentence[];       // 例句列表

+ 187 - 0
word-app/src/app/recite-words/word-progress-storage.service.ts

@@ -0,0 +1,187 @@
+import { Injectable } from '@angular/core';
+import { CloudObject, CloudQuery, CloudUser } from './ncloud';
+import { Word } from './word-interfaces';
+
+/** 与 assets 文件名一致,对应 wordList.assetKey */
+export const RECITE_WORDLIST_ASSET_KEY = 'words_cet6';
+
+export interface WordListContext {
+  listObjectId: string;
+  intervalDays: number[];
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class WordProgressStorageService {
+  /** 未配置阶梯时的默认间隔(天) */
+  private readonly defaultIntervalDays = [1, 2, 4, 7, 15, 30, 60];
+
+  async getCurrentUserId(): Promise<string | null> {
+    const u = await CloudUser.getCurrent();
+    return u?.id ?? null;
+  }
+
+  /**
+   * 根据 assetKey 查 wordList,并解析 ladderPreset → intervalDays
+   */
+  async resolveWordListContext(): Promise<WordListContext | null> {
+    const q = new CloudQuery('wordList');
+    q.equalTo('assetKey', RECITE_WORDLIST_ASSET_KEY);
+    const row = await q.first();
+    if (!row?.id) {
+      console.warn('[WordProgress] 未找到 wordList,assetKey=', RECITE_WORDLIST_ASSET_KEY);
+      return null;
+    }
+
+    let intervalDays = [...this.defaultIntervalDays];
+    const presetField = row.get('ladderPreset');
+    const presetId = this.pointerObjectId(presetField);
+
+    if (presetId) {
+      const pq = new CloudQuery('wordReviewLadderPreset');
+      const preset = await pq.get(presetId);
+      const days = preset?.get('intervalDays');
+      if (Array.isArray(days) && days.length > 0) {
+        intervalDays = days.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n));
+        if (intervalDays.length === 0) {
+          intervalDays = [...this.defaultIntervalDays];
+        }
+      }
+    }
+
+    return { listObjectId: row.id, intervalDays };
+  }
+
+  /**
+   * 加载某用户在某词库下的全部 wordUserProgress,key 为小写 wordText
+   */
+  async loadProgressByList(userId: string, listObjectId: string): Promise<Map<string, CloudObject>> {
+    const q = new CloudQuery('wordUserProgress');
+    q.equalTo('user', { __type: 'Pointer', className: '_User', objectId: userId });
+    q.equalTo('list', { __type: 'Pointer', className: 'wordList', objectId: listObjectId });
+    const rows = await q.find();
+    const map = new Map<string, CloudObject>();
+    for (const r of rows) {
+      const text = String(r.get('wordText') ?? '')
+        .trim()
+        .toLowerCase();
+      if (text) {
+        map.set(text, r);
+      }
+    }
+    return map;
+  }
+
+  /**
+   * 将服务端进度合并到 Word(仅填充与持久化相关字段)
+   */
+  applyProgressRowToWord(word: Word, row: CloudObject): void {
+    word.progressObjectId = row.id;
+    const sc = Number(row.get('studyCount'));
+    word.studyCount = Number.isNaN(sc) ? 0 : Math.max(0, Math.min(3, sc));
+    word.isMastered = word.studyCount >= 3;
+    word.isNewDone = word.studyCount >= 3 ? true : !!row.get('isNewDone');
+    const ls = Number(row.get('ladderStep'));
+    word.ladderStep = Number.isNaN(ls) ? 0 : ls;
+    const last = this.isoFromParseDate(row.get('lastReviewAt'));
+    const next = this.isoFromParseDate(row.get('nextReviewAt'));
+    word.lastReviewAt = last;
+    word.nextReviewAt = next;
+  }
+
+  /**
+   * 写入/更新 wordUserProgress
+   * @param prevStudyCount 更新学习次数之前的状态;undefined 表示仅快照保存(不触发「刚完成新学」的排期初始化)
+   */
+  async upsertWordProgress(
+    word: Word,
+    userId: string,
+    listObjectId: string,
+    intervalDays: number[],
+    prevStudyCount?: number
+  ): Promise<void> {
+    const isNewDone = word.studyCount >= 3;
+    let ladderStep = word.ladderStep ?? 0;
+    let lastReviewAt: { __type: 'Date'; iso: string } | null = null;
+    let nextReviewAt: { __type: 'Date'; iso: string } | null = null;
+
+    if (!isNewDone) {
+      ladderStep = 0;
+      lastReviewAt = null;
+      nextReviewAt = null;
+    } else if (prevStudyCount !== undefined && prevStudyCount < 3) {
+      const now = new Date();
+      ladderStep = 0;
+      lastReviewAt = this.toParseDate(now);
+      nextReviewAt = this.toParseDate(this.addDays(now, intervalDays[0] ?? 1));
+    } 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;
+    }
+
+    const payload: Record<string, unknown> = {
+      user: { __type: 'Pointer', className: '_User', objectId: userId },
+      list: { __type: 'Pointer', className: 'wordList', objectId: listObjectId },
+      wordText: word.word,
+      studyCount: word.studyCount,
+      isNewDone,
+      ladderStep,
+      lastReviewAt,
+      nextReviewAt
+    };
+
+    const obj = new CloudObject('wordUserProgress');
+    if (word.progressObjectId) {
+      obj.id = word.progressObjectId;
+    }
+    obj.set(payload as Record<string, any>);
+    await obj.save();
+    if (obj.id) {
+      word.progressObjectId = obj.id;
+    }
+    word.isNewDone = isNewDone;
+    word.ladderStep = ladderStep;
+    word.lastReviewAt = lastReviewAt ? this.isoFromParseValue(lastReviewAt) : undefined;
+    word.nextReviewAt = nextReviewAt ? this.isoFromParseValue(nextReviewAt) : undefined;
+  }
+
+  private pointerObjectId(v: unknown): string | null {
+    if (!v || typeof v !== 'object') {
+      return null;
+    }
+    const o = v as { objectId?: string };
+    return o.objectId ?? null;
+  }
+
+  private isoFromParseDate(v: unknown): string | undefined {
+    if (v == null) {
+      return undefined;
+    }
+    if (typeof v === 'string') {
+      return v;
+    }
+    if (typeof v === 'object' && v !== null && '__type' in v) {
+      const d = v as { __type?: string; iso?: string };
+      if (d.__type === 'Date' && d.iso) {
+        return d.iso;
+      }
+    }
+    return undefined;
+  }
+
+  private toParseDate(d: Date): { __type: 'Date'; iso: string } {
+    return { __type: 'Date', iso: d.toISOString() };
+  }
+
+  private isoFromParseValue(v: { __type: 'Date'; iso: string }): string {
+    return v.iso;
+  }
+
+  private addDays(d: Date, days: number): Date {
+    const x = new Date(d.getTime());
+    x.setUTCDate(x.getUTCDate() + days);
+    return x;
+  }
+}

+ 139 - 133
word-app/src/app/spelling-practice/spelling-practice.page.html

@@ -1,141 +1,147 @@
-<ion-header [translucent]="true">
-  <ion-toolbar>
-    <!-- 测试模式下的导航 -->
-    <ion-buttons *ngIf="!showSummary" slot="start">
-      <ion-button (click)="goBack()">
-        <ion-icon slot="icon-only" name="chevron-back-outline"></ion-icon>
-      </ion-button>
-    </ion-buttons>
-    
-    <!-- 总结模式下的导航 -->
-    <ion-buttons *ngIf="showSummary" slot="start">
-      <ion-button (click)="goToTab1()">
-        <ion-icon slot="icon-only" name="chevron-back-outline"></ion-icon>
-      </ion-button>
-    </ion-buttons>
-    
-    <!-- 测试模式下的进度显示 -->
-    <ion-buttons *ngIf="!showSummary" slot="end">
-      <ion-button fill="clear" size="small" color="medium">
-        {{ currentIndex + 1 }}/{{ totalWords }}
-      </ion-button>
-    </ion-buttons>
-    
-    <!-- 总结模式下的标题:中间显示"小结" -->
-    <ion-title *ngIf="showSummary" class="ion-text-center">小结</ion-title>
-  </ion-toolbar>
-</ion-header>
-
-<ion-content [fullscreen]="true" class="ion-padding">
-  <!-- 测试进行中 -->
-  <div *ngIf="!showSummary && currentWord" class="test-container">
-    <!-- 原有测试页面内容保持不变 -->
-    <div class="input-section">
-      <div class="input-wrapper">
-        <ion-input 
-          #wordInput
-          type="text"
-          [(ngModel)]="userInput"
-          (keyup.enter)="onInputEnter()"
-          [readonly]="isSubmitted"
-          [class.correct]="isSubmitted && isCorrect"
-          [class.wrong]="(isSubmitted && !isCorrect) || showAnswer"
-          class="spelling-input"
-          autocomplete="off"
-          autocapitalize="none"
-          autocorrect="off"
-          spellcheck="false">
-        </ion-input>
+<ion-content [fullscreen]="true" class="word-story-content">
+  <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)="showSummary ? goToTab1() : 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">{{ showSummary ? '小结' : '拼写练习' }}</span>
+        </span>
+      </h1>
+
+      <div class="nav-header-end">
+        <div class="placeholder"></div>
+      </div>
+    </div>
+
+    <div class="loading-container" *ngIf="!showSummary && !currentWord && !showCompletionAlert">
+      <ion-spinner name="crescent" color="primary"></ion-spinner>
+      <p>加载中...</p>
     </div>
-    
-    <div class="meaning-section">
-      <div class="meaning-card">
-        <div *ngIf="(isSubmitted && isCorrect) || (showAnswer) || !isSubmitted" class="meaning-content">
-          <div class="meaning-text">{{ getWordType() }}  {{ displayText }}</div>
+
+    <!-- 测试进行中:套用背单词页面同一套 shell-card 海洋主题样式 -->
+    <div
+      *ngIf="!showSummary && currentWord"
+      class="word-container shell-card word-container--spelling"
+    >
+      <div class="input-section">
+        <div class="input-wrapper">
+          <ion-input
+            #wordInput
+            type="text"
+            [(ngModel)]="userInput"
+            (keyup.enter)="onInputEnter()"
+            [readonly]="isSubmitted"
+            [class.correct]="isSubmitted && isCorrect"
+            [class.wrong]="(isSubmitted && !isCorrect) || showAnswer"
+            class="spelling-input"
+            autocomplete="off"
+            autocapitalize="none"
+            autocorrect="off"
+            spellcheck="false"
+          ></ion-input>
         </div>
-        
-        <div *ngIf="isSubmitted && !isCorrect" class="correct-word-display">
-          <div class="correct-word">{{ currentWord.word }}</div>
+      </div>
+
+      <div class="meaning-section">
+        <div class="meaning-card">
+          <div
+            *ngIf="(isSubmitted && isCorrect) || (showAnswer) || !isSubmitted"
+            class="meaning-content"
+          >
+            <div class="meaning-text">{{ getWordType() }} {{ displayText }}</div>
+          </div>
+
+          <div *ngIf="isSubmitted && !isCorrect" class="correct-word-display">
+            <div class="correct-word">{{ currentWord.word }}</div>
+          </div>
         </div>
       </div>
+
+      <div class="button-section">
+        <ion-button fill="clear" color="medium" (click)="revealAnswer()" class="action-button left-button">
+          <ion-icon name="eye-outline" slot="start"></ion-icon>
+        </ion-button>
+
+        <ion-button fill="clear" color="medium" (click)="playWordSound()" class="action-button middle-button">
+          <ion-icon name="volume-high-outline" slot="icon-only"></ion-icon>
+        </ion-button>
+
+        <ion-button
+          fill="clear"
+          color="medium"
+          (click)="isSubmitted || showAnswer ? nextWord() : submitAnswer()"
+          class="action-button right-button"
+        >
+          <ion-icon
+            [name]="isSubmitted || showAnswer ? 'arrow-forward-outline' : 'checkmark-outline'"
+            slot="icon-only"
+          ></ion-icon>
+        </ion-button>
+      </div>
     </div>
-    
-    <div class="button-section">
-      <ion-button 
-        fill="clear"
-        color="medium"
-        (click)="revealAnswer()"
-        class="action-button left-button">
-        <ion-icon name="eye-outline" slot="start"></ion-icon>
-      </ion-button>
-      
-      <ion-button 
-        fill="clear"
-        color="medium"
-        (click)="playWordSound()"
-        class="action-button middle-button">
-        <ion-icon name="volume-high-outline" slot="icon-only"></ion-icon>
-      </ion-button>
-      
-      <ion-button 
-        fill="clear"
-        color="medium"
-        (click)="isSubmitted || showAnswer? nextWord() : submitAnswer()"
-        class="action-button right-button">
-        <ion-icon 
-          [name]="isSubmitted || showAnswer ? 'arrow-forward-outline' : 'checkmark-outline'" 
-          slot="icon-only">
-        </ion-icon>
-      </ion-button>
-    </div>
-  </div>
-  
-  <!-- 加载状态 -->
-  <div *ngIf="!showSummary && !currentWord && !showCompletionAlert" class="loading-container">
-    <ion-spinner name="crescent" color="primary"></ion-spinner>
-    <p>加载中...</p>
-  </div>
-  
-  <!-- =========== 总结页面 =========== -->
-  <div *ngIf="showSummary" class="summary-container">
-    <!-- 单词列表 -->
-    <div class="words-list">
-      <ion-list>
-        <ion-item *ngFor="let word of wordGroup?.words" class="word-item">
-          <!-- 左边显示单词 -->
-          <ion-label class="word-text">
-            <h2>{{ word.word }}</h2>
-          </ion-label>
-          
-<!-- 右边显示答错次数 -->
-<div slot="end" class="wrong-count">
-  答错<span class="wrong-number">{{ word.spellAttempts || 0 }}</span>次
-</div>
-        </ion-item>
-      </ion-list>
+
+    <!-- 总结页面:同样套用 shell-card 外观 -->
+    <div *ngIf="showSummary" class="word-container shell-card word-container--spelling-summary">
+      <div class="summary-container">
+        <div class="words-list">
+          <ion-list>
+            <ion-item *ngFor="let word of wordGroup?.words" class="word-item">
+              <ion-label class="word-text">
+                <h2>{{ word.word }}</h2>
+              </ion-label>
+
+              <div slot="end" class="wrong-count">
+                答错<span class="wrong-number">{{ word.spellAttempts || 0 }}</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="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>
-</ion-content>
+</ion-content>
+

+ 38 - 22
word-app/src/app/spelling-practice/spelling-practice.page.scss

@@ -1,3 +1,5 @@
+@use '../recite-words/recite-words.page.scss' as *;
+
 :host {
   display: block;
   height: 100%;
@@ -23,15 +25,16 @@ ion-toolbar {
 
 // =========== 总结页面样式 ===========
 .summary-container {
-  min-height: calc(100vh - 56px);
+  min-height: 0;
+  height: 100%;
   display: flex;
   flex-direction: column;
   
   .words-list {
     flex: 1;
     overflow-y: auto;
-    margin: 16px,16px,0,0;
-    padding-bottom: 40px;
+    margin: 16px 16px 0 16px;
+    padding-bottom: 16px;
     
     .word-item {
       --padding-start: 16px;
@@ -67,7 +70,7 @@ ion-toolbar {
   // 底部按钮区域
   .summary-buttons {
     margin-top: auto;
-    padding: 32px 16px 100px;
+    padding: 24px 16px 8px;
     
     .button-row {
       display: flex;
@@ -87,7 +90,7 @@ ion-toolbar {
         margin: 0;
         flex: 1;
         
-        --color: var(--ion-color-medium);
+        --color: #{$ocean-deep};
         opacity: 1;
         
         &:active {
@@ -119,7 +122,7 @@ ion-toolbar {
     }
     
     .summary-buttons {
-      padding: 24px 16px 60px;
+      padding: 20px 16px 8px;
     }
   }
 }
@@ -141,11 +144,27 @@ ion-toolbar {
     }
     
     .summary-buttons {
-      padding: 32px 16px 120px;
+      padding: 28px 16px 12px;
     }
   }
 }
 
+/* 拼写练习:白色矩形框高度(对齐背单词页面卡片体验) */
+.word-container.shell-card.word-container--spelling {
+  height: 62vh;
+  margin-top: 18px;
+}
+
+.word-container.shell-card.word-container--spelling-summary {
+  height: 62vh;
+  overflow: hidden;
+  margin-top: 18px;
+
+  .summary-container {
+    height: 100%;
+  }
+}
+
 // =========== 原有测试页面样式保持不变 ===========
 .test-container {
   display: flex;
@@ -157,7 +176,7 @@ ion-toolbar {
 }
 
 .input-section {
-  margin-top: 40px;
+  margin-top: 28px;
   margin-bottom: 10px;
   
   .input-wrapper {
@@ -278,10 +297,10 @@ ion-toolbar {
     --padding-start: 0;
     --padding-end: 0;
     --border-radius: 16px;
-    --border-width: 1px;
-    --border-color: #e0e0e0;
-    --border-style: solid;
-    --background: #fff;
+    --border-width: 0;
+    --border-color: transparent;
+    --border-style: none;
+    --background: transparent;
     width: 70px;
     height: 70px;
     margin: 0;
@@ -294,20 +313,15 @@ ion-toolbar {
     
     ion-icon {
       font-size: 28px;
-      color: #4caf50;
+      color: currentColor;
       margin: 0;
     }
     
-    &:active {
-      --background: #f5f5f5;
-    }
-    
     &:disabled {
-      opacity: 0.4;
-      --border-color: #ccc;
+      opacity: 0.45;
       
       ion-icon {
-        color: #a5d6a7;
+        color: rgba($ocean-deep, 0.45);
       }
     }
   }
@@ -319,7 +333,7 @@ ion-toolbar {
     
     ion-icon {
       font-size: 30px;
-      color: #4caf50;
+      color: currentColor;
     }
   }
 }
@@ -336,7 +350,9 @@ ion-toolbar {
   }
   
   p {
-    color: #666;
+    color: rgba($ocean-deep, 0.7);
+    margin-bottom: 20px;
+    text-align: center;
   }
 }
 

+ 0 - 8
word-app/src/app/spelling-practice/spelling-practice.page.ts

@@ -3,15 +3,11 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { 
   IonContent, 
-  IonHeader, 
-  IonToolbar, 
   IonButton, 
-  IonButtons, 
   IonIcon,
   AlertController,
   IonSpinner,
   IonInput,
-  IonTitle,
   IonList,
   IonItem,
   IonLabel
@@ -61,14 +57,10 @@ export interface WordGroup {
     CommonModule,
     FormsModule,
     IonContent,
-    IonHeader,
-    IonToolbar,
     IonButton,
-    IonButtons,
     IonIcon,
     IonSpinner,
     IonInput,
-    IonTitle,
     IonList,
     IonItem,
     IonLabel

+ 6 - 5
word-app/src/app/word-detail/word-detail.component.html

@@ -1,14 +1,14 @@
 <div class="word-detail-container">
   
   <!-- 释义卡片 -->
-  <div class="definition-card">
+  <div class="definition-card" [class.scrolling]="isSectionScrolling('definition')" (scroll)="onSectionScroll('definition')">
     <div class="definition-item" *ngFor="let definition of definitions">
       {{ definition }}
     </div>
   </div>
 
 <!-- 例句卡片(只显示第一个例句) -->
-<div class="example-card" *ngIf="firstSentence">
+<div class="example-card" *ngIf="firstSentence" [class.scrolling]="isSectionScrolling('example')" (scroll)="onSectionScroll('example')">
   <div class="example-item">
     <div class="example-english">{{ firstSentence.s_content }}</div>
     <div class="example-chinese">{{ firstSentence.s_cn }}</div>
@@ -40,7 +40,7 @@
     </div>
 
     <!-- 短语内容 -->
-    <div class="tab-content" *ngIf="activeTab === 'phrases'">
+    <div class="tab-content" *ngIf="activeTab === 'phrases'" [class.scrolling]="isSectionScrolling('tab')" (scroll)="onSectionScroll('tab')">
       <div class="content-card">
         <div class="phrase-item" *ngFor="let phrase of word!.phrases">
           <span class="phrase-english">{{ phrase.p_content }}</span>
@@ -53,7 +53,7 @@
     </div>
 
    <!-- 派生词内容 -->
-<div class="tab-content" *ngIf="activeTab === 'derivatives'">
+<div class="tab-content" *ngIf="activeTab === 'derivatives'" [class.scrolling]="isSectionScrolling('tab')" (scroll)="onSectionScroll('tab')">
   <div class="content-card">
     <div class="derivative-group" *ngFor="let group of word!.relWords">
       <!-- 每个派生词单独显示词性,因此不再需要分组词性标题 -->
@@ -70,7 +70,7 @@
 </div>
 
    <!-- 近义词内容 -->
-<div class="tab-content" *ngIf="activeTab === 'synonyms'">
+<div class="tab-content" *ngIf="activeTab === 'synonyms'" [class.scrolling]="isSectionScrolling('tab')" (scroll)="onSectionScroll('tab')">
   <div class="content-card">
     <div class="synonym-group" *ngFor="let group of word!.synonyms">
       <!-- 词性和中文释义放在同一行 -->
@@ -88,6 +88,7 @@
     </div>
   </div>
 </div>
+
   </div>
 
   <!-- 没有扩展信息时的占位 -->

+ 112 - 25
word-app/src/app/word-detail/word-detail.component.scss

@@ -1,13 +1,62 @@
 .word-detail-container {
-  padding: 0 0 20px 0;
+  padding: 0 0 16px 0;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
   margin-top: -32px;
+  height: 100%;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+
+  .definition-card,
+  .example-card,
+  .tabs-card {
+    box-sizing: border-box;
+    margin-bottom: 0;
+  }
+
+  .definition-card,
+  .example-card {
+    overflow-y: auto;
+    -webkit-overflow-scrolling: touch;
+    scrollbar-width: none;
+    scrollbar-color: #3ec6ff #e8f7ff;
+
+    &::-webkit-scrollbar {
+      width: 0;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: linear-gradient(180deg, #66d6ff 0%, #11b1f6 100%);
+      border-radius: 999px;
+    }
+
+    &::-webkit-scrollbar-track {
+      background: #e8f7ff;
+      border-radius: 999px;
+    }
+
+    &::-webkit-scrollbar-thumb:hover {
+      background: linear-gradient(180deg, #3ec6ff 0%, #0d98d8 100%);
+    }
+
+    &.scrolling {
+      scrollbar-width: thin;
+
+      &::-webkit-scrollbar {
+        width: 6px;
+      }
+    }
+  }
+
   // ---------- 释义卡片 ----------
   .definition-card {
     background: white;
     border-radius: 16px;
-    padding: 16px 20px;
-    margin-bottom: 16px;
+    padding: 12px 14px;
+    flex: 0 0 18%;
+    min-height: 100px;
+    max-height: 150px;
     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
 
     .definition-item {
@@ -26,8 +75,11 @@
   .example-card {
     background: white;
     border-radius: 16px;
-    padding: 16px 20px;
-    margin-bottom: 20px;
+    border: 3px solid rgba(17, 177, 246, 0.62);
+    padding: 12px 14px;
+    flex: 0 0 28%;
+    min-height: 130px;
+    max-height: 220px;
     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
 
     .example-item {
@@ -53,15 +105,19 @@
     }
   }
 
-  // ---------- 选项卡卡片(固定高度,内容滚动)----------
+  // ---------- 选项卡卡片(固定区域,内容滚动)----------
   .tabs-card {
     background: white;
     border-radius: 16px;
-    padding: 16px 16px 0 16px;
+    position: relative;
+    overflow: hidden;
+    border: 3px solid rgba(17, 177, 246, 0.62);
+    padding: 12px 12px 10px 12px;
     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
 
-    // 固定高度,flex 垂直布局
-    height: 30vh;               
+    // 占据剩余固定空间
+    flex: 6.8 1 0;
+    min-height: 0;
     display: flex;
     flex-direction: column;
     box-sizing: border-box;
@@ -70,23 +126,32 @@
       flex-shrink: 0;              // 按钮区域不压缩
       display: flex;
       gap: 8px;
-      margin-bottom: 16px;
+      margin-bottom: 10px;
 
       .tab-btn {
         flex: 1;
-        background: #f5f5f5;
-        border: none;
-        padding: 10px 0;
+        background: linear-gradient(180deg, #e8f7ff 0%, #d6f0ff 100%);
+        border: 1.5px solid rgba(17, 177, 246, 0.45);
+        padding: 9px 0;
         font-size: 15px;
-        color: #666;
+        font-weight: 600;
+        color: #0a5f91;
         border-radius: 24px;
         cursor: pointer;
-        transition: all 0.2s;
+        box-shadow: 0 2px 6px rgba(2, 136, 209, 0.12);
+        transition: all 0.2s ease;
 
         &.active {
-          background: #e0e0e0;
-          color: #333;
-          font-weight: 500;
+          background: linear-gradient(180deg, #3ec6ff 0%, #11b1f6 100%);
+          border-color: #0d98d8;
+          color: #ffffff;
+          font-weight: 700;
+          box-shadow: 0 4px 10px rgba(2, 136, 209, 0.28);
+          transform: translateY(-1px);
+        }
+
+        &:active {
+          transform: scale(0.98);
         }
       }
     }
@@ -94,21 +159,42 @@
     // 内容区域:占据剩余空间,超出滚动
     .tab-content {
       flex: 1;
+      min-height: 0;
       overflow-y: auto;
+      scrollbar-width: none;
+      scrollbar-color: #3ec6ff #e8f7ff;
 
       // 自定义滚动条(可选)
       &::-webkit-scrollbar {
-        width: 4px;
+        width: 0;
       }
+
       &::-webkit-scrollbar-thumb {
-        background: #ccc;
-        border-radius: 4px;
+        background: linear-gradient(180deg, #66d6ff 0%, #11b1f6 100%);
+        border-radius: 999px;
+      }
+
+      &::-webkit-scrollbar-track {
+        background: #e8f7ff;
+        border-radius: 999px;
+      }
+
+      &::-webkit-scrollbar-thumb:hover {
+        background: linear-gradient(180deg, #3ec6ff 0%, #0d98d8 100%);
+      }
+
+      &.scrolling {
+        scrollbar-width: thin;
+
+        &::-webkit-scrollbar {
+          width: 6px;
+        }
       }
 
       .content-card {
         background: transparent;
         box-shadow: none;
-        padding: 0 0 16px 0;
+        padding: 0 0 8px 0;
 
        // 短语样式
 .phrase-item {
@@ -117,7 +203,7 @@
   padding: 14px 0;
 
   &:not(:last-child) {
-    border-bottom: 1px solid #f0f0f0;
+    border-bottom: 2px solid rgba(17, 177, 246, 0.34);
   }
 
   .phrase-english {
@@ -152,7 +238,7 @@
   &:not(:last-child) {
     margin-bottom: 20px;
     padding-bottom: 16px;
-    border-bottom: 1px solid #f0f0f0;
+    border-bottom: 2px solid rgba(17, 177, 246, 0.38);
   }
 
   .derivative-item {
@@ -162,7 +248,7 @@
     padding: 8px 0;
 
     &:not(:last-child) {
-      border-bottom: 1px dashed #f0f0f0;
+      border-bottom: 2px dashed rgba(17, 177, 246, 0.42);
     }
 
     .derivative-word {
@@ -232,6 +318,7 @@
         }
       }
     }
+
   }
 
   // ---------- 空状态卡片 ----------

+ 18 - 0
word-app/src/app/word-detail/word-detail.component.ts

@@ -22,6 +22,9 @@ export class WordDetailComponent {
   
   private fallbackDefinitions: string[] = [];
   activeTab: string = 'phrases'; // 默认显示短语
+  private readonly scrollHideDelayMs = 600;
+  private readonly scrollHideTimers: Partial<Record<'definition' | 'example' | 'tab', ReturnType<typeof setTimeout>>> = {};
+  private scrollingSections: Partial<Record<'definition' | 'example' | 'tab', boolean>> = {};
 
   /**
    * 获取所有释义
@@ -52,6 +55,21 @@ export class WordDetailComponent {
     this.activeTab = tab;
   }
 
+  onSectionScroll(section: 'definition' | 'example' | 'tab'): void {
+    this.scrollingSections[section] = true;
+    const timer = this.scrollHideTimers[section];
+    if (timer) {
+      clearTimeout(timer);
+    }
+    this.scrollHideTimers[section] = setTimeout(() => {
+      this.scrollingSections[section] = false;
+    }, this.scrollHideDelayMs);
+  }
+
+  isSectionScrolling(section: 'definition' | 'example' | 'tab'): boolean {
+    return !!this.scrollingSections[section];
+  }
+
   /**
    * 格式化近义词列表
    */

+ 0 - 0
word-app/src/assets/words.txt → word-app/src/assets/words_cet6.txt


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels