DialogComponents.vue 28 KB

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