DialogContent.vue 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750
  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
  69. v-if="currentDialogue.type === 'user'"
  70. class="dialogue-card user-input-card"
  71. >
  72. <div class="dialogue-header">
  73. <span class="role-name">我</span>
  74. </div>
  75. <div class="dialogue-content">
  76. <textarea
  77. :value="userInput"
  78. @input="e => userInput = e.target.value"
  79. class="user-input-textarea"
  80. placeholder="请输入内容..."
  81. @keyup.enter.exact="submitUserInput"
  82. ></textarea>
  83. <div class="input-actions">
  84. <button class="cancel-btn" @click="cancelUserInput">清空</button>
  85. <button class="submit-btn" @click="submitUserInput">发送</button>
  86. </div>
  87. </div>
  88. </div>
  89. <!-- 输入按钮区域 -->
  90. <div class="input-buttons-container" >
  91. <!-- 上一个对话按钮 -->
  92. <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
  93. <el-icon class="arrow-icon"><CaretLeft /></el-icon>
  94. </div>
  95. <!-- 语音输入按钮 -->
  96. <div class="voice-input-outer" v-if="currentDialogue.type === 'user'" :class="{ 'recording': isVoiceRecording }">
  97. <VoiceInput
  98. inputSelector=".user-input-textarea"
  99. lang="zh-CN"
  100. maxDuration="10"
  101. @voiceRecognized="handleVoiceRecognized"
  102. @recordingStatusChanged="handleRecordingStatusChanged"
  103. />
  104. </div>
  105. <!-- 语音输入按钮占位符 -->
  106. <div class="voice-input-outer placeholder" v-else></div>
  107. <!-- 下一个对话按钮 -->
  108. <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': currentSectionIndex === scriptData.sections.length - 1 && currentDialogueIndex === currentSection.dialogues.length - 1 }">
  109. <el-icon class="arrow-icon"><CaretRight /></el-icon>
  110. </div>
  111. </div>
  112. </div>
  113. <img :src="currentBackgroundImage" alt="背景图" class="background-image">
  114. </div>
  115. </template>
  116. <script setup>
  117. import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
  118. import { useRouter } from 'vue-router'
  119. import { ArrowLeftBold, CaretLeft, CaretRight, Grid, VideoPlay } from '@element-plus/icons-vue'
  120. import VoiceInput from '@/components/ai/voice/VoiceInput_Api.vue'
  121. import { marked } from 'marked'
  122. import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
  123. import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
  124. import {Message} from "@/utils/message/Message.js";
  125. const { playAudioChunk , stopPlayback, setOnPlaybackComplete } = useAudioPlayer();
  126. // 路由实例
  127. const router = useRouter()
  128. const props = defineProps({
  129. scriptData: {
  130. type: Object,
  131. default: () => ({
  132. title: "",
  133. sections: []
  134. })
  135. },
  136. scriptRoles: {
  137. type: Array,
  138. default: () => []
  139. },
  140. backText: {
  141. type: String,
  142. default: '返回课程'
  143. }
  144. })
  145. // 对话相关状态
  146. // 当前章节索引
  147. const currentSectionIndex = ref(0)
  148. // 当前对话索引
  149. const currentDialogueIndex = ref(0)
  150. // 是否正在播放
  151. const isPlaying = ref(false)
  152. // 用户输入内容
  153. const userInput = ref('')
  154. // 语音录音状态
  155. const isVoiceRecording = ref(false)
  156. // 实时语音识别结果
  157. const voiceRecognizedText = ref("")
  158. // 诗词显示状态
  159. const showPoem = ref(false)
  160. // 当前诗词内容
  161. const currentPoemContent = ref('')
  162. // 音频对象
  163. // 背景音频
  164. const backgroundAudio = ref(null)
  165. // 对话音频
  166. const dialogueAudio = ref(null)
  167. // 遮罩层显示状态
  168. const showMask = ref(true)
  169. // 计算属性
  170. // 当前章节信息
  171. const currentSection = computed(() => {
  172. return props.scriptData.sections[currentSectionIndex.value]
  173. })
  174. // 当前对话信息
  175. const currentDialogue = computed(() => {
  176. if (!currentSection.value) return null
  177. return currentSection.value.dialogues[currentDialogueIndex.value]
  178. })
  179. const currentBackgroundImage = computed(() => {
  180. if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
  181. return ''
  182. }
  183. return currentSection.value.backgroundImage.url
  184. })
  185. // 当前对话缓存
  186. const currentDialogueCache = ref(null)
  187. // 方法
  188. const handleVoiceRecognized = (data) => {
  189. console.log('语音识别结果:', data.originalText)
  190. if (isVoiceRecording.value) {
  191. // 在同一次录音过程中,实时更新文本框内容
  192. voiceRecognizedText.value = data.originalText
  193. userInput.value = data.processedText
  194. } else {
  195. // 在录音结束时,将最终的语音内容追加到userInput.value
  196. const textarea = document.querySelector('.user-input-textarea')
  197. if (textarea) {
  198. userInput.value = data.processedText
  199. // 重新设置光标位置到插入文本的末尾
  200. setTimeout(() => {
  201. textarea.selectionStart = textarea.selectionEnd = data.cursorPos
  202. }, 0)
  203. } else {
  204. // 如果没有找到输入框,直接替换整个内容
  205. userInput.value = data.originalText
  206. }
  207. // 清空临时变量
  208. voiceRecognizedText.value = ""
  209. }
  210. }
  211. // 处理录音状态变化
  212. const handleRecordingStatusChanged = (isRecording) => {
  213. console.log('录音状态:', isRecording)
  214. const wasRecording = isVoiceRecording.value
  215. isVoiceRecording.value = isRecording
  216. // 如果是从录音状态切换到非录音状态,只需要清空临时变量
  217. if (wasRecording && !isRecording) {
  218. // 清空临时变量
  219. voiceRecognizedText.value = ""
  220. }
  221. }
  222. // 提交用户输入
  223. const submitUserInput = async () => {
  224. if (userInput.value.trim()) {
  225. console.log('用户输入:', userInput.value)
  226. await createAiChart();
  227. await doSendMessage();
  228. }
  229. }
  230. // 取消用户输入
  231. const cancelUserInput = () => {
  232. userInput.value = ''
  233. }
  234. // 解析 Markdown 内容
  235. const parseMarkdown = (content) => {
  236. if (!content) return ''
  237. return marked(content)
  238. }
  239. // 格式化诗词内容,在逗号和句号后添加换行
  240. const formatPoemContent = (content) => {
  241. if (!content) return ''
  242. // 移除markdown格式符号,保留文字
  243. let plainText = content.replace(/[\*#`\[\]\(\)]/g, '')
  244. // 在逗号和句号后添加换行符(保留标点符号)
  245. let formatted = plainText.replace(/([。!?;、])/g, '$1<br/>')
  246. // 写死的额外数据
  247. // const extraData = '<p>' + formatted + '</p>'
  248. return formatted
  249. }
  250. const getCharacterSide = () => {
  251. return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
  252. }
  253. // 根据角色ID获取角色名称
  254. const getRole = (roleName) => {
  255. return props.scriptRoles.find(r => r.name === roleName)
  256. }
  257. const getCharacterImage = (roleName) => {
  258. const role = getRole(roleName)
  259. return role ? role.avatar : ''
  260. }
  261. const stopAllAudio = () => {
  262. if (backgroundAudio.value) {
  263. backgroundAudio.value.pause()
  264. backgroundAudio.value.currentTime = 0
  265. }
  266. if (dialogueAudio.value) {
  267. dialogueAudio.value.pause()
  268. dialogueAudio.value.currentTime = 0
  269. }
  270. }
  271. const playBackgroundAudio = () => {
  272. // 停止之前的背景音
  273. if (backgroundAudio.value) {
  274. backgroundAudio.value.pause()
  275. backgroundAudio.value.currentTime = 0
  276. }
  277. // 播放当前环节的背景音
  278. if (currentSection.value?.backgroundAudio?.url && isPlaying.value) {
  279. backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url)
  280. backgroundAudio.value.loop = true
  281. backgroundAudio.value.volume = 1
  282. backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e))
  283. }
  284. }
  285. const playDialogueAudio = (isAutoPlay = false) => {
  286. // 停止之前的对话语音
  287. if (dialogueAudio.value) {
  288. dialogueAudio.value.pause()
  289. dialogueAudio.value.currentTime = 0
  290. }
  291. // 如果是诗词类型,不播放音频
  292. if (currentDialogue.value?.type === 'poem') {
  293. return
  294. }
  295. // 播放当前对话的语音
  296. if (currentDialogue.value?.voiceoverUrl) {
  297. const audio = new Audio(currentDialogue.value.voiceoverUrl)
  298. dialogueAudio.value = audio
  299. // 音频结束事件
  300. audio.onended = () => {
  301. // 如果是自动播放状态,继续播放下一条
  302. if (isAutoPlay && isPlaying.value) {
  303. setTimeout(() => {
  304. if (!playNext(true)) {
  305. // 播放完毕
  306. isPlaying.value = false
  307. stopAllAudio()
  308. }
  309. }, 100)
  310. }
  311. }
  312. // 播放音频
  313. audio.play().catch(e => {
  314. console.error('对话语音播放失败:', e)
  315. // 播放失败时,2秒后跳转
  316. if (isAutoPlay && isPlaying.value) {
  317. setTimeout(() => {
  318. if (!playNext(true)) {
  319. // 播放完毕
  320. isPlaying.value = false
  321. stopAllAudio()
  322. }
  323. }, 2000)
  324. }
  325. })
  326. } else {
  327. // 如果没有语音文件,2秒后跳转
  328. if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
  329. setTimeout(() => {
  330. if (!playNext(true)) {
  331. // 播放完毕
  332. isPlaying.value = false
  333. stopAllAudio()
  334. }
  335. }, 2000)
  336. }
  337. }
  338. }
  339. const togglePlay = () => {
  340. isPlaying.value = !isPlaying.value
  341. if (isPlaying.value) {
  342. // 播放背景音
  343. playBackgroundAudio()
  344. // 开始播放序列
  345. playSequence()
  346. } else {
  347. // 暂停所有音频
  348. stopAllAudio()
  349. }
  350. }
  351. // 自动播放序列
  352. const playSequence = () => {
  353. if (!isPlaying.value) return
  354. // 如果当前是用户输入卡片,暂停播放等待用户输入
  355. if (currentDialogue.value?.type === 'user') return
  356. // 检查当前对话是否为诗词类型
  357. if (currentDialogue.value?.type === 'poem') {
  358. // 显示诗词并替换内容为最新的诗词
  359. showPoem.value = true
  360. currentPoemContent.value = currentDialogue.value.content
  361. // 自动切换到下一条对话
  362. setTimeout(() => {
  363. if (!playNext(true)) {
  364. // 播放完毕
  365. isPlaying.value = false
  366. stopAllAudio()
  367. }
  368. }, 500)
  369. } else {
  370. // 播放当前对话语音,传递isAutoPlay参数
  371. playDialogueAudio(true)
  372. }
  373. }
  374. const playPrevious = () => {
  375. // 停止当前音频
  376. stopAllAudio()
  377. // 如果正在进行数字人对话,调用stopStream清理
  378. recoverQuestDialogue()
  379. stopPlayback(false) // 不触发回调
  380. if (conversationInProgress.value) {
  381. stopStream()
  382. }
  383. if (currentDialogueIndex.value > 0) {
  384. currentDialogueIndex.value--
  385. } else if (currentSectionIndex.value > 0) {
  386. currentSectionIndex.value--
  387. const section = props.scriptData.sections[currentSectionIndex.value]
  388. currentDialogueIndex.value = section.dialogues.length - 1
  389. // 切换环节时隐藏诗词
  390. showPoem.value = false
  391. currentPoemContent.value = ''
  392. }
  393. // 检查当前对话是否为诗词类型
  394. if (currentDialogue.value?.type === 'poem') {
  395. // 显示诗词并替换内容为最新的诗词
  396. showPoem.value = true
  397. currentPoemContent.value = currentDialogue.value.content
  398. playPrevious()
  399. return
  400. }
  401. // 播放背景音
  402. playBackgroundAudio()
  403. // 播放当前对话语音(非诗词类型)
  404. if (currentDialogue.value?.type !== 'poem') {
  405. if (isPlaying.value) {
  406. playDialogueAudio(true)
  407. } else {
  408. playDialogueAudio()
  409. }
  410. }
  411. }
  412. const playNext = (isAutoPlay = false) => {
  413. // 停止当前对话语音
  414. if (dialogueAudio.value) {
  415. dialogueAudio.value.pause()
  416. dialogueAudio.value.currentTime = 0
  417. }
  418. // 如果正在进行数字人对话,调用stopStream清理
  419. recoverQuestDialogue()
  420. stopPlayback(false)
  421. if (conversationInProgress.value) {
  422. stopStream()
  423. }
  424. if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
  425. currentDialogueIndex.value++
  426. // 检查当前对话是否为诗词类型
  427. if (currentDialogue.value?.type === 'poem') {
  428. // 显示诗词并替换内容为最新的诗词
  429. showPoem.value = true
  430. currentPoemContent.value = currentDialogue.value.content
  431. // 自动切换到下一条对话,不需要播放音频
  432. setTimeout(() => {
  433. playNext(isAutoPlay)
  434. }, 500)
  435. return true
  436. }
  437. // 根据是否为自动播放状态决定如何播放语音
  438. if (isPlaying.value) {
  439. playDialogueAudio(true)
  440. } else {
  441. playDialogueAudio()
  442. }
  443. return true
  444. } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
  445. currentSectionIndex.value++
  446. currentDialogueIndex.value = 0
  447. // 切换环节时隐藏诗词
  448. showPoem.value = false
  449. currentPoemContent.value = ''
  450. // 播放背景音
  451. playBackgroundAudio()
  452. // 检查新环节的第一个对话是否为诗词类型
  453. if (currentDialogue.value?.type === 'poem') {
  454. // 显示诗词
  455. showPoem.value = true
  456. currentPoemContent.value = currentDialogue.value.content
  457. // 自动切换到下一条对话,不需要播放音频
  458. setTimeout(() => {
  459. playNext(isAutoPlay)
  460. }, 500)
  461. } else {
  462. // 根据是否为自动播放状态决定如何播放语音
  463. if (isPlaying.value) {
  464. playDialogueAudio(true)
  465. } else {
  466. playDialogueAudio()
  467. }
  468. }
  469. return true
  470. }
  471. return false
  472. }
  473. // 返回主页按钮点击事件
  474. const goBackToMain = () => {
  475. // 停止所有音频
  476. stopAllAudio()
  477. // 跳转到 ai-general-course 页面,保持侧边栏选中状态
  478. router.push('/ai-general-course')
  479. }
  480. // 开始播放
  481. const maskLayer = ref(null)
  482. const startPlayback = () => {
  483. // 消失动画
  484. if (maskLayer.value) {
  485. maskLayer.value.classList.add('fade-out')
  486. // 等待动画完成后隐藏遮罩层
  487. setTimeout(() => {
  488. showMask.value = false
  489. }, 500)
  490. }
  491. // 检查当前对话是否为诗词类型
  492. if (currentDialogue.value?.type === 'poem') {
  493. // 显示诗词
  494. showPoem.value = true
  495. currentPoemContent.value = currentDialogue.value.content
  496. // 自动切换到下一条对话
  497. setTimeout(() => {
  498. playNext()
  499. }, 500)
  500. } else {
  501. // 播放当前对话语音
  502. playDialogueAudio()
  503. }
  504. }
  505. // 键盘事件处理,键盘左右箭头控制对话
  506. const handleKeydown = (event) => {
  507. // 如果遮罩层显示,不处理键盘事件
  508. if (showMask.value) {
  509. return
  510. }
  511. // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
  512. if (currentDialogue.value?.type === 'user') {
  513. return
  514. }
  515. // 处理左右箭头键
  516. switch (event.key) {
  517. case 'ArrowLeft':
  518. playPrevious()
  519. event.preventDefault() // 防止默认行为
  520. break
  521. case 'ArrowRight':
  522. playNext()
  523. event.preventDefault() // 防止默认行为
  524. break
  525. default:
  526. // 其他按键不做处理
  527. break
  528. }
  529. }
  530. // 监听环节变化
  531. watch(currentSectionIndex, () => {
  532. playBackgroundAudio()
  533. })
  534. // 监听 scriptData 变化(侧边栏切换话题时)
  535. watch(() => props.scriptData, (newVal, oldVal) => {
  536. if (newVal && oldVal && newVal !== oldVal) {
  537. // 停止所有音频
  538. stopAllAudio()
  539. // 如果正在进行数字人对话,调用stopStream清理
  540. recoverQuestDialogue()
  541. stopPlayback(false)
  542. if (conversationInProgress.value) {
  543. stopStream()
  544. }
  545. // 清空索引
  546. currentSectionIndex.value = 0
  547. currentDialogueIndex.value = 0
  548. // 重置播放状态
  549. isPlaying.value = false
  550. // 显示遮罩层
  551. showMask.value = true
  552. // 清空用户输入
  553. userInput.value = ''
  554. // 清空对话缓存
  555. currentDialogueCache.value = null
  556. // 隐藏诗词
  557. showPoem.value = false
  558. currentPoemContent.value = ''
  559. }
  560. }, { deep: true })
  561. // 会话ID
  562. const activeConversationId = ref(null)
  563. const conversationInProgress = ref(false)
  564. const conversationInAbortController = ref()
  565. const receiveMessageFullText = ref('')
  566. //创建对话
  567. const createAiChart = async () => {
  568. let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
  569. // 智能问答
  570. await CreateDialogue({ roleId: role.id })
  571. .then(res => {
  572. console.log("创建会话:", res.data);
  573. activeConversationId.value = res.data
  574. })
  575. .catch(error => {
  576. console.error('请求出错:', error)
  577. })
  578. }
  579. /** 真正执行【发送】消息操作 */
  580. const doSendMessage = async () => {
  581. // 校验
  582. if (userInput.value.length < 1) {
  583. console.error('发送失败,原因:内容为空!')
  584. return
  585. }
  586. if (activeConversationId.value == null) {
  587. console.error('还没创建对话,不能发送!')
  588. return
  589. }
  590. let userInputTemp = userInput.value;
  591. let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
  592. userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题处理我回答的内容是否正确并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
  593. // 执行发送
  594. await doSendMessageStream({
  595. conversationId: activeConversationId.value,
  596. content: userInputTemp,
  597. contentAnswer: null,
  598. })
  599. // 清空输入框
  600. userInput.value = ''
  601. }
  602. /** 显示问题回答对话 */
  603. const showQuestAnswerDialogue = () => {
  604. // 缓存当前对话
  605. currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
  606. // 将当前对话类型设置为数字人对话
  607. currentDialogue.value.type = "digital"
  608. // 设置默认内容为"让我思考一下..."
  609. currentDialogue.value.content = "让我思考一下..."
  610. }
  611. //延时恢复对话,避免立即回复导致对话内容被覆盖
  612. const delayRecoverQuestDialogue = () => {
  613. // Message().error('当前网络无反应,请稍后重试!', true);
  614. // 设置默认内容为"让我思考一下..."
  615. currentDialogue.value.content = "当前网络无反应,请稍后重试!"
  616. setTimeout(() => {
  617. recoverQuestDialogue()
  618. }, 1500)
  619. }
  620. /** 回复对话 */
  621. const recoverQuestDialogue = () => {
  622. // 如果有缓存的对话
  623. if (currentDialogueCache.value){
  624. // 恢复当前对话
  625. const currentSection = props.scriptData.sections[currentSectionIndex.value]
  626. if (currentSection) {
  627. currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
  628. }
  629. // 清空缓存
  630. currentDialogueCache.value = null
  631. }
  632. }
  633. /** 真正执行【发送】消息操作 */
  634. const doSendMessageStream = async userMessage => {
  635. // 创建 AbortController 实例,以便中止请求
  636. conversationInAbortController.value = new AbortController()
  637. // 标记对话进行中
  638. conversationInProgress.value = true
  639. // 设置为空
  640. receiveMessageFullText.value = ''
  641. showQuestAnswerDialogue()
  642. try {
  643. // 发送 event stream
  644. let isFirstChunk = true // 是否是第一个 chunk 消息段
  645. await sendChatMessageStream(
  646. userMessage.conversationId,
  647. userMessage.content,
  648. userMessage.contentAnswer,
  649. conversationInAbortController.value,
  650. true, // enableContext 参数
  651. async res => {
  652. const { code, data, msg } = JSON.parse(res.data)
  653. if (code !== 0) {
  654. console.log(`对话异常! ${msg}`)
  655. stopStream();
  656. delayRecoverQuestDialogue()
  657. return
  658. }
  659. if (data.eventType === 'TEXT') {
  660. // 如果内容为空,就不处理。
  661. if (data.receive?.content === '') {
  662. return
  663. }
  664. receiveMessageFullText.value += data.receive.content
  665. // 更新数字人对话框内容
  666. currentDialogue.value.content = receiveMessageFullText.value
  667. // 首次返回需要添加一个 message 到页面,后面的都是更新
  668. if (isFirstChunk) {
  669. isFirstChunk = false
  670. //第一次返回
  671. } else {
  672. //更新最后一条消息
  673. }
  674. }
  675. if (data.eventType === 'AUDIO') {
  676. // 处理音频消息
  677. await playAudioChunk(data.audioData);
  678. }
  679. },
  680. error => {
  681. console.log(`对话异常! ${error}`)
  682. stopStream()
  683. delayRecoverQuestDialogue()
  684. // 需要抛出异常,禁止重试
  685. throw error
  686. },
  687. () => {
  688. console.log(`结束对话! `)
  689. stopStream()
  690. }
  691. )
  692. } catch (error) {
  693. console.error('发送消息失败:', error)
  694. stopStream()
  695. delayRecoverQuestDialogue()
  696. }
  697. }
  698. /** 停止 stream 流式调用 */
  699. const stopStream = async () => {
  700. // 如果 stream 进行中的 message,就需要调用 controller 结束
  701. if (conversationInAbortController.value) {
  702. conversationInAbortController.value.abort()
  703. }
  704. // 销毁语音读取
  705. // stopPlayback();
  706. // 设置为 false
  707. conversationInProgress.value = false
  708. console.log(`结束对话!更改状态: `,conversationInProgress.value)
  709. }
  710. // 处理音频播放完成
  711. const handleAudioPlaybackComplete = () => {
  712. console.log('智能问答音频播放完成');
  713. // AI回答完成后,如果之前是播放状态,继续播放
  714. stopAllAudio();
  715. // 如果处于自动播放状态,继续播放下一条对话
  716. if (isPlaying.value) {
  717. if (playNext()) {
  718. // 继续自动播放序列
  719. playSequence();
  720. } else {
  721. // 播放完毕
  722. isPlaying.value = false;
  723. stopAllAudio();
  724. }
  725. }
  726. };
  727. // 组件挂载时添加键盘事件监听
  728. onMounted(() => {
  729. window.addEventListener('keydown', handleKeydown)
  730. // 播放背景音
  731. playBackgroundAudio()
  732. // // 播放当前对话语音
  733. // playDialogueAudio()
  734. // 设置音频播放完成回调
  735. setOnPlaybackComplete(handleAudioPlaybackComplete)
  736. })
  737. // 组件卸载时移除键盘事件监听和停止音频
  738. onUnmounted(() => {
  739. window.removeEventListener('keydown', handleKeydown)
  740. stopAllAudio()
  741. stopPlayback(false)
  742. })
  743. </script>
  744. <style scoped lang="scss">
  745. @use "sass:math";
  746. @function rpx($px) {
  747. @return math.div($px, 750) * 100vw;
  748. }
  749. .dialog-content-wrapper {
  750. width: 100%;
  751. height: 100%;
  752. position: absolute;
  753. top: 0;
  754. left: 0;
  755. z-index: 100;
  756. }
  757. .title-box {
  758. position: absolute;
  759. top: 0;
  760. left: 0;
  761. right: 0;
  762. height: rpx(60);
  763. display: flex;
  764. align-items: center;
  765. justify-content: space-between;
  766. color: white;
  767. padding: 0 rpx(20);
  768. }
  769. .title-left {
  770. width: 33.33%;
  771. height: 100%;
  772. display: flex;
  773. align-items: center;
  774. gap: rpx(5);
  775. z-index: 30;
  776. .box-icon {
  777. display: flex;
  778. align-items: center;
  779. color: #0064BE;
  780. gap: rpx(5);
  781. padding: rpx(5) rpx(10);
  782. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  783. border: rpx(1) solid rgba(0, 100, 192);
  784. border-radius: rpx(30);
  785. backdrop-filter: blur(10px);
  786. cursor: pointer;
  787. transition: all 0.3s ease;
  788. font-size: rpx(9);
  789. font-weight: 500;
  790. width: fit-content;
  791. }
  792. .box-icon:hover {
  793. background-color: rgba(255, 255, 255, 90%);
  794. transform: translateX(-3px);
  795. }
  796. .left-icon {
  797. font-size: rpx(12);
  798. }
  799. }
  800. .title-center {
  801. display: flex;
  802. align-items: center;
  803. justify-content: center;
  804. height: 100%;
  805. background-image: url('@/assets/dialogue/number-title.png');
  806. background-size: 100% 85%;
  807. background-repeat: no-repeat;
  808. background-position: center;
  809. width: fit-content;
  810. padding: 0 rpx(100);
  811. min-width: rpx(100);
  812. z-index: 10;
  813. }
  814. .title-text {
  815. height: 100%;
  816. font-size: rpx(11);
  817. font-weight: bold;
  818. display: flex;
  819. align-items: center;
  820. justify-content: center;
  821. white-space: nowrap;
  822. }
  823. .title-right {
  824. width: 33.33%;
  825. height: 100%;
  826. display: flex;
  827. align-items: center;
  828. justify-content: flex-end;
  829. gap: rpx(10);
  830. z-index: 10;
  831. .box-icon {
  832. display: flex;
  833. align-items: center;
  834. color: #0064BE;
  835. gap: rpx(5);
  836. padding: rpx(5) rpx(10);
  837. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  838. border: rpx(1) solid rgba(0, 100, 192);
  839. border-radius: rpx(30);
  840. backdrop-filter: blur(10px);
  841. cursor: pointer;
  842. transition: all 0.3s ease;
  843. font-size: rpx(9);
  844. font-weight: 500;
  845. width: fit-content;
  846. }
  847. .box-icon:hover {
  848. background-color: rgba(255, 255, 255, 90%);
  849. transform: translateX(-3px);
  850. }
  851. .left-icon {
  852. font-size: rpx(12);
  853. }
  854. .play-text {
  855. font-size: rpx(9);
  856. color: #0064BE;
  857. font-weight: 500;
  858. }
  859. }
  860. .background-image {
  861. width: 100%;
  862. height: 100%;
  863. object-fit: cover;
  864. z-index: 1;
  865. position: relative;
  866. }
  867. /* 遮罩层样式 */
  868. .mask-layer {
  869. position: absolute;
  870. top: 0;
  871. left: 0;
  872. right: 0;
  873. bottom: 0;
  874. background-color: rgba(0, 0, 0, 0.7);
  875. z-index: 20;
  876. display: flex;
  877. justify-content: center;
  878. align-items: center;
  879. opacity: 1;
  880. transition: all 0.5s ease-out;
  881. }
  882. .mask-layer.fade-out {
  883. opacity: 0;
  884. transform: scale(1.1);
  885. z-index: -1;
  886. }
  887. .play-button-container {
  888. display: flex;
  889. justify-content: center;
  890. align-items: center;
  891. }
  892. .play-button {
  893. width: rpx(80);
  894. height: rpx(80);
  895. border-radius: 50%;
  896. border: none;
  897. background: transparent;
  898. display: flex;
  899. justify-content: center;
  900. align-items: center;
  901. cursor: pointer;
  902. outline: none;
  903. -webkit-tap-highlight-color: transparent;
  904. }
  905. .play-icon {
  906. font-size: rpx(40);
  907. color: #A0DCF0;
  908. transition: all 0.3s ease;
  909. cursor: pointer;
  910. }
  911. .play-icon:hover {
  912. transform: scale(1.2);
  913. color: #50BEF0;
  914. text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
  915. }
  916. .content-box {
  917. position: absolute;
  918. top: rpx(60);
  919. left: 0;
  920. right: 0;
  921. bottom: 0;
  922. display: flex;
  923. align-items: flex-end;
  924. justify-content: center;
  925. padding-bottom: rpx(0);
  926. z-index: 10;
  927. }
  928. .character {
  929. position: absolute;
  930. bottom: 0;
  931. width: rpx(135);
  932. height: rpx(240);
  933. background-size: contain;
  934. background-position: bottom;
  935. background-repeat: no-repeat;
  936. opacity: 0;
  937. z-index: 1;
  938. // background-color: #fff;
  939. }
  940. .character.left {
  941. left: rpx(17);
  942. transform: translateX(-100%);
  943. animation: characterEnterLeft 0.8s ease forwards;
  944. }
  945. .character.right {
  946. right: rpx(17);
  947. transform: translateX(100%) scaleX(-1);
  948. animation: characterEnterRight 0.8s ease forwards;
  949. }
  950. @keyframes characterEnterLeft {
  951. 0% {
  952. opacity: 0;
  953. transform: translateX(-100%) scale(0.8);
  954. }
  955. 70% {
  956. opacity: 0.9;
  957. transform: translateX(10%) scale(1.05);
  958. }
  959. 100% {
  960. opacity: 1;
  961. transform: translateX(0) scale(1);
  962. }
  963. }
  964. @keyframes characterEnterRight {
  965. 0% {
  966. opacity: 0;
  967. transform: translateX(100%) scale(0.8) scaleX(-1);
  968. }
  969. 70% {
  970. opacity: 0.9;
  971. transform: translateX(-10%) scale(1.05) scaleX(-1);
  972. }
  973. 100% {
  974. opacity: 1;
  975. transform: translateX(0) scale(1) scaleX(-1);
  976. }
  977. }
  978. .dialogue-card {
  979. background: rgba(255, 255, 255, 0.9);
  980. border-radius: rpx(6);
  981. padding: rpx(8);
  982. max-width: 35%;
  983. min-width: rpx(200);
  984. box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
  985. position: absolute;
  986. bottom: rpx(50);
  987. width: auto;
  988. display: inline-block;
  989. z-index: 2;
  990. }
  991. .dialogue-card.left {
  992. left: rpx(145);
  993. animation: dialogueEnterLeft 0.6s ease forwards;
  994. }
  995. .dialogue-card.right {
  996. right: rpx(145);
  997. animation: dialogueEnterRight 0.6s ease forwards;
  998. }
  999. .dialogue-card.left::before {
  1000. content: '';
  1001. position: absolute;
  1002. left: rpx(-11.5);
  1003. bottom: rpx(12);
  1004. width: 0;
  1005. height: 0;
  1006. border-top: rpx(7) solid transparent;
  1007. border-bottom: rpx(7) solid transparent;
  1008. border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
  1009. transform: translateY(0);
  1010. }
  1011. .dialogue-card.right::before {
  1012. content: '';
  1013. position: absolute;
  1014. right: rpx(-11.5);
  1015. bottom: rpx(12);
  1016. width: 0;
  1017. height: 0;
  1018. border-top: rpx(7) solid transparent;
  1019. border-bottom: rpx(7) solid transparent;
  1020. border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
  1021. transform: translateY(0);
  1022. }
  1023. @keyframes dialogueEnterLeft {
  1024. from {
  1025. opacity: 0;
  1026. transform: translateX(-rpx(30)) translateY(rpx(20));
  1027. }
  1028. to {
  1029. opacity: 1;
  1030. transform: translateX(0) translateY(0);
  1031. }
  1032. }
  1033. @keyframes dialogueEnterRight {
  1034. from {
  1035. opacity: 0;
  1036. transform: translateX(rpx(30)) translateY(rpx(20));
  1037. }
  1038. to {
  1039. opacity: 1;
  1040. transform: translateX(0) translateY(0);
  1041. }
  1042. }
  1043. .dialogue-header {
  1044. position: absolute;
  1045. top: rpx(-11);
  1046. left: rpx(12);
  1047. background: #409EFF;
  1048. color: white;
  1049. padding: rpx(1.2) rpx(6);
  1050. border-radius: rpx(5);
  1051. font-size: rpx(8);
  1052. box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
  1053. }
  1054. .dialogue-card.right .dialogue-header {
  1055. left: rpx(10);
  1056. }
  1057. .role-name {
  1058. font-weight: 600;
  1059. color: white;
  1060. font-size: rpx(10);
  1061. }
  1062. .dialogue-content {
  1063. font-size: rpx(12);
  1064. line-height: 1.2;
  1065. color: #333;
  1066. text-align: left;
  1067. display: flex;
  1068. flex-direction: column;
  1069. align-items: center;
  1070. justify-content: center;
  1071. width: 100%;
  1072. }
  1073. .user-input-card {
  1074. max-width: 50%;
  1075. position: absolute;
  1076. left: 50%;
  1077. transform: translateX(-50%);
  1078. bottom: rpx(50);
  1079. right: auto;
  1080. animation: dialogueEnterCenter 0.6s ease forwards;
  1081. }
  1082. .user-input-card::before {
  1083. display: none;
  1084. }
  1085. @keyframes dialogueEnterCenter {
  1086. from {
  1087. opacity: 0;
  1088. transform: translateX(-50%) translateY(rpx(20));
  1089. }
  1090. to {
  1091. opacity: 1;
  1092. transform: translateX(-50%) translateY(0);
  1093. }
  1094. }
  1095. .user-input-textarea {
  1096. width: 95%;
  1097. min-height: rpx(50);
  1098. max-height: rpx(150);
  1099. border: none;
  1100. outline: none;
  1101. background: transparent;
  1102. font-size: rpx(10);
  1103. line-height: 1.2;
  1104. color: #333;
  1105. resize: none;
  1106. font-family: inherit;
  1107. overflow-y: auto;
  1108. text-align: left;
  1109. padding: rpx(5) 0;
  1110. }
  1111. .voice-input-content {
  1112. width: 95%;
  1113. min-height: rpx(50);
  1114. max-height: rpx(150);
  1115. font-size: rpx(10);
  1116. line-height: 1.2;
  1117. color: #333;
  1118. text-align: left;
  1119. padding: rpx(5) 0;
  1120. overflow-y: auto;
  1121. }
  1122. .voice-input-placeholder {
  1123. width: 95%;
  1124. min-height: rpx(50);
  1125. font-size: rpx(10);
  1126. line-height: 1.2;
  1127. color: #999;
  1128. text-align: left;
  1129. padding: rpx(5) 0;
  1130. font-style: italic;
  1131. }
  1132. /* 滚动条样式 */
  1133. .user-input-textarea::-webkit-scrollbar {
  1134. width: rpx(0);
  1135. }
  1136. .user-input-textarea::-webkit-scrollbar-track {
  1137. background: rgba(0, 0, 0, 0.05);
  1138. border-radius: rpx(3);
  1139. }
  1140. .user-input-textarea::-webkit-scrollbar-thumb {
  1141. background: rgba(64, 158, 255, 0.5);
  1142. border-radius: rpx(3);
  1143. }
  1144. .user-input-textarea::-webkit-scrollbar-thumb:hover {
  1145. background: rgba(64, 158, 255, 0.8);
  1146. }
  1147. .input-actions {
  1148. display: flex;
  1149. justify-content: flex-end;
  1150. gap: rpx(6);
  1151. margin-top: rpx(6);
  1152. width: 100%;
  1153. }
  1154. .cancel-btn, .submit-btn {
  1155. padding: rpx(2.5) rpx(10);
  1156. border: none;
  1157. border-radius: rpx(4);
  1158. font-size: rpx(8);
  1159. cursor: pointer;
  1160. transition: all 0.3s ease;
  1161. }
  1162. .cancel-btn {
  1163. background: #E0E0E0;
  1164. color: #666;
  1165. }
  1166. .submit-btn {
  1167. background: #409EFF;
  1168. color: white;
  1169. }
  1170. .cancel-btn:hover, .submit-btn:hover {
  1171. transform: scale(1.05);
  1172. }
  1173. .cancel-btn:active, .submit-btn:active {
  1174. transform: scale(0.95);
  1175. }
  1176. .dialogue-content :deep(p) {
  1177. margin: 0 0 rpx(4) 0;
  1178. }
  1179. .dialogue-content :deep(strong),
  1180. .dialogue-content :deep(b) {
  1181. font-weight: bold;
  1182. }
  1183. .dialogue-content :deep(em),
  1184. .dialogue-content :deep(i) {
  1185. font-style: italic;
  1186. }
  1187. .dialogue-content :deep(ul),
  1188. .dialogue-content :deep(ol) {
  1189. margin: rpx(4) 0;
  1190. padding-left: rpx(10);
  1191. }
  1192. .dialogue-content :deep(li) {
  1193. margin: rpx(2) 0;
  1194. }
  1195. .dialogue-content :deep(code) {
  1196. background-color: #f0f0f0;
  1197. padding: rpx(1) rpx(3);
  1198. border-radius: rpx(2);
  1199. font-family: monospace;
  1200. font-size: rpx(9);
  1201. }
  1202. .dialogue-content :deep(a) {
  1203. color: #409EFF;
  1204. text-decoration: underline;
  1205. }
  1206. .input-buttons-container {
  1207. position: relative;
  1208. display: flex;
  1209. align-items: center;
  1210. justify-content: center;
  1211. width: 100%;
  1212. z-index: 10;
  1213. transition: all 0.3s ease;
  1214. transform: scale(0.9);
  1215. gap: rpx(10);
  1216. .arrow-icon-circle {
  1217. width: rpx(20);
  1218. height: rpx(20);
  1219. border-radius: 50%;
  1220. border: rpx(1) solid rgba(0, 100, 192);
  1221. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1222. display: flex;
  1223. align-items: center;
  1224. justify-content: center;
  1225. cursor: pointer;
  1226. transition: all 0.3s ease;
  1227. &:hover:not(.disabled) {
  1228. transform: scale(1.1);
  1229. box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
  1230. }
  1231. &:active:not(.disabled) {
  1232. transform: scale(0.95);
  1233. }
  1234. &.disabled {
  1235. opacity: 0.5;
  1236. cursor: not-allowed;
  1237. border-color: rgba(0, 100, 192, 0.3);
  1238. background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
  1239. }
  1240. .arrow-icon {
  1241. font-size: rpx(15);
  1242. color: #0064BE;
  1243. }
  1244. }
  1245. }
  1246. .voice-input-outer,
  1247. .keyboard-input-outer {
  1248. position: absolute;
  1249. display: flex;
  1250. align-items: center;
  1251. justify-content: center;
  1252. transition: all 0.3s ease;
  1253. }
  1254. .voice-input-outer {
  1255. position: relative;
  1256. width: rpx(50);
  1257. height: rpx(50);
  1258. border-radius: 50%;
  1259. background: transparent;
  1260. display: flex;
  1261. align-items: center;
  1262. justify-content: center;
  1263. // 正常状态只显示一圈
  1264. &::before {
  1265. content: '';
  1266. position: absolute;
  1267. width: 85%;
  1268. height: 85%;
  1269. border-radius: 50%;
  1270. border: rpx(2) solid rgba(80, 190, 240, 0.6);
  1271. }
  1272. // 录音时显示波纹效果
  1273. &.recording {
  1274. &::before {
  1275. animation: pulse 2s infinite;
  1276. border-color: rgba(0, 100, 192, 0.6);
  1277. }
  1278. &::after {
  1279. content: '';
  1280. position: absolute;
  1281. width: 85%;
  1282. height: 85%;
  1283. border-radius: 50%;
  1284. border: rpx(2) solid rgba(0, 100, 192, 0.4);
  1285. animation: pulse 2s infinite 0.5s;
  1286. }
  1287. :deep(.voice-input-container) {
  1288. .speech-btn {
  1289. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1290. border-color: rgba(0, 100, 192, 1);
  1291. .el-icon {
  1292. color: #0064BE;
  1293. }
  1294. }
  1295. }
  1296. }
  1297. // 占位符样式
  1298. &.placeholder {
  1299. visibility: hidden;
  1300. }
  1301. :deep(.voice-input-container) {
  1302. position: relative;
  1303. z-index: 10;
  1304. .speech-btn {
  1305. width: rpx(40);
  1306. height: rpx(40);
  1307. border-radius: 50%;
  1308. border: rpx(2) solid rgba(0, 100, 192);
  1309. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1310. display: flex;
  1311. align-items: center;
  1312. justify-content: center;
  1313. padding: 0;
  1314. gap: 0;
  1315. transition: all 0.3s ease;
  1316. cursor: pointer;
  1317. position: relative;
  1318. overflow: hidden;
  1319. &:hover {
  1320. transform: scale(1.05);
  1321. box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
  1322. }
  1323. &:active {
  1324. transform: scale(0.95);
  1325. }
  1326. &::before {
  1327. content: '';
  1328. position: absolute;
  1329. top: 50%;
  1330. left: 50%;
  1331. width: 0;
  1332. height: 0;
  1333. border-radius: 50%;
  1334. background: rgba(255, 255, 255, 0.5);
  1335. transform: translate(-50%, -50%);
  1336. transition: width 0.6s, height 0.6s;
  1337. }
  1338. &:active::before {
  1339. width: rpx(80);
  1340. height: rpx(80);
  1341. }
  1342. .el-icon {
  1343. font-size: rpx(20);
  1344. color: #0064BE;
  1345. z-index: 1;
  1346. }
  1347. .countdown-text {
  1348. display: block;
  1349. font-size: rpx(8);
  1350. color: #0064BE;
  1351. position: absolute;
  1352. bottom: rpx(5);
  1353. left: 50%;
  1354. transform: translateX(-50%);
  1355. }
  1356. }
  1357. }
  1358. }
  1359. @keyframes pulse {
  1360. 0% {
  1361. transform: scale(1);
  1362. opacity: 1;
  1363. }
  1364. 100% {
  1365. transform: scale(1.15);
  1366. opacity: 0;
  1367. }
  1368. }
  1369. .keyboard-input-outer {
  1370. width: rpx(30);
  1371. height: rpx(30);
  1372. border-radius: 50%;
  1373. background: transparent;
  1374. &.active {
  1375. width: rpx(40);
  1376. height: rpx(40);
  1377. left: 50%;
  1378. transform: translateX(-50%);
  1379. z-index: 11;
  1380. .keyboard-btn {
  1381. width: rpx(36);
  1382. height: rpx(36);
  1383. border: rpx(2) solid rgba(0, 100, 192);
  1384. box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
  1385. .keyboard-icon {
  1386. font-size: rpx(18);
  1387. }
  1388. }
  1389. }
  1390. &.inactive {
  1391. width: rpx(24);
  1392. height: rpx(24);
  1393. left: calc(50% + rpx(40));
  1394. transform: translateX(-50%);
  1395. z-index: 10;
  1396. .keyboard-btn {
  1397. width: rpx(22);
  1398. height: rpx(22);
  1399. border: rpx(1) solid rgba(128, 128, 128, 0.5);
  1400. box-shadow: none;
  1401. background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
  1402. .keyboard-icon {
  1403. font-size: rpx(12);
  1404. color: #808080;
  1405. }
  1406. }
  1407. }
  1408. }
  1409. .keyboard-btn {
  1410. width: rpx(28);
  1411. height: rpx(28);
  1412. border-radius: 50%;
  1413. border: rpx(1) solid rgba(0, 100, 192);
  1414. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1415. display: flex;
  1416. align-items: center;
  1417. justify-content: center;
  1418. padding: 0;
  1419. gap: 0;
  1420. transition: all 0.3s ease;
  1421. cursor: pointer;
  1422. &:hover {
  1423. transform: scale(1.1);
  1424. box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
  1425. }
  1426. &:active {
  1427. transform: scale(0.95);
  1428. }
  1429. .keyboard-icon {
  1430. font-size: rpx(14);
  1431. color: #0064BE;
  1432. }
  1433. }
  1434. /* 诗词显示样式 */
  1435. .poem-display {
  1436. position: absolute;
  1437. top: 25%;
  1438. left: 50%;
  1439. transform: translate(-50%, -50%);
  1440. width: rpx(500);
  1441. height: rpx(200);
  1442. padding: rpx(0);
  1443. display: flex;
  1444. justify-content: center;
  1445. align-items: center;
  1446. z-index: 5;
  1447. background-image: url('@/assets/dialogue/long-scroll.png');
  1448. background-size: 100%;
  1449. background-repeat: no-repeat;
  1450. background-position: center;
  1451. animation: scrollBackgroundFadeIn 0.8s ease-out;
  1452. }
  1453. .poem-content {
  1454. text-align: center;
  1455. animation: poemFadeIn 1s ease-in-out;
  1456. transition: all 0.5s ease-in-out;
  1457. }
  1458. .poem-text {
  1459. width: 100%;
  1460. max-width: rpx(500);
  1461. max-height: rpx(200);
  1462. height: auto;
  1463. font-family: 'STKaiti', 'KaiTi', '楷体', 'Ma Shan Zheng', cursive;
  1464. font-size: rpx(20);
  1465. color: black;
  1466. position: relative;
  1467. overflow: auto;
  1468. display: flex;
  1469. align-items: center;
  1470. justify-content: center;
  1471. flex-direction: column;
  1472. transition: all 0.5s ease-in-out;
  1473. }
  1474. .poem-text::-webkit-scrollbar {
  1475. width: rpx(0);
  1476. }
  1477. .poem-text::-webkit-scrollbar-track {
  1478. background: rgba(255, 255, 255, 0.1);
  1479. border-radius: rpx(4);
  1480. }
  1481. .poem-text::-webkit-scrollbar-thumb {
  1482. background: rgba(0, 0, 0, 0.3);
  1483. border-radius: rpx(4);
  1484. }
  1485. .poem-text::-webkit-scrollbar-thumb:hover {
  1486. background: rgba(0, 0, 0, 0.5);
  1487. }
  1488. .poem-text::before {
  1489. content: '';
  1490. position: absolute;
  1491. top: 0;
  1492. left: 0;
  1493. right: 0;
  1494. bottom: 0;
  1495. // background: linear-gradient(45deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
  1496. animation: poemShine 3s ease-in-out infinite;
  1497. }
  1498. @keyframes poemFadeIn {
  1499. from {
  1500. opacity: 0;
  1501. transform: scale(0.8) translateY(20px);
  1502. }
  1503. to {
  1504. opacity: 1;
  1505. transform: scale(1) translateY(0);
  1506. }
  1507. }
  1508. @keyframes scrollBackgroundFadeIn {
  1509. from {
  1510. opacity: 0;
  1511. background-size: 80%;
  1512. }
  1513. to {
  1514. opacity: 1;
  1515. background-size: 100%;
  1516. }
  1517. }
  1518. // @keyframes poemShine {
  1519. // 0% {
  1520. // transform: translateX(-100%) rotate(45deg);
  1521. // }
  1522. // 100% {
  1523. // transform: translateX(100%) rotate(45deg);
  1524. // }
  1525. // }
  1526. .poem-text p {
  1527. margin: rpx(10) 0;
  1528. opacity: 0;
  1529. animation: poemSlideIn 0.5s ease forwards;
  1530. font-weight: 500;
  1531. letter-spacing: rpx(2);
  1532. }
  1533. .poem-text p:nth-child(1) {
  1534. animation-delay: 0.2s;
  1535. }
  1536. .poem-text p:nth-child(2) {
  1537. animation-delay: 0.4s;
  1538. }
  1539. .poem-text p:nth-child(3) {
  1540. animation-delay: 0.6s;
  1541. }
  1542. .poem-text p:nth-child(4) {
  1543. animation-delay: 0.8s;
  1544. }
  1545. .poem-text p:nth-child(5) {
  1546. animation-delay: 1s;
  1547. }
  1548. @keyframes poemSlideIn {
  1549. from {
  1550. opacity: 0;
  1551. transform: translateY(10px);
  1552. }
  1553. to {
  1554. opacity: 1;
  1555. transform: translateY(0);
  1556. }
  1557. }
  1558. </style>