Explorar o código

优化AI诗词课组件加入声音开关、更新暂停继续按钮逻辑

liyanbo hai 5 días
pai
achega
c54f99c846

+ 84 - 20
src/api/tts/useAudioPlayer.js

@@ -1,7 +1,13 @@
 export function useAudioPlayer() {
     let audioContext = null;
+    let outputGain = null;
     let audioQueue = [];
     let isPlaying = false;
+    let isMuted = false;
+    let isPaused = false;
+    let streamComplete = false;
+    let completionNotified = false;
+    let playbackGeneration = 0;
     let currentTime = 0; // 当前播放时间(用于连续播放)
     let onPlaybackComplete = null; // 音频播放完成回调
     const SAMPLE_RATE = 16000; // 匹配后端采样率
@@ -14,6 +20,12 @@ export function useAudioPlayer() {
             audioContext = new (window.AudioContext || window.webkitAudioContext)({
                 sampleRate: SAMPLE_RATE
             });
+            outputGain = audioContext.createGain();
+            outputGain.gain.value = isMuted ? 0 : 1;
+            outputGain.connect(audioContext.destination);
+            if (isPaused) {
+                audioContext.suspend();
+            }
             currentTime = 0; // 重置播放时间
         }
     };
@@ -27,18 +39,36 @@ export function useAudioPlayer() {
         audioQueue.push(audioBytes);
 
         if (!isPlaying) {
-            processAudioQueue();
+            processAudioQueue(playbackGeneration);
+        }
+    };
+
+    const notifyPlaybackComplete = () => {
+        if (!streamComplete || isPlaying || audioQueue.length > 0 || completionNotified) return;
+        completionNotified = true;
+        if (onPlaybackComplete) {
+            onPlaybackComplete();
         }
     };
 
+    // 开始接收一段新的流式音频
+    const beginStream = () => {
+        streamComplete = false;
+        completionNotified = false;
+    };
+
+    // 标记服务端已发送完全部音频分片
+    const markStreamComplete = () => {
+        streamComplete = true;
+        notifyPlaybackComplete();
+    };
+
     // 处理音频队列(核心流式播放逻辑)
