TextToText.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. <template>
  2. <!-- 原左侧折叠面板和右侧AI问答 -->
  3. <div class="content-wrapper">
  4. <div class="people-image">
  5. <div class="selected-image">
  6. <img :src="selectedImage" alt="" />
  7. </div>
  8. </div>
  9. <!-- 右侧AI问答 -->
  10. <div class="number-people">
  11. <div class="content-box">
  12. <!-- AI对话框 -->
  13. <div class="chat-dialog">
  14. <!-- 对话消息列表 -->
  15. <div class="message-list" ref="messageListRef" @scroll="handleScroll">
  16. <div v-for="(item, index) in messageList" :key="index">
  17. <!-- AI消息 -->
  18. <div class="ai-message" v-if="item.type !== 'user'">
  19. <MarkdownView class="left-text" :content="item.content" />
  20. <!-- {{item.content}} -->
  21. </div>
  22. <!-- 用户消息 -->
  23. <div class="user-message" v-if="item.type === 'user'">
  24. {{ item.content }}
  25. </div>
  26. </div>
  27. </div>
  28. <!-- 默认消息 -->
  29. <DefaultMessage
  30. v-if="showDefaultMessages"
  31. @select-message="handleDefaultMessageSelect"
  32. :category="route.query.category"
  33. :quest-tip="route.query.default"
  34. />
  35. <!-- 输入框和发送按钮 -->
  36. <div class="input-section">
  37. <input
  38. type="text"
  39. v-model="prompt"
  40. placeholder="问我任何问题..."
  41. @keyup.enter="handleSendByKeydown"
  42. />
  43. <!-- 语音输入按钮 -->
  44. <button
  45. @click="toggleSpeechInput"
  46. class="speech-btn"
  47. :class="{ 'recording': isRecording }"
  48. >
  49. <el-icon v-if="!isRecording"><Microphone /></el-icon>
  50. <el-icon v-else><Mute /></el-icon>
  51. <!-- 显示倒计时(仅录音时显示) -->
  52. <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
  53. </button>
  54. <!-- 终止问答按钮 -->
  55. <div
  56. v-if="conversationInProgress"
  57. @click="stopStream"
  58. class="stop-btn"
  59. title="终止问答"
  60. >
  61. <img :src="stopicon" alt="停止" />
  62. </div>
  63. <button
  64. v-if="!conversationInProgress"
  65. @click="handleSendByButton"
  66. >
  67. 发送
  68. </button>
  69. </div>
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. </template>
  75. <script setup>
  76. import { ref, onMounted,onUnmounted, computed, watch, nextTick } from "vue";
  77. import { CreateDialogue, sendChatMessageStream } from "@/api/questions.js";
  78. import { useRouter, useRoute } from "vue-router";
  79. import { saveRecord } from "@/api/personalized/index.js";
  80. import { teacherList } from '@/api/teachers.js'
  81. // 导入全局状态
  82. import { globalState } from "@/utils/globalState.js";
  83. // 终止按钮
  84. import stopicon from "@/assets/icon/stopicon.png";
  85. import MarkdownView from "@/components/MarkdownView/index.vue";
  86. import {
  87. Document,
  88. Menu as IconMenu,
  89. Location,
  90. Setting,
  91. ArrowLeftBold,
  92. MagicStick,
  93. ChatLineRound,
  94. Fold,
  95. Expand,
  96. Picture,
  97. Tickets,
  98. User,
  99. Search, // 使用Search图标作为替代
  100. } from "@element-plus/icons-vue";
  101. import DefaultMessage from "@/components/DefaultMessage/index.vue";
  102. // 语音图标
  103. import { Microphone, Mute } from "@element-plus/icons-vue";
  104. import LeftPanel from "@/components/LeftPanel.vue";
  105. const leftPanelRef = ref(null);
  106. // 定义props
  107. const props = defineProps({
  108. personId: { type: Number},
  109. personName: { type: String },
  110. personImage: { type: String},
  111. personIntroduce: { type: String},
  112. })
  113. // 语音输入响应式变量
  114. const isRecording = ref(false); // 录音状态
  115. const recognition = ref(null); // 语音识别实例
  116. const countdown = ref(0); // 倒计时剩余秒数
  117. const countdownTimer = ref(null); // 倒计时定时器
  118. // 默认消息控制
  119. const showDefaultMessages = ref(true);
  120. const handleDefaultMessageSelect = (message) => {
  121. prompt.value = message;
  122. handleSendByButton();
  123. showDefaultMessages.value = false;
  124. };
  125. // 抽屉显示状态
  126. const drawerVisible = ref(true);
  127. // 添加切换抽屉显示状态的函数
  128. const toggleDrawer = () => {
  129. drawerVisible.value = !drawerVisible.value;
  130. };
  131. // 处理菜单展开和关闭
  132. const handleOpen = () => {};
  133. const handleClose = () => {};
  134. // 返回上一页
  135. const goBack = () => {
  136. // 停止语音播放
  137. stopPlayback();
  138. router.push("/ai-laboratory");
  139. };
  140. const router = useRouter();
  141. const route = useRoute();
  142. const personId = ref(props.personId);
  143. const personName = ref(props.personName);
  144. const personImage = ref(props.personImage);
  145. const personIntroduce = ref(props.personIntroduce);
  146. // 渲染实验室携带的人物形象图片
  147. const selectedImage = ref("");
  148. // 聊天对话
  149. const activeConversationModelPath = ref(null); // 选中的对话编号
  150. const activeConversationId = ref(null); // 选中的对话编号
  151. const activeConversation = ref(null); // 选中的 Conversation
  152. const conversationInProgress = ref(false); // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作,导致 stream 中断
  153. // 消息列表
  154. const messageRef = ref();
  155. const activeMessageList = ref([]); // 选中对话的消息列表
  156. const activeMessageListLoading = ref(false); // activeMessageList 是否正在加载中
  157. const activeMessageListLoadingTimer = ref(); // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
  158. // 消息滚动
  159. const textSpeed = ref(50); // Typing speed in milliseconds
  160. const textRoleRunning = ref(false); // Typing speed in milliseconds
  161. // 发送消息输入框
  162. const isComposing = ref(false); // 判断用户是否在输入
  163. const conversationInAbortController = ref(); // 对话进行中 abort 控制器(控制 stream 对话)
  164. const inputTimeout = ref(); // 处理输入中回车的定时器
  165. const prompt = ref(""); // prompt
  166. const enableContext = ref(true); // 是否开启上下文
  167. // 接收 Stream 消息
  168. const receiveMessageFullText = ref("");
  169. const receiveMessageDisplayedText = ref("");
  170. const messageListRef = ref(null);
  171. const userScrolled = ref(false)//是否用户手动滚动
  172. // =========== 【聊天对话】相关 ===========
  173. /** 获取对话信息 */
  174. const getConversation = async (id) => {
  175. if (!id) {
  176. return;
  177. }
  178. const conversation = ref({});
  179. if (!conversation) {
  180. return;
  181. }
  182. conversation.systemMessage = personIntroduce.value;
  183. activeConversation.value = conversation;
  184. // activeConversationId.value = personId.value
  185. activeConversationModelPath.value = personImage.value;
  186. };
  187. // =========== 【语音录入】相关 ===========
  188. // 初始化语音识别
  189. const initSpeechRecognition = () => {
  190. const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  191. if (!SpeechRecognition) {
  192. alert("当前浏览器不支持语音输入功能");
  193. return null;
  194. }
  195. const instance = new SpeechRecognition();
  196. instance.lang = 'zh-CN';
  197. instance.interimResults = false;
  198. instance.onresult = (event) => {
  199. if (event.results?.[0]?.[0]) {
  200. prompt.value += event.results[0][0].transcript;
  201. }
  202. };
  203. //识别器结束时清除定时器
  204. instance.onend = () => {
  205. clearInterval(countdownTimer.value);
  206. isRecording.value = false;
  207. countdown.value = 0;
  208. };
  209. instance.onerror = (event) => {
  210. console.error('语音识别错误:', event.error);
  211. clearInterval(countdownTimer.value); // 出错时清除定时器
  212. isRecording.value = false;
  213. Message().error('语音输入失败,请重试!', true)
  214. countdown.value = 0;
  215. };
  216. return instance;
  217. };
  218. // 切换录音状态
  219. const toggleSpeechInput = () => {
  220. // 无论当前状态如何,先清除可能存在的旧定时器
  221. clearInterval(countdownTimer.value);
  222. countdownTimer.value = null;
  223. if (isRecording.value) {
  224. // 手动停止时重置状态
  225. countdown.value = 0;
  226. recognition.value?.stop();
  227. isRecording.value = false;
  228. } else {
  229. // 初始化倒计时前再次清除定时器(防止快速点击)
  230. clearInterval(countdownTimer.value);
  231. countdown.value = 10; // 重置为10秒
  232. recognition.value = initSpeechRecognition();
  233. if (!recognition.value) return;
  234. navigator.mediaDevices.getUserMedia({ audio: true })
  235. .then(() => {
  236. recognition.value.start();
  237. isRecording.value = true;
  238. // 启动新的倒计时定时器
  239. countdownTimer.value = setInterval(() => {
  240. countdown.value--;
  241. if (countdown.value <= 0) {
  242. clearInterval(countdownTimer.value); // 倒计时结束清除
  243. recognition.value.stop();
  244. isRecording.value = false;
  245. countdown.value = 0;
  246. }
  247. }, 1000);
  248. })
  249. .catch((err) => {
  250. console.error("麦克风权限获取失败:", err);
  251. alert("请允许麦克风权限以使用语音输入");
  252. // 出错时重置状态
  253. isRecording.value = false;
  254. countdown.value = 0;
  255. });
  256. }
  257. };
  258. // =========== 【聊天对话】相关 ===========
  259. /** 处理来自 keydown 的发送消息 */
  260. const handleSendByKeydown = async (event) => {
  261. // 判断用户是否在输入
  262. if (isComposing.value) {
  263. return;
  264. }
  265. // 进行中不允许发送
  266. if (conversationInProgress.value) {
  267. return;
  268. }
  269. const content = prompt.value?.trim();
  270. if (event.key === "Enter") {
  271. if (event.shiftKey) {
  272. // 插入换行
  273. prompt.value += "\r\n";
  274. event.preventDefault(); // 防止默认的换行行为
  275. } else {
  276. // 发送消息
  277. await doSendMessage(content);
  278. event.preventDefault(); // 防止默认的提交行为
  279. }
  280. }
  281. };
  282. /** 处理来自【发送】按钮的发送消息 */
  283. const handleSendByButton = () => {
  284. doSendMessage(prompt.value?.trim());
  285. };
  286. /** 处理 prompt 输入变化 */
  287. const handlePromptInput = (event) => {
  288. // 非输入法 输入设置为 true
  289. if (!isComposing.value) {
  290. // 回车 event data 是 null
  291. if (event.data == null) {
  292. return;
  293. }
  294. isComposing.value = true;
  295. }
  296. // 清理定时器
  297. if (inputTimeout.value) {
  298. clearTimeout(inputTimeout.value);
  299. }
  300. // 重置定时器
  301. inputTimeout.value = setTimeout(() => {
  302. isComposing.value = false;
  303. }, 400);
  304. };
  305. // TODO注:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
  306. const onCompositionstart = () => {
  307. isComposing.value = true;
  308. };
  309. const onCompositionend = () => {
  310. setTimeout(() => {
  311. isComposing.value = false;
  312. }, 200);
  313. };
  314. // 保存记录
  315. // 年级ID相关
  316. const gradeId = ref("");
  317. // 添加消息计数器变量
  318. const messageCount = ref(0);
  319. /** 真正执行【发送】消息操作 */
  320. const doSendMessage = async (content) => {
  321. // 校验
  322. if (content.length < 1) {
  323. console.error("发送失败,原因:内容为空!");
  324. return;
  325. }
  326. if (activeConversationId.value == null) {
  327. console.error("还没创建对话,不能发送!");
  328. return;
  329. }
  330. // 递增消息计数器
  331. messageCount.value++;
  332. // 发送saveRecord请求 保存消息次数
  333. try {
  334. await saveRecord({
  335. brpNjId: gradeId.value,
  336. brpType: "aiCount",
  337. brpProgress: messageCount.value,
  338. });
  339. } catch (error) {
  340. console.error("保存记录失败:", error);
  341. }
  342. // 清空输入框
  343. prompt.value = "";
  344. // 执行发送
  345. await doSendMessageStream({
  346. conversationId: activeConversationId.value,
  347. content: content,
  348. });
  349. };
  350. import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
  351. import {Message} from "@/utils/message/Message.js";
  352. // 解构 stopPlayback 方法
  353. const { playAudioChunk,stopPlayback } = useAudioPlayer();
  354. /** 真正执行【发送】消息操作 */
  355. const doSendMessageStream = async (userMessage) => {
  356. // 创建 AbortController 实例,以便中止请求
  357. conversationInAbortController.value = new AbortController();
  358. // 标记对话进行中
  359. conversationInProgress.value = true;
  360. // 设置为空
  361. receiveMessageFullText.value = "";
  362. try {
  363. // 1.1 先添加两个假数据,等 stream 返回再替换
  364. activeMessageList.value.push({
  365. id: -1,
  366. conversationId: activeConversationId.value,
  367. type: "user",
  368. content: userMessage.content,
  369. createTime: new Date(),
  370. });
  371. activeMessageList.value.push({
  372. id: -2,
  373. conversationId: activeConversationId.value,
  374. type: "assistant",
  375. content: "思考中...",
  376. createTime: new Date(),
  377. });
  378. // 1.2 开始滚动
  379. textRoll();
  380. // 2. 发送 event stream
  381. let isFirstChunk = true; // 是否是第一个 chunk 消息段
  382. // 销毁语音读取
  383. stopPlayback();
  384. await sendChatMessageStream(
  385. userMessage.conversationId,
  386. userMessage.content, null,
  387. conversationInAbortController.value,
  388. enableContext.value,
  389. async (res) => {
  390. const { code, data, msg } = JSON.parse(res.data);
  391. if (code !== 0) {
  392. console.log(`对话异常! ${msg}`);
  393. return;
  394. }
  395. // 根据事件类型处理
  396. if (data.eventType === "TEXT") {
  397. // 如果内容为空,就不处理。
  398. if (data.receive?.content === "") {
  399. return;
  400. }
  401. // 处理文本消息
  402. receiveMessageFullText.value += data.receive.content;
  403. // 首次返回需要添加一个 message 到页面,后面的都是更新
  404. if (isFirstChunk) {
  405. isFirstChunk = false;
  406. // 弹出两个假数据
  407. activeMessageList.value.pop();
  408. activeMessageList.value.pop();
  409. // 更新返回的数据
  410. activeMessageList.value.push(data.send);
  411. activeMessageList.value.push(data.receive);
  412. }
  413. } else if (data.eventType === "AUDIO") {
  414. // 处理音频消息
  415. await playAudioChunk(data.audioData);
  416. }
  417. },
  418. (error) => {
  419. console.log(`对话异常! ${error}`);
  420. stopStream();
  421. // 需要抛出异常,禁止重试
  422. throw error;
  423. },
  424. () => {
  425. console.log(`结束对话! `)
  426. stopStream();
  427. }
  428. );
  429. } catch (error) {
  430. console.error('发送消息失败:', error)
  431. stopStream()
  432. }
  433. };
  434. /** 停止 stream 流式调用 */
  435. const stopStream = async () => {
  436. // tip:如果 stream 进行中的 message,就需要调用 controller 结束
  437. if (conversationInAbortController.value) {
  438. conversationInAbortController.value.abort();
  439. }
  440. // 设置为 false
  441. conversationInProgress.value = false;
  442. };
  443. /**
  444. * 消息列表
  445. *
  446. * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
  447. */
  448. const messageList = computed(() => {
  449. if (activeMessageList.value.length > 0) {
  450. return activeMessageList.value;
  451. }
  452. // 没有消息时,如果有 systemMessage 则展示它
  453. if (activeConversation.value?.systemMessage) {
  454. let systemMessage = {
  455. id: 0,
  456. type: "system",
  457. content: activeConversation.value.systemMessage,
  458. };
  459. activeMessageList.value.push(systemMessage);
  460. return [systemMessage];
  461. }
  462. return [];
  463. });
  464. // ============== 【消息滚动】相关 =============
  465. //处理滚动事件,判断用户是否手动滚动
  466. const handleScroll = () => {
  467. if (messageListRef.value) {
  468. const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
  469. // 当用户滚动距离底部超过50px时,认为是手动滚动
  470. userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
  471. }
  472. }
  473. /** 滚动到 message 底部 */
  474. const scrollToBottom = async (isIgnore = false) => {
  475. // 如果用户手动滚动过,不自动滚动
  476. if (userScrolled.value) return
  477. await nextTick();
  478. if (messageListRef.value) {
  479. requestAnimationFrame(() => {
  480. messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
  481. });
  482. }
  483. };
  484. /** 自提滚动效果 */
  485. const textRoll = async () => {
  486. let index = 0;
  487. try {
  488. // 只能执行一次
  489. if (textRoleRunning.value) {
  490. return;
  491. }
  492. // 设置状态
  493. textRoleRunning.value = true;
  494. receiveMessageDisplayedText.value = "";
  495. const task = async () => {
  496. // 调整速度
  497. const diff =
  498. (receiveMessageFullText.value.length -
  499. receiveMessageDisplayedText.value.length) /
  500. 10;
  501. if (diff > 5) {
  502. textSpeed.value = 10;
  503. } else if (diff > 2) {
  504. textSpeed.value = 30;
  505. } else if (diff > 1.5) {
  506. textSpeed.value = 50;
  507. } else {
  508. textSpeed.value = 100;
  509. }
  510. // 对话结束,就按 30 的速度
  511. if (!conversationInProgress.value) {
  512. textSpeed.value = 10;
  513. }
  514. if (index < receiveMessageFullText.value.length) {
  515. receiveMessageDisplayedText.value +=
  516. receiveMessageFullText.value[index];
  517. index++;
  518. // 更新 message
  519. const lastMessage =
  520. activeMessageList.value[activeMessageList.value.length - 1];
  521. lastMessage.content = receiveMessageDisplayedText.value;
  522. // 滚动到住下面
  523. await scrollToBottom();
  524. // 重新设置任务
  525. timer = setTimeout(task, textSpeed.value);
  526. } else {
  527. // 不是对话中可以结束
  528. if (!conversationInProgress.value) {
  529. textRoleRunning.value = false;
  530. clearTimeout(timer);
  531. } else {
  532. // 重新设置任务
  533. timer = setTimeout(task, textSpeed.value);
  534. }
  535. }
  536. };
  537. let timer = setTimeout(task, textSpeed.value);
  538. } catch {}
  539. };
  540. // 监听消息列表变化,自动滚动到底部
  541. watch(
  542. () => messageList.value,
  543. () => {
  544. scrollToBottom();
  545. },
  546. { deep: true }
  547. );
  548. /** 初始化 **/
  549. onMounted(async () => {
  550. // 从全局状态初始化年级ID
  551. gradeId.value = globalState.initGradeId();
  552. //默认加载没传递数字人表示的话---默认加载小智
  553. if (!personId.value) {
  554. let grade = route.query.grade || localStorage.getItem('selectedGrade')
  555. grade = "小学低年级"
  556. // 获取小学低年级AI数据
  557. const juniorAIRes = await teacherList({category: grade + 'AI'})
  558. const aiPerson = juniorAIRes.data.list.find(
  559. person => person.name === '小智'
  560. )
  561. if (aiPerson) {
  562. personId.value = aiPerson.id;
  563. personName.value = aiPerson.name;
  564. personImage.value = aiPerson.model2dPath;
  565. personIntroduce.value = aiPerson.systemMessage;
  566. selectedImage.value = personImage.value;
  567. }
  568. }
  569. // 智能问答
  570. CreateDialogue({ roleId: personId.value })
  571. .then((res) => {
  572. console.log("创建会话:", res);
  573. activeConversationId.value = res.data;
  574. })
  575. .catch((error) => {
  576. console.error("请求出错:", error);
  577. });
  578. await getConversation(personId.value);
  579. // 获取列表数据
  580. // activeMessageListLoading.value = true
  581. });
  582. // 路由参数变化监听
  583. watch(
  584. () => route.query,
  585. (newQuery, oldQuery) => {
  586. // 只有当id变化时才更新数据,避免不必要的刷新
  587. if (newQuery.id && newQuery.id !== oldQuery?.id) {
  588. // 停止语音播放
  589. stopPlayback();
  590. // 更新相关数据
  591. personId.value = newQuery.id;
  592. personName.value = newQuery.name;
  593. personIntroduce.value = newQuery.message;
  594. personImage.value = newQuery.image;
  595. selectedImage.value = newQuery.image;
  596. // 重新初始化对话
  597. CreateDialogue({ roleId: newQuery.id })
  598. .then((res) => {
  599. activeConversationId.value = res.data;
  600. })
  601. .catch((error) => {
  602. console.error("请求出错:", error);
  603. });
  604. getConversation(newQuery.id);
  605. // 重置消息列表和默认消息显示状态
  606. activeMessageList.value = [];
  607. showDefaultMessages.value = true;
  608. }
  609. },
  610. { immediate: true, deep: true }
  611. );
  612. // 组件卸载时清理语音资源
  613. onUnmounted(() => {
  614. stopPlayback();
  615. });
  616. </script>
  617. <style scoped lang="scss">
  618. @use "sass:math";
  619. // 定义rpx转换函数
  620. @function rpx($px) {
  621. @return math.div($px, 750) * 100vw;
  622. }
  623. .content-wrapper {
  624. display: flex;
  625. flex: 1;
  626. }
  627. .left-group {
  628. width: rpx(135);
  629. height: 100%;
  630. background: linear-gradient(to bottom, #001169, #8a78d0);
  631. }
  632. .mb-2 {
  633. color: black;
  634. margin-top: rpx(1);
  635. }
  636. .tac ::v-deep(.el-menu) {
  637. background-color: transparent;
  638. border: none;
  639. width: 100%;
  640. margin-top: rpx(55);
  641. margin-left: rpx(10);
  642. }
  643. .el-menu-item {
  644. width: rpx(115);
  645. height: rpx(25);
  646. margin-bottom: rpx(5);
  647. border-radius: rpx(6);
  648. color: white;
  649. font-size: rpx(8);
  650. }
  651. .el-menu-item .el-icon svg {
  652. font-size: rpx(15);
  653. color: white;
  654. }
  655. .el-menu ::v-deep(.el-menu-item:hover),
  656. .el-menu ::v-deep(.el-menu-item:focus),
  657. .el-menu ::v-deep(.el-menu-item:active) {
  658. background: linear-gradient(
  659. to bottom,
  660. #ffefb0,
  661. #ffcc00
  662. ); /* 设置悬停、聚焦、点击状态下的背景色 */
  663. box-shadow: 0 8px 8px rgb(0, 0, 0, 0.3);
  664. color: black;
  665. font-size: rpx(8);
  666. }
  667. .el-menu .el-menu-item.is-active {
  668. background: linear-gradient(to bottom, #fee78a, #ffce1b);
  669. color: black;
  670. font-size: rpx(8);
  671. box-shadow: 0 4px 8px rgba(3, 3, 3, 0.3);
  672. }
  673. // 侧边栏
  674. .people-image {
  675. width: rpx(130);
  676. height: 100%;
  677. display: flex;
  678. background-color: #ece9fd;
  679. overflow: hidden;
  680. }
  681. .people-image img {
  682. width: rpx(120);
  683. height: auto;
  684. object-fit: contain;
  685. }
  686. .selected-image {
  687. flex: 1;
  688. margin: auto;
  689. display: flex;
  690. justify-content: center;
  691. align-items: center;
  692. }
  693. .title-box {
  694. height: rpx(50);
  695. }
  696. .box-icon {
  697. width: 100%;
  698. height: 100%;
  699. flex: 1;
  700. display: flex; // 添加 flex 布局
  701. align-items: center; // 垂直居中
  702. color: black; // 设置图标颜色为白色
  703. padding-left: rpx(15);
  704. font-size: rpx(10); // 设置图标大小,可按需调整
  705. cursor: pointer; // 添加鼠标指针样式
  706. }
  707. .box-icon .left-icon {
  708. margin-left: rpx(10);
  709. margin-right: rpx(5); // 设置图标和文字之间的间距 ;
  710. }
  711. .number-people {
  712. flex: 1;
  713. height: 100%;
  714. display: flex;
  715. background-color: #ece9fd;
  716. }
  717. .content-box {
  718. flex: 1;
  719. // margin-top: rpx(10);
  720. // margin-bottom: rpx(10);
  721. margin: rpx(7);
  722. border-radius: rpx(15);
  723. background: rgba($color: #ffffff, $alpha: 0.5);
  724. }
  725. // 对话框
  726. .chat-dialog {
  727. display: flex;
  728. flex-direction: column;
  729. height: 100%;
  730. }
  731. .message-list {
  732. flex: 1;
  733. overflow-y: auto;
  734. padding: rpx(15);
  735. }
  736. /* 自定义滚动条样式 */
  737. .message-list::-webkit-scrollbar {
  738. width: rpx(2); /* 滚动条宽度 */
  739. }
  740. .message-list::-webkit-scrollbar-track {
  741. background: #f1effd; /* 滚动条轨道背景色 */
  742. border-radius: rpx(4);
  743. }
  744. .message-list::-webkit-scrollbar-thumb {
  745. background: #e2ddfc; /* 滚动条滑块颜色 */
  746. border-radius: rpx(4);
  747. }
  748. .message-list::-webkit-scrollbar-thumb:hover {
  749. background: #e2ddfc; /* 滚动条滑块 hover 状态颜色 */
  750. }
  751. .message-list .user-message {
  752. background-color: #ffffff;
  753. margin-left: auto; // 消息靠右显示
  754. margin-right: 0; // 重置右边距
  755. margin-bottom: rpx(10);
  756. max-width: rpx(400);
  757. font-size: rpx(8);
  758. width: fit-content; // 宽度随文字内容变化
  759. border-radius: rpx(5);
  760. padding: rpx(5);
  761. text-align: left; // 文字左对齐
  762. }
  763. .message-list .ai-message {
  764. background-color: #ffdd55;
  765. margin-left: 0; // 消息靠左显示
  766. margin-right: auto; // 重置右边距
  767. margin-bottom: rpx(10);
  768. width: fit-content;
  769. max-width: rpx(400);
  770. padding: rpx(5);
  771. font-size: rpx(8);
  772. border-radius: rpx(5);
  773. text-align: left; // 文字左对齐
  774. }
  775. .input-section {
  776. display: flex;
  777. padding: rpx(10);
  778. gap: rpx(5);
  779. .speech-btn {
  780. padding: rpx(5) rpx(10);
  781. background: #fff;
  782. border: 1px solid #ffce1b;
  783. border-radius: rpx(5);
  784. cursor: pointer;
  785. display: flex;
  786. align-items: center;
  787. &.recording {
  788. background: #ffeeba;
  789. border-color: #ffc107;
  790. .el-icon {
  791. color: #dc3545;
  792. }
  793. }
  794. .el-icon {
  795. font-size: rpx(8);
  796. color: #666;
  797. }
  798. }
  799. // 终止按钮样式
  800. .stop-btn {
  801. cursor: pointer;
  802. display: flex;
  803. align-items: center;
  804. img {
  805. width: rpx(20);
  806. height: rpx(20);
  807. }
  808. }
  809. }
  810. .input-section input {
  811. flex: 1;
  812. padding: rpx(5);
  813. font-size: rpx(7);
  814. border: 1px solid #ccc;
  815. border-radius: rpx(5);
  816. }
  817. .input-section button {
  818. padding: rpx(5) rpx(15);
  819. background: linear-gradient(
  820. to bottom,
  821. #fee78a,
  822. #ffce1b
  823. ); /* 设置悬停、聚焦、点击状态下的背景色 */
  824. color: black;
  825. border: none;
  826. font-size: rpx(7);
  827. border-radius: rpx(5);
  828. cursor: pointer;
  829. box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
  830. }
  831. </style>