| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168 |
- <template>
- <div>
- <!-- 试题弹框 -->
- <transition name="fade-scale">
- <div
- v-show="questionDialogVisible"
- class="child-dialog-wrapper"
- @click.self="handleCloseQuestionDialog"
- >
- <div class="child-dialog">
- <div class="question-title">
- <span class="question-icon">?</span>
- <span v-html="currentQuestion.ccQuestContent"></span>
- </div>
- <!-- 选项区域 -->
- <div
- v-if="currentQuestion.ccQuestOption && currentQuestion.ccQuestOption.length > 0"
- class="options-container"
- >
- <div
- v-for="(option, index) in currentQuestion.ccQuestOption.split(',')"
- :key="index"
- class="question-option"
- >
- <el-radio
- v-model="selectedOption"
- :label="option"
- :value="option"
- >
- <span>{{ option }}</span>
- </el-radio>
- </div>
- </div>
- <div v-else class="no-options">
- <!-- 暂无选项 -->
- </div>
- <!-- 底部按钮 -->
- <div class="dialog-footer">
- <el-button
- class="child-button confirm"
- @click="handleSubmitAnswer"
- >确定</el-button
- >
- </div>
- <!-- 右侧小图标 -->
- <div
- v-if="currentQuestion.ccAiAnswer !== null"
- class="ai-icon-container"
- @click="handleAIClick"
- >
- <img
- src="@/assets/images/xiaozhi.png"
- alt="AI对话"
- class="ai-icon"
- />
- <span class="ai-text">小智智能助手</span>
- </div>
- </div>
- </div>
- </transition>
- <!-- AI对话弹框 -->
- <div
- v-show="showAIDialog"
- class="ai-dialog-wrapper"
- @click.self="showAIDialog = false"
- >
- <div class="ai-dialog">
- <div class="ai-dialog-header">
- <h3>
- <img :src="auto" alt="" />
- 小智智能助手
- </h3>
- <el-button @click="showAIDialog = false" class="close-btn"
- >×</el-button
- >
- </div>
- <div class="ai-dialog-content">
- <div class="ai-message-history" ref="messageContainer" @scroll="handleScroll">
- <div
- v-for="(message, index) in messageList"
- :key="index"
- :class="['message', message.type]"
- >
- <img
- v-if="message.type === 'user'"
- src="@/assets/images/user.png"
- class="avatar user"
- />
- <img v-else src="@/assets/images/xiaozhi.png" class="avatar" />
- <div
- class="message-content"
- v-if="message.type === 'user'"
- v-html="message.content"
- ></div>
- <div class="message-content" v-else>
- <MarkdownView class="left-text" :content="message.content" />
- </div>
- </div>
- </div>
- <!-- 弹框默认消息 -->
- <DefaultMessage
- class="default-messages"
- :category="'ai_develop'"
- :questTip="currentQuestion.ccAiQuestTip || ''"
- @select-message="handleSelectMessage"
- />
- <!-- 消息输入框 -->
- <el-input
- v-model="prompt"
- placeholder="输入问题..."
- class="user-input"
- @keyup.enter="handleSendByKeydown"
- >
- <!-- 语音输入 -->
- <template #prepend>
- <el-button
- @click="toggleSpeechInput"
- size="small"
- :class="{ 'recording': isRecording }"
- circle
- >
- <el-icon v-if="!isRecording"><Microphone /></el-icon>
- <el-icon v-else><Mute /></el-icon>
- <!-- 显示倒计时(仅录音时显示) -->
- <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
- </el-button>
- </template>
-
- <!-- 终止按钮和发送按钮条件渲染 -->
- <template #append>
- <!-- 终止问答按钮 -->
- <div
- v-if="conversationInProgress"
- @click="stopStream"
- class="stop-btn"
- title="终止问答"
- >
- <img :src="stopicon" alt="停止" />
- </div>
- <!-- 发送按钮 -->
- <el-button v-if="!conversationInProgress" @click="handleSendByButton" size="large" round
- >发送</el-button
- >
- </template>
- </el-input>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import {ref,onUnmounted, defineProps, defineEmits, onMounted, watch, nextTick} from 'vue'
- import { ElMessage } from 'element-plus'
- import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
- import { teacherList } from '@/api/teachers.js'
- import DefaultMessage from '@/components/DefaultMessage/index.vue'
- import MarkdownView from '@/components/MarkdownView/index.vue'
- import { saveRecord } from '@/api/personalized/index.js'
- // 语音图标导入
- import { Microphone, Mute } from '@element-plus/icons-vue'
- // 终止
- import stopicon from '@/assets/icon/stopicon.png'
- // 导入图标
- import auto from '@/assets/icon/auto_awesome.png'
- // 定义props
- const props = defineProps({
- componentType: { type: String, default: 'course' },
- questionDialogVisible: { type: Boolean, default: false },
- currentQuestion: { type: Object, default: () => ({}) },
- gradeId: { type: String, default: '' },
- typeId: { type: String, default: '' },
- courseId: { type: String, default: '' },
- })
- // 定义emits
- const emits = defineEmits(['closeQuestionDialog', 'submitAnswer', 'saveProgress'])
- // 内部状态
- const showAIDialog = ref(false)
- const selectedOption = ref(null)
- const messageList = ref([])
- const prompt = ref('')
- const messageContainer = ref(null)
- const userScrolled = ref(false) //是否用户手动滚动
- const xZAiData = ref({})
- const activeConversationId = ref(null)
- const conversationInProgress = ref(false)
- const conversationInAbortController = ref()
- const receiveMessageFullText = ref('')
- const isComposing = ref(false)
- const inputTimeout = ref()
- const enableContext = ref(true)
- // tts
- import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
- const { playAudioChunk , stopPlayback } = useAudioPlayer();
- // 语音输入响应式变量
- const isRecording = ref(false) // 录音状态
- const recognition = ref(null) // 语音识别实例
- const countdown = ref(0) // 倒计时剩余秒数
- const countdownTimer = ref(null) // 倒计时定时器
- // 处理选择的默认消息
- const handleSelectMessage = message => {
- prompt.value = message
- }
- // 关闭试题弹框
- const handleCloseQuestionDialog = () => {
- stopPlayback(); // 销毁语音读取
- emits('closeQuestionDialog')
- }
- // 提交答案
- const handleSubmitAnswer = () => {
- if (props.currentQuestion.ccQuestOption && props.currentQuestion.ccQuestOption.length > 0 && !selectedOption.value) {
- ElMessage.warning('请选择一个选项!')
- return
- }
- if (selectedOption.value === props.currentQuestion.ccAnswer) {
- ElMessage.success('恭喜回答正确!')
- }else{
- ElMessage.warning('回答错误!')
- }
- emits('submitAnswer', selectedOption.value === props.currentQuestion.ccAnswer)
- selectedOption.value = null
- }
- // 处理 AI 助手点击事件
- const handleAIClick = async () => {
- // 清空输入框
- messageList.value = []
- showAIDialog.value = true
- // 创建对话
- await createAiChart()
- if (props.currentQuestion.ccQuestContent) {
- // prompt.value = props.currentQuestion.ccQuestContent
- sendMessage()
- prompt.value = ''
- console.log("handleAIClick", props.currentQuestion.ccAiAnswer)
- // 执行发送
- await doSendMessageStream({
- conversationId: activeConversationId.value,
- content: props.currentQuestion.ccQuestContent,
- contentAnswer: props.currentQuestion.ccAiAnswer,
- })
- }
- }
- // 数字人接口
- const getXzAi = async () => {
- try {
- //根据组件类型(course/blockly)获取不同的年级/角色类型
- const grade = props.componentType === 'course' ? localStorage.getItem('selectedGrade') || '小学低年级' : 'Blockly-'
- // 获取AI数据
- const juniorAIRes = await teacherList({ category: grade + 'AI' })
- const aiPerson = juniorAIRes.data.list.find(
- person => person.name === '小智'
- )
- if (aiPerson) {
- xZAiData.value = {
- id: aiPerson.id,
- name: aiPerson.name,
- image: aiPerson.model2dPath,
- message: aiPerson.systemMessage,
- default: aiPerson.questTip
- }
- } else {
- console.warn('未找到名为小智的数据')
- }
- } catch (error) {
- console.error('获取年级AI数据失败:', error)
- }
- }
- //创建对话
- const createAiChart = async () => {
- // 先获取数字人接口
- await getXzAi()
- // 智能问答
- await CreateDialogue({ roleId: xZAiData.value.id })
- .then(res => {
- console.log("创建会话:", res.data);
- activeConversationId.value = res.data
- })
- .catch(error => {
- console.error('请求出错:', error)
- })
- }
-
- // 发送消息
- const sendMessage = async () => {
- if (prompt.value.trim()) {
- // 添加用户消息到历史记录
- messageList.value.push({
- type: 'user',
- content: prompt.value
- })
- // // 模拟 AI 回复
- // const aiResponse = await simulateAIResponse(prompt.value)
- // messageList.value.push({
- // type: 'ai',
- // content: aiResponse
- // })
- // 清空输入框
- prompt.value = ''
- }
- }
- // =========== 【语音录入】相关 ===========
- // 初始化语音识别
- const initSpeechRecognition = () => {
- const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
- if (!SpeechRecognition) {
- ElMessage.warning('当前浏览器不支持语音输入功能')
- return null
- }
- const instance = new SpeechRecognition()
- instance.lang = "zh-CN"
- instance.interimResults = false
- instance.onresult = (event) => {
- if (event.results?.[0]?.[0]) {
- prompt.value += event.results[0][0].transcript
- }
- }
- // 识别器结束时清除定时器
- instance.onend = () => {
- clearInterval(countdownTimer.value)
- isRecording.value = false
- countdown.value = 0
- }
- instance.onerror = (event) => {
- console.error("语音识别错误:", event.error)
- clearInterval(countdownTimer.value) // 出错时清除定时器
- isRecording.value = false
- ElMessage.error('语音输入失败,请重试')
- countdown.value = 0
- }
- return instance
- }
- // 切换录音状态
- const toggleSpeechInput = () => {
- // 无论当前状态如何,先清除可能存在的旧定时器
- clearInterval(countdownTimer.value)
- countdownTimer.value = null
- if (isRecording.value) {
- // 手动停止时重置状态
- countdown.value = 0
- recognition.value?.stop()
- isRecording.value = false
- } else {
- // 初始化倒计时前再次清除定时器(防止快速点击)
- clearInterval(countdownTimer.value)
- countdown.value = 10 // 重置为10秒
- recognition.value = initSpeechRecognition()
- if (!recognition.value) return
- navigator.mediaDevices.getUserMedia({ audio: true })
- .then(() => {
- recognition.value.start()
- isRecording.value = true
- // 启动新的倒计时定时器
- countdownTimer.value = setInterval(() => {
- countdown.value--
- if (countdown.value <= 0) {
- clearInterval(countdownTimer.value) // 倒计时结束清除
- recognition.value.stop()
- isRecording.value = false
- countdown.value = 0
- }
- }, 1000)
- })
- .catch((err) => {
- console.error("麦克风权限获取失败:", err)
- ElMessage.warning('请允许麦克风权限以使用语音输入')
- // 出错时重置状态
- isRecording.value = false
- countdown.value = 0
- })
- }
- }
- // 模拟 AI 回复
- const simulateAIResponse = question => {
- return new Promise(resolve => {
- setTimeout(() => {
- if (props.currentQuestion.ccAiAnswer) {
- resolve(props.currentQuestion.ccAiAnswer)
- return
- }
- // 若未匹配到自定义回复,给出默认回复
- resolve(`您的问题是:${question},这是 AI 的回复示例。`)
- }, 1000)
- })
- }
- /** 处理来自 keydown 的发送消息 */
- const handleSendByKeydown = async event => {
- // 判断用户是否在输入
- if (isComposing.value) {
- return
- }
- // 进行中不允许发送
- if (conversationInProgress.value) {
- return
- }
- const content = prompt.value?.trim()
- if (event.key === 'Enter') {
- if (event.shiftKey) {
- // 插入换行
- prompt.value += '\r\n'
- event.preventDefault() // 防止默认的换行行为
- } else {
- // 发送消息
- await doSendMessage(content)
- event.preventDefault() // 防止默认的提交行为
- }
- }
- }
- /** 处理来自【发送】按钮的发送消息 */
- const handleSendByButton = () => {
- doSendMessage(prompt.value?.trim())
- }
- /** 真正执行【发送】消息操作 */
- const doSendMessage = async content => {
- // 校验
- if (content.length < 1) {
- console.error('发送失败,原因:内容为空!')
- return
- }
- if (activeConversationId.value == null) {
- console.error('还没创建对话,不能发送!')
- return
- }
- // 通过emit事件通知父组件保存进度
- emits('saveProgress', "aiCount", 1)
- // 清空输入框
- prompt.value = ''
- // 执行发送
- await doSendMessageStream({
- conversationId: activeConversationId.value,
- content: content,
- contentAnswer: null,
- })
- }
- /** 真正执行【发送】消息操作 */
- const doSendMessageStream = async userMessage => {
- // 创建 AbortController 实例,以便中止请求
- conversationInAbortController.value = new AbortController()
- // 标记对话进行中
- conversationInProgress.value = true
- // 设置为空
- receiveMessageFullText.value = ''
- try {
- // 1.1 先添加两个假数据,等 stream 返回再替换
- messageList.value.push({
- id: -1,
- conversationId: activeConversationId.value,
- type: 'user',
- content: userMessage.content,
- createTime: new Date()
- })
- messageList.value.push({
- id: -2,
- conversationId: activeConversationId.value,
- type: 'assistant',
- content: '思考中...',
- createTime: new Date()
- })
- // 销毁语音读取
- stopPlayback();
- // 2. 发送 event stream
- let isFirstChunk = true // 是否是第一个 chunk 消息段
- console.log("doSendMessageStream-userMessage", userMessage)
- await sendChatMessageStream(
- userMessage.conversationId,
- userMessage.content,
- userMessage.contentAnswer,
- conversationInAbortController.value,
- enableContext.value,
- async res => {
- const { code, data, msg } = JSON.parse(res.data)
- if (code !== 0) {
- console.log(`对话异常! ${msg}`)
- return
- }
- if (data.eventType === 'TEXT') {
- // 如果内容为空,就不处理。
- if (data.receive?.content === '') {
- return
- }
- receiveMessageFullText.value += data.receive.content
- // 首次返回需要添加一个 message 到页面,后面的都是更新
- if (isFirstChunk) {
- isFirstChunk = false
- // 弹出两个假数据
- messageList.value.pop()
- messageList.value.pop()
- // 更新返回的数据
- messageList.value.push(data.send)
- messageList.value.push(data.receive)
- } else {
- //更新最后一条消息
- if (messageList.value.length > 0) {
- const lastMessage = messageList.value[messageList.value.length - 1]
- if (lastMessage.id === data.receive.id) {
- lastMessage.content = receiveMessageFullText.value
- }
- }
- }
- }
- if (data.eventType === 'AUDIO') {
- // 处理音频消息
- await playAudioChunk(data.audioData);
- }
- // 确保触发滚动
- scrollToBottom()
- },
- error => {
- console.log(`对话异常! ${error}`)
- stopStream()
- // 需要抛出异常,禁止重试
- throw error
- },
- () => {
- console.log(`结束对话! `)
- stopStream()
- }
- )
- } catch (error) {
- console.error('发送消息失败:', error)
- stopStream()
- }
- }
- /** 停止 stream 流式调用 */
- const stopStream = async () => {
- // 如果 stream 进行中的 message,就需要调用 controller 结束
- if (conversationInAbortController.value) {
- conversationInAbortController.value.abort()
- }
- // 销毁语音读取
- // stopPlayback();
- // 设置为 false
- conversationInProgress.value = false
- console.log(`结束对话!更改状态: `,conversationInProgress.value)
- }
- /** 处理 prompt 输入变化 */
- const handlePromptInput = event => {
- // 非输入法 输入设置为 true
- if (!isComposing.value) {
- // 回车 event data 是 null
- if (event.data == null) {
- return
- }
- isComposing.value = true
- }
- // 清理定时器
- if (inputTimeout.value) {
- clearTimeout(inputTimeout.value)
- }
- // 重置定时器
- inputTimeout.value = setTimeout(() => {
- isComposing.value = false
- }, 400)
- }
- const onCompositionstart = () => {
- isComposing.value = true
- }
- const onCompositionend = () => {
- setTimeout(() => {
- isComposing.value = false
- }, 200)
- }
- // 监听props变化
- watch(() => props.questionDialogVisible, (newVal) => {
- if (newVal && props.currentQuestion) {
- // 重置选项
- selectedOption.value = null
- }
- })
- // 监听showAIDialog变化,在关闭时销毁语音读取
- watch(() => showAIDialog.value, (newVal) => {
- if (!newVal) {
- stopPlayback();
- }
- })
- // 监听消息列表变化,自动滚动到底部
- watch(messageList, () => {
- scrollToBottom()
- }, { deep: true })
- //处理滚动事件,判断用户是否手动滚动
- const handleScroll = () => {
- if (messageContainer.value) {
- const { scrollTop, scrollHeight, clientHeight } = messageContainer.value
- // 当用户滚动距离底部超过50px时,认为是手动滚动
- userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
- }
- }
- // 单独的滚动到底部函数
- const scrollToBottom = () => {
- // 如果用户手动滚动过,不自动滚动
- if (userScrolled.value) return
- nextTick(() => {
- if (messageContainer.value) {
- // 强制重排以确保获取最新高度
- messageContainer.value.scrollTop = messageContainer.value.scrollHeight
- // 双重保险:使用requestAnimationFrame确保在浏览器重绘后执行
- requestAnimationFrame(() => {
- messageContainer.value.scrollTop = messageContainer.value.scrollHeight
- })
- }
- })
- }
- onMounted(() => {
- // 初始化
- })
- // 组件卸载时清理语音资源
- onUnmounted(() => {
- stopPlayback();
- // 确保在组件卸载时也停止 SSE 流
- if (conversationInProgress.value) {
- stopStream();
- }
- });
- </script>
- <style scoped lang="scss">
- @use 'sass:math';
- @use 'sass:color'; // 引入 color 模块
- // 定义rpx转换函数
- @function rpx($px) {
- @return math.div($px, 750) * 100vw;
- }
- // 定义儿童风格的蓝紫色调
- $primary-color: rgba(106, 90, 205, 0.52); // 主色调:蓝紫色
- $secondary-color: rgba(147, 112, 219, 0.66); // 辅助色:亮蓝紫色
- $accent-color: rgb(133, 89, 220); // 强调色:暗蓝紫色
- $light-color: #ffffff; // 浅色背景:淡紫色
- $text-color: #483d8b; // 文本颜色:靛蓝色
- // 儿童风格试题弹框样式
- .child-dialog {
- .el-dialog__header {
- display: none; // 隐藏原有的标题栏
- }
- .el-dialog__body {
- padding: rpx(20);
- position: relative;
- }
- .el-dialog__footer {
- border-top: none;
- padding: rpx(10) rpx(20);
- text-align: center;
- margin-top: auto; // 使底部按钮位于底部
- }
- .el-dialog__wrapper {
- // 修改半透明背景色
- background-color: rgba(0, 0, 0, 0.6);
- }
- .el-dialog {
- border: none;
- border-radius: rpx(20);
- background: linear-gradient(
- 135deg,
- $light-color,
- #d8bfd8
- ); // 柔和的蓝紫色渐变
- overflow: hidden;
- display: flex; // 添加 flex 布局
- flex-direction: column; // 设置垂直布局
- min-height: 0; // 防止子元素溢出
- // 添加装饰元素
- &::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: rpx(10);
- background: linear-gradient(90deg, $secondary-color, $accent-color);
- }
- }
- }
- // 问题标题样式
- .question-title {
- padding: rpx(15);
- border-radius: rpx(12);
- margin-bottom: rpx(20);
- color: #483d8b;
- font-weight: bold;
- font-size: rpx(12);
- position: relative;
- display: flex;
- align-items: center;
- text-align: left;
- .question-icon {
- background-color: $accent-color;
- color: white;
- width: rpx(24);
- height: rpx(24);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: rpx(10);
- font-weight: bold;
- box-shadow: 0 rpx(2) rpx(5) rgba($accent-color, 0.3);
- flex-shrink: 0; // 防止图标被压缩
- }
- }
- // 选项容器样式
- .options-container {
- margin-bottom: rpx(20);
- }
- // 问题选项样式
- .question-option {
- margin: rpx(8) 0;
- padding: rpx(10) rpx(15);
- border-radius: rpx(12);
- border: rpx(1) solid rgba($primary-color, 0.3);
- transition: all 0.3s ease;
- display: flex;
- align-items: center;
- background-color: white;
- box-shadow: 0 rpx(2) rpx(5) rgba($primary-color, 0.05);
- cursor: pointer; // 鼠标指针变为手型
-
- // 确保整个区域可点击
- &:active {
- transform: translateY(0);
- }
- ::v-deep(.el-radio) {
- width: 100%;
- display: flex;
- align-items: center;
- cursor: pointer;
- }
- ::v-deep(.el-radio__label) {
- color: $text-color;
- margin-left: rpx(8);
- flex: 1;
- text-align: left;
- font-size: rpx(12);
- }
- // 增强选中时的样式变化
- ::v-deep(.el-radio__input.is-checked) {
- + .el-radio__label {
- font-weight: bold;
- color: $accent-color;
- }
-
- // 修改选中的radio样式
- .el-radio__inner {
- border-color: $accent-color;
- background-color: $accent-color;
- }
-
- .el-radio__inner::after {
- background-color: white;
- }
- }
- // 增强悬浮效果
- &:hover {
- background-color: rgba($primary-color, 0.15);
- border-color: rgba($primary-color, 0.7);
- transform: translateY(-rpx(1));
- box-shadow: 0 rpx(4) rpx(10) rgba($primary-color, 0.15);
- }
-
- // 增强选中时的整体样式
- &:has(.el-radio__input.is-checked) {
- background-color: rgba($primary-color, 0.1);
- border-color: $accent-color;
- box-shadow: 0 rpx(3) rpx(8) rgba($accent-color, 0.2);
- }
- }
- // 暂无选项样式
- .no-options {
- color: rgba($text-color, 0.7);
- text-align: center;
- padding: rpx(20);
- font-size: rpx(12);
- }
- // 底部按钮样式
- .child-button {
- min-width: rpx(70);
- height: rpx(25);
- border-radius: rpx(8);
- font-size: rpx(12);
- font-weight: 500;
- transition: all 0.3s ease;
- box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.1);
- &.confirm {
- background: linear-gradient(to bottom, #ab81ff, #8559dc);
- border: none;
- border-right: 15px;
- color: white;
- &:hover {
- background: linear-gradient(
- to bottom,
- color.adjust(#ab81ff, $lightness: -5%),
- color.adjust(#8559dc, $lightness: -5%)
- );
- transform: translateY(-rpx(1));
- color: white;
- }
- }
- }
- // AI对话图标样式
- .ai-icon-container {
- position: absolute;
- bottom: rpx(10);
- right: rpx(20);
- display: flex;
- flex-direction: column;
- align-items: center;
- cursor: pointer;
- transition: all 0.3s ease;
- &:hover {
- transform: translateY(-rpx(2));
- }
- .ai-icon {
- width: rpx(30);
- height: rpx(30);
- margin-bottom: rpx(0);
- // filter: drop-shadow(0 rpx(2) rpx(4) rgba($primary-color, 0.3));
- // 添加过渡动画
- transition: transform 0.3s ease;
- }
- // 悬浮时放大效果
- .ai-icon:hover {
- transform: scale(1.5);
- }
- .ai-text {
- color: $text-color;
- font-size: rpx(8);
- background-color: rgba(255, 255, 255, 0.7);
- padding: rpx(2) rpx(5);
- border-radius: rpx(5);
- }
- }
- // AI对话弹框样式
- .ai-dialog-wrapper {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- justify-content: flex-end;
- align-items: center;
- z-index: 1001;
- pointer-events: none;
- }
- .ai-dialog {
- border: none;
- border-radius: rpx(15);
- background: rgb(255, 255, 255, 0.8);
- overflow: hidden;
- padding: rpx(20);
- width: 30%;
- // 增加高度
- height: 80%;
- margin-right: rpx(50);
- pointer-events: auto;
- position: relative;
- display: flex;
- flex-direction: column;
- &::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: rpx(10);
- }
- }
- .ai-dialog-header {
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
- margin-bottom: rpx(15);
- margin-top: rpx(-10);
- img {
- width: rpx(15);
- }
- h3 {
- color: black;
- font-size: rpx(12);
- }
- .close-btn {
- padding: 0;
- width: rpx(20);
- height: rpx(20);
- font-size: rpx(16);
- line-height: 1;
- position: absolute;
- background-color: transparent;
- border: none;
- margin-left: rpx(200);
- }
- }
- .ai-dialog-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- }
- .ai-message-history {
- flex: 1;
- // 当内容超出容器高度时,显示垂直滚动条
- overflow-y: auto;
- margin-bottom: rpx(20);
- // 可以根据实际情况调整最大高度
- font-size: rpx(5);
- max-height: 50vh;
- .message {
- display: flex;
- align-items: flex-start;
- margin-bottom: rpx(10);
- &.user {
- flex-direction: row-reverse;
- }
- .avatar {
- width: rpx(30);
- height: rpx(30);
- border-radius: 50%;
- margin: 0 rpx(10);
- }
- .user {
- width: 15px;
- height: 15px;
- }
- .message-content {
- background-color: #ffffff;
- font-size: rpx(5);
- max-width: 80%;
- text-align: left;
- color: black;
- box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
- }
- }
- // 滚动条整体样式
- &::-webkit-scrollbar {
- width: rpx(4); // 滚动条宽度
- }
- // 滚动条滑块样式
- &::-webkit-scrollbar-thumb {
- background-color: $primary-color; // 滑块颜色
- border-radius: rpx(4); // 滑块圆角
- }
- // 滚动条轨道样式
- &::-webkit-scrollbar-track {
- background-color: rgba($primary-color, 0.2); // 轨道颜色
- border-radius: rpx(4); // 轨道圆角
- }
- .message {
- display: flex;
- align-items: flex-start;
- margin-bottom: rpx(10);
- &.user {
- flex-direction: row-reverse;
- }
- .avatar {
- width: rpx(30);
- height: rpx(30);
- border-radius: 50%;
- margin: 0 rpx(10);
- }
- .message-content {
- background-color: white;
- padding: rpx(8) rpx(12);
- border-radius: rpx(5);
- font-size: rpx(8);
- color: black;
- max-width: 80%;
- box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
- }
- }
- }
- // 终止按钮
- .stop-btn {
- cursor: pointer;
- display: flex;
- align-items: center;
- // padding: rpx(5);
- img {
- width: rpx(20);
- height: rpx(20);
- }
- }
- // 用户输入框样式
- .user-input {
- gap: rpx(5); // 间距
- ::v-deep(.el-input__wrapper) {
- height: rpx(23);
- border-radius: rpx(5);
- border-color: rgba($primary-color, 0.3);
- &:focus-within {
- box-shadow: 0 0 0 rpx(1) rgba($primary-color, 0.5);
- }
- }
- ::v-deep(.el-input__inner) {
- font-size: rpx(10);
- text-indent: 1em;
- }
-
- // 语音按钮样式
- ::v-deep(.el-input-group__prepend) {
- width: rpx(15);
- background: white;
- border-radius: rpx(5);
- text-align: center;
- }
-
- ::v-deep(.el-input-group__prepend .el-button.recording) {
- padding: rpx(5) rpx(10);
- background: #fff;
- border: none;
- border-radius: rpx(5);
- cursor: pointer;
- display: flex;
- align-items: center;
- color: #dc3545;
- }
-
- ::v-deep(.el-input-group__append) {
- border: none;
- background: linear-gradient(to bottom, #ab81ff, #8559dc);
- border-radius: rpx(5);
- color: white;
- font-size: rpx(9);
- border-left: none;
- }
- }
- /* 定义淡入和缩放动画 */
- .fade-scale-enter-active,
- .fade-scale-leave-active {
- transition: all 0.5s ease;
- }
- .fade-scale-enter-from,
- .fade-scale-leave-to {
- opacity: 0.1;
- transform: scale(0.9);
- }
- // 自定义试题弹框背景
- .child-dialog-wrapper {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.6); // 半透明背景
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1000;
- }
- .child-dialog {
- border: none;
- min-width: 40%;
- border-radius: rpx(15);
- background: rgb(255, 255, 255, 0.8); // 柔和的蓝紫色渐变
- overflow: hidden;
- padding: rpx(10);
- position: relative;
- }
- .default-messages {
- margin-top: rpx(-10);
- margin-bottom: rpx(5);
- }
- </style>
|