DialogContent.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344
  1. <template>
  2. <div class="dialog-content-wrapper">
  3. <!-- 标题 -->
  4. <div class="title-box">
  5. <!-- 返回 -->
  6. <div class="title-left">
  7. <div class="back-icon-circle" @click="goBackToMain">
  8. <el-icon class="back-icon"><ArrowLeftBold /></el-icon>
  9. </div>
  10. <span class="back-text" @click="goBackToMain">{{ backText }}</span>
  11. </div>
  12. <!-- 标题 -->
  13. <div class="title-center">
  14. {{ currentSection?.name }}
  15. </div>
  16. <!-- 左右按钮 -->
  17. <div class="title-right">
  18. <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
  19. <el-icon class="arrow-icon"><CaretLeft /></el-icon>
  20. </div>
  21. <div class="arrow-icon-circle" @click="togglePlay">
  22. <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
  23. </div>
  24. <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': currentSectionIndex === scriptData.sections.length - 1 && currentDialogueIndex === currentSection.dialogues.length - 1 }">
  25. <el-icon class="arrow-icon"><CaretRight /></el-icon>
  26. </div>
  27. </div>
  28. </div>
  29. <!-- 内容区域 -->
  30. <div class="content-box">
  31. <!-- 人物形象 -->
  32. <div
  33. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  34. :key="`character-${currentDialogueIndex}`"
  35. class="character"
  36. :class="{
  37. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  38. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  39. }"
  40. :style="{ backgroundImage: `url(${getCharacterImage(currentDialogue.roleName)})` }"
  41. ></div>
  42. <!-- 对话卡片 -->
  43. <div
  44. v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
  45. :key="`dialogue-${currentDialogueIndex}`"
  46. class="dialogue-card"
  47. :class="{
  48. 'left': getCharacterSide(currentDialogue.roleName) === 'left',
  49. 'right': getCharacterSide(currentDialogue.roleName) === 'right'
  50. }"
  51. >
  52. <div class="dialogue-header">
  53. <span class="role-name">{{ currentDialogue?.roleName }}</span>
  54. </div>
  55. <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
  56. </div>
  57. <!-- 用户输入卡片 -->
  58. <div
  59. v-if="currentDialogue.type === 'user'"
  60. class="dialogue-card user-input-card"
  61. >
  62. <div class="dialogue-header">
  63. <span class="role-name">我</span>
  64. </div>
  65. <div class="dialogue-content">
  66. <textarea
  67. :value="userInput"
  68. @input="e => userInput = e.target.value"
  69. class="user-input-textarea"
  70. placeholder="请输入内容..."
  71. @keyup.enter.exact="submitUserInput"
  72. ></textarea>
  73. <div class="input-actions">
  74. <button class="cancel-btn" @click="cancelUserInput">清空</button>
  75. <button class="submit-btn" @click="submitUserInput">发送</button>
  76. </div>
  77. </div>
  78. </div>
  79. <!-- 输入按钮区域 -->
  80. <div class="input-buttons-container" v-if="currentDialogue.type === 'user'">
  81. <!-- 语音输入按钮 -->
  82. <div class="voice-input-outer" :class="{ 'recording': isVoiceRecording }">
  83. <VoiceInput
  84. @voiceRecognized="handleVoiceRecognized"
  85. @recordingStatusChanged="handleRecordingStatusChanged"
  86. />
  87. </div>
  88. </div>
  89. </div>
  90. <img :src="currentBackgroundImage" alt="背景图" class="background-image">
  91. </div>
  92. </template>
  93. <script setup>
  94. import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
  95. import { useRouter } from 'vue-router'
  96. import { ArrowLeftBold, CaretLeft, CaretRight, Grid } from '@element-plus/icons-vue'
  97. import VoiceInput from '@/components/ai/voice/VoiceInput.vue'
  98. import { marked } from 'marked'
  99. import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
  100. import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
  101. const { playAudioChunk , stopPlayback, setOnPlaybackComplete } = useAudioPlayer();
  102. // 路由实例
  103. const router = useRouter()
  104. const props = defineProps({
  105. scriptData: {
  106. type: Object,
  107. default: () => ({
  108. title: "",
  109. sections: []
  110. })
  111. },
  112. scriptRoles: {
  113. type: Array,
  114. default: () => []
  115. },
  116. backText: {
  117. type: String,
  118. default: '返回课程'
  119. }
  120. })
  121. // 对话相关状态
  122. // 当前章节索引
  123. const currentSectionIndex = ref(0)
  124. // 当前对话索引
  125. const currentDialogueIndex = ref(0)
  126. // 是否正在播放
  127. const isPlaying = ref(false)
  128. // 用户输入内容
  129. const userInput = ref('')
  130. // 语音录音状态
  131. const isVoiceRecording = ref(false)
  132. // 实时语音识别结果
  133. const voiceRecognizedText = ref("")
  134. // 录音开始时的光标位置
  135. const recordingStartCursorPos = ref(0)
  136. // 录音开始时的原始文本
  137. const recordingStartText = ref("")
  138. // 音频对象
  139. // 背景音频
  140. const backgroundAudio = ref(null)
  141. // 对话音频
  142. const dialogueAudio = ref(null)
  143. // 计算属性
  144. // 当前章节信息
  145. const currentSection = computed(() => {
  146. return props.scriptData.sections[currentSectionIndex.value]
  147. })
  148. // 当前对话信息
  149. const currentDialogue = computed(() => {
  150. if (!currentSection.value) return null
  151. return currentSection.value.dialogues[currentDialogueIndex.value]
  152. })
  153. const currentBackgroundImage = computed(() => {
  154. if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
  155. return ''
  156. }
  157. return currentSection.value.backgroundImage.url
  158. })
  159. // 当前对话缓存
  160. const currentDialogueCache = ref(null)
  161. // 方法
  162. const handleVoiceRecognized = (text) => {
  163. console.log('语音识别结果:', text)
  164. if (isVoiceRecording.value) {
  165. // 在同一次录音过程中,实时更新文本框内容
  166. voiceRecognizedText.value = text
  167. const textarea = document.querySelector('.user-input-textarea')
  168. if (textarea) {
  169. // 使用录音开始时的原始文本和光标位置
  170. const startPos = recordingStartCursorPos.value
  171. const originalText = recordingStartText.value
  172. // 在光标位置插入实时识别结果
  173. userInput.value = originalText.substring(0, startPos) + text + originalText.substring(startPos)
  174. }
  175. } else {
  176. // 在录音结束时,将最终的语音内容追加到userInput.value
  177. const textarea = document.querySelector('.user-input-textarea')
  178. if (textarea) {
  179. // 使用录音开始时的光标位置和原始文本
  180. const startPos = recordingStartCursorPos.value
  181. const originalText = recordingStartText.value
  182. // 在光标位置插入文本
  183. userInput.value = originalText.substring(0, startPos) + text + originalText.substring(startPos)
  184. // 重新设置光标位置到插入文本的末尾
  185. setTimeout(() => {
  186. textarea.selectionStart = textarea.selectionEnd = startPos + text.length
  187. }, 0)
  188. } else {
  189. // 如果没有找到输入框,直接替换整个内容
  190. userInput.value = text
  191. }
  192. // 清空临时变量
  193. voiceRecognizedText.value = ""
  194. }
  195. }
  196. // 处理录音状态变化
  197. const handleRecordingStatusChanged = (isRecording) => {
  198. console.log('录音状态:', isRecording)
  199. const wasRecording = isVoiceRecording.value
  200. isVoiceRecording.value = isRecording
  201. // 如果是从未录音状态切换到录音状态,记录当前光标位置和文本内容
  202. if (!wasRecording && isRecording) {
  203. const textarea = document.querySelector('.user-input-textarea')
  204. if (textarea) {
  205. recordingStartCursorPos.value = textarea.selectionStart
  206. recordingStartText.value = userInput.value
  207. } else {
  208. recordingStartCursorPos.value = 0
  209. recordingStartText.value = userInput.value
  210. }
  211. }
  212. // 如果是从录音状态切换到非录音状态,只需要清空临时变量
  213. if (wasRecording && !isRecording) {
  214. // 清空临时变量
  215. voiceRecognizedText.value = ""
  216. }
  217. }
  218. // 提交用户输入
  219. const submitUserInput = async () => {
  220. if (userInput.value.trim()) {
  221. console.log('用户输入:', userInput.value)
  222. await createAiChart();
  223. await doSendMessage();
  224. }
  225. }
  226. // 取消用户输入
  227. const cancelUserInput = () => {
  228. userInput.value = ''
  229. }
  230. // 解析 Markdown 内容
  231. const parseMarkdown = (content) => {
  232. if (!content) return ''
  233. return marked(content)
  234. }
  235. const getCharacterSide = () => {
  236. return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
  237. }
  238. // 根据角色ID获取角色名称
  239. const getRole = (roleName) => {
  240. return props.scriptRoles.find(r => r.name === roleName)
  241. }
  242. const getCharacterImage = (roleName) => {
  243. const role = getRole(roleName)
  244. return role ? role.avatar : ''
  245. }
  246. const stopAllAudio = () => {
  247. if (backgroundAudio.value) {
  248. backgroundAudio.value.pause()
  249. backgroundAudio.value.currentTime = 0
  250. }
  251. if (dialogueAudio.value) {
  252. dialogueAudio.value.pause()
  253. dialogueAudio.value.currentTime = 0
  254. }
  255. }
  256. const playBackgroundAudio = () => {
  257. // 停止之前的背景音
  258. if (backgroundAudio.value) {
  259. backgroundAudio.value.pause()
  260. backgroundAudio.value.currentTime = 0
  261. }
  262. // 播放当前环节的背景音
  263. if (currentSection.value?.backgroundAudio?.url && isPlaying.value) {
  264. backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url)
  265. backgroundAudio.value.loop = true
  266. backgroundAudio.value.volume = 1
  267. backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e))
  268. }
  269. }
  270. const playDialogueAudio = (isAutoPlay = false) => {
  271. // 停止之前的对话语音
  272. if (dialogueAudio.value) {
  273. dialogueAudio.value.pause()
  274. dialogueAudio.value.currentTime = 0
  275. }
  276. // 播放当前对话的语音
  277. if (currentDialogue.value?.voiceoverUrl) {
  278. const audio = new Audio(currentDialogue.value.voiceoverUrl)
  279. dialogueAudio.value = audio
  280. // 音频结束事件
  281. audio.onended = () => {
  282. // 如果是自动播放状态,继续播放下一条
  283. if (isAutoPlay && isPlaying.value) {
  284. setTimeout(() => {
  285. if (!playNext(true)) {
  286. // 播放完毕
  287. isPlaying.value = false
  288. stopAllAudio()
  289. }
  290. }, 100)
  291. }
  292. }
  293. // 播放音频
  294. audio.play().catch(e => {
  295. console.error('对话语音播放失败:', e)
  296. // 播放失败时,2秒后跳转(暂时注销,因为会导致user类型数字人回复播报跳转后语音播报报错,直接2秒跳转)
  297. // if (isAutoPlay && isPlaying.value) {
  298. // setTimeout(() => {
  299. // if (!playNext(true)) {
  300. // // 播放完毕
  301. // isPlaying.value = false
  302. // stopAllAudio()
  303. // }
  304. // }, 2000)
  305. // }
  306. })
  307. } else {
  308. // 如果没有语音文件,2秒后跳转
  309. if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
  310. setTimeout(() => {
  311. if (!playNext(true)) {
  312. // 播放完毕
  313. isPlaying.value = false
  314. stopAllAudio()
  315. }
  316. }, 2000)
  317. }
  318. }
  319. }
  320. const togglePlay = () => {
  321. isPlaying.value = !isPlaying.value
  322. if (isPlaying.value) {
  323. // 播放背景音
  324. playBackgroundAudio()
  325. // 开始播放序列
  326. playSequence()
  327. } else {
  328. // 暂停所有音频
  329. stopAllAudio()
  330. }
  331. }
  332. // 自动播放序列
  333. const playSequence = () => {
  334. if (!isPlaying.value) return
  335. // 如果当前是用户输入卡片,暂停播放等待用户输入
  336. if (currentDialogue.value?.type === 'user') return
  337. // 播放当前对话语音,传递isAutoPlay参数
  338. playDialogueAudio(true)
  339. }
  340. const playPrevious = () => {
  341. // 停止当前音频
  342. stopAllAudio()
  343. // 如果正在进行数字人对话,调用stopStream清理
  344. recoverQuestDialogue()
  345. stopPlayback(false) // 不触发回调
  346. if (conversationInProgress.value) {
  347. stopStream()
  348. }
  349. if (currentDialogueIndex.value > 0) {
  350. currentDialogueIndex.value--
  351. } else if (currentSectionIndex.value > 0) {
  352. currentSectionIndex.value--
  353. const section = props.scriptData.sections[currentSectionIndex.value]
  354. currentDialogueIndex.value = section.dialogues.length - 1
  355. }
  356. // 播放背景音
  357. playBackgroundAudio()
  358. // 播放当前对话语音
  359. if (isPlaying.value) {
  360. playDialogueAudio(true)
  361. } else {
  362. playDialogueAudio()
  363. }
  364. }
  365. const playNext = (isAutoPlay = false) => {
  366. // 停止当前对话语音
  367. if (dialogueAudio.value) {
  368. dialogueAudio.value.pause()
  369. dialogueAudio.value.currentTime = 0
  370. }
  371. // 如果正在进行数字人对话,调用stopStream清理
  372. recoverQuestDialogue()
  373. stopPlayback(false)
  374. if (conversationInProgress.value) {
  375. stopStream()
  376. }
  377. if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
  378. currentDialogueIndex.value++
  379. // 根据是否为自动播放状态决定如何播放语音
  380. if (isPlaying.value) {
  381. playDialogueAudio(true)
  382. } else {
  383. playDialogueAudio()
  384. }
  385. return true
  386. } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
  387. currentSectionIndex.value++
  388. currentDialogueIndex.value = 0
  389. // 播放背景音
  390. playBackgroundAudio()
  391. // 根据是否为自动播放状态决定如何播放语音
  392. if (isPlaying.value) {
  393. playDialogueAudio(true)
  394. } else {
  395. playDialogueAudio()
  396. }
  397. return true
  398. }
  399. return false
  400. }
  401. // 返回主页按钮点击事件
  402. const goBackToMain = () => {
  403. // 停止所有音频
  404. stopAllAudio()
  405. // 跳转到 ai-general-course 页面,保持侧边栏选中状态
  406. router.push('/ai-general-course')
  407. }
  408. // 键盘事件处理,键盘左右箭头控制对话
  409. const handleKeydown = (event) => {
  410. // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
  411. if (currentDialogue.value?.type === 'user') {
  412. return
  413. }
  414. if (event.key === 'ArrowLeft') {
  415. playPrevious()
  416. } else if (event.key === 'ArrowRight') {
  417. playNext()
  418. }
  419. }
  420. // 监听环节变化
  421. watch(currentSectionIndex, () => {
  422. playBackgroundAudio()
  423. })
  424. // 监听对话变化
  425. watch(currentDialogue, () => {
  426. if (!isPlaying.value) {
  427. playDialogueAudio()
  428. }
  429. })
  430. // 会话ID
  431. const activeConversationId = ref(null)
  432. const conversationInProgress = ref(false)
  433. const conversationInAbortController = ref()
  434. const receiveMessageFullText = ref('')
  435. //创建对话
  436. const createAiChart = async () => {
  437. let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
  438. // 智能问答
  439. await CreateDialogue({ roleId: role.id })
  440. .then(res => {
  441. console.log("创建会话:", res.data);
  442. activeConversationId.value = res.data
  443. })
  444. .catch(error => {
  445. console.error('请求出错:', error)
  446. })
  447. }
  448. /** 真正执行【发送】消息操作 */
  449. const doSendMessage = async () => {
  450. // 校验
  451. if (userInput.value.length < 1) {
  452. console.error('发送失败,原因:内容为空!')
  453. return
  454. }
  455. if (activeConversationId.value == null) {
  456. console.error('还没创建对话,不能发送!')
  457. return
  458. }
  459. let userInputTemp = userInput.value;
  460. let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
  461. userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题处理我回答的内容是否正确并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
  462. // 执行发送
  463. await doSendMessageStream({
  464. conversationId: activeConversationId.value,
  465. content: userInputTemp,
  466. contentAnswer: null,
  467. })
  468. // 清空输入框
  469. userInput.value = ''
  470. }
  471. /** 显示问题回答对话 */
  472. const showQuestAnswerDialogue = () => {
  473. // 缓存当前对话
  474. currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
  475. // 将当前对话类型设置为数字人对话
  476. currentDialogue.value.type = "digital"
  477. // 设置默认内容为"让我思考一下..."
  478. currentDialogue.value.content = "让我思考一下..."
  479. }
  480. /** 回复对话 */
  481. const recoverQuestDialogue = () => {
  482. // 如果有缓存的对话
  483. if (currentDialogueCache.value){
  484. // 恢复当前对话
  485. const currentSection = props.scriptData.sections[currentSectionIndex.value]
  486. if (currentSection) {
  487. currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
  488. }
  489. // 清空缓存
  490. currentDialogueCache.value = null
  491. }
  492. }
  493. /** 真正执行【发送】消息操作 */
  494. const doSendMessageStream = async userMessage => {
  495. // 创建 AbortController 实例,以便中止请求
  496. conversationInAbortController.value = new AbortController()
  497. // 标记对话进行中
  498. conversationInProgress.value = true
  499. // 设置为空
  500. receiveMessageFullText.value = ''
  501. showQuestAnswerDialogue()
  502. try {
  503. // 发送 event stream
  504. let isFirstChunk = true // 是否是第一个 chunk 消息段
  505. await sendChatMessageStream(
  506. userMessage.conversationId,
  507. userMessage.content,
  508. userMessage.contentAnswer,
  509. conversationInAbortController.value,
  510. true, // enableContext 参数
  511. async res => {
  512. const { code, data, msg } = JSON.parse(res.data)
  513. if (code !== 0) {
  514. console.log(`对话异常! ${msg}`)
  515. stopStream();
  516. return
  517. }
  518. if (data.eventType === 'TEXT') {
  519. // 如果内容为空,就不处理。
  520. if (data.receive?.content === '') {
  521. return
  522. }
  523. receiveMessageFullText.value += data.receive.content
  524. // 更新数字人对话框内容
  525. currentDialogue.value.content = receiveMessageFullText.value
  526. // 首次返回需要添加一个 message 到页面,后面的都是更新
  527. if (isFirstChunk) {
  528. isFirstChunk = false
  529. //第一次返回
  530. } else {
  531. //更新最后一条消息
  532. }
  533. }
  534. if (data.eventType === 'AUDIO') {
  535. // 处理音频消息
  536. await playAudioChunk(data.audioData);
  537. }
  538. },
  539. error => {
  540. console.log(`对话异常! ${error}`)
  541. stopStream()
  542. // 需要抛出异常,禁止重试
  543. throw error
  544. },
  545. () => {
  546. console.log(`结束对话! `)
  547. stopStream()
  548. }
  549. )
  550. } catch (error) {
  551. console.error('发送消息失败:', error)
  552. stopStream()
  553. }
  554. }
  555. /** 停止 stream 流式调用 */
  556. const stopStream = async () => {
  557. // 如果 stream 进行中的 message,就需要调用 controller 结束
  558. if (conversationInAbortController.value) {
  559. conversationInAbortController.value.abort()
  560. }
  561. // 销毁语音读取
  562. // stopPlayback();
  563. // 设置为 false
  564. conversationInProgress.value = false
  565. console.log(`结束对话!更改状态: `,conversationInProgress.value)
  566. }
  567. // 处理音频播放完成
  568. const handleAudioPlaybackComplete = () => {
  569. console.log('智能问答音频播放完成');
  570. // AI回答完成后,如果之前是播放状态,继续播放
  571. stopAllAudio();
  572. // 如果处于自动播放状态,继续播放下一条对话
  573. if (isPlaying.value) {
  574. if (playNext()) {
  575. // 继续自动播放序列
  576. playSequence();
  577. } else {
  578. // 播放完毕
  579. isPlaying.value = false;
  580. stopAllAudio();
  581. }
  582. }
  583. };
  584. // 组件挂载时添加键盘事件监听
  585. onMounted(() => {
  586. window.addEventListener('keydown', handleKeydown)
  587. // 播放背景音
  588. playBackgroundAudio()
  589. // 播放当前对话语音
  590. playDialogueAudio()
  591. // 设置音频播放完成回调
  592. setOnPlaybackComplete(handleAudioPlaybackComplete)
  593. })
  594. // 组件卸载时移除键盘事件监听和停止音频
  595. onUnmounted(() => {
  596. window.removeEventListener('keydown', handleKeydown)
  597. stopAllAudio()
  598. stopPlayback(false)
  599. })
  600. </script>
  601. <style scoped lang="scss">
  602. @use "sass:math";
  603. @function rpx($px) {
  604. @return math.div($px, 750) * 100vw;
  605. }
  606. .dialog-content-wrapper {
  607. width: 100%;
  608. height: 100%;
  609. position: absolute;
  610. top: 0;
  611. left: 0;
  612. z-index: 100;
  613. }
  614. .title-box {
  615. position: absolute;
  616. top: 0;
  617. left: 0;
  618. right: 0;
  619. height: rpx(60);
  620. display: flex;
  621. align-items: center;
  622. justify-content: space-between;
  623. color: white;
  624. padding: 0 rpx(20);
  625. z-index: 10;
  626. }
  627. .title-left {
  628. width: 33.33%;
  629. height: 100%;
  630. display: flex;
  631. align-items: center;
  632. justify-content: flex-start;
  633. gap: rpx(5);
  634. .back-icon-circle {
  635. width: rpx(20);
  636. height: rpx(20);
  637. border-radius: 50%;
  638. border: rpx(1) solid rgba(0, 100, 192);
  639. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  640. display: flex;
  641. align-items: center;
  642. justify-content: center;
  643. cursor: pointer;
  644. transition: all 0.3s ease;
  645. }
  646. .back-icon-circle:hover {
  647. transform: scale(1.1);
  648. box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
  649. }
  650. .back-icon-circle:active {
  651. transform: scale(0.95);
  652. }
  653. .back-icon {
  654. font-size: rpx(13);
  655. color: #0064BE;
  656. }
  657. .back-text {
  658. font-size: rpx(12);
  659. color: #0064BE;
  660. cursor: pointer;
  661. }
  662. }
  663. .title-center {
  664. width: 33.33%;
  665. display: flex;
  666. align-items: center;
  667. justify-content: center;
  668. font-size: rpx(13);
  669. font-weight: bold;
  670. background-image: url('@/assets/dialogue/number-title.png');
  671. background-size: contain;
  672. background-repeat: no-repeat;
  673. background-position: center;
  674. height: 100%;
  675. }
  676. .title-right {
  677. width: 33.33%;
  678. height: 100%;
  679. display: flex;
  680. align-items: center;
  681. justify-content: flex-end;
  682. gap: rpx(10);
  683. .arrow-icon-circle {
  684. width: rpx(20);
  685. height: rpx(20);
  686. border-radius: 50%;
  687. border: rpx(1) solid rgba(0, 100, 192);
  688. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  689. display: flex;
  690. align-items: center;
  691. justify-content: center;
  692. cursor: pointer;
  693. transition: all 0.3s ease;
  694. }
  695. .arrow-icon-circle:hover:not(.disabled) {
  696. transform: scale(1.1);
  697. box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
  698. }
  699. .arrow-icon-circle:active:not(.disabled) {
  700. transform: scale(0.95);
  701. }
  702. .arrow-icon-circle.disabled {
  703. opacity: 0.5;
  704. cursor: not-allowed;
  705. border-color: rgba(0, 100, 192, 0.3);
  706. background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
  707. }
  708. .arrow-icon {
  709. font-size: rpx(15);
  710. color: #0064BE;
  711. }
  712. .play-text {
  713. font-size: rpx(7);
  714. color: #0064BE;
  715. font-weight: 500;
  716. }
  717. }
  718. .background-image {
  719. width: 100%;
  720. height: 100%;
  721. object-fit: cover;
  722. z-index: 1;
  723. position: relative;
  724. }
  725. .content-box {
  726. position: absolute;
  727. top: rpx(60);
  728. left: 0;
  729. right: 0;
  730. bottom: 0;
  731. display: flex;
  732. align-items: flex-end;
  733. justify-content: center;
  734. padding-bottom: rpx(10);
  735. z-index: 10;
  736. }
  737. .character {
  738. position: absolute;
  739. bottom: 0;
  740. width: rpx(135);
  741. height: rpx(240);
  742. background-size: contain;
  743. background-position: bottom;
  744. background-repeat: no-repeat;
  745. opacity: 0;
  746. z-index: 1;
  747. }
  748. .character.left {
  749. left: rpx(30);
  750. transform: translateX(-100%);
  751. animation: characterEnterLeft 0.8s ease forwards;
  752. }
  753. .character.right {
  754. right: rpx(30);
  755. transform: translateX(100%) scaleX(-1);
  756. animation: characterEnterRight 0.8s ease forwards;
  757. }
  758. @keyframes characterEnterLeft {
  759. 0% {
  760. opacity: 0;
  761. transform: translateX(-100%) scale(0.8);
  762. }
  763. 70% {
  764. opacity: 0.9;
  765. transform: translateX(10%) scale(1.05);
  766. }
  767. 100% {
  768. opacity: 1;
  769. transform: translateX(0) scale(1);
  770. }
  771. }
  772. @keyframes characterEnterRight {
  773. 0% {
  774. opacity: 0;
  775. transform: translateX(100%) scale(0.8) scaleX(-1);
  776. }
  777. 70% {
  778. opacity: 0.9;
  779. transform: translateX(-10%) scale(1.05) scaleX(-1);
  780. }
  781. 100% {
  782. opacity: 1;
  783. transform: translateX(0) scale(1) scaleX(-1);
  784. }
  785. }
  786. .dialogue-card {
  787. background: rgba(255, 255, 255, 0.9);
  788. border-radius: rpx(6);
  789. padding: rpx(8);
  790. max-width: 20%;
  791. min-width: rpx(150);
  792. box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
  793. position: absolute;
  794. bottom: rpx(50);
  795. width: auto;
  796. display: inline-block;
  797. z-index: 2;
  798. }
  799. .dialogue-card.left {
  800. left: rpx(145);
  801. animation: dialogueEnterLeft 0.6s ease forwards;
  802. }
  803. .dialogue-card.right {
  804. right: rpx(145);
  805. animation: dialogueEnterRight 0.6s ease forwards;
  806. }
  807. .dialogue-card.left::before {
  808. content: '';
  809. position: absolute;
  810. left: rpx(-11.5);
  811. bottom: rpx(12);
  812. width: 0;
  813. height: 0;
  814. border-top: rpx(7) solid transparent;
  815. border-bottom: rpx(7) solid transparent;
  816. border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
  817. transform: translateY(0);
  818. }
  819. .dialogue-card.right::before {
  820. content: '';
  821. position: absolute;
  822. right: rpx(-11.5);
  823. bottom: rpx(12);
  824. width: 0;
  825. height: 0;
  826. border-top: rpx(7) solid transparent;
  827. border-bottom: rpx(7) solid transparent;
  828. border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
  829. transform: translateY(0);
  830. }
  831. @keyframes dialogueEnterLeft {
  832. from {
  833. opacity: 0;
  834. transform: translateX(-rpx(30)) translateY(rpx(20));
  835. }
  836. to {
  837. opacity: 1;
  838. transform: translateX(0) translateY(0);
  839. }
  840. }
  841. @keyframes dialogueEnterRight {
  842. from {
  843. opacity: 0;
  844. transform: translateX(rpx(30)) translateY(rpx(20));
  845. }
  846. to {
  847. opacity: 1;
  848. transform: translateX(0) translateY(0);
  849. }
  850. }
  851. .dialogue-header {
  852. position: absolute;
  853. top: rpx(-11);
  854. left: rpx(12);
  855. background: #409EFF;
  856. color: white;
  857. padding: rpx(1.2) rpx(6);
  858. border-radius: rpx(5);
  859. font-size: rpx(8);
  860. box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
  861. }
  862. .dialogue-card.right .dialogue-header {
  863. left: rpx(10);
  864. }
  865. .role-name {
  866. font-weight: 600;
  867. color: white;
  868. font-size: rpx(10);
  869. }
  870. .dialogue-content {
  871. font-size: rpx(12);
  872. line-height: 1.2;
  873. color: #333;
  874. text-align: left;
  875. display: flex;
  876. flex-direction: column;
  877. align-items: center;
  878. justify-content: center;
  879. width: 100%;
  880. }
  881. .user-input-card {
  882. max-width: 50%;
  883. position: absolute;
  884. left: 50%;
  885. transform: translateX(-50%);
  886. bottom: rpx(70);
  887. right: auto;
  888. animation: dialogueEnterCenter 0.6s ease forwards;
  889. }
  890. .user-input-card::before {
  891. display: none;
  892. }
  893. @keyframes dialogueEnterCenter {
  894. from {
  895. opacity: 0;
  896. transform: translateX(-50%) translateY(rpx(20));
  897. }
  898. to {
  899. opacity: 1;
  900. transform: translateX(-50%) translateY(0);
  901. }
  902. }
  903. .user-input-textarea {
  904. width: 95%;
  905. min-height: rpx(50);
  906. max-height: rpx(150);
  907. border: none;
  908. outline: none;
  909. background: transparent;
  910. font-size: rpx(10);
  911. line-height: 1.2;
  912. color: #333;
  913. resize: none;
  914. font-family: inherit;
  915. overflow-y: auto;
  916. text-align: left;
  917. padding: rpx(5) 0;
  918. }
  919. .voice-input-content {
  920. width: 95%;
  921. min-height: rpx(50);
  922. max-height: rpx(150);
  923. font-size: rpx(10);
  924. line-height: 1.2;
  925. color: #333;
  926. text-align: left;
  927. padding: rpx(5) 0;
  928. overflow-y: auto;
  929. }
  930. .voice-input-placeholder {
  931. width: 95%;
  932. min-height: rpx(50);
  933. font-size: rpx(10);
  934. line-height: 1.2;
  935. color: #999;
  936. text-align: left;
  937. padding: rpx(5) 0;
  938. font-style: italic;
  939. }
  940. /* 滚动条样式 */
  941. .user-input-textarea::-webkit-scrollbar {
  942. width: rpx(0);
  943. }
  944. .user-input-textarea::-webkit-scrollbar-track {
  945. background: rgba(0, 0, 0, 0.05);
  946. border-radius: rpx(3);
  947. }
  948. .user-input-textarea::-webkit-scrollbar-thumb {
  949. background: rgba(64, 158, 255, 0.5);
  950. border-radius: rpx(3);
  951. }
  952. .user-input-textarea::-webkit-scrollbar-thumb:hover {
  953. background: rgba(64, 158, 255, 0.8);
  954. }
  955. .input-actions {
  956. display: flex;
  957. justify-content: flex-end;
  958. gap: rpx(6);
  959. margin-top: rpx(6);
  960. width: 100%;
  961. }
  962. .cancel-btn, .submit-btn {
  963. padding: rpx(2.5) rpx(10);
  964. border: none;
  965. border-radius: rpx(4);
  966. font-size: rpx(8);
  967. cursor: pointer;
  968. transition: all 0.3s ease;
  969. }
  970. .cancel-btn {
  971. background: #E0E0E0;
  972. color: #666;
  973. }
  974. .submit-btn {
  975. background: #409EFF;
  976. color: white;
  977. }
  978. .cancel-btn:hover, .submit-btn:hover {
  979. transform: scale(1.05);
  980. }
  981. .cancel-btn:active, .submit-btn:active {
  982. transform: scale(0.95);
  983. }
  984. .dialogue-content :deep(p) {
  985. margin: 0 0 rpx(4) 0;
  986. }
  987. .dialogue-content :deep(strong),
  988. .dialogue-content :deep(b) {
  989. font-weight: bold;
  990. }
  991. .dialogue-content :deep(em),
  992. .dialogue-content :deep(i) {
  993. font-style: italic;
  994. }
  995. .dialogue-content :deep(ul),
  996. .dialogue-content :deep(ol) {
  997. margin: rpx(4) 0;
  998. padding-left: rpx(10);
  999. }
  1000. .dialogue-content :deep(li) {
  1001. margin: rpx(2) 0;
  1002. }
  1003. .dialogue-content :deep(code) {
  1004. background-color: #f0f0f0;
  1005. padding: rpx(1) rpx(3);
  1006. border-radius: rpx(2);
  1007. font-family: monospace;
  1008. font-size: rpx(9);
  1009. }
  1010. .dialogue-content :deep(a) {
  1011. color: #409EFF;
  1012. text-decoration: underline;
  1013. }
  1014. .input-buttons-container {
  1015. position: relative;
  1016. display: flex;
  1017. align-items: center;
  1018. justify-content: center;
  1019. width: 100%;
  1020. z-index: 10;
  1021. transition: all 0.3s ease;
  1022. transform: scale(0.9);
  1023. }
  1024. .voice-input-outer,
  1025. .keyboard-input-outer {
  1026. position: absolute;
  1027. display: flex;
  1028. align-items: center;
  1029. justify-content: center;
  1030. transition: all 0.3s ease;
  1031. }
  1032. .voice-input-outer {
  1033. position: relative;
  1034. width: rpx(50);
  1035. height: rpx(50);
  1036. border-radius: 50%;
  1037. background: transparent;
  1038. display: flex;
  1039. align-items: center;
  1040. justify-content: center;
  1041. // 正常状态只显示一圈
  1042. &::before {
  1043. content: '';
  1044. position: absolute;
  1045. width: 85%;
  1046. height: 85%;
  1047. border-radius: 50%;
  1048. border: rpx(2) solid rgba(80, 190, 240, 0.6);
  1049. }
  1050. // 录音时显示波纹效果
  1051. &.recording {
  1052. &::before {
  1053. animation: pulse 2s infinite;
  1054. }
  1055. &::after {
  1056. content: '';
  1057. position: absolute;
  1058. width: 85%;
  1059. height: 85%;
  1060. border-radius: 50%;
  1061. border: rpx(2) solid rgba(80, 190, 240, 0.4);
  1062. animation: pulse 2s infinite 0.5s;
  1063. }
  1064. }
  1065. :deep(.voice-input-container) {
  1066. position: relative;
  1067. z-index: 10;
  1068. .speech-btn {
  1069. width: rpx(40);
  1070. height: rpx(40);
  1071. border-radius: 50%;
  1072. border: rpx(2) solid rgba(0, 100, 192);
  1073. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1074. display: flex;
  1075. align-items: center;
  1076. justify-content: center;
  1077. padding: 0;
  1078. gap: 0;
  1079. transition: all 0.3s ease;
  1080. cursor: pointer;
  1081. position: relative;
  1082. overflow: hidden;
  1083. &:hover {
  1084. transform: scale(1.05);
  1085. box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
  1086. }
  1087. &:active {
  1088. transform: scale(0.95);
  1089. }
  1090. &::before {
  1091. content: '';
  1092. position: absolute;
  1093. top: 50%;
  1094. left: 50%;
  1095. width: 0;
  1096. height: 0;
  1097. border-radius: 50%;
  1098. background: rgba(255, 255, 255, 0.5);
  1099. transform: translate(-50%, -50%);
  1100. transition: width 0.6s, height 0.6s;
  1101. }
  1102. &:active::before {
  1103. width: rpx(80);
  1104. height: rpx(80);
  1105. }
  1106. .el-icon {
  1107. font-size: rpx(20);
  1108. color: #0064BE;
  1109. z-index: 1;
  1110. }
  1111. .countdown-text {
  1112. display: none;
  1113. }
  1114. .waveform-container {
  1115. display: none;
  1116. }
  1117. }
  1118. }
  1119. }
  1120. @keyframes pulse {
  1121. 0% {
  1122. transform: scale(1);
  1123. opacity: 1;
  1124. }
  1125. 100% {
  1126. transform: scale(1.15);
  1127. opacity: 0;
  1128. }
  1129. }
  1130. .keyboard-input-outer {
  1131. width: rpx(30);
  1132. height: rpx(30);
  1133. border-radius: 50%;
  1134. background: transparent;
  1135. &.active {
  1136. width: rpx(40);
  1137. height: rpx(40);
  1138. left: 50%;
  1139. transform: translateX(-50%);
  1140. z-index: 11;
  1141. .keyboard-btn {
  1142. width: rpx(36);
  1143. height: rpx(36);
  1144. border: rpx(2) solid rgba(0, 100, 192);
  1145. box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
  1146. .keyboard-icon {
  1147. font-size: rpx(18);
  1148. }
  1149. }
  1150. }
  1151. &.inactive {
  1152. width: rpx(24);
  1153. height: rpx(24);
  1154. left: calc(50% + rpx(40));
  1155. transform: translateX(-50%);
  1156. z-index: 10;
  1157. .keyboard-btn {
  1158. width: rpx(22);
  1159. height: rpx(22);
  1160. border: rpx(1) solid rgba(128, 128, 128, 0.5);
  1161. box-shadow: none;
  1162. background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
  1163. .keyboard-icon {
  1164. font-size: rpx(12);
  1165. color: #808080;
  1166. }
  1167. }
  1168. }
  1169. }
  1170. .keyboard-btn {
  1171. width: rpx(28);
  1172. height: rpx(28);
  1173. border-radius: 50%;
  1174. border: rpx(1) solid rgba(0, 100, 192);
  1175. background: linear-gradient(135deg, #A0DCF0, #50BEF0);
  1176. display: flex;
  1177. align-items: center;
  1178. justify-content: center;
  1179. padding: 0;
  1180. gap: 0;
  1181. transition: all 0.3s ease;
  1182. cursor: pointer;
  1183. &:hover {
  1184. transform: scale(1.1);
  1185. box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
  1186. }
  1187. &:active {
  1188. transform: scale(0.95);
  1189. }
  1190. .keyboard-icon {
  1191. font-size: rpx(14);
  1192. color: #0064BE;
  1193. }
  1194. }
  1195. </style>