-    const processAudioQueue = async () => {
+    const processAudioQueue = async (generation = playbackGeneration) => {
+        if (generation !== playbackGeneration) return;
         if (audioQueue.length === 0) {
             isPlaying = false;
-            // 音频播放完成,调用回调
-            if (onPlaybackComplete) {
-                onPlaybackComplete();
-            }
+            notifyPlaybackComplete();
             return;
         }
 
@@ -50,7 +80,7 @@ export function useAudioPlayer() {
             if (currentTime === 0) {
                 // 解码完整WAV文件(仅首次)
                 const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
-                playBuffer(audioBuffer);
+                playBuffer(audioBuffer, generation);
                 currentTime += audioBuffer.duration; // 更新播放时间
             }
             // 2. 处理后续PCM分片(无文件头)
@@ -60,27 +90,28 @@ export function useAudioPlayer() {
                 // 创建音频缓冲区
                 const audioBuffer = audioContext.createBuffer(CHANNELS, float32Data.length, SAMPLE_RATE);
                 audioBuffer.copyToChannel(float32Data, 0); // 复制到音频通道
-                playBuffer(audioBuffer);
+                playBuffer(audioBuffer, generation);
                 currentTime += audioBuffer.duration; // 更新播放时间
             }
         } catch (error) {
             console.error('音频处理失败:', error);
             isPlaying = false;
-            // 错误时也调用回调
-            if (onPlaybackComplete) {
-                onPlaybackComplete();
-            }
+            processAudioQueue(generation);
         }
     };
 
     // 播放音频缓冲区并调度下一个分片
-    const playBuffer = (audioBuffer) => {
+    const playBuffer = (audioBuffer, generation) => {
         const source = audioContext.createBufferSource();
         source.buffer = audioBuffer;
-        source.connect(audioContext.destination);
+        source.connect(outputGain);
         source.start(currentTime); // 从当前时间点开始播放
         // 播放结束后继续处理队列
-        source.onended = processAudioQueue;
+        source.onended = () => {
+            if (generation === playbackGeneration) {
+                processAudioQueue(generation);
+            }
+        };
     };
 
     // 将16位PCM字节转换为Float32Array([-1.0, 1.0]范围)
@@ -95,10 +126,13 @@ export function useAudioPlayer() {
 
     // 停止播放并清理
     const stopPlayback = (callCallback = true) => {
+        playbackGeneration++;
         if (audioContext) {
-            audioContext.close().then(() => {
-                audioContext = null;
-                currentTime = 0; // 重置播放时间
+            const contextToClose = audioContext;
+            audioContext = null;
+            outputGain = null;
+            currentTime = 0;
+            contextToClose.close().then(() => {
                 // 停止播放时调用回调
                 if (callCallback && onPlaybackComplete) {
                     onPlaybackComplete();
@@ -112,6 +146,23 @@ export function useAudioPlayer() {
         }
         audioQueue = [];
         isPlaying = false;
+        streamComplete = false;
+        completionNotified = false;
+    };
+
+    // 暂停/继续仅改变播放时钟,不清空队列和进度
+    const pausePlayback = async () => {
+        isPaused = true;
+        if (audioContext && audioContext.state === 'running') {
+            await audioContext.suspend();
+        }
+    };
+
+    const resumePlayback = async () => {
+        isPaused = false;
+        if (audioContext && audioContext.state === 'suspended') {
+            await audioContext.resume();
+        }
     };
 
     // 设置播放完成回调
@@ -124,10 +175,23 @@ export function useAudioPlayer() {
         return isPlaying;
     };
 
+    // 仅控制声音输出,不停止播放、清空队列或影响完成回调。
+    const setMuted = (muted) => {
+        isMuted = Boolean(muted);
+        if (audioContext && outputGain && audioContext.state !== 'closed') {
+            outputGain.gain.setValueAtTime(isMuted ? 0 : 1, audioContext.currentTime);
+        }
+    };
+
     return {
         playAudioChunk,
+        beginStream,
+        markStreamComplete,
         stopPlayback,
+        pausePlayback,
+        resumePlayback,
         setOnPlaybackComplete,
-        getIsPlaying
+        getIsPlaying,
+        setMuted
     };
-}
+}

+ 262 - 135
src/components/aiCourse/DialogEngine.vue

@@ -5,17 +5,20 @@
       :background-type="currentBackgroundType"
       :image-url="currentBackgroundImage"
       :video-url="currentBackgroundVideo"
-      :is-playing="isPlaying"
       :is-playback-started="isPlaybackStarted"
+      :is-paused="isPaused"
     />
 
     <!-- 标题栏 -->
     <DialogHeader
       :title="currentSection?.name"
       :back-text="backText"
-      :is-playing="isPlaying"
+      :is-playing="autoAdvanceEnabled"
+      :is-paused="isPaused"
+      :is-muted="isMuted"
       @back="goBackToMain"
       @toggle-play="togglePlay"
+      @toggle-mute="toggleMute"
     />
 
     <!-- 遮罩层 -->
@@ -29,6 +32,8 @@
         :script-roles="scriptRoles"
         :index="currentDialogueIndex"
         :is-playback-started="isPlaybackStarted"
+        :is-muted="isMuted"
+        :is-paused="isPaused"
         :previous-quest="previousQuestDialogue"
         :poem-show="showPoem"
         :poem-content="currentPoemContent"
@@ -43,8 +48,11 @@
       <InputButtons
         :can-prev="!isAtFirstDialogue"
         :can-next="Boolean(currentDialogue)"
+        :sections="scriptData.sections"
+        :current-section-index="currentSectionIndex"
         @prev="playPrevious"
         @next="handleNextClick"
+        @section-change="handleSectionChange"
       />
     </div>
   </div>
@@ -70,7 +78,17 @@ import DialogCard from './dialog/DialogCard.vue';             // 对话卡片容
 import InputButtons from './engine/InputButtons.vue';         // 底部控制按钮
 
 // 音频播放器钩子
-const { playAudioChunk, stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer();
+const {
+  playAudioChunk,
+  beginStream,
+  markStreamComplete,
+  stopPlayback,
+  pausePlayback,
+  resumePlayback,
+  setOnPlaybackComplete,
+  getIsPlaying,
+  setMuted: setStreamMuted
+} = useAudioPlayer();
 
 // 路由实例
 const router = useRouter();
@@ -108,9 +126,12 @@ const currentSectionIndex = ref(0);       // 当前章节索引
 const currentDialogueIndex = ref(0);      // 当前对话索引
 
 // 播放控制状态
-const isPlaying = ref(false);             // 是否正在播放
+const autoAdvanceEnabled = ref(false);    // 是否开启自动连播
+const isPaused = ref(false);              // 当前媒体是否暂停
+const currentContentEnded = ref(false);   // 当前对话媒体是否已经播放完成
 const showMask = ref(true);               // 是否显示遮罩层
 const isPlaybackStarted = ref(false);     // 播放是否已开始
+const isMuted = ref(localStorage.getItem('ai-course-muted') === 'true');
 
 // 诗词显示状态
 const showPoem = ref(false);              // 是否显示诗词
@@ -122,6 +143,8 @@ const selectedOption = ref('');           // 选中的选项
 // 音频对象
 const backgroundAudio = ref(null);        // 背景音频对象
 const dialogueAudio = ref(null);          // 对话音频对象
+let pendingAdvanceTimer = null;           // 待执行的自动跳转任务
+let pendingRecoveryTimer = null;          // 网络异常后的对话恢复任务
 
 // 会话相关状态
 const activeConversationId = ref(null);               // 活跃会话ID
@@ -199,38 +222,108 @@ const getPreviousDialogue = () => {
   return null;
 };
 
+const clearPendingAdvance = () => {
+  if (pendingAdvanceTimer) {
+    clearTimeout(pendingAdvanceTimer);
+    pendingAdvanceTimer = null;
+  }
+};
+
+const clearPendingRecovery = () => {
+  if (pendingRecoveryTimer) {
+    clearTimeout(pendingRecoveryTimer);
+    pendingRecoveryTimer = null;
+  }
+};
+
+const scheduleAutoAdvance = (delay = 0) => {
+  clearPendingAdvance();
+  if (!autoAdvanceEnabled.value || isPaused.value || isAtLastDialogueOfCourse.value) return;
+
+  const sectionIndex = currentSectionIndex.value;
+  const dialogueIndex = currentDialogueIndex.value;
+  pendingAdvanceTimer = setTimeout(() => {
+    pendingAdvanceTimer = null;
+    const positionUnchanged = sectionIndex === currentSectionIndex.value &&
+      dialogueIndex === currentDialogueIndex.value;
+    if (!positionUnchanged || !autoAdvanceEnabled.value || isPaused.value) return;
+
+    if (!playNext()) {
+      autoAdvanceEnabled.value = false;
+    }
+  }, delay);
+};
+
+const markCurrentContentEnded = (advanceDelay = 0) => {
+  currentContentEnded.value = true;
+  if (isAtLastDialogueOfCourse.value) {
+    autoAdvanceEnabled.value = false;
+    return;
+  }
+  scheduleAutoAdvance(advanceDelay);
+};
+
 /**
- * 停止所有音频播放
+ * 停止并重置所有引擎级音频,用于导航或组件销毁。
  */
 const stopAllAudio = () => {
   if (backgroundAudio.value) {
     backgroundAudio.value.pause();
     backgroundAudio.value.currentTime = 0;
+    backgroundAudio.value = null;
   }
   if (dialogueAudio.value) {
     dialogueAudio.value.pause();
     dialogueAudio.value.currentTime = 0;
+    dialogueAudio.value = null;
+  }
+};
+
+/** 暂停当前媒体但保留播放位置。 */
+const pauseCurrentMedia = () => {
+  backgroundAudio.value?.pause();
+  dialogueAudio.value?.pause();
+  pausePlayback().catch(e => console.error('流式语音暂停失败:', e));
+};
+
+/** 从当前位置继续当前媒体。 */
+const resumeCurrentMedia = () => {
+  if (backgroundAudio.value) {
+    backgroundAudio.value.play().catch(e => console.error('背景音继续播放失败:', e));
+  } else {
+    playBackgroundAudio();
+  }
+
+  if (dialogueAudio.value && !dialogueAudio.value.ended) {
+    dialogueAudio.value.play().catch(e => console.error('对话语音继续播放失败:', e));
   }
+  resumePlayback().catch(e => console.error('流式语音继续播放失败:', e));
 };
 
 /**
  * 播放背景音频
  */
 const playBackgroundAudio = () => {
-  if (backgroundAudio.value) {
-    backgroundAudio.value.pause();
-    backgroundAudio.value.currentTime = 0;
+  const audioUrl = currentSection.value?.backgroundAudio?.url;
+  const shouldPlay = currentBackgroundType.value === 'imageAudio' &&
+    audioUrl && isPlaybackStarted.value && !isPaused.value;
+
+  if (!shouldPlay) {
+    backgroundAudio.value?.pause();
+    return;
   }
 
-  // 背景音频在 isPlaying 或 isPlaybackStarted 状态下播放
-  if (currentBackgroundType.value === 'imageAudio' &&
-      currentSection.value?.backgroundAudio?.url &&
-      (isPlaying.value || isPlaybackStarted.value)) {
-    backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url);
-    backgroundAudio.value.loop = true;
-    backgroundAudio.value.volume = 1;
-    backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e));
+  if (!backgroundAudio.value || backgroundAudio.value._courseSourceUrl !== audioUrl) {
+    backgroundAudio.value?.pause();
+    const audio = new Audio(audioUrl);
+    audio._courseSourceUrl = audioUrl;
+    audio.loop = true;
+    audio.volume = 1;
+    audio.muted = isMuted.value;
+    backgroundAudio.value = audio;
   }
+
+  backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e));
 };
 
 /**
@@ -242,9 +335,10 @@ const playBackgroundVideo = () => {
 
 /**
  * 播放对话语音
- * @param {Boolean} isAutoPlay - 是否自动播放下一条
  */
-const playDialogueAudio = (isAutoPlay = false) => {
+const playDialogueAudio = () => {
+  clearPendingAdvance();
+  currentContentEnded.value = false;
   if (dialogueAudio.value) {
     dialogueAudio.value.pause();
     dialogueAudio.value.currentTime = 0;
@@ -252,51 +346,22 @@ const playDialogueAudio = (isAutoPlay = false) => {
 
   if (currentDialogue.value?.voiceoverUrl && currentDialogue.value?.type !== 'video') {
     const audio = new Audio(currentDialogue.value.voiceoverUrl);
+    audio.muted = isMuted.value;
     dialogueAudio.value = audio;
 
     audio.onended = () => {
-      if (isAtLastDialogueOfCourse.value) {
-        isPlaying.value = false;
-        return;
-      }
-
-      if (isAutoPlay && isPlaying.value) {
-        setTimeout(() => {
-          if (!playNext(true)) {
-            isPlaying.value = false;
-            stopAllAudio();
-          }
-        }, 100);
-      }
+      markCurrentContentEnded(100);
     };
 
+    if (isPaused.value || !isPlaybackStarted.value) return;
     audio.play().catch(e => {
       console.error('对话语音播放失败:', e);
-      if (isAtLastDialogueOfCourse.value) {
-        isPlaying.value = false;
-        return;
-      }
-      if (isAutoPlay && isPlaying.value) {
-        setTimeout(() => {
-          if (!playNext(true)) {
-            isPlaying.value = false;
-            stopAllAudio();
-          }
-        }, 2000);
-      }
+      markCurrentContentEnded(2000);
     });
   } else if (currentDialogue.value?.type !== 'video') {
-    if (isAtLastDialogueOfCourse.value) {
-      isPlaying.value = false;
-      return;
-    }
-    if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
-      setTimeout(() => {
-        if (!playNext(true)) {
-          isPlaying.value = false;
-          stopAllAudio();
-        }
-      }, 2000);
+    if (currentDialogue.value?.type !== 'user' &&
+        !isEvaluationDialogue(currentDialogue.value?.type)) {
+      markCurrentContentEnded(2000);
     }
   }
 };
@@ -305,23 +370,36 @@ const playDialogueAudio = (isAutoPlay = false) => {
  * 切换播放/暂停状态
  */
 const togglePlay = () => {
-  isPlaying.value = !isPlaying.value;
-  if (isPlaying.value) {
-    // 关闭遮罩层(如果显示的话)
-    if (showMask.value) {
-      showMask.value = false;
-      isPlaybackStarted.value = true;
-    }
-    playBackgroundAudio();
-    if (!getIsPlaying() && !conversationInProgress.value) {
-      if (currentDialogue.value?.type === 'video') {
-        // 视频类型由VideoDisplay组件处理
-      } else {
-        playSequence();
-      }
-    }
+  if (autoAdvanceEnabled.value) {
+    autoAdvanceEnabled.value = false;
+    isPaused.value = true;
+    clearPendingAdvance();
+    pauseCurrentMedia();
+    return;
+  }
+
+  autoAdvanceEnabled.value = true;
+  const wasPaused = isPaused.value;
+  isPaused.value = false;
+
+  if (showMask.value) {
+    startPlayback();
+    return;
+  }
+
+  if (wasPaused) {
+    resumeCurrentMedia();
   } else {
-    stopAllAudio();
+    playBackgroundAudio();
+  }
+
+  if (currentContentEnded.value) {
+    scheduleAutoAdvance(100);
+  } else if (!dialogueAudio.value && !getIsPlaying() && !conversationInProgress.value &&
+             currentDialogue.value?.type !== 'video' &&
+             currentDialogue.value?.type !== 'user' &&
+             !isEvaluationDialogue(currentDialogue.value?.type)) {
+    playSequence();
   }
 };
 
@@ -329,21 +407,14 @@ const togglePlay = () => {
  * 处理视频播放完成
  */
 const handleVideoEnded = () => {
-  if (isPlaying.value) {
-    setTimeout(() => {
-      if (!playNext(true)) {
-        isPlaying.value = false;
-        stopAllAudio();
-      }
-    }, 1500);
-  }
+  markCurrentContentEnded(1500);
 };
 
 /**
  * 播放对话序列
  */
 const playSequence = () => {
-  if (!isPlaying.value) return;
+  if (!autoAdvanceEnabled.value || isPaused.value) return;
 
   if (currentDialogue.value?.type === 'user') return;
 
@@ -354,19 +425,14 @@ const playSequence = () => {
     currentPoemContent.value = currentDialogue.value.content;
 
     if (currentDialogue.value?.voiceoverUrl) {
-      playDialogueAudio(true);
+      playDialogueAudio();
     } else {
-      setTimeout(() => {
-        if (!playNext(true)) {
-          isPlaying.value = false;
-          stopAllAudio();
-        }
-      }, 500);
+      markCurrentContentEnded(500);
     }
   } else if (isEvaluationDialogue(currentDialogue.value?.type)) {
     showPoem.value = false;
   } else {
-    playDialogueAudio(true);
+    playDialogueAudio();
   }
 };
 
@@ -376,6 +442,8 @@ const playSequence = () => {
 const playPrevious = () => {
   if (isAtFirstDialogue.value) return;
 
+  clearPendingAdvance();
+  clearPendingRecovery();
   stopAllAudio();
   recoverQuestDialogue();
   stopPlayback(false);
@@ -416,17 +484,64 @@ const playPrevious = () => {
     // 诗词类型播放语音
     if (currentDialogue.value?.type === 'poem') {
       if (currentDialogue.value?.voiceoverUrl) {
-        playDialogueAudio(isPlaying.value);
+        playDialogueAudio();
       } else {
-        // 诗词没有语音,延迟后继续切换上一句
-        if (!isAtFirstDialogue.value) {
-          setTimeout(() => {
-            playPrevious();
-          }, 100);
-        }
+        currentContentEnded.value = true;
+        scheduleAutoAdvance(500);
+      }
+    } else if (!isEvaluationDialogue(currentDialogue.value?.type)) {
+      playDialogueAudio();
+    }
+  });
+};
+
+/**
+ * 切换课程总静音状态。只改变输出音量,不改变任何播放进度。
+ */
+const toggleMute = () => {
+  isMuted.value = !isMuted.value;
+};
+
+/**
+ * 从指定环节的第一条对话开始
+ * @param {Number} sectionIndex - 目标环节索引
+ */
+const handleSectionChange = (sectionIndex) => {
+  const targetSection = props.scriptData.sections?.[sectionIndex];
+  if (!targetSection?.dialogues?.length) return;
+  if (sectionIndex === currentSectionIndex.value && currentDialogueIndex.value === 0) return;
+
+  clearPendingAdvance();
+  clearPendingRecovery();
+  stopAllAudio();
+  recoverQuestDialogue();
+  stopPlayback(false);
+  if (conversationInProgress.value) {
+    stopStream();
+  }
+
+  currentSectionIndex.value = sectionIndex;
+  currentDialogueIndex.value = 0;
+  currentContentEnded.value = false;
+  showPoem.value = false;
+  currentPoemContent.value = '';
+
+  nextTick(() => {
+    // 初始遮罩未点击时只切换展示位置,不提前播放内容。
+    if (!isPlaybackStarted.value) return;
+
+    if (currentDialogue.value?.type === 'poem') {
+      showPoem.value = true;
+      currentPoemContent.value = currentDialogue.value.content;
+      if (currentDialogue.value?.voiceoverUrl) {
+        playDialogueAudio();
+      } else {
+        markCurrentContentEnded(500);
       }
+    } else if (isEvaluationDialogue(currentDialogue.value?.type)) {
+      showPoem.value = false;
     } else {
-      playDialogueAudio(isPlaying.value);
+      playDialogueAudio();
     }
   });
 };
@@ -442,17 +557,19 @@ const handleNextClick = () => {
     return;
   }
 
-  playNext(false);
+  playNext();
 };
 
 /**
  * 播放下一条对话
- * @param {Boolean} isAutoPlay - 是否自动播放
  * @returns {Boolean} 是否成功切换
  */
-const playNext = (isAutoPlay = false) => {
+const playNext = () => {
   if (isAtLastDialogueOfCourse.value) return false;
 
+  clearPendingAdvance();
+  clearPendingRecovery();
+  currentContentEnded.value = false;
   if (dialogueAudio.value) {
     dialogueAudio.value.pause();
     dialogueAudio.value.currentTime = 0;
@@ -474,12 +591,9 @@ const playNext = (isAutoPlay = false) => {
       currentPoemContent.value = currentDialogue.value.content;
 
       if (currentDialogue.value?.voiceoverUrl) {
-        playDialogueAudio(isPlaying.value);
+        playDialogueAudio();
       } else {
-        // 诗词没有语音,延迟后继续切换下一句
-        setTimeout(() => {
-          playNext(isAutoPlay);
-        }, 100);
+        markCurrentContentEnded(500);
       }
       return true;
     }
@@ -490,7 +604,7 @@ const playNext = (isAutoPlay = false) => {
       return true;
     }
 
-    playDialogueAudio(isPlaying.value);
+    playDialogueAudio();
     return true;
   } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
     currentSectionIndex.value++;
@@ -505,16 +619,14 @@ const playNext = (isAutoPlay = false) => {
         currentPoemContent.value = currentDialogue.value.content;
 
         if (currentDialogue.value?.voiceoverUrl) {
-          playDialogueAudio(isPlaying.value);
+          playDialogueAudio();
         } else {
-          setTimeout(() => {
-            playNext(isAutoPlay);
-          }, 100);
+          markCurrentContentEnded(500);
         }
       } else if (isEvaluationDialogue(currentDialogue.value?.type)) {
         showPoem.value = false;
       } else {
-        playDialogueAudio(isPlaying.value);
+        playDialogueAudio();
       }
     });
     return true;
@@ -526,7 +638,10 @@ const playNext = (isAutoPlay = false) => {
  * 返回主页面 - 触发父组件的 goBack 方法
  */
 const goBackToMain = () => {
+  clearPendingAdvance();
+  clearPendingRecovery();
   stopAllAudio();
+  stopPlayback(false);
   emit('goBack');
 };
 
@@ -535,6 +650,8 @@ const goBackToMain = () => {
  */
 const startPlayback = () => {
   isPlaybackStarted.value = true;
+  isPaused.value = false;
+  currentContentEnded.value = false;
   showMask.value = false;
 
   // 启动背景音频
@@ -547,9 +664,7 @@ const startPlayback = () => {
     if (currentDialogue.value?.voiceoverUrl) {
       playDialogueAudio();
     } else {
-      setTimeout(() => {
-        playNext();
-      }, 500);
+      markCurrentContentEnded(500);
     }
   } else if (isEvaluationDialogue(currentDialogue.value?.type)) {
     showPoem.value = false;
@@ -724,8 +839,14 @@ const showQuestAnswerDialogue = (roleName) => {
  */
 const delayRecoverQuestDialogue = () => {
   currentDialogue.value.content = "当前网络无反应,请稍后重试!";
-  setTimeout(() => {
-    recoverQuestDialogue();
+  clearPendingRecovery();
+  const sectionIndex = currentSectionIndex.value;
+  const dialogueIndex = currentDialogueIndex.value;
+  pendingRecoveryTimer = setTimeout(() => {
+    pendingRecoveryTimer = null;
+    if (sectionIndex === currentSectionIndex.value && dialogueIndex === currentDialogueIndex.value) {
+      recoverQuestDialogue();
+    }
   }, 1500);
 };
 
@@ -771,6 +892,7 @@ const handlePoemReadingComplete = (result) => {
     currentDialogue.value.content = result.content;
     currentDialogue.value.voiceoverUrl = result.audioUrl;
   }
+  markCurrentContentEnded(300);
 };
 
 /**
@@ -778,6 +900,8 @@ const handlePoemReadingComplete = (result) => {
  * @param {Object} userMessage - 用户消息
  */
 const doSendMessageStream = async (userMessage) => {
+  beginStream();
+  currentContentEnded.value = false;
   conversationInAbortController.value = new AbortController();
   conversationInProgress.value = true;
   receiveMessageFullText.value = '';
@@ -797,6 +921,7 @@ const doSendMessageStream = async (userMessage) => {
         if (code !== 0) {
           console.log(`对话异常! ${msg}`);
           stopStream();
+          stopPlayback(false);
           delayRecoverQuestDialogue();
           return;
         }
@@ -816,20 +941,20 @@ const doSendMessageStream = async (userMessage) => {
       (error) => {
         console.log(`对话异常! ${error}`);
         stopStream();
+        stopPlayback(false);
         delayRecoverQuestDialogue();
         throw error;
       },
       () => {
         console.log(`结束对话!`);
+        markStreamComplete();
         stopStream();
-        if (isAtLastDialogueOfCourse.value && currentDialogue.value?.type === 'digital') {
-          isPlaying.value = false;
-        }
       }
     );
   } catch (error) {
     console.error('发送消息失败:', error);
     stopStream();
+    stopPlayback(false);
     delayRecoverQuestDialogue();
   }
 };
@@ -850,23 +975,7 @@ const stopStream = async () => {
  */
 const handleAudioPlaybackComplete = () => {
   console.log('智能问答音频播放完成');
-  setOnPlaybackComplete(null);
-  stopAllAudio();
-
-  if (isAtLastDialogueOfCourse.value) {
-    isPlaying.value = false;
-    return;
-  }
-
-  if (isPlaying.value) {
-    setOnPlaybackComplete(handleAudioPlaybackComplete);
-    if (playNext(true)) {
-      // playNext 内部已调用 playDialogueAudio
-    } else {
-      isPlaying.value = false;
-      stopAllAudio();
-    }
-  }
+  markCurrentContentEnded(100);
 };
 
 /** 生命周期钩子与监听 **/
@@ -878,11 +987,24 @@ watch(currentSectionIndex, () => {
   playBackgroundAudio();
 });
 
+watch(isMuted, (muted) => {
+  if (backgroundAudio.value) {
+    backgroundAudio.value.muted = muted;
+  }
+  if (dialogueAudio.value) {
+    dialogueAudio.value.muted = muted;
+  }
+  setStreamMuted(muted);
+  localStorage.setItem('ai-course-muted', String(muted));
+}, { immediate: true });
+
 /**
  * 监听剧本数据变化 - 重置所有状态
  */
 watch(() => props.scriptData, (newVal, oldVal) => {
   if (newVal && oldVal && newVal !== oldVal) {
+    clearPendingAdvance();
+    clearPendingRecovery();
     stopAllAudio();
     recoverQuestDialogue();
     stopPlayback(false);
@@ -891,7 +1013,10 @@ watch(() => props.scriptData, (newVal, oldVal) => {
     }
     currentSectionIndex.value = 0;
     currentDialogueIndex.value = 0;
-    isPlaying.value = false;
+    autoAdvanceEnabled.value = false;
+    isPaused.value = false;
+    resumePlayback().catch(() => {});
+    currentContentEnded.value = false;
     showMask.value = true;
     isPlaybackStarted.value = false;
     currentDialogueCache.value = null;
@@ -914,6 +1039,8 @@ onMounted(() => {
  */
 onUnmounted(() => {
   window.removeEventListener('keydown', handleKeydown);
+  clearPendingAdvance();
+  clearPendingRecovery();
   stopAllAudio();
   stopPlayback(false);
 });

+ 14 - 0
src/components/aiCourse/dialog/DialogCard.vue

@@ -33,6 +33,8 @@
       v-else-if="dialogue?.type === 'video'"
       :video-url="dialogue?.videoUrl"
       :is-playback-started="isPlaybackStarted"
+      :is-muted="isMuted"
+      :is-paused="isPaused"
       @ended="$emit('video-ended', $event)"
     />
 
@@ -41,6 +43,8 @@
         v-if="dialogue?.type === 'poem_reading'"
         :content="dialogue?.content"
         :role-id="getRoleIdByRoleName(dialogue?.roleName, scriptRoles)"
+        :is-muted="isMuted"
+        :is-paused="isPaused"
         @evaluation-complete="$emit('poem-reading-complete', $event)"
     />
 
@@ -56,6 +60,8 @@
         v-if="dialogue?.type === 'recite_reading'"
         :content="dialogue?.content"
         :role-id="getRoleIdByRoleName(dialogue?.roleName, scriptRoles)"
+        :is-muted="isMuted"
+        :is-paused="isPaused"
         @evaluation-complete="$emit('poem-reading-complete', $event)"
     />
 
@@ -108,6 +114,14 @@ defineProps({
     type: Boolean,
     default: false
   },
+  isMuted: {
+    type: Boolean,
+    default: false
+  },
+  isPaused: {
+    type: Boolean,
+    default: false
+  },
   previousQuest: {
     type: Object,
     default: null

+ 62 - 4
src/components/aiCourse/dialog/dialogType/PoemReadingDialogue.vue

@@ -51,23 +51,36 @@
       </div>
       <audio
         v-if="evaluationResult.audioUrl"
+        ref="resultAudioRef"
         :src="evaluationResult.audioUrl"
+        :muted="isMuted"
         controls
         class="result-audio"
         @ended="handleAudioEnded"
+        @play="handleResultAudioPlay"
+        @volumechange="handleResultAudioVolumeChange"
       />
     </div>
 </template>
 
 <script setup>
-import { ref, computed, onUnmounted } from 'vue';
+import { ref, computed, watch, onUnmounted } from 'vue';
 import { ElMessage } from 'element-plus';
 import LiveWaveform from '../../../ai/voice/LiveWaveform.vue';
 import PoemDisplay from './PoemDisplay.vue';
 import { evaluateReading } from '@/api/audio.js';
 import { useAudioPlayer } from '@/api/tts/useAudioPlayer.js';
 // 音频播放器钩子
-const { playAudioChunk } = useAudioPlayer();
+const {
+  playAudioChunk,
+  beginStream,
+  markStreamComplete,
+  stopPlayback,
+  pausePlayback,
+  resumePlayback,
+  setOnPlaybackComplete,
+  setMuted: setStreamMuted
+} = useAudioPlayer();
 
 const props = defineProps({
   content: {
@@ -89,10 +102,40 @@ const props = defineProps({
   resultTitle: {
     type: String,
     default: '朗读评价'
+  },
+  isMuted: {
+    type: Boolean,
+    default: false
+  },
+  isPaused: {
+    type: Boolean,
+    default: false
   }
 });
 
+watch(() => props.isMuted, (muted) => {
+  setStreamMuted(muted);
+}, { immediate: true });
+
 const emit = defineEmits(['evaluationComplete']);
+const resultAudioRef = ref(null);
+const pendingEvaluationResult = ref(null);
+
+setOnPlaybackComplete(() => {
+  if (!pendingEvaluationResult.value) return;
+  const result = pendingEvaluationResult.value;
+  pendingEvaluationResult.value = null;
+  emit('evaluationComplete', result);
+});
+
+watch(() => props.isPaused, async (paused) => {
+  if (paused) {
+    await pausePlayback();
+    resultAudioRef.value?.pause();
+  } else {
+    await resumePlayback();
+  }
+}, { immediate: true });
 
 // 状态管理
 const isRecording = ref(false);
@@ -248,6 +291,8 @@ const convertBlobToBase64AndEvaluate = async () => {
 
   isEvaluating.value = true;
   displayedResult.value = '';
+  pendingEvaluationResult.value = null;
+  beginStream();
 
   try {
     const base64Audio = await blobToBase64(recordedBlob.value);
@@ -367,8 +412,8 @@ const handleStreamData = async (data) => {
     }
     evaluationResult.value.score = data.score;
     evaluationResult.value.content = displayedResult.value;
-
-    emit('evaluationComplete', evaluationResult.value);
+    pendingEvaluationResult.value = { ...evaluationResult.value };
+    markStreamComplete();
   }
 };
 
@@ -377,6 +422,18 @@ const handleAudioEnded = () => {
   // 音频播放完成后的处理
 };
 
+const handleResultAudioVolumeChange = (event) => {
+  if (props.isMuted && !event.currentTarget.muted) {
+    event.currentTarget.muted = true;
+  }
+};
+
+const handleResultAudioPlay = (event) => {
+  if (props.isPaused) {
+    event.currentTarget.pause();
+  }
+};
+
 // 重置状态
 const resetState = () => {
   isEvaluating.value = false;
@@ -385,6 +442,7 @@ const resetState = () => {
 // 组件卸载时清理
 onUnmounted(() => {
   stopRecording();
+  stopPlayback(false);
   if (abortController.value) {
     abortController.value.abort();
   }

+ 10 - 0
src/components/aiCourse/dialog/dialogType/ReciteReadingDialogue.vue

@@ -2,6 +2,8 @@
   <PoemReadingDialogue
     :content="content"
     :role-id="roleId"
+    :is-muted="isMuted"
+    :is-paused="isPaused"
     :show-poem="false"
     :evaluation-prompt="evaluationPrompt"
     result-title="背诵评价"
@@ -21,6 +23,14 @@ const props = defineProps({
   roleId: {
     type: [Number, String],
     default: 10
+  },
+  isMuted: {
+    type: Boolean,
+    default: false
+  },
+  isPaused: {
+    type: Boolean,
+    default: false
   }
 });
 

+ 43 - 2
src/components/aiCourse/dialog/dialogType/VideoDisplay.vue

@@ -6,8 +6,11 @@
         class="dialogue-video"
         ref="videoRef"
         controls
+        :muted="isMuted"
         preload="metadata"
         @ended="$emit('ended')"
+        @play="handlePlay"
+        @volumechange="handleVolumeChange"
         @contextmenu.prevent
         controlslist="nodownload"
       >
@@ -38,6 +41,14 @@ const props = defineProps({
   isPlaybackStarted: {
     type: Boolean,
     default: false
+  },
+  isMuted: {
+    type: Boolean,
+    default: false
+  },
+  isPaused: {
+    type: Boolean,
+    default: false
   }
 });
 
@@ -53,16 +64,37 @@ const videoRef = ref(null);
  * 根据课程播放状态控制视频,初始遮罩未点击时保持暂停
  */
 onMounted(() => {
-  if (videoRef.value && props.videoUrl && props.isPlaybackStarted) {
+  if (!videoRef.value) return;
+
+  videoRef.value.muted = props.isMuted;
+  if (props.videoUrl && props.isPlaybackStarted && !props.isPaused) {
     videoRef.value.play().catch(e => console.error('对话视频播放失败:', e));
   }
 });
 
+watch(() => props.isMuted, (muted) => {
+  if (videoRef.value) {
+    videoRef.value.muted = muted;
+  }
+});
+
+const handleVolumeChange = (event) => {
+  if (props.isMuted && !event.currentTarget.muted) {
+    event.currentTarget.muted = true;
+  }
+};
+
+const handlePlay = (event) => {
+  if (props.isPaused) {
+    event.currentTarget.pause();
+  }
+};
+
 watch(() => props.isPlaybackStarted, (started) => {
   nextTick(() => {
     if (!videoRef.value) return;
 
-    if (started && props.videoUrl) {
+    if (started && props.videoUrl && !props.isPaused) {
       videoRef.value.play().catch(e => console.error('对话视频播放失败:', e));
     } else {
       videoRef.value.pause();
@@ -70,6 +102,15 @@ watch(() => props.isPlaybackStarted, (started) => {
   });
 });
 
+watch(() => props.isPaused, (paused) => {
+  if (!videoRef.value || !props.isPlaybackStarted) return;
+  if (paused) {
+    videoRef.value.pause();
+  } else if (!videoRef.value.ended && props.videoUrl) {
+    videoRef.value.play().catch(e => console.error('对话视频继续播放失败:', e));
+  }
+});
+
 /**
  * 组件卸载时清理视频
  */

+ 10 - 10
src/components/aiCourse/engine/DialogBackground.vue

@@ -35,8 +35,8 @@ import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
  * @prop {String} backgroundType - 背景类型:imageAudio | video
  * @prop {String} imageUrl - 背景图URL
  * @prop {String} videoUrl - 背景视频URL
- * @prop {Boolean} isPlaying - 是否正在播放
  * @prop {Boolean} isPlaybackStarted - 是否已开始播放(用户点击播放按钮后变为true)
+ * @prop {Boolean} isPaused - 课程是否处于暂停状态
  */
 const props = defineProps({
   backgroundType: {
@@ -51,11 +51,11 @@ const props = defineProps({
     type: String,
     default: ''
   },
-  isPlaying: {
+  isPlaybackStarted: {
     type: Boolean,
     default: false
   },
-  isPlaybackStarted: {
+  isPaused: {
     type: Boolean,
     default: false
   }
@@ -83,7 +83,7 @@ const pauseVideo = () => {
 
 // 监听 videoUrl 变化,重新播放视频(仅在已开始播放状态下)
 watch(() => props.videoUrl, (newUrl) => {
-  if (newUrl && props.backgroundType === 'video' && props.isPlaybackStarted) {
+  if (newUrl && props.backgroundType === 'video' && props.isPlaybackStarted && !props.isPaused) {
     nextTick(() => {
       playVideo();
     });
@@ -92,16 +92,16 @@ watch(() => props.videoUrl, (newUrl) => {
 
 // 监听 backgroundType 变化(仅在已开始播放状态下)
 watch(() => props.backgroundType, (newType) => {
-  if (newType === 'video' && props.videoUrl && props.isPlaybackStarted) {
+  if (newType === 'video' && props.videoUrl && props.isPlaybackStarted && !props.isPaused) {
     nextTick(() => {
       playVideo();
     });
   }
 });
 
-// 监听 isPlaying 变化
-watch(() => props.isPlaying, (newVal) => {
-  if (newVal) {
+// 监听课程暂停状态
+watch(() => props.isPaused, (paused) => {
+  if (!paused && props.isPlaybackStarted) {
     playVideo();
   } else {
     pauseVideo();
@@ -110,7 +110,7 @@ watch(() => props.isPlaying, (newVal) => {
 
 // 监听 isPlaybackStarted 变化 - 用户点击播放按钮后开始播放背景视频
 watch(() => props.isPlaybackStarted, (started) => {
-  if (started && props.backgroundType === 'video' && props.videoUrl) {
+  if (started && !props.isPaused && props.backgroundType === 'video' && props.videoUrl) {
     nextTick(() => {
       playVideo();
     });
@@ -153,4 +153,4 @@ onUnmounted(() => {
   height: 100%;
   object-fit: cover;
 }
-</style>
+</style>

+ 86 - 6
src/components/aiCourse/engine/DialogHeader.vue

@@ -13,11 +13,37 @@
         {{ title }}
       </div>
     </div>
-    <!-- 自动按钮 -->
+    <!-- 声音及自动播放按钮 -->
     <div class="title-right">
-      <div class="box-icon" @click="$emit('toggle-play')">
-        <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
-      </div>
+      <button
+        type="button"
+        class="box-icon sound-toggle"
+        :class="{ muted: isMuted }"
+        :title="isMuted ? '开启声音' : '关闭声音'"
+        :aria-label="isMuted ? '开启声音' : '关闭声音'"
+        @click="$emit('toggle-mute')"
+      >
+        <svg
+          class="sound-icon"
+          viewBox="0 0 24 24"
+          aria-hidden="true"
+        >
+          <path d="M4 9v6h4l5 4V5L8 9H4Z" />
+          <path d="M16 9a4 4 0 0 1 0 6" />
+          <path d="M18.5 6.5a7.5 7.5 0 0 1 0 11" />
+        </svg>
+        <span v-if="isMuted" class="mute-slash" aria-hidden="true"></span>
+      </button>
+      <button
+        type="button"
+        class="box-icon play-toggle"
+        :class="{ playing: isPlaying, paused: isPaused }"
+        :aria-label="isPlaying ? '暂停自动播放' : isPaused ? '继续自动播放' : '开启自动播放'"
+        :aria-pressed="isPlaying"
+        @click="$emit('toggle-play')"
+      >
+        <span class="play-text">{{ isPlaying ? '暂停' : isPaused ? '继续' : '自动' }}</span>
+      </button>
     </div>
   </div>
 </template>
@@ -48,6 +74,14 @@ defineProps({
   isPlaying: {
     type: Boolean,
     default: false
+  },
+  isPaused: {
+    type: Boolean,
+    default: false
+  },
+  isMuted: {
+    type: Boolean,
+    default: false
   }
 });
 
@@ -56,7 +90,7 @@ defineProps({
  * @event back - 返回课程
  * @event toggle-play - 切换播放状态
  */
-defineEmits(['back', 'toggle-play']);
+defineEmits(['back', 'toggle-play', 'toggle-mute']);
 </script>
 
 <style scoped lang="scss">
@@ -176,5 +210,51 @@ defineEmits(['back', 'toggle-play']);
     color: #0064BE;
     font-weight: 500;
   }
+
+  .play-toggle {
+    appearance: none;
+
+    &.paused {
+      border-color: rgba(230, 140, 30, 0.8);
+      background: linear-gradient(135deg, #FFE6B8, #FFC76A);
+    }
+  }
+
+  .sound-toggle {
+    position: relative;
+    min-width: rpx(30);
+    justify-content: center;
+    padding: rpx(5);
+    border: rpx(1) solid rgba(0, 100, 192);
+
+    &.muted {
+      color: #8A3B3B;
+      border-color: rgba(138, 59, 59, 0.65);
+      background: linear-gradient(135deg, #F6D5D5, #E8A5A5);
+    }
+  }
+
+  .sound-icon {
+    width: rpx(13);
+    height: rpx(13);
+    fill: none;
+    stroke: currentColor;
+    stroke-width: 1.8;
+    stroke-linecap: round;
+    stroke-linejoin: round;
+  }
+
+  .mute-slash {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: rpx(17);
+    height: rpx(2);
+    border-radius: rpx(2);
+    background: #E53935;
+    box-shadow: 0 0 rpx(2) rgba(255, 255, 255, 0.8);
+    transform: translate(-50%, -50%) rotate(-45deg);
+    pointer-events: none;
+  }
 }
-</style>
+</style>

+ 190 - 11
src/components/aiCourse/engine/InputButtons.vue

@@ -1,17 +1,52 @@
 <template>
   <div class="input-buttons-container">
-    <!-- 上一个对话按钮 -->
-    <div class="arrow-icon-circle" @click="handlePrev" :class="{ 'disabled': !canPrev }">
-      <el-icon class="arrow-icon"><CaretLeft /></el-icon>
+    <div class="arrow-buttons">
+      <!-- 上一个对话按钮 -->
+      <div class="arrow-icon-circle" @click="handlePrev" :class="{ 'disabled': !canPrev }">
+        <el-icon class="arrow-icon"><CaretLeft /></el-icon>
+      </div>
+      <!-- 下一个对话按钮 -->
+      <div class="arrow-icon-circle" @click="handleNext" :class="{ 'disabled': !canNext }">
+        <el-icon class="arrow-icon"><CaretRight /></el-icon>
+      </div>
     </div>
-    <!-- 下一个对话按钮 -->
-    <div class="arrow-icon-circle" @click="handleNext" :class="{ 'disabled': !canNext }">
-      <el-icon class="arrow-icon"><CaretRight /></el-icon>
+
+    <div v-if="sections.length" class="section-progress-scroll">
+      <div
+        class="section-progress"
+        :style="{
+          '--section-count': Math.max(sections.length, 1),
+          minWidth: `max(100%, ${sections.length * 9.6}vw)`
+        }"
+      >
+        <div v-if="sections.length > 1" class="progress-rail">
+          <div class="progress-rail-active" :style="{ width: progressPercent }"></div>
+        </div>
+
+        <button
+          v-for="(section, index) in sections"
+          :key="section.id ?? section.name ?? index"
+          type="button"
+          class="section-node"
+          :class="{
+            completed: index < currentSectionIndex,
+            active: index === currentSectionIndex
+          }"
+          :disabled="!section.dialogues?.length"
+          :title="getSectionName(section, index)"
+          :aria-current="index === currentSectionIndex ? 'step' : undefined"
+          @click="handleSectionChange(index)"
+        >
+          <span class="section-dot"></span>
+          <span class="section-name">{{ getSectionName(section, index) }}</span>
+        </button>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
+import { computed } from 'vue';
 import { CaretLeft, CaretRight } from '@element-plus/icons-vue';
 
 /**
@@ -32,6 +67,14 @@ const props = defineProps({
   canNext: {
     type: Boolean,
     default: true
+  },
+  sections: {
+    type: Array,
+    default: () => []
+  },
+  currentSectionIndex: {
+    type: Number,
+    default: 0
   }
 });
 
@@ -40,7 +83,16 @@ const props = defineProps({
  * @event prev - 切换到上一句
  * @event next - 切换到下一句
  */
-const emit = defineEmits(['prev', 'next']);
+const emit = defineEmits(['prev', 'next', 'section-change']);
+
+const progressPercent = computed(() => {
+  if (props.sections.length <= 1) return '0%';
+  return `${(props.currentSectionIndex / (props.sections.length - 1)) * 100}%`;
+});
+
+const getSectionName = (section, index) => {
+  return section?.name || section?.sectionName || `环节${index + 1}`;
+};
 
 const handlePrev = () => {
   if (props.canPrev) emit('prev');
@@ -49,6 +101,12 @@ const handlePrev = () => {
 const handleNext = () => {
   if (props.canNext) emit('next');
 };
+
+const handleSectionChange = (index) => {
+  if (props.sections[index]?.dialogues?.length) {
+    emit('section-change', index);
+  }
+};
 </script>
 
 <style scoped lang="scss">
@@ -64,17 +122,29 @@ const handleNext = () => {
   left: 0;
   right: 0;
   display: flex;
+  flex-direction: column;
   align-items: center;
   justify-content: center;
   width: 100%;
-  z-index: 10;
+  // 高于视频、对话卡片等内容;外层 content-box 仍低于初始播放遮罩。
+  z-index: 30;
   transition: all 0.3s ease;
-  gap: rpx(20);
+  gap: rpx(7);
   margin-bottom: 0;
-  padding-bottom: rpx(10);
+  padding: 0 rpx(16) rpx(7);
+  box-sizing: border-box;
 }
 
-  .arrow-icon-circle {
+.arrow-buttons {
+  position: relative;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: rpx(20);
+}
+
+.arrow-icon-circle {
     width: rpx(20);
     height: rpx(20);
     border-radius: 50%;
@@ -106,6 +176,115 @@ const handleNext = () => {
       font-size: rpx(15);
       color: #0064BE;
     }
+}
+
+.section-progress-scroll {
+  width: 100%;
+  overflow-x: auto;
+  overflow-y: hidden;
+  padding: rpx(2) rpx(4) 0;
+  scrollbar-width: none;
+  background-color: #0000006e;
+  border-radius: 30px;
+
+  &::-webkit-scrollbar {
+    display: none;
   }
+}
+
+.section-progress {
+  --section-count: 1;
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(var(--section-count), minmax(rpx(72), 1fr));
+  align-items: start;
+}
+
+.progress-rail {
+  position: absolute;
+  top: rpx(6);
+  left: calc(50% / var(--section-count));
+  right: calc(50% / var(--section-count));
+  height: rpx(2);
+  overflow: hidden;
+  border-radius: rpx(2);
+  background: rgba(255, 255, 255, 0.42);
+  box-shadow: 0 0 rpx(4) rgba(0, 0, 0, 0.2);
+}
+
+.progress-rail-active {
+  height: 100%;
+  border-radius: inherit;
+  background: linear-gradient(90deg, #50BEF0, #F59E0B);
+  transition: width 0.35s ease;
+}
+
+.section-node {
+  position: relative;
+  z-index: 1;
+  min-width: 0;
+  padding: 0 rpx(3);
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: rgba(255, 255, 255, 0.72);
+  cursor: pointer;
+  font-family: inherit;
+
+  &:hover:not(:disabled) .section-dot {
+    transform: scale(1.2);
+    box-shadow: 0 0 rpx(7) rgba(80, 190, 240, 0.8);
+  }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+  }
+
+  &.completed {
+    color: #D9F5FF;
+
+    .section-dot {
+      border-color: #50BEF0;
+      background: #50BEF0;
+    }
+  }
+
+  &.active {
+    color: #FFF4D6;
+    font-weight: 700;
+
+    .section-dot {
+      border-color: #FFF4D6;
+      background: #F59E0B;
+      transform: scale(1.25);
+      box-shadow: 0 0 rpx(8) rgba(245, 158, 11, 0.8);
+    }
+  }
+}
+
+.section-dot {
+  display: block;
+  width: rpx(10);
+  height: rpx(10);
+  margin: 0 auto rpx(3);
+  box-sizing: border-box;
+  border: rpx(2) solid rgba(255, 255, 255, 0.78);
+  border-radius: 50%;
+  background: #667085;
+  transition: all 0.25s ease;
+}
+
+.section-name {
+  display: block;
+  overflow: hidden;
+  color: inherit;
+  font-size: rpx(7);
+  line-height: 1.25;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.65);
+}
 
 </style>