DialogContent.vue 30 KB

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