ai-qa.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804
  1. <template>
  2. <view class="ai-qa-page">
  3. <!-- 页面内静态挂载的确认弹窗,统一跨端样式与交互 -->
  4. <AppConfirm ref="AppConfirm" />
  5. <!-- 自定义导航栏,透明覆盖在顶部 -->
  6. <view class="custom-navbar" :style="{ paddingTop: statusBarPx + 'px' }">
  7. <text class="nav-title">AI 智能助手</text>
  8. <view v-if="messages.length > 0" class="clear-btn" @click="clearChat">
  9. <text class="clear-text">清空</text>
  10. </view>
  11. </view>
  12. <!-- 顶部区域 -->
  13. <view class="top-section" v-show="messages.length === 0">
  14. <image class="ai-bg" src="/static/ai_bg.png" mode="aspectFill" />
  15. <image class="ai-banner" src="/static/ai_banner.png" mode="widthFix" />
  16. </view>
  17. <!-- 聊天消息列表 -->
  18. <view class="chat-list" v-show="messages.length > 0" :style="{ paddingTop: `${statusBarPx + 88}rpx`, bottom: chatBottom }">
  19. <scroll-view scroll-y class="chat-scroll" :scroll-top="scrollTop" :scroll-into-view="scrollIntoViewId" :scroll-with-animation="scrollWithAnimation" lower-threshold="60" @scrolltolower="onScrollToLower" @scroll="onScroll">
  20. <view v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
  21. <!-- AI回复卡片 -->
  22. <view v-if="msg.role === 'assistant'" class="ai-card">
  23. <!-- AI头像和深度思考标识头部 -->
  24. <view class="ai-card-header">
  25. <view class="ai-avatar">
  26. <image src="/static/ai_avatar.png" mode="aspectFill" />
  27. </view>
  28. <view v-if="msg.isThinking" class="ai-loading-spinner" aria-label="loading"></view>
  29. <view class="thinking-header" @click="toggleThinking(index)">
  30. <text class="thinking-text" v-if="msg.isThinking || msg.hasThinking">{{ msg.isThinking ? '深度思考中…' : '已深度思考' }}</text>
  31. <view class="toggle-icon" :class="{ 'expanded': msg.showThinking }">
  32. <image class="caret-icon" src="/static/collapase_up.png" mode="widthFix" />
  33. </view>
  34. </view>
  35. </view>
  36. <!-- 回复内容 -->
  37. <view class="response-content" v-show="msg.showThinking">
  38. <!-- 思考内容(单独显示,支持Markdown) -->
  39. <view v-if="msg.hasThinking && msg.thinkContent" class="thinking-block"><rich-text :nodes="msg.thinkContentHtml"></rich-text></view>
  40. <!-- 正文内容(支持Markdown) -->
  41. <view class="answer-block"><rich-text :nodes="msg.contentHtml"></rich-text></view>
  42. </view>
  43. <!-- 复制按钮 -->
  44. <view class="copy-icon" v-show="msg.showThinking && !msg.isLoading" @click="copyContent(msg.content)">
  45. <image class="copy-icon-img" src="/static/ai_copy.png" mode="widthFix" />
  46. </view>
  47. </view>
  48. <!-- 用户消息 -->
  49. <view v-else class="user-bubble">
  50. <view class="user-content">
  51. {{msg.content}}
  52. </view>
  53. </view>
  54. </view>
  55. <!-- 底部锚点:用于自动滚动到最底部 -->
  56. <view :id="'bottom-anchor-' + anchorKey" style="height:1px;"></view>
  57. </scroll-view>
  58. </view>
  59. <!-- 底部输入框区域 -->
  60. <view class="input-bar">
  61. <view class="input-wrap" :class="{ 'chat-mode': messages.length > 0 }">
  62. <textarea
  63. class="input textarea"
  64. v-model="question"
  65. placeholder="输入你的问题"
  66. placeholder-style="color:#A3ADC2;"
  67. auto-height="true"
  68. confirm-type="send"
  69. show-confirm-bar="true"
  70. @confirm="onSend"
  71. @keyup.ctrl.enter="onSend"
  72. @keyup.meta.enter="onSend"
  73. @input="onInput"
  74. />
  75. <view class="send-btn" :class="{ 'chat-mode': messages.length > 0, 'disabled': (messages.length > 0 && !question.trim()) || isSending }" @click="onSend">
  76. <image class="send-icon" src="/static/aisend_btn_white.png" mode="widthFix" />
  77. </view>
  78. </view>
  79. </view>
  80. <tm-tabbar :permission="nowPermission" />
  81. </view>
  82. </template>
  83. <script>
  84. import { askAIStream, formatAIResponse, checkNetworkStatus } from '@/utils/ai-api.js';
  85. import { markdownToHtml } from '@/utils/markdown.js';
  86. import AppConfirm from '@/components/app-confirm/app-confirm.vue'
  87. export default {
  88. components: { AppConfirm },
  89. data() {
  90. return {
  91. nowPermission: '', // 当前权限,用于底部tab显示
  92. statusBarPx: 0, // 顶部状态栏高度
  93. question: '', // 输入的问题
  94. messages: [], // 聊天消息列表
  95. scrollTop: 0, // 滚动位置
  96. scrollIntoViewId: '', // 滚动锚点ID
  97. anchorKey: 0, // 锚点变更键,强制刷新
  98. scrollWithAnimation: false, // 默认先不开启动画,等待页面ready后再开启,避免初次抖动
  99. statusBarRpx: 0,
  100. screenWidth: 0,
  101. chatBottom: '105rpx',
  102. isSending: false, // 是否正在发送消息,防止重复发送
  103. isAutoScroll: true, // 是否自动滚动到底部
  104. lastAnchorUpdateTs: 0, // 上次锚点更新时间戳
  105. lastScrollTop: 0, // 上次滚动位置
  106. persistTimer: null // 本地持久化节流定时器
  107. }
  108. },
  109. created() {
  110. // 从缓存读取当前权限
  111. this.nowPermission = uni.getStorageSync('nowPermission');
  112. // 读取状态栏高度,保证顶部视觉延伸到状态栏
  113. const info = uni.getSystemInfoSync();
  114. this.statusBarPx = info.statusBarHeight || 0;
  115. this.screenWidth = info.screenWidth;
  116. this.statusBarRpx = Math.round(this.statusBarPx * 750 / this.screenWidth);
  117. },
  118. mounted() {
  119. this.updateChatBottom();
  120. this.$nextTick(() => { this.scrollWithAnimation = true; });
  121. // 页面挂载后尝试恢复本地缓存消息
  122. this.restoreMessagesFromStorage();
  123. },
  124. beforeDestroy() {
  125. // 页面销毁时无需特殊处理
  126. },
  127. onHide() {
  128. // 页面隐藏时立即保存一次
  129. this.saveMessagesImmediate();
  130. },
  131. methods: {
  132. onInput() {
  133. this.updateChatBottom();
  134. },
  135. updateChatBottom() {
  136. this.$nextTick(() => {
  137. const query = uni.createSelectorQuery().in(this);
  138. query.select('.input-bar').boundingClientRect(rect => {
  139. if (rect) {
  140. const heightRpx = Math.round(rect.height * 750 / this.screenWidth);
  141. const bottomOffset = 85; // 与 .input-bar 的 bottom 保持一致,避开底部tabbar
  142. this.chatBottom = (heightRpx + bottomOffset) + 'rpx';
  143. }
  144. }).exec();
  145. });
  146. },
  147. // 从本地恢复消息
  148. restoreMessagesFromStorage() {
  149. try {
  150. const key = 'aiQaMessagesV1';
  151. const cached = uni.getStorageSync(key);
  152. if (cached && Array.isArray(cached)) {
  153. this.messages = cached;
  154. // 恢复后滚动到底部
  155. this.$nextTick(() => { this.forceScrollToBottom(); });
  156. }
  157. } catch (e) {
  158. console.warn('恢复聊天缓存失败', e);
  159. }
  160. },
  161. // 立即保存消息
  162. saveMessagesImmediate() {
  163. try {
  164. const key = 'aiQaMessagesV1';
  165. uni.setStorageSync(key, this.messages);
  166. } catch (e) {
  167. console.warn('保存聊天缓存失败', e);
  168. }
  169. },
  170. // 节流保存
  171. saveMessagesDebounced() {
  172. if (this.persistTimer) { clearTimeout(this.persistTimer); }
  173. this.persistTimer = setTimeout(() => {
  174. this.saveMessagesImmediate();
  175. }, 300);
  176. },
  177. // 处理流式响应(HTTP chunked 实时追加),支持 <think> 思考区分和 Markdown
  178. async handleStreamingResponse(question, aiMessageIndex) {
  179. return new Promise((resolve, reject) => {
  180. // 流式期间关闭滚动动画,避免频繁触发导致抖动
  181. this.scrollWithAnimation = false;
  182. let fullText = '';
  183. let thinking = false;
  184. const onChunk = (chunkText, nowFull, rawPiece) => {
  185. // 识别思考标签:一旦检测到 <think> 立即进入思考态并标记 hasThinking
  186. if (typeof rawPiece === 'string') {
  187. if (rawPiece.indexOf('<think>') > -1) {
  188. thinking = true;
  189. this.$set(this.messages[aiMessageIndex], 'isThinking', true);
  190. if (!this.messages[aiMessageIndex].hasThinking) {
  191. this.$set(this.messages[aiMessageIndex], 'hasThinking', true);
  192. // 初始化思考内容(避免首块为空时不显示)
  193. if (!this.messages[aiMessageIndex].thinkContent) {
  194. this.$set(this.messages[aiMessageIndex], 'thinkContent', '');
  195. this.$set(this.messages[aiMessageIndex], 'thinkContentHtml', '');
  196. }
  197. }
  198. }
  199. if (rawPiece.indexOf('</think>') > -1) {
  200. thinking = false;
  201. this.$set(this.messages[aiMessageIndex], 'isThinking', false);
  202. this.$set(this.messages[aiMessageIndex], 'hasThinking', true);
  203. }
  204. }
  205. // 根据 thinking 累积到不同区域
  206. if (thinking) {
  207. const prev = this.messages[aiMessageIndex].thinkContent || '';
  208. const merged = prev + (chunkText || '');
  209. this.$set(this.messages[aiMessageIndex], 'thinkContent', merged);
  210. this.$set(this.messages[aiMessageIndex], 'thinkContentHtml', markdownToHtml(merged));
  211. } else {
  212. // 仅在非思考阶段累计正文
  213. fullText = nowFull || (fullText + (chunkText || ''));
  214. this.$set(this.messages[aiMessageIndex], 'content', fullText);
  215. this.$set(this.messages[aiMessageIndex], 'contentHtml', markdownToHtml(fullText));
  216. }
  217. // 统一调度自动滚动,内部带节流与 nextTick
  218. this.scheduleScrollToBottom();
  219. // 节流持久化,保证切换tab后还能恢复
  220. this.saveMessagesDebounced();
  221. };
  222. askAIStream(question, onChunk)
  223. .then((res) => {
  224. this.$set(this.messages[aiMessageIndex], 'isLoading', false);
  225. this.$set(this.messages[aiMessageIndex], 'isThinking', false);
  226. if (!res || res.success !== true) {
  227. reject(new Error(res && res.error ? res.error : '服务异常'));
  228. return;
  229. }
  230. // 流式结束,恢复滚动动画并强制滚动一次到底部
  231. this.scrollWithAnimation = true;
  232. this.forceScrollToBottom();
  233. this.saveMessagesImmediate();
  234. resolve();
  235. })
  236. .catch((err) => {
  237. console.error('AI流式请求失败:', err);
  238. this.$set(this.messages[aiMessageIndex], 'content', '网络连接失败,请检查网络后重试。');
  239. this.$set(this.messages[aiMessageIndex], 'isLoading', false);
  240. this.$set(this.messages[aiMessageIndex], 'isThinking', false);
  241. // 出错也恢复滚动动画并强制滚动
  242. this.scrollWithAnimation = true;
  243. this.forceScrollToBottom();
  244. this.saveMessagesImmediate();
  245. reject(err);
  246. });
  247. });
  248. },
  249. // 清空聊天记录
  250. async clearChat() {
  251. const ok = await this.$refs.AppConfirm.open({
  252. title: '确认清空',
  253. content: '确定要清空所有聊天记录吗?',
  254. confirmText: '清空',
  255. cancelText: '取消',
  256. type: 'danger'
  257. });
  258. if (ok) {
  259. this.messages = [];
  260. // 重置滚动位置
  261. this.scrollTop = 0;
  262. // 清除本地缓存
  263. try { uni.removeStorageSync('aiQaMessagesV1'); } catch (e) {}
  264. }
  265. },
  266. // 切换深度思考展开/收起
  267. toggleThinking(index) {
  268. this.$set(this.messages[index], 'showThinking', !this.messages[index].showThinking);
  269. },
  270. // 复制内容
  271. copyContent(content) {
  272. uni.setClipboardData({
  273. data: content,
  274. success: () => {
  275. uni.showToast({
  276. title: '复制成功',
  277. icon: 'success',
  278. duration: 1500
  279. });
  280. },
  281. fail: () => {
  282. uni.showToast({
  283. title: '复制失败',
  284. icon: 'none',
  285. duration: 1500
  286. });
  287. }
  288. });
  289. },
  290. // 发送按钮点击
  291. async onSend(){
  292. // 如果输入为空或正在发送中,直接返回
  293. if(!this.question.trim() || this.isSending){
  294. return;
  295. }
  296. // 设置发送状态,防止重复发送
  297. this.isSending = true;
  298. const userQuestion = this.question.trim();
  299. // 添加用户消息
  300. this.messages.push({role: 'user', content: userQuestion});
  301. this.saveMessagesDebounced();
  302. // 清空输入框
  303. this.question = '';
  304. // 滚动到底部
  305. this.scheduleScrollToBottom();
  306. // 添加AI回复占位消息
  307. const aiMessageIndex = this.messages.length;
  308. this.messages.push({
  309. role: 'assistant',
  310. content: '',
  311. showThinking: true,
  312. isLoading: true, // 标记为加载状态
  313. isThinking: true
  314. });
  315. this.saveMessagesDebounced();
  316. try {
  317. // 检查网络连接
  318. const isNetworkAvailable = await checkNetworkStatus();
  319. if (!isNetworkAvailable) {
  320. this.$set(this.messages[aiMessageIndex], 'content', '网络连接不可用,请检查网络设置后重试。');
  321. this.$set(this.messages[aiMessageIndex], 'isLoading', false);
  322. this.saveMessagesImmediate();
  323. return;
  324. }
  325. // 使用模拟流式响应处理
  326. await this.handleStreamingResponse(userQuestion, aiMessageIndex);
  327. } catch (error) {
  328. // 处理网络错误或其他异常
  329. let errorMessage = '网络连接失败,请检查网络后重试。';
  330. if (error && error.error) {
  331. errorMessage = error.error;
  332. } else if (error && error.message) {
  333. errorMessage = error.message;
  334. }
  335. this.$set(this.messages[aiMessageIndex], 'content', errorMessage);
  336. console.error('AI问答API调用异常:', error);
  337. } finally {
  338. // 无论成功还是失败,都要结束加载状态和发送状态
  339. this.$set(this.messages[aiMessageIndex], 'isLoading', false);
  340. this.isSending = false;
  341. }
  342. // 滚动到底部显示最新消息(强制一次)
  343. this.forceScrollToBottom();
  344. this.saveMessagesImmediate();
  345. this.updateChatBottom(); // 更新底部间距
  346. },
  347. scheduleScrollToBottom() {
  348. if (!this.isAutoScroll) return;
  349. const now = Date.now();
  350. if (now - this.lastAnchorUpdateTs < 80) return; // 80ms节流
  351. this.lastAnchorUpdateTs = now;
  352. this.anchorKey++;
  353. this.$nextTick(() => {
  354. this.scrollIntoViewId = 'bottom-anchor-' + this.anchorKey;
  355. });
  356. },
  357. // 强制滚动到底部:用于生成完成/异常时兜底
  358. forceScrollToBottom() {
  359. this.anchorKey++;
  360. this.$nextTick(() => {
  361. this.scrollIntoViewId = 'bottom-anchor-' + this.anchorKey;
  362. // 兼容部分端:给一个较大的 scrollTop 值
  363. this.scrollTop = 999999;
  364. });
  365. },
  366. onScroll(e) {
  367. const top = (e && e.detail && typeof e.detail.scrollTop === 'number') ? e.detail.scrollTop : 0;
  368. if (top + 40 < this.lastScrollTop) {
  369. this.isAutoScroll = false;
  370. }
  371. this.lastScrollTop = top;
  372. },
  373. onScrollToLower() {
  374. this.isAutoScroll = true;
  375. this.scheduleScrollToBottom();
  376. }
  377. }
  378. }
  379. </script>
  380. <style lang="less" scoped>
  381. .ai-qa-page {
  382. min-height: 100%;
  383. padding-bottom: 100rpx; // 预留底部tabbar高度
  384. .custom-navbar {
  385. position: fixed;
  386. top: 0;
  387. left: 0;
  388. z-index: 10;
  389. width: 100%;
  390. height: 88rpx;
  391. display: flex;
  392. align-items: center;
  393. justify-content: center;
  394. background: transparent; // 透明,让背景延伸到顶部
  395. backdrop-filter: blur(10rpx); // 添加毛玻璃效果
  396. }
  397. .nav-title {
  398. font-size: 32rpx;
  399. color: #1f2333;
  400. font-weight: 600;
  401. letter-spacing: 1rpx; // 增加字间距
  402. }
  403. .clear-btn {
  404. position: absolute;
  405. right: 30rpx;
  406. top: 50%;
  407. transform: translateY(-50%);
  408. padding: 8rpx 16rpx;
  409. border-radius: 10rpx;
  410. background: rgba(255, 255, 255, 0.8);
  411. backdrop-filter: blur(10rpx);
  412. cursor: pointer;
  413. transition: all 0.3s ease;
  414. }
  415. .clear-btn:active {
  416. transform: translateY(-50%) scale(0.95);
  417. }
  418. .clear-text {
  419. font-size: 24rpx;
  420. color: #666;
  421. font-weight: 500;
  422. }
  423. .top-section {
  424. position: relative;
  425. width: 100%;
  426. padding-top: 88rpx;
  427. min-height: 800rpx; // 假设值,确保填充屏幕,实际根据banner调整
  428. overflow: hidden; // 确保背景图片不会溢出
  429. }
  430. .ai-bg {
  431. position: absolute;
  432. top: 0;
  433. left: 0;
  434. width: 100%;
  435. height: 100%;
  436. z-index: 1;
  437. object-fit: cover; // 确保图片正确填充
  438. }
  439. .ai-banner {
  440. position: relative;
  441. z-index: 2;
  442. width: 100%;
  443. display: block; // 去除行内元素底部空隙
  444. object-fit: contain; // 确保图片保持比例
  445. }
  446. // 底部输入框容器
  447. .input-bar{
  448. position: fixed;
  449. left: 0;
  450. bottom: 85rpx; // 避开底部tabbar
  451. z-index: 5;
  452. width: 100%;
  453. padding: 0 25rpx 25rpx;
  454. box-sizing: border-box;
  455. }
  456. .input-wrap{
  457. position: relative;
  458. border-radius: 22rpx; // 微调圆角使条形更贴设计
  459. border: 1px solid transparent; // 初始状态:透明边框配合渐变背景
  460. background:
  461. linear-gradient(#fff, #fff) padding-box,
  462. linear-gradient(90deg, rgba(141, 102, 255, 1), rgba(52, 119, 255, 1)) border-box;
  463. overflow: hidden; // 裁剪内部使圆角生效
  464. padding-right: 96rpx; // 预留发送按钮空间
  465. transition: all 0.3s ease; // 添加过渡动画
  466. }
  467. .input-wrap.chat-mode {
  468. border: 3rpx solid #E7EDF2; // 对话状态:实线边框
  469. background: #fff; // 白色背景
  470. }
  471. .input{
  472. width: 100%;
  473. min-height: 56rpx; // 提高默认高度
  474. max-height: 220rpx;
  475. padding: 28rpx 28rpx 4rpx; // 按单行时的垂直居中计算(64-40)/2=12,28=16+12
  476. font-size: 28rpx;
  477. color: #292C33;
  478. box-sizing: border-box;
  479. background: transparent; // 确保背景透明
  480. border: none; // 移除边框
  481. outline: none; // 移除轮廓
  482. }
  483. // 多行输入样式
  484. .textarea{
  485. position: relative;
  486. top:-4rpx;
  487. line-height: 40rpx;
  488. overflow-y: auto;
  489. resize: none; // 禁用调整大小
  490. }
  491. .send-btn{
  492. position: absolute;
  493. right: 10rpx;
  494. bottom: 18rpx; // 固定在右下角
  495. width: 52rpx;
  496. height: 52rpx;
  497. border-radius: 16rpx;
  498. background: linear-gradient(90deg, #8C66FE 0%, #3377FF 100%); // 初始状态:渐变背景
  499. display: flex;
  500. align-items: center;
  501. justify-content: center;
  502. cursor: pointer; // 添加鼠标指针样式
  503. transition: all 0.3s ease; // 添加过渡动画
  504. }
  505. .send-btn.chat-mode {
  506. background: #3377FF; // 对话状态:纯色背景
  507. }
  508. .send-btn.disabled {
  509. background: #7A8599 !important; // 禁用状态下的灰色背景,优先级最高
  510. cursor: not-allowed; // 禁用状态下的鼠标样式
  511. }
  512. .send-btn:not(.disabled):active {
  513. transform: scale(0.95); // 只有在非禁用状态下才有点击缩小效果
  514. }
  515. .send-icon{
  516. width: 45rpx;
  517. height: 45rpx;
  518. object-fit: contain; // 确保图标正确显示
  519. }
  520. }
  521. // 添加聊天列表样式
  522. .chat-list {
  523. position: absolute;
  524. top: 0;
  525. left: 0;
  526. right: 0;
  527. padding: 0 30rpx;
  528. background: #F5F6FA; // 对话背景色
  529. z-index: 3; // 低于input-bar的z5
  530. box-sizing: border-box;
  531. }
  532. .chat-scroll {
  533. height: 100%;
  534. }
  535. .message {
  536. display: flex;
  537. margin-bottom: 25rpx; // 缩小消息间距
  538. align-items: flex-start; // 确保头像和消息顶部对齐
  539. }
  540. .message:last-child {
  541. margin-bottom: 12rpx; // 最后一条消息底部更紧凑
  542. }
  543. .message.assistant {
  544. flex-direction: row;
  545. justify-content: flex-start; // 确保AI消息左对齐
  546. width: 100%; // 确保占满宽度
  547. }
  548. .message.user {
  549. flex-direction: row-reverse;
  550. text-align: right;
  551. justify-content: flex-end; // 确保用户消息右对齐
  552. width: 100%; // 确保占满宽度
  553. }
  554. .avatar {
  555. width: 40rpx;
  556. height: 40rpx;
  557. overflow: hidden;
  558. flex-shrink: 0; // 防止头像被压缩
  559. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); // 添加头像阴影
  560. }
  561. .avatar image {
  562. width: 100%;
  563. height: 100%;
  564. object-fit: cover; // 确保头像图片正确填充
  565. }
  566. // AI卡片样式
  567. .ai-card {
  568. background: #fff; // AI回复卡片背景色为白色
  569. border-radius: 16rpx; // 调整圆角以匹配设计稿
  570. overflow: hidden; // 确保内部元素不会超出圆角
  571. box-shadow: none; // 去除卡片阴影
  572. width: 100%; // AI卡片宽度100%
  573. }
  574. .ai-card-header {
  575. display: flex;
  576. align-items: center;
  577. padding: 20rpx 30rpx;
  578. }
  579. .ai-loading-spinner {
  580. width: 28rpx;
  581. height: 28rpx;
  582. margin-right: 16rpx;
  583. border: 4rpx solid rgba(122, 133, 153, 0.15);
  584. border-top-color: #7A8599; // 指定颜色
  585. border-radius: 50%;
  586. animation: ai-rotate 0.9s linear infinite;
  587. }
  588. @keyframes ai-rotate {
  589. 0% { transform: rotate(0deg); }
  590. 100% { transform: rotate(360deg); }
  591. }
  592. .ai-avatar {
  593. width: 40rpx;
  594. height: 40rpx;
  595. border-radius: 50%;
  596. overflow: hidden;
  597. margin-right: 20rpx;
  598. flex-shrink: 0; // 防止头像被压缩
  599. }
  600. .ai-avatar image {
  601. width: 100%;
  602. height: 100%;
  603. object-fit: cover; // 确保头像图片正确填充
  604. }
  605. // 用户消息气泡样式
  606. .user-bubble {
  607. background: #3377FF; // 蓝色背景
  608. color: #fff;
  609. box-shadow: 0 2rpx 10rpx rgba(51, 119, 255, 0.2);
  610. margin-left: auto; // 确保用户消息靠右
  611. max-width: 70%; // 用户消息稍微窄一点
  612. border-radius: 16rpx; // 添加圆角
  613. overflow: hidden; // 确保内容不超出圆角
  614. }
  615. .thinking-header {
  616. display: flex;
  617. align-items: center;
  618. padding: 0; // 移除内边距,因为现在在头部容器内
  619. border-bottom: none; // 移除边框,因为头部容器已经有边框
  620. cursor: pointer;
  621. background: transparent; // 透明背景,因为头部容器已经有背景
  622. transition: background-color 0.2s ease; // 添加过渡效果
  623. flex: 1; // 占据剩余空间
  624. }
  625. .thinking-header:hover {
  626. background: rgba(240, 242, 245, 0.5); // 悬停时的半透明背景色
  627. }
  628. .thinking-icon {
  629. width: 24rpx;
  630. height: 24rpx;
  631. margin-right: 12rpx;
  632. display: flex;
  633. align-items: center;
  634. justify-content: center;
  635. flex-shrink: 0; // 防止图标被压缩
  636. }
  637. .dot-line {
  638. width: 16rpx;
  639. height: 2rpx;
  640. background: #6B7280;
  641. position: relative;
  642. border-radius: 1rpx; // 添加圆角
  643. }
  644. .dot-line::before,
  645. .dot-line::after {
  646. content: '';
  647. position: absolute;
  648. width: 4rpx;
  649. height: 4rpx;
  650. background: #6B7280;
  651. border-radius: 50%;
  652. top: -1rpx;
  653. box-shadow: 0 0 2rpx rgba(107, 114, 128, 0.3); // 添加阴影效果
  654. }
  655. .dot-line::before {
  656. left: -2rpx;
  657. }
  658. .dot-line::after {
  659. right: -2rpx;
  660. }
  661. .thinking-text {
  662. font-size: 24rpx;
  663. color: #6B7280;
  664. flex: 1;
  665. font-weight: 500; // 添加字重
  666. }
  667. .toggle-icon {
  668. width: 40rpx;
  669. height: 40rpx;
  670. display: flex;
  671. align-items: center;
  672. justify-content: center;
  673. transition: transform 0.3s ease;
  674. flex-shrink: 0; // 防止图标被压缩
  675. margin-left: auto; // 推到右侧
  676. }
  677. .toggle-icon.expanded {
  678. transform: rotate(180deg);
  679. }
  680. .caret-icon {
  681. width: 30rpx;
  682. height: 30rpx;
  683. object-fit: contain; // 确保图标正确显示
  684. }
  685. .response-content {
  686. padding: 20rpx 30rpx;
  687. padding-top: 0;
  688. font-size: 28rpx;
  689. line-height: 42rpx;
  690. color: #333;
  691. word-break: break-all; // 确保长文本正确换行
  692. white-space: pre-wrap; // 保持换行格式
  693. }
  694. .thinking-block {
  695. font-size: 26rpx; // 比正文稍小
  696. line-height: 40rpx;
  697. color: #7A8599; // 指定灰色
  698. padding-bottom: 20rpx;
  699. margin-bottom: 20rpx;
  700. border-bottom: 2rpx dashed #E2E8F0; // 底部分割虚线
  701. }
  702. .answer-block {
  703. font-size: 28rpx;
  704. line-height: 42rpx;
  705. color: #333;
  706. }
  707. .copy-icon {
  708. padding: 0 30rpx 20rpx;
  709. display: flex;
  710. justify-content: flex-start; // 左对齐,显示在左下角
  711. cursor: pointer; // 添加鼠标指针样式
  712. }
  713. .message:last-child .copy-icon {
  714. padding-bottom: 8rpx; // 缩小最后一条的底部留白
  715. }
  716. .copy-icon-img {
  717. width: 32rpx;
  718. height: 32rpx;
  719. object-fit: contain; // 确保图标正确显示
  720. transition: transform 0.2s ease; // 添加过渡效果
  721. }
  722. .copy-icon:hover .copy-icon-img {
  723. transform: scale(1.1); // 悬停时稍微放大
  724. }
  725. // 加载状态样式
  726. .loading-content {
  727. display: flex;
  728. flex-direction: column;
  729. align-items: center;
  730. padding: 40rpx 0;
  731. }
  732. .loading-dots {
  733. display: flex;
  734. align-items: center;
  735. margin-bottom: 20rpx;
  736. }
  737. .dot {
  738. width: 12rpx;
  739. height: 12rpx;
  740. border-radius: 50%;
  741. background: #3377FF;
  742. margin: 0 6rpx;
  743. animation: loading-dot 1.4s infinite ease-in-out;
  744. }
  745. .dot:nth-child(1) {
  746. animation-delay: -0.32s;
  747. }
  748. .dot:nth-child(2) {
  749. animation-delay: -0.16s;
  750. }
  751. @keyframes loading-dot {
  752. 0%, 80%, 100% {
  753. transform: scale(0.8);
  754. opacity: 0.5;
  755. }
  756. 40% {
  757. transform: scale(1);
  758. opacity: 1;
  759. }
  760. }
  761. .loading-text {
  762. font-size: 26rpx;
  763. color: #6B7280;
  764. font-weight: 500;
  765. }
  766. .user-content {
  767. padding: 20rpx 30rpx;
  768. font-size: 28rpx;
  769. line-height: 42rpx;
  770. color: #fff;
  771. word-break: break-all; // 确保长文本正确换行
  772. text-align: right; // 用户消息文本右对齐
  773. }
  774. </style>