DialogContent.vue 30 KB

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