DialogContent.vue 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282
  1. <template>
  2. <div class="dialog-content-wrapper">
  3. <!-- 遮罩层 -->
  4. <div v-if="showMask" class="mask-layer" ref="maskLayer">
  5. <div class="play-button-container">
  6. <button class="play-button" @click="startPlayback">
  7. <el-icon class="play-icon"><VideoPlay /></el-icon>
  8. </button>
  9. </div>
  10. </div>
  11. <!-- 标题 -->
  12. <div class="title-box">
  13. <!-- 返回 -->
  14. <div class="title-left">
  15. <div class="box-icon" @click="goBackToMain">
  16. <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
  17. {{ backText }}
  18. </div>
  19. </div>
  20. <!-- 标题 -->
  21. <div class="title-center">
  22. <div class="title-text" :title="currentSection?.name">
  23. {{ currentSection?.name }}
  24. </div>
  25. </div>
  26. <!-- 自动按钮 -->
  27. <div class="title-right">
  28. <div class="box-icon" @click="togglePlay">
  29. <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
  30. </div>
  31. </div>
  32. </div>
  33. <!-- 内容区域 -->
  34. <div class="content-box">
  35. <!-- 人物形象 -->
  36. <div
  37. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  38. :key="`character-${currentDialogueIndex}`"
  39. class="character"
  40. :class="{
  41. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  42. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  43. }"
  44. :style="{ backgroundImage: `url(${getCharacterImage(currentDialogue.roleName)})` }"
  45. ></div>
  46. <!-- 对话卡片 -->
  47. <div
  48. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  49. :key="`dialogue-${currentDialogueIndex}`"
  50. class="dialogue-card"
  51. :class="{
  52. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  53. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  54. }"
  55. >
  56. <div class="dialogue-header">
  57. <span class="role-name">{{ currentDialogue?.roleName }}</span>
  58. </div>
  59. <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
  60. </div>
  61. <!-- 诗词显示区域 -->
  62. <div v-if="showPoem" class="poem-display">
  63. <div class="poem-content">
  64. <div class="poem-text" v-html="formatPoemContent(currentPoemContent)" ></div>
  65. </div>
  66. </div>
  67. <!-- 视频显示区域 -->
  68. <div v-if="currentDialogue && currentDialogue.type === 'video'" class="video-display">
  69. <div class="video-frame">
  70. <video
  71. :src="currentDialogue.videoUrl"
  72. class="dialogue-video"
  73. ref="dialogueVideoRef"
  74. controls
  75. autoplay
  76. @ended="handleVideoEnded"
  77. @contextmenu.prevent
  78. controlslist="nodownload"
  79. >
  80. 您的浏览器不支持视频播放
  81. </video>
  82. </div>
  83. </div>
  84. <!-- 用户输入卡片 -->
  85. <div
  86. v-if="currentDialogue.type === 'user' && !isUserSingleChoice"
  87. class="dialogue-card user-input-card"
  88. >
  89. <div class="dialogue-header">
  90. <span class="role-name">我</span>
  91. </div>
  92. <div class="dialogue-content">
  93. <textarea
  94. :value="userInput"
  95. @input="e => userInput = e.target.value"
  96. class="user-input-textarea"
  97. placeholder="请输入内容..."
  98. @keyup.enter.exact="submitUserInput"
  99. ></textarea>
  100. <div class="input-actions">
  101. <button class="cancel-btn" @click="cancelUserInput">清空</button>
  102. <button class="submit-btn" @click="submitUserInput">发送</button>
  103. </div>
  104. </div>
  105. </div>
  106. <!-- 单选问题卡片(在user类型时显示) -->
  107. <div
  108. v-if="isUserSingleChoice"
  109. class="dialogue-card single-choice-card"
  110. >
  111. <div class="dialogue-header">
  112. <span class="role-name">我</span>
  113. </div>
  114. <div class="single-choice-content">
  115. <!-- 问题描述 -->
  116. <div class="question-text" v-html="parseMarkdown(previousQuestDialogue?.content || '')"></div>
  117. <!-- 选项列表 -->
  118. <div class="options-list">
  119. <div
  120. v-for="(option, index) in previousQuestDialogue?.options"
  121. :key="index"
  122. class="option-item"
  123. :class="{ 'selected': selectedOption === optionLabels[index] }"
  124. @click="selectOption(optionLabels[index])"
  125. >
  126. <span class="option-label">{{ optionLabels[index] }}</span>
  127. <span class="option-content">{{ option.content }}</span>
  128. </div>
  129. </div>
  130. <!-- 操作按钮 -->
  131. <div class="input-actions">
  132. <button class="cancel-btn" @click="cancelSingleChoice">清空选择</button>
  133. <button class="submit-btn" :disabled="!selectedOption" @click="submitSingleChoice">提交答案</button>
  134. </div>
  135. </div>
  136. </div>
  137. <!-- 输入按钮区域 -->
  138. <div class="input-buttons-container" >
  139. <!-- 上一个对话按钮 -->
  140. <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
  141. <el-icon class="arrow-icon"><CaretLeft /></el-icon>
  142. </div>
  143. <!-- 语音输入按钮 -->
  144. <div class="voice-input-outer" v-if="currentDialogue.type === 'user'" :class="{ 'recording': isVoiceRecording }">
  145. <VoiceInput
  146. inputSelector=".user-input-textarea"
  147. lang="zh-CN"
  148. maxDuration="10"
  149. @voiceRecognized="handleVoiceRecognized"
  150. @recordingStatusChanged="handleRecordingStatusChanged"
  151. />
  152. </div>
  153. <!-- 语音输入按钮占位符 -->
  154. <div class="voice-input-outer placeholder" v-else></div>
  155. <!-- 下一个对话按钮 -->
  156. <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': isAtLastDialogue() }">
  157. <el-icon class="arrow-icon"><CaretRight /></el-icon>
  158. </div>
  159. </div>
  160. </div>
  161. <!-- 背景图 -->
  162. <img
  163. v-if="currentBackgroundType === 'imageAudio'"
  164. :src="currentBackgroundImage"
  165. alt="背景图"
  166. class="background-image"
  167. >
  168. <!-- 背景视频 -->
  169. <video
  170. v-else-if="currentBackgroundType === 'video'"
  171. ref="backgroundVideoRef"
  172. :src="currentBackgroundVideo"
  173. class="background-video"
  174. loop
  175. muted
  176. playsinline>
  177. 您的浏览器不支持视频播放
  178. </video>
  179. </div>
  180. </template>
  181. <script setup>
  182. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  183. import { useRouter } from 'vue-router'
  184. import { ArrowLeftBold, CaretLeft, CaretRight, Grid, VideoPlay } from '@element-plus/icons-vue'
  185. import VoiceInput from '@/components/ai/voice/VoiceInput_Api.vue'
  186. import { marked } from 'marked'
  187. import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
  188. import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
  189. const { playAudioChunk , stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer();
  190. // 路由实例
  191. const router = useRouter()
  192. const props = defineProps({
  193. scriptData: {
  194. type: Object,
  195. default: () => ({
  196. title: "",
  197. sections: []
  198. })
  199. },
  200. scriptRoles: {
  201. type: Array,
  202. default: () => []
  203. },
  204. backText: {
  205. type: String,
  206. default: '返回课程'
  207. },
  208. // 是否是最后一节课
  209. isLastCourse: {
  210. type: Boolean,
  211. default: false
  212. }
  213. })
  214. const emit = defineEmits(['dialogueEnded'])
  215. // 对话相关状态
  216. // 当前章节索引
  217. const currentSectionIndex = ref(0)
  218. // 当前对话索引
  219. const currentDialogueIndex = ref(0)
  220. // 是否正在播放
  221. const isPlaying = ref(false)
  222. // 用户输入内容
  223. const userInput = ref('')
  224. // 语音录音状态
  225. const isVoiceRecording = ref(false)
  226. // 实时语音识别结果
  227. const voiceRecognizedText = ref("")
  228. // 诗词显示状态
  229. const showPoem = ref(false)
  230. // 当前诗词内容
  231. const currentPoemContent = ref('')
  232. // 单选问题选中答案
  233. const selectedOption = ref('')
  234. // 选项标签映射
  235. const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
  236. // 音频对象
  237. // 背景音频
  238. const backgroundAudio = ref(null)
  239. // 对话音频
  240. const dialogueAudio = ref(null)
  241. // 背景视频
  242. const backgroundVideoRef = ref(null)
  243. // 对话视频
  244. const dialogueVideoRef = ref(null)
  245. // 遮罩层显示状态
  246. const showMask = ref(true)
  247. // 是否开始播放
  248. const isPlaybackStarted = ref(false)
  249. // 计算属性
  250. // 当前章节信息
  251. const currentSection = computed(() => {
  252. return props.scriptData.sections[currentSectionIndex.value]
  253. })
  254. // 当前对话信息
  255. const currentDialogue = computed(() => {
  256. if (!currentSection.value) return null
  257. return currentSection.value.dialogues[currentDialogueIndex.value]
  258. })
  259. const currentBackgroundImage = computed(() => {
  260. if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
  261. return ''
  262. }
  263. return currentSection.value.backgroundImage.url
  264. })
  265. // 当前背景类型
  266. const currentBackgroundType = computed(() => {
  267. return currentSection.value?.backgroundType || 'imageAudio'
  268. })
  269. // 当前背景视频
  270. const currentBackgroundVideo = computed(() => {
  271. if (!currentSection.value || !currentSection.value.backgroundVideo || !currentSection.value.backgroundVideo.url) {
  272. return ''
  273. }
  274. return currentSection.value.backgroundVideo.url
  275. })
  276. // 当前对话缓存
  277. const currentDialogueCache = ref(null)
  278. // 方法
  279. const handleVoiceRecognized = (data) => {
  280. console.log('语音识别结果:', data.originalText)
  281. if (isVoiceRecording.value) {
  282. // 在同一次录音过程中,实时更新文本框内容
  283. voiceRecognizedText.value = data.originalText
  284. userInput.value = data.processedText
  285. } else {
  286. // 在录音结束时,将最终的语音内容追加到userInput.value
  287. const textarea = document.querySelector('.user-input-textarea')
  288. if (textarea) {
  289. userInput.value = data.processedText
  290. // 重新设置光标位置到插入文本的末尾
  291. setTimeout(() => {
  292. textarea.selectionStart = textarea.selectionEnd = data.cursorPos
  293. }, 0)
  294. } else {
  295. // 如果没有找到输入框,直接替换整个内容
  296. userInput.value = data.originalText
  297. }
  298. // 清空临时变量
  299. voiceRecognizedText.value = ""
  300. }
  301. }
  302. // 处理录音状态变化
  303. const handleRecordingStatusChanged = (isRecording) => {
  304. console.log('录音状态:', isRecording)
  305. const wasRecording = isVoiceRecording.value
  306. isVoiceRecording.value = isRecording
  307. // 如果是从录音状态切换到非录音状态,只需要清空临时变量
  308. if (wasRecording && !isRecording) {
  309. // 清空临时变量
  310. voiceRecognizedText.value = ""
  311. }
  312. }
  313. // 获取上一条quest对话
  314. const previousQuestDialogue = computed(() => {
  315. const prevDialogue = getPreviousDialogue()
  316. if (prevDialogue?.type === 'quest') {
  317. return prevDialogue
  318. }
  319. return null
  320. })
  321. // 判断当前user对话是否应该显示单选框
  322. const isUserSingleChoice = computed(() => {
  323. if (currentDialogue.value?.type !== 'user') return false
  324. const prevDialogue = previousQuestDialogue.value
  325. return prevDialogue?.questionType === 'singleChoice' &&
  326. prevDialogue?.options &&
  327. prevDialogue.options.length > 0
  328. })
  329. // 判断是否是单选问题(quest类型时)
  330. const isSingleChoiceQuestion = computed(() => {
  331. return currentDialogue.value?.type === 'quest' &&
  332. currentDialogue.value?.questionType === 'singleChoice' &&
  333. currentDialogue.value?.options &&
  334. currentDialogue.value.options.length > 0
  335. })
  336. // 提交用户输入
  337. const submitUserInput = async () => {
  338. if (userInput.value.trim()) {
  339. console.log('用户输入:', userInput.value)
  340. await createAiChart();
  341. await doSendMessage();
  342. }
  343. }
  344. // 取消用户输入
  345. const cancelUserInput = () => {
  346. userInput.value = ''
  347. }
  348. // 选择单选选项
  349. const selectOption = (label) => {
  350. selectedOption.value = label
  351. }
  352. // 取消单选选择
  353. const cancelSingleChoice = () => {
  354. selectedOption.value = ''
  355. }
  356. // 提交单选答案
  357. const submitSingleChoice = async () => {
  358. if (!selectedOption.value) return
  359. console.log('用户选择:', selectedOption.value)
  360. await createAiChart();
  361. await doSendSingleChoiceMessage();
  362. }
  363. // 解析 Markdown 内容
  364. const parseMarkdown = (content) => {
  365. if (!content) return ''
  366. return marked(content)
  367. }
  368. // 格式化诗词内容,在逗号和句号后添加换行
  369. const formatPoemContent = (content) => {
  370. if (!content) return ''
  371. const plainText = content.replace(/[\*#`\[\]\(\)]/g, '')
  372. return plainText.replace(/([。!?;、])/g, '$1<br/>')
  373. }
  374. const getCharacterSide = () => {
  375. return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
  376. }
  377. // 根据角色ID获取角色名称
  378. const getRole = (roleName) => {
  379. return props.scriptRoles.find(r => r.name === roleName)
  380. }
  381. const getCharacterImage = (roleName) => {
  382. const role = getRole(roleName)
  383. return role ? role.avatar : ''
  384. }
  385. const stopAllAudio = () => {
  386. if (backgroundAudio.value) {
  387. backgroundAudio.value.pause()
  388. backgroundAudio.value.currentTime = 0
  389. }
  390. if (dialogueAudio.value) {
  391. dialogueAudio.value.pause()
  392. dialogueAudio.value.currentTime = 0
  393. }
  394. }
  395. const playBackgroundAudio = () => {
  396. // 停止之前的背景音
  397. if (backgroundAudio.value) {
  398. backgroundAudio.value.pause()
  399. backgroundAudio.value.currentTime = 0
  400. }
  401. // 只有当背景类型为 imageAudio 时才播放背景音频
  402. if (currentBackgroundType.value === 'imageAudio' && currentSection.value?.backgroundAudio?.url && isPlaying.value) {
  403. backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url)
  404. backgroundAudio.value.loop = true
  405. backgroundAudio.value.volume = 1
  406. backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e))
  407. }
  408. // 处理背景视频
  409. if (currentBackgroundType.value === 'video' && isPlaybackStarted.value) {
  410. // 视频已经在模板中渲染,这里只需要确保它在播放状态
  411. if (backgroundVideoRef.value) {
  412. backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
  413. }
  414. }
  415. }
  416. const playDialogueAudio = (isAutoPlay = false) => {
  417. // 停止之前的对话语音
  418. if (dialogueAudio.value) {
  419. dialogueAudio.value.pause()
  420. dialogueAudio.value.currentTime = 0
  421. }
  422. // 播放当前对话的语音
  423. if (currentDialogue.value?.voiceoverUrl && currentDialogue.value?.type !== 'video') {
  424. const audio = new Audio(currentDialogue.value.voiceoverUrl)
  425. dialogueAudio.value = audio
  426. // 音频结束事件
  427. audio.onended = () => {
  428. // 检查是否是最后一个对话
  429. if (isAtLastDialogue()) {
  430. // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
  431. if (currentDialogue.value?.type === 'user') {
  432. console.log('用户输入类型,等待AI回答完成后再提示');
  433. return;
  434. }
  435. // 语音播报完成且是最后一个对话,触发事件通知父组件
  436. console.log('普通对话语音播放完成,已到达最后一个对话,触发 dialogueEnded 事件');
  437. emit('dialogueEnded', props.isLastCourse);
  438. isPlaying.value = false;
  439. return;
  440. }
  441. // 如果是自动播放状态,继续播放下一条
  442. if (isAutoPlay && isPlaying.value) {
  443. setTimeout(() => {
  444. if (!playNext(true)) {
  445. // 播放完毕
  446. isPlaying.value = false
  447. stopAllAudio()
  448. }
  449. }, 100)
  450. }
  451. }
  452. // 播放音频
  453. audio.play().catch(e => {
  454. console.error('对话语音播放失败:', e)
  455. // 播放失败时,检查是否是最后一个对话
  456. if (isAtLastDialogue()) {
  457. // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
  458. if (currentDialogue.value?.type === 'user') {
  459. console.log('用户输入类型,等待AI回答完成后再提示');
  460. return;
  461. }
  462. emit('dialogueEnded', props.isLastCourse);
  463. isPlaying.value = false;
  464. return;
  465. }
  466. // 播放失败时,2秒后跳转
  467. if (isAutoPlay && isPlaying.value) {
  468. setTimeout(() => {
  469. if (!playNext(true)) {
  470. // 播放完毕
  471. isPlaying.value = false
  472. stopAllAudio()
  473. }
  474. }, 2000)
  475. }
  476. })
  477. } else if (currentDialogue.value?.type !== 'video') {
  478. // 检查是否是最后一个对话
  479. if (isAtLastDialogue()) {
  480. // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
  481. if (currentDialogue.value?.type === 'user') {
  482. console.log('用户输入类型,等待AI回答完成后再提示');
  483. return;
  484. }
  485. emit('dialogueEnded', props.isLastCourse);
  486. isPlaying.value = false;
  487. return;
  488. }
  489. // 如果没有语音文件,2秒后跳转
  490. if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
  491. setTimeout(() => {
  492. if (!playNext(true)) {
  493. // 播放完毕
  494. isPlaying.value = false
  495. stopAllAudio()
  496. }
  497. }, 2000)
  498. }
  499. }
  500. }
  501. const togglePlay = () => {
  502. isPlaying.value = !isPlaying.value
  503. if (isPlaying.value) {
  504. // 播放背景音
  505. playBackgroundAudio()
  506. if(!getIsPlaying() && !conversationInProgress.value){
  507. if(currentDialogue.value?.type === 'video'){
  508. // 视频类型对话,播放视频
  509. if (dialogueVideoRef.value) {
  510. dialogueVideoRef.value.play().catch(e => console.error('对话视频播放失败:', e))
  511. }
  512. } else {
  513. // 开始播放序列
  514. playSequence()
  515. }
  516. }
  517. } else {
  518. // 暂停所有音频
  519. stopAllAudio()
  520. }
  521. }
  522. // 处理视频结束事件
  523. const handleVideoEnded = () => {
  524. // 视频播放完毕后,继续播放下一条对话
  525. if (isPlaying.value) {
  526. setTimeout(() => {
  527. if (!playNext(true)) {
  528. // 播放完毕,检查是否是最后一个对话
  529. if (isAtLastDialogue()) {
  530. console.log('视频序列:已到达最后一个对话,触发 dialogueEnded 事件');
  531. emit('dialogueEnded', props.isLastCourse);
  532. }
  533. isPlaying.value = false
  534. stopAllAudio()
  535. }
  536. }, 1500)
  537. }
  538. }
  539. // 自动播放序列
  540. const playSequence = () => {
  541. if (!isPlaying.value) return
  542. // 如果当前是用户输入卡片,暂停播放等待用户输入
  543. if (currentDialogue.value?.type === 'user') return
  544. // 检查当前对话是否为视频类型
  545. if (currentDialogue.value?.type === 'video') {
  546. // 视频类型对话,不自动播放下一条,等待视频结束
  547. return
  548. }
  549. // 检查当前对话是否为诗词类型
  550. if (currentDialogue.value?.type === 'poem') {
  551. // 显示诗词并替换内容为最新的诗词
  552. showPoem.value = true
  553. currentPoemContent.value = currentDialogue.value.content
  554. // 检查是否有语音
  555. if (currentDialogue.value?.voiceoverUrl) {
  556. // 播放诗词语音
  557. playDialogueAudio(true)
  558. } else {
  559. // 没有语音,直接切换到下一条对话
  560. setTimeout(() => {
  561. if (!playNext(true)) {
  562. // 播放完毕,检查是否是最后一个对话
  563. if (isAtLastDialogue()) {
  564. console.log('诗词序列:已到达最后一个对话,触发 dialogueEnded 事件');
  565. emit('dialogueEnded', props.isLastCourse);
  566. }
  567. // 播放完毕
  568. isPlaying.value = false
  569. stopAllAudio()
  570. }
  571. }, 500)
  572. }
  573. } else if (currentDialogue.value?.type === 'video') {
  574. // 视频类型对话,不自动播放下一条,等待视频结束事件
  575. return
  576. } else {
  577. // 播放当前对话语音,传递isAutoPlay参数
  578. playDialogueAudio(true)
  579. }
  580. }
  581. const playPrevious = () => {
  582. // 如果已经到达第一句,直接返回,不执行任何操作
  583. if (currentDialogueIndex.value === 0 && currentSectionIndex.value === 0) {
  584. return
  585. }
  586. // 停止当前音频
  587. stopAllAudio()
  588. // 如果正在进行数字人对话,调用stopStream清理
  589. recoverQuestDialogue()
  590. stopPlayback(false) // 不触发回调
  591. if (conversationInProgress.value) {
  592. stopStream()
  593. }
  594. if (currentDialogueIndex.value > 0) {
  595. currentDialogueIndex.value--
  596. } else if (currentSectionIndex.value > 0) {
  597. currentSectionIndex.value--
  598. const section = props.scriptData.sections[currentSectionIndex.value]
  599. currentDialogueIndex.value = section.dialogues.length - 1
  600. // 切换环节时隐藏诗词
  601. showPoem.value = false
  602. currentPoemContent.value = ''
  603. }
  604. // 检查当前对话是否为诗词类型
  605. if (currentDialogue.value?.type === 'poem') {
  606. // 显示诗词并替换内容为最新的诗词
  607. showPoem.value = true
  608. currentPoemContent.value = currentDialogue.value.content
  609. } else if (currentDialogue.value?.type === 'video') {
  610. // 视频类型对话,隐藏诗词
  611. showPoem.value = false
  612. currentPoemContent.value = ""
  613. } else {
  614. showPoem.value = false
  615. currentPoemContent.value = ""
  616. //读取上一条诗词内容显示
  617. for (let i = currentDialogueIndex.value; i >= 0; i--) {
  618. let dialogueTemp = currentSection.value.dialogues[i];
  619. if (dialogueTemp.type === 'poem'){
  620. // 显示诗词并替换内容为最新的诗词
  621. showPoem.value = true
  622. currentPoemContent.value = dialogueTemp.content
  623. }
  624. }
  625. }
  626. // 播放背景音
  627. nextTick(() => {
  628. playBackgroundAudio()
  629. // 播放当前对话语音(非诗词类型)
  630. if (currentDialogue.value?.type !== 'poem') {
  631. if (isPlaying.value) {
  632. playDialogueAudio(true)
  633. } else {
  634. playDialogueAudio()
  635. }
  636. }
  637. })
  638. }
  639. const playNext = (isAutoPlay = false) => {
  640. // 如果已经到达最后一句,直接返回,不执行任何操作
  641. if (isAtLastDialogue()) {
  642. return false
  643. }
  644. // 停止当前对话语音
  645. if (dialogueAudio.value) {
  646. dialogueAudio.value.pause()
  647. dialogueAudio.value.currentTime = 0
  648. }
  649. // 如果正在进行数字人对话,调用stopStream清理
  650. recoverQuestDialogue()
  651. stopPlayback(false)
  652. if (conversationInProgress.value) {
  653. stopStream()
  654. }
  655. if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
  656. currentDialogueIndex.value++
  657. // 检查当前对话是否为诗词类型
  658. if (currentDialogue.value?.type === 'poem') {
  659. // 显示诗词并替换内容为最新的诗词
  660. showPoem.value = true
  661. currentPoemContent.value = currentDialogue.value.content
  662. // 检查是否有语音
  663. if (currentDialogue.value?.voiceoverUrl) {
  664. // 播放诗词语音
  665. if (isPlaying.value) {
  666. playDialogueAudio(true)
  667. } else {
  668. playDialogueAudio()
  669. }
  670. } else {
  671. // 没有语音,直接切换到下一条对话
  672. setTimeout(() => {
  673. playNext(isAutoPlay)
  674. }, 500)
  675. }
  676. return true
  677. } else if (currentDialogue.value?.type === 'video') {
  678. // 视频类型对话,隐藏诗词,不自动播放下一条
  679. showPoem.value = false
  680. currentPoemContent.value = ''
  681. return true
  682. }
  683. // 根据是否为自动播放状态决定如何播放语音
  684. if (isPlaying.value) {
  685. playDialogueAudio(true)
  686. } else {
  687. playDialogueAudio()
  688. }
  689. return true
  690. } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
  691. currentSectionIndex.value++
  692. currentDialogueIndex.value = 0
  693. // 切换环节时隐藏诗词
  694. showPoem.value = false
  695. currentPoemContent.value = ''
  696. // 播放背景音
  697. nextTick(() => {
  698. playBackgroundAudio()
  699. // 检查新环节的第一个对话是否为诗词类型
  700. if (currentDialogue.value?.type === 'poem') {
  701. // 显示诗词
  702. showPoem.value = true
  703. currentPoemContent.value = currentDialogue.value.content
  704. // 检查是否有语音
  705. if (currentDialogue.value?.voiceoverUrl) {
  706. // 播放诗词语音
  707. if (isPlaying.value) {
  708. playDialogueAudio(true)
  709. } else {
  710. playDialogueAudio()
  711. }
  712. } else {
  713. // 没有语音,直接切换到下一条对话
  714. setTimeout(() => {
  715. playNext(isAutoPlay)
  716. }, 500)
  717. }
  718. } else if (currentDialogue.value?.type === 'video') {
  719. // 视频类型对话,隐藏诗词
  720. showPoem.value = false
  721. currentPoemContent.value = ''
  722. } else {
  723. // 根据是否为自动播放状态决定如何播放语音
  724. if (isPlaying.value) {
  725. playDialogueAudio(true)
  726. } else {
  727. playDialogueAudio()
  728. }
  729. }
  730. })
  731. return true
  732. }
  733. return false
  734. }
  735. // 返回主页按钮点击事件
  736. const goBackToMain = () => {
  737. // 停止所有音频
  738. stopAllAudio()
  739. // 跳转到 ai-general-course 页面,保持侧边栏选中状态
  740. router.push('/ai-general-course')
  741. }
  742. // 开始播放
  743. const maskLayer = ref(null)
  744. const startPlayback = () => {
  745. // 设置开始播放状态
  746. isPlaybackStarted.value = true
  747. // 开始播放背景视频
  748. if (backgroundVideoRef.value) {
  749. backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
  750. }
  751. // 消失动画
  752. if (maskLayer.value) {
  753. maskLayer.value.classList.add('fade-out')
  754. // 等待动画完成后隐藏遮罩层
  755. setTimeout(() => {
  756. showMask.value = false
  757. }, 500)
  758. }
  759. // 检查当前对话是否为诗词类型
  760. if (currentDialogue.value?.type === 'poem') {
  761. // 显示诗词
  762. showPoem.value = true
  763. currentPoemContent.value = currentDialogue.value.content
  764. // 检查是否有语音
  765. if (currentDialogue.value?.voiceoverUrl) {
  766. // 播放诗词语音
  767. playDialogueAudio()
  768. } else {
  769. // 没有语音,直接切换到下一条对话
  770. setTimeout(() => {
  771. playNext()
  772. }, 500)
  773. }
  774. } else if (currentDialogue.value?.type === 'video') {
  775. // 视频类型对话,隐藏诗词
  776. showPoem.value = false
  777. currentPoemContent.value = ''
  778. } else {
  779. // 播放当前对话语音
  780. playDialogueAudio()
  781. }
  782. }
  783. // 键盘事件处理,键盘左右箭头控制对话
  784. const handleKeydown = (event) => {
  785. // 如果遮罩层显示,不处理键盘事件
  786. if (showMask.value) {
  787. return
  788. }
  789. // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
  790. if (currentDialogue.value?.type === 'user') {
  791. return
  792. }
  793. // 处理左右箭头键
  794. switch (event.key) {
  795. case 'ArrowLeft':
  796. playPrevious()
  797. event.preventDefault() // 防止默认行为
  798. break
  799. case 'ArrowRight':
  800. playNext()
  801. event.preventDefault() // 防止默认行为
  802. break
  803. default:
  804. // 其他按键不做处理
  805. break
  806. }
  807. }
  808. // 监听环节变化
  809. watch(currentSectionIndex, () => {
  810. playBackgroundAudio()
  811. })
  812. // 监听 scriptData 变化(侧边栏切换话题时)
  813. watch(() => props.scriptData, (newVal, oldVal) => {
  814. if (newVal && oldVal && newVal !== oldVal) {
  815. // 停止所有音频
  816. stopAllAudio()
  817. // 如果正在进行数字人对话,调用stopStream清理
  818. recoverQuestDialogue()
  819. stopPlayback(false)
  820. if (conversationInProgress.value) {
  821. stopStream()
  822. }
  823. // 清空索引
  824. currentSectionIndex.value = 0
  825. currentDialogueIndex.value = 0
  826. // 重置播放状态
  827. isPlaying.value = false
  828. // 显示遮罩层
  829. showMask.value = true
  830. // 清空用户输入
  831. userInput.value = ''
  832. // 清空对话缓存
  833. currentDialogueCache.value = null
  834. // 隐藏诗词
  835. showPoem.value = false
  836. currentPoemContent.value = ''
  837. }
  838. }, { deep: true })
  839. // 会话ID
  840. const activeConversationId = ref(null)
  841. const conversationInProgress = ref(false)
  842. const conversationInAbortController = ref()
  843. const receiveMessageFullText = ref('')
  844. //创建对话
  845. const createAiChart = async () => {
  846. let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
  847. // 智能问答
  848. await CreateDialogue({ roleId: role.id })
  849. .then(res => {
  850. console.log("创建会话:", res.data);
  851. activeConversationId.value = res.data
  852. })
  853. .catch(error => {
  854. console.error('请求出错:', error)
  855. })
  856. }
  857. /** 真正执行【发送】消息操作 */
  858. const doSendMessage = async () => {
  859. // 校验
  860. if (userInput.value.length < 1) {
  861. console.error('发送失败,原因:内容为空!')
  862. return
  863. }
  864. if (activeConversationId.value == null) {
  865. console.error('还没创建对话,不能发送!')
  866. return
  867. }
  868. let userInputTemp = userInput.value;
  869. let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
  870. userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题回复我回答的内容是否正确,并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
  871. // 执行发送
  872. await doSendMessageStream({
  873. conversationId: activeConversationId.value,
  874. content: userInputTemp,
  875. contentAnswer: null,
  876. })
  877. // 清空输入框
  878. userInput.value = ''
  879. }
  880. /** 发送单选问题消息 */
  881. const doSendSingleChoiceMessage = async () => {
  882. if (activeConversationId.value == null) {
  883. console.error('还没创建对话,不能发送!')
  884. return
  885. }
  886. // 使用上一条quest对话的数据
  887. const dialogue = previousQuestDialogue.value
  888. if (!dialogue) {
  889. console.error('找不到上一条quest对话!')
  890. return
  891. }
  892. // 构建选项字符串
  893. const optionsStr = dialogue.options.map((opt, idx) => `${optionLabels[idx]}. ${opt.content}`).join(';')
  894. // 构建发送内容:包含问题、选项、用户答案
  895. const content = `问题:${dialogue.content}\n选项:${optionsStr}\n我的答案:${selectedOption.value}\n正确答案:${dialogue.answer}\n\n请判断我的答案是否正确,并给予鼓励或夸赞,回复请精简,控制在50字内。`
  896. console.log('发送单选问题:', content)
  897. // 执行发送
  898. await doSendMessageStream({
  899. conversationId: activeConversationId.value,
  900. content: content,
  901. contentAnswer: null,
  902. })
  903. // 清空选择
  904. selectedOption.value = ''
  905. }
  906. // 获取上一条对话
  907. const getPreviousDialogue = () => {
  908. const section = props.scriptData.sections[currentSectionIndex.value]
  909. if (!section) return null
  910. if (currentDialogueIndex.value > 0) {
  911. return section.dialogues[currentDialogueIndex.value - 1]
  912. }
  913. return null
  914. }
  915. /** 显示问题回答对话 */
  916. const showQuestAnswerDialogue = () => {
  917. // 缓存当前对话
  918. currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
  919. // 将当前对话类型设置为数字人对话
  920. currentDialogue.value.type = "digital"
  921. // 设置默认内容为"让我思考一下..."
  922. currentDialogue.value.content = "让我思考一下..."
  923. }
  924. //延时恢复对话,避免立即回复导致对话内容被覆盖
  925. const delayRecoverQuestDialogue = () => {
  926. // Message().error('当前网络无反应,请稍后重试!', true);
  927. // 设置默认内容为"让我思考一下..."
  928. currentDialogue.value.content = "当前网络无反应,请稍后重试!"
  929. setTimeout(() => {
  930. recoverQuestDialogue()
  931. }, 1500)
  932. }
  933. /** 回复对话 */
  934. const recoverQuestDialogue = () => {
  935. // 如果有缓存的对话
  936. if (currentDialogueCache.value){
  937. // 恢复当前对话
  938. const currentSection = props.scriptData.sections[currentSectionIndex.value]
  939. if (currentSection) {
  940. currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
  941. }
  942. // 清空缓存
  943. currentDialogueCache.value = null
  944. }
  945. }
  946. /** 真正执行【发送】消息操作 */
  947. const doSendMessageStream = async userMessage => {
  948. // 创建 AbortController 实例,以便中止请求
  949. conversationInAbortController.value = new AbortController()
  950. // 标记对话进行中
  951. conversationInProgress.value = true
  952. // 设置为空
  953. receiveMessageFullText.value = ''
  954. showQuestAnswerDialogue()
  955. try {
  956. // 发送 event stream
  957. let isFirstChunk = true // 是否是第一个 chunk 消息段
  958. await sendChatMessageStream(
  959. userMessage.conversationId,
  960. userMessage.content,
  961. userMessage.contentAnswer,
  962. conversationInAbortController.value,
  963. true, // enableContext 参数
  964. async res => {
  965. const { code, data, msg } = JSON.parse(res.data)
  966. if (code !== 0) {
  967. console.log(`对话异常! ${msg}`)
  968. stopStream();
  969. delayRecoverQuestDialogue()
  970. return
  971. }
  972. if (data.eventType === 'TEXT') {
  973. // 如果内容为空,就不处理。
  974. if (data.receive?.content === '') {
  975. return
  976. }
  977. receiveMessageFullText.value += data.receive.content
  978. // 更新数字人对话框内容
  979. currentDialogue.value.content = receiveMessageFullText.value
  980. // 首次返回需要添加一个 message 到页面,后面的都是更新
  981. if (isFirstChunk) {
  982. isFirstChunk = false
  983. //第一次返回
  984. } else {
  985. //更新最后一条消息
  986. }
  987. }
  988. if (data.eventType === 'AUDIO') {
  989. // 处理音频消息
  990. await playAudioChunk(data.audioData);
  991. }
  992. },
  993. error => {
  994. console.log(`对话异常! ${error}`)
  995. stopStream()
  996. delayRecoverQuestDialogue()
  997. // 需要抛出异常,禁止重试
  998. throw error
  999. },
  1000. () => {
  1001. console.log(`结束对话! `)
  1002. stopStream()
  1003. // AI回答完成,检查是否是最后一个对话且是用户输入类型
  1004. if (isAtLastDialogue() && currentDialogue.value?.type === 'user') {
  1005. console.log('AI回答完成,触发 dialogueEnded 事件');
  1006. emit('dialogueEnded', props.isLastCourse);
  1007. isPlaying.value = false;
  1008. }
  1009. }
  1010. )
  1011. } catch (error) {
  1012. console.error('发送消息失败:', error)
  1013. stopStream()
  1014. delayRecoverQuestDialogue()
  1015. }
  1016. }
  1017. /** 停止 stream 流式调用 */
  1018. const stopStream = async () => {
  1019. // 如果 stream 进行中的 message,就需要调用 controller 结束
  1020. if (conversationInAbortController.value) {
  1021. conversationInAbortController.value.abort()
  1022. }
  1023. // 销毁语音读取
  1024. // stopPlayback();
  1025. // 设置为 false
  1026. conversationInProgress.value = false
  1027. console.log(`结束对话!更改状态: `,conversationInProgress.value)
  1028. }
  1029. // 处理音频播放完成
  1030. const handleAudioPlaybackComplete = () => {
  1031. console.log('智能问答音频播放完成');
  1032. // 先清除回调,防止 playNext 内部调用 stopPlayback(false) 时
  1033. // 关闭 audioContext 触发 source.onended → processAudioQueue → 再次触发回调,造成二次跳转
  1034. setOnPlaybackComplete(null);
  1035. stopAllAudio();
  1036. // 检查是否是最后一个对话
  1037. if (isAtLastDialogue()) {
  1038. // 语音播报完成且是最后一个对话,触发事件通知父组件
  1039. console.log('已到达最后一个对话,触发 dialogueEnded 事件');
  1040. emit('dialogueEnded', props.isLastCourse);
  1041. isPlaying.value = false;
  1042. return;
  1043. }
  1044. // 如果处于自动播放状态,继续播放下一条对话
  1045. if (isPlaying.value) {
  1046. // 恢复回调,供下一条 AI 对话使用
  1047. setOnPlaybackComplete(handleAudioPlaybackComplete);
  1048. if (playNext(true)) {
  1049. // playNext 内部已调用 playDialogueAudio(true),无需再调 playSequence
  1050. } else {
  1051. // 播放完毕
  1052. isPlaying.value = false;
  1053. stopAllAudio();
  1054. }
  1055. }
  1056. };
  1057. // 判断是否是最后一个对话
  1058. const isAtLastDialogue = () => {
  1059. if (!props.scriptData.sections || props.scriptData.sections.length === 0) {
  1060. return false
  1061. }
  1062. const lastSectionIndex = props.scriptData.sections.length - 1
  1063. const lastSection = props.scriptData.sections[lastSectionIndex]
  1064. if (!lastSection.dialogues || lastSection.dialogues.length === 0) {
  1065. return false
  1066. }
  1067. const lastDialogueIndex = lastSection.dialogues.length - 1
  1068. return currentSectionIndex.value === lastSectionIndex &&
  1069. currentDialogueIndex.value === lastDialogueIndex
  1070. }
  1071. // 组件挂载时添加键盘事件监听
  1072. onMounted(() => {
  1073. window.addEventListener('keydown', handleKeydown)
  1074. // 播放背景音
  1075. playBackgroundAudio()
  1076. // // 播放当前对话语音
  1077. // playDialogueAudio()
  1078. // 设置音频播放完成回调
  1079. setOnPlaybackComplete(handleAudioPlaybackComplete)
  1080. })
  1081. // 组件卸载时移除键盘事件监听和停止音频
  1082. onUnmounted(() => {
  1083. window.removeEventListener('keydown', handleKeydown)
  1084. stopAllAudio()
  1085. stopPlayback(false)
  1086. })
  1087. </script>
  1088. <style scoped lang="scss">
  1089. @use "sass:math";
  1090. @function rpx($px) {
  1091. @return math.div($px, 750) * 100vw;
  1092. }
  1093. .dialog-content-wrapper {
  1094. width: 100%;
  1095. height: 100%;
  1096. position: absolute;
  1097. top: 0;
  1098. left: 0;
  1099. z-index: 100;
  1100. }
  1101. .title-box {
  1102. position: absolute;
  1103. top: 0;
  1104. left: 0;
  1105. right: 0;
  1106. height: rpx(60);
  1107. display: flex;
  1108. align-items: center;
  1109. justify-content: space-between;
  1110. color: white;
  1111. padding: 0 rpx(20);
  1112. }
  1113. .title-left {
  1114. width: 33.33%;
  1115. height: 100%;
  1116. display: flex;
  1117. align-items: center;
  1118. gap: rpx(5);
  1119. z-index: 30;
  1120. .box-icon {
  1121. display: flex;
  1122. align-items: center;
  1123. color: #0064BE;
  1124. gap: rpx(5);
  1125. padding: rpx(5) rpx(10);
  1126. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1127. border: rpx(1) solid rgba(0, 100, 192);
  1128. border-radius: rpx(30);
  1129. backdrop-filter: blur(10px);
  1130. cursor: pointer;
  1131. transition: all 0.3s ease;
  1132. font-size: rpx(9);
  1133. font-weight: 500;
  1134. width: fit-content;
  1135. }
  1136. .box-icon:hover {
  1137. background-color: rgba(255, 255, 255, 90%);
  1138. transform: translateX(-3px);
  1139. }
  1140. .left-icon {
  1141. font-size: rpx(12);
  1142. }
  1143. }
  1144. .title-center {
  1145. display: flex;
  1146. align-items: center;
  1147. justify-content: center;
  1148. height: 100%;
  1149. background-image: url('@/assets/dialogue/number-title.png');
  1150. background-size: 100% 85%;
  1151. background-repeat: no-repeat;
  1152. background-position: center;
  1153. width: fit-content;
  1154. padding: 0 rpx(100);
  1155. min-width: rpx(100);
  1156. z-index: 10;
  1157. }
  1158. .title-text {
  1159. height: 100%;
  1160. font-size: rpx(11);
  1161. font-weight: bold;
  1162. display: flex;
  1163. align-items: center;
  1164. justify-content: center;
  1165. white-space: nowrap;
  1166. }
  1167. .title-right {
  1168. width: 33.33%;
  1169. height: 100%;
  1170. display: flex;
  1171. align-items: center;
  1172. justify-content: flex-end;
  1173. gap: rpx(10);
  1174. z-index: 10;
  1175. .box-icon {
  1176. display: flex;
  1177. align-items: center;
  1178. color: #0064BE;
  1179. gap: rpx(5);
  1180. padding: rpx(5) rpx(10);
  1181. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1182. border: rpx(1) solid rgba(0, 100, 192);
  1183. border-radius: rpx(30);
  1184. backdrop-filter: blur(10px);
  1185. cursor: pointer;
  1186. transition: all 0.3s ease;
  1187. font-size: rpx(9);
  1188. font-weight: 500;
  1189. width: fit-content;
  1190. }
  1191. .box-icon:hover {
  1192. background-color: rgba(255, 255, 255, 90%);
  1193. transform: translateX(-3px);
  1194. }
  1195. .left-icon {
  1196. font-size: rpx(12);
  1197. }
  1198. .play-text {
  1199. font-size: rpx(9);
  1200. color: #0064BE;
  1201. font-weight: 500;
  1202. }
  1203. }
  1204. .background-image {
  1205. width: 100%;
  1206. height: 100%;
  1207. object-fit: cover;
  1208. z-index: 1;
  1209. position: relative;
  1210. }
  1211. .background-video {
  1212. width: 100%;
  1213. height: 100%;
  1214. object-fit: cover;
  1215. z-index: 1;
  1216. position: relative;
  1217. }
  1218. /* 遮罩层样式 */
  1219. .mask-layer {
  1220. position: absolute;
  1221. top: 0;
  1222. left: 0;
  1223. right: 0;
  1224. bottom: 0;
  1225. background-color: rgba(0, 0, 0, 0.7);
  1226. z-index: 20;
  1227. display: flex;
  1228. justify-content: center;
  1229. align-items: center;
  1230. opacity: 1;
  1231. transition: all 0.5s ease-out;
  1232. }
  1233. .mask-layer.fade-out {
  1234. opacity: 0;
  1235. transform: scale(1.1);
  1236. z-index: -1;
  1237. }
  1238. .play-button-container {
  1239. display: flex;
  1240. justify-content: center;
  1241. align-items: center;
  1242. }
  1243. .play-button {
  1244. width: rpx(80);
  1245. height: rpx(80);
  1246. border-radius: 50%;
  1247. border: none;
  1248. background: transparent;
  1249. display: flex;
  1250. justify-content: center;
  1251. align-items: center;
  1252. cursor: pointer;
  1253. outline: none;
  1254. -webkit-tap-highlight-color: transparent;
  1255. }
  1256. .play-icon {
  1257. font-size: rpx(40);
  1258. color: #A0DCF0;
  1259. transition: all 0.3s ease;
  1260. cursor: pointer;
  1261. }
  1262. .play-icon:hover {
  1263. transform: scale(1.2);
  1264. color: #50BEF0;
  1265. text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
  1266. }
  1267. .content-box {
  1268. position: absolute;
  1269. top: rpx(60);
  1270. left: 0;
  1271. right: 0;
  1272. bottom: 0;
  1273. display: flex;
  1274. align-items: flex-end;
  1275. justify-content: center;
  1276. padding-bottom: rpx(0);
  1277. z-index: 10;
  1278. }
  1279. .character {
  1280. position: absolute;
  1281. bottom: 0;
  1282. width: rpx(135);
  1283. height: rpx(240);
  1284. background-size: contain;
  1285. background-position: bottom;
  1286. background-repeat: no-repeat;
  1287. opacity: 0;
  1288. z-index: 1;
  1289. // background-color: #fff;
  1290. }
  1291. .character.left {
  1292. left: rpx(17);
  1293. transform: translateX(-100%);
  1294. animation: characterEnterLeft 0.8s ease forwards;
  1295. }
  1296. .character.right {
  1297. right: rpx(17);
  1298. transform: translateX(100%) scaleX(-1);
  1299. animation: characterEnterRight 0.8s ease forwards;
  1300. }
  1301. @keyframes characterEnterLeft {
  1302. 0% {
  1303. opacity: 0;
  1304. transform: translateX(-100%) scale(0.8);
  1305. }
  1306. 70% {
  1307. opacity: 0.9;
  1308. transform: translateX(10%) scale(1.05);
  1309. }
  1310. 100% {
  1311. opacity: 1;
  1312. transform: translateX(0) scale(1);
  1313. }
  1314. }
  1315. @keyframes characterEnterRight {
  1316. 0% {
  1317. opacity: 0;
  1318. transform: translateX(100%) scale(0.8) scaleX(-1);
  1319. }
  1320. 70% {
  1321. opacity: 0.9;
  1322. transform: translateX(-10%) scale(1.05) scaleX(-1);
  1323. }
  1324. 100% {
  1325. opacity: 1;
  1326. transform: translateX(0) scale(1) scaleX(-1);
  1327. }
  1328. }
  1329. .dialogue-card {
  1330. background: rgba(255, 255, 255, 0.9);
  1331. border-radius: rpx(6);
  1332. padding: rpx(8);
  1333. max-width: 35%;
  1334. min-width: rpx(200);
  1335. box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
  1336. position: absolute;
  1337. bottom: rpx(50);
  1338. width: auto;
  1339. display: inline-block;
  1340. z-index: 2;
  1341. }
  1342. .dialogue-card.left {
  1343. left: rpx(145);
  1344. animation: dialogueEnterLeft 0.6s ease forwards;
  1345. }
  1346. .dialogue-card.right {
  1347. right: rpx(145);
  1348. animation: dialogueEnterRight 0.6s ease forwards;
  1349. }
  1350. .dialogue-card.left::before {
  1351. content: '';
  1352. position: absolute;
  1353. left: rpx(-11.5);
  1354. bottom: rpx(12);
  1355. width: 0;
  1356. height: 0;
  1357. border-top: rpx(7) solid transparent;
  1358. border-bottom: rpx(7) solid transparent;
  1359. border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
  1360. transform: translateY(0);
  1361. }
  1362. .dialogue-card.right::before {
  1363. content: '';
  1364. position: absolute;
  1365. right: rpx(-11.5);
  1366. bottom: rpx(12);
  1367. width: 0;
  1368. height: 0;
  1369. border-top: rpx(7) solid transparent;
  1370. border-bottom: rpx(7) solid transparent;
  1371. border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
  1372. transform: translateY(0);
  1373. }
  1374. @keyframes dialogueEnterLeft {
  1375. from {
  1376. opacity: 0;
  1377. transform: translateX(-rpx(30)) translateY(rpx(20));
  1378. }
  1379. to {
  1380. opacity: 1;
  1381. transform: translateX(0) translateY(0);
  1382. }
  1383. }
  1384. @keyframes dialogueEnterRight {
  1385. from {
  1386. opacity: 0;
  1387. transform: translateX(rpx(30)) translateY(rpx(20));
  1388. }
  1389. to {
  1390. opacity: 1;
  1391. transform: translateX(0) translateY(0);
  1392. }
  1393. }
  1394. .dialogue-header {
  1395. position: absolute;
  1396. top: rpx(-11);
  1397. left: rpx(12);
  1398. background: #409EFF;
  1399. color: white;
  1400. padding: rpx(1.2) rpx(6);
  1401. border-radius: rpx(5);
  1402. font-size: rpx(8);
  1403. box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
  1404. }
  1405. .dialogue-card.right .dialogue-header {
  1406. left: rpx(10);
  1407. }
  1408. .role-name {
  1409. font-weight: 600;
  1410. color: white;
  1411. font-size: rpx(10);
  1412. }
  1413. .dialogue-content {
  1414. font-size: rpx(12);
  1415. line-height: 1.2;
  1416. color: #333;
  1417. text-align: left;
  1418. display: flex;
  1419. flex-direction: column;
  1420. align-items: center;
  1421. justify-content: center;
  1422. width: 100%;
  1423. }
  1424. .user-input-card {
  1425. max-width: 50%;
  1426. position: absolute;
  1427. left: 50%;
  1428. transform: translateX(-50%);
  1429. bottom: rpx(50);
  1430. right: auto;
  1431. animation: dialogueEnterCenter 0.6s ease forwards;
  1432. }
  1433. .user-input-card::before {
  1434. display: none;
  1435. }
  1436. .single-choice-card {
  1437. max-width: 55%;
  1438. position: absolute;
  1439. left: 50%;
  1440. transform: translateX(-50%);
  1441. bottom: rpx(50);
  1442. right: auto;
  1443. animation: dialogueEnterCenter 0.6s ease forwards;
  1444. z-index: 10;
  1445. }
  1446. .single-choice-card::before {
  1447. display: none;
  1448. }
  1449. .single-choice-content {
  1450. font-size: rpx(12);
  1451. line-height: 1.4;
  1452. color: #333;
  1453. text-align: left;
  1454. width: 100%;
  1455. }
  1456. .question-text {
  1457. margin-bottom: rpx(10);
  1458. padding-bottom: rpx(8);
  1459. border-bottom: rpx(1) dashed #ddd;
  1460. font-weight: 500;
  1461. }
  1462. .options-list {
  1463. display: flex;
  1464. flex-direction: column;
  1465. gap: rpx(6);
  1466. margin-bottom: rpx(12);
  1467. }
  1468. .option-item {
  1469. display: flex;
  1470. align-items: center;
  1471. padding: rpx(8) rpx(12);
  1472. background: #f8f9fa;
  1473. border: rpx(1) solid #e9ecef;
  1474. border-radius: rpx(6);
  1475. cursor: pointer;
  1476. transition: all 0.3s ease;
  1477. &:hover {
  1478. background: #e8f4fd;
  1479. border-color: #409EFF;
  1480. }
  1481. &.selected {
  1482. background: #e8f4fd;
  1483. border-color: #409EFF;
  1484. box-shadow: 0 rpx(2) rpx(8) rgba(64, 158, 255, 0.2);
  1485. }
  1486. }
  1487. .option-label {
  1488. width: rpx(24);
  1489. height: rpx(24);
  1490. display: flex;
  1491. align-items: center;
  1492. justify-content: center;
  1493. background: #fff;
  1494. border: rpx(1) solid #ddd;
  1495. border-radius: 50%;
  1496. font-size: rpx(10);
  1497. font-weight: 600;
  1498. color: #666;
  1499. margin-right: rpx(8);
  1500. flex-shrink: 0;
  1501. transition: all 0.3s ease;
  1502. .option-item.selected & {
  1503. background: #409EFF;
  1504. border-color: #409EFF;
  1505. color: #fff;
  1506. }
  1507. }
  1508. .option-content {
  1509. font-size: rpx(11);
  1510. color: #333;
  1511. flex: 1;
  1512. }
  1513. @keyframes dialogueEnterCenter {
  1514. from {
  1515. opacity: 0;
  1516. transform: translateX(-50%) translateY(rpx(20));
  1517. }
  1518. to {
  1519. opacity: 1;
  1520. transform: translateX(-50%) translateY(0);
  1521. }
  1522. }
  1523. .user-input-textarea {
  1524. width: 95%;
  1525. min-height: rpx(50);
  1526. max-height: rpx(150);
  1527. border: none;
  1528. outline: none;
  1529. background: transparent;
  1530. font-size: rpx(10);
  1531. line-height: 1.2;
  1532. color: #333;
  1533. resize: none;
  1534. font-family: inherit;
  1535. overflow-y: auto;
  1536. text-align: left;
  1537. padding: rpx(5) 0;
  1538. }
  1539. .voice-input-content {
  1540. width: 95%;
  1541. min-height: rpx(50);
  1542. max-height: rpx(150);
  1543. font-size: rpx(10);
  1544. line-height: 1.2;
  1545. color: #333;
  1546. text-align: left;
  1547. padding: rpx(5) 0;
  1548. overflow-y: auto;
  1549. }
  1550. .voice-input-placeholder {
  1551. width: 95%;
  1552. min-height: rpx(50);
  1553. font-size: rpx(10);
  1554. line-height: 1.2;
  1555. color: #999;
  1556. text-align: left;
  1557. padding: rpx(5) 0;
  1558. font-style: italic;
  1559. }
  1560. /* 滚动条样式 */
  1561. .user-input-textarea::-webkit-scrollbar {
  1562. width: rpx(0);
  1563. }
  1564. .user-input-textarea::-webkit-scrollbar-track {
  1565. background: rgba(0, 0, 0, 0.05);
  1566. border-radius: rpx(3);
  1567. }
  1568. .user-input-textarea::-webkit-scrollbar-thumb {
  1569. background: rgba(64, 158, 255, 0.5);
  1570. border-radius: rpx(3);
  1571. }
  1572. .user-input-textarea::-webkit-scrollbar-thumb:hover {
  1573. background: rgba(64, 158, 255, 0.8);
  1574. }
  1575. .input-actions {
  1576. display: flex;
  1577. justify-content: flex-end;
  1578. gap: rpx(6);
  1579. margin-top: rpx(6);
  1580. width: 100%;
  1581. }
  1582. .cancel-btn, .submit-btn {
  1583. padding: rpx(2.5) rpx(10);
  1584. border: none;
  1585. border-radius: rpx(4);
  1586. font-size: rpx(8);
  1587. cursor: pointer;
  1588. transition: all 0.3s ease;
  1589. }
  1590. .cancel-btn {
  1591. background: #E0E0E0;
  1592. color: #666;
  1593. }
  1594. .submit-btn {
  1595. background: #409EFF;
  1596. color: white;
  1597. }
  1598. .cancel-btn:hover, .submit-btn:hover {
  1599. transform: scale(1.05);
  1600. }
  1601. .cancel-btn:active, .submit-btn:active {
  1602. transform: scale(0.95);
  1603. }
  1604. .dialogue-content :deep(p) {
  1605. margin: 0 0 rpx(4) 0;
  1606. }
  1607. .dialogue-content :deep(strong),
  1608. .dialogue-content :deep(b) {
  1609. font-weight: bold;
  1610. }
  1611. .dialogue-content :deep(em),
  1612. .dialogue-content :deep(i) {
  1613. font-style: italic;
  1614. }
  1615. .dialogue-content :deep(ul),
  1616. .dialogue-content :deep(ol) {
  1617. margin: rpx(4) 0;
  1618. padding-left: rpx(10);
  1619. }
  1620. .dialogue-content :deep(li) {
  1621. margin: rpx(2) 0;
  1622. }
  1623. .dialogue-content :deep(code) {
  1624. background-color: #f0f0f0;
  1625. padding: rpx(1) rpx(3);
  1626. border-radius: rpx(2);
  1627. font-family: monospace;
  1628. font-size: rpx(9);
  1629. }
  1630. .dialogue-content :deep(a) {
  1631. color: #409EFF;
  1632. text-decoration: underline;
  1633. }
  1634. .input-buttons-container {
  1635. position: relative;
  1636. display: flex;
  1637. align-items: center;
  1638. justify-content: center;
  1639. width: 100%;
  1640. z-index: 10;
  1641. transition: all 0.3s ease;
  1642. transform: scale(0.9);
  1643. gap: rpx(10);
  1644. .arrow-icon-circle {
  1645. width: rpx(20);
  1646. height: rpx(20);
  1647. border-radius: 50%;
  1648. border: rpx(1) solid rgba(0, 100, 192);
  1649. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1650. display: flex;
  1651. align-items: center;
  1652. justify-content: center;
  1653. cursor: pointer;
  1654. transition: all 0.3s ease;
  1655. &:hover:not(.disabled) {
  1656. transform: scale(1.1);
  1657. box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
  1658. }
  1659. &:active:not(.disabled) {
  1660. transform: scale(0.95);
  1661. }
  1662. &.disabled {
  1663. opacity: 0.5;
  1664. cursor: not-allowed;
  1665. border-color: rgba(0, 100, 192, 0.3);
  1666. background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
  1667. }
  1668. .arrow-icon {
  1669. font-size: rpx(15);
  1670. color: #0064BE;
  1671. }
  1672. }
  1673. }
  1674. .voice-input-outer,
  1675. .keyboard-input-outer {
  1676. position: absolute;
  1677. display: flex;
  1678. align-items: center;
  1679. justify-content: center;
  1680. transition: all 0.3s ease;
  1681. }
  1682. .voice-input-outer {
  1683. position: relative;
  1684. width: rpx(50);
  1685. height: rpx(50);
  1686. border-radius: 50%;
  1687. background: transparent;
  1688. display: flex;
  1689. align-items: center;
  1690. justify-content: center;
  1691. // 正常状态只显示一圈
  1692. &::before {
  1693. content: '';
  1694. position: absolute;
  1695. width: 85%;
  1696. height: 85%;
  1697. border-radius: 50%;
  1698. border: rpx(2) solid rgba(80, 190, 240, 0.6);
  1699. }
  1700. // 录音时显示波纹效果
  1701. &.recording {
  1702. &::before {
  1703. animation: pulse 2s infinite;
  1704. border-color: rgba(0, 100, 192, 0.6);
  1705. }
  1706. &::after {
  1707. content: '';
  1708. position: absolute;
  1709. width: 85%;
  1710. height: 85%;
  1711. border-radius: 50%;
  1712. border: rpx(2) solid rgba(0, 100, 192, 0.4);
  1713. animation: pulse 2s infinite 0.5s;
  1714. }
  1715. :deep(.voice-input-container) {
  1716. .speech-btn {
  1717. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1718. border-color: rgba(0, 100, 192, 1);
  1719. .el-icon {
  1720. color: #0064BE;
  1721. }
  1722. }
  1723. }
  1724. }
  1725. // 占位符样式
  1726. &.placeholder {
  1727. visibility: hidden;
  1728. }
  1729. :deep(.voice-input-container) {
  1730. position: relative;
  1731. z-index: 10;
  1732. .speech-btn {
  1733. width: rpx(40);
  1734. height: rpx(40);
  1735. border-radius: 50%;
  1736. border: rpx(2) solid rgba(0, 100, 192);
  1737. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1738. display: flex;
  1739. align-items: center;
  1740. justify-content: center;
  1741. padding: 0;
  1742. gap: 0;
  1743. transition: all 0.3s ease;
  1744. cursor: pointer;
  1745. position: relative;
  1746. overflow: hidden;
  1747. &:hover {
  1748. transform: scale(1.05);
  1749. box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
  1750. }
  1751. &:active {
  1752. transform: scale(0.95);
  1753. }
  1754. &::before {
  1755. content: '';
  1756. position: absolute;
  1757. top: 50%;
  1758. left: 50%;
  1759. width: 0;
  1760. height: 0;
  1761. border-radius: 50%;
  1762. background: rgba(255, 255, 255, 0.5);
  1763. transform: translate(-50%, -50%);
  1764. transition: width 0.6s, height 0.6s;
  1765. }
  1766. &:active::before {
  1767. width: rpx(80);
  1768. height: rpx(80);
  1769. }
  1770. .el-icon {
  1771. font-size: rpx(20);
  1772. color: #0064BE;
  1773. z-index: 1;
  1774. }
  1775. .countdown-text {
  1776. display: block;
  1777. font-size: rpx(8);
  1778. color: #0064BE;
  1779. position: absolute;
  1780. bottom: rpx(5);
  1781. left: 50%;
  1782. transform: translateX(-50%);
  1783. }
  1784. }
  1785. }
  1786. }
  1787. @keyframes pulse {
  1788. 0% {
  1789. transform: scale(1);
  1790. opacity: 1;
  1791. }
  1792. 100% {
  1793. transform: scale(1.15);
  1794. opacity: 0;
  1795. }
  1796. }
  1797. .keyboard-input-outer {
  1798. width: rpx(30);
  1799. height: rpx(30);
  1800. border-radius: 50%;
  1801. background: transparent;
  1802. &.active {
  1803. width: rpx(40);
  1804. height: rpx(40);
  1805. left: 50%;
  1806. transform: translateX(-50%);
  1807. z-index: 11;
  1808. .keyboard-btn {
  1809. width: rpx(36);
  1810. height: rpx(36);
  1811. border: rpx(2) solid rgba(0, 100, 192);
  1812. box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
  1813. .keyboard-icon {
  1814. font-size: rpx(18);
  1815. }
  1816. }
  1817. }
  1818. &.inactive {
  1819. width: rpx(24);
  1820. height: rpx(24);
  1821. left: calc(50% + rpx(40));
  1822. transform: translateX(-50%);
  1823. z-index: 10;
  1824. .keyboard-btn {
  1825. width: rpx(22);
  1826. height: rpx(22);
  1827. border: rpx(1) solid rgba(128, 128, 128, 0.5);
  1828. box-shadow: none;
  1829. background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
  1830. .keyboard-icon {
  1831. font-size: rpx(12);
  1832. color: #808080;
  1833. }
  1834. }
  1835. }
  1836. }
  1837. .keyboard-btn {
  1838. width: rpx(28);
  1839. height: rpx(28);
  1840. border-radius: 50%;
  1841. border: rpx(1) solid rgba(0, 100, 192);
  1842. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1843. display: flex;
  1844. align-items: center;
  1845. justify-content: center;
  1846. padding: 0;
  1847. gap: 0;
  1848. transition: all 0.3s ease;
  1849. cursor: pointer;
  1850. &:hover {
  1851. transform: scale(1.1);
  1852. box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
  1853. }
  1854. &:active {
  1855. transform: scale(0.95);
  1856. }
  1857. .keyboard-icon {
  1858. font-size: rpx(14);
  1859. color: #0064BE;
  1860. }
  1861. }
  1862. /* 诗词显示样式 */
  1863. .poem-display {
  1864. position: absolute;
  1865. top: 25%;
  1866. left: 50%;
  1867. transform: translate(-50%, -50%);
  1868. width: rpx(500);
  1869. height: rpx(200);
  1870. padding: rpx(0);
  1871. display: flex;
  1872. justify-content: center;
  1873. align-items: center;
  1874. z-index: 5;
  1875. animation: scrollBackgroundFadeIn 0.8s ease-out;
  1876. }
  1877. .poem-display::before {
  1878. content: '';
  1879. position: absolute;
  1880. top: 0;
  1881. left: 0;
  1882. width: 100%;
  1883. height: 100%;
  1884. background-image: url('@/assets/dialogue/long-scroll.png');
  1885. background-size: 100%;
  1886. background-repeat: no-repeat;
  1887. background-position: center;
  1888. opacity: 0.8;
  1889. z-index: -1;
  1890. }
  1891. .poem-content {
  1892. text-align: center;
  1893. animation: poemFadeIn 1s ease-in-out;
  1894. transition: all 0.5s ease-in-out;
  1895. }
  1896. .poem-text {
  1897. width: 100%;
  1898. max-width: rpx(500);
  1899. max-height: rpx(200);
  1900. height: auto;
  1901. font-family: 'STKaiti', 'KaiTi', '楷体', 'Ma Shan Zheng', cursive;
  1902. font-size: rpx(20);
  1903. color: black;
  1904. position: relative;
  1905. overflow: auto;
  1906. display: flex;
  1907. align-items: center;
  1908. justify-content: center;
  1909. text-align: center;
  1910. transition: all 0.5s ease-in-out;
  1911. }
  1912. .poem-text::-webkit-scrollbar {
  1913. width: rpx(0);
  1914. }
  1915. .poem-text::-webkit-scrollbar-track {
  1916. background: rgba(255, 255, 255, 0.1);
  1917. border-radius: rpx(4);
  1918. }
  1919. .poem-text::-webkit-scrollbar-thumb {
  1920. background: rgba(0, 0, 0, 0.3);
  1921. border-radius: rpx(4);
  1922. }
  1923. .poem-text::-webkit-scrollbar-thumb:hover {
  1924. background: rgba(0, 0, 0, 0.5);
  1925. }
  1926. .video-display {
  1927. position: absolute;
  1928. top: 45%;
  1929. left: 50%;
  1930. transform: translate(-50%, -50%) scale(0.8);
  1931. z-index: 5;
  1932. width: 100%;
  1933. display: flex;
  1934. justify-content: center;
  1935. align-items: center;
  1936. opacity: 0;
  1937. animation: videoFadeIn 0.8s ease-out forwards;
  1938. }
  1939. .video-frame {
  1940. width: rpx(420);
  1941. height: rpx(250);
  1942. padding: rpx(5);
  1943. background: linear-gradient(135deg, rgba(160, 220, 240, 0.8), rgba(80, 190, 240, 0.8));
  1944. border: rpx(2) solid rgba(0, 100, 192, 0.8);
  1945. border-radius: rpx(15);
  1946. box-shadow: 0 rpx(8) rpx(25) rgba(0, 0, 0, 0.4);
  1947. backdrop-filter: blur(rpx(5));
  1948. display: flex;
  1949. justify-content: center;
  1950. align-items: center;
  1951. }
  1952. .dialogue-video {
  1953. width: 100%;
  1954. height: 100%;
  1955. border-radius: rpx(10);
  1956. background-color: #000;
  1957. object-fit: contain;
  1958. }
  1959. .poem-text::before {
  1960. content: '';
  1961. position: absolute;
  1962. top: 0;
  1963. left: 0;
  1964. right: 0;
  1965. bottom: 0;
  1966. animation: poemShine 3s ease-in-out infinite;
  1967. }
  1968. @keyframes poemFadeIn {
  1969. from {
  1970. opacity: 0;
  1971. transform: scale(0.8) translateY(20px);
  1972. }
  1973. to {
  1974. opacity: 1;
  1975. transform: scale(1) translateY(0);
  1976. }
  1977. }
  1978. @keyframes scrollBackgroundFadeIn {
  1979. from {
  1980. opacity: 0;
  1981. background-size: 80%;
  1982. }
  1983. to {
  1984. opacity: 1;
  1985. background-size: 100%;
  1986. }
  1987. }
  1988. .poem-text p {
  1989. margin: rpx(10) 0;
  1990. opacity: 0;
  1991. animation: poemSlideIn 0.5s ease forwards;
  1992. font-weight: 500;
  1993. letter-spacing: rpx(2);
  1994. }
  1995. .poem-text p:nth-child(1) {
  1996. animation-delay: 0.2s;
  1997. }
  1998. .poem-text p:nth-child(2) {
  1999. animation-delay: 0.4s;
  2000. }
  2001. .poem-text p:nth-child(3) {
  2002. animation-delay: 0.6s;
  2003. }
  2004. .poem-text p:nth-child(4) {
  2005. animation-delay: 0.8s;
  2006. }
  2007. .poem-text p:nth-child(5) {
  2008. animation-delay: 1s;
  2009. }
  2010. @keyframes poemSlideIn {
  2011. from {
  2012. opacity: 0;
  2013. transform: translateY(10px);
  2014. }
  2015. to {
  2016. opacity: 1;
  2017. transform: translateY(0);
  2018. }
  2019. }
  2020. // 对话视频显示动画
  2021. @keyframes videoFadeIn {
  2022. 0% {
  2023. opacity: 0;
  2024. transform: translate(-50%, -50%) scale(0.8);
  2025. }
  2026. 70% {
  2027. opacity: 1;
  2028. transform: translate(-50%, -50%) scale(1.05);
  2029. }
  2030. 100% {
  2031. opacity: 1;
  2032. transform: translate(-50%, -50%) scale(1);
  2033. }
  2034. }
  2035. </style>