Преглед на файлове

初始AI生成课程功能

liyanbo преди 4 месеца
родител
ревизия
43f8438b15

+ 5 - 0
src/api/ai/tts/index.ts

@@ -50,5 +50,10 @@ export const TtsApi = {
   // 查询AI TTS文转音列表
   getTtsSimpleList: async () => {
     return await request.get({ url: `/ai/tts/simple-list`})
+  },
+
+  // 文本转语音
+  convert: async (params: any) => {
+    return await request.post({ url: `/ai/tts/convert`, params })
   }
 }

+ 1 - 13
src/types/auto-components.d.ts

@@ -45,18 +45,13 @@ declare module 'vue' {
     Draggable: typeof import('./../components/Draggable/index.vue')['default']
     Echart: typeof import('./../components/Echart/src/Echart.vue')['default']
     Editor: typeof import('./../components/Editor/src/Editor.vue')['default']
-    ElAside: typeof import('element-plus/es')['ElAside']
-    ElAutoResizer: typeof import('element-plus/es')['ElAutoResizer']
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
     ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']
-    ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
-    ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
@@ -74,18 +69,14 @@ declare module 'vue' {
     ElementOtherConfig: typeof import('./../components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue')['default']
     ElementProperties: typeof import('./../components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue')['default']
     ElementTask: typeof import('./../components/bpmnProcessDesigner/package/penal/task/ElementTask.vue')['default']
-    ElEmpty: typeof import('element-plus/es')['ElEmpty']
-    ElFooter: typeof import('element-plus/es')['ElFooter']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
-    ElHeader: typeof import('element-plus/es')['ElHeader']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElImage: typeof import('element-plus/es')['ElImage']
     ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElLink: typeof import('element-plus/es')['ElLink']
-    ElMain: typeof import('element-plus/es')['ElMain']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElPopover: typeof import('element-plus/es')['ElPopover']
@@ -98,18 +89,14 @@ declare module 'vue' {
     ElSegmented: typeof import('element-plus/es')['ElSegmented']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
-    ElSlider: typeof import('element-plus/es')['ElSlider']
     ElSpace: typeof import('element-plus/es')['ElSpace']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
-    ElTableV2: typeof import('element-plus/es')['ElTableV2']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
     ElText: typeof import('element-plus/es')['ElText']
-    ElTimeline: typeof import('element-plus/es')['ElTimeline']
-    ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElTree: typeof import('element-plus/es')['ElTree']
     ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
@@ -186,6 +173,7 @@ declare module 'vue' {
     VerifyPoints: typeof import('./../components/Verifition/src/Verify/VerifyPoints.vue')['default']
     VerifySlide: typeof import('./../components/Verifition/src/Verify/VerifySlide.vue')['default']
     VerticalButtonGroup: typeof import('./../components/VerticalButtonGroup/index.vue')['default']
+    VideoPreview: typeof import('../views/bjdx/course/aiGenerate/VideoPreview.vue')['default']
     XButton: typeof import('./../components/XButton/src/XButton.vue')['default']
     XTextButton: typeof import('./../components/XButton/src/XTextButton.vue')['default']
   }

+ 2 - 0
src/types/auto-imports.d.ts

@@ -7,6 +7,8 @@ export {}
 declare global {
   const DICT_TYPE: typeof import('@/utils/dict')['DICT_TYPE']
   const EffectScope: typeof import('vue')['EffectScope']
+  const ElMessage: typeof import('element-plus/es')['ElMessage']
+  const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
   const computed: typeof import('vue')['computed']
   const createApp: typeof import('vue')['createApp']
   const customRef: typeof import('vue')['customRef']

+ 925 - 0
src/views/bjdx/course/aiGenerate/VideoPreview.vue

@@ -0,0 +1,925 @@
+<template>
+  <div class="video-preview-modal" v-if="visible">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h3>课程视频预览</h3>
+        <button class="close-btn" @click="close">×</button>
+      </div>
+      <div class="modal-body">
+        <div class="video-container">
+          <!-- 环节指示器 -->
+          <div class="section-indicators">
+            <div
+              v-for="(section, index) in scriptData.sections"
+              :key="index"
+              class="section-indicator"
+              :class="{ active: index === currentSectionIndex }"
+              @click="jumpToSection(index)"
+            >
+              {{ section.name }}
+            </div>
+          </div>
+
+          <!-- 背景图 -->
+          <div class="background-container" :style="{ backgroundImage: `url(${currentSection?.backgroundImage?.url || getBackgroundUrl(currentSection?.backgroundImage?.prompt)})` }">
+            <!-- 背景音信息 -->
+            <div v-if="currentSection?.backgroundAudio?.type" class="background-music-info">
+              <strong>当前背景音:</strong> {{ currentSection.backgroundAudio.type }}
+            </div>
+            
+            <!-- 人物形象 - 仅数字人对话显示 -->
+            <div
+              v-if="currentDialogue && currentDialogue.type === 'digital'"
+              :key="`character-${currentDialogue.roleName}-${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'"
+              :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">{{ getRoleName(currentDialogue.roleName) }}</span>
+              </div>
+              <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
+            </div>
+
+            <!-- 用户回答状态 -->
+            <div v-if="currentDialogue && currentDialogue.type === 'user'" class="user-input-section">
+              <!-- 麦克风图标 -->
+              <div 
+                class="microphone-icon" 
+                :class="{ 'recording': isRecording }"
+                @click="toggleRecording"
+              >
+                <span class="mic-icon">{{ isRecording ? '⏹' : '🎤' }}</span>
+                <span class="mic-text">{{ isRecording ? '点击停止' : '点击说话' }}</span>
+              </div>
+              
+              <!-- 用户对话卡片 -->
+              <div v-if="userInputContent" class="user-dialogue-card">
+                <div class="dialogue-header user-header">
+                  <span class="role-name">用户</span>
+                </div>
+                <div class="dialogue-content">{{ userInputContent }}</div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 控制栏 -->
+          <div class="video-controls">
+            <button class="control-btn" @click="playPrevious" :disabled="currentSectionIndex === 0 && currentDialogueIndex === 0">
+              上一句
+            </button>
+            <button class="control-btn primary" @click="togglePlay">
+              {{ isPlaying ? '暂停' : '播放' }}
+            </button>
+            <button class="control-btn" @click="playNext" :disabled="currentSectionIndex === scriptData.sections.length - 1 && currentDialogueIndex === currentSection.dialogues.length - 1">
+              下一句
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
+import { marked } from 'marked'
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: true
+  },
+  scriptData: {
+    type: Object,
+  },
+  scriptRoles: {
+    type: Array,
+  }
+})
+
+const emit = defineEmits(['close'])
+
+// 状态
+const isPlaying = ref(false)
+const currentSectionIndex = ref(0)
+const currentDialogueIndex = ref(0)
+const isRecording = ref(false)
+const userInputContent = ref('')
+
+// 音频对象
+const backgroundAudio = ref(null)
+const dialogueAudio = ref(null)
+const mediaRecorder = ref(null)
+const audioChunks = ref([])
+
+// 计算属性
+const currentSection = computed(() => {
+  return props.scriptData.sections[currentSectionIndex.value]
+})
+
+const currentDialogue = computed(() => {
+  if (!currentSection.value) return null
+  return currentSection.value.dialogues[currentDialogueIndex.value]
+})
+
+// 方法
+const close = () => {
+  // 停止所有音频
+  stopAllAudio()
+  emit('close')
+}
+
+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
+  }
+
+  // 播放当前环节的背景音
+  if (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))
+  }
+}
+
+const playDialogueAudio = () => {
+  // 停止之前的对话语音
+  if (dialogueAudio.value) {
+    dialogueAudio.value.pause()
+    dialogueAudio.value.currentTime = 0
+  }
+
+  // 播放当前对话的语音
+  if (currentDialogue.value?.voiceoverUrl) {
+    dialogueAudio.value = new Audio(currentDialogue.value.voiceoverUrl)
+    return new Promise((resolve) => {
+      // 最短3秒跳转
+      const minDelay = 3000
+      let audioEnded = false
+
+      // 音频结束事件
+      dialogueAudio.value.onended = () => {
+        audioEnded = true
+        resolve()
+      }
+
+      // 播放音频
+      dialogueAudio.value.play().catch(e => {
+        console.error('对话语音播放失败:', e)
+        // 播放失败时,2秒后跳转
+        setTimeout(resolve, minDelay)
+      })
+
+      // 检查音频时长
+      dialogueAudio.value.addEventListener('loadedmetadata', () => {
+        const duration = dialogueAudio.value.duration
+        // 如果音频时长小于2秒,2秒后跳转
+        if (duration < 2) {
+          setTimeout(() => {
+            if (!audioEnded) {
+              resolve()
+            }
+          }, minDelay)
+        }
+      })
+    })
+  }
+  // 如果没有语音文件,2秒后跳转
+  return new Promise(resolve => setTimeout(resolve, 2000))
+}
+
+const togglePlay = () => {
+  isPlaying.value = !isPlaying.value
+  if (isPlaying.value) {
+    // 播放背景音
+    playBackgroundAudio()
+    // 开始播放序列
+    playSequence()
+  } else {
+    // 暂停所有音频
+    stopAllAudio()
+  }
+}
+
+const playSequence = async () => {
+  if (!isPlaying.value) return
+
+  // 检查当前对话类型
+  if (currentDialogue.value && currentDialogue.value.type === 'user') {
+    // 用户回答类型,暂停自动播放,等待用户输入
+    // 这里不需要播放音频,只需要等待用户输入
+    return
+  }
+
+  // 播放当前对话语音
+  await playDialogueAudio()
+
+  // 播放下一个对话
+  if (!playNext()) {
+    // 播放完毕
+    isPlaying.value = false
+    // 停止所有音频,包括背景音
+    stopAllAudio()
+  } else {
+    playSequence()
+  }
+}
+
+const playPrevious = () => {
+  // 停止当前音频
+  stopAllAudio()
+
+  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
+  }
+
+  // 播放背景音
+  playBackgroundAudio()
+  // 播放当前对话语音
+  playDialogueAudio()
+}
+
+const playNext = () => {
+  // 停止当前对话语音
+  if (dialogueAudio.value) {
+    dialogueAudio.value.pause()
+    dialogueAudio.value.currentTime = 0
+    // 清除音频对象引用
+    dialogueAudio.value = null
+  }
+
+  if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
+    currentDialogueIndex.value++
+    // 播放当前对话语音
+    playDialogueAudio()
+    return true
+  } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
+    currentSectionIndex.value++
+    currentDialogueIndex.value = 0
+    // 播放背景音
+    playBackgroundAudio()
+    // 播放当前对话语音
+    playDialogueAudio()
+    return true
+  }
+  return false
+}
+
+const jumpToSection = (index) => {
+  // 停止当前音频
+  stopAllAudio()
+
+  currentSectionIndex.value = index
+  currentDialogueIndex.value = 0
+
+  // 播放背景音
+  playBackgroundAudio()
+  // 播放当前对话语音
+  playDialogueAudio()
+}
+
+const getRoleName = (roleName) => {
+  const role = props.scriptRoles.find(r => r.name === roleName)
+  return role ? role.name : '未知角色'
+}
+
+const getCharacterImage = (roleName) => {
+  const role = props.scriptRoles.find(r => r.name === roleName)
+  return role ? role.avatar : ''
+}
+
+const getCharacterSide = () => {
+  return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
+}
+
+const getBackgroundUrl = (prompt) => {
+  if (!prompt) return ''
+  return `https://neeko-copilot.bytedance.net/api/text2image?prompt=${encodeURIComponent(prompt)}&size=1024x512`
+}
+
+// 解析 Markdown 文本
+const parseMarkdown = (text) => {
+  if (!text) return ''
+  return marked(text)
+}
+
+// 语音输入相关方法
+const toggleRecording = async () => {
+  if (!isRecording.value) {
+    // 开始录音
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+      mediaRecorder.value = new MediaRecorder(stream)
+      audioChunks.value = []
+
+      mediaRecorder.value.ondataavailable = (event) => {
+        if (event.data.size > 0) {
+          audioChunks.value.push(event.data)
+        }
+      }
+
+      mediaRecorder.value.onstop = () => {
+        const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' })
+        convertSpeechToText(audioBlob)
+      }
+
+      mediaRecorder.value.start()
+      isRecording.value = true
+    } catch (error) {
+      console.error('录音失败:', error)
+      alert('无法访问麦克风,请检查权限设置')
+    }
+  } else {
+    // 停止录音
+    if (mediaRecorder.value) {
+      mediaRecorder.value.stop()
+      isRecording.value = false
+    }
+  }
+}
+
+const convertSpeechToText = (audioBlob) => {
+  // 这里需要调用语音转文字的API
+  // 由于没有实际的API,这里模拟一个转换过程
+  setTimeout(() => {
+    // 模拟转换结果
+    userInputContent.value = '这是一段模拟的语音转文字内容'
+    // 3秒后自动进入下一句
+    setTimeout(() => {
+      playNext()
+    }, 3000)
+  }, 1000)
+}
+
+// 监听环节变化
+watch(currentSectionIndex, () => {
+  playBackgroundAudio()
+})
+
+// 监听对话变化
+watch(currentDialogue, () => {
+  if (!isPlaying.value) {
+    playDialogueAudio()
+  }
+})
+
+// 监听可见性变化
+watch(() => props.visible, (newVal) => {
+  if (newVal) {
+    // 重置状态
+    isPlaying.value = false
+    currentSectionIndex.value = 0
+    currentDialogueIndex.value = 0
+    // 自动点击播放按钮
+    setTimeout(() => {
+      togglePlay()
+    }, 500)
+  } else {
+    // 停止所有音频
+    stopAllAudio()
+  }
+})
+
+// 组件销毁时停止音频
+onUnmounted(() => {
+  stopAllAudio()
+})
+</script>
+
+<style scoped>
+.video-preview-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.8);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.modal-content {
+  background: white;
+  border-radius: 12px;
+  width: 90%;
+  max-width: 1000px;
+  max-height: 90vh;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.modal-header {
+  padding: 20px;
+  border-bottom: 1px solid #e8e8e8;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #fafafa;
+}
+
+.modal-header h3 {
+  margin: 0;
+  color: #333;
+}
+
+.close-btn {
+  background: none;
+  border: none;
+  font-size: 24px;
+  cursor: pointer;
+  color: #999;
+  padding: 0;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  transition: all 0.3s ease;
+  outline: none;
+}
+
+.close-btn:hover {
+  background: #f0f0f0;
+  color: #333;
+}
+
+.modal-body {
+  flex: 1;
+  padding: 20px;
+  overflow: hidden;
+  min-height: 500px;
+}
+
+.video-container {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  min-height: 500px;
+  background: #f0f0f0;
+  border-radius: 0;
+  overflow: hidden;
+}
+
+/* 环节指示器 */
+.section-indicators {
+  position: absolute;
+  top: 10px;
+  left: 10px;
+  z-index: 10;
+  display: flex;
+  gap: 10px;
+  overflow-x: auto;
+  padding: 5px;
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.section-indicator {
+  flex: 0 0 auto;
+  padding: 6px 14px;
+  background: #f0f0f0;
+  border-radius: 16px;
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  white-space: nowrap;
+  border: 1px solid #e8e8e8;
+}
+
+.section-indicator:hover {
+  background: #e6f7ff;
+  color: #409EFF;
+  border-color: #409EFF;
+}
+
+.section-indicator.active {
+  background: #409EFF;
+  color: white;
+  border-color: #409EFF;
+}
+
+.background-container {
+  width: 100%;
+  height: 100%;
+  min-height: 500px;
+  background-size: cover;
+  background-position: center;
+  background-repeat: no-repeat;
+  position: relative;
+}
+
+/* 背景音信息 */
+.background-music-info {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  background: rgba(255, 255, 255, 0.7);
+  color: black;
+  padding: 6px 12px;
+  border-radius: 12px;
+  font-size: 14px;
+  z-index: 5;
+}
+
+.character {
+  position: absolute;
+  bottom: 50px;
+  width: 150px;
+  height: 300px;
+  background-size: contain;
+  background-position: bottom;
+  background-repeat: no-repeat;
+  opacity: 0;
+  transform: scale(0.5);
+  animation: characterEnter 0.8s ease forwards;
+}
+
+.character.left {
+  left: 20px;
+}
+
+.character.right {
+  right: 20px;
+}
+
+@keyframes characterEnter {
+  0% {
+    opacity: 0;
+    transform: scale(0.5);
+    bottom: 50px;
+  }
+  70% {
+    opacity: 0.9;
+    transform: scale(1.05);
+    bottom: 70px;
+  }
+  100% {
+    opacity: 1;
+    transform: scale(1);
+    bottom: 50px;
+  }
+}
+
+.dialogue-card {
+  background: rgba(255, 255, 255, 0.7);
+  border-radius: 12px;
+  padding: 12px;
+  max-width: 40%;
+  min-width: 200px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  position: absolute;
+  bottom: 130px;
+  width: auto;
+  display: inline-block;
+}
+
+.dialogue-card.left {
+  left: 170px;
+  animation: dialogueEnterLeft 0.6s ease forwards;
+}
+
+.dialogue-card.right {
+  right: 170px;
+  animation: dialogueEnterRight 0.6s ease forwards;
+}
+
+.dialogue-card.left::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  bottom: 20px;
+  width: 0;
+  height: 0;
+  border-top: 10px solid transparent;
+  border-right: 15px solid rgba(255, 255, 255, 0.5);
+  border-bottom: 10px solid transparent;
+  transform: translateX(-100%);
+}
+
+.dialogue-card.right::before {
+  content: '';
+  position: absolute;
+  right: 0;
+  bottom: 20px;
+  width: 0;
+  height: 0;
+  border-top: 10px solid transparent;
+  border-left: 15px solid rgba(255, 255, 255, 0.5);
+  border-bottom: 10px solid transparent;
+  transform: translateX(100%);
+}
+
+@keyframes dialogueEnterLeft {
+  from {
+    opacity: 0;
+    transform: translateX(-30px) translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0) translateY(0);
+  }
+}
+
+@keyframes dialogueEnterRight {
+  from {
+    opacity: 0;
+    transform: translateX(30px) translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0) translateY(0);
+  }
+}
+
+.dialogue-header {
+  position: absolute;
+  top: -20px;
+  left: 10px;
+  background: #409EFF;
+  color: white;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-size: 14px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.dialogue-card.right .dialogue-header {
+  left: 10px;
+}
+
+.role-name {
+  font-weight: 600;
+  color: white;
+  font-size: 14px;
+}
+
+.dialogue-content {
+  font-size: 18px;
+  line-height: 1.6;
+  color: #333;
+  text-align: left;
+}
+
+.dialogue-content h1,
+.dialogue-content h2,
+.dialogue-content h3,
+.dialogue-content h4,
+.dialogue-content h5,
+.dialogue-content h6 {
+  margin: 16px 0 8px 0;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.dialogue-content h1 { font-size: 24px; }
+.dialogue-content h2 { font-size: 20px; }
+.dialogue-content h3 { font-size: 18px; }
+.dialogue-content h4 { font-size: 16px; }
+.dialogue-content h5 { font-size: 14px; }
+.dialogue-content h6 { font-size: 12px; }
+
+.dialogue-content p {
+  margin: 8px 0;
+}
+
+.dialogue-content ul,
+.dialogue-content ol {
+  margin: 8px 0;
+  padding-left: 24px;
+}
+
+.dialogue-content li {
+  margin: 4px 0;
+}
+
+.dialogue-content strong {
+  font-weight: 600;
+}
+
+.dialogue-content em {
+  font-style: italic;
+}
+
+.dialogue-content a {
+  color: #409EFF;
+  text-decoration: none;
+}
+
+.dialogue-content a:hover {
+  text-decoration: underline;
+}
+
+.dialogue-content code {
+  background: #f5f5f5;
+  padding: 2px 4px;
+  border-radius: 4px;
+  font-family: 'Courier New', Courier, monospace;
+  font-size: 14px;
+}
+
+.dialogue-content pre {
+  background: #f5f5f5;
+  padding: 12px;
+  border-radius: 8px;
+  overflow-x: auto;
+  margin: 12px 0;
+}
+
+.dialogue-content pre code {
+  background: none;
+  padding: 0;
+}
+
+/* 用户输入部分 */
+.user-input-section {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 20px;
+  z-index: 5;
+}
+
+.microphone-icon {
+  width: 120px;
+  height: 120px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.8);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+  transition: all 0.3s ease;
+  border: 3px solid #409EFF;
+}
+
+.microphone-icon:hover {
+  transform: scale(1.05);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
+}
+
+.microphone-icon.recording {
+  background: rgba(255, 87, 34, 0.8);
+  border-color: #ff5722;
+  animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+  0% {
+    box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.7);
+  }
+  70% {
+    box-shadow: 0 0 0 15px rgba(255, 87, 34, 0);
+  }
+  100% {
+    box-shadow: 0 0 0 0 rgba(255, 87, 34, 0);
+  }
+}
+
+.mic-icon {
+  font-size: 48px;
+  margin-bottom: 8px;
+}
+
+.mic-text {
+  font-size: 14px;
+  color: #333;
+  font-weight: 500;
+}
+
+.microphone-icon.recording .mic-text {
+  color: white;
+}
+
+.user-dialogue-card {
+  background: rgba(255, 255, 255, 0.9);
+  border-radius: 12px;
+  padding: 16px;
+  max-width: 400px;
+  min-width: 300px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  animation: dialogueEnterCenter 0.6s ease forwards;
+}
+
+@keyframes dialogueEnterCenter {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.dialogue-header.user-header {
+  background: #67C23A;
+  left: 50%;
+  transform: translateX(-50%);
+}
+
+/* 控制栏 */
+.video-controls {
+  position: absolute;
+  bottom: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  justify-content: center;
+  gap: 20px;
+  z-index: 10;
+  background: rgba(255, 255, 255, 0.8);
+  padding: 10px 20px;
+  border-radius: 30px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.control-btn {
+  padding: 10px 28px;
+  border: 1px solid #d9d9d9;
+  border-radius: 24px;
+  background: white;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  font-weight: 500;
+  outline: none;
+  font-size: 14px;
+  min-width: 100px;
+}
+
+.control-btn:hover:not(:disabled) {
+  border-color: #409EFF;
+  color: #409EFF;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
+}
+
+.control-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+  border-color: #f0f0f0;
+  color: #bfbfbf;
+}
+
+.control-btn:focus {
+  outline: none;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
+}
+
+.control-btn.primary {
+  background: #409EFF;
+  color: white;
+  border-color: #409EFF;
+  font-weight: 600;
+  min-width: 100px;
+}
+
+.control-btn.primary:hover:not(:disabled) {
+  background: #1890ff;
+  border-color: #1890ff;
+  color: white;
+  box-shadow: 0 2px 12px rgba(64, 158, 255, 0.4);
+}
+</style>

+ 2295 - 0
src/views/bjdx/course/aiGenerate/aiGengrate.vue

@@ -0,0 +1,2295 @@
+<template>
+  <div class="course-script-editor">
+    <!-- 全屏遮挡层 -->
+    <div v-if="isGenerating" class="loading-overlay">
+      <div class="loading-content">
+        <div class="loading-spinner"></div>
+        <div class="loading-text">生成中...</div>
+      </div>
+    </div>
+    <div class="main-container">
+      <!-- 左侧步骤指示器 -->
+      <div class="steps-sidebar">
+        <div class="steps-container">
+          <template v-for="(step, index) in steps" :key="step.id">
+            <div
+              :class="['step-item', { process: currentStep === step.id, finish: step.id < currentStep, wait: step.id > currentStep }]"
+            >
+              <div class="step-circle">{{ step.id }}</div>
+              <div class="step-title">{{ step.label }}</div>
+            </div>
+            <div v-if="index < steps.length - 1" class="step-line" :class="{ active: currentStep > step.id }"></div>
+          </template>
+        </div>
+      </div>
+
+      <!-- 右侧内容区域 -->
+      <div class="content-area">
+        <!-- 步骤1:AI一句话生成 -->
+        <div v-if="currentStep === 1" class="step-content">
+          <div class="ai-input-container">
+            <div class="ai-input-wrapper">
+              <div class="ai-icon">🤖</div>
+              <textarea
+                v-model="scriptPrompt"
+                placeholder="AI一句话生成课程脚本,例如:生成一节关于牛顿万有引力的小学科学课脚本..."
+                class="ai-textarea"
+              ></textarea>
+            </div>
+
+            <div class="selection-group">
+              <div class="selection-item">
+                <label>主讲老师</label>
+                <el-select v-model="selectedMainTeacher" filterable size="large" placeholder="请选择主讲老师" style="width: 300px" clearable>
+                  <el-option
+                    v-for="teacher in digitalHumans"
+                    :key="teacher.name"
+                    :label="teacher.name"
+                    :value="teacher.name"
+                  />
+                </el-select>
+              </div>
+
+              <div class="selection-item">
+                <label>助讲老师</label>
+                <el-select v-model="selectedAssistants" filterable size="large" placeholder="请选择助讲老师" style="width: 300px" clearable multiple>
+                  <el-option
+                    v-for="teacher in digitalHumans"
+                    :key="teacher.name"
+                    :label="teacher.name"
+                    :value="teacher.name"
+                  />
+                </el-select>
+              </div>
+            </div>
+
+            <button
+              class="generate-btn primary"
+              :disabled="!scriptPrompt || !selectedMainTeacher || isGenerating"
+              @click="generateScript"
+            >
+              {{ isGenerating ? '生成中...' : '生成课程脚本' }}
+            </button>
+          </div>
+        </div>
+
+        <!-- 步骤2:脚本编辑(含背景图/音生成) -->
+        <div v-else-if="currentStep === 2" class="step-content">
+          <!-- 固定顶部的一键操作栏 -->
+          <div class="editor-toolbar sticky-top">
+            <button 
+              class="toolbar-btn" 
+              @click="generateAllImages"
+              :disabled="isGeneratingImages || isAnyImageGenerating"
+            >
+              {{ isGeneratingImages ? '生成中...' : '一键生成所有背景图' }}
+            </button>
+            <button 
+              class="toolbar-btn" 
+              @click="generateAllVoiceovers"
+              :disabled="isGeneratingVoiceovers || isAnyVoiceoverGenerating"
+            >
+              {{ isGeneratingVoiceovers ? '生成中...' : '一键配音' }}
+            </button>
+          </div>
+
+          <!-- 可滚动的内容区域 -->
+          <div class="script-editor">
+            <div
+              v-for="(section, sectionIndex) in scriptData.sections"
+              :key="sectionIndex"
+              class="script-section"
+            >
+              <div class="section-header">
+                <div class="section-name-container">
+                  <label class="section-name-label">环节 {{sectionIndex+1}}</label>
+                  <input
+                    v-model="section.name"
+                    class="section-title"
+                    placeholder="如:环节一(引入)"
+                  />
+                </div>
+                <button class="remove-section-btn" @click="removeSection(sectionIndex)">×</button>
+              </div>
+              <div class="media-controls" >
+                <div class="media-item">
+                  <div class="media-input-group">
+                    <span class="media-label">背景图</span>
+                    <el-input
+                      v-model="section.backgroundImage.prompt"
+                      type="textarea"
+                      :autosize="{ minRows: 2, maxRows: 4 }"
+                      placeholder="描述词"
+                      class="media-prompt"
+                    />
+                    <el-button
+                      type="primary"
+                      size="small"
+                      :loading="section.backgroundImage.generating"
+                      :disabled="!section.backgroundImage.prompt || section.backgroundImage.generating"
+                      @click="generateMedia(sectionIndex)"
+                      class="generate-btn"
+                    >
+                      {{ section.backgroundImage.generating ? '生成中...' : (section.backgroundImage.url ? '重新生成' : '生成') }}
+                    </el-button>
+                  </div>
+                  <div v-if="section.backgroundImage.url" class="media-preview">
+                    <img :src="section.backgroundImage.url" alt="背景图预览" />
+                  </div>
+                </div>
+
+                <div class="media-item">
+                  <div class="media-input-group">
+                    <span class="media-label">背景音</span>
+                    <el-select v-model="section.backgroundAudio.type" placeholder="选择背景音" style="width: 240px" clearable size="large" @change="(value) => handleBackgroundAudioChange(value, section)">
+                      <el-option
+                        v-for="musicType in backgroundMusicTypes"
+                        :key="musicType.id"
+                        :label="musicType.name"
+                        :value="musicType.id"
+                      />
+                    </el-select>
+                    <button
+                        v-if="section.backgroundAudio.type"
+                        class="play-btn small"
+                        @click="playBackgroundAudio(section.backgroundAudio.type)"
+                      >
+                        <span class="play-icon">{{ audioState.isPlaying && audioState.currentType === 'background' && audioState.currentUrl === section.backgroundAudio.url ? '⏸' : '▶' }}</span>
+                      </button>
+                  </div>
+                </div>
+              </div>
+
+              <div class="dialogues-container">
+                <div
+                  v-for="(dialogue, dialogueIndex) in section.dialogues"
+                  :key="dialogueIndex"
+                  class="dialogue-item"
+                  :class="dialogue.type"
+                >
+                  <div class="dialogue-header">
+                    <div class="dialogue-type-tag" :class="dialogue.type">
+                      {{ dialogue.type === 'digital' ? '数字人' : '用户' }}
+                    </div>
+                  </div>
+                  <div class="dialogue-row">
+                    <!-- 数字人对话 -->
+                    <template v-if="dialogue.type === 'digital'">
+                      <div class="dialogue-role-select">
+                        <el-select v-model="dialogue.roleName" placeholder="选择角色" style="width: 140px" clearable>
+                          <el-option
+                            v-for="role in digitalHumans"
+                            :key="role.id"
+                            :label="role.name"
+                            :value="role.name"
+                          />
+                        </el-select>
+                      </div>
+                      <div class="dialogue-content-container">
+                        <el-input
+                          v-model="dialogue.content"
+                          type="textarea"
+                          class="dialogue-content"
+                          placeholder="对话内容..."
+                          :autosize="{ minRows: 2, maxRows: 4 }"
+                        />
+                      </div>
+                      <div class="action-buttons">
+                        <div class="action-buttons-row">
+                          <button
+                            v-if="dialogue.voiceoverUrl"
+                            class="play-btn"
+                            @click="playVoiceover(dialogue.voiceoverUrl)"
+                          >
+                            <span class="play-icon">{{ audioState.isPlaying && audioState.currentType === 'voice' && audioState.currentUrl === dialogue.voiceoverUrl ? '⏸' : '▶' }}</span>
+                          </button>
+                          <button class="remove-btn" @click="removeDialogue(sectionIndex, dialogueIndex)">×</button>
+                        </div>
+                        <div class="action-buttons-row">
+                          <button
+                            v-if="!dialogue.voiceoverUrl"
+                            class="generate-btn small"
+                            :disabled="!dialogue.content || !dialogue.roleName || dialogue.generatingVoiceover"
+                            @click="generateVoiceover(sectionIndex, dialogueIndex)"
+                          >
+                            <span class="voice-icon">{{ dialogue.generatingVoiceover ? '生成中...' : '生成语音' }}</span>
+                          </button>
+                          <button
+                            v-if="dialogue.voiceoverUrl"
+                            class="generate-btn small"
+                            :disabled="!dialogue.content || !dialogue.roleName || dialogue.generatingVoiceover"
+                            @click="generateVoiceover(sectionIndex, dialogueIndex)"
+                          >
+                            <span class="voice-icon">{{ dialogue.generatingVoiceover ? '生成中...' : '重新生成' }}</span>
+                          </button>
+                        </div>
+                      </div>
+                    </template>
+
+                    <!-- 用户 -->
+                    <template v-else-if="dialogue.type === 'user'">
+                      <div class="dialogue-content-container full-width">
+                        <el-input
+                          v-model="dialogue.content"
+                          type="textarea"
+                          class="dialogue-content"
+                          placeholder="用户回复内容..."
+                          :autosize="{ minRows: 2, maxRows: 4 }"
+                        />
+                      </div>
+                      <div class="dialogue-reply-select">
+                        <el-select v-model="dialogue.roleName" placeholder="选择数字人回复" style="width: 140px" clearable>
+                          <el-option
+                            v-for="role in digitalHumans"
+                            :key="role.id"
+                            :label="role.name"
+                            :value="role.name"
+                          />
+                        </el-select>
+                      </div>
+                      <div class="action-buttons">
+                        <div class="action-buttons-row">
+                          <button class="remove-btn" @click="removeDialogue(sectionIndex, dialogueIndex)">×</button>
+                        </div>
+                      </div>
+                    </template>
+                  </div>
+                </div>
+
+                <div class="add-dialogue-buttons">
+                  <button class="add-dialogue-btn digital" @click="addDialogue(sectionIndex)">+ 添加对话</button>
+                  <button class="add-dialogue-btn user" @click="addUserReply(sectionIndex)">+ 添加用户回复</button>
+                </div>
+              </div>
+            </div>
+
+            <button class="add-section-btn" @click="addSection">+ 添加环节</button>
+          </div>
+
+
+          <div class="preview-actions">
+
+            <button
+              v-if="currentStep > 1"
+              class="secondary-btn"
+              @click="currentStep--"
+            >
+              重新生成
+            </button>
+
+            <button
+              class="primary-btn"
+              :disabled="!canProceed"
+              @click="nextStep"
+            >
+              预览完整脚本
+            </button>
+          </div>
+        </div>
+
+        <!-- 步骤3:预览与保存 -->
+        <div v-else-if="currentStep === 3" class="step-content">
+          <div class="preview-container">
+            <h3>课程脚本预览</h3>
+            <br/>
+
+            <div class="validation-result" :class="{ valid: isValidationPassed, invalid: !isValidationPassed }">
+              {{ validationMessage }}
+            </div>
+
+            <div class="preview-content">
+              <div
+                v-for="(section, sectionIndex) in scriptData.sections"
+                :key="sectionIndex"
+                class="preview-section"
+                :style="{ backgroundImage: section.backgroundImage.url ? `url(${section.backgroundImage.url})` : 'none', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }"
+              >
+                <div class="preview-section-content">
+                  <div class="preview-media">
+                    <div class="preview-media-left">
+                      <label class="section-name-label">环节 {{sectionIndex+1}}</label>
+                      <strong>{{ section.name }}</strong>
+                    </div>
+                    <div class="preview-media-right">
+                      <span v-if="section.backgroundAudio.type" class="preview-audio">
+                        背景音:{{ section.backgroundAudio.type }}
+                        <button
+                        class="play-btn small"
+                        @click="playBackgroundAudio(section.backgroundAudio.type)"
+                      >
+                        <span class="play-icon">{{ audioState.isPlaying && audioState.currentType === 'background' && audioState.currentUrl === section.backgroundAudio.url ? '⏸' : '▶' }}</span>
+                      </button>
+                      </span>
+                    </div>
+                  </div>
+
+                <div class="preview-dialogues">
+                  <div
+                    v-for="(dialogue, dialogueIndex) in section.dialogues"
+                    :key="dialogueIndex"
+                    class="preview-dialogue"
+                    :class="dialogue.type"
+                  >
+                    <div class="dialogue-header">
+                      <div class="dialogue-header-left">
+                        <div class="dialogue-type-tag" :class="dialogue.type">
+                          {{ dialogue.type === 'digital' ? '数字人' : '用户' }}
+                        </div>
+                        <div class="dialogue-role">
+                          {{ dialogue.type === 'digital' ? getRoleName(dialogue.roleName) : '用户' }}:
+                        </div>
+                      </div>
+                      <button
+                        v-if="dialogue.voiceoverUrl && dialogue.type === 'digital'"
+                        class="play-btn small"
+                        @click="playVoiceover(dialogue.voiceoverUrl)"
+                      >
+                        <span class="play-icon">{{ audioState.isPlaying && audioState.currentType === 'voice' && audioState.currentUrl === dialogue.voiceoverUrl ? '⏸' : '▶' }}</span>
+                      </button>
+                    </div>
+                    <div class="dialogue-text" v-html="parseMarkdown(dialogue.content)"></div>
+                    <div v-if="dialogue.type === 'user' && dialogue.roleName" class="reply-info">
+                      回复角色:{{ getRoleName(dialogue.roleName) }}
+                    </div>
+                  </div>
+                </div>
+                </div>
+              </div>
+            </div>
+
+            <div class="preview-actions">
+              <button class="secondary-btn" @click="currentStep = 2">返回修改</button>
+              <button
+                class="primary-btn"
+                :disabled="!isValidationPassed"
+                @click="showVideoPreview"
+              >
+                预览视频
+              </button>
+              <button
+                class="primary-btn"
+                :disabled="!isValidationPassed"
+                @click="saveScript"
+              >
+                保存课程脚本
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 视频预览模态框 -->
+  <VideoPreview
+    :visible="showVideoPreviewModal"
+    :script-data="scriptData"
+    :script-roles="digitalHumans"
+    @close="closeVideoPreview"
+  />
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
+import { ChatMessageApi } from '@/api/ai/chat/message'
+import { ChatConversationApi } from '@/api/ai/chat/conversation'
+import { ChatRoleApi } from '@/api/ai/model/chatRole'
+import { ImageApi } from '@/api/ai/image'
+import { TtsApi } from '@/api/ai/tts'
+import VideoPreview from '@/views/bjdx/course/aiGenerate/VideoPreview.vue'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+import { marked } from 'marked'
+
+// 步骤配置
+const steps = [
+  { id: 1, label: 'AI生成脚本' },
+  { id: 2, label: '编辑脚本与媒体' },
+  { id: 3, label: '预览与保存' }
+]
+
+// 当前步骤
+const currentStep = ref(2)
+
+// 步骤1数据
+const scriptPrompt = ref('请给我一个讲解万有引力的课程')
+const selectedMainTeacher = ref('牛顿')
+const selectedAssistants = ref([])
+const isGenerating = ref(false)
+
+// 步骤2生成状态
+const isGeneratingImages = ref(false)
+const isGeneratingVoiceovers = ref(false)
+
+// 数字人列表
+const digitalHumans = ref([])
+
+// 背景音类型
+const backgroundMusicTypes = ref([
+  { id: '轻松欢快', name: '轻松欢快', url: 'http://192.168.110.9:8080/admin-api/infra/file/29/get/20260228/bgm_1772265321253.MP3' },
+])
+
+// 从localStorage加载脚本数据
+const loadScriptDataFromCache = () => {
+  const cachedData = localStorage.getItem('courseScriptData')
+  if (cachedData) {
+    try {
+      return JSON.parse(cachedData)
+    } catch (error) {
+      console.error('解析缓存数据失败:', error)
+    }
+  }
+  return {
+    title: '',
+    sections: [
+      {
+        name: '环节一',
+        backgroundImage: {
+          prompt: '',
+          url: '',
+          generating: false
+        },
+        backgroundAudio: {
+          type: '',
+          url: ''
+        },
+        dialogues: [
+          {
+            type: 'digital',
+            roleName: '',
+            content: '',
+            voiceoverUrl: '',
+            generatingVoiceover: false
+          },
+          {
+            type: 'user',
+            roleName: '',
+            content: '',
+          }
+        ]
+      }
+    ]
+  }
+}
+
+// 脚本数据结构
+const scriptData = reactive(loadScriptDataFromCache())
+
+// 保存脚本数据到localStorage
+const saveScriptDataToCache = () => {
+  try {
+    localStorage.setItem('courseScriptData', JSON.stringify(scriptData))
+  } catch (error) {
+    console.error('保存缓存数据失败:', error)
+  }
+}
+
+// 清空脚本数据缓存
+const clearScriptDataCache = () => {
+  localStorage.removeItem('courseScriptData')
+}
+
+// 监听脚本数据变化,自动保存到缓存
+watch(() => scriptData, () => {
+  saveScriptDataToCache()
+}, { deep: true })
+
+// 音频播放状态
+const audioState = reactive({
+  currentAudio: null,
+  isPlaying: false,
+  currentType: '',
+  currentUrl: ''
+})
+
+// 存储所有音频实例
+const audioInstances = new Map()
+
+// 计算属性:判断是否可以进入下一步
+const canProceed = computed(() => {
+  switch (currentStep.value) {
+    case 1:
+      return !!scriptPrompt.value && !!selectedMainTeacher.value
+    case 2:
+    case 3:
+      return scriptData.sections.every(section =>
+        section.backgroundImage.url && section.dialogues.every(dialogue => dialogue.type !== "digital" || dialogue.voiceoverUrl)
+      )
+    default:
+      return false
+  }
+})
+
+// 计算属性:检查是否有任何背景图正在生成
+const isAnyImageGenerating = computed(() => {
+  return scriptData.sections.some(section => section.backgroundImage.generating)
+})
+
+// 计算属性:检查是否有任何配音正在生成
+const isAnyVoiceoverGenerating = computed(() => {
+  return scriptData.sections.some(section =>
+    section.dialogues.some(dialogue => dialogue.generatingVoiceover)
+  )
+})
+
+// 校验结果
+const isValidationPassed = ref(false)
+const validationMessage = ref('')
+
+// 视频预览状态
+const showVideoPreviewModal = ref(false)
+
+const activeConversationId = ref(null) // 选中的对话编号
+const conversationInAbortController = ref() // 对话进行中 abort 控制器(控制 stream 对话)
+const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
+const enableContext = ref(false) // 是否开启上下文
+
+// 接收 Stream 消息
+const receiveMessageFullText = ref('')
+const scriptDataTemp = ref(null)
+
+
+
+
+// 步骤1:生成脚本
+const generateScript = async () => {
+  isGenerating.value = true
+  try {
+    clearScriptDataCache()
+    scriptData.title = ''
+    scriptData.sections = []
+    receiveMessageFullText.value = ''
+    scriptDataTemp.value = null
+
+    const role = digitalHumans.value.find(r => r.name === selectedMainTeacher.value)
+    let content = scriptPrompt.value + "(主讲人:" + role.name + "[" + role.description + "],助讲:"
+    let zhujiang = []
+
+    selectedAssistants.value.forEach((rName) => {
+      const role = digitalHumans.value.find(r => r.name === rName)
+      zhujiang.push( role.name + "[" + role.description + "]")
+    })
+    content+= zhujiang.join(',') + ")";
+
+    await createAiRoleIdConversation(13)
+    currentStep.value = 2
+    await doSendMessageStream(activeConversationId.value, content)
+
+  } catch (error) {
+    console.error('生成脚本失败:', error)
+  } finally {
+    isGenerating.value = false
+  }
+}
+
+/** 选择 card 角色:新建聊天对话 */
+const createAiRoleIdConversation = async (roleId) => {
+  activeConversationId.value =  await ChatConversationApi.createChatConversationMy({
+    roleId: roleId
+  })
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async (conversationId, content) => {
+  conversationInAbortController.value = new AbortController()
+  conversationInProgress.value = true
+  scriptPrompt.value = ''
+
+  try {
+    let isFirstChunk = true
+    await ChatMessageApi.sendChatMessageStream(
+      conversationId,
+      content,
+      conversationInAbortController.value,
+      enableContext.value,
+      async (res) => {
+        const { code, data, msg } = JSON.parse(res.data)
+        if (code !== 0) {
+          console.error(`对话异常! ${msg}`)
+          return
+        }
+
+        if (data.eventType === 'TEXT') {
+          if (data.receive?.content === '') {
+            return
+          }
+
+          if (isFirstChunk) {
+            isFirstChunk = false
+          }
+
+          receiveMessageFullText.value += data.receive.content
+
+          try {
+            const parsedData = JSON.parse(receiveMessageFullText.value)
+            scriptDataTemp.value = parsedData
+            Object.assign(scriptData, parsedData)
+          } catch (e) {
+            // 解析失败,说明数据还不完整,继续等待
+          }
+        }
+      },
+      (error) => {
+        console.error(`对话异常! ${error}`)
+        throw error
+      },
+      () => {
+        try {
+          if (receiveMessageFullText.value) {
+            const parsedData = JSON.parse(receiveMessageFullText.value)
+            scriptDataTemp.value = parsedData
+            Object.assign(scriptData, parsedData)
+          }
+        } catch (e) {
+          console.error("最终数据解析失败:", e)
+        }
+      }
+    )
+  } catch (error) {
+    console.error("发送消息流失败:", error)
+  } finally {
+    conversationInProgress.value = false
+  }
+}
+
+// 步骤2:添加环节
+const addSection = () => {
+  scriptData.sections.push({
+    name: `环节${scriptData.sections.length + 1}`,
+    backgroundImage: { prompt: '', url: '', generating: false },
+    backgroundAudio: { type: '', url: '' },
+    dialogues: []
+  })
+}
+
+// 步骤2:添加对话(数字人)
+const addDialogue = (sectionIndex) => {
+  scriptData.sections[sectionIndex].dialogues.push({
+    type: 'digital',
+    roleName: '',
+    content: '',
+    voiceoverUrl: '',
+    generatingVoiceover: false
+  })
+}
+
+// 步骤2:添加用户回复
+const addUserReply = (sectionIndex) => {
+  scriptData.sections[sectionIndex].dialogues.push({
+    type: 'user',
+    content: '',
+    roleName: '',
+    voiceoverUrl: '',
+    generatingVoiceover: false
+  })
+}
+
+// 步骤2:删除对话
+const removeDialogue = (sectionIndex, dialogueIndex) => {
+  scriptData.sections[sectionIndex].dialogues.splice(dialogueIndex, 1)
+}
+
+// 步骤2:删除环节
+const removeSection = (sectionIndex) => {
+  if (scriptData.sections.length > 1) {
+    scriptData.sections.splice(sectionIndex, 1)
+  } else {
+    alert('至少需要保留一个环节')
+  }
+}
+
+// 图片生成相关状态
+const inProgressImageMap = ref({}) // 监听的图片映射,key 为 image 编号,value 为 { sectionIndex, type: 'image'|'audio' }
+const inProgressTimer = ref(null) // 生成中的图片定时器,轮询生成进展
+
+// 步骤2:生成单个媒体(仅背景图)
+const generateMedia = async (sectionIndex) => {
+  const section = scriptData.sections[sectionIndex]
+  const media = section.backgroundImage
+
+  media.generating = true
+  try {
+    const form = {
+      platform: 'DOU_BAO',
+      modelId: 56,
+      prompt: media.prompt,
+      width: 1024,
+      height: 768
+    }
+    const response = await ImageApi.drawImage(form)
+    inProgressImageMap.value[response] = {
+      sectionIndex
+    }
+  } catch (error) {
+    console.error(`生成图片失败:`, error)
+    media.generating = false
+  }
+}
+
+// 步骤2:一键生成所有背景图
+const generateAllImages = async () => {
+  isGeneratingImages.value = true
+  try {
+    for (let i = 0; i < scriptData.sections.length; i++) {
+      const section = scriptData.sections[i]
+      // 只处理没有背景图URL的环节
+      if (!section.backgroundImage.url) {
+        await generateMedia(i)
+      }
+    }
+
+    // 检查是否有图片正在生成
+    const checkImagesComplete = setInterval(() => {
+      if (Object.keys(inProgressImageMap.value).length === 0) {
+        isGeneratingImages.value = false
+        clearInterval(checkImagesComplete)
+      }
+    }, 1000)
+  } catch (error) {
+    console.error('生成所有背景图失败:', error)
+    isGeneratingImages.value = false
+  }
+}
+
+
+
+// 轮询生成中的图片列表
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length === 0) {
+    return
+  }
+
+  try {
+    const list = await ImageApi.getImageListMyByIds(imageIds)
+    const newWatchImages = {}
+    list.forEach((image) => {
+      const info = inProgressImageMap.value[image.id]
+      if (info) {
+        if (image.status === AiImageStatusEnum.IN_PROGRESS) {
+          newWatchImages[image.id] = info
+        } else if (image.status === AiImageStatusEnum.SUCCESS && image.picUrl) {
+          const section = scriptData.sections[info.sectionIndex]
+          section.backgroundImage.url = image.picUrl
+          section.backgroundImage.generating = false
+        } else if (image.status === AiImageStatusEnum.FAIL) {
+          const section = scriptData.sections[info.sectionIndex]
+          section.backgroundImage.generating = false
+        }
+      }
+    })
+    inProgressImageMap.value = newWatchImages
+  } catch (error) {
+    console.error('轮询图片状态失败:', error)
+  }
+}
+
+// 播放音频通用函数
+const playAudio = (url, type) => {
+  if (audioState.currentAudio) {
+    audioState.currentAudio.pause()
+    audioState.currentAudio.currentTime = 0
+  }
+
+  if (audioState.isPlaying && audioState.currentUrl === url && audioState.currentType === type) {
+    if (audioState.currentAudio) {
+      audioState.currentAudio.pause()
+    }
+    audioState.isPlaying = false
+    return
+  }
+
+  let audio = audioInstances.get(`${type}_${url}`)
+  if (!audio) {
+    audio = new Audio(url)
+    audioInstances.set(`${type}_${url}`, audio)
+
+    audio.onended = () => {
+      audioState.isPlaying = false
+      audioState.currentAudio = null
+      audioState.currentUrl = ''
+      audioState.currentType = ''
+    }
+  }
+
+  audio.play().catch(error => {
+    console.error('播放失败:', error)
+    audioState.isPlaying = false
+  })
+
+  audioState.currentAudio = audio
+  audioState.isPlaying = true
+  audioState.currentUrl = url
+  audioState.currentType = type
+}
+
+// 播放语音
+const playVoiceover = (voiceoverUrl) => {
+  playAudio(voiceoverUrl, 'voice')
+}
+
+// 处理背景音选择变化
+const handleBackgroundAudioChange = (value, section) => {
+  if (value) {
+    const selectedMusic = backgroundMusicTypes.value.find(music => music.id === value)
+    if (selectedMusic) {
+      section.backgroundAudio.url = selectedMusic.url
+    }
+  } else {
+    section.backgroundAudio.url = ''
+  }
+}
+
+// 播放背景音
+const playBackgroundAudio = (musicType) => {
+  const selectedMusic = backgroundMusicTypes.value.find(music => music.id === musicType)
+  const audioUrl = selectedMusic ? selectedMusic.url : `https://example.com/${musicType}.mp3`
+  playAudio(audioUrl, 'background')
+}
+
+// 生成单个语音
+const generateVoiceover = async (sectionIndex, dialogueIndex) => {
+  const dialogue = scriptData.sections[sectionIndex].dialogues[dialogueIndex]
+
+  // 只处理数字人类型的对话
+  if (dialogue.type !== 'digital') {
+    return
+  }
+
+  dialogue.generatingVoiceover = true
+
+  let role = digitalHumans.value.find(r => r.name === dialogue.roleName)
+
+  try {
+    // 调用后端API将文本转成语音
+    const speechUrl = await TtsApi.convert({
+      roleId: Number(role.id),
+      content: dialogue.content
+    })
+
+    // 将返回的URL赋值给对话
+    dialogue.voiceoverUrl = speechUrl
+  } catch (error) {
+    console.error('生成语音失败:', error)
+  } finally {
+    dialogue.generatingVoiceover = false
+  }
+}
+
+// 步骤3:一键生成所有语音
+const generateAllVoiceovers = async () => {
+  isGeneratingVoiceovers.value = true
+  try {
+    for (let i = 0; i < scriptData.sections.length; i++) {
+      for (let j = 0; j < scriptData.sections[i].dialogues.length; j++) {
+        const dialogue = scriptData.sections[i].dialogues[j]
+        // 只处理没有语音URL的对话
+        if (!dialogue.voiceoverUrl) {
+          await generateVoiceover(i, j)
+        }
+      }
+    }
+  } catch (error) {
+    console.error('生成所有语音失败:', error)
+  } finally {
+    isGeneratingVoiceovers.value = false
+  }
+}
+
+// 获取角色名称
+const getRoleName = (roleName) => {
+  const role = digitalHumans.value.find(r => r.name === roleName)
+  return role ? role.name : '未知角色'
+}
+
+// 解析 Markdown 文本
+const parseMarkdown = (text) => {
+  if (!text) return ''
+  return marked(text)
+}
+
+// 步骤4:校验脚本
+const validateScript = () => {
+  let isValid = true
+  let message = '校验通过'
+
+  if (!scriptData.sections.every(s => s.name.trim())) {
+    isValid = false
+    message = '存在空的环节名称'
+  } else if (!scriptData.sections.every(s => s.backgroundImage.url)) {
+    isValid = false
+    message = '存在未生成的背景图'
+  } else if (!scriptData.sections.every(s =>
+    s.dialogues.every(d => (d.type === 'digital' && d.roleName && d.content.trim() && d.voiceoverUrl) || (d.type === 'user' && d.roleName && d.content.trim()))
+  )) {
+    isValid = false
+    message = '存在未完成的对话或语音'
+  }
+
+  isValidationPassed.value = isValid
+  validationMessage.value = message
+}
+
+// 步骤4:保存脚本
+const saveScript = async () => {
+  validateScript()
+
+  if (!isValidationPassed.value) {
+    return
+  }
+  console.log('scriptData===', JSON.stringify(scriptData))
+}
+
+// 下一步操作
+const nextStep = () => {
+  if (canProceed.value) {
+    if (currentStep.value === 2) {
+      validateScript()
+    }
+    currentStep.value++
+  }
+}
+
+// 显示视频预览
+const showVideoPreview = () => {
+  showVideoPreviewModal.value = true
+}
+
+// 关闭视频预览
+const closeVideoPreview = () => {
+  showVideoPreviewModal.value = false
+}
+
+// 组件挂载时
+onMounted(async () => {
+  try {
+    const params = {
+      pageOn: 1,
+      pageSize: 100,
+      category: "小学低年级",
+      publicStatus: true
+    }
+    const { list } = await ChatRoleApi.getMyPage(params)
+    params.category += "AI"
+    const { list:listAi } = await ChatRoleApi.getMyPage(params)
+    list.push(...listAi)
+    digitalHumans.value = list
+  } catch (error) {
+    console.error('获取角色数据失败:', error)
+  }
+
+  inProgressTimer.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 3000)
+})
+
+// 组件卸载时
+onUnmounted(() => {
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
+  }
+})
+</script>
+
+<style scoped>
+.course-script-editor {
+  width: 100%;
+  height: 100vh;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background-color: #f8f9fa;
+}
+
+/* 主容器样式 */
+.main-container {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+}
+
+/* 左侧步骤指示器样式 */
+.steps-sidebar {
+  width: 200px;
+  background-color: #f5f7fa;
+  border-right: 1px solid #ebeef5;
+  padding: 30px 0;
+  display: flex;
+  align-items: center;
+}
+
+/* 右侧内容区域样式 */
+.content-area {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* 步骤内容样式 */
+.step-content {
+  flex: 1;
+  padding: 20px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  background-color: white;
+  border-radius: 0;
+  box-shadow: none;
+}
+
+/* 步骤1垂直居中 */
+.step-content:has(.ai-input-container) {
+  justify-content: center;
+  align-items: center;
+}
+
+/* 编辑器工具栏样式 - 固定顶部 */
+.editor-toolbar.sticky-top {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  margin-top: -20px;
+  margin-left: -20px;
+  margin-right: -20px;
+  border-radius: 0;
+  border-bottom: 1px solid #ebeef5;
+  background: rgba(250, 250, 250, 0.95);
+  backdrop-filter: blur(10px);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+/* 脚本编辑器样式 - 可滚动 */
+.script-editor {
+  flex: 1;
+  overflow-y: auto;
+  margin-top: 20px;
+  padding-right: 10px;
+  background-color: white;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.script-editor::-webkit-scrollbar {
+  width: 6px;
+}
+
+.script-editor::-webkit-scrollbar-track {
+  background: #f1f1f1;
+  border-radius: 3px;
+}
+
+.script-editor::-webkit-scrollbar-thumb {
+  background: #c1c1c1;
+  border-radius: 3px;
+}
+
+.script-editor::-webkit-scrollbar-thumb:hover {
+  background: #a1a1a1;
+}
+/* 步骤导航 */
+.steps-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 20px;
+  width: 100%;
+  .step-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    width: 100%;
+    .step-circle {
+      width: 32px;
+      height: 32px;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-weight: bold;
+      margin-bottom: 8px;
+    }
+    .step-title {
+      font-size: 14px;
+      text-align: center;
+      padding: 0 20px;
+    }
+    &.process {
+      .step-circle {
+        background-color: #409EFF;
+        color: white;
+      }
+      .step-title {
+        color: #409EFF;
+      }
+    }
+    &.wait {
+      .step-circle {
+        background-color: #E4E7ED;
+        color: #C0C4CC;
+      }
+      .step-title {
+        color: #C0C4CC;
+      }
+    }
+    &.finish {
+      .step-circle {
+        background-color: #67C23A;
+        color: white;
+      }
+      .step-title {
+        color: #67C23A;
+      }
+    }
+  }
+  .step-line {
+    width: 2px;
+    height: 40px;
+    background-color: #E4E7ED;
+    &.active {
+      background-color: #409EFF;
+    }
+  }
+}
+
+/* 步骤内容 */
+.step-content {
+  background-color: white;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+/* 对话类型标签 */
+.dialogue-type-tag {
+  position: absolute;
+  top: -10px;
+  left: 10px;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: white;
+}
+
+.dialogue-type-tag.digital {
+  background-color: #409EFF;
+}
+
+.dialogue-type-tag.user {
+  background-color: #67C23A;
+}
+
+/* 对话项样式 */
+.dialogue-item {
+  position: relative;
+  background-color: #f9f9f9;
+  border-radius: 8px;
+  padding: 20px;
+  margin-bottom: 15px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+  transition: all 0.3s ease;
+}
+
+.dialogue-item:hover {
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+}
+
+.dialogue-item.digital {
+  border-left: 4px solid #409EFF;
+}
+
+.dialogue-item.user {
+  border-left: 4px solid #67C23A;
+}
+
+/* 对话头部 */
+.dialogue-header {
+  margin-bottom: 10px;
+}
+
+/* 对话行 */
+.dialogue-row {
+  display: flex;
+  gap: 15px;
+  align-items: flex-start;
+}
+
+/* 角色选择 */
+.dialogue-role-select {
+  flex-shrink: 0;
+}
+
+/* 回复角色选择 */
+.dialogue-reply-select {
+  flex-shrink: 0;
+}
+
+/* 内容容器 */
+.dialogue-content-container {
+  flex: 1;
+  min-width: 0;
+}
+
+
+
+/* 添加对话按钮容器 */
+.add-dialogue-buttons {
+  display: flex;
+  gap: 10px;
+  margin-top: 15px;
+}
+
+/* 添加对话按钮 */
+.add-dialogue-btn {
+  padding: 8px 16px;
+  border: none;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+  min-width: 120px;
+}
+
+.add-dialogue-btn.digital {
+  background-color: #83c2ff;
+  color: white;
+}
+
+.add-dialogue-btn.digital:hover {
+  background-color: #409EFF;
+  border-color: #4aa2fd;
+  color: white;
+}
+
+.add-dialogue-btn.user {
+  background-color: #85ce61;
+  color: white;
+}
+
+.add-dialogue-btn.user:hover {
+  background-color: #67C23A;
+  border-color: #67C23A;
+  color: white;
+}
+
+/* 预览对话样式 */
+.preview-dialogue {
+  position: relative;
+  margin-bottom: 20px;
+  padding: 15px;
+  border-radius: 8px;
+  background-color: #f9f9f9;
+}
+
+.preview-dialogue.digital {
+  border-left: 4px solid #409EFF;
+}
+
+.preview-dialogue.user {
+  border-left: 4px solid #67C23A;
+}
+
+.preview-dialogue .dialogue-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0;
+  margin-bottom: 10px;
+}
+
+.preview-dialogue .dialogue-header-left {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+
+.preview-dialogue .dialogue-type-tag {
+  position: static;
+  margin-right: 2px;
+}
+
+/* 回复信息 */
+.reply-info {
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #e8e8e8;
+  font-size: 14px;
+  color: #666;
+  font-style: italic;
+}
+
+/* AI输入容器 */
+.ai-input-container {
+  max-width: 800px;
+  margin: 0 auto;
+  padding: 30px;
+  background: white;
+  border-radius: 16px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+  display: flex;
+  flex-direction: column;
+  min-height: 500px;
+}
+
+.selection-group {
+  margin-bottom: 30px;
+}
+
+.ai-input-wrapper {
+  margin-bottom: 30px;
+}
+
+.ai-input-container button.generate-btn.primary {
+  margin-top: 30px;
+  align-self: center;
+}
+
+.ai-input-wrapper {
+  position: relative;
+  margin-bottom: 30px;
+}
+
+.ai-icon {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  font-size: 28px;
+  color: #409EFF;
+  background: rgba(64, 158, 255, 0.1);
+  width: 48px;
+  height: 48px;
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+  0% {
+    box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4);
+  }
+  70% {
+    box-shadow: 0 0 0 10px rgba(64, 158, 255, 0);
+  }
+  100% {
+    box-shadow: 0 0 0 0 rgba(64, 158, 255, 0);
+  }
+}
+
+.ai-textarea {
+  width: 100%;
+  min-height: 140px;
+  padding: 24px 24px 24px 84px;
+  border: 2px solid #e8e8e8;
+  border-radius: 16px;
+  font-size: 16px;
+  resize: vertical;
+  transition: all 0.3s ease;
+  background: #fafafa;
+  line-height: 1.6;
+}
+
+.ai-textarea:focus {
+  outline: none;
+  border-color: #409EFF;
+  background: white;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.selection-group {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+  margin-bottom: 30px;
+}
+
+.selection-item {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.selection-item label {
+  font-weight: 600;
+  color: #333;
+  font-size: 14px;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  color: #666;
+}
+
+.selection-item select {
+  padding: 14px 16px;
+  border: 2px solid #e8e8e8;
+  border-radius: 10px;
+  font-size: 14px;
+  transition: all 0.3s ease;
+  background: white;
+}
+
+.selection-item select:focus {
+  outline: none;
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.multi-select {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  padding: 12px;
+  border: 2px solid #e8e8e8;
+  border-radius: 10px;
+  background: white;
+  transition: all 0.3s ease;
+}
+
+.multi-select:focus-within {
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.select-option {
+  padding: 8px 16px;
+  background: #f5f5f5;
+  border-radius: 20px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 14px;
+  font-weight: 500;
+  border: 1px solid transparent;
+}
+
+.select-option:hover {
+  background: #e6f7ff;
+  border-color: #91d5ff;
+}
+
+.select-option.selected {
+  background: #409EFF;
+  color: white;
+  border-color: #409EFF;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
+}
+
+/* 加载遮挡层样式 */
+.loading-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.1);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+  pointer-events: auto;
+}
+
+.loading-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16px;
+  background: white;
+  padding: 32px;
+  border-radius: 12px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+}
+
+.loading-spinner {
+  width: 48px;
+  height: 48px;
+  border: 4px solid #f3f3f3;
+  border-top: 4px solid #409EFF;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+.loading-text {
+  font-size: 16px;
+  font-weight: 500;
+  color: #333;
+}
+
+/* 按钮样式 */
+.generate-btn {
+  padding: 14px 8px;
+  border: none;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 400;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  position: relative;
+  overflow: hidden;
+}
+
+.generate-btn.primary {
+  background: linear-gradient(135deg, #409EFF, #1890ff);
+  color: white;
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+.generate-btn.primary:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
+}
+
+.generate-btn.primary:disabled {
+  background: #91c5f7;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.generate-btn.small {
+  padding: 8px 16px;
+  font-size: 14px;
+  border-radius: 8px;
+}
+
+.generate-btn.small:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 3px 8px rgba(64, 158, 255, 0.3);
+}
+
+.generate-btn.small:disabled {
+  background: #91c5f7;
+  color: white;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.toolbar-btn:disabled {
+  background: #f0f0f0;
+  color: #c0c0c0;
+  border-color: #e0e0e0;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.toolbar-btn:disabled:hover {
+  background: #f0f0f0;
+  color: #c0c0c0;
+  border-color: #e0e0e0;
+  transform: none;
+  box-shadow: none;
+}
+
+/* 编辑器工具栏 */
+.editor-toolbar {
+  position: sticky;
+  top: 20px;
+  z-index: 100;
+  margin-bottom: 30px;
+  padding: 16px 20px;
+  background: rgba(250, 250, 250, 0.95);
+  backdrop-filter: blur(10px);
+  border-radius: 12px;
+  border: 1px solid #e8e8e8;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  overflow-x: auto;
+  overflow-y: hidden;
+  white-space: nowrap;
+}
+
+.editor-toolbar::-webkit-scrollbar {
+  height: 4px;
+}
+
+.editor-toolbar::-webkit-scrollbar-track {
+  background: #f1f1f1;
+  border-radius: 2px;
+}
+
+.editor-toolbar::-webkit-scrollbar-thumb {
+  background: #c1c1c1;
+  border-radius: 2px;
+}
+
+.editor-toolbar::-webkit-scrollbar-thumb:hover {
+  background: #a1a1a1;
+}
+
+.toolbar-btn {
+  padding: 12px 24px;
+  margin: 0 12px;
+  background: white;
+  border: 2px solid #e8e8e8;
+  border-radius: 10px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  font-weight: 500;
+  color: #333;
+}
+
+.toolbar-btn:hover {
+  background: #409EFF;
+  color: white;
+  border-color: #409EFF;
+  transform: translateY(-1px);
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+/* 脚本编辑器 */
+.script-section {
+  margin-top: 40px;
+  margin-bottom: 40px;
+  padding: 5px 24px 24px;
+  background: white;
+  border-radius: 16px;
+  position: relative;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+  border: 1px solid #f0f0f0;
+}
+
+.section-header {
+  margin-bottom: 24px;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.section-name-container {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  gap: 12px;
+  margin-top: 16px;
+}
+
+.remove-section-btn {
+  background: #ff4d4f;
+  border: none;
+  color: white;
+  font-size: 16px;
+  cursor: pointer;
+  padding: 0;
+  width: 28px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 6px;
+  transition: all 0.3s ease;
+  flex-shrink: 0;
+}
+
+.remove-section-btn:hover {
+  background: #ff7875;
+  transform: scale(1.1);
+  box-shadow: 0 2px 8px rgba(255, 77, 79, 0.3);
+}
+
+.section-name-label {
+  padding: 2px 15px;
+  border-radius: 10px;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  background-color: #c18484;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  height: 44px;
+  box-sizing: border-box;
+}
+
+.section-title {
+  flex: 1;
+  padding: 14px 16px;
+  border: 2px solid #e8e8e8;
+  border-radius: 10px;
+  font-size: 16px;
+  font-weight: 500;
+  transition: all 0.3s ease;
+}
+
+.section-title:focus {
+  outline: none;
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.media-controls {
+  display: flex;
+  background: linear-gradient(135deg, #f8f9fa, #ffffff);
+  padding: 16px 20px;
+  border-radius: 10px;
+  border: 1px solid #e8e8e8;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.media-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-right: 50px;
+}
+
+.media-input-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.media-label {
+  font-size: 14px;
+  font-weight: 600;
+  white-space: nowrap;
+  color: #666;
+}
+
+.media-prompt {
+  font-size: 14px;
+  transition: all 0.3s ease;
+  width: 300px;
+}
+
+.media-prompt .el-textarea {
+  width: 100%;
+  min-height: 60px;
+}
+
+.media-prompt .el-textarea__inner {
+  padding: 8px 12px;
+  border: 2px solid #e8e8e8;
+  border-radius: 8px;
+  font-size: 14px;
+  transition: all 0.3s ease;
+  min-height: 60px;
+  resize: none;
+}
+
+.media-prompt .el-textarea__inner:focus {
+  outline: none;
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.media-preview {
+  margin-top: 12px;
+  margin-left: 20px;
+}
+
+.media-preview img {
+  max-width: 180px;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  transition: transform 0.3s ease;
+}
+
+.media-preview img:hover {
+  transform: scale(1.05);
+}
+
+.dialogues-container {
+  margin-top: 48px;
+}
+
+.dialogue-item {
+  margin-bottom: 20px;
+  padding: 20px;
+  background: #fafafa;
+  border-radius: 12px;
+  border: 1px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.dialogue-item:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  transform: translateY(-2px);
+}
+
+.dialogue-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.dialogue-role-select {
+  min-width: 140px;
+}
+
+.dialogue-role-select select {
+  width: 100%;
+  padding: 10px 12px;
+  border: 2px solid #e8e8e8;
+  border-radius: 8px;
+  font-size: 14px;
+  transition: all 0.3s ease;
+}
+
+.dialogue-role-select select:focus {
+  outline: none;
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.dialogue-content-container {
+  flex: 1;
+}
+
+.dialogue-content:focus {
+  outline: none;
+  border-color: #409EFF;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
+}
+
+.remove-btn {
+  background: #ff4d4f;
+  border: none;
+  color: white;
+  font-size: 16px;
+  cursor: pointer;
+  padding: 0;
+  width: 28px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 6px;
+  transition: all 0.3s ease;
+  flex-shrink: 0;
+}
+
+.remove-btn:hover {
+  background: #ff7875;
+  transform: scale(1.1);
+  box-shadow: 0 2px 8px rgba(255, 77, 79, 0.3);
+}
+
+.dialogue-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.dialogue-text {
+  flex: 1;
+  width: 100%;
+  font-size: 16px;
+  line-height: 1.6;
+}
+
+.generate-btn.small {
+  padding: 8px;
+  background: linear-gradient(135deg, #409EFF, #1890ff);
+  color: white;
+  border: none;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  font-size: 14px;
+}
+
+.generate-btn.small:hover {
+  transform: scale(1.1);
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
+}
+
+.play-btn {
+  padding: 8px;
+  background: #52c41a;
+  color: white;
+  border: none;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  font-size: 14px;
+}
+
+.play-btn:hover {
+  background: #73d13d;
+  transform: scale(1.1);
+  box-shadow: 0 2px 8px rgba(82, 196, 26, 0.3);
+}
+
+.play-btn.small {
+  padding: 8px;
+  width: 28px;
+  height: 28px;
+  font-size: 14px;
+}
+
+.voice-icon {
+  font-size: 14px;
+  line-height: 1;
+}
+
+.play-icon {
+  font-size: 14px;
+  line-height: 1;
+}
+
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin-left: 8px;
+}
+
+.action-buttons-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+
+
+.add-dialogue-btn,
+.add-section-btn {
+  padding: 12px 24px;
+  background: white;
+  border: 2px dashed #d9d9d9;
+  border-radius: 10px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  font-weight: 500;
+  color: #666;
+  margin-top: 16px;
+  width: 100%;
+  text-align: center;
+}
+
+.add-dialogue-btn:hover,
+.add-section-btn:hover {
+  border-color: #c18484;
+  color: white;
+  background: rgba(193, 132, 132, 0.6);
+  transform: translateY(-1px);
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
+}
+
+
+
+/* 预览页面 */
+.preview-container {
+  width: 100%;
+  height: 100%;
+  background: white;
+  border-radius: 16px;
+  padding: 30px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+  display: flex;
+  flex-direction: column;
+}
+
+.preview-content {
+  flex: 1;
+  overflow-y: auto;
+  margin-top: 20px;
+  margin-bottom: 30px;
+  padding-right: 10px;
+}
+
+.preview-content::-webkit-scrollbar {
+  width: 6px;
+}
+
+.preview-content::-webkit-scrollbar-track {
+  background: #f1f1f1;
+  border-radius: 3px;
+}
+
+.preview-content::-webkit-scrollbar-thumb {
+  background: #c1c1c1;
+  border-radius: 3px;
+}
+
+.preview-content::-webkit-scrollbar-thumb:hover {
+  background: #a1a1a1;
+}
+
+.preview-actions {
+  margin-top: auto;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+
+.validation-result {
+  padding: 16px 20px;
+  border-radius: 10px;
+  font-weight: 500;
+  font-size: 14px;
+}
+
+.validation-result.valid {
+  background: #f6ffed;
+  border: 2px solid #b7eb8f;
+  color: #52c41a;
+  box-shadow: 0 2px 8px rgba(82, 196, 26, 0.1);
+}
+
+.validation-result.invalid {
+  background: #fff2f0;
+  border: 2px solid #ffccc7;
+  color: #ff4d4f;
+  box-shadow: 0 2px 8px rgba(255, 77, 79, 0.1);
+}
+
+.preview-section {
+  margin-bottom: 40px;
+  padding: 40px;
+  border-radius: 12px;
+  border: 1px solid #e8e8e8;
+  min-height: 300px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.preview-section h4 {
+  margin-top: 0;
+  color: #333;
+  font-size: 18px;
+  font-weight: 600;
+  margin-bottom: 20px;
+}
+
+.preview-section-content {
+  background: rgba(255, 255, 255, 0.2);
+  padding: 20px;
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  backdrop-filter: blur(5px);
+  width: 80%  ;
+}
+
+.preview-media {
+  margin: 20px 0;
+  padding: 16px;
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 10px;
+  border: 1px solid #e8e8e8;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}
+
+.preview-media-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.preview-media-right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.preview-image img {
+  max-width: 250px;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  transition: transform 0.3s ease;
+}
+
+.preview-image img:hover {
+  transform: scale(1.05);
+}
+
+.preview-audio{
+  float: right;
+  margin-right: 20px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.preview-dialogue {
+  margin-bottom: 20px;
+  padding: 16px;
+  background: white;
+  border-radius: 10px;
+  border: 1px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.dialogue-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.preview-dialogue:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.dialogue-role {
+  font-weight: 600;
+  color: #409EFF;
+  font-size: 14px;
+}
+
+.dialogue-text {
+  line-height: 1.6;
+  font-size: 14px;
+  color: #333;
+}
+
+/* Markdown 样式 */
+.dialogue-text h1,
+.dialogue-text h2,
+.dialogue-text h3,
+.dialogue-text h4,
+.dialogue-text h5,
+.dialogue-text h6 {
+  margin: 16px 0 8px 0;
+  font-weight: 600;
+  line-height: 1.2;
+}
+
+.dialogue-text h1 { font-size: 24px; }
+.dialogue-text h2 { font-size: 20px; }
+.dialogue-text h3 { font-size: 18px; }
+.dialogue-text h4 { font-size: 16px; }
+.dialogue-text h5 { font-size: 14px; }
+.dialogue-text h6 { font-size: 12px; }
+
+.dialogue-text p {
+  margin: 8px 0;
+}
+
+.dialogue-text ul,
+.dialogue-text ol {
+  margin: 8px 0;
+  padding-left: 24px;
+}
+
+.dialogue-text li {
+  margin: 4px 0;
+}
+
+.dialogue-text strong {
+  font-weight: 600;
+}
+
+.dialogue-text em {
+  font-style: italic;
+}
+
+.dialogue-text a {
+  color: #409EFF;
+  text-decoration: none;
+}
+
+.dialogue-text a:hover {
+  text-decoration: underline;
+}
+
+.dialogue-text code {
+  background: #f5f5f5;
+  padding: 2px 4px;
+  border-radius: 4px;
+  font-family: 'Courier New', Courier, monospace;
+  font-size: 14px;
+}
+
+.dialogue-text pre {
+  background: #f5f5f5;
+  padding: 12px;
+  border-radius: 8px;
+  overflow-x: auto;
+  margin: 12px 0;
+}
+
+.dialogue-text pre code {
+  background: none;
+  padding: 0;
+}
+
+.voiceover-preview {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid #f0f0f0;
+}
+
+.preview-actions {
+  display: flex;
+  justify-content: center;
+  gap: 20px;
+  padding-top: 24px;
+  border-top: 1px solid #e8e8e8;
+}
+
+.primary-btn,
+.secondary-btn {
+  padding: 14px 32px;
+  border: none;
+  border-radius: 12px;
+  font-size: 16px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  position: relative;
+  overflow: hidden;
+}
+
+.primary-btn {
+  background: linear-gradient(135deg, #409EFF, #1890ff);
+  color: white;
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+.primary-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
+}
+
+.primary-btn:disabled {
+  background: #91c5f7;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.secondary-btn {
+  background: white;
+  color: #333;
+  border: 2px solid #e8e8e8;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.secondary-btn:hover {
+  background: #fafafa;
+  border-color: #409EFF;
+  color: #409EFF;
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+</style>