ソースを参照

优化AI对话课程引擎组件,拆分和整理架构,重新梳理逻辑

liyanbo 2 時間 前
コミット
73e385f545

+ 876 - 0
src/components/aiCourse/DialogEngine.vue

@@ -0,0 +1,876 @@
+<template>
+  <div class="dialog-engine">
+    <!-- 背景层 -->
+    <DialogBackground
+      :background-type="currentBackgroundType"
+      :image-url="currentBackgroundImage"
+      :video-url="currentBackgroundVideo"
+      :is-playing="isPlaying"
+      :is-playback-started="isPlaybackStarted"
+    />
+
+    <!-- 标题栏 -->
+    <DialogHeader
+      :title="currentSection?.name"
+      :back-text="backText"
+      :is-playing="isPlaying"
+      @back="goBackToMain"
+      @toggle-play="togglePlay"
+    />
+
+    <!-- 遮罩层 -->
+    <DialogMask v-if="showMask" @start="startPlayback" />
+
+    <!-- 内容区域 -->
+    <div class="content-box">
+      <!-- 对话卡片(统一入口,根据类型显示不同内容) -->
+      <DialogCard
+        :dialogue="currentDialogue"
+        :script-roles="scriptRoles"
+        :index="currentDialogueIndex"
+        :previous-quest="previousQuestDialogue"
+        :poem-show="showPoem"
+        :poem-content="currentPoemContent"
+        @user-input-submit="handleUserInputSubmit"
+        @single-choice-submit="handleSingleChoiceSubmit"
+        @video-ended="handleVideoEnded"
+      />
+
+      <!-- 控制按钮 -->
+      <InputButtons
+        :can-prev="!isAtFirstDialogue"
+        :can-next="!isAtLastDialogue && !isUserInputWaiting"
+        :show-voice="showVoiceInput"
+        :is-recording="isVoiceRecording"
+        @prev="playPrevious"
+        @next="playNext"
+        @voice-recognized="handleVoiceRecognized"
+        @recording-status-changed="handleRecordingStatusChanged"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+/**
+ * DialogEngine - 对话引擎核心组件
+ * 负责管理对话流程、音频播放、状态控制
+ */
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
+import { useRouter } from 'vue-router';
+import { marked } from 'marked';
+import { CreateDialogue, sendChatMessageStream } from "@/api/questions.js";
+import { useAudioPlayer } from "@/api/tts/useAudioPlayer.js";
+
+// 子组件导入
+import DialogBackground from './engine/DialogBackground.vue'; // 背景层(图片/视频)
+import DialogHeader from './engine/DialogHeader.vue';         // 标题栏(返回/播放控制)
+import DialogMask from './engine/DialogMask.vue';             // 遮罩层(初始播放按钮)
+import DialogCard from './dialog/DialogCard.vue';             // 对话卡片容器
+import InputButtons from './engine/InputButtons.vue';         // 底部控制按钮
+
+// 音频播放器钩子
+const { playAudioChunk, stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer();
+
+// 路由实例
+const router = useRouter();
+
+/**
+ * Props 定义
+ * @prop {Object} scriptData - 剧本数据(包含章节和对话)
+ * @prop {Array} scriptRoles - 角色列表
+ * @prop {String} backText - 返回按钮文本
+ * @prop {Boolean} isLastCourse - 是否为最后一节课程
+ */
+const props = defineProps({
+  scriptData: {
+    type: Object,
+    default: () => ({ title: "", sections: [] })
+  },
+  scriptRoles: {
+    type: Array,
+    default: () => []
+  },
+  backText: {
+    type: String,
+    default: '返回课程'
+  },
+  isLastCourse: {
+    type: Boolean,
+    default: false
+  }
+});
+
+// 事件定义
+const emit = defineEmits(['dialogueEnded']);
+
+/** 状态定义 **/
+// 对话位置状态
+const currentSectionIndex = ref(0);       // 当前章节索引
+const currentDialogueIndex = ref(0);      // 当前对话索引
+
+// 播放控制状态
+const isPlaying = ref(false);             // 是否正在播放
+const showMask = ref(true);               // 是否显示遮罩层
+const isPlaybackStarted = ref(false);     // 播放是否已开始
+
+// 语音输入状态
+const isVoiceRecording = ref(false);      // 是否正在录音
+const voiceRecognizedText = ref("");      // 语音识别结果
+
+// 诗词显示状态
+const showPoem = ref(false);              // 是否显示诗词
+const currentPoemContent = ref('');       // 当前诗词内容
+
+// 用户选择状态
+const selectedOption = ref('');           // 选中的选项
+
+// 音频对象
+const backgroundAudio = ref(null);        // 背景音频对象
+const dialogueAudio = ref(null);          // 对话音频对象
+
+// 会话相关状态
+const activeConversationId = ref(null);               // 活跃会话ID
+const conversationInProgress = ref(false);            // 是否有会话进行中
+const conversationInAbortController = ref();         // 会话中止控制器
+const receiveMessageFullText = ref('');               // 接收到的完整消息
+const currentDialogueCache = ref(null);               // 当前对话缓存
+
+/** 计算属性 **/
+// 当前章节数据
+const currentSection = computed(() => props.scriptData.sections[currentSectionIndex.value]);
+
+// 当前对话数据
+const currentDialogue = computed(() => {
+  if (!currentSection.value) return null;
+  return currentSection.value.dialogues[currentDialogueIndex.value];
+});
+
+// 当前背景类型(图片音频/视频)
+const currentBackgroundType = computed(() => currentSection.value?.backgroundType || 'imageAudio');
+
+// 当前背景图片URL
+const currentBackgroundImage = computed(() => {
+  if (!currentSection.value?.backgroundImage?.url) return '';
+  return currentSection.value.backgroundImage.url;
+});
+
+// 当前背景视频URL
+const currentBackgroundVideo = computed(() => {
+  if (!currentSection.value?.backgroundVideo?.url) return '';
+  return currentSection.value.backgroundVideo.url;
+});
+
+// 上一个提问类型对话
+const previousQuestDialogue = computed(() => {
+  const prev = getPreviousDialogue();
+  if (prev?.type === 'quest') return prev;
+  return null;
+});
+
+// 是否显示语音输入
+const showVoiceInput = computed(() => {
+  if (currentDialogue.value?.type !== 'user') return false;
+  const prev = previousQuestDialogue.value;
+  return prev?.questionType !== 'singleChoice'; // 非单选问题时显示语音输入
+});
+
+// 是否等待用户输入
+const isUserInputWaiting = computed(() => currentDialogue.value?.type === 'user');
+
+// 是否在第一个对话
+const isAtFirstDialogue = computed(() => {
+  return currentSectionIndex.value === 0 && currentDialogueIndex.value === 0;
+});
+
+// 是否在最后一个对话
+const isAtLastDialogue = computed(() => {
+  if (!props.scriptData.sections?.length) return false;
+  const lastSectionIdx = props.scriptData.sections.length - 1;
+  const lastSection = props.scriptData.sections[lastSectionIdx];
+  if (!lastSection?.dialogues?.length) return false;
+  return currentSectionIndex.value === lastSectionIdx &&
+    currentDialogueIndex.value === lastSection.dialogues.length - 1;
+});
+
+/** 方法定义 **/
+
+/**
+ * 获取上一条对话
+ * @returns {Object|null} 上一条对话数据
+ */
+const getPreviousDialogue = () => {
+  const section = props.scriptData.sections[currentSectionIndex.value];
+  if (!section) return null;
+  if (currentDialogueIndex.value > 0) {
+    return section.dialogues[currentDialogueIndex.value - 1];
+  }
+  return null;
+};
+
+/**
+ * 处理语音识别结果
+ * @param {Object} data - 语音识别数据
+ */
+const handleVoiceRecognized = (data) => {
+  console.log('语音识别结果:', data.originalText);
+  if (isVoiceRecording.value) {
+    voiceRecognizedText.value = data.originalText;
+  } else {
+    voiceRecognizedText.value = "";
+  }
+};
+
+/**
+ * 处理录音状态变化
+ * @param {Boolean} isRecording - 是否正在录音
+ */
+const handleRecordingStatusChanged = (isRecording) => {
+  console.log('录音状态:', isRecording);
+  const wasRecording = isVoiceRecording.value;
+  isVoiceRecording.value = isRecording;
+  if (wasRecording && !isRecording) {
+    voiceRecognizedText.value = "";
+  }
+};
+
+/**
+ * 停止所有音频播放
+ */
+const stopAllAudio = () => {
+  if (backgroundAudio.value) {
+    backgroundAudio.value.pause();
+    backgroundAudio.value.currentTime = 0;
+  }
+  if (dialogueAudio.value) {
+    dialogueAudio.value.pause();
+    dialogueAudio.value.currentTime = 0;
+  }
+};
+
+/**
+ * 播放背景音频
+ */
+const playBackgroundAudio = () => {
+  if (backgroundAudio.value) {
+    backgroundAudio.value.pause();
+    backgroundAudio.value.currentTime = 0;
+  }
+
+  // 背景音频在 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));
+  }
+};
+
+/**
+ * 播放背景视频(预留方法)
+ */
+const playBackgroundVideo = () => {
+  // 通过 emit 通知 DialogBackground 组件播放视频
+};
+
+/**
+ * 播放对话语音
+ * @param {Boolean} isAutoPlay - 是否自动播放下一条
+ */
+const playDialogueAudio = (isAutoPlay = false) => {
+  if (dialogueAudio.value) {
+    dialogueAudio.value.pause();
+    dialogueAudio.value.currentTime = 0;
+  }
+
+  if (currentDialogue.value?.voiceoverUrl && currentDialogue.value?.type !== 'video') {
+    const audio = new Audio(currentDialogue.value.voiceoverUrl);
+    dialogueAudio.value = audio;
+
+    audio.onended = () => {
+      if (isAtLastDialogue.value) {
+        if (currentDialogue.value?.type === 'user') return;
+        emit('dialogueEnded', props.isLastCourse);
+        isPlaying.value = false;
+        return;
+      }
+
+      if (isAutoPlay && isPlaying.value) {
+        setTimeout(() => {
+          if (!playNext(true)) {
+            isPlaying.value = false;
+            stopAllAudio();
+          }
+        }, 100);
+      }
+    };
+
+    audio.play().catch(e => {
+      console.error('对话语音播放失败:', e);
+      if (isAtLastDialogue.value) {
+        if (currentDialogue.value?.type === 'user') return;
+        emit('dialogueEnded', props.isLastCourse);
+        isPlaying.value = false;
+        return;
+      }
+      if (isAutoPlay && isPlaying.value) {
+        setTimeout(() => {
+          if (!playNext(true)) {
+            isPlaying.value = false;
+            stopAllAudio();
+          }
+        }, 2000);
+      }
+    });
+  } else if (currentDialogue.value?.type !== 'video') {
+    if (isAtLastDialogue.value) {
+      if (currentDialogue.value?.type === 'user') return;
+      emit('dialogueEnded', props.isLastCourse);
+      isPlaying.value = false;
+      return;
+    }
+    if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
+      setTimeout(() => {
+        if (!playNext(true)) {
+          isPlaying.value = false;
+          stopAllAudio();
+        }
+      }, 2000);
+    }
+  }
+};
+
+/**
+ * 切换播放/暂停状态
+ */
+const togglePlay = () => {
+  isPlaying.value = !isPlaying.value;
+  if (isPlaying.value) {
+    playBackgroundAudio();
+    if (!getIsPlaying() && !conversationInProgress.value) {
+      if (currentDialogue.value?.type === 'video') {
+        // 视频类型由VideoDisplay组件处理
+      } else {
+        playSequence();
+      }
+    }
+  } else {
+    stopAllAudio();
+  }
+};
+
+/**
+ * 处理视频播放完成
+ */
+const handleVideoEnded = () => {
+  if (isPlaying.value) {
+    setTimeout(() => {
+      if (!playNext(true)) {
+        if (isAtLastDialogue.value) {
+          console.log('视频序列:已到达最后一个对话');
+          emit('dialogueEnded', props.isLastCourse);
+        }
+        isPlaying.value = false;
+        stopAllAudio();
+      }
+    }, 1500);
+  }
+};
+
+/**
+ * 播放对话序列
+ */
+const playSequence = () => {
+  if (!isPlaying.value) return;
+
+  if (currentDialogue.value?.type === 'user') return;
+
+  if (currentDialogue.value?.type === 'video') return;
+
+  if (currentDialogue.value?.type === 'poem') {
+    showPoem.value = true;
+    currentPoemContent.value = currentDialogue.value.content;
+
+    if (currentDialogue.value?.voiceoverUrl) {
+      playDialogueAudio(true);
+    } else {
+      setTimeout(() => {
+        if (!playNext(true)) {
+          if (isAtLastDialogue.value) {
+            console.log('诗词序列:已到达最后一个对话');
+            emit('dialogueEnded', props.isLastCourse);
+          }
+          isPlaying.value = false;
+          stopAllAudio();
+        }
+      }, 500);
+    }
+  } else {
+    playDialogueAudio(true);
+  }
+};
+
+/**
+ * 播放上一条对话
+ */
+const playPrevious = () => {
+  if (isAtFirstDialogue.value) return;
+
+  stopAllAudio();
+  recoverQuestDialogue();
+  stopPlayback(false);
+  if (conversationInProgress.value) {
+    stopStream();
+  }
+
+  // 索引切换逻辑
+  if (currentDialogueIndex.value > 0) {
+    currentDialogueIndex.value--;
+  } else if (currentSectionIndex.value > 0) {
+    currentSectionIndex.value--;
+    const section = props.scriptData.sections[currentSectionIndex.value];
+    currentDialogueIndex.value = section.dialogues.length - 1;
+    showPoem.value = false;
+    currentPoemContent.value = '';
+  }
+
+  // 诗词显示逻辑
+  if (currentDialogue.value?.type === 'poem') {
+    showPoem.value = true;
+    currentPoemContent.value = currentDialogue.value.content;
+  } else {
+    showPoem.value = false;
+    currentPoemContent.value = "";
+    // 向前查找最近的诗词
+    for (let i = currentDialogueIndex.value; i >= 0; i--) {
+      let dialogueTemp = currentSection.value.dialogues[i];
+      if (dialogueTemp.type === 'poem') {
+        showPoem.value = true;
+        currentPoemContent.value = dialogueTemp.content;
+      }
+    }
+  }
+
+  nextTick(() => {
+    playBackgroundAudio();
+    // 诗词类型播放语音
+    if (currentDialogue.value?.type === 'poem') {
+      if (currentDialogue.value?.voiceoverUrl) {
+        playDialogueAudio(isPlaying.value);
+      } else {
+        // 诗词没有语音,延迟后继续切换上一句
+        if (!isAtFirstDialogue.value) {
+          setTimeout(() => {
+            playPrevious();
+          }, 100);
+        }
+      }
+    } else {
+      playDialogueAudio(isPlaying.value);
+    }
+  });
+};
+
+/**
+ * 播放下一条对话
+ * @param {Boolean} isAutoPlay - 是否自动播放
+ * @returns {Boolean} 是否成功切换
+ */
+const playNext = (isAutoPlay = false) => {
+  if (isAtLastDialogue.value) return false;
+
+  if (dialogueAudio.value) {
+    dialogueAudio.value.pause();
+    dialogueAudio.value.currentTime = 0;
+  }
+
+  recoverQuestDialogue();
+  stopPlayback(false);
+  if (conversationInProgress.value) {
+    stopStream();
+  }
+
+  // 同一章节内切换
+  if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
+    currentDialogueIndex.value++;
+
+    // 诗词处理
+    if (currentDialogue.value?.type === 'poem') {
+      showPoem.value = true;
+      currentPoemContent.value = currentDialogue.value.content;
+
+      if (currentDialogue.value?.voiceoverUrl) {
+        playDialogueAudio(isPlaying.value);
+      } else {
+        // 诗词没有语音,延迟后继续切换下一句
+        setTimeout(() => {
+          playNext(isAutoPlay);
+        }, 100);
+      }
+      return true;
+    }
+
+    playDialogueAudio(isPlaying.value);
+    return true;
+  } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
+    currentSectionIndex.value++;
+    currentDialogueIndex.value = 0;
+    showPoem.value = false;
+    currentPoemContent.value = '';
+
+    nextTick(() => {
+      playBackgroundAudio();
+      if (currentDialogue.value?.type === 'poem') {
+        showPoem.value = true;
+        currentPoemContent.value = currentDialogue.value.content;
+
+        if (currentDialogue.value?.voiceoverUrl) {
+          playDialogueAudio(isPlaying.value);
+        } else {
+          // 诗词没有语音,延迟后继续切换下一句(避免连续递归)
+          setTimeout(() => {
+            playNext(isAutoPlay);
+          }, 100);
+        }
+      // } else if (currentDialogue.value?.type === 'video') {
+      //   showPoem.value = false;
+      //   currentPoemContent.value = '';
+      } else {
+        playDialogueAudio(isPlaying.value);
+      }
+    });
+    return true;
+  }
+  return false;
+};
+
+/**
+ * 返回主页面
+ */
+const goBackToMain = () => {
+  stopAllAudio();
+  router.push('/ai-general-course');
+};
+
+/**
+ * 开始播放(点击遮罩层播放按钮后调用)
+ */
+const startPlayback = () => {
+  isPlaybackStarted.value = true;
+  showMask.value = false;
+
+  // 启动背景音频
+  playBackgroundAudio();
+
+  if (currentDialogue.value?.type === 'poem') {
+    showPoem.value = true;
+    currentPoemContent.value = currentDialogue.value.content;
+
+    if (currentDialogue.value?.voiceoverUrl) {
+      playDialogueAudio();
+    } else {
+      setTimeout(() => {
+        playNext();
+      }, 500);
+    }
+  } else {
+    playDialogueAudio();
+  }
+};
+
+/**
+ * 处理键盘事件
+ * @param {Event} event - 键盘事件
+ */
+const handleKeydown = (event) => {
+  if (showMask.value) return;
+  if (currentDialogue.value?.type === 'user') return;
+
+  switch (event.key) {
+    case 'ArrowLeft':
+      playPrevious();
+      event.preventDefault();
+      break;
+    case 'ArrowRight':
+      playNext();
+      event.preventDefault();
+      break;
+    default:
+      break;
+  }
+};
+
+/**
+ * 创建AI对话会话
+ */
+const createAiChart = async () => {
+  let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName);
+  await CreateDialogue({ roleId: role.id })
+    .then(res => {
+      console.log("创建会话:", res.data);
+      activeConversationId.value = res.data;
+    })
+    .catch(error => {
+      console.error('请求出错:', error);
+    });
+};
+
+/**
+ * 处理用户输入提交
+ * @param {Object} data - 用户输入数据
+ */
+const handleUserInputSubmit = async (data) => {
+  console.log('用户输入:', data.content);
+  await createAiChart();
+  
+  let userInputTemp = data.content;
+  let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value - 1];
+  userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题回复我回答的内容是否正确,并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)";
+
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: userInputTemp,
+    contentAnswer: null,
+  });
+};
+
+/**
+ * 处理单选问题提交
+ * @param {Object} data - 用户选择数据
+ */
+const handleSingleChoiceSubmit = async (data) => {
+  console.log('用户选择:', data.label);
+  await createAiChart();
+
+  const dialogue = previousQuestDialogue.value;
+  if (!dialogue) {
+    console.error('找不到上一条quest对话!');
+    return;
+  }
+
+  const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+  const optionsStr = dialogue.options.map((opt, idx) => `${optionLabels[idx]}. ${opt.content}`).join(';');
+  const content = `问题:${dialogue.content}\n选项:${optionsStr}\n我的答案:${data.label}\n正确答案:${dialogue.answer}\n\n请判断我的答案是否正确,并给予鼓励或夸赞,回复请精简,控制在50字内。`;
+
+  console.log('发送单选问题:', content);
+
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content,
+    contentAnswer: null,
+  });
+};
+
+/**
+ * 显示问题回答对话(AI思考中)
+ */
+const showQuestAnswerDialogue = () => {
+  currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value));
+  currentDialogue.value.type = "digital";
+  currentDialogue.value.content = "让我思考一下...";
+};
+
+/**
+ * 延迟恢复问题对话(网络异常时)
+ */
+const delayRecoverQuestDialogue = () => {
+  currentDialogue.value.content = "当前网络无反应,请稍后重试!";
+  setTimeout(() => {
+    recoverQuestDialogue();
+  }, 1500);
+};
+
+/**
+ * 恢复问题对话到原始状态
+ */
+const recoverQuestDialogue = () => {
+  if (currentDialogueCache.value) {
+    const currentSection = props.scriptData.sections[currentSectionIndex.value];
+    if (currentSection) {
+      currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value));
+    }
+    currentDialogueCache.value = null;
+  }
+};
+
+/**
+ * 发送消息流到AI
+ * @param {Object} userMessage - 用户消息
+ */
+const doSendMessageStream = async (userMessage) => {
+  conversationInAbortController.value = new AbortController();
+  conversationInProgress.value = true;
+  receiveMessageFullText.value = '';
+
+  showQuestAnswerDialogue();
+
+  try {
+    let isFirstChunk = true;
+    await sendChatMessageStream(
+      userMessage.conversationId,
+      userMessage.content,
+      userMessage.contentAnswer,
+      conversationInAbortController.value,
+      true,
+      async (res) => {
+        const { code, data, msg } = JSON.parse(res.data);
+        if (code !== 0) {
+          console.log(`对话异常! ${msg}`);
+          stopStream();
+          delayRecoverQuestDialogue();
+          return;
+        }
+
+        if (data.eventType === 'TEXT') {
+          if (data.receive?.content === '') return;
+          receiveMessageFullText.value += data.receive.content;
+          currentDialogue.value.content = receiveMessageFullText.value;
+          if (isFirstChunk) {
+            isFirstChunk = false;
+          }
+        }
+        if (data.eventType === 'AUDIO') {
+          await playAudioChunk(data.audioData);
+        }
+      },
+      (error) => {
+        console.log(`对话异常! ${error}`);
+        stopStream();
+        delayRecoverQuestDialogue();
+        throw error;
+      },
+      () => {
+        console.log(`结束对话!`);
+        stopStream();
+        if (isAtLastDialogue.value && currentDialogue.value?.type === 'digital') {
+          console.log('AI回答完成,触发 dialogueEnded 事件');
+          emit('dialogueEnded', props.isLastCourse);
+          isPlaying.value = false;
+        }
+      }
+    );
+  } catch (error) {
+    console.error('发送消息失败:', error);
+    stopStream();
+    delayRecoverQuestDialogue();
+  }
+};
+
+/**
+ * 停止消息流
+ */
+const stopStream = async () => {
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort();
+  }
+  conversationInProgress.value = false;
+  console.log(`结束对话!更改状态:`, conversationInProgress.value);
+};
+
+/**
+ * 处理音频播放完成
+ */
+const handleAudioPlaybackComplete = () => {
+  console.log('智能问答音频播放完成');
+  setOnPlaybackComplete(null);
+  stopAllAudio();
+
+  if (isAtLastDialogue.value) {
+    console.log('已到达最后一个对话,触发 dialogueEnded 事件');
+    emit('dialogueEnded', props.isLastCourse);
+    isPlaying.value = false;
+    return;
+  }
+
+  if (isPlaying.value) {
+    setOnPlaybackComplete(handleAudioPlaybackComplete);
+    if (playNext(true)) {
+      // playNext 内部已调用 playDialogueAudio
+    } else {
+      isPlaying.value = false;
+      stopAllAudio();
+    }
+  }
+};
+
+/** 生命周期钩子与监听 **/
+
+/**
+ * 监听环节变化 - 切换环节时更新背景音频
+ */
+watch(currentSectionIndex, () => {
+  playBackgroundAudio();
+});
+
+/**
+ * 监听剧本数据变化 - 重置所有状态
+ */
+watch(() => props.scriptData, (newVal, oldVal) => {
+  if (newVal && oldVal && newVal !== oldVal) {
+    stopAllAudio();
+    recoverQuestDialogue();
+    stopPlayback(false);
+    if (conversationInProgress.value) {
+      stopStream();
+    }
+    currentSectionIndex.value = 0;
+    currentDialogueIndex.value = 0;
+    isPlaying.value = false;
+    showMask.value = true;
+    currentDialogueCache.value = null;
+    showPoem.value = false;
+    currentPoemContent.value = '';
+  }
+}, { deep: true });
+
+/**
+ * 组件挂载时初始化
+ */
+onMounted(() => {
+  window.addEventListener('keydown', handleKeydown);
+  // 不自动播放背景音频,等待用户点击播放按钮
+  setOnPlaybackComplete(handleAudioPlaybackComplete);
+});
+
+/**
+ * 组件卸载时清理
+ */
+onUnmounted(() => {
+  window.removeEventListener('keydown', handleKeydown);
+  stopAllAudio();
+  stopPlayback(false);
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.dialog-engine {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 100;
+}
+
+.content-box {
+  position: absolute;
+  top: rpx(60);
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  padding-bottom: 0;
+  margin-bottom: 0;
+  z-index: 10;
+}
+</style>

+ 210 - 0
src/components/aiCourse/common/DialogBubble.vue

@@ -0,0 +1,210 @@
+<template>
+  <div 
+    class="dialogue-card"
+    :class="{
+      'left': side === 'left',
+      'right': side === 'right'
+    }"
+  >
+    <div class="dialogue-header">
+      <span class="role-name">{{ roleName }}</span>
+    </div>
+    <div class="dialogue-content" v-html="parsedContent"></div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { marked } from 'marked';
+
+/**
+ * DialogBubble - 对话气泡组件
+ * 显示角色名称和对话内容,支持Markdown格式
+ */
+
+/**
+ * Props 定义
+ * @prop {String} roleName - 角色名称
+ * @prop {String} content - 对话内容
+ * @prop {Number} index - 对话索引
+ */
+const props = defineProps({
+  roleName: {
+    type: String,
+    default: ''
+  },
+  content: {
+    type: String,
+    default: ''
+  },
+  index: {
+    type: Number,
+    default: 0
+  }
+});
+
+/**
+ * 计算气泡显示位置(根据索引奇偶性)
+ */
+const side = computed(() => {
+  return props.index % 2 === 0 ? 'left' : 'right';
+});
+
+/**
+ * 解析Markdown内容
+ */
+const parsedContent = computed(() => {
+  return marked(props.content || '');
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.dialogue-card {
+  background: rgba(255, 255, 255, 0.9);
+  border-radius: rpx(6);
+  padding: rpx(8);
+  max-width: 35%;
+  min-width: rpx(200);
+  box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
+  position: absolute;
+  bottom: rpx(50);
+  width: auto;
+  display: inline-block;
+  z-index: 2;
+}
+
+.dialogue-card.left {
+  left: rpx(145);
+  animation: dialogueEnterLeft 0.6s ease forwards;
+}
+
+.dialogue-card.right {
+  right: rpx(145);
+  animation: dialogueEnterRight 0.6s ease forwards;
+}
+
+.dialogue-card.left::before {
+  content: '';
+  position: absolute;
+  left: rpx(-11.5);
+  bottom: rpx(12);
+  width: 0;
+  height: 0;
+  border-top: rpx(7) solid transparent;
+  border-bottom: rpx(7) solid transparent;
+  border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
+  transform: translateY(0);
+}
+
+.dialogue-card.right::before {
+  content: '';
+  position: absolute;
+  right: rpx(-11.5);
+  bottom: rpx(12);
+  width: 0;
+  height: 0;
+  border-top: rpx(7) solid transparent;
+  border-bottom: rpx(7) solid transparent;
+  border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
+  transform: translateY(0);
+}
+
+@keyframes dialogueEnterLeft {
+  from {
+    opacity: 0;
+    transform: translateX(-rpx(30)) translateY(rpx(20));
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0) translateY(0);
+  }
+}
+
+@keyframes dialogueEnterRight {
+  from {
+    opacity: 0;
+    transform: translateX(rpx(30)) translateY(rpx(20));
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0) translateY(0);
+  }
+}
+
+.dialogue-header {
+  position: absolute;
+  top: rpx(-11);
+  left: rpx(12);
+  background: #409EFF;
+  color: white;
+  padding: rpx(1.2) rpx(6);
+  border-radius: rpx(5);
+  font-size: rpx(8);
+  box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
+}
+
+.dialogue-card.right .dialogue-header {
+  left: rpx(10);
+}
+
+.role-name {
+  font-weight: 600;
+  color: white;
+  font-size: rpx(10);
+}
+
+.dialogue-content {
+  font-size: rpx(12);
+  line-height: 1.2;
+  color: #333;
+  text-align: left;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+}
+
+.dialogue-content :deep(p) {
+  margin: 0 0 rpx(4) 0;
+}
+
+.dialogue-content :deep(strong),
+.dialogue-content :deep(b) {
+  font-weight: bold;
+}
+
+.dialogue-content :deep(em),
+.dialogue-content :deep(i) {
+  font-style: italic;
+}
+
+.dialogue-content :deep(ul),
+.dialogue-content :deep(ol) {
+  margin: rpx(4) 0;
+  padding-left: rpx(10);
+}
+
+.dialogue-content :deep(li) {
+  margin: rpx(2) 0;
+}
+
+.dialogue-content :deep(code) {
+  background-color: #f0f0f0;
+  padding: rpx(1) rpx(3);
+  border-radius: rpx(2);
+  font-family: monospace;
+  font-size: rpx(9);
+}
+
+.dialogue-content :deep(a) {
+  color: #409EFF;
+  text-decoration: underline;
+}
+</style>

+ 117 - 0
src/components/aiCourse/common/DialogCharacter.vue

@@ -0,0 +1,117 @@
+<template>
+  <div 
+    class="character"
+    :class="{
+      'left': side === 'left',
+      'right': side === 'right'
+    }"
+    :style="{ backgroundImage: `url(${characterImage})` }"
+  ></div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+
+/**
+ * DialogCharacter - 数字人形象组件
+ * 根据对话索引自动切换左右位置显示角色形象
+ */
+
+/**
+ * Props 定义
+ * @prop {String} roleName - 角色名称
+ * @prop {Array} scriptRoles - 角色列表
+ * @prop {Number} index - 对话索引
+ */
+const props = defineProps({
+  roleName: {
+    type: String,
+    default: ''
+  },
+  scriptRoles: {
+    type: Array,
+    default: () => []
+  },
+  index: {
+    type: Number,
+    default: 0
+  }
+});
+
+/**
+ * 计算角色显示位置(根据索引奇偶性)
+ */
+const side = computed(() => {
+  return props.index % 2 === 0 ? 'left' : 'right';
+});
+
+/**
+ * 获取角色头像URL
+ */
+const characterImage = computed(() => {
+  const role = props.scriptRoles.find(r => r.name === props.roleName);
+  return role ? role.avatar : '';
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.character {
+  position: absolute;
+  bottom: 0;
+  width: rpx(135);
+  height: rpx(240);
+  background-size: contain;
+  background-position: bottom;
+  background-repeat: no-repeat;
+  opacity: 0;
+  z-index: 1;
+}
+
+.character.left {
+  left: rpx(17);
+  transform: translateX(-100%);
+  animation: characterEnterLeft 0.8s ease forwards;
+}
+
+.character.right {
+  right: rpx(17);
+  transform: translateX(100%) scaleX(-1);
+  animation: characterEnterRight 0.8s ease forwards;
+}
+
+@keyframes characterEnterLeft {
+  0% {
+    opacity: 0;
+    transform: translateX(-100%) scale(0.8);
+  }
+  70% {
+    opacity: 0.9;
+    transform: translateX(10%) scale(1.05);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(0) scale(1);
+  }
+}
+
+@keyframes characterEnterRight {
+  0% {
+    opacity: 0;
+    transform: translateX(100%) scale(0.8) scaleX(-1);
+  }
+  70% {
+    opacity: 0.9;
+    transform: translateX(-10%) scale(1.05) scaleX(-1);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(0) scale(1) scaleX(-1);
+  }
+}
+</style>

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

@@ -0,0 +1,122 @@
+<template>
+  <div class="dialog-card-container">
+    <!-- 普通对话类型 -->
+    <DigitalDialogue
+      v-if="dialogue?.type === 'digital'"
+      :role-name="dialogue?.roleName"
+      :content="dialogue?.content"
+      :script-roles="scriptRoles"
+      :index="index"
+    />
+
+    <!-- 提问类型(只显示提问内容,不显示用户输入) -->
+    <QuestDialogue
+      v-else-if="dialogue?.type === 'quest'"
+      :role-name="dialogue?.roleName"
+      :content="dialogue?.content"
+      :script-roles="scriptRoles"
+      :index="index"
+    />
+
+    <!-- 用户输入类型(根据上一条问题类型显示不同输入方式) -->
+    <UserDialogue
+      v-else-if="dialogue?.type === 'user'"
+      :question="previousQuest?.content"
+      :question-type="getQuestionType(previousQuest)"
+      :options="previousQuest?.options"
+      @user-input-submit="$emit('user-input-submit', $event)"
+      @single-choice-submit="$emit('single-choice-submit', $event)"
+    />
+
+    <!-- 视频显示 -->
+    <VideoDisplay
+      v-else-if="dialogue?.type === 'video'"
+      :video-url="dialogue?.videoUrl"
+      @ended="$emit('video-ended', $event)"
+    />
+
+    <!-- 诗词显示 -->
+    <PoemDisplay
+        v-if="poemShow"
+        :content="poemContent"
+    />
+  </div>
+</template>
+
+<script setup>
+import DigitalDialogue from './dialogType/DigitalDialogue.vue';
+import QuestDialogue from './dialogType/QuestDialogue.vue';
+import UserDialogue from './dialogType/UserDialogue.vue';
+import PoemDisplay from './dialogType/PoemDisplay.vue';
+import VideoDisplay from './dialogType/VideoDisplay.vue';
+
+/**
+ * DialogCard - 统一对话卡片容器
+ * 根据对话类型渲染不同的子组件
+ */
+
+/**
+ * Props 定义
+ * @prop {Object} dialogue - 当前对话数据
+ * @prop {Array} scriptRoles - 角色列表
+ * @prop {Number} index - 对话索引
+ * @prop {Object} previousQuest - 上一条提问对话
+ * @prop {Boolean} poemShow - 是否显示诗词
+ * @prop {String} poemContent - 诗词内容
+ */
+defineProps({
+  dialogue: {
+    type: Object,
+    default: null
+  },
+  scriptRoles: {
+    type: Array,
+    default: () => []
+  },
+  index: {
+    type: Number,
+    default: 0
+  },
+  previousQuest: {
+    type: Object,
+    default: null
+  },
+  poemShow: {
+    type: Boolean,
+    default: false
+  },
+  poemContent: {
+    type: String,
+    default: ''
+  }
+});
+
+/**
+ * Emits 定义
+ * @event user-input-submit - 用户文本输入提交
+ * @event single-choice-submit - 单选问题提交
+ * @event video-ended - 视频播放结束
+ */
+defineEmits(['user-input-submit', 'single-choice-submit', 'video-ended']);
+
+/**
+ * 获取问题类型
+ * @param {Object} questDialogue - 提问对话数据
+ * @returns {String} 问题类型:'singleChoice' | 'AI Q&A'
+ */
+const getQuestionType = (questDialogue) => {
+  if (!questDialogue) return 'AI Q&A';
+  if (questDialogue.questionType === 'singleChoice' && questDialogue.options?.length > 0) {
+    return 'singleChoice';
+  }
+  return 'AI Q&A';
+};
+</script>
+
+<style scoped lang="scss">
+.dialog-card-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+</style>

+ 61 - 0
src/components/aiCourse/dialog/dialogType/DigitalDialogue.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="digital-dialogue">
+    <!-- 数字人形象 -->
+    <DialogCharacter
+      :role-name="roleName"
+      :script-roles="scriptRoles"
+      :index="index"
+    />
+    
+    <!-- 对话气泡 -->
+    <DialogBubble
+      :role-name="roleName"
+      :content="content"
+      :index="index"
+    />
+  </div>
+</template>
+
+<script setup>
+import DialogCharacter from '../../common/DialogCharacter.vue';
+import DialogBubble from '../../common/DialogBubble.vue';
+
+/**
+ * DigitalDialogue - 普通对话组件
+ * 显示数字人形象和对话气泡
+ */
+
+/**
+ * Props 定义
+ * @prop {String} roleName - 角色名称
+ * @prop {String} content - 对话内容
+ * @prop {Array} scriptRoles - 角色列表
+ * @prop {Number} index - 对话索引
+ */
+defineProps({
+  roleName: {
+    type: String,
+    default: ''
+  },
+  content: {
+    type: String,
+    default: ''
+  },
+  scriptRoles: {
+    type: Array,
+    default: () => []
+  },
+  index: {
+    type: Number,
+    default: 0
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.digital-dialogue {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+</style>

+ 187 - 0
src/components/aiCourse/dialog/dialogType/PoemDisplay.vue

@@ -0,0 +1,187 @@
+<template>
+  <div class="poem-display">
+    <div class="poem-content">
+      <div class="poem-text" v-html="formattedContent"></div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+
+/**
+ * PoemDisplay - 诗词展示组件
+ * 以卷轴样式展示诗词内容
+ */
+
+/**
+ * Props 定义
+ * @prop {String} content - 诗词内容
+ */
+const props = defineProps({
+  content: {
+    type: String,
+    default: ''
+  }
+});
+
+/**
+ * 格式化诗词内容
+ * 移除markdown标记并按标点符号分行
+ */
+const formattedContent = computed(() => {
+  if (!props.content) return '';
+  const plainText = props.content.replace(/[\*#`\[\]\(\)]/g, '');
+  return plainText.replace(/([。!?;、])/g, '$1<br/>');
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.poem-display {
+  position: absolute;
+  top: 25%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: rpx(500);
+  height: rpx(200);
+  padding: rpx(0);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 5;
+  animation: scrollBackgroundFadeIn 0.8s ease-out;
+}
+
+.poem-display::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-image: url('@/assets/dialogue/long-scroll.png');
+  background-size: 100%;
+  background-repeat: no-repeat;
+  background-position: center;
+  opacity: 0.8;
+  z-index: -1;
+}
+
+.poem-content {
+  text-align: center;
+  animation: poemFadeIn 1s ease-in-out;
+  transition: all 0.5s ease-in-out;
+}
+
+.poem-text {
+  width: 100%;
+  max-width: rpx(500);
+  max-height: rpx(200);
+  height: auto;
+  font-family: 'STKaiti', 'KaiTi', '楷体', 'Ma Shan Zheng', cursive;
+  font-size: rpx(20);
+  color: black;
+  position: relative;
+  overflow: auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  transition: all 0.5s ease-in-out;
+}
+
+.poem-text::-webkit-scrollbar {
+  width: rpx(0);
+}
+
+.poem-text::-webkit-scrollbar-track {
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: rpx(4);
+}
+
+.poem-text::-webkit-scrollbar-thumb {
+  background: rgba(0, 0, 0, 0.3);
+  border-radius: rpx(4);
+}
+
+.poem-text::-webkit-scrollbar-thumb:hover {
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.poem-text::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  animation: poemShine 3s ease-in-out infinite;
+}
+
+@keyframes poemFadeIn {
+  from {
+    opacity: 0;
+    transform: scale(0.8) translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1) translateY(0);
+  }
+}
+
+@keyframes scrollBackgroundFadeIn {
+  from {
+    opacity: 0;
+    background-size: 80%;
+  }
+  to {
+    opacity: 1;
+    background-size: 100%;
+  }
+}
+
+.poem-text p {
+  margin: rpx(10) 0;
+  opacity: 0;
+  animation: poemSlideIn 0.5s ease forwards;
+  font-weight: 500;
+  letter-spacing: rpx(2);
+}
+
+.poem-text p:nth-child(1) {
+  animation-delay: 0.2s;
+}
+
+.poem-text p:nth-child(2) {
+  animation-delay: 0.4s;
+}
+
+.poem-text p:nth-child(3) {
+  animation-delay: 0.6s;
+}
+
+.poem-text p:nth-child(4) {
+  animation-delay: 0.8s;
+}
+
+.poem-text p:nth-child(5) {
+  animation-delay: 1s;
+}
+
+@keyframes poemSlideIn {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>

+ 61 - 0
src/components/aiCourse/dialog/dialogType/QuestDialogue.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="quest-dialogue">
+    <!-- 数字人形象 -->
+    <DialogCharacter
+      :role-name="roleName"
+      :script-roles="scriptRoles"
+      :index="index"
+    />
+    
+    <!-- 对话气泡 -->
+    <DialogBubble
+      :role-name="roleName"
+      :content="content"
+      :index="index"
+    />
+  </div>
+</template>
+
+<script setup>
+import DialogCharacter from '../../common/DialogCharacter.vue';
+import DialogBubble from '../../common/DialogBubble.vue';
+
+/**
+ * QuestDialogue - 提问对话组件
+ * 显示提问内容的数字人形象和对话气泡
+ */
+
+/**
+ * Props 定义
+ * @prop {String} roleName - 角色名称
+ * @prop {String} content - 提问内容
+ * @prop {Array} scriptRoles - 角色列表
+ * @prop {Number} index - 对话索引
+ */
+defineProps({
+  roleName: {
+    type: String,
+    default: ''
+  },
+  content: {
+    type: String,
+    default: ''
+  },
+  scriptRoles: {
+    type: Array,
+    default: () => []
+  },
+  index: {
+    type: Number,
+    default: 0
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.quest-dialogue {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+</style>

+ 63 - 0
src/components/aiCourse/dialog/dialogType/UserDialogue.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="user-dialogue">
+    <!-- AI Q&A 用户文本输入 -->
+    <UserInputCard
+      v-if="questionType === 'AI Q&A'"
+      @submit="$emit('user-input-submit', $event)"
+    />
+    
+    <!-- 单选问题 -->
+    <SingleChoiceCard
+      v-else-if="questionType === 'singleChoice'"
+      :question="question"
+      :options="options"
+      @submit="$emit('single-choice-submit', $event)"
+    />
+  </div>
+</template>
+
+<script setup>
+import UserInputCard from './questType/UserInputCard.vue';
+import SingleChoiceCard from './questType/SingleChoiceCard.vue';
+
+/**
+ * UserDialogue - 用户输入组件
+ * 根据问题类型显示不同的输入方式
+ */
+
+/**
+ * Props 定义
+ * @prop {String} question - 问题内容
+ * @prop {String} questionType - 问题类型:'AI Q&A' | 'singleChoice'
+ * @prop {Array} options - 选项列表(单选问题时使用)
+ */
+defineProps({
+  question: {
+    type: String,
+    default: ''
+  },
+  questionType: {
+    type: String,
+    default: 'AI Q&A'
+  },
+  options: {
+    type: Array,
+    default: () => []
+  }
+});
+
+/**
+ * Emits 定义
+ * @event user-input-submit - 用户文本输入提交
+ * @event single-choice-submit - 单选问题提交
+ */
+defineEmits(['user-input-submit', 'single-choice-submit']);
+</script>
+
+<style scoped lang="scss">
+.user-dialogue {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+</style>

+ 124 - 0
src/components/aiCourse/dialog/dialogType/VideoDisplay.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="video-display">
+    <div class="video-frame">
+      <video
+        :src="videoUrl"
+        class="dialogue-video"
+        ref="videoRef"
+        controls
+        autoplay
+        @ended="$emit('ended')"
+        @contextmenu.prevent
+        controlslist="nodownload"
+      >
+        您的浏览器不支持视频播放
+      </video>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue';
+
+/**
+ * VideoDisplay - 视频播放组件
+ * 显示对话中的视频内容
+ */
+
+/**
+ * Props 定义
+ * @prop {String} videoUrl - 视频URL
+ */
+const props = defineProps({
+  videoUrl: {
+    type: String,
+    default: ''
+  }
+});
+
+/**
+ * Emits 定义
+ * @event ended - 视频播放结束
+ */
+defineEmits(['ended']);
+
+const videoRef = ref(null);
+
+/**
+ * 组件挂载时自动播放视频
+ */
+onMounted(() => {
+  if (videoRef.value && props.videoUrl) {
+    videoRef.value.play().catch(e => console.error('对话视频播放失败:', e));
+  }
+});
+
+/**
+ * 组件卸载时清理视频
+ */
+onUnmounted(() => {
+  if (videoRef.value) {
+    videoRef.value.pause();
+    videoRef.value.currentTime = 0;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.video-display {
+  position: absolute;
+  top: 45%;
+  left: 50%;
+  transform: translate(-50%, -50%) scale(0.8);
+  z-index: 15;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  opacity: 0;
+  animation: videoFadeIn 0.8s ease-out forwards;
+}
+
+.video-frame {
+  width: rpx(420);
+  height: rpx(250);
+  padding: rpx(5);
+  background: linear-gradient(135deg, rgba(160, 220, 240, 0.8), rgba(80, 190, 240, 0.8));
+  border: rpx(2) solid rgba(0, 100, 192, 0.8);
+  border-radius: rpx(15);
+  box-shadow: 0 rpx(8) rpx(25) rgba(0, 0, 0, 0.4);
+  backdrop-filter: blur(rpx(5));
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.dialogue-video {
+  width: 100%;
+  height: 100%;
+  border-radius: rpx(10);
+  background-color: #000;
+  object-fit: contain;
+}
+
+@keyframes videoFadeIn {
+  0% {
+    opacity: 0;
+    transform: translate(-50%, -50%) scale(0.8);
+  }
+  70% {
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1.05);
+  }
+  100% {
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1);
+  }
+}
+</style>

+ 276 - 0
src/components/aiCourse/dialog/dialogType/questType/SingleChoiceCard.vue

@@ -0,0 +1,276 @@
+<template>
+  <div class="single-choice-card">
+    <div class="dialogue-header">
+      <span class="role-name">我</span>
+    </div>
+    <div class="single-choice-content">
+      <!-- 问题描述 -->
+      <div class="question-text" v-html="parsedQuestion"></div>
+      <!-- 选项列表 -->
+      <div class="options-list">
+        <div
+          v-for="(option, index) in options"
+          :key="index"
+          class="option-item"
+          :class="{ 'selected': selectedOption === optionLabels[index] }"
+          @click="selectOption(optionLabels[index])"
+        >
+          <span class="option-label">{{ optionLabels[index] }}</span>
+          <span class="option-content">{{ option.content }}</span>
+        </div>
+      </div>
+      <!-- 操作按钮 -->
+      <div class="input-actions">
+        <button class="cancel-btn" @click="handleCancel">清空选择</button>
+        <button class="submit-btn" :disabled="!selectedOption" @click="handleSubmit">提交答案</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import { marked } from 'marked';
+
+/**
+ * SingleChoiceCard - 单选问题卡片组件
+ * 用于展示单选问题和选项
+ */
+
+/**
+ * Props 定义
+ * @prop {String} question - 问题内容
+ * @prop {Array} options - 选项列表
+ */
+const props = defineProps({
+  question: {
+    type: String,
+    default: ''
+  },
+  options: {
+    type: Array,
+    default: () => []
+  }
+});
+
+/**
+ * Emits 定义
+ * @event submit - 用户提交选择
+ */
+const emit = defineEmits(['submit']);
+
+const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+const selectedOption = ref('');
+
+/**
+ * 解析Markdown格式的问题内容
+ */
+const parsedQuestion = computed(() => {
+  return marked(props.question || '');
+});
+
+/**
+ * 选择选项
+ * @param {String} label - 选项标签
+ */
+const selectOption = (label) => {
+  selectedOption.value = label;
+};
+
+/**
+ * 清空选择
+ */
+const handleCancel = () => {
+  selectedOption.value = '';
+};
+
+/**
+ * 提交答案
+ */
+const handleSubmit = () => {
+  if (!selectedOption.value) return;
+  
+  const selectedIndex = optionLabels.indexOf(selectedOption.value);
+  const selectedOptionData = props.options[selectedIndex];
+  
+  emit('submit', {
+    type: 'singleChoice',
+    label: selectedOption.value,
+    content: selectedOptionData?.content || ''
+  });
+  
+  selectedOption.value = '';
+};
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.single-choice-card {
+  background: rgba(255, 255, 255, 0.9);
+  border-radius: rpx(6);
+  padding: rpx(8);
+  max-width: 55%;
+  min-width: rpx(200);
+  box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
+  position: absolute;
+  bottom: rpx(50);
+  left: 50%;
+  transform: translateX(-50%);
+  width: auto;
+  display: inline-block;
+  z-index: 10;
+  animation: dialogueEnterCenter 0.6s ease forwards;
+}
+
+.single-choice-card::before {
+  display: none;
+}
+
+@keyframes dialogueEnterCenter {
+  from {
+    opacity: 0;
+    transform: translateX(-50%) translateY(rpx(20));
+  }
+  to {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0);
+  }
+}
+
+.dialogue-header {
+  position: absolute;
+  top: rpx(-11);
+  left: rpx(12);
+  background: #409EFF;
+  color: white;
+  padding: rpx(1.2) rpx(6);
+  border-radius: rpx(5);
+  font-size: rpx(8);
+  box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
+}
+
+.role-name {
+  font-weight: 600;
+  color: white;
+  font-size: rpx(10);
+}
+
+.single-choice-content {
+  font-size: rpx(12);
+  line-height: 1.4;
+  color: #333;
+  text-align: left;
+  width: 100%;
+}
+
+.question-text {
+  margin-bottom: rpx(10);
+  padding-bottom: rpx(8);
+  border-bottom: rpx(1) dashed #ddd;
+  font-weight: 500;
+}
+
+.options-list {
+  display: flex;
+  flex-direction: column;
+  gap: rpx(6);
+  margin-bottom: rpx(12);
+}
+
+.option-item {
+  display: flex;
+  align-items: center;
+  padding: rpx(8) rpx(12);
+  background: #f8f9fa;
+  border: rpx(1) solid #e9ecef;
+  border-radius: rpx(6);
+  cursor: pointer;
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: #e8f4fd;
+    border-color: #409EFF;
+  }
+
+  &.selected {
+    background: #e8f4fd;
+    border-color: #409EFF;
+    box-shadow: 0 rpx(2) rpx(8) rgba(64, 158, 255, 0.2);
+  }
+}
+
+.option-label {
+  width: rpx(24);
+  height: rpx(24);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fff;
+  border: rpx(1) solid #ddd;
+  border-radius: 50%;
+  font-size: rpx(10);
+  font-weight: 600;
+  color: #666;
+  margin-right: rpx(8);
+  flex-shrink: 0;
+  transition: all 0.3s ease;
+
+  .option-item.selected & {
+    background: #409EFF;
+    border-color: #409EFF;
+    color: #fff;
+  }
+}
+
+.option-content {
+  font-size: rpx(11);
+  color: #333;
+  flex: 1;
+}
+
+.input-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: rpx(6);
+  margin-top: rpx(6);
+  width: 100%;
+}
+
+.cancel-btn, .submit-btn {
+  padding: rpx(2.5) rpx(10);
+  border: none;
+  border-radius: rpx(4);
+  font-size: rpx(8);
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.cancel-btn {
+  background: #E0E0E0;
+  color: #666;
+}
+
+.submit-btn {
+  background: #409EFF;
+  color: white;
+}
+
+.cancel-btn:hover, .submit-btn:hover {
+  transform: scale(1.05);
+}
+
+.cancel-btn:active, .submit-btn:active {
+  transform: scale(0.95);
+}
+
+.submit-btn:disabled {
+  background: #ccc;
+  cursor: not-allowed;
+  transform: none;
+}
+</style>

+ 196 - 0
src/components/aiCourse/dialog/dialogType/questType/UserInputCard.vue

@@ -0,0 +1,196 @@
+<template>
+  <div class="user-input-card">
+    <div class="dialogue-header">
+      <span class="role-name">我</span>
+    </div>
+    <div class="dialogue-content">
+      <textarea
+        v-model="userInput"
+        class="user-input-textarea"
+        placeholder="请输入内容..."
+        @keyup.enter.exact="handleSubmit"
+      ></textarea>
+      <div class="input-actions">
+        <button class="cancel-btn" @click="handleCancel">清空</button>
+        <button class="submit-btn" @click="handleSubmit">发送</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+
+/**
+ * UserInputCard - 用户文本输入卡片组件
+ * 用于AI Q&A类型问题的用户输入
+ */
+
+/**
+ * Emits 定义
+ * @event submit - 用户提交输入内容
+ */
+const emit = defineEmits(['submit']);
+
+const userInput = ref('');
+
+/**
+ * 处理提交
+ */
+const handleSubmit = () => {
+  if (userInput.value.trim()) {
+    emit('submit', {
+      type: 'text',
+      content: userInput.value
+    });
+    userInput.value = '';
+  }
+};
+
+/**
+ * 处理清空
+ */
+const handleCancel = () => {
+  userInput.value = '';
+};
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.user-input-card {
+  background: rgba(255, 255, 255, 0.9);
+  border-radius: rpx(6);
+  padding: rpx(8);
+  max-width: 50%;
+  min-width: rpx(200);
+  box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
+  position: absolute;
+  bottom: rpx(50);
+  left: 50%;
+  transform: translateX(-50%);
+  width: auto;
+  display: inline-block;
+  z-index: 2;
+  animation: dialogueEnterCenter 0.6s ease forwards;
+}
+
+.user-input-card::before {
+  display: none;
+}
+
+@keyframes dialogueEnterCenter {
+  from {
+    opacity: 0;
+    transform: translateX(-50%) translateY(rpx(20));
+  }
+  to {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0);
+  }
+}
+
+.dialogue-header {
+  position: absolute;
+  top: rpx(-11);
+  left: rpx(12);
+  background: #409EFF;
+  color: white;
+  padding: rpx(1.2) rpx(6);
+  border-radius: rpx(5);
+  font-size: rpx(8);
+  box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
+}
+
+.role-name {
+  font-weight: 600;
+  color: white;
+  font-size: rpx(10);
+}
+
+.dialogue-content {
+  font-size: rpx(12);
+  line-height: 1.2;
+  color: #333;
+  text-align: left;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+}
+
+.user-input-textarea {
+  width: 95%;
+  min-height: rpx(50);
+  max-height: rpx(150);
+  border: none;
+  outline: none;
+  background: transparent;
+  font-size: rpx(10);
+  line-height: 1.2;
+  color: #333;
+  resize: none;
+  font-family: inherit;
+  overflow-y: auto;
+  text-align: left;
+  padding: rpx(5) 0;
+}
+
+.user-input-textarea::-webkit-scrollbar {
+  width: rpx(0);
+}
+
+.user-input-textarea::-webkit-scrollbar-track {
+  background: rgba(0, 0, 0, 0.05);
+  border-radius: rpx(3);
+}
+
+.user-input-textarea::-webkit-scrollbar-thumb {
+  background: rgba(64, 158, 255, 0.5);
+  border-radius: rpx(3);
+}
+
+.user-input-textarea::-webkit-scrollbar-thumb:hover {
+  background: rgba(64, 158, 255, 0.8);
+}
+
+.input-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: rpx(6);
+  margin-top: rpx(6);
+  width: 100%;
+}
+
+.cancel-btn, .submit-btn {
+  padding: rpx(2.5) rpx(10);
+  border: none;
+  border-radius: rpx(4);
+  font-size: rpx(8);
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.cancel-btn {
+  background: #E0E0E0;
+  color: #666;
+}
+
+.submit-btn {
+  background: #409EFF;
+  color: white;
+}
+
+.cancel-btn:hover, .submit-btn:hover {
+  transform: scale(1.05);
+}
+
+.cancel-btn:active, .submit-btn:active {
+  transform: scale(0.95);
+}
+</style>

+ 156 - 0
src/components/aiCourse/engine/DialogBackground.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="dialog-background">
+    <!-- 背景图 -->
+    <img 
+      v-if="backgroundType === 'imageAudio' && imageUrl" 
+      :src="imageUrl" 
+      alt="背景图" 
+      class="background-image"
+    >
+    <!-- 背景视频 -->
+    <video 
+      v-else-if="backgroundType === 'video' && videoUrl"  
+      ref="videoRef" 
+      :key="videoUrl"
+      :src="videoUrl" 
+      class="background-video"  
+      loop 
+      muted 
+      playsinline>
+      您的浏览器不支持视频播放
+    </video>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
+
+/**
+ * DialogBackground - 对话背景组件
+ * 支持背景图和背景视频两种类型
+ */
+
+/**
+ * Props 定义
+ * @prop {String} backgroundType - 背景类型:imageAudio | video
+ * @prop {String} imageUrl - 背景图URL
+ * @prop {String} videoUrl - 背景视频URL
+ * @prop {Boolean} isPlaying - 是否正在播放
+ * @prop {Boolean} isPlaybackStarted - 是否已开始播放(用户点击播放按钮后变为true)
+ */
+const props = defineProps({
+  backgroundType: {
+    type: String,
+    default: 'imageAudio'
+  },
+  imageUrl: {
+    type: String,
+    default: ''
+  },
+  videoUrl: {
+    type: String,
+    default: ''
+  },
+  isPlaying: {
+    type: Boolean,
+    default: false
+  },
+  isPlaybackStarted: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const videoRef = ref(null);
+
+/**
+ * 播放视频
+ */
+const playVideo = () => {
+  if (videoRef.value) {
+    videoRef.value.play().catch(e => console.error('背景视频播放失败:', e));
+  }
+};
+
+/**
+ * 暂停视频
+ */
+const pauseVideo = () => {
+  if (videoRef.value) {
+    videoRef.value.pause();
+  }
+};
+
+// 监听 videoUrl 变化,重新播放视频(仅在已开始播放状态下)
+watch(() => props.videoUrl, (newUrl) => {
+  if (newUrl && props.backgroundType === 'video' && props.isPlaybackStarted) {
+    nextTick(() => {
+      playVideo();
+    });
+  }
+});
+
+// 监听 backgroundType 变化(仅在已开始播放状态下)
+watch(() => props.backgroundType, (newType) => {
+  if (newType === 'video' && props.videoUrl && props.isPlaybackStarted) {
+    nextTick(() => {
+      playVideo();
+    });
+  }
+});
+
+// 监听 isPlaying 变化
+watch(() => props.isPlaying, (newVal) => {
+  if (newVal) {
+    playVideo();
+  } else {
+    pauseVideo();
+  }
+});
+
+// 监听 isPlaybackStarted 变化 - 用户点击播放按钮后开始播放背景视频
+watch(() => props.isPlaybackStarted, (started) => {
+  if (started && props.backgroundType === 'video' && props.videoUrl) {
+    nextTick(() => {
+      playVideo();
+    });
+  }
+});
+
+onMounted(() => {
+  // 不自动播放,等待用户点击播放按钮
+});
+
+onUnmounted(() => {
+  pauseVideo();
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.dialog-background {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+}
+
+.background-image {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.background-video {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+</style>

+ 180 - 0
src/components/aiCourse/engine/DialogHeader.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="title-box">
+    <!-- 返回 -->
+    <div class="title-left">
+      <div class="box-icon" @click="$emit('back')">
+        <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
+        {{ backText }}
+      </div>
+    </div>
+    <!-- 标题 -->
+    <div class="title-center">
+      <div class="title-text" :title="title">
+        {{ title }}
+      </div>
+    </div>
+    <!-- 自动按钮 -->
+    <div class="title-right">
+      <div class="box-icon" @click="$emit('toggle-play')">
+        <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ArrowLeftBold } from '@element-plus/icons-vue';
+
+/**
+ * DialogHeader - 对话标题栏组件
+ * 包含返回按钮、标题和播放控制按钮
+ */
+
+/**
+ * Props 定义
+ * @prop {String} title - 课程标题
+ * @prop {String} backText - 返回按钮文本
+ * @prop {Boolean} isPlaying - 是否正在播放
+ */
+defineProps({
+  title: {
+    type: String,
+    default: ''
+  },
+  backText: {
+    type: String,
+    default: '返回课程'
+  },
+  isPlaying: {
+    type: Boolean,
+    default: false
+  }
+});
+
+/**
+ * Emits 定义
+ * @event back - 返回课程
+ * @event toggle-play - 切换播放状态
+ */
+defineEmits(['back', 'toggle-play']);
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.title-box {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: rpx(60);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: white;
+  padding: 0 rpx(20);
+  z-index: 20;
+}
+
+.title-left {
+  width: 33.33%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  gap: rpx(5);
+  z-index: 30;
+
+  .box-icon {
+    display: flex;
+    align-items: center;
+    color: #0064BE;
+    gap: rpx(5);
+    padding: rpx(5) rpx(10);
+    background: linear-gradient(135deg, #A0DCF0, #50BEF0);
+    border: rpx(1) solid rgba(0, 100, 192);
+    border-radius: rpx(30);
+    backdrop-filter: blur(10px);
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-size: rpx(9);
+    font-weight: 500;
+    width: fit-content;
+  }
+
+  .box-icon:hover {
+    background-color: rgba(255, 255, 255, 90%);
+    transform: translateX(-3px);
+  }
+
+  .left-icon {
+    font-size: rpx(12);
+  }
+}
+
+.title-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  background-image: url('@/assets/dialogue/number-title.png');
+  background-size: 100% 85%;
+  background-repeat: no-repeat;
+  background-position: center;
+  width: fit-content;
+  padding: 0 rpx(100);
+  min-width: rpx(100);
+  z-index: 10;
+}
+
+.title-text {
+  height: 100%;
+  font-size: rpx(11);
+  font-weight: bold;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  white-space: nowrap;
+}
+
+.title-right {
+  width: 33.33%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: rpx(10);
+  z-index: 10;
+
+  .box-icon {
+    display: flex;
+    align-items: center;
+    color: #0064BE;
+    gap: rpx(5);
+    padding: rpx(5) rpx(10);
+    background: linear-gradient(135deg, #A0DCF0, #50BEF0);
+    border: rpx(1) solid rgba(0, 100, 192);
+    border-radius: rpx(30);
+    backdrop-filter: blur(10px);
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-size: rpx(9);
+    font-weight: 500;
+    width: fit-content;
+  }
+
+  .box-icon:hover {
+    background-color: rgba(255, 255, 255, 90%);
+    transform: translateX(-3px);
+  }
+
+  .play-text {
+    font-size: rpx(9);
+    color: #0064BE;
+    font-weight: 500;
+  }
+}
+</style>

+ 101 - 0
src/components/aiCourse/engine/DialogMask.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="mask-layer" ref="maskLayer">
+    <div class="play-button-container">
+      <button class="play-button" @click="handleStart">
+        <el-icon class="play-icon"><VideoPlay /></el-icon>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+import { VideoPlay } from '@element-plus/icons-vue';
+
+/**
+ * DialogMask - 初始遮罩层组件
+ * 用于课程开始前的遮挡,点击播放按钮后淡出并触发开始事件
+ */
+
+/**
+ * Emits 定义
+ * @event start - 开始播放
+ */
+const emit = defineEmits(['start']);
+
+const maskLayer = ref(null);
+
+/**
+ * 处理开始播放按钮点击
+ */
+const handleStart = () => {
+  if (maskLayer.value) {
+    maskLayer.value.classList.add('fade-out');
+    setTimeout(() => {
+      emit('start');
+    }, 500);
+  }
+};
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.mask-layer {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.7);
+  z-index: 20;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  opacity: 1;
+  transition: all 0.5s ease-out;
+}
+
+.mask-layer.fade-out {
+  opacity: 0;
+  transform: scale(1.1);
+  z-index: -1;
+}
+
+.play-button-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.play-button {
+  width: rpx(80);
+  height: rpx(80);
+  border-radius: 50%;
+  border: none;
+  background: transparent;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  outline: none;
+  -webkit-tap-highlight-color: transparent;
+}
+
+.play-icon {
+  font-size: rpx(40);
+  color: #A0DCF0;
+  transition: all 0.3s ease;
+  cursor: pointer;
+}
+
+.play-icon:hover {
+  transform: scale(1.2);
+  color: #50BEF0;
+  text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
+}
+</style>

+ 255 - 0
src/components/aiCourse/engine/InputButtons.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="input-buttons-container">
+    <!-- 上一个对话按钮 -->
+    <div class="arrow-icon-circle" @click="$emit('prev')" :class="{ 'disabled': !canPrev }">
+      <el-icon class="arrow-icon"><CaretLeft /></el-icon>
+    </div>
+    <!-- 语音输入按钮 -->
+    <div class="voice-input-outer" v-if="showVoice" :class="{ 'recording': isRecording }">
+      <VoiceInput
+        inputSelector=".user-input-textarea"
+        lang="zh-CN"
+        maxDuration="10"
+        @voiceRecognized="$emit('voice-recognized', $event)"
+        @recordingStatusChanged="$emit('recording-status-changed', $event)"
+      />
+    </div>
+    <!-- 语音输入按钮占位符 -->
+    <div class="voice-input-outer placeholder" v-else></div>
+    <!-- 下一个对话按钮 -->
+    <div class="arrow-icon-circle" @click="$emit('next')" :class="{ 'disabled': !canNext }">
+      <el-icon class="arrow-icon"><CaretRight /></el-icon>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { CaretLeft, CaretRight } from '@element-plus/icons-vue';
+import VoiceInput from '@/components/ai/voice/VoiceInput_Api.vue';
+
+/**
+ * InputButtons - 底部控制按钮组件
+ * 包含上一句/下一句切换按钮和语音输入按钮
+ */
+
+/**
+ * Props 定义
+ * @prop {Boolean} canPrev - 是否可以切换到上一句
+ * @prop {Boolean} canNext - 是否可以切换到下一句
+ * @prop {Boolean} showVoice - 是否显示语音输入按钮
+ * @prop {Boolean} isRecording - 是否正在录音
+ */
+defineProps({
+  canPrev: {
+    type: Boolean,
+    default: true
+  },
+  canNext: {
+    type: Boolean,
+    default: true
+  },
+  showVoice: {
+    type: Boolean,
+    default: false
+  },
+  isRecording: {
+    type: Boolean,
+    default: false
+  }
+});
+
+/**
+ * Emits 定义
+ * @event prev - 切换到上一句
+ * @event next - 切换到下一句
+ * @event voice-recognized - 语音识别完成
+ * @event recording-status-changed - 录音状态变化
+ */
+defineEmits(['prev', 'next', 'voice-recognized', 'recording-status-changed']);
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.input-buttons-container {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  z-index: 10;
+  transition: all 0.3s ease;
+  gap: rpx(10);
+  margin-bottom: 0;
+  padding-bottom: 0;
+}
+
+  .arrow-icon-circle {
+    width: rpx(20);
+    height: rpx(20);
+    border-radius: 50%;
+    border: rpx(1) solid rgba(0, 100, 192);
+    background: linear-gradient(135deg, #A0DCF0, #50BEF0);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    transition: all 0.3s ease;
+
+    &:hover:not(.disabled) {
+      transform: scale(1.1);
+      box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
+    }
+
+    &:active:not(.disabled) {
+      transform: scale(0.95);
+    }
+
+    &.disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+      border-color: rgba(0, 100, 192, 0.3);
+      background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
+    }
+
+    .arrow-icon {
+      font-size: rpx(15);
+      color: #0064BE;
+    }
+  }
+
+.voice-input-outer {
+  position: relative;
+  width: rpx(42);
+  height: rpx(42);
+  border-radius: 50%;
+  background: transparent;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &::before {
+    content: '';
+    position: absolute;
+    width: 85%;
+    height: 85%;
+    border-radius: 50%;
+    border: rpx(2) solid rgba(80, 190, 240, 0.6);
+  }
+
+  &.recording {
+    &::before {
+      animation: pulse 2s infinite;
+      border-color: rgba(0, 100, 192, 0.6);
+    }
+
+    &::after {
+      content: '';
+      position: absolute;
+      width: 85%;
+      height: 85%;
+      border-radius: 50%;
+      border: rpx(2) solid rgba(0, 100, 192, 0.4);
+      animation: pulse 2s infinite 0.5s;
+    }
+
+    :deep(.voice-input-container) {
+      .speech-btn {
+        background: linear-gradient(135deg, #A0DCF0, #50BEF0);
+        border-color: rgba(0, 100, 192, 1);
+
+        .el-icon {
+          color: #0064BE;
+        }
+      }
+    }
+  }
+
+  &.placeholder {
+    visibility: hidden;
+  }
+
+  :deep(.voice-input-container) {
+    position: relative;
+    z-index: 10;
+
+    .speech-btn {
+      width: rpx(33);
+      height: rpx(33);
+      border-radius: 50%;
+      border: rpx(2) solid rgba(0, 100, 192);
+      background: linear-gradient(135deg, #A0DCF0, #50BEF0);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 0;
+      gap: 0;
+      transition: all 0.3s ease;
+      cursor: pointer;
+      position: relative;
+      overflow: hidden;
+
+      &:hover {
+        transform: scale(1.05);
+        box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
+      }
+
+      &:active {
+        transform: scale(0.95);
+      }
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        width: 0;
+        height: 0;
+        border-radius: 50%;
+        background: rgba(255, 255, 255, 0.5);
+        transform: translate(-50%, -50%);
+        transition: width 0.6s, height 0.6s;
+      }
+
+      &:active::before {
+        width: rpx(80);
+        height: rpx(80);
+      }
+
+      .el-icon {
+        font-size: rpx(18);
+        color: #0064BE;
+        z-index: 1;
+      }
+
+      .countdown-text {
+        display: block;
+        font-size: rpx(8);
+        color: #0064BE;
+        position: absolute;
+        bottom: rpx(5);
+        left: 50%;
+        transform: translateX(-50%);
+      }
+    }
+  }
+}
+
+@keyframes pulse {
+  0% {
+    transform: scale(1);
+    opacity: 1;
+  }
+  100% {
+    transform: scale(1.15);
+    opacity: 0;
+  }
+}
+</style>

+ 13 - 2250
src/views/AIPage/aiGenerate/DialogContent.vue

@@ -1,205 +1,15 @@
 <template>
-  <div class="dialog-content-wrapper">
-    <!-- 遮罩层 -->
-    <div v-if="showMask" class="mask-layer" ref="maskLayer">
-      <div class="play-button-container">
-        <button class="play-button" @click="startPlayback">
-          <el-icon class="play-icon"><VideoPlay /></el-icon>
-        </button>
-      </div>
-    </div>
-    <!-- 标题 -->
-    <div class="title-box">
-      <!-- 返回 -->
-      <div class="title-left">
-        <div class="box-icon" @click="goBackToMain">
-          <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
-          {{ backText }}
-        </div>
-      </div>
-      <!-- 标题 -->
-      <div class="title-center">
-        <div class="title-text" :title="currentSection?.name">
-          {{ currentSection?.name }}
-        </div>
-      </div>
-      <!-- 自动按钮 -->
-      <div class="title-right">
-        <div class="box-icon" @click="togglePlay">
-          <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
-        </div>
-      </div>
-    </div>
-
-    <!-- 内容区域 -->
-    <div class="content-box">
-      <!-- 人物形象 -->
-      <div
-        v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
-        :key="`character-${currentDialogueIndex}`"
-        class="character"
-        :class="{
-          'left': getCharacterSide(currentDialogue.roleName) === 'left',
-          'right': getCharacterSide(currentDialogue.roleName) === 'right'
-        }"
-        :style="{ backgroundImage: `url(${getCharacterImage(currentDialogue.roleName)})` }"
-      ></div>
-
-      <!-- 对话卡片 -->
-      <div
-        v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
-        :key="`dialogue-${currentDialogueIndex}`"
-        class="dialogue-card"
-        :class="{
-          'left': getCharacterSide(currentDialogue.roleName) === 'left',
-          'right': getCharacterSide(currentDialogue.roleName) === 'right'
-        }"
-      >
-        <div class="dialogue-header">
-          <span class="role-name">{{ currentDialogue?.roleName }}</span>
-        </div>
-        <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
-      </div>
-
-      <!-- 诗词显示区域 -->
-      <div v-if="showPoem" class="poem-display">
-        <div class="poem-content">
-          <div class="poem-text" v-html="formatPoemContent(currentPoemContent)" ></div>
-        </div>
-      </div>
-
-      <!-- 视频显示区域 -->
-      <div v-if="currentDialogue && currentDialogue.type === 'video'" class="video-display">
-        <div class="video-frame">
-          <video
-            :src="currentDialogue.videoUrl"
-            class="dialogue-video"
-            ref="dialogueVideoRef"
-            controls
-            autoplay
-            @ended="handleVideoEnded"
-            @contextmenu.prevent
-            controlslist="nodownload"
-          >
-            您的浏览器不支持视频播放
-          </video>
-        </div>
-      </div>
-
-      <!-- 用户输入卡片 -->
-      <div
-        v-if="currentDialogue.type === 'user' && !isUserSingleChoice"
-        class="dialogue-card user-input-card"
-      >
-        <div class="dialogue-header">
-          <span class="role-name">我</span>
-        </div>
-        <div class="dialogue-content">
-          <textarea
-            :value="userInput"
-            @input="e => userInput = e.target.value"
-            class="user-input-textarea"
-            placeholder="请输入内容..."
-            @keyup.enter.exact="submitUserInput"
-          ></textarea>
-          <div class="input-actions">
-            <button class="cancel-btn" @click="cancelUserInput">清空</button>
-            <button class="submit-btn" @click="submitUserInput">发送</button>
-          </div>
-        </div>
-      </div>
-
-      <!-- 单选问题卡片(在user类型时显示) -->
-      <div
-        v-if="isUserSingleChoice"
-        class="dialogue-card single-choice-card"
-      >
-        <div class="dialogue-header">
-          <span class="role-name">我</span>
-        </div>
-        <div class="single-choice-content">
-          <!-- 问题描述 -->
-          <div class="question-text" v-html="parseMarkdown(previousQuestDialogue?.content || '')"></div>
-          <!-- 选项列表 -->
-          <div class="options-list">
-            <div
-              v-for="(option, index) in previousQuestDialogue?.options"
-              :key="index"
-              class="option-item"
-              :class="{ 'selected': selectedOption === optionLabels[index] }"
-              @click="selectOption(optionLabels[index])"
-            >
-              <span class="option-label">{{ optionLabels[index] }}</span>
-              <span class="option-content">{{ option.content }}</span>
-            </div>
-          </div>
-          <!-- 操作按钮 -->
-          <div class="input-actions">
-            <button class="cancel-btn" @click="cancelSingleChoice">清空选择</button>
-            <button class="submit-btn" :disabled="!selectedOption" @click="submitSingleChoice">提交答案</button>
-          </div>
-        </div>
-      </div>
-
-      <!-- 输入按钮区域 -->
-      <div class="input-buttons-container" >
-        <!-- 上一个对话按钮 -->
-        <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
-          <el-icon class="arrow-icon"><CaretLeft /></el-icon>
-        </div>
-        <!-- 语音输入按钮 -->
-        <div class="voice-input-outer" v-if="currentDialogue.type === 'user' && !isUserSingleChoice" :class="{ 'recording': isVoiceRecording }">
-          <VoiceInput
-            inputSelector=".user-input-textarea"
-            lang="zh-CN"
-            maxDuration="10"
-            @voiceRecognized="handleVoiceRecognized"
-            @recordingStatusChanged="handleRecordingStatusChanged"
-          />
-        </div>
-        <!-- 语音输入按钮占位符 -->
-        <div class="voice-input-outer placeholder" v-else></div>
-        <!-- 下一个对话按钮 -->
-        <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': isAtLastDialogue() }">
-          <el-icon class="arrow-icon"><CaretRight /></el-icon>
-        </div>
-      </div>
-    </div>
-
-    <!-- 背景图 -->
-    <img 
-      v-if="currentBackgroundType === 'imageAudio'" 
-      :src="currentBackgroundImage" 
-      alt="背景图" 
-      class="background-image"
-    >
-    <!-- 背景视频 -->
-    <video 
-      v-else-if="currentBackgroundType === 'video'"  
-      ref="backgroundVideoRef" 
-      :src="currentBackgroundVideo" 
-      class="background-video"  
-      loop 
-      muted 
-      playsinline>
-      您的浏览器不支持视频播放
-    </video>
-  </div>
+  <DialogEngine
+    :script-data="scriptData"
+    :script-roles="scriptRoles"
+    :back-text="backText"
+    :is-last-course="isLastCourse"
+    @dialogue-ended="handleDialogueEnded"
+  />
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ArrowLeftBold, CaretLeft, CaretRight, Grid, VideoPlay } from '@element-plus/icons-vue'
-import VoiceInput from '@/components/ai/voice/VoiceInput_Api.vue'
-import { marked } from 'marked'
-import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
-import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
-
-const { playAudioChunk , stopPlayback, setOnPlaybackComplete, getIsPlaying  } = useAudioPlayer();
-
-// 路由实例
-const router = useRouter()
+import DialogEngine from '../../../components/aiCourse/DialogEngine.vue';
 
 const props = defineProps({
   scriptData: {
@@ -217,2066 +27,19 @@ const props = defineProps({
     type: String,
     default: '返回课程'
   },
-  // 是否是最后一节课
   isLastCourse: {
     type: Boolean,
     default: false
   }
-})
-
-const emit = defineEmits(['dialogueEnded'])
-
-// 对话相关状态
-// 当前章节索引
-const currentSectionIndex = ref(0)
-// 当前对话索引
-const currentDialogueIndex = ref(0)
-// 是否正在播放
-const isPlaying = ref(false)
-// 用户输入内容
-const userInput = ref('')
-// 语音录音状态
-const isVoiceRecording = ref(false)
-// 实时语音识别结果
-const voiceRecognizedText = ref("")
-// 诗词显示状态
-const showPoem = ref(false)
-// 当前诗词内容
-const currentPoemContent = ref('')
-// 单选问题选中答案
-const selectedOption = ref('')
-// 选项标签映射
-const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
-
-// 音频对象
-// 背景音频
-const backgroundAudio = ref(null)
-// 对话音频
-const dialogueAudio = ref(null)
-// 背景视频
-const backgroundVideoRef = ref(null)
-// 对话视频
-const dialogueVideoRef = ref(null)
-// 遮罩层显示状态
-const showMask = ref(true)
-// 是否开始播放
-const isPlaybackStarted = ref(false)
-
-// 计算属性
-// 当前章节信息
-const currentSection = computed(() => {
-  return props.scriptData.sections[currentSectionIndex.value]
-})
-// 当前对话信息
-const currentDialogue = computed(() => {
-  if (!currentSection.value) return null
-  return currentSection.value.dialogues[currentDialogueIndex.value]
-})
-
-const currentBackgroundImage = computed(() => {
-  if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
-    return ''
-  }
-  return currentSection.value.backgroundImage.url
-})
-
-
-// 当前背景类型
-const currentBackgroundType = computed(() => {
-  return currentSection.value?.backgroundType || 'imageAudio'
-})
-
-// 当前背景视频
-const currentBackgroundVideo = computed(() => {
-  if (!currentSection.value || !currentSection.value.backgroundVideo || !currentSection.value.backgroundVideo.url) {
-    return ''
-  }
-  return currentSection.value.backgroundVideo.url
-})
-
-// 当前对话缓存
-const currentDialogueCache = ref(null)
-
-// 方法
-const handleVoiceRecognized = (data) => {
-  console.log('语音识别结果:', data.originalText)
-  if (isVoiceRecording.value) {
-    // 在同一次录音过程中,实时更新文本框内容
-    voiceRecognizedText.value = data.originalText
-    userInput.value = data.processedText
-  } else {
-    // 在录音结束时,将最终的语音内容追加到userInput.value
-    const textarea = document.querySelector('.user-input-textarea')
-    if (textarea) {
-      userInput.value = data.processedText
-      // 重新设置光标位置到插入文本的末尾
-      setTimeout(() => {
-        textarea.selectionStart = textarea.selectionEnd = data.cursorPos
-      }, 0)
-    } else {
-      // 如果没有找到输入框,直接替换整个内容
-      userInput.value = data.originalText
-    }
-    // 清空临时变量
-    voiceRecognizedText.value = ""
-  }
-}
-
-// 处理录音状态变化
-const handleRecordingStatusChanged = (isRecording) => {
-  console.log('录音状态:', isRecording)
-  const wasRecording = isVoiceRecording.value
-  isVoiceRecording.value = isRecording
-
-  // 如果是从录音状态切换到非录音状态,只需要清空临时变量
-  if (wasRecording && !isRecording) {
-    // 清空临时变量
-    voiceRecognizedText.value = ""
-  }
-}
-
-// 获取上一条quest对话
-const previousQuestDialogue = computed(() => {
-  const prevDialogue = getPreviousDialogue()
-  if (prevDialogue?.type === 'quest') {
-    return prevDialogue
-  }
-  return null
-})
-
-// 判断当前user对话是否应该显示单选框
-const isUserSingleChoice = computed(() => {
-  if (currentDialogue.value?.type !== 'user') return false
-  const prevDialogue = previousQuestDialogue.value
-  return prevDialogue?.questionType === 'singleChoice' && 
-         prevDialogue?.options && 
-         prevDialogue.options.length > 0
-})
-
-// 判断是否是单选问题(quest类型时)
-const isSingleChoiceQuestion = computed(() => {
-  return currentDialogue.value?.type === 'quest' && 
-         currentDialogue.value?.questionType === 'singleChoice' && 
-         currentDialogue.value?.options && 
-         currentDialogue.value.options.length > 0
-})
-
-// 提交用户输入
-const submitUserInput = async () => {
-  if (userInput.value.trim()) {
-    console.log('用户输入:', userInput.value)
-    
-    await createAiChart();
-    await doSendMessage();
-  }
-}
-
-// 取消用户输入
-const cancelUserInput = () => {
-  userInput.value = ''
-}
-
-// 选择单选选项
-const selectOption = (label) => {
-  selectedOption.value = label
-}
-
-// 取消单选选择
-const cancelSingleChoice = () => {
-  selectedOption.value = ''
-}
-
-// 提交单选答案
-const submitSingleChoice = async () => {
-  if (!selectedOption.value) return
-  
-  console.log('用户选择:', selectedOption.value)
-  
-  await createAiChart();
-  await doSendSingleChoiceMessage();
-}
-
-// 解析 Markdown 内容
-const parseMarkdown = (content) => {
-  if (!content) return ''
-  return marked(content)
-}
-
-// 格式化诗词内容,在逗号和句号后添加换行
-const formatPoemContent = (content) => {
-  if (!content) return ''
-  const plainText = content.replace(/[\*#`\[\]\(\)]/g, '')
-  return plainText.replace(/([。!?;、])/g, '$1<br/>')
-}
-
-const getCharacterSide = () => {
-  return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
-  
-}
-
-// 根据角色ID获取角色名称
-const getRole = (roleName) => {
-  return props.scriptRoles.find(r => r.name === roleName)
-}
-
-const getCharacterImage = (roleName) => {
-  const role = getRole(roleName)
-  return role ? role.avatar : ''
-}
-
-const stopAllAudio = () => {
-  if (backgroundAudio.value) {
-    backgroundAudio.value.pause()
-    backgroundAudio.value.currentTime = 0
-  }
-  if (dialogueAudio.value) {
-    dialogueAudio.value.pause()
-    dialogueAudio.value.currentTime = 0
-  }
-}
-
-const playBackgroundAudio = () => {
-  // 停止之前的背景音
-  if (backgroundAudio.value) {
-    backgroundAudio.value.pause()
-    backgroundAudio.value.currentTime = 0
-  }
-  
-  // 只有当背景类型为 imageAudio 时才播放背景音频
-  if (currentBackgroundType.value === 'imageAudio' && currentSection.value?.backgroundAudio?.url && isPlaying.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 (currentBackgroundType.value === 'video' && isPlaybackStarted.value) {
-    // 视频已经在模板中渲染,这里只需要确保它在播放状态
-    if (backgroundVideoRef.value) {
-      backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
-    }
-  }
-}
-
-const playDialogueAudio = (isAutoPlay = false) => {
-  // 停止之前的对话语音
-  if (dialogueAudio.value) {
-    dialogueAudio.value.pause()
-    dialogueAudio.value.currentTime = 0
-  }
-
-  // 播放当前对话的语音
-  if (currentDialogue.value?.voiceoverUrl && currentDialogue.value?.type !== 'video') {
-    const audio = new Audio(currentDialogue.value.voiceoverUrl)
-    dialogueAudio.value = audio
-
-    // 音频结束事件
-    audio.onended = () => {
-      // 检查是否是最后一个对话
-      if (isAtLastDialogue()) {
-        // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
-        if (currentDialogue.value?.type === 'user') {
-          console.log('用户输入类型,等待AI回答完成后再提示');
-          return;
-        }
-        // 语音播报完成且是最后一个对话,触发事件通知父组件
-        console.log('普通对话语音播放完成,已到达最后一个对话,触发 dialogueEnded 事件');
-        emit('dialogueEnded', props.isLastCourse);
-        isPlaying.value = false;
-        return;
-      }
-
-      // 如果是自动播放状态,继续播放下一条
-      if (isAutoPlay && isPlaying.value) {
-        setTimeout(() => {
-          if (!playNext(true)) {
-            // 播放完毕
-            isPlaying.value = false
-            stopAllAudio()
-          }
-        }, 100)
-      }
-    }
-
-    // 播放音频
-    audio.play().catch(e => {
-      console.error('对话语音播放失败:', e)
-      // 播放失败时,检查是否是最后一个对话
-      if (isAtLastDialogue()) {
-        // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
-        if (currentDialogue.value?.type === 'user') {
-          console.log('用户输入类型,等待AI回答完成后再提示');
-          return;
-        }
-        emit('dialogueEnded', props.isLastCourse);
-        isPlaying.value = false;
-        return;
-      }
-      // 播放失败时,2秒后跳转
-      if (isAutoPlay && isPlaying.value) {
-        setTimeout(() => {
-          if (!playNext(true)) {
-            // 播放完毕
-            isPlaying.value = false
-            stopAllAudio()
-          }
-        }, 2000)
-      }
-    })
-  } else if (currentDialogue.value?.type !== 'video') {
-    // 检查是否是最后一个对话
-    if (isAtLastDialogue()) {
-      // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
-      if (currentDialogue.value?.type === 'user') {
-        console.log('用户输入类型,等待AI回答完成后再提示');
-        return;
-      }
-      emit('dialogueEnded', props.isLastCourse);
-      isPlaying.value = false;
-      return;
-    }
-    // 如果没有语音文件,2秒后跳转
-    if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
-      setTimeout(() => {
-        if (!playNext(true)) {
-          // 播放完毕
-          isPlaying.value = false
-          stopAllAudio()
-        }
-      }, 2000)
-    }
-  }
-}
-
-const togglePlay = () => {
-  isPlaying.value = !isPlaying.value
-  if (isPlaying.value) {
-    // 播放背景音
-    playBackgroundAudio()
-    if(!getIsPlaying() && !conversationInProgress.value){
-      if(currentDialogue.value?.type === 'video'){
-        // 视频类型对话,播放视频
-        if (dialogueVideoRef.value) {
-          dialogueVideoRef.value.play().catch(e => console.error('对话视频播放失败:', e))
-        }
-      } else {
-        // 开始播放序列
-        playSequence()
-      }
-    }
-  } else {
-    // 暂停所有音频
-    stopAllAudio()
-  }
-}
-
-// 处理视频结束事件
-const handleVideoEnded = () => {
-  // 视频播放完毕后,继续播放下一条对话
-  if (isPlaying.value) {
-    setTimeout(() => {
-      if (!playNext(true)) {
-        // 播放完毕,检查是否是最后一个对话
-        if (isAtLastDialogue()) {
-          console.log('视频序列:已到达最后一个对话,触发 dialogueEnded 事件');
-          emit('dialogueEnded', props.isLastCourse);
-        }
-        isPlaying.value = false
-        stopAllAudio()
-      }
-    }, 1500)
-  }
-}
-
-// 自动播放序列
-const playSequence = () => {
-  if (!isPlaying.value) return
-
-  // 如果当前是用户输入卡片,暂停播放等待用户输入
-  if (currentDialogue.value?.type === 'user') return
-
-  // 检查当前对话是否为视频类型
-  if (currentDialogue.value?.type === 'video') {
-    // 视频类型对话,不自动播放下一条,等待视频结束
-    return
-  }
-
-  // 检查当前对话是否为诗词类型
-  if (currentDialogue.value?.type === 'poem') {
-    // 显示诗词并替换内容为最新的诗词
-    showPoem.value = true
-    currentPoemContent.value = currentDialogue.value.content
-
-    // 检查是否有语音
-    if (currentDialogue.value?.voiceoverUrl) {
-      // 播放诗词语音
-      playDialogueAudio(true)
-    } else {
-      // 没有语音,直接切换到下一条对话
-      setTimeout(() => {
-        if (!playNext(true)) {
-          // 播放完毕,检查是否是最后一个对话
-          if (isAtLastDialogue()) {
-            console.log('诗词序列:已到达最后一个对话,触发 dialogueEnded 事件');
-            emit('dialogueEnded', props.isLastCourse);
-          }
-          // 播放完毕
-          isPlaying.value = false
-          stopAllAudio()
-        }
-      }, 500)
-    }
-  } else if (currentDialogue.value?.type === 'video') {
-    // 视频类型对话,不自动播放下一条,等待视频结束事件
-    return
-  } else {
-    // 播放当前对话语音,传递isAutoPlay参数
-    playDialogueAudio(true)
-  }
-}
-
-const playPrevious = () => {
-  // 如果已经到达第一句,直接返回,不执行任何操作
-  if (currentDialogueIndex.value === 0 && currentSectionIndex.value === 0) {
-    return
-  }
-  
-  // 停止当前音频
-  stopAllAudio()
-
-  // 如果正在进行数字人对话,调用stopStream清理
-  recoverQuestDialogue()
-  stopPlayback(false) // 不触发回调
-  if (conversationInProgress.value) {
-    stopStream()
-  }
-
-  if (currentDialogueIndex.value > 0) {
-    currentDialogueIndex.value--
-  } else if (currentSectionIndex.value > 0) {
-    currentSectionIndex.value--
-    const section = props.scriptData.sections[currentSectionIndex.value]
-    currentDialogueIndex.value = section.dialogues.length - 1
-    // 切换环节时隐藏诗词
-    showPoem.value = false
-    currentPoemContent.value = ''
-  }
-
-  // 检查当前对话是否为诗词类型
-  if (currentDialogue.value?.type === 'poem') {
-    // 显示诗词并替换内容为最新的诗词
-    showPoem.value = true
-    currentPoemContent.value = currentDialogue.value.content
-  } else if (currentDialogue.value?.type === 'video') {
-    // 视频类型对话,隐藏诗词
-    showPoem.value = false
-    currentPoemContent.value = ""
-  } else {
-    showPoem.value = false
-    currentPoemContent.value = ""
-    //读取上一条诗词内容显示
-    for (let i = currentDialogueIndex.value; i >= 0; i--) {
-      let dialogueTemp = currentSection.value.dialogues[i];
-      if (dialogueTemp.type === 'poem'){
-        // 显示诗词并替换内容为最新的诗词
-        showPoem.value = true
-        currentPoemContent.value = dialogueTemp.content
-      }
-    }
-  }
-
-  // 播放背景音
-  nextTick(() => {
-    playBackgroundAudio()
-    // 播放当前对话语音(非诗词类型)
-    if (currentDialogue.value?.type !== 'poem') {
-      if (isPlaying.value) {
-        playDialogueAudio(true)
-      } else {
-        playDialogueAudio()
-      }
-    }
-  })
-}
-
-const playNext = (isAutoPlay = false) => {
-  // 如果已经到达最后一句,直接返回,不执行任何操作
-  if (isAtLastDialogue()) {
-    return false
-  }
-  
-  // 停止当前对话语音
-  if (dialogueAudio.value) {
-    dialogueAudio.value.pause()
-    dialogueAudio.value.currentTime = 0
-  }
-  
-  // 如果正在进行数字人对话,调用stopStream清理
-  recoverQuestDialogue()
-  stopPlayback(false)
-  if (conversationInProgress.value) {
-    stopStream()
-  }
-
-  if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
-    currentDialogueIndex.value++
-    
-    // 检查当前对话是否为诗词类型
-    if (currentDialogue.value?.type === 'poem') {
-      // 显示诗词并替换内容为最新的诗词
-      showPoem.value = true
-      currentPoemContent.value = currentDialogue.value.content
-
-      // 检查是否有语音
-      if (currentDialogue.value?.voiceoverUrl) {
-        // 播放诗词语音
-        if (isPlaying.value) {
-          playDialogueAudio(true)
-        } else {
-          playDialogueAudio()
-        }
-      } else {
-        // 没有语音,直接切换到下一条对话
-        setTimeout(() => {
-          playNext(isAutoPlay)
-        }, 500)
-      }
-      return true
-    } else if (currentDialogue.value?.type === 'video') {
-      // 视频类型对话,隐藏诗词,不自动播放下一条
-      showPoem.value = false
-      currentPoemContent.value = ''
-      return true
-    }
-    
-    // 根据是否为自动播放状态决定如何播放语音
-    if (isPlaying.value) {
-      playDialogueAudio(true)
-    } else {
-      playDialogueAudio()
-    }
-    return true
-  } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
-    currentSectionIndex.value++
-    currentDialogueIndex.value = 0
-    // 切换环节时隐藏诗词
-    showPoem.value = false
-    currentPoemContent.value = ''
-    // 播放背景音
-    nextTick(() => {
-      playBackgroundAudio()
-      // 检查新环节的第一个对话是否为诗词类型
-      if (currentDialogue.value?.type === 'poem') {
-        // 显示诗词
-        showPoem.value = true
-        currentPoemContent.value = currentDialogue.value.content
-
-        // 检查是否有语音
-        if (currentDialogue.value?.voiceoverUrl) {
-          // 播放诗词语音
-          if (isPlaying.value) {
-            playDialogueAudio(true)
-          } else {
-            playDialogueAudio()
-          }
-        } else {
-          // 没有语音,直接切换到下一条对话
-          setTimeout(() => {
-            playNext(isAutoPlay)
-          }, 500)
-        }
-      } else if (currentDialogue.value?.type === 'video') {
-        // 视频类型对话,隐藏诗词
-        showPoem.value = false
-        currentPoemContent.value = ''
-      } else {
-        // 根据是否为自动播放状态决定如何播放语音
-        if (isPlaying.value) {
-          playDialogueAudio(true)
-        } else {
-          playDialogueAudio()
-        }
-      }
-    })
-    return true
-  }
-  return false
-}
-
-// 返回主页按钮点击事件
-const goBackToMain = () => {
-  // 停止所有音频
-  stopAllAudio()
-  // 跳转到 ai-general-course 页面,保持侧边栏选中状态
-  router.push('/ai-general-course')
-}
-
-// 开始播放
-const maskLayer = ref(null)
-const startPlayback = () => {
-  // 设置开始播放状态
-  isPlaybackStarted.value = true
-
-  // 开始播放背景视频
-  if (backgroundVideoRef.value) {
-    backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
-  }
-
-  // 消失动画
-  if (maskLayer.value) {
-    maskLayer.value.classList.add('fade-out')
-    // 等待动画完成后隐藏遮罩层
-    setTimeout(() => {
-      showMask.value = false
-    }, 500)
-  }
-  
-  // 检查当前对话是否为诗词类型
-  if (currentDialogue.value?.type === 'poem') {
-    // 显示诗词
-    showPoem.value = true
-    currentPoemContent.value = currentDialogue.value.content
-
-    // 检查是否有语音
-    if (currentDialogue.value?.voiceoverUrl) {
-      // 播放诗词语音
-      playDialogueAudio()
-    } else {
-      // 没有语音,直接切换到下一条对话
-      setTimeout(() => {
-        playNext()
-      }, 500)
-    }
-  } else if (currentDialogue.value?.type === 'video') {
-    // 视频类型对话,隐藏诗词
-    showPoem.value = false
-    currentPoemContent.value = ''
-  } else {
-    // 播放当前对话语音
-    playDialogueAudio()
-  }
-}
+});
 
-// 键盘事件处理,键盘左右箭头控制对话
-const handleKeydown = (event) => {
-  // 如果遮罩层显示,不处理键盘事件
-  if (showMask.value) {
-    return
-  }
-  
-  // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
-  if (currentDialogue.value?.type === 'user') {
-    return
-  }
-  
-  // 处理左右箭头键
-  switch (event.key) {
-    case 'ArrowLeft':
-      playPrevious()
-      event.preventDefault() // 防止默认行为
-      break
-    case 'ArrowRight':
-      playNext()
-      event.preventDefault() // 防止默认行为
-      break
-    default:
-      // 其他按键不做处理
-      break
-  }
-}
-
-// 监听环节变化
-watch(currentSectionIndex, () => {
-  playBackgroundAudio()
-})
-
-// 监听 scriptData 变化(侧边栏切换话题时)
-watch(() => props.scriptData, (newVal, oldVal) => {
-  if (newVal && oldVal && newVal !== oldVal) {
-    // 停止所有音频
-    stopAllAudio()
-    // 如果正在进行数字人对话,调用stopStream清理
-    recoverQuestDialogue()
-    stopPlayback(false)
-    if (conversationInProgress.value) {
-      stopStream()
-    }
-    // 清空索引
-    currentSectionIndex.value = 0
-    currentDialogueIndex.value = 0
-    // 重置播放状态
-    isPlaying.value = false
-    // 显示遮罩层
-    showMask.value = true
-    // 清空用户输入
-    userInput.value = ''
-    // 清空对话缓存
-    currentDialogueCache.value = null
-    // 隐藏诗词
-    showPoem.value = false
-    currentPoemContent.value = ''
-  }
-}, { deep: true })
-
-// 会话ID
-const activeConversationId = ref(null)
-const conversationInProgress = ref(false)
-const conversationInAbortController = ref()
-const receiveMessageFullText = ref('')
-
-//创建对话
-const createAiChart = async () => {
-  let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
-  // 智能问答
-  await CreateDialogue({ roleId: role.id })
-      .then(res => {
-        console.log("创建会话:", res.data);
-        activeConversationId.value = res.data
-      })
-      .catch(error => {
-        console.error('请求出错:', error)
-      })
-}
-
-/** 真正执行【发送】消息操作 */
-const doSendMessage = async () => {
-  // 校验
-  if (userInput.value.length < 1) {
-    console.error('发送失败,原因:内容为空!')
-    return
-  }
-
-  if (activeConversationId.value == null) {
-    console.error('还没创建对话,不能发送!')
-    return
-  }
-
-  let userInputTemp = userInput.value;
-  let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
-  userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题回复我回答的内容是否正确,并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
-  // 执行发送
-  await doSendMessageStream({
-    conversationId: activeConversationId.value,
-    content: userInputTemp,
-    contentAnswer: null,
-  })
-  // 清空输入框
-  userInput.value = ''
-}
-
-/** 发送单选问题消息 */
-const doSendSingleChoiceMessage = async () => {
-  if (activeConversationId.value == null) {
-    console.error('还没创建对话,不能发送!')
-    return
-  }
-
-  // 使用上一条quest对话的数据
-  const dialogue = previousQuestDialogue.value
-  if (!dialogue) {
-    console.error('找不到上一条quest对话!')
-    return
-  }
-  
-  // 构建选项字符串
-  const optionsStr = dialogue.options.map((opt, idx) => `${optionLabels[idx]}. ${opt.content}`).join(';')
-  
-  // 构建发送内容:包含问题、选项、用户答案
-  const content = `问题:${dialogue.content}\n选项:${optionsStr}\n我的答案:${selectedOption.value}\n正确答案:${dialogue.answer}\n\n请判断我的答案是否正确,并给予鼓励或夸赞,回复请精简,控制在50字内。`
-  
-  console.log('发送单选问题:', content)
-  
-  // 执行发送
-  await doSendMessageStream({
-    conversationId: activeConversationId.value,
-    content: content,
-    contentAnswer: null,
-  })
-  
-  // 清空选择
-  selectedOption.value = ''
-}
-
-// 获取上一条对话
-const getPreviousDialogue = () => {
-  const section = props.scriptData.sections[currentSectionIndex.value]
-  if (!section) return null
-  if (currentDialogueIndex.value > 0) {
-    return section.dialogues[currentDialogueIndex.value - 1]
-  }
-  return null
-}
+const emit = defineEmits(['dialogueEnded']);
 
-
-
-/** 显示问题回答对话 */
-const showQuestAnswerDialogue = () => {
-  // 缓存当前对话
-  currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
-
-  // 将当前对话类型设置为数字人对话
-  currentDialogue.value.type = "digital"
-  // 设置默认内容为"让我思考一下..."
-  currentDialogue.value.content = "让我思考一下..."
-
-}
-
-//延时恢复对话,避免立即回复导致对话内容被覆盖
-const delayRecoverQuestDialogue = () => {
-  // Message().error('当前网络无反应,请稍后重试!', true);
-  // 设置默认内容为"让我思考一下..."
-  currentDialogue.value.content = "当前网络无反应,请稍后重试!"
-
-  setTimeout(() => {
-    recoverQuestDialogue()
-  }, 1500)
-}
-
-/** 回复对话 */
-const recoverQuestDialogue = () => {
-  // 如果有缓存的对话
-  if (currentDialogueCache.value){
-    // 恢复当前对话
-    const currentSection = props.scriptData.sections[currentSectionIndex.value]
-    if (currentSection) {
-      currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
-    }
-    // 清空缓存
-    currentDialogueCache.value = null
-  }
-}
-
-/** 真正执行【发送】消息操作 */
-const doSendMessageStream = async userMessage => {
-  // 创建 AbortController 实例,以便中止请求
-  conversationInAbortController.value = new AbortController()
-  // 标记对话进行中
-  conversationInProgress.value = true
-  // 设置为空
-  receiveMessageFullText.value = ''
-
-  showQuestAnswerDialogue()
-
-  try {
-
-    // 发送 event stream
-    let isFirstChunk = true // 是否是第一个 chunk 消息段
-    await sendChatMessageStream(
-        userMessage.conversationId,
-        userMessage.content,
-        userMessage.contentAnswer,
-        conversationInAbortController.value,
-        true, // enableContext 参数
-        async res => {
-          const { code, data, msg } = JSON.parse(res.data)
-          if (code !== 0) {
-            console.log(`对话异常! ${msg}`)
-            stopStream();
-            delayRecoverQuestDialogue()
-            return
-          }
-
-          if (data.eventType === 'TEXT') {
-
-            // 如果内容为空,就不处理。
-            if (data.receive?.content === '') {
-              return
-            }
-            receiveMessageFullText.value += data.receive.content
-            // 更新数字人对话框内容
-            currentDialogue.value.content = receiveMessageFullText.value
-            // 首次返回需要添加一个 message 到页面,后面的都是更新
-            if (isFirstChunk) {
-              isFirstChunk = false
-              //第一次返回
-            } else {
-              //更新最后一条消息
-            }
-          }
-          if (data.eventType === 'AUDIO') {
-            // 处理音频消息
-            await playAudioChunk(data.audioData);
-          }
-        },
-        error => {
-          console.log(`对话异常! ${error}`)
-          stopStream()
-
-          delayRecoverQuestDialogue()
-          // 需要抛出异常,禁止重试
-          throw error
-        },
-        () => {
-          console.log(`结束对话! `)
-          stopStream()
-          // AI回答完成,检查是否是最后一个对话且是用户输入类型
-          if (isAtLastDialogue() && currentDialogue.value?.type === 'user') {
-            console.log('AI回答完成,触发 dialogueEnded 事件');
-            emit('dialogueEnded', props.isLastCourse);
-            isPlaying.value = false;
-          }
-        }
-    )
-  } catch (error) {
-    console.error('发送消息失败:', error)
-    stopStream()
-    delayRecoverQuestDialogue()
-  }
-}
-
-/** 停止 stream 流式调用 */
-const stopStream = async () => {
-  // 如果 stream 进行中的 message,就需要调用 controller 结束
-  if (conversationInAbortController.value) {
-    conversationInAbortController.value.abort()
-  }
-  // 销毁语音读取
-  // stopPlayback();
-  // 设置为 false
-  conversationInProgress.value = false
-
-  console.log(`结束对话!更改状态: `,conversationInProgress.value)
-}
-
-// 处理音频播放完成
-const handleAudioPlaybackComplete = () => {
-  console.log('智能问答音频播放完成');
-
-  // 先清除回调,防止 playNext 内部调用 stopPlayback(false) 时
-  // 关闭 audioContext 触发 source.onended → processAudioQueue → 再次触发回调,造成二次跳转
-  setOnPlaybackComplete(null);
-
-  stopAllAudio();
-
-  // 检查是否是最后一个对话
-  if (isAtLastDialogue()) {
-    // 语音播报完成且是最后一个对话,触发事件通知父组件
-    console.log('已到达最后一个对话,触发 dialogueEnded 事件');
-    emit('dialogueEnded', props.isLastCourse);
-    isPlaying.value = false;
-    return;
-  }
-
-  // 如果处于自动播放状态,继续播放下一条对话
-  if (isPlaying.value) {
-    // 恢复回调,供下一条 AI 对话使用
-    setOnPlaybackComplete(handleAudioPlaybackComplete);
-    if (playNext(true)) {
-      // playNext 内部已调用 playDialogueAudio(true),无需再调 playSequence
-    } else {
-      // 播放完毕
-      isPlaying.value = false;
-      stopAllAudio();
-    }
-  }
+const handleDialogueEnded = (isLastCourse) => {
+  emit('dialogueEnded', isLastCourse);
 };
-
-// 判断是否是最后一个对话
-const isAtLastDialogue = () => {
-  if (!props.scriptData.sections || props.scriptData.sections.length === 0) {
-    return false
-  }
-  const lastSectionIndex = props.scriptData.sections.length - 1
-  const lastSection = props.scriptData.sections[lastSectionIndex]
-  if (!lastSection.dialogues || lastSection.dialogues.length === 0) {
-    return false
-  }
-  const lastDialogueIndex = lastSection.dialogues.length - 1
-
-  return currentSectionIndex.value === lastSectionIndex &&
-         currentDialogueIndex.value === lastDialogueIndex
-}
-
-// 组件挂载时添加键盘事件监听
-onMounted(() => {
-  window.addEventListener('keydown', handleKeydown)
-  // 播放背景音
-  playBackgroundAudio()
-  // // 播放当前对话语音
-  // playDialogueAudio()
-  // 设置音频播放完成回调
-  setOnPlaybackComplete(handleAudioPlaybackComplete)
-})
-
-// 组件卸载时移除键盘事件监听和停止音频
-onUnmounted(() => {
-  window.removeEventListener('keydown', handleKeydown)
-  stopAllAudio()
-  stopPlayback(false)
-})
 </script>
 
 <style scoped lang="scss">
-@use "sass:math";
-
-@function rpx($px) {
-  @return math.div($px, 750) * 100vw;
-}
-
-.dialog-content-wrapper {
-  width: 100%;
-  height: 100%;
-  position: absolute;
-  top: 0;
-  left: 0;
-  z-index: 100;
-}
-
-.title-box {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  height: rpx(60);
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  color: white;
-  padding: 0 rpx(20);
-}
-
-.title-left {
-  width: 33.33%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  gap: rpx(5);
-  z-index: 30;
-  .box-icon {
-    display: flex;
-    align-items: center;
-    color: #0064BE;
-    gap: rpx(5);
-    padding: rpx(5) rpx(10);
-    background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-    border: rpx(1) solid rgba(0, 100, 192);
-    border-radius: rpx(30);
-    backdrop-filter: blur(10px);
-    cursor: pointer;
-    transition: all 0.3s ease;
-    font-size: rpx(9);
-    font-weight: 500;
-    width: fit-content;
-  }
-  
-  .box-icon:hover {
-    background-color: rgba(255, 255, 255, 90%);
-    transform: translateX(-3px);
-  }
-  
-  .left-icon {
-    font-size: rpx(12);
-  }
-}
-
-.title-center {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  height: 100%;
-  background-image: url('@/assets/dialogue/number-title.png');
-  background-size: 100% 85%;
-  background-repeat: no-repeat;
-  background-position: center;
-  width: fit-content;
-  padding: 0 rpx(100);
-  min-width: rpx(100);
-  z-index: 10;
-}
-
-.title-text {
-  height: 100%;
-  font-size: rpx(11);
-  font-weight: bold;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  white-space: nowrap;
-}
-
-.title-right {
-    width: 33.33%;
-    height: 100%;
-    display: flex;
-    align-items: center;
-    justify-content: flex-end;
-    gap: rpx(10);
-    z-index: 10;
-    .box-icon {
-      display: flex;
-      align-items: center;
-      color: #0064BE;
-      gap: rpx(5);
-      padding: rpx(5) rpx(10);
-      background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-      border: rpx(1) solid rgba(0, 100, 192);
-      border-radius: rpx(30);
-      backdrop-filter: blur(10px);
-      cursor: pointer;
-      transition: all 0.3s ease;
-      font-size: rpx(9);
-      font-weight: 500;
-      width: fit-content;
-    }
-    
-    .box-icon:hover {
-      background-color: rgba(255, 255, 255, 90%);
-      transform: translateX(-3px);
-    }
-    
-    .left-icon {
-      font-size: rpx(12);
-    }
-    
-    .play-text {
-      font-size: rpx(9);
-      color: #0064BE;
-      font-weight: 500;
-    }
-  }
-
-.background-image {
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-  z-index: 1;
-  position: relative;
-}
-
-.background-video {
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-  z-index: 1;
-  position: relative;
-}
-
-/* 遮罩层样式 */
-.mask-layer {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background-color: rgba(0, 0, 0, 0.7);
-  z-index: 20;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  opacity: 1;
-  transition: all 0.5s ease-out;
-}
-
-.mask-layer.fade-out {
-  opacity: 0;
-  transform: scale(1.1);
-  z-index: -1;
-}
-
-.play-button-container {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-}
-
-.play-button {
-  width: rpx(80);
-  height: rpx(80);
-  border-radius: 50%;
-  border: none;
-  background: transparent;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  cursor: pointer;
-  outline: none;
-  -webkit-tap-highlight-color: transparent;
-}
-
-.play-icon {
-  font-size: rpx(40);
-  color: #A0DCF0;
-  transition: all 0.3s ease;
-  cursor: pointer;
-}
-
-.play-icon:hover {
-  transform: scale(1.2);
-  color: #50BEF0;
-  text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
-}
-
-.content-box {
-  position: absolute;
-  top: rpx(60);
-  left: 0;
-  right: 0;
-  bottom: 0;
-  display: flex;
-  align-items: flex-end;
-  justify-content: center;
-  padding-bottom: rpx(0);
-  z-index: 10;
-}
-
-.character {
-  position: absolute;
-  bottom: 0;
-  width: rpx(135);
-  height: rpx(240);
-  background-size: contain;
-  background-position: bottom;
-  background-repeat: no-repeat;
-  opacity: 0;
-  z-index: 1;
-  // background-color: #fff;
-}
-
-.character.left {
-  left: rpx(17);
-  transform: translateX(-100%);
-  animation: characterEnterLeft 0.8s ease forwards;
-}
-
-.character.right {
-  right: rpx(17);
-  transform: translateX(100%) scaleX(-1);
-  animation: characterEnterRight 0.8s ease forwards;
-}
-
-@keyframes characterEnterLeft {
-  0% {
-    opacity: 0;
-    transform: translateX(-100%) scale(0.8);
-  }
-  70% {
-    opacity: 0.9;
-    transform: translateX(10%) scale(1.05);
-  }
-  100% {
-    opacity: 1;
-    transform: translateX(0) scale(1);
-  }
-}
-
-@keyframes characterEnterRight {
-  0% {
-    opacity: 0;
-    transform: translateX(100%) scale(0.8) scaleX(-1);
-  }
-  70% {
-    opacity: 0.9;
-    transform: translateX(-10%) scale(1.05) scaleX(-1);
-  }
-  100% {
-    opacity: 1;
-    transform: translateX(0) scale(1) scaleX(-1);
-  }
-}
-
-.dialogue-card {
-  background: rgba(255, 255, 255, 0.9);
-  border-radius: rpx(6);
-  padding: rpx(8);
-  max-width: 35%;
-  min-width: rpx(200);
-  box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
-  position: absolute;
-  bottom: rpx(50);
-  width: auto;
-  display: inline-block;
-  z-index: 2;
-}
-
-.dialogue-card.left {
-  left: rpx(145);
-  animation: dialogueEnterLeft 0.6s ease forwards;
-}
-
-.dialogue-card.right {
-  right: rpx(145);
-  animation: dialogueEnterRight 0.6s ease forwards;
-}
-
-.dialogue-card.left::before {
-  content: '';
-  position: absolute;
-  left: rpx(-11.5);
-  bottom: rpx(12);
-  width: 0;
-  height: 0;
-  border-top: rpx(7) solid transparent;
-  border-bottom: rpx(7) solid transparent;
-  border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
-  transform: translateY(0);
-}
-
-.dialogue-card.right::before {
-  content: '';
-  position: absolute;
-  right: rpx(-11.5);
-  bottom: rpx(12);
-  width: 0;
-  height: 0;
-  border-top: rpx(7) solid transparent;
-  border-bottom: rpx(7) solid transparent;
-  border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
-  transform: translateY(0);
-}
-
-@keyframes dialogueEnterLeft {
-  from {
-    opacity: 0;
-    transform: translateX(-rpx(30)) translateY(rpx(20));
-  }
-  to {
-    opacity: 1;
-    transform: translateX(0) translateY(0);
-  }
-}
-
-@keyframes dialogueEnterRight {
-  from {
-    opacity: 0;
-    transform: translateX(rpx(30)) translateY(rpx(20));
-  }
-  to {
-    opacity: 1;
-    transform: translateX(0) translateY(0);
-  }
-}
-
-.dialogue-header {
-  position: absolute;
-  top: rpx(-11);
-  left: rpx(12);
-  background: #409EFF;
-  color: white;
-  padding: rpx(1.2) rpx(6);
-  border-radius: rpx(5);
-  font-size: rpx(8);
-  box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
-}
-
-.dialogue-card.right .dialogue-header {
-  left: rpx(10);
-}
-
-.role-name {
-  font-weight: 600;
-  color: white;
-  font-size: rpx(10);
-}
-
-.dialogue-content {
-  font-size: rpx(12);
-  line-height: 1.2;
-  color: #333;
-  text-align: left;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  width: 100%;
-}
-
-.user-input-card {
-  max-width: 50%;
-  position: absolute;
-  left: 50%;
-  transform: translateX(-50%);
-  bottom: rpx(50);
-  right: auto;
-  animation: dialogueEnterCenter 0.6s ease forwards;
-}
-
-.user-input-card::before {
-  display: none;
-}
-
-.single-choice-card {
-  max-width: 55%;
-  position: absolute;
-  left: 50%;
-  transform: translateX(-50%);
-  bottom: rpx(50);
-  right: auto;
-  animation: dialogueEnterCenter 0.6s ease forwards;
-  z-index: 10;
-}
-
-.single-choice-card::before {
-  display: none;
-}
-
-.single-choice-content {
-  font-size: rpx(12);
-  line-height: 1.4;
-  color: #333;
-  text-align: left;
-  width: 100%;
-}
-
-.question-text {
-  margin-bottom: rpx(10);
-  padding-bottom: rpx(8);
-  border-bottom: rpx(1) dashed #ddd;
-  font-weight: 500;
-}
-
-.options-list {
-  display: flex;
-  flex-direction: column;
-  gap: rpx(6);
-  margin-bottom: rpx(12);
-}
-
-.option-item {
-  display: flex;
-  align-items: center;
-  padding: rpx(8) rpx(12);
-  background: #f8f9fa;
-  border: rpx(1) solid #e9ecef;
-  border-radius: rpx(6);
-  cursor: pointer;
-  transition: all 0.3s ease;
-
-  &:hover {
-    background: #e8f4fd;
-    border-color: #409EFF;
-  }
-
-  &.selected {
-    background: #e8f4fd;
-    border-color: #409EFF;
-    box-shadow: 0 rpx(2) rpx(8) rgba(64, 158, 255, 0.2);
-  }
-}
-
-.option-label {
-  width: rpx(24);
-  height: rpx(24);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #fff;
-  border: rpx(1) solid #ddd;
-  border-radius: 50%;
-  font-size: rpx(10);
-  font-weight: 600;
-  color: #666;
-  margin-right: rpx(8);
-  flex-shrink: 0;
-  transition: all 0.3s ease;
-
-  .option-item.selected & {
-    background: #409EFF;
-    border-color: #409EFF;
-    color: #fff;
-  }
-}
-
-.option-content {
-  font-size: rpx(11);
-  color: #333;
-  flex: 1;
-}
-
-@keyframes dialogueEnterCenter {
-  from {
-    opacity: 0;
-    transform: translateX(-50%) translateY(rpx(20));
-  }
-  to {
-    opacity: 1;
-    transform: translateX(-50%) translateY(0);
-  }
-}
-
-.user-input-textarea {
-  width: 95%;
-  min-height: rpx(50);
-  max-height: rpx(150);
-  border: none;
-  outline: none;
-  background: transparent;
-  font-size: rpx(10);
-  line-height: 1.2;
-  color: #333;
-  resize: none;
-  font-family: inherit;
-  overflow-y: auto;
-  text-align: left;
-  padding: rpx(5) 0;
-}
-
-.voice-input-content {
-  width: 95%;
-  min-height: rpx(50);
-  max-height: rpx(150);
-  font-size: rpx(10);
-  line-height: 1.2;
-  color: #333;
-  text-align: left;
-  padding: rpx(5) 0;
-  overflow-y: auto;
-}
-
-.voice-input-placeholder {
-  width: 95%;
-  min-height: rpx(50);
-  font-size: rpx(10);
-  line-height: 1.2;
-  color: #999;
-  text-align: left;
-  padding: rpx(5) 0;
-  font-style: italic;
-}
-
-/* 滚动条样式 */
-.user-input-textarea::-webkit-scrollbar {
-  width: rpx(0);
-}
-
-.user-input-textarea::-webkit-scrollbar-track {
-  background: rgba(0, 0, 0, 0.05);
-  border-radius: rpx(3);
-}
-
-.user-input-textarea::-webkit-scrollbar-thumb {
-  background: rgba(64, 158, 255, 0.5);
-  border-radius: rpx(3);
-}
-
-.user-input-textarea::-webkit-scrollbar-thumb:hover {
-  background: rgba(64, 158, 255, 0.8);
-}
-
-.input-actions {
-  display: flex;
-  justify-content: flex-end;
-  gap: rpx(6);
-  margin-top: rpx(6);
-  width: 100%;
-}
-
-.cancel-btn, .submit-btn {
-  padding: rpx(2.5) rpx(10);
-  border: none;
-  border-radius: rpx(4);
-  font-size: rpx(8);
-  cursor: pointer;
-  transition: all 0.3s ease;
-}
-
-.cancel-btn {
-  background: #E0E0E0;
-  color: #666;
-}
-
-.submit-btn {
-  background: #409EFF;
-  color: white;
-}
-
-.cancel-btn:hover, .submit-btn:hover {
-  transform: scale(1.05);
-}
-
-.cancel-btn:active, .submit-btn:active {
-  transform: scale(0.95);
-}
-
-.dialogue-content :deep(p) {
-  margin: 0 0 rpx(4) 0;
-}
-
-.dialogue-content :deep(strong),
-.dialogue-content :deep(b) {
-  font-weight: bold;
-}
-
-.dialogue-content :deep(em),
-.dialogue-content :deep(i) {
-  font-style: italic;
-}
-
-.dialogue-content :deep(ul),
-.dialogue-content :deep(ol) {
-  margin: rpx(4) 0;
-  padding-left: rpx(10);
-}
-
-.dialogue-content :deep(li) {
-  margin: rpx(2) 0;
-}
-
-.dialogue-content :deep(code) {
-  background-color: #f0f0f0;
-  padding: rpx(1) rpx(3);
-  border-radius: rpx(2);
-  font-family: monospace;
-  font-size: rpx(9);
-}
-
-.dialogue-content :deep(a) {
-  color: #409EFF;
-  text-decoration: underline;
-}
-
-.input-buttons-container {
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 100%;
-  z-index: 10;
-  transition: all 0.3s ease;
-  transform: scale(0.9);
-  gap: rpx(10);
-  
-  .arrow-icon-circle {
-    width: rpx(20);
-    height: rpx(20);
-    border-radius: 50%;
-    border: rpx(1) solid rgba(0, 100, 192);
-    background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    cursor: pointer;
-    transition: all 0.3s ease;
-    
-    &:hover:not(.disabled) {
-      transform: scale(1.1);
-      box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
-    }
-    
-    &:active:not(.disabled) {
-      transform: scale(0.95);
-    }
-    
-    &.disabled {
-      opacity: 0.5;
-      cursor: not-allowed;
-      border-color: rgba(0, 100, 192, 0.3);
-      background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
-    }
-    
-    .arrow-icon {
-      font-size: rpx(15);
-      color: #0064BE;
-    }
-  }
-}
-
-.voice-input-outer,
-.keyboard-input-outer {
-  position: absolute;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: all 0.3s ease;
-}
-
-.voice-input-outer {
-  position: relative;
-  width: rpx(50);
-  height: rpx(50);
-  border-radius: 50%;
-  background: transparent;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  // 正常状态只显示一圈
-  &::before {
-    content: '';
-    position: absolute;
-    width: 85%;
-    height: 85%;
-    border-radius: 50%;
-    border: rpx(2) solid rgba(80, 190, 240, 0.6);
-  }
-
-  // 录音时显示波纹效果
-  &.recording {
-    &::before {
-      animation: pulse 2s infinite;
-      border-color: rgba(0, 100, 192, 0.6);
-    }
-
-    &::after {
-      content: '';
-      position: absolute;
-      width: 85%;
-      height: 85%;
-      border-radius: 50%;
-      border: rpx(2) solid rgba(0, 100, 192, 0.4);
-      animation: pulse 2s infinite 0.5s;
-    }
-
-    :deep(.voice-input-container) {
-      .speech-btn {
-        background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-        border-color: rgba(0, 100, 192, 1);
-
-        .el-icon {
-          color: #0064BE;
-        }
-      }
-    }
-  }
-
-  // 占位符样式
-  &.placeholder {
-    visibility: hidden;
-  }
-
-  :deep(.voice-input-container) {
-    position: relative;
-    z-index: 10;
-    .speech-btn {
-      width: rpx(40);
-      height: rpx(40);
-      border-radius: 50%;
-      border: rpx(2) solid rgba(0, 100, 192);
-      background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      padding: 0;
-      gap: 0;
-      transition: all 0.3s ease;
-      cursor: pointer;
-      position: relative;
-      overflow: hidden;
-
-      &:hover {
-        transform: scale(1.05);
-        box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
-      }
-
-      &:active {
-        transform: scale(0.95);
-      }
-
-      &::before {
-        content: '';
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        width: 0;
-        height: 0;
-        border-radius: 50%;
-        background: rgba(255, 255, 255, 0.5);
-        transform: translate(-50%, -50%);
-        transition: width 0.6s, height 0.6s;
-      }
-
-      &:active::before {
-        width: rpx(80);
-        height: rpx(80);
-      }
-
-      .el-icon {
-        font-size: rpx(20);
-        color: #0064BE;
-        z-index: 1;
-      }
-
-      .countdown-text {
-        display: block;
-        font-size: rpx(8);
-        color: #0064BE;
-        position: absolute;
-        bottom: rpx(5);
-        left: 50%;
-        transform: translateX(-50%);
-      }
-    }
-  }
-}
-
-@keyframes pulse {
-  0% {
-    transform: scale(1);
-    opacity: 1;
-  }
-  100% {
-    transform: scale(1.15);
-    opacity: 0;
-  }
-}
-
-.keyboard-input-outer {
-  width: rpx(30);
-  height: rpx(30);
-  border-radius: 50%;
-  background: transparent;
-
-  &.active {
-    width: rpx(40);
-    height: rpx(40);
-    left: 50%;
-    transform: translateX(-50%);
-    z-index: 11;
-
-    .keyboard-btn {
-      width: rpx(36);
-      height: rpx(36);
-      border: rpx(2) solid rgba(0, 100, 192);
-      box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
-
-      .keyboard-icon {
-        font-size: rpx(18);
-      }
-    }
-  }
-
-  &.inactive {
-    width: rpx(24);
-    height: rpx(24);
-    left: calc(50% + rpx(40));
-    transform: translateX(-50%);
-    z-index: 10;
-
-    .keyboard-btn {
-      width: rpx(22);
-      height: rpx(22);
-      border: rpx(1) solid rgba(128, 128, 128, 0.5);
-      box-shadow: none;
-      background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
-
-      .keyboard-icon {
-        font-size: rpx(12);
-        color: #808080;
-      }
-    }
-  }
-}
-
-.keyboard-btn {
-  width: rpx(28);
-  height: rpx(28);
-  border-radius: 50%;
-  border: rpx(1) solid rgba(0, 100, 192);
-  background: linear-gradient(135deg, #A0DCF0, #50BEF0);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0;
-  gap: 0;
-  transition: all 0.3s ease;
-  cursor: pointer;
-
-  &:hover {
-    transform: scale(1.1);
-    box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
-  }
-
-  &:active {
-    transform: scale(0.95);
-  }
-
-  .keyboard-icon {
-        font-size: rpx(14);
-        color: #0064BE;
-      }
-}
-
-/* 诗词显示样式 */
-.poem-display {
-  position: absolute;
-  top: 25%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  width: rpx(500);
-  height: rpx(200);
-  padding: rpx(0);
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  z-index: 5;
-  animation: scrollBackgroundFadeIn 0.8s ease-out;
-}
-
-.poem-display::before {
-  content: '';
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background-image: url('@/assets/dialogue/long-scroll.png');
-  background-size: 100%;
-  background-repeat: no-repeat;
-  background-position: center;
-  opacity: 0.8;
-  z-index: -1;
-}
-
-.poem-content {
-  text-align: center;
-  animation: poemFadeIn 1s ease-in-out;
-  transition: all 0.5s ease-in-out;
-}
-
-.poem-text {
-  width: 100%;
-  max-width: rpx(500);
-  max-height: rpx(200);
-  height: auto;
-  font-family: 'STKaiti', 'KaiTi', '楷体', 'Ma Shan Zheng', cursive;
-  font-size: rpx(20);
-  color: black;
-  position: relative;
-  overflow: auto;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  text-align: center;
-  transition: all 0.5s ease-in-out;
-}
-
-.poem-text::-webkit-scrollbar {
-  width: rpx(0);
-}
-.poem-text::-webkit-scrollbar-track {
-  background: rgba(255, 255, 255, 0.1);
-  border-radius: rpx(4);
-}
-.poem-text::-webkit-scrollbar-thumb {
-  background: rgba(0, 0, 0, 0.3);
-  border-radius: rpx(4);
-}
-
-.poem-text::-webkit-scrollbar-thumb:hover {
-  background: rgba(0, 0, 0, 0.5);
-}
-
-.video-display {
-  position: absolute;
-  top: 45%;
-  left: 50%;
-  transform: translate(-50%, -50%) scale(0.8);
-  z-index: 5;
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  opacity: 0;
-  animation: videoFadeIn 0.8s ease-out forwards;
-}
-
-.video-frame {
-  width: rpx(420);
-  height: rpx(250);
-  padding: rpx(5);
-  background: linear-gradient(135deg, rgba(160, 220, 240, 0.8), rgba(80, 190, 240, 0.8));
-  border: rpx(2) solid rgba(0, 100, 192, 0.8);
-  border-radius: rpx(15);
-  box-shadow: 0 rpx(8) rpx(25) rgba(0, 0, 0, 0.4);
-  backdrop-filter: blur(rpx(5));
-  display: flex;
-  justify-content: center;
-  align-items: center;
-}
-
-.dialogue-video {
-  width: 100%;
-  height: 100%;
-  border-radius: rpx(10);
-  background-color: #000;
-  object-fit: contain;
-}
-
-.poem-text::before {
-  content: '';
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  animation: poemShine 3s ease-in-out infinite;
-}
-
-@keyframes poemFadeIn {
-  from {
-    opacity: 0;
-    transform: scale(0.8) translateY(20px);
-  }
-  to {
-    opacity: 1;
-    transform: scale(1) translateY(0);
-  }
-}
-
-@keyframes scrollBackgroundFadeIn {
-  from {
-    opacity: 0;
-    background-size: 80%;
-  }
-  to {
-    opacity: 1;
-    background-size: 100%;
-  }
-}
-
-
-.poem-text p {
-  margin: rpx(10) 0;
-  opacity: 0;
-  animation: poemSlideIn 0.5s ease forwards;
-  font-weight: 500;
-  letter-spacing: rpx(2);
-}
-
-.poem-text p:nth-child(1) {
-  animation-delay: 0.2s;
-}
-
-.poem-text p:nth-child(2) {
-  animation-delay: 0.4s;
-}
-
-.poem-text p:nth-child(3) {
-  animation-delay: 0.6s;
-}
-
-.poem-text p:nth-child(4) {
-  animation-delay: 0.8s;
-}
-
-.poem-text p:nth-child(5) {
-  animation-delay: 1s;
-}
-
-@keyframes poemSlideIn {
-  from {
-    opacity: 0;
-    transform: translateY(10px);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-// 对话视频显示动画
-@keyframes videoFadeIn {
-  0% {
-    opacity: 0;
-    transform: translate(-50%, -50%) scale(0.8);
-  }
-  70% {
-    opacity: 1;
-    transform: translate(-50%, -50%) scale(1.05);
-  }
-  100% {
-    opacity: 1;
-    transform: translate(-50%, -50%) scale(1);
-  }
-}
+/* 原有样式已迁移到各子组件中 */
 </style>