DialogComponents.vue 29 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168
  1. <template>
  2. <div>
  3. <!-- 试题弹框 -->
  4. <transition name="fade-scale">
  5. <div
  6. v-show="questionDialogVisible"
  7. class="child-dialog-wrapper"
  8. @click.self="handleCloseQuestionDialog"
  9. >
  10. <div class="child-dialog">
  11. <div class="question-title">
  12. <span class="question-icon">?</span>
  13. <span v-html="currentQuestion.ccQuestContent"></span>
  14. </div>
  15. <!-- 选项区域 -->
  16. <div
  17. v-if="currentQuestion.ccQuestOption && currentQuestion.ccQuestOption.length > 0"
  18. class="options-container"
  19. >
  20. <div
  21. v-for="(option, index) in currentQuestion.ccQuestOption.split(',')"
  22. :key="index"
  23. class="question-option"
  24. >
  25. <el-radio
  26. v-model="selectedOption"
  27. :label="option"
  28. :value="option"
  29. >
  30. <span>{{ option }}</span>
  31. </el-radio>
  32. </div>
  33. </div>
  34. <div v-else class="no-options">
  35. <!-- 暂无选项 -->
  36. </div>
  37. <!-- 底部按钮 -->
  38. <div class="dialog-footer">
  39. <el-button
  40. class="child-button confirm"
  41. @click="handleSubmitAnswer"
  42. >确定</el-button
  43. >
  44. </div>
  45. <!-- 右侧小图标 -->
  46. <div
  47. v-if="currentQuestion.ccAiAnswer !== null"
  48. class="ai-icon-container"
  49. @click="handleAIClick"
  50. >
  51. <img
  52. src="@/assets/images/xiaozhi.png"
  53. alt="AI对话"
  54. class="ai-icon"
  55. />
  56. <span class="ai-text">小智智能助手</span>
  57. </div>
  58. </div>
  59. </div>
  60. </transition>
  61. <!-- AI对话弹框 -->
  62. <div
  63. v-show="showAIDialog"
  64. class="ai-dialog-wrapper"
  65. @click.self="showAIDialog = false"
  66. >
  67. <div class="ai-dialog">
  68. <div class="ai-dialog-header">
  69. <h3>
  70. <img :src="auto" alt="" />
  71. 小智智能助手
  72. </h3>
  73. <el-button @click="showAIDialog = false" class="close-btn"
  74. >×</el-button
  75. >
  76. </div>
  77. <div class="ai-dialog-content">
  78. <div class="ai-message-history" ref="messageContainer" @scroll="handleScroll">
  79. <div
  80. v-for="(message, index) in messageList"
  81. :key="index"
  82. :class="['message', message.type]"
  83. >
  84. <img
  85. v-if="message.type === 'user'"
  86. src="@/assets/images/user.png"
  87. class="avatar user"
  88. />
  89. <img v-else src="@/assets/images/xiaozhi.png" class="avatar" />
  90. <div
  91. class="message-content"
  92. v-if="message.type === 'user'"
  93. v-html="message.content"
  94. ></div>
  95. <div class="message-content" v-else>
  96. <MarkdownView class="left-text" :content="message.content" />
  97. </div>
  98. </div>
  99. </div>
  100. <!-- 弹框默认消息 -->
  101. <DefaultMessage
  102. class="default-messages"
  103. :category="'ai_develop'"
  104. :questTip="currentQuestion.ccAiQuestTip || ''"
  105. @select-message="handleSelectMessage"
  106. />
  107. <!-- 消息输入框 -->
  108. <el-input
  109. v-model="prompt"
  110. placeholder="输入问题..."
  111. class="user-input"
  112. @keyup.enter="handleSendByKeydown"
  113. >
  114. <!-- 语音输入 -->
  115. <template #prepend>
  116. <el-button
  117. @click="toggleSpeechInput"
  118. size="small"
  119. :class="{ 'recording': isRecording }"
  120. circle
  121. >
  122. <el-icon v-if="!isRecording"><Microphone /></el-icon>
  123. <el-icon v-else><Mute /></el-icon>
  124. <!-- 显示倒计时(仅录音时显示) -->
  125. <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
  126. </el-button>
  127. </template>
  128. <!-- 终止按钮和发送按钮条件渲染 -->
  129. <template #append>
  130. <!-- 终止问答按钮 -->
  131. <div
  132. v-if="conversationInProgress"
  133. @click="stopStream"
  134. class="stop-btn"
  135. title="终止问答"
  136. >
  137. <img :src="stopicon" alt="停止" />
  138. </div>
  139. <!-- 发送按钮 -->
  140. <el-button v-if="!conversationInProgress" @click="handleSendByButton" size="large" round
  141. >发送</el-button
  142. >
  143. </template>
  144. </el-input>
  145. </div>
  146. </div>
  147. </div>
  148. </div>
  149. </template>
  150. <script setup>
  151. import {ref,onUnmounted, defineProps, defineEmits, onMounted, watch, nextTick} from 'vue'
  152. import { ElMessage } from 'element-plus'
  153. import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
  154. import { teacherList } from '@/api/teachers.js'
  155. import DefaultMessage from '@/components/DefaultMessage/index.vue'
  156. import MarkdownView from '@/components/MarkdownView/index.vue'
  157. import { saveRecord } from '@/api/personalized/index.js'
  158. // 语音图标导入
  159. import { Microphone, Mute } from '@element-plus/icons-vue'
  160. // 终止
  161. import stopicon from '@/assets/icon/stopicon.png'
  162. // 导入图标
  163. import auto from '@/assets/icon/auto_awesome.png'
  164. // 定义props
  165. const props = defineProps({
  166. componentType: { type: String, default: 'course' },
  167. questionDialogVisible: { type: Boolean, default: false },
  168. currentQuestion: { type: Object, default: () => ({}) },
  169. gradeId: { type: String, default: '' },
  170. typeId: { type: String, default: '' },
  171. courseId: { type: String, default: '' },
  172. })
  173. // 定义emits
  174. const emits = defineEmits(['closeQuestionDialog', 'submitAnswer', 'saveProgress'])
  175. // 内部状态
  176. const showAIDialog = ref(false)
  177. const selectedOption = ref(null)
  178. const messageList = ref([])
  179. const prompt = ref('')
  180. const messageContainer = ref(null)
  181. const userScrolled = ref(false) //是否用户手动滚动
  182. const xZAiData = ref({})
  183. const activeConversationId = ref(null)
  184. const conversationInProgress = ref(false)
  185. const conversationInAbortController = ref()
  186. const receiveMessageFullText = ref('')
  187. const isComposing = ref(false)
  188. const inputTimeout = ref()
  189. const enableContext = ref(true)
  190. // tts
  191. import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
  192. const { playAudioChunk , stopPlayback } = useAudioPlayer();
  193. // 语音输入响应式变量
  194. const isRecording = ref(false) // 录音状态
  195. const recognition = ref(null) // 语音识别实例
  196. const countdown = ref(0) // 倒计时剩余秒数
  197. const countdownTimer = ref(null) // 倒计时定时器
  198. // 处理选择的默认消息
  199. const handleSelectMessage = message => {
  200. prompt.value = message
  201. }
  202. // 关闭试题弹框
  203. const handleCloseQuestionDialog = () => {
  204. stopPlayback(); // 销毁语音读取
  205. emits('closeQuestionDialog')
  206. }
  207. // 提交答案
  208. const handleSubmitAnswer = () => {
  209. if (props.currentQuestion.ccQuestOption && props.currentQuestion.ccQuestOption.length > 0 && !selectedOption.value) {
  210. ElMessage.warning('请选择一个选项!')
  211. return
  212. }
  213. if (selectedOption.value === props.currentQuestion.ccAnswer) {
  214. ElMessage.success('恭喜回答正确!')
  215. }else{
  216. ElMessage.warning('回答错误!')
  217. }
  218. emits('submitAnswer', selectedOption.value === props.currentQuestion.ccAnswer)
  219. selectedOption.value = null
  220. }
  221. // 处理 AI 助手点击事件
  222. const handleAIClick = async () => {
  223. // 清空输入框
  224. messageList.value = []
  225. showAIDialog.value = true
  226. // 创建对话
  227. await createAiChart()
  228. if (props.currentQuestion.ccQuestContent) {
  229. // prompt.value = props.currentQuestion.ccQuestContent
  230. sendMessage()
  231. prompt.value = ''
  232. console.log("handleAIClick", props.currentQuestion.ccAiAnswer)
  233. // 执行发送
  234. await doSendMessageStream({
  235. conversationId: activeConversationId.value,
  236. content: props.currentQuestion.ccQuestContent,
  237. contentAnswer: props.currentQuestion.ccAiAnswer,
  238. })
  239. }
  240. }
  241. // 数字人接口
  242. const getXzAi = async () => {
  243. try {
  244. //根据组件类型(course/blockly)获取不同的年级/角色类型
  245. const grade = props.componentType === 'course' ? localStorage.getItem('selectedGrade') || '小学低年级' : 'Blockly-'
  246. // 获取AI数据
  247. const juniorAIRes = await teacherList({ category: grade + 'AI' })
  248. const aiPerson = juniorAIRes.data.list.find(
  249. person => person.name === '小智'
  250. )
  251. if (aiPerson) {
  252. xZAiData.value = {
  253. id: aiPerson.id,
  254. name: aiPerson.name,
  255. image: aiPerson.model2dPath,
  256. message: aiPerson.systemMessage,
  257. default: aiPerson.questTip
  258. }
  259. } else {
  260. console.warn('未找到名为小智的数据')
  261. }
  262. } catch (error) {
  263. console.error('获取年级AI数据失败:', error)
  264. }
  265. }
  266. //创建对话
  267. const createAiChart = async () => {
  268. // 先获取数字人接口
  269. await getXzAi()
  270. // 智能问答
  271. await CreateDialogue({ roleId: xZAiData.value.id })
  272. .then(res => {
  273. console.log("创建会话:", res.data);
  274. activeConversationId.value = res.data
  275. })
  276. .catch(error => {
  277. console.error('请求出错:', error)
  278. })
  279. }
  280. // 发送消息
  281. const sendMessage = async () => {
  282. if (prompt.value.trim()) {
  283. // 添加用户消息到历史记录
  284. messageList.value.push({
  285. type: 'user',
  286. content: prompt.value
  287. })
  288. // // 模拟 AI 回复
  289. // const aiResponse = await simulateAIResponse(prompt.value)
  290. // messageList.value.push({
  291. // type: 'ai',
  292. // content: aiResponse
  293. // })
  294. // 清空输入框
  295. prompt.value = ''
  296. }
  297. }
  298. // =========== 【语音录入】相关 ===========
  299. // 初始化语音识别
  300. const initSpeechRecognition = () => {
  301. const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
  302. if (!SpeechRecognition) {
  303. ElMessage.warning('当前浏览器不支持语音输入功能')
  304. return null
  305. }
  306. const instance = new SpeechRecognition()
  307. instance.lang = "zh-CN"
  308. instance.interimResults = false
  309. instance.onresult = (event) => {
  310. if (event.results?.[0]?.[0]) {
  311. prompt.value += event.results[0][0].transcript
  312. }
  313. }
  314. // 识别器结束时清除定时器
  315. instance.onend = () => {
  316. clearInterval(countdownTimer.value)
  317. isRecording.value = false
  318. countdown.value = 0
  319. }
  320. instance.onerror = (event) => {
  321. console.error("语音识别错误:", event.error)
  322. clearInterval(countdownTimer.value) // 出错时清除定时器
  323. isRecording.value = false
  324. ElMessage.error('语音输入失败,请重试')
  325. countdown.value = 0
  326. }
  327. return instance
  328. }
  329. // 切换录音状态
  330. const toggleSpeechInput = () => {
  331. // 无论当前状态如何,先清除可能存在的旧定时器
  332. clearInterval(countdownTimer.value)
  333. countdownTimer.value = null
  334. if (isRecording.value) {
  335. // 手动停止时重置状态
  336. countdown.value = 0
  337. recognition.value?.stop()
  338. isRecording.value = false
  339. } else {
  340. // 初始化倒计时前再次清除定时器(防止快速点击)
  341. clearInterval(countdownTimer.value)
  342. countdown.value = 10 // 重置为10秒
  343. recognition.value = initSpeechRecognition()
  344. if (!recognition.value) return
  345. navigator.mediaDevices.getUserMedia({ audio: true })
  346. .then(() => {
  347. recognition.value.start()
  348. isRecording.value = true
  349. // 启动新的倒计时定时器
  350. countdownTimer.value = setInterval(() => {
  351. countdown.value--
  352. if (countdown.value <= 0) {
  353. clearInterval(countdownTimer.value) // 倒计时结束清除
  354. recognition.value.stop()
  355. isRecording.value = false
  356. countdown.value = 0
  357. }
  358. }, 1000)
  359. })
  360. .catch((err) => {
  361. console.error("麦克风权限获取失败:", err)
  362. ElMessage.warning('请允许麦克风权限以使用语音输入')
  363. // 出错时重置状态
  364. isRecording.value = false
  365. countdown.value = 0
  366. })
  367. }
  368. }
  369. // 模拟 AI 回复
  370. const simulateAIResponse = question => {
  371. return new Promise(resolve => {
  372. setTimeout(() => {
  373. if (props.currentQuestion.ccAiAnswer) {
  374. resolve(props.currentQuestion.ccAiAnswer)
  375. return
  376. }
  377. // 若未匹配到自定义回复,给出默认回复
  378. resolve(`您的问题是:${question},这是 AI 的回复示例。`)
  379. }, 1000)
  380. })
  381. }
  382. /** 处理来自 keydown 的发送消息 */
  383. const handleSendByKeydown = async event => {
  384. // 判断用户是否在输入
  385. if (isComposing.value) {
  386. return
  387. }
  388. // 进行中不允许发送
  389. if (conversationInProgress.value) {
  390. return
  391. }
  392. const content = prompt.value?.trim()
  393. if (event.key === 'Enter') {
  394. if (event.shiftKey) {
  395. // 插入换行
  396. prompt.value += '\r\n'
  397. event.preventDefault() // 防止默认的换行行为
  398. } else {
  399. // 发送消息
  400. await doSendMessage(content)
  401. event.preventDefault() // 防止默认的提交行为
  402. }
  403. }
  404. }
  405. /** 处理来自【发送】按钮的发送消息 */
  406. const handleSendByButton = () => {
  407. doSendMessage(prompt.value?.trim())
  408. }
  409. /** 真正执行【发送】消息操作 */
  410. const doSendMessage = async content => {
  411. // 校验
  412. if (content.length < 1) {
  413. console.error('发送失败,原因:内容为空!')
  414. return
  415. }
  416. if (activeConversationId.value == null) {
  417. console.error('还没创建对话,不能发送!')
  418. return
  419. }
  420. // 通过emit事件通知父组件保存进度
  421. emits('saveProgress', "aiCount", 1)
  422. // 清空输入框
  423. prompt.value = ''
  424. // 执行发送
  425. await doSendMessageStream({
  426. conversationId: activeConversationId.value,
  427. content: content,
  428. contentAnswer: null,
  429. })
  430. }
  431. /** 真正执行【发送】消息操作 */
  432. const doSendMessageStream = async userMessage => {
  433. // 创建 AbortController 实例,以便中止请求
  434. conversationInAbortController.value = new AbortController()
  435. // 标记对话进行中
  436. conversationInProgress.value = true
  437. // 设置为空
  438. receiveMessageFullText.value = ''
  439. try {
  440. // 1.1 先添加两个假数据,等 stream 返回再替换
  441. messageList.value.push({
  442. id: -1,
  443. conversationId: activeConversationId.value,
  444. type: 'user',
  445. content: userMessage.content,
  446. createTime: new Date()
  447. })
  448. messageList.value.push({
  449. id: -2,
  450. conversationId: activeConversationId.value,
  451. type: 'assistant',
  452. content: '思考中...',
  453. createTime: new Date()
  454. })
  455. // 销毁语音读取
  456. stopPlayback();
  457. // 2. 发送 event stream
  458. let isFirstChunk = true // 是否是第一个 chunk 消息段
  459. console.log("doSendMessageStream-userMessage", userMessage)
  460. await sendChatMessageStream(
  461. userMessage.conversationId,
  462. userMessage.content,
  463. userMessage.contentAnswer,
  464. conversationInAbortController.value,
  465. enableContext.value,
  466. async res => {
  467. const { code, data, msg } = JSON.parse(res.data)
  468. if (code !== 0) {
  469. console.log(`对话异常! ${msg}`)
  470. return
  471. }
  472. if (data.eventType === 'TEXT') {
  473. // 如果内容为空,就不处理。
  474. if (data.receive?.content === '') {
  475. return
  476. }
  477. receiveMessageFullText.value += data.receive.content
  478. // 首次返回需要添加一个 message 到页面,后面的都是更新
  479. if (isFirstChunk) {
  480. isFirstChunk = false
  481. // 弹出两个假数据
  482. messageList.value.pop()
  483. messageList.value.pop()
  484. // 更新返回的数据
  485. messageList.value.push(data.send)
  486. messageList.value.push(data.receive)
  487. } else {
  488. //更新最后一条消息
  489. if (messageList.value.length > 0) {
  490. const lastMessage = messageList.value[messageList.value.length - 1]
  491. if (lastMessage.id === data.receive.id) {
  492. lastMessage.content = receiveMessageFullText.value
  493. }
  494. }
  495. }
  496. }
  497. if (data.eventType === 'AUDIO') {
  498. // 处理音频消息
  499. await playAudioChunk(data.audioData);
  500. }
  501. // 确保触发滚动
  502. scrollToBottom()
  503. },
  504. error => {
  505. console.log(`对话异常! ${error}`)
  506. stopStream()
  507. // 需要抛出异常,禁止重试
  508. throw error
  509. },
  510. () => {
  511. console.log(`结束对话! `)
  512. stopStream()
  513. }
  514. )
  515. } catch (error) {
  516. console.error('发送消息失败:', error)
  517. stopStream()
  518. }
  519. }
  520. /** 停止 stream 流式调用 */
  521. const stopStream = async () => {
  522. // 如果 stream 进行中的 message,就需要调用 controller 结束
  523. if (conversationInAbortController.value) {
  524. conversationInAbortController.value.abort()
  525. }
  526. // 销毁语音读取
  527. // stopPlayback();
  528. // 设置为 false
  529. conversationInProgress.value = false
  530. console.log(`结束对话!更改状态: `,conversationInProgress.value)
  531. }
  532. /** 处理 prompt 输入变化 */
  533. const handlePromptInput = event => {
  534. // 非输入法 输入设置为 true
  535. if (!isComposing.value) {
  536. // 回车 event data 是 null
  537. if (event.data == null) {
  538. return
  539. }
  540. isComposing.value = true
  541. }
  542. // 清理定时器
  543. if (inputTimeout.value) {
  544. clearTimeout(inputTimeout.value)
  545. }
  546. // 重置定时器
  547. inputTimeout.value = setTimeout(() => {
  548. isComposing.value = false
  549. }, 400)
  550. }
  551. const onCompositionstart = () => {
  552. isComposing.value = true
  553. }
  554. const onCompositionend = () => {
  555. setTimeout(() => {
  556. isComposing.value = false
  557. }, 200)
  558. }
  559. // 监听props变化
  560. watch(() => props.questionDialogVisible, (newVal) => {
  561. if (newVal && props.currentQuestion) {
  562. // 重置选项
  563. selectedOption.value = null
  564. }
  565. })
  566. // 监听showAIDialog变化,在关闭时销毁语音读取
  567. watch(() => showAIDialog.value, (newVal) => {
  568. if (!newVal) {
  569. stopPlayback();
  570. }
  571. })
  572. // 监听消息列表变化,自动滚动到底部
  573. watch(messageList, () => {
  574. scrollToBottom()
  575. }, { deep: true })
  576. //处理滚动事件,判断用户是否手动滚动
  577. const handleScroll = () => {
  578. if (messageContainer.value) {
  579. const { scrollTop, scrollHeight, clientHeight } = messageContainer.value
  580. // 当用户滚动距离底部超过50px时,认为是手动滚动
  581. userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
  582. }
  583. }
  584. // 单独的滚动到底部函数
  585. const scrollToBottom = () => {
  586. // 如果用户手动滚动过,不自动滚动
  587. if (userScrolled.value) return
  588. nextTick(() => {
  589. if (messageContainer.value) {
  590. // 强制重排以确保获取最新高度
  591. messageContainer.value.scrollTop = messageContainer.value.scrollHeight
  592. // 双重保险:使用requestAnimationFrame确保在浏览器重绘后执行
  593. requestAnimationFrame(() => {
  594. messageContainer.value.scrollTop = messageContainer.value.scrollHeight
  595. })
  596. }
  597. })
  598. }
  599. onMounted(() => {
  600. // 初始化
  601. })
  602. // 组件卸载时清理语音资源
  603. onUnmounted(() => {
  604. stopPlayback();
  605. // 确保在组件卸载时也停止 SSE 流
  606. if (conversationInProgress.value) {
  607. stopStream();
  608. }
  609. });
  610. </script>
  611. <style scoped lang="scss">
  612. @use 'sass:math';
  613. @use 'sass:color'; // 引入 color 模块
  614. // 定义rpx转换函数
  615. @function rpx($px) {
  616. @return math.div($px, 750) * 100vw;
  617. }
  618. // 定义儿童风格的蓝紫色调
  619. $primary-color: rgba(106, 90, 205, 0.52); // 主色调:蓝紫色
  620. $secondary-color: rgba(147, 112, 219, 0.66); // 辅助色:亮蓝紫色
  621. $accent-color: rgb(133, 89, 220); // 强调色:暗蓝紫色
  622. $light-color: #ffffff; // 浅色背景:淡紫色
  623. $text-color: #483d8b; // 文本颜色:靛蓝色
  624. // 儿童风格试题弹框样式
  625. .child-dialog {
  626. .el-dialog__header {
  627. display: none; // 隐藏原有的标题栏
  628. }
  629. .el-dialog__body {
  630. padding: rpx(20);
  631. position: relative;
  632. }
  633. .el-dialog__footer {
  634. border-top: none;
  635. padding: rpx(10) rpx(20);
  636. text-align: center;
  637. margin-top: auto; // 使底部按钮位于底部
  638. }
  639. .el-dialog__wrapper {
  640. // 修改半透明背景色
  641. background-color: rgba(0, 0, 0, 0.6);
  642. }
  643. .el-dialog {
  644. border: none;
  645. border-radius: rpx(20);
  646. background: linear-gradient(
  647. 135deg,
  648. $light-color,
  649. #d8bfd8
  650. ); // 柔和的蓝紫色渐变
  651. overflow: hidden;
  652. display: flex; // 添加 flex 布局
  653. flex-direction: column; // 设置垂直布局
  654. min-height: 0; // 防止子元素溢出
  655. // 添加装饰元素
  656. &::before {
  657. content: '';
  658. position: absolute;
  659. top: 0;
  660. left: 0;
  661. width: 100%;
  662. height: rpx(10);
  663. background: linear-gradient(90deg, $secondary-color, $accent-color);
  664. }
  665. }
  666. }
  667. // 问题标题样式
  668. .question-title {
  669. padding: rpx(15);
  670. border-radius: rpx(12);
  671. margin-bottom: rpx(20);
  672. color: #483d8b;
  673. font-weight: bold;
  674. font-size: rpx(12);
  675. position: relative;
  676. display: flex;
  677. align-items: center;
  678. text-align: left;
  679. .question-icon {
  680. background-color: $accent-color;
  681. color: white;
  682. width: rpx(24);
  683. height: rpx(24);
  684. border-radius: 50%;
  685. display: flex;
  686. align-items: center;
  687. justify-content: center;
  688. margin-right: rpx(10);
  689. font-weight: bold;
  690. box-shadow: 0 rpx(2) rpx(5) rgba($accent-color, 0.3);
  691. flex-shrink: 0; // 防止图标被压缩
  692. }
  693. }
  694. // 选项容器样式
  695. .options-container {
  696. margin-bottom: rpx(20);
  697. }
  698. // 问题选项样式
  699. .question-option {
  700. margin: rpx(8) 0;
  701. padding: rpx(10) rpx(15);
  702. border-radius: rpx(12);
  703. border: rpx(1) solid rgba($primary-color, 0.3);
  704. transition: all 0.3s ease;
  705. display: flex;
  706. align-items: center;
  707. background-color: white;
  708. box-shadow: 0 rpx(2) rpx(5) rgba($primary-color, 0.05);
  709. cursor: pointer; // 鼠标指针变为手型
  710. // 确保整个区域可点击
  711. &:active {
  712. transform: translateY(0);
  713. }
  714. ::v-deep(.el-radio) {
  715. width: 100%;
  716. display: flex;
  717. align-items: center;
  718. cursor: pointer;
  719. }
  720. ::v-deep(.el-radio__label) {
  721. color: $text-color;
  722. margin-left: rpx(8);
  723. flex: 1;
  724. text-align: left;
  725. font-size: rpx(12);
  726. }
  727. // 增强选中时的样式变化
  728. ::v-deep(.el-radio__input.is-checked) {
  729. + .el-radio__label {
  730. font-weight: bold;
  731. color: $accent-color;
  732. }
  733. // 修改选中的radio样式
  734. .el-radio__inner {
  735. border-color: $accent-color;
  736. background-color: $accent-color;
  737. }
  738. .el-radio__inner::after {
  739. background-color: white;
  740. }
  741. }
  742. // 增强悬浮效果
  743. &:hover {
  744. background-color: rgba($primary-color, 0.15);
  745. border-color: rgba($primary-color, 0.7);
  746. transform: translateY(-rpx(1));
  747. box-shadow: 0 rpx(4) rpx(10) rgba($primary-color, 0.15);
  748. }
  749. // 增强选中时的整体样式
  750. &:has(.el-radio__input.is-checked) {
  751. background-color: rgba($primary-color, 0.1);
  752. border-color: $accent-color;
  753. box-shadow: 0 rpx(3) rpx(8) rgba($accent-color, 0.2);
  754. }
  755. }
  756. // 暂无选项样式
  757. .no-options {
  758. color: rgba($text-color, 0.7);
  759. text-align: center;
  760. padding: rpx(20);
  761. font-size: rpx(12);
  762. }
  763. // 底部按钮样式
  764. .child-button {
  765. min-width: rpx(70);
  766. height: rpx(25);
  767. border-radius: rpx(8);
  768. font-size: rpx(12);
  769. font-weight: 500;
  770. transition: all 0.3s ease;
  771. box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.1);
  772. &.confirm {
  773. background: linear-gradient(to bottom, #ab81ff, #8559dc);
  774. border: none;
  775. border-right: 15px;
  776. color: white;
  777. &:hover {
  778. background: linear-gradient(
  779. to bottom,
  780. color.adjust(#ab81ff, $lightness: -5%),
  781. color.adjust(#8559dc, $lightness: -5%)
  782. );
  783. transform: translateY(-rpx(1));
  784. color: white;
  785. }
  786. }
  787. }
  788. // AI对话图标样式
  789. .ai-icon-container {
  790. position: absolute;
  791. bottom: rpx(10);
  792. right: rpx(20);
  793. display: flex;
  794. flex-direction: column;
  795. align-items: center;
  796. cursor: pointer;
  797. transition: all 0.3s ease;
  798. &:hover {
  799. transform: translateY(-rpx(2));
  800. }
  801. .ai-icon {
  802. width: rpx(30);
  803. height: rpx(30);
  804. margin-bottom: rpx(0);
  805. // filter: drop-shadow(0 rpx(2) rpx(4) rgba($primary-color, 0.3));
  806. // 添加过渡动画
  807. transition: transform 0.3s ease;
  808. }
  809. // 悬浮时放大效果
  810. .ai-icon:hover {
  811. transform: scale(1.5);
  812. }
  813. .ai-text {
  814. color: $text-color;
  815. font-size: rpx(8);
  816. background-color: rgba(255, 255, 255, 0.7);
  817. padding: rpx(2) rpx(5);
  818. border-radius: rpx(5);
  819. }
  820. }
  821. // AI对话弹框样式
  822. .ai-dialog-wrapper {
  823. position: fixed;
  824. top: 0;
  825. left: 0;
  826. right: 0;
  827. bottom: 0;
  828. display: flex;
  829. justify-content: flex-end;
  830. align-items: center;
  831. z-index: 1001;
  832. pointer-events: none;
  833. }
  834. .ai-dialog {
  835. border: none;
  836. border-radius: rpx(15);
  837. background: rgb(255, 255, 255, 0.8);
  838. overflow: hidden;
  839. padding: rpx(20);
  840. width: 30%;
  841. // 增加高度
  842. height: 80%;
  843. margin-right: rpx(50);
  844. pointer-events: auto;
  845. position: relative;
  846. display: flex;
  847. flex-direction: column;
  848. &::before {
  849. content: '';
  850. position: absolute;
  851. top: 0;
  852. left: 0;
  853. width: 100%;
  854. height: rpx(10);
  855. }
  856. }
  857. .ai-dialog-header {
  858. position: relative;
  859. display: flex;
  860. justify-content: center;
  861. align-items: center;
  862. margin-bottom: rpx(15);
  863. margin-top: rpx(-10);
  864. img {
  865. width: rpx(15);
  866. }
  867. h3 {
  868. color: black;
  869. font-size: rpx(12);
  870. }
  871. .close-btn {
  872. padding: 0;
  873. width: rpx(20);
  874. height: rpx(20);
  875. font-size: rpx(16);
  876. line-height: 1;
  877. position: absolute;
  878. background-color: transparent;
  879. border: none;
  880. margin-left: rpx(200);
  881. }
  882. }
  883. .ai-dialog-content {
  884. flex: 1;
  885. display: flex;
  886. flex-direction: column;
  887. }
  888. .ai-message-history {
  889. flex: 1;
  890. // 当内容超出容器高度时,显示垂直滚动条
  891. overflow-y: auto;
  892. margin-bottom: rpx(20);
  893. // 可以根据实际情况调整最大高度
  894. font-size: rpx(5);
  895. max-height: 50vh;
  896. .message {
  897. display: flex;
  898. align-items: flex-start;
  899. margin-bottom: rpx(10);
  900. &.user {
  901. flex-direction: row-reverse;
  902. }
  903. .avatar {
  904. width: rpx(30);
  905. height: rpx(30);
  906. border-radius: 50%;
  907. margin: 0 rpx(10);
  908. }
  909. .user {
  910. width: 15px;
  911. height: 15px;
  912. }
  913. .message-content {
  914. background-color: #ffffff;
  915. font-size: rpx(5);
  916. max-width: 80%;
  917. text-align: left;
  918. color: black;
  919. box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
  920. }
  921. }
  922. // 滚动条整体样式
  923. &::-webkit-scrollbar {
  924. width: rpx(4); // 滚动条宽度
  925. }
  926. // 滚动条滑块样式
  927. &::-webkit-scrollbar-thumb {
  928. background-color: $primary-color; // 滑块颜色
  929. border-radius: rpx(4); // 滑块圆角
  930. }
  931. // 滚动条轨道样式
  932. &::-webkit-scrollbar-track {
  933. background-color: rgba($primary-color, 0.2); // 轨道颜色
  934. border-radius: rpx(4); // 轨道圆角
  935. }
  936. .message {
  937. display: flex;
  938. align-items: flex-start;
  939. margin-bottom: rpx(10);
  940. &.user {
  941. flex-direction: row-reverse;
  942. }
  943. .avatar {
  944. width: rpx(30);
  945. height: rpx(30);
  946. border-radius: 50%;
  947. margin: 0 rpx(10);
  948. }
  949. .message-content {
  950. background-color: white;
  951. padding: rpx(8) rpx(12);
  952. border-radius: rpx(5);
  953. font-size: rpx(8);
  954. color: black;
  955. max-width: 80%;
  956. box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
  957. }
  958. }
  959. }
  960. // 终止按钮
  961. .stop-btn {
  962. cursor: pointer;
  963. display: flex;
  964. align-items: center;
  965. // padding: rpx(5);
  966. img {
  967. width: rpx(20);
  968. height: rpx(20);
  969. }
  970. }
  971. // 用户输入框样式
  972. .user-input {
  973. gap: rpx(5); // 间距
  974. ::v-deep(.el-input__wrapper) {
  975. height: rpx(23);
  976. border-radius: rpx(5);
  977. border-color: rgba($primary-color, 0.3);
  978. &:focus-within {
  979. box-shadow: 0 0 0 rpx(1) rgba($primary-color, 0.5);
  980. }
  981. }
  982. ::v-deep(.el-input__inner) {
  983. font-size: rpx(10);
  984. text-indent: 1em;
  985. }
  986. // 语音按钮样式
  987. ::v-deep(.el-input-group__prepend) {
  988. width: rpx(15);
  989. background: white;
  990. border-radius: rpx(5);
  991. text-align: center;
  992. }
  993. ::v-deep(.el-input-group__prepend .el-button.recording) {
  994. padding: rpx(5) rpx(10);
  995. background: #fff;
  996. border: none;
  997. border-radius: rpx(5);
  998. cursor: pointer;
  999. display: flex;
  1000. align-items: center;
  1001. color: #dc3545;
  1002. }
  1003. ::v-deep(.el-input-group__append) {
  1004. border: none;
  1005. background: linear-gradient(to bottom, #ab81ff, #8559dc);
  1006. border-radius: rpx(5);
  1007. color: white;
  1008. font-size: rpx(9);
  1009. border-left: none;
  1010. }
  1011. }
  1012. /* 定义淡入和缩放动画 */
  1013. .fade-scale-enter-active,
  1014. .fade-scale-leave-active {
  1015. transition: all 0.5s ease;
  1016. }
  1017. .fade-scale-enter-from,
  1018. .fade-scale-leave-to {
  1019. opacity: 0.1;
  1020. transform: scale(0.9);
  1021. }
  1022. // 自定义试题弹框背景
  1023. .child-dialog-wrapper {
  1024. position: fixed;
  1025. top: 0;
  1026. left: 0;
  1027. right: 0;
  1028. bottom: 0;
  1029. background-color: rgba(0, 0, 0, 0.6); // 半透明背景
  1030. display: flex;
  1031. justify-content: center;
  1032. align-items: center;
  1033. z-index: 1000;
  1034. }
  1035. .child-dialog {
  1036. border: none;
  1037. min-width: 40%;
  1038. border-radius: rpx(15);
  1039. background: rgb(255, 255, 255, 0.8); // 柔和的蓝紫色渐变
  1040. overflow: hidden;
  1041. padding: rpx(10);
  1042. position: relative;
  1043. }
  1044. .default-messages {
  1045. margin-top: rpx(-10);
  1046. margin-bottom: rpx(5);
  1047. }
  1048. </style>