index.vue 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703
  1. <template>
  2. <view class="container">
  3. <!-- 聊天记录区域 -->
  4. <scroll-view class="chat-container" scroll-y :scroll-top="scrollTop" :scroll-with-animation="true"
  5. @scroll="onScroll" :style="{ height: `calc(100vh - ${inputHeight}px)`,marginTop: '0'}">
  6. <view class="chat-list">
  7. <view v-for="(message, index) in chatMessages" :key="index" class="message-item" :class="{ 'message-ai': message.sender === 'ai', 'message-user': message.sender === 'user' }">
  8. <!-- AI消息 -->
  9. <template v-if="message.sender === 'ai'">
  10. <view class="avatar-container">
  11. <image class="avatar" src="/static/icons/ai-1.png" mode="aspectFill"></image>
  12. </view>
  13. <view class="message-content">
  14. <view class="message-bubble ai-bubble"
  15. :class="{'typing': message.isTyping, 'welcome': message.isWelcome || index === 0}">
  16. <!-- 正在输入指示器 -->
  17. <view v-if="message.isTyping" class="typing-indicator">
  18. <view class="typing-dot"></view>
  19. <view class="typing-dot"></view>
  20. <view class="typing-dot"></view>
  21. </view>
  22. <!-- 消息内容 -->
  23. <text v-else-if="message.content" class="message-text"
  24. :class="{'highlight': containsKeywords(message.content)}">
  25. {{ message.content }}
  26. </text>
  27. <!-- AI免责声明(非欢迎消息且非输入中显示) -->
  28. <text class="ai-disclaimer">
  29. 内容均由AI生成,仅供参考
  30. </text>
  31. </view>
  32. <!-- 操作按钮区域(仅最后一条AI消息显示) -->
  33. <view v-if="index === chatMessages.length - 1 && message.sender === 'ai' && !message.isTyping" class="message-actions">
  34. <view class="action-button" @click="regenerateMessage" hover-class="action-button-hover">
  35. <text class="action-icon">🔄</text>
  36. <text class="action-text">重新生成</text>
  37. </view>
  38. </view>
  39. <text class="message-time">{{ message.time }}</text>
  40. </view>
  41. </template>
  42. <!-- 用户消息 -->
  43. <template v-else>
  44. <view class="message-content user-content">
  45. <text class="message-time">{{ message.time }}</text>
  46. <view class="message-bubble user-bubble">
  47. <text class="message-text">{{ message.content }}</text>
  48. </view>
  49. </view>
  50. <view class="avatar-container">
  51. <image class="avatar" src="/static/images/user-avatar.svg" mode="aspectFill"></image>
  52. </view>
  53. </template>
  54. </view>
  55. </view>
  56. </scroll-view>
  57. <!-- 底部输入区 -->
  58. <view class="input-container" :style="{
  59. // #ifdef H5
  60. paddingBottom: `${safeAreaBottom + 100}rpx`
  61. // #endif
  62. // #ifdef APP-HARMONY
  63. paddingBottom: `${safeAreaBottom + 20}rpx`
  64. // #endif
  65. }">
  66. <!-- 问题建议区 -->
  67. <scroll-view v-if="chatMessages.length <= 3 && !inputMessage && !isProcessing" class="suggested-questions" scroll-x>
  68. <view v-for="(question, index) in suggestedQuestions" :key="index" class="question-chip"
  69. @click="useQuestion(question)">
  70. <text>{{ question }}</text>
  71. </view>
  72. </scroll-view>
  73. <view class="input-wrapper">
  74. <textarea class="message-input" v-model="inputMessage" placeholder="请输入您的问题..." :disabled="isProcessing"
  75. auto-height :maxlength="300" :style="{ maxHeight: '120rpx' }" @focus="onInputFocus"
  76. @confirm="submitQuestion" />
  77. <!-- 中止按钮(流式输出时显示) -->
  78. <view v-if="isProcessing" class="stop-button" @click="stopStreaming" hover-class="button-hover">
  79. <text class="stop-icon">⏹</text>
  80. </view>
  81. <!-- 发送按钮 -->
  82. <view v-else class="send-button" :class="{ 'disabled': !inputMessage.trim() }"
  83. @click="submitQuestion" hover-class="button-hover">
  84. <image class="send-icon-image"
  85. :src="inputMessage.trim() ? '/static/icons/chat.png' : '/static/icons/chat_off.png'"
  86. mode="aspectFit"></image>
  87. </view>
  88. </view>
  89. </view>
  90. <!-- renderjs 模块容器(用于 H5/App 端 SSE 流式连接) -->
  91. <!-- #ifdef H5 -->
  92. <view
  93. :change:prop="renderModule.onDataChange"
  94. :prop="renderjsData"
  95. :onStreamData="onStreamData"
  96. class="renderjs-container"
  97. ></view>
  98. <!-- #endif -->
  99. </view>
  100. </template>
  101. <script setup>
  102. import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from 'vue'
  103. import api from "@/config/api.js";
  104. import storage from "@/utils/storage.js";
  105. import { chatStreamSuggested } from "@/api/services/chart.js";
  106. // 响应式数据
  107. const inputMessage = ref('') // 输入框消息
  108. // 初始化函数,用于获取初始时间
  109. const getInitialFormattedTime = () => {
  110. const date = new Date()
  111. const hours = date.getHours().toString().padStart(2, '0')
  112. const minutes = date.getMinutes().toString().padStart(2, '0')
  113. return `${hours}:${minutes}`
  114. }
  115. const chatMessages = ref([{
  116. sender: 'ai',
  117. content: '您好!我是农小禹,您的智能农业助手🌱 我可以帮您解答农业种植、病虫害防治、农产品管理等方面的问题。有什么可以帮助您的吗?',
  118. time: getInitialFormattedTime(),
  119. timestamp: Date.now(),
  120. isWelcome: true
  121. }])
  122. const scrollTop = ref(0)
  123. const inputHeight = ref(110)
  124. const isProcessing = ref(false)
  125. const currentTypingMessage = ref(null) // 当前正在输入的消息索引
  126. const lastUserQuestion = ref('') // 保存最后一个用户问题,用于重新生成
  127. const suggestedQuestions = ref([
  128. '水稻插秧后如何管理?',
  129. '果树夏季修剪技巧?',
  130. '如何防治蔬菜常见病虫害?',
  131. '农药使用注意事项?',
  132. '有机肥和化肥怎么搭配使用?'
  133. ])
  134. const statusBarHeight = ref(20)
  135. const safeAreaBottom = ref(0)
  136. const isIOS = ref(false)
  137. // renderjs 通信数据
  138. const renderjsData = ref({
  139. action: '', // start, stop
  140. url: '',
  141. data: {},
  142. timestamp: 0
  143. })
  144. // 消息队列和打字机效果
  145. const messageQueue = ref([])
  146. const isTypingEffect = ref(false)
  147. const typingTimer = ref(null)
  148. // Thinking 模式追踪
  149. const isInThinkingMode = ref(false)
  150. const sessionId = ref(null)
  151. const messageId = ref(null) // 消息ID,用于标识当前消息
  152. const thinkingBuffer = ref('') // 临时存储 Thinking 内容
  153. // 防抖函数引用
  154. let debouncedSubmitQuestion = null
  155. // uni-app 生命周期
  156. const onNavigationBarButtonTap = (e) => {
  157. console.log("导航栏按钮点击:", e);
  158. }
  159. // ========== renderjs 通信方法 ==========
  160. // renderjs 回调:接收流式数据
  161. const onStreamData = (data) => {
  162. console.log('=== Vue收到流式数据 ===');
  163. console.log('数据类型:', data.type);
  164. if (data.type === 'thinking') {
  165. console.log('进入 Thinking 模式');
  166. // 第一个 thinking 类型,开启 Thinking 模式
  167. isInThinkingMode.value = true;
  168. thinkingBuffer.value = data.content;
  169. processThinkingContent();
  170. } else if (data.type === 'message') {
  171. const contentLength = data.content ? data.content.length : 0;
  172. console.log('收到消息内容,长度:', contentLength);
  173. // 判断是否在 Thinking 模式中
  174. if (isInThinkingMode.value) {
  175. console.log('仍在 Thinking 模式,累积内容');
  176. // 仍在 Thinking 区域内,累积内容
  177. thinkingBuffer.value += data.content;
  178. processThinkingContent();
  179. } else {
  180. console.log('普通消息,添加到队列');
  181. // 普通消息内容
  182. handleMessageData(data.content);
  183. }
  184. } else if (data.type === 'end') {
  185. // 流式结束
  186. console.log("流式结束,消息id:", data.id);
  187. finishStreaming(data.id);
  188. } else if (data.type === 'error') {
  189. // 错误处理
  190. console.error('流式错误:', data.error);
  191. handleStreamError(data.error);
  192. }
  193. }
  194. // 处理 Thinking 内容(跳过 Thinking,只处理后续内容)
  195. const processThinkingContent = () => {
  196. if (currentTypingMessage.value === null) return;
  197. // 检查是否包含 </details> 结束标签
  198. if (thinkingBuffer.value.includes('</details>')) {
  199. // Thinking 区域结束,提取 </details> 之后的内容
  200. isInThinkingMode.value = false;
  201. // 提取 </details> 后面的内容
  202. const detailsEndIndex = thinkingBuffer.value.indexOf('</details>');
  203. const contentAfterThinking = thinkingBuffer.value.substring(detailsEndIndex + '</details>'.length);
  204. // 如果有内容,添加到消息队列
  205. if (contentAfterThinking) {
  206. handleMessageData(contentAfterThinking);
  207. }
  208. // 清空缓冲区
  209. thinkingBuffer.value = '';
  210. }
  211. // 如果还在 Thinking 区域内,不做任何显示,继续累积
  212. }
  213. // 处理消息数据(添加到队列)
  214. const handleMessageData = (text) => {
  215. console.log('handleMessageData 被调用,文本长度:', text ? text.length : 0);
  216. if (!text) return;
  217. // 将文本按字符添加到队列
  218. for (let char of text) {
  219. messageQueue.value.push(char);
  220. }
  221. console.log('消息队列长度:', messageQueue.value.length);
  222. // 如果打字机效果未启动,则启动
  223. if (!isTypingEffect.value) {
  224. console.log('启动打字机效果');
  225. startTypingEffect();
  226. }
  227. }
  228. // 启动打字机效果
  229. const startTypingEffect = () => {
  230. console.log('startTypingEffect 被调用');
  231. console.log('isTypingEffect:', isTypingEffect.value);
  232. console.log('currentTypingMessage:', currentTypingMessage.value);
  233. console.log('messageQueue 长度:', messageQueue.value.length);
  234. if (isTypingEffect.value) {
  235. console.log('打字机效果已在运行,退出');
  236. return;
  237. }
  238. if (currentTypingMessage.value === null) {
  239. console.log('没有当前消息,退出');
  240. return;
  241. }
  242. if (messageQueue.value.length === 0) {
  243. console.log('消息队列为空,退出');
  244. return;
  245. }
  246. isTypingEffect.value = true;
  247. // 关闭 typing 指示器
  248. if (chatMessages.value[currentTypingMessage.value]) {
  249. chatMessages.value[currentTypingMessage.value].isTyping = false;
  250. console.log('已关闭 typing 指示器');
  251. }
  252. console.log('开始打字机效果,消息索引:', currentTypingMessage.value);
  253. const processQueue = () => {
  254. if (messageQueue.value.length > 0 && currentTypingMessage.value !== null) {
  255. // 每次取出一个字符
  256. const char = messageQueue.value.shift();
  257. const currentMsg = chatMessages.value[currentTypingMessage.value];
  258. if (currentMsg) {
  259. const currentContent = currentMsg.content || '';
  260. currentMsg.content = currentContent + char;
  261. // 每20个字符滚动一次,优化性能
  262. if (currentContent.length % 20 === 0) {
  263. scrollToBottom();
  264. }
  265. }
  266. // 继续处理队列
  267. typingTimer.value = setTimeout(processQueue, 10);
  268. } else if (messageQueue.value.length === 0 && isProcessing.value) {
  269. // 队列为空但还在处理中,等待新数据
  270. typingTimer.value = setTimeout(processQueue, 50);
  271. } else {
  272. // 队列为空且不在处理中,停止打字机效果
  273. console.log('队列处理完成,停止打字机效果');
  274. isTypingEffect.value = false;
  275. }
  276. };
  277. processQueue();
  278. }
  279. // 停止打字机效果
  280. const stopTypingEffect = () => {
  281. isTypingEffect.value = false;
  282. if (typingTimer.value) {
  283. clearTimeout(typingTimer.value);
  284. typingTimer.value = null;
  285. }
  286. // 清空队列,将剩余内容一次性显示
  287. if (messageQueue.value.length > 0 && currentTypingMessage.value !== null) {
  288. const remainingText = messageQueue.value.join('');
  289. const currentContent = chatMessages.value[currentTypingMessage.value].content || '';
  290. chatMessages.value[currentTypingMessage.value].content = currentContent + remainingText;
  291. messageQueue.value = [];
  292. }
  293. }
  294. // 完成流式输出
  295. const finishStreaming = (id) => {
  296. console.log("完成流式输出,消息id:", id);
  297. stopTypingEffect();
  298. if (currentTypingMessage.value !== null) {
  299. // 如果消息内容为空(可能是被中止了),移除这条消息
  300. if (!chatMessages.value[currentTypingMessage.value]?.content) {
  301. console.log('[完成流式] 消息内容为空,移除消息');
  302. chatMessages.value.splice(currentTypingMessage.value, 1);
  303. } else {
  304. chatMessages.value[currentTypingMessage.value].isTyping = false;
  305. }
  306. currentTypingMessage.value = null;
  307. }
  308. // 重置 Thinking 模式状态
  309. isInThinkingMode.value = false;
  310. thinkingBuffer.value = '';
  311. // 重置微信小程序中止标记
  312. // #ifdef MP-WEIXIN
  313. isWeixinRequestAborted = false;
  314. // #endif
  315. isProcessing.value = false;
  316. scrollToBottom();
  317. // 获取下一轮建议问题
  318. // fetchSuggestedQuestions({user: sessionId.value, messageId: id});
  319. }
  320. // 处理流式错误
  321. const handleStreamError = (error) => {
  322. console.error('流式错误:', error);
  323. // 如果是微信小程序的主动中止,不显示错误
  324. // #ifdef MP-WEIXIN
  325. if (isWeixinRequestAborted) {
  326. console.log('[微信小程序] 忽略主动中止的错误');
  327. return;
  328. }
  329. // #endif
  330. stopTypingEffect();
  331. // 移除正在输入的消息
  332. if (currentTypingMessage.value !== null) {
  333. chatMessages.value.splice(currentTypingMessage.value, 1);
  334. currentTypingMessage.value = null;
  335. }
  336. // 重置 Thinking 模式状态
  337. isInThinkingMode.value = false;
  338. thinkingBuffer.value = '';
  339. isProcessing.value = false;
  340. handleError({ message: error || '网络异常,请稍后重试' });
  341. }
  342. // ========== 用户交互方法 ==========
  343. // 发送消息
  344. const submitQuestion = () => {
  345. if (!storage.getHasLogin()) {
  346. uni.showModal({
  347. title: '提示',
  348. content: '您还未登录,请先登录',
  349. confirmText: '去登录',
  350. cancelText: '取消',
  351. success: function(res) {
  352. if (res.confirm) {
  353. uni.navigateTo({
  354. url: '/pages/login/index'
  355. });
  356. }
  357. },
  358. });
  359. return;
  360. }
  361. if (!inputMessage.value.trim() || isProcessing.value) return;
  362. const question = inputMessage.value.trim();
  363. lastUserQuestion.value = question; // 保存问题用于重新生成
  364. inputMessage.value = '';
  365. // 添加用户消息
  366. chatMessages.value.push({
  367. sender: 'user',
  368. content: question,
  369. time: getCurrentTime(),
  370. timestamp: Date.now()
  371. });
  372. // 添加 AI 正在输入的消息
  373. const typingMessageIndex = chatMessages.value.push({
  374. sender: 'ai',
  375. content: '',
  376. time: getCurrentTime(),
  377. timestamp: Date.now(),
  378. isTyping: true
  379. }) - 1;
  380. currentTypingMessage.value = typingMessageIndex;
  381. isProcessing.value = true;
  382. scrollToBottom();
  383. // 通过 renderjs 发起 SSE 请求
  384. startSSERequest(question);
  385. }
  386. // 启动 SSE 请求(通过 renderjs)
  387. const startSSERequest = (question) => {
  388. const url = api.serve + '/uniapp/dify/chat/stream';
  389. sessionId.value = Date.now().toString()
  390. const requestData = {
  391. query: question,
  392. user: 'user_' + sessionId.value
  393. };
  394. // #ifdef H5
  395. // 更新 renderjs 数据,触发 SSE 连接
  396. renderjsData.value = {
  397. action: 'start',
  398. url: url,
  399. data: requestData,
  400. token: storage.getAccessToken(),
  401. timestamp: Date.now()
  402. };
  403. // #endif
  404. // #ifdef MP-WEIXIN
  405. // 微信小程序使用普通请求(不支持 SSE)
  406. startWeixinRequest(url, requestData);
  407. // #endif
  408. // #ifdef APP-HARMONY
  409. // 鸿蒙平台使用原生方式
  410. startSSERequestNative(url, requestData);
  411. // #endif
  412. }
  413. // 鸿蒙端 SSE 请求任务引用
  414. let sseRequestTask = null;
  415. // 微信小程序请求任务引用
  416. let weixinRequestTask = null;
  417. let isWeixinRequestAborted = false; // 标记请求是否被主动中止
  418. // 微信小程序请求(不支持 SSE,使用普通请求)
  419. const startWeixinRequest = (url, data) => {
  420. console.log('[微信小程序] 开始请求:', url);
  421. // 重置中止标记
  422. isWeixinRequestAborted = false;
  423. // 创建请求任务
  424. weixinRequestTask = uni.request({
  425. url: url,
  426. method: 'POST',
  427. header: {
  428. 'Content-Type': 'application/json',
  429. 'Authorization': `Bearer ${storage.getAccessToken()}`
  430. },
  431. data: data,
  432. timeout: 60000, // 60秒超时
  433. success: (res) => {
  434. // 如果请求被主动中止,不处理响应
  435. if (isWeixinRequestAborted) {
  436. console.log('[微信小程序] 请求已被中止,忽略响应');
  437. return;
  438. }
  439. console.log('[微信小程序] 请求成功,状态码:', res.statusCode);
  440. if (res.statusCode === 200 && res.data) {
  441. // 检查返回数据格式
  442. if (typeof res.data === 'string') {
  443. console.log('[微信小程序] 收到文本格式数据,长度:', res.data.length);
  444. // SSE 格式数据
  445. parseSSEResponse(res.data);
  446. } else if (res.data.answer || res.data.content) {
  447. console.log('[微信小程序] 收到 JSON 格式数据');
  448. // JSON 格式数据,直接显示
  449. const content = res.data.answer || res.data.content || '';
  450. handleMessageData(content);
  451. finishStreaming(res.data.id || null);
  452. } else {
  453. console.error('[微信小程序] 未知的响应格式:', res.data);
  454. handleStreamError('响应格式错误');
  455. }
  456. } else {
  457. console.error('[微信小程序] 请求失败,状态码:', res.statusCode);
  458. handleStreamError('请求失败,状态码: ' + res.statusCode);
  459. }
  460. },
  461. fail: (err) => {
  462. // 如果是主动中止的请求,不显示错误
  463. if (isWeixinRequestAborted) {
  464. console.log('[微信小程序] 请求已被主动中止');
  465. return;
  466. }
  467. console.error('[微信小程序] 请求失败:', err);
  468. // 过滤掉 abort 错误(用户主动中止)
  469. if (err.errMsg && err.errMsg.includes('abort')) {
  470. console.log('[微信小程序] 请求被中止');
  471. return;
  472. }
  473. // 过滤掉 WebSocket 相关错误(可能是其他组件导致的)
  474. if (err.errMsg && err.errMsg.includes('WebSocket')) {
  475. console.warn('[微信小程序] 忽略 WebSocket 错误,这可能来自其他组件');
  476. return;
  477. }
  478. handleStreamError(err.errMsg || '网络异常');
  479. },
  480. complete: () => {
  481. weixinRequestTask = null;
  482. }
  483. });
  484. }
  485. // 停止微信小程序请求
  486. const stopWeixinRequest = () => {
  487. if (weixinRequestTask) {
  488. console.log('[微信小程序] 准备中止请求');
  489. // 设置中止标记
  490. isWeixinRequestAborted = true;
  491. try {
  492. weixinRequestTask.abort();
  493. console.log('[微信小程序] 请求已中止');
  494. } catch (e) {
  495. console.error('[微信小程序] 中止请求失败:', e);
  496. }
  497. weixinRequestTask = null;
  498. }
  499. }
  500. // 原生方式处理 SSE 请求(用于鸿蒙等平台)
  501. const startSSERequestNative = (url, data) => {
  502. console.log('[鸿蒙端] 开始 SSE 请求:', url);
  503. // 创建请求任务
  504. sseRequestTask = uni.request({
  505. url: url,
  506. method: 'POST',
  507. header: {
  508. 'Content-Type': 'application/json',
  509. 'Accept': 'text/event-stream',
  510. 'Authorization': `Bearer ${storage.getAccessToken()}`
  511. },
  512. data: data,
  513. enableChunked: true, // 启用分块传输
  514. responseType: 'text', // 接收文本数据
  515. success: (res) => {
  516. console.log('[鸿蒙端] 请求成功,状态码:', res.statusCode);
  517. console.log('[鸿蒙端] 响应数据类型:', typeof res.data);
  518. if (res.statusCode === 200 && res.data) {
  519. console.log('[鸿蒙端] 响应数据长度:', res.data.length);
  520. // 处理 SSE 格式的响应数据
  521. parseSSEResponse(res.data);
  522. } else {
  523. console.error('[鸿蒙端] 请求失败或无数据');
  524. handleStreamError('请求失败,状态码: ' + res.statusCode);
  525. }
  526. },
  527. fail: (err) => {
  528. console.error('[鸿蒙端] 请求失败:', err);
  529. handleStreamError(err.errMsg || '网络异常');
  530. }
  531. });
  532. // 监听数据接收(如果支持分块传输)
  533. if (sseRequestTask && typeof sseRequestTask.onChunkReceived === 'function') {
  534. console.log('[鸿蒙端] 支持分块接收,启用监听');
  535. sseRequestTask.onChunkReceived((chunkRes) => {
  536. console.log('[鸿蒙端] 收到数据块');
  537. if (chunkRes && chunkRes.data) {
  538. parseSSEResponseChunk(chunkRes.data);
  539. }
  540. });
  541. } else {
  542. console.log('[鸿蒙端] 不支持分块接收,将在 success 回调中处理完整响应');
  543. }
  544. }
  545. // 解析 SSE 格式的响应数据(处理分块数据)
  546. let sseBuffer = ''; // 用于累积不完整的行
  547. const parseSSEResponseChunk = (chunkText) => {
  548. if (!chunkText || typeof chunkText !== 'string') {
  549. console.log('[鸿蒙端] 无效的数据块');
  550. return;
  551. }
  552. console.log('[鸿蒙端] 处理数据块,长度:', chunkText.length);
  553. // 累积到缓冲区
  554. sseBuffer += chunkText;
  555. // 按行分割
  556. const lines = sseBuffer.split('\n');
  557. // 保留最后一行(可能不完整)
  558. sseBuffer = lines.pop() || '';
  559. // 处理完整的行
  560. processSSELines(lines);
  561. }
  562. // 解析 SSE 格式的响应数据(处理完整响应)
  563. const parseSSEResponse = (responseText) => {
  564. if (!responseText || typeof responseText !== 'string') {
  565. console.log('[鸿蒙端] 无效的响应数据');
  566. finishStreaming();
  567. return;
  568. }
  569. console.log('[鸿蒙端] 开始解析完整 SSE 数据,长度:', responseText.length);
  570. // 按行分割
  571. const lines = responseText.split('\n');
  572. // 处理所有行
  573. processSSELines(lines);
  574. // 清空缓冲区
  575. sseBuffer = '';
  576. }
  577. // 处理 SSE 行数据
  578. const processSSELines = (lines) => {
  579. let messageId = null;
  580. for (let i = 0; i < lines.length; i++) {
  581. const line = lines[i].trim();
  582. if (!line) continue; // 跳过空行
  583. console.log(`[鸿蒙端] 处理第 ${i} 行:`, line.substring(0, 100));
  584. // 解析 event: 行
  585. if (line.startsWith('event:')) {
  586. // event: message
  587. continue;
  588. }
  589. // 解析 data: 行
  590. if (line.startsWith('data:') || line !== '') {
  591. let data = line;
  592. if (line.startsWith('data:')) {
  593. data = data.substring(5).trim();
  594. }
  595. if (!data || data.includes("ping")) continue;
  596. console.log('[鸿蒙端] 解析后的数据,长度:', data.length);
  597. // 过滤掉 MESSAGE_END 等元数据事件(JSON 格式)
  598. if (data.startsWith('{') && data.includes('"eventType"')) {
  599. try {
  600. const jsonData = JSON.parse(data);
  601. console.log('[鸿蒙端] JSON 事件:', jsonData.eventType || jsonData.event);
  602. // 如果是 MESSAGE_END 事件,通知结束
  603. if (jsonData.eventType === 'MESSAGE_END' || jsonData.event === 'message_end') {
  604. console.log('[鸿蒙端] 收到 MESSAGE_END 事件,流式结束');
  605. messageId = jsonData.id;
  606. // 不要在这里 continue,继续处理后续行
  607. continue;
  608. }
  609. // 其他元数据事件也忽略
  610. if (jsonData.eventType || jsonData.event) {
  611. console.log('[鸿蒙端] 跳过元数据事件');
  612. continue;
  613. }
  614. } catch (e) {
  615. console.log('[鸿蒙端] JSON 解析失败,作为普通文本处理');
  616. // 不是 JSON,继续处理
  617. }
  618. }
  619. // 检查是否是 Thinking 内容
  620. if (data.includes('<details') && data.includes('<summary>')) {
  621. console.log('[鸿蒙端] 发送 thinking 类型数据');
  622. onStreamData({ type: 'thinking', content: data });
  623. } else if (data) {
  624. // 普通消息内容 - 逐行发送,不累积
  625. console.log('[鸿蒙端] 发送 message 类型数据,长度:', data.length);
  626. onStreamData({ type: 'message', content: data });
  627. }
  628. }
  629. }
  630. // 如果找到了 messageId,结束流式输出
  631. if (messageId) {
  632. console.log('[鸿蒙端] 数据处理完成,结束流式输出');
  633. setTimeout(() => {
  634. onStreamData({ type: 'end', id: messageId });
  635. }, 300);
  636. } else {
  637. // 如果没有 messageId,延迟结束(兼容处理)
  638. console.log('[鸿蒙端] 无 messageId,延迟结束');
  639. setTimeout(() => {
  640. if (isProcessing.value) {
  641. onStreamData({ type: 'end', id: null });
  642. }
  643. }, 1000);
  644. }
  645. }
  646. // 停止鸿蒙端的 SSE 请求
  647. const stopSSERequestNative = () => {
  648. if (sseRequestTask) {
  649. console.log('[鸿蒙端] 中止请求');
  650. sseRequestTask.abort();
  651. sseRequestTask = null;
  652. }
  653. }
  654. // 停止流式输出
  655. const stopStreaming = () => {
  656. console.log('用户中止流式输出');
  657. // #ifdef H5
  658. // 通知 renderjs 停止
  659. renderjsData.value = {
  660. action: 'stop',
  661. timestamp: Date.now()
  662. };
  663. // #endif
  664. // #ifdef APP-HARMONY
  665. // 停止鸿蒙端的请求
  666. stopSSERequestNative();
  667. // #endif
  668. // #ifdef MP-WEIXIN
  669. // 停止微信小程序请求
  670. stopWeixinRequest();
  671. // #endif
  672. // 立即停止打字机效果并完成
  673. finishStreaming();
  674. // 不显示 Toast,避免干扰用户
  675. console.log('[用户操作] 已中止流式输出');
  676. }
  677. // 重新生成回复
  678. const regenerateMessage = () => {
  679. if (!lastUserQuestion.value || isProcessing.value) return;
  680. // 删除最后一条 AI 消息
  681. if (chatMessages.value.length > 0 && chatMessages.value[chatMessages.value.length - 1].sender === 'ai') {
  682. chatMessages.value.pop();
  683. }
  684. // 添加新的正在输入消息
  685. const typingMessageIndex = chatMessages.value.push({
  686. sender: 'ai',
  687. content: '',
  688. time: getCurrentTime(),
  689. timestamp: Date.now(),
  690. isTyping: true
  691. }) - 1;
  692. currentTypingMessage.value = typingMessageIndex;
  693. isProcessing.value = true;
  694. messageQueue.value = [];
  695. // 重置 Thinking 模式状态
  696. isInThinkingMode.value = false;
  697. thinkingBuffer.value = '';
  698. scrollToBottom();
  699. // 重新发起请求
  700. startSSERequest(lastUserQuestion.value);
  701. }
  702. // ========== 工具方法 ==========
  703. // 错误处理
  704. const handleError = (error) => {
  705. let errorMessage = '发生错误';
  706. if (error.errMsg) {
  707. errorMessage = error.errMsg;
  708. } else if (error.message) {
  709. errorMessage = error.message;
  710. }
  711. uni.showToast({
  712. title: errorMessage,
  713. icon: 'none',
  714. duration: 2000
  715. });
  716. }
  717. // 防抖函数
  718. const debounce = (func, wait) => {
  719. let timeout;
  720. return (...args) => {
  721. clearTimeout(timeout);
  722. timeout = setTimeout(() => {
  723. func.apply(null, args);
  724. }, wait);
  725. };
  726. }
  727. // 输入框获取焦点
  728. const onInputFocus = () => {
  729. nextTick(() => {
  730. scrollToBottom();
  731. });
  732. }
  733. // 滚动到底部
  734. const scrollToBottom = () => {
  735. nextTick(() => {
  736. const query = uni.createSelectorQuery();
  737. query.select('.chat-list').boundingClientRect(data => {
  738. if (data) {
  739. scrollTop.value = data.height + 1000;
  740. }
  741. }).exec();
  742. });
  743. }
  744. const onScroll = (e) => {
  745. // 可以添加滚动事件处理
  746. }
  747. const getCurrentTime = () => {
  748. return getFormattedTime(new Date());
  749. }
  750. const getFormattedTime = (date) => {
  751. const hours = date.getHours().toString().padStart(2, '0');
  752. const minutes = date.getMinutes().toString().padStart(2, '0');
  753. return `${hours}:${minutes}`;
  754. }
  755. const useQuestion = (question) => {
  756. inputMessage.value = question;
  757. }
  758. const containsKeywords = (text) => {
  759. const keywords = ['水稻', '小麦', '玉米', '病虫害', '农药', '化肥', '有机肥', '种植技术'];
  760. return keywords.some(keyword => text.includes(keyword));
  761. }
  762. const formatMessage = (text) => {
  763. // 将文本中的换行符转换为<br>标签
  764. return text.replace(/\n/g, '<br>');
  765. }
  766. const initMessages = () => {
  767. // 确保消息有时间戳
  768. chatMessages.value.forEach(msg => {
  769. if (!msg.timestamp) {
  770. msg.timestamp = new Date().getTime();
  771. }
  772. });
  773. // 按时间排序
  774. chatMessages.value.sort((a, b) => a.timestamp - b.timestamp);
  775. }
  776. const showDateSeparator = (index) => {
  777. // 判断是否需要显示日期分割线
  778. if (index === 0) return true;
  779. const currentMsg = chatMessages.value[index];
  780. const prevMsg = chatMessages.value[index - 1];
  781. // 如果两条消息相隔超过30分钟,或者是不同日期,显示日期分割线
  782. return isDifferentDay(currentMsg.timestamp, prevMsg.timestamp) ||
  783. (currentMsg.timestamp - prevMsg.timestamp > 30 * 60 * 1000);
  784. }
  785. const isDifferentDay = (timestamp1, timestamp2) => {
  786. const date1 = new Date(timestamp1);
  787. const date2 = new Date(timestamp2);
  788. return date1.getDate() !== date2.getDate() ||
  789. date1.getMonth() !== date2.getMonth() ||
  790. date1.getFullYear() !== date2.getFullYear();
  791. }
  792. const formatDateSeparator = (timestamp) => {
  793. const now = new Date();
  794. const msgDate = new Date(timestamp);
  795. // 今天
  796. if (isSameDay(msgDate, now)) {
  797. return '今天 ' + getFormattedTime(msgDate);
  798. }
  799. // 昨天
  800. const yesterday = new Date(now);
  801. yesterday.setDate(now.getDate() - 1);
  802. if (isSameDay(msgDate, yesterday)) {
  803. return '昨天 ' + getFormattedTime(msgDate);
  804. }
  805. // 一周内
  806. const oneWeekAgo = new Date(now);
  807. oneWeekAgo.setDate(now.getDate() - 7);
  808. if (msgDate >= oneWeekAgo) {
  809. const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
  810. return weekdays[msgDate.getDay()] + ' ' + getFormattedTime(msgDate);
  811. }
  812. // 其他日期
  813. return msgDate.getFullYear() + '年' + (msgDate.getMonth() + 1) + '月' + msgDate.getDate() + '日 ' + getFormattedTime(msgDate);
  814. }
  815. const isSameDay = (date1, date2) => {
  816. return date1.getDate() === date2.getDate() &&
  817. date1.getMonth() === date2.getMonth() &&
  818. date1.getFullYear() === date2.getFullYear();
  819. }
  820. // 获取下一轮建议问题列表
  821. const fetchSuggestedQuestions = async (data) => {
  822. console.log("获取下一轮建议问题参数",data);
  823. try {
  824. const response = await chatStreamSuggested(data);
  825. if (response && response.data) {
  826. // 假设接口返回的数据格式为 { data: ["问题1", "问题2", ...] }
  827. if (Array.isArray(response.data) && response.data.length > 0) {
  828. suggestedQuestions.value = response.data;
  829. } else if (response.data.questions && Array.isArray(response.data.questions)) {
  830. // 或者接口返回 { data: { questions: [...] } }
  831. suggestedQuestions.value = response.data.questions;
  832. }
  833. }
  834. } catch (error) {
  835. console.error('获取建议问题失败:', error);
  836. // 失败时保持默认建议问题,不影响用户体验
  837. }
  838. }
  839. // 生命周期钩子
  840. onMounted(() => {
  841. console.log('[生命周期] 组件挂载');
  842. // 捕获全局错误,防止 WebSocket 等错误影响功能
  843. // #ifdef MP-WEIXIN
  844. const originalError = console.error;
  845. console.error = function(...args) {
  846. // 过滤掉 WebSocket 相关错误(可能来自其他组件或框架)
  847. const errorMsg = args.join(' ');
  848. if (errorMsg.includes('WebSocket') || errorMsg.includes('closeSocket')) {
  849. console.warn('[已过滤] WebSocket 错误:', ...args);
  850. return;
  851. }
  852. originalError.apply(console, args);
  853. };
  854. // #endif
  855. // 初始化防抖函数
  856. debouncedSubmitQuestion = debounce(submitQuestion, 300)
  857. // 获取系统信息
  858. try {
  859. const systemInfo = uni.getSystemInfoSync();
  860. statusBarHeight.value = systemInfo.statusBarHeight || 20;
  861. isIOS.value = systemInfo.platform === 'ios';
  862. // 计算底部安全区域(包含 tabbar 高度)
  863. const safeAreaInsetsBottom = systemInfo.safeAreaInsets ? (systemInfo.safeAreaInsets.bottom || 0) : 0;
  864. // 转换为 rpx,并确保至少有基础间距
  865. safeAreaBottom.value = Math.max(safeAreaInsetsBottom * 2, 0);
  866. console.log('[系统信息] 平台:', systemInfo.platform, '状态栏高度:', statusBarHeight.value);
  867. } catch (e) {
  868. console.error('[系统信息] 获取失败:', e);
  869. }
  870. // 初始化消息
  871. initMessages();
  872. // 滚动到底部
  873. nextTick(() => {
  874. scrollToBottom();
  875. });
  876. // 监听来自 renderjs 的自定义事件(仅在 H5 平台)
  877. // #ifdef H5
  878. if (typeof window !== 'undefined') {
  879. window.addEventListener('renderjs-stream-data', (event) => {
  880. console.log('[H5] 通过事件接收到数据:', event.detail);
  881. onStreamData(event.detail);
  882. });
  883. }
  884. // #endif
  885. console.log('[生命周期] 组件挂载完成');
  886. })
  887. // 组件销毁时清理资源
  888. onBeforeUnmount(() => {
  889. console.log('[生命周期] 组件即将卸载,清理资源');
  890. // 停止打字机效果
  891. stopTypingEffect();
  892. // #ifdef H5
  893. // 通知 renderjs 停止连接
  894. if (isProcessing.value) {
  895. try {
  896. renderjsData.value = {
  897. action: 'stop',
  898. timestamp: Date.now()
  899. };
  900. } catch (e) {
  901. console.error('[H5] 停止 renderjs 失败:', e);
  902. }
  903. }
  904. // 移除事件监听器
  905. if (typeof window !== 'undefined') {
  906. try {
  907. window.removeEventListener('renderjs-stream-data', onStreamData);
  908. } catch (e) {
  909. console.error('[H5] 移除事件监听失败:', e);
  910. }
  911. }
  912. // #endif
  913. // #ifdef APP-HARMONY
  914. // 停止鸿蒙端请求
  915. if (isProcessing.value) {
  916. try {
  917. stopSSERequestNative();
  918. } catch (e) {
  919. console.error('[鸿蒙] 停止请求失败:', e);
  920. }
  921. }
  922. // #endif
  923. // #ifdef MP-WEIXIN
  924. // 停止微信小程序请求
  925. if (isProcessing.value) {
  926. try {
  927. stopWeixinRequest();
  928. } catch (e) {
  929. console.error('[微信小程序] 停止请求失败:', e);
  930. }
  931. }
  932. // #endif
  933. // 清理消息队列
  934. messageQueue.value = [];
  935. isProcessing.value = false;
  936. })
  937. </script>
  938. <!-- #ifndef APP-HARMONY -->
  939. <script module="renderModule" lang="renderjs">
  940. // renderjs 模块状态
  941. let eventSource = null;
  942. let reader = null;
  943. let isReading = false;
  944. let eventType = '';
  945. let dataBuffer = [];
  946. let messageIdS = null;
  947. // 启动 SSE 连接
  948. async function startSSE(config, ownerInstance) {
  949. // 先停止之前的连接
  950. stopSSE();
  951. const { url, data, token } = config;
  952. try {
  953. // 使用 fetch API 建立 SSE 连接
  954. const response = await fetch(url, {
  955. method: 'POST',
  956. headers: {
  957. 'Content-Type': 'application/json',
  958. 'Accept': 'text/event-stream',
  959. 'Authorization': `Bearer ${token}`
  960. },
  961. body: JSON.stringify(data)
  962. });
  963. if (!response.ok) {
  964. throw new Error(`HTTP error! status: ${response.status}`);
  965. }
  966. // 获取 reader
  967. reader = response.body.getReader();
  968. const decoder = new TextDecoder('utf-8');
  969. isReading = true;
  970. let buffer = '';
  971. eventType = ''; // 当前事件名
  972. dataBuffer = []; // 当前事件的所有 data 行
  973. // 读取流式数据
  974. while (isReading) {
  975. const { done, value } = await reader.read();
  976. if (done) {
  977. console.log('SSE 流结束');
  978. if (typeof window !== 'undefined') {
  979. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  980. detail: { type: 'end', id: messageIdS }
  981. }));
  982. }
  983. break;
  984. }
  985. // 解码数据
  986. buffer += decoder.decode(value, { stream: true });
  987. // 按行处理
  988. const lines = buffer.split('\n');
  989. buffer = lines.pop() || ''; // 保留最后不完整的行
  990. for (const line of lines) {
  991. processLine(line, ownerInstance);
  992. }
  993. }
  994. } catch (error) {
  995. console.error('SSE 连接错误:', error);
  996. if (typeof window !== 'undefined') {
  997. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  998. detail: {
  999. type: 'error',
  1000. error: error.message || '连接失败'
  1001. }
  1002. }));
  1003. }
  1004. } finally {
  1005. stopSSE();
  1006. }
  1007. }
  1008. // 处理单行数据
  1009. function processLine(line, ownerInstance) {
  1010. if (!line.trim()) return;
  1011. console.log('[renderjs] 处理行数据:', line);
  1012. // 解析 SSE 格式
  1013. if (line.startsWith('event:')) {
  1014. // event: message
  1015. return;
  1016. }
  1017. if (line.startsWith('data:') || line !== '') {
  1018. let data = line;
  1019. if (line.startsWith('data:')) {
  1020. data = data.substring(5).trim();
  1021. }
  1022. if (!data || data.includes("ping")) return;
  1023. console.log('[renderjs] 解析后的数据:', data);
  1024. // 过滤掉 MESSAGE_END 等元数据事件(JSON 格式)
  1025. if (data.startsWith('{') && data.includes('"eventType"')) {
  1026. try {
  1027. const jsonData = JSON.parse(data);
  1028. console.log('[renderjs] JSON 事件:', jsonData);
  1029. // 如果是 MESSAGE_END 事件,通知结束
  1030. if (jsonData.eventType === 'MESSAGE_END' || jsonData.event === 'message_end') {
  1031. console.log('[renderjs] 收到 MESSAGE_END 事件,流式结束');
  1032. messageIdS = jsonData.id;
  1033. if (typeof window !== 'undefined') {
  1034. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  1035. detail: { type: 'end', id: messageIdS }
  1036. }));
  1037. }
  1038. return;
  1039. }
  1040. // 其他元数据事件也忽略
  1041. return;
  1042. } catch (e) {
  1043. console.log('[renderjs] JSON 解析失败,作为普通文本处理');
  1044. // 不是 JSON,继续处理
  1045. }
  1046. }
  1047. // 检查是否是 Thinking 内容
  1048. if (data.includes('<details') && data.includes('<summary>')) {
  1049. console.log('[renderjs] 发送 thinking 类型数据');
  1050. // 使用自定义事件发送数据
  1051. if (typeof window !== 'undefined') {
  1052. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  1053. detail: {
  1054. type: 'thinking',
  1055. content: data
  1056. }
  1057. }));
  1058. }
  1059. } else {
  1060. console.log('[renderjs] 发送 message 类型数据,长度:', data.length);
  1061. // 普通消息内容
  1062. if (typeof window !== 'undefined') {
  1063. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  1064. detail: {
  1065. type: 'message',
  1066. content: data
  1067. }
  1068. }));
  1069. }
  1070. }
  1071. }
  1072. }
  1073. // 停止 SSE 连接
  1074. function stopSSE() {
  1075. isReading = false;
  1076. if (reader) {
  1077. try {
  1078. reader.cancel();
  1079. } catch (e) {
  1080. console.error('关闭 reader 失败:', e);
  1081. }
  1082. reader = null;
  1083. }
  1084. if (eventSource) {
  1085. eventSource.close();
  1086. eventSource = null;
  1087. }
  1088. }
  1089. // 导出方法供 Vue 调用
  1090. export default {
  1091. methods: {
  1092. // 监听 prop 变化
  1093. onDataChange(newValue, oldValue, ownerInstance) {
  1094. if (!newValue || !newValue.action) return;
  1095. if (newValue.action === 'start') {
  1096. startSSE(newValue, ownerInstance);
  1097. } else if (newValue.action === 'stop') {
  1098. stopSSE();
  1099. }
  1100. }
  1101. }
  1102. };
  1103. </script>
  1104. <!-- #endif -->
  1105. <style>
  1106. /* 容器样式 */
  1107. .container {
  1108. position: relative;
  1109. min-height: 100vh;
  1110. background-color: #f5f5f5;
  1111. overflow: hidden;
  1112. /* 防止内容溢出 */
  1113. }
  1114. /* 聊天容器 */
  1115. .chat-container {
  1116. padding: 20rpx 30rpx;
  1117. box-sizing: border-box;
  1118. background-color: #f8f8f8;
  1119. background-image: url('/static/images/chat-bg-pattern.png');
  1120. background-size: 300rpx;
  1121. background-blend-mode: overlay;
  1122. background-opacity: 0.05;
  1123. -webkit-overflow-scrolling: touch;
  1124. /* 增强iOS滚动体验 */
  1125. }
  1126. .chat-list {
  1127. padding-bottom: 30rpx;
  1128. }
  1129. /* 消息项 */
  1130. .message-item {
  1131. display: flex;
  1132. margin-bottom: 30rpx;
  1133. position: relative;
  1134. }
  1135. .message-ai {
  1136. justify-content: flex-start;
  1137. }
  1138. .message-user {
  1139. justify-content: flex-end;
  1140. }
  1141. /* 头像 */
  1142. .avatar-container {
  1143. width: 90rpx;
  1144. height: 90rpx;
  1145. flex-shrink: 0;
  1146. }
  1147. .avatar {
  1148. width: 90rpx;
  1149. height: 90rpx;
  1150. border-radius: 50%;
  1151. background-color: #e0e0e0;
  1152. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  1153. object-fit: cover;
  1154. }
  1155. /* 消息内容 */
  1156. .message-content {
  1157. max-width: 70%;
  1158. margin: 0 20rpx;
  1159. display: flex;
  1160. flex-direction: column;
  1161. }
  1162. .user-content {
  1163. align-items: flex-end;
  1164. }
  1165. .message-bubble {
  1166. padding: 24rpx;
  1167. border-radius: 24rpx;
  1168. position: relative;
  1169. margin-bottom: 10rpx;
  1170. word-wrap: break-word;
  1171. min-width: 80rpx;
  1172. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
  1173. transition: all 0.3s ease;
  1174. max-width: 100%;
  1175. }
  1176. .ai-bubble {
  1177. background-color: #e8f5e9;
  1178. border-top-left-radius: 4rpx;
  1179. }
  1180. .user-bubble {
  1181. background-color: #e3f2fd;
  1182. border-top-right-radius: 4rpx;
  1183. }
  1184. .message-text {
  1185. font-size: 28rpx;
  1186. color: #333;
  1187. line-height: 1.5;
  1188. word-break: break-all;
  1189. }
  1190. .message-time {
  1191. font-size: 22rpx;
  1192. color: #999;
  1193. }
  1194. /* 消息操作按钮 */
  1195. .message-actions {
  1196. display: flex;
  1197. gap: 16rpx;
  1198. margin-top: 12rpx;
  1199. }
  1200. .action-button {
  1201. display: flex;
  1202. align-items: center;
  1203. padding: 8rpx 16rpx;
  1204. background-color: #f5f5f5;
  1205. border-radius: 20rpx;
  1206. border: 1rpx solid #e0e0e0;
  1207. transition: all 0.2s;
  1208. }
  1209. .action-button-hover {
  1210. background-color: #e8f5e9;
  1211. border-color: #a5d6a7;
  1212. }
  1213. .action-icon {
  1214. font-size: 24rpx;
  1215. margin-right: 6rpx;
  1216. }
  1217. .action-text {
  1218. font-size: 24rpx;
  1219. color: #666;
  1220. }
  1221. /* 输入区域 */
  1222. .input-container {
  1223. position: fixed;
  1224. bottom: 0;
  1225. left: 0;
  1226. right: 0;
  1227. background-color: #fff;
  1228. padding: 20rpx 30rpx;
  1229. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
  1230. display: flex;
  1231. flex-direction: column;
  1232. /* z-index: 999; */
  1233. /* 确保在 tabbar 之上 */
  1234. }
  1235. .input-wrapper {
  1236. display: flex;
  1237. align-items: flex-end;
  1238. }
  1239. .message-input {
  1240. flex: 1;
  1241. min-height: 70rpx;
  1242. max-height: 120rpx;
  1243. border-radius: 35rpx;
  1244. background-color: #f5f5f5;
  1245. padding: 15rpx 30rpx;
  1246. font-size: 28rpx;
  1247. color: #333;
  1248. border: 1rpx solid #e0e0e0;
  1249. line-height: 1.4;
  1250. }
  1251. .send-button {
  1252. margin-left: 16rpx;
  1253. width: 76rpx;
  1254. height: 76rpx;
  1255. border-radius: 50%;
  1256. background-color: transparent;
  1257. background-image: none;
  1258. display: flex;
  1259. align-items: center;
  1260. justify-content: center;
  1261. transition: all 0.2s ease;
  1262. position: relative;
  1263. align-self: center;
  1264. }
  1265. .send-button.disabled {
  1266. background-color: transparent;
  1267. background-image: none;
  1268. opacity: 1;
  1269. }
  1270. /* 中止按钮 */
  1271. .stop-button {
  1272. margin-left: 16rpx;
  1273. width: 76rpx;
  1274. height: 76rpx;
  1275. border-radius: 50%;
  1276. background-color: #ff5252;
  1277. display: flex;
  1278. align-items: center;
  1279. justify-content: center;
  1280. transition: all 0.2s ease;
  1281. align-self: center;
  1282. }
  1283. .stop-icon {
  1284. font-size: 36rpx;
  1285. color: white;
  1286. }
  1287. .button-hover {
  1288. transform: scale(0.95);
  1289. }
  1290. @keyframes pulse {
  1291. 0% {
  1292. transform: scale(1);
  1293. }
  1294. 50% {
  1295. transform: scale(0.95);
  1296. }
  1297. 100% {
  1298. transform: scale(1);
  1299. }
  1300. }
  1301. .send-button:active:not(.disabled) {
  1302. animation: pulse 0.3s ease-in-out;
  1303. }
  1304. /* 删除或注释掉之前的样式 */
  1305. .send-icon {
  1306. display: none;
  1307. }
  1308. .send-icon:before {
  1309. display: none;
  1310. }
  1311. .send-icon-text {
  1312. display: none;
  1313. }
  1314. /* 推荐问题区域 */
  1315. .suggested-questions {
  1316. display: flex;
  1317. white-space: nowrap;
  1318. margin-bottom: 15rpx;
  1319. padding: 5rpx 0;
  1320. }
  1321. .question-chip {
  1322. display: inline-block;
  1323. padding: 12rpx 20rpx;
  1324. margin-right: 15rpx;
  1325. background-color: #e8f5e9;
  1326. color: #4CAF50;
  1327. font-size: 24rpx;
  1328. border-radius: 30rpx;
  1329. border: 1rpx solid #a5d6a7;
  1330. }
  1331. /* AI正在输入的样式 */
  1332. .message-bubble.ai-bubble.typing {
  1333. background-color: #f0f0f0;
  1334. }
  1335. .typing-indicator {
  1336. display: flex;
  1337. align-items: center;
  1338. justify-content: center;
  1339. height: 40rpx;
  1340. padding: 0 20rpx;
  1341. }
  1342. .typing-dot {
  1343. width: 10rpx;
  1344. height: 10rpx;
  1345. margin: 0 5rpx;
  1346. background-color: #4CAF50;
  1347. border-radius: 50%;
  1348. opacity: 0.5;
  1349. animation: typingAnimation 1.4s infinite both;
  1350. }
  1351. .typing-dot:nth-child(2) {
  1352. animation-delay: 0.2s;
  1353. }
  1354. .typing-dot:nth-child(3) {
  1355. animation-delay: 0.4s;
  1356. }
  1357. @keyframes typingAnimation {
  1358. 0% {
  1359. opacity: 0.3;
  1360. transform: translateY(0);
  1361. }
  1362. 50% {
  1363. opacity: 1;
  1364. transform: translateY(-5rpx);
  1365. }
  1366. 100% {
  1367. opacity: 0.3;
  1368. transform: translateY(0);
  1369. }
  1370. }
  1371. /* 关键词高亮 */
  1372. .message-text.highlight {
  1373. color: #2E7D32;
  1374. font-weight: 500;
  1375. }
  1376. /* AI免责声明 */
  1377. .ai-disclaimer {
  1378. display: block;
  1379. font-size: 20rpx;
  1380. color: #999;
  1381. margin-top: 12rpx;
  1382. padding-top: 8rpx;
  1383. border-top: 1rpx solid rgba(0, 0, 0, 0.05);
  1384. line-height: 1.4;
  1385. }
  1386. /* 欢迎消息特殊样式 */
  1387. .welcome {
  1388. background-color: #e3f2fd !important;
  1389. border-left: none !important;
  1390. border-radius: 24rpx !important;
  1391. }
  1392. /* 日期分割线样式 */
  1393. .date-separator {
  1394. display: flex;
  1395. align-items: center;
  1396. justify-content: center;
  1397. margin: 20rpx 0;
  1398. }
  1399. .date-separator text {
  1400. background-color: rgba(0, 0, 0, 0.1);
  1401. color: #666;
  1402. font-size: 24rpx;
  1403. padding: 4rpx 20rpx;
  1404. border-radius: 20rpx;
  1405. }
  1406. /* 纸飞机图标 */
  1407. .plane-svg {
  1408. display: none;
  1409. }
  1410. .send-icon-image {
  1411. width: 76rpx;
  1412. height: 76rpx;
  1413. }
  1414. .material-icon {
  1415. font-family: 'Material Icons';
  1416. font-weight: normal;
  1417. font-style: normal;
  1418. font-size: 48rpx;
  1419. line-height: 1;
  1420. letter-spacing: normal;
  1421. text-transform: none;
  1422. display: inline-block;
  1423. white-space: nowrap;
  1424. word-wrap: normal;
  1425. direction: ltr;
  1426. -webkit-font-smoothing: antialiased;
  1427. color: white;
  1428. }
  1429. /* 删除不需要的导航栏样式 */
  1430. .custom-navbar {
  1431. display: none;
  1432. }
  1433. .navbar-bg,
  1434. .navbar-content,
  1435. .navbar-left,
  1436. .navbar-title,
  1437. .navbar-right,
  1438. .back-icon,
  1439. .arrow-left {
  1440. display: none;
  1441. }
  1442. /* renderjs 容器(隐藏) */
  1443. .renderjs-container {
  1444. display: none;
  1445. width: 0;
  1446. height: 0;
  1447. opacity: 0;
  1448. position: absolute;
  1449. pointer-events: none;
  1450. }
  1451. </style>