index.vue 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344
  1. <template>
  2. <view class="content">
  3. <!-- 合并后的顶部区域 -->
  4. <view class="header-combined">
  5. <view class="left-section">
  6. <view class="back-btn" @tap="goBack">
  7. <text class="back-icon">←</text>
  8. <text class="back-text">返回</text>
  9. </view>
  10. </view>
  11. <view class="middle-section">
  12. <view class="logo-container">
  13. <text class="logo-icon">✈️</text>
  14. <text class="main-title">Gooh旅行</text>
  15. </view>
  16. <text class="sub-title">智能旅行规划专家</text>
  17. </view>
  18. <view class="right-section">
  19. <!-- <view class="test-btn" @tap="testConnection" v-if="isDevelopment">
  20. <text class="test-text">测试</text>
  21. </view>
  22. <view class="console-btn" @tap="testConsoleLog" v-if="isDevelopment">
  23. <text class="console-text">测试控制台</text>
  24. </view> -->
  25. <view class="new-chat-btn" @tap="createNewChat">
  26. <text class="new-chat-icon">+</text>
  27. <text class="new-chat-text">新建对话</text>
  28. </view>
  29. </view>
  30. </view>
  31. <!-- 聊天头部信息 -->
  32. <view class="chat-header">
  33. <view class="assistant-name">
  34. <view class="dot"></view>
  35. <text>旅行AI助手</text>
  36. </view>
  37. </view>
  38. <!-- 消息内容区域 -->
  39. <view class="message-container">
  40. <!-- 消息列表 -->
  41. <scroll-view
  42. class="message-list"
  43. scroll-y="true"
  44. :scroll-top="scrollTop"
  45. :scroll-with-animation="true"
  46. @scrolltoupper="loadMoreMessages"
  47. >
  48. <!-- 欢迎消息与快捷问题组合 -->
  49. <view class="welcome-container" v-if="messages.length > 0 && messages[0].type === 'ai'">
  50. <view class="welcome-message">
  51. <view class="avatar">
  52. <image src="/static/images/ai-avatar.png" mode="aspectFill"></image>
  53. </view>
  54. <view class="message-bubble">
  55. <rich-text :nodes="messages[0].content"></rich-text>
  56. </view>
  57. </view>
  58. <!-- 快捷问题区域 -->
  59. <view class="quick-questions">
  60. <view
  61. v-for="(question, key) in quickQuestions"
  62. :key="key"
  63. class="quick-question"
  64. @tap="handleQuickQuestion(key)"
  65. >
  66. <view class="dot-marker"></view>
  67. <text class="question-text">{{ key }}</text>
  68. <view class="arrow-icon">
  69. <text>›</text>
  70. </view>
  71. </view>
  72. </view>
  73. </view>
  74. <!-- 其他消息 -->
  75. <view
  76. v-for="(msg, index) in displayMessages"
  77. :key="index"
  78. :class="['message-item', msg.type]"
  79. >
  80. <!-- 消息头部(时间和角色) -->
  81. <view class="message-header">
  82. <text class="message-time">{{ formatTime(msg.timestamp) }}</text>
  83. <text class="message-role">{{ msg.type === 'user' ? '我' : 'AI助手' }}</text>
  84. </view>
  85. <!-- 消息内容 -->
  86. <view class="message-content">
  87. <view class="avatar">
  88. <image :src="msg.type === 'user' ? '/static/images/user-avatar.png' : '/static/images/ai-avatar.png'" mode="aspectFill"></image>
  89. </view>
  90. <view class="message-bubble">
  91. <rich-text v-if="!msg.isThinking && msg.type !== 'ai-plan'" :nodes="msg.content"></rich-text>
  92. <view v-else-if="msg.type === 'ai-plan'" class="travel-plan-card">
  93. <!-- 计划头部 -->
  94. <view class="travel-plan-header">
  95. <view class="plan-logo">
  96. <text>Gooh</text>
  97. </view>
  98. </view>
  99. <!-- 基本信息 -->
  100. <view class="travel-plan-basic">
  101. <view class="travel-plan-title">
  102. <text>{{msg.planData.name}}</text>
  103. </view>
  104. <view class="travel-plan-desc">
  105. <text>{{msg.planData.description}}</text>
  106. </view>
  107. <view class="travel-plan-feature">
  108. <text>{{msg.planData.features}}</text>
  109. </view>
  110. <view class="travel-plan-price">
  111. <text>¥{{msg.planData.price}}</text><text v-if="msg.planData.price.includes('-')">(单人经济型)</text>
  112. </view>
  113. </view>
  114. <!-- 行程安排 -->
  115. <view class="travel-plan-schedule">
  116. <!-- 日历指示器 -->
  117. <view class="calendar-indicator">
  118. <text class="month">July</text>
  119. <text class="day">17</text>
  120. </view>
  121. <view class="schedule-title">行程安排</view>
  122. </view>
  123. <!-- 天数指示器 -->
  124. <view class="day-indicator">
  125. <view class="pin-icon">📍</view>
  126. <text class="day-text">第{{msg.planData.currentDay}}天</text>
  127. </view>
  128. <!-- 行程详情 -->
  129. <view class="travel-details">
  130. <view class="detail-item">
  131. <text class="detail-label">旅游地点:</text>
  132. <text class="detail-value">{{msg.planData.city}}</text>
  133. </view>
  134. <view class="detail-item" v-if="msg.planData.attractions">
  135. <text class="detail-label">地点:</text>
  136. <text class="detail-value">{{msg.planData.attractions}}</text>
  137. </view>
  138. <view class="detail-item" v-if="msg.planData.food">
  139. <text class="detail-label">周边美食:</text>
  140. <text class="detail-value">{{msg.planData.food}}</text>
  141. </view>
  142. <view class="detail-item">
  143. <text class="detail-label">安排说明:</text>
  144. <text class="detail-value">{{msg.planData.arrangement}}</text>
  145. </view>
  146. </view>
  147. <!-- 底部信息 -->
  148. <view class="travel-footer">
  149. <view class="footer-item">
  150. <text>{{msg.planData.days}}</text>
  151. </view>
  152. <view class="footer-item">
  153. <text>总花费约为: ¥{{msg.planData.price}}</text>
  154. </view>
  155. <view class="footer-item">
  156. <text>{{msg.planData.spots}}</text>
  157. </view>
  158. </view>
  159. <!-- 备注信息 -->
  160. <view class="travel-note" v-if="msg.planData.note">
  161. <text class="note-label">备注:</text>
  162. <text class="note-content">{{msg.planData.note}}</text>
  163. </view>
  164. </view>
  165. <view v-else class="thinking-dots">
  166. <text>.</text>
  167. <text>.</text>
  168. <text>.</text>
  169. </view>
  170. </view>
  171. </view>
  172. </view>
  173. <!-- 底部留白,防止被输入框遮挡 -->
  174. <view class="bottom-space"></view>
  175. </scroll-view>
  176. </view>
  177. <!-- 输入区域 -->
  178. <view class="input-area">
  179. <input
  180. class="message-input"
  181. type="text"
  182. v-model="inputMessage"
  183. :disabled="isLoading"
  184. :maxlength="maxLength"
  185. placeholder="请输入您的问题(最多300字)"
  186. @confirm="sendMessage"
  187. />
  188. <button
  189. class="send-btn"
  190. :disabled="isLoading || !inputMessage.trim()"
  191. :class="{ 'loading': isLoading }"
  192. @tap="sendMessage"
  193. >
  194. {{ isLoading ? '发送中...' : '发送' }}
  195. </button>
  196. </view>
  197. </view>
  198. </template>
  199. <script>
  200. // 导入AI API
  201. import { chatWithAI, startAISession, getWebSocketUrl, getSSEUrl, getLastAIReply, fetchServerResponse } from '@/pages/api/ai.js';
  202. import { baseUrl } from '@/pages/api/config.js';
  203. export default {
  204. data() {
  205. return {
  206. inputMessage: '',
  207. messages: [],
  208. isLoading: false,
  209. scrollTop: 0,
  210. maxLength: 300,
  211. userId: 'user_123', // 模拟用户ID
  212. sessionId: '',
  213. eventSource: null, // SSE连接对象
  214. pollingInterval: null, // 轮询定时器
  215. isConnected: false, // SSE连接状态
  216. isDevelopment: true, // 控制测试按钮显示
  217. // 快捷问题
  218. quickQuestions: {
  219. '重庆深度5日游': {
  220. destination: '重庆',
  221. days: 5,
  222. budget: 3000,
  223. preferences: ['景点', '美食', '文化'],
  224. transportation: '公共交通'
  225. },
  226. '推荐几个热门旅游城市': {
  227. requestType: 'recommendation',
  228. preferences: ['热门', '旅游城市']
  229. },
  230. '旅游注意事项有哪些': {
  231. requestType: 'travelTips',
  232. preferences: ['安全', '注意事项']
  233. }
  234. }
  235. }
  236. },
  237. computed: {
  238. // 用于显示除欢迎消息外的其他消息
  239. displayMessages() {
  240. // 如果没有消息或只有一条AI欢迎消息,返回空数组
  241. if (this.messages.length === 0 || (this.messages.length === 1 && this.messages[0].type === 'ai')) {
  242. return [];
  243. }
  244. // 如果有多条消息,返回除第一条欢迎消息外的所有消息
  245. if (this.messages.length > 1 && this.messages[0].type === 'ai') {
  246. return this.messages.slice(1);
  247. }
  248. // 否则返回所有消息
  249. return this.messages;
  250. }
  251. },
  252. onLoad(options) {
  253. // 检测环境并设置isDevelopment
  254. // #ifdef H5
  255. if (window && window.location) {
  256. this.isDevelopment = window.location.hostname === 'localhost' || window.location.hostname.includes('192.168');
  257. }
  258. // #endif
  259. // #ifdef MP-WEIXIN
  260. if (typeof __wxConfig !== 'undefined') {
  261. this.isDevelopment = __wxConfig.envVersion === 'develop' || __wxConfig.envVersion === 'trial';
  262. }
  263. // #endif
  264. // 从本地存储加载消息历史
  265. try {
  266. const savedMessages = uni.getStorageSync('chat_messages');
  267. if (savedMessages) {
  268. this.messages = JSON.parse(savedMessages);
  269. } else {
  270. // 如果没有历史消息,添加默认欢迎消息
  271. this.addMessage('您好,欢迎遇见Gooh旅记旅行规划师!我将为您设计专属行程,解答旅途中的各类问题,让旅行无忧。有任何想法,请随时告诉我~', 'ai');
  272. }
  273. } catch (e) {
  274. console.error('加载消息历史失败:', e);
  275. // 出错也添加欢迎消息
  276. this.addMessage('您好,欢迎遇见Gooh旅记旅行规划师!我将为您设计专属行程,解答旅途中的各类问题,让旅行无忧。有任何想法,请随时告诉我~', 'ai');
  277. }
  278. // 初始化会话
  279. this.startSession();
  280. },
  281. onUnload() {
  282. // 保存消息到本地存储
  283. try {
  284. uni.setStorageSync('chat_messages', JSON.stringify(this.messages));
  285. } catch (e) {
  286. console.error('保存消息历史失败:', e);
  287. }
  288. // 关闭SSE连接
  289. if (this.eventSource) {
  290. this.eventSource.close();
  291. }
  292. // 清除轮询
  293. this.stopPolling();
  294. },
  295. methods: {
  296. // 返回旅游规划页面
  297. goBack() {
  298. // 直接跳转到旅游规划页面
  299. uni.switchTab({
  300. url: '/pages/planning/index'
  301. });
  302. },
  303. // 创建新对话
  304. async createNewChat() {
  305. // 关闭现有SSE连接
  306. if (this.eventSource) {
  307. try {
  308. this.eventSource.close();
  309. this.eventSource = null;
  310. } catch (e) {
  311. console.error('关闭SSE连接失败:', e);
  312. }
  313. }
  314. // 清除轮询定时器
  315. if (this.pollingInterval) {
  316. clearInterval(this.pollingInterval);
  317. this.pollingInterval = null;
  318. }
  319. // 清空消息和输入
  320. this.messages = [];
  321. this.inputMessage = '';
  322. this.sessionId = '';
  323. // 开始新会话
  324. await this.startSession();
  325. uni.showToast({
  326. title: '已创建新对话',
  327. icon: 'none'
  328. });
  329. },
  330. // 开始新会话
  331. async startSession() {
  332. try {
  333. const response = await startAISession();
  334. if (response && response.code === 200 && response.sessionId) {
  335. this.sessionId = response.sessionId;
  336. console.log('会话创建成功,sessionId:', this.sessionId);
  337. // 只有在没有消息时才显示AI欢迎消息
  338. if (response.msg && this.messages.length === 0) {
  339. // 使用AI返回的欢迎消息
  340. this.addMessage(response.msg, 'ai');
  341. }
  342. // 确保WebSocket连接在sessionId存在时才建立
  343. if (this.sessionId) {
  344. await this.setupWebSocket();
  345. } else {
  346. throw new Error('未获取到有效的sessionId');
  347. }
  348. } else {
  349. throw new Error(response?.msg || '创建会话失败');
  350. }
  351. } catch (error) {
  352. console.error('创建会话失败:', error);
  353. uni.showToast({
  354. title: error.message || '连接失败,请重试',
  355. icon: 'none'
  356. });
  357. }
  358. },
  359. // 设置SSE连接
  360. async setupWebSocket() {
  361. if (!this.sessionId) {
  362. console.error('无法建立SSE连接:sessionId不存在');
  363. return;
  364. }
  365. // 获取SSE连接URL
  366. const url = getSSEUrl(this.sessionId);
  367. console.log('尝试连接SSE:', url);
  368. // 关闭已有的事件源
  369. if (this.eventSource) {
  370. try {
  371. this.eventSource.close();
  372. console.log('已关闭旧的SSE连接');
  373. } catch (e) {
  374. console.error('关闭旧SSE连接失败:', e);
  375. }
  376. this.eventSource = null;
  377. }
  378. return new Promise((resolve, reject) => {
  379. try {
  380. console.log('开始创建SSE连接...');
  381. // 使用EventSource创建SSE连接
  382. // #ifdef H5
  383. // 在H5环境下使用原生EventSource
  384. if (typeof window !== 'undefined' && window) {
  385. const EventSource = window.EventSource;
  386. if (!EventSource) {
  387. console.log('当前环境不支持EventSource,使用替代方法');
  388. // 在H5环境但不支持EventSource时,使用轮询
  389. this.isConnected = true;
  390. this.setupPolling();
  391. resolve();
  392. return;
  393. }
  394. this.eventSource = new EventSource(url);
  395. this.eventSource.onopen = (event) => {
  396. console.log('SSE连接已打开:', event);
  397. this.isConnected = true;
  398. resolve(event);
  399. };
  400. this.eventSource.onmessage = (event) => {
  401. console.log('收到SSE消息:', event);
  402. this.handleSocketMessage(event);
  403. };
  404. this.eventSource.onerror = (error) => {
  405. console.error('SSE连接错误:', error);
  406. this.isConnected = false;
  407. this.eventSource.close();
  408. this.eventSource = null;
  409. reject(new Error('SSE连接错误'));
  410. };
  411. } else {
  412. // 如果window不存在,使用替代方法
  413. console.log('window对象不存在,使用替代方法连接');
  414. this.isConnected = true;
  415. this.setupPolling();
  416. resolve();
  417. }
  418. // #endif
  419. // #ifdef MP-WEIXIN || APP-PLUS
  420. // 在小程序和APP环境下使用uni.request模拟SSE
  421. console.log('在小程序环境下使用替代方法连接SSE');
  422. this.isConnected = true;
  423. // 仅在加载状态时进行轮询,而不是一直轮询
  424. this.setupPolling();
  425. resolve();
  426. // #endif
  427. // 设置连接超时
  428. setTimeout(() => {
  429. if (!this.isConnected) {
  430. console.error('SSE连接超时');
  431. // #ifdef H5
  432. if (this.eventSource) {
  433. this.eventSource.close();
  434. this.eventSource = null;
  435. }
  436. // #endif
  437. reject(new Error('SSE连接超时'));
  438. }
  439. }, 10000); // 10秒超时
  440. } catch (error) {
  441. console.error('创建SSE连接时发生错误:', error);
  442. reject(error);
  443. }
  444. });
  445. },
  446. // 处理WebSocket消息
  447. handleSocketMessage(res) {
  448. try {
  449. console.log('开始处理WebSocket消息:', res.data);
  450. // 尝试解析消息数据
  451. let data;
  452. try {
  453. data = JSON.parse(res.data);
  454. } catch (parseError) {
  455. // 如果解析失败,可能是纯文本消息
  456. console.log('WebSocket数据不是JSON格式,尝试作为纯文本处理');
  457. data = {
  458. type: 'message',
  459. content: res.data
  460. };
  461. }
  462. console.log('处理后的WebSocket消息数据:', data);
  463. // 如果缺少type字段,默认为message类型
  464. if (!data.type && data.content) {
  465. data.type = 'message';
  466. }
  467. switch(data.type) {
  468. case 'start':
  469. console.log('收到start类型消息');
  470. // 只有在用户发送过消息后才显示"思考中"
  471. if (this.messages.length > 0 && this.messages[this.messages.length - 1].type === 'user') {
  472. this.addMessage('AI正在思考...', 'ai', true);
  473. }
  474. // 在小程序环境中,收到开始消息后启动轮询
  475. // #ifdef MP-WEIXIN || APP-PLUS
  476. this.setupPolling();
  477. // #endif
  478. break;
  479. case 'message':
  480. console.log('收到message类型消息:', data.content);
  481. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  482. this.messages.pop();
  483. }
  484. if (data.content) {
  485. // 检查是否是旅游行程信息
  486. const isPlanContent = this.checkIfTravelPlanContent(data.content);
  487. if (isPlanContent) {
  488. console.log('检测到旅游行程信息,解析为卡片显示');
  489. const planData = this.parseTravelPlanContent(data.content);
  490. this.addTravelPlanMessage(planData);
  491. } else {
  492. let formattedContent = this.formatAIResponse(data.content);
  493. this.addMessage(formattedContent, 'ai');
  494. }
  495. }
  496. break;
  497. case 'done':
  498. console.log('收到done类型消息');
  499. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  500. this.messages.pop();
  501. }
  502. this.isLoading = false;
  503. // 在小程序环境中,收到done消息后停止轮询
  504. // #ifdef MP-WEIXIN || APP-PLUS
  505. this.stopPolling();
  506. // #endif
  507. break;
  508. case 'error':
  509. console.error('收到error类型消息:', data.content);
  510. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  511. this.messages.pop();
  512. }
  513. this.addMessage(`错误: ${data.content}`, 'ai');
  514. this.isLoading = false;
  515. uni.showToast({
  516. title: '发生错误,请重试',
  517. icon: 'none'
  518. });
  519. // 在小程序环境中,收到错误消息后停止轮询
  520. // #ifdef MP-WEIXIN || APP-PLUS
  521. this.stopPolling();
  522. // #endif
  523. break;
  524. default:
  525. console.log('收到未知类型消息:', data);
  526. // 如果没有指定类型,但有内容,尝试显示内容
  527. if (data.content || (typeof data === 'string' && data)) {
  528. const content = data.content || data;
  529. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  530. this.messages.pop();
  531. }
  532. let formattedContent = this.formatAIResponse(content);
  533. this.addMessage(formattedContent, 'ai');
  534. }
  535. break;
  536. }
  537. } catch (error) {
  538. console.error('处理WebSocket消息失败:', error);
  539. this.isLoading = false;
  540. // 尝试直接显示消息内容,无论格式如何
  541. try {
  542. let content = res.data;
  543. // 如果消息是字符串,直接显示
  544. if (typeof content === 'string' && content.length > 0) {
  545. console.log('尝试直接显示原始消息内容');
  546. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  547. this.messages.pop();
  548. }
  549. let formattedContent = this.formatAIResponse(content);
  550. this.addMessage(formattedContent, 'ai');
  551. return;
  552. }
  553. } catch (e) {
  554. console.error('尝试直接显示消息内容也失败:', e);
  555. }
  556. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  557. this.messages.pop();
  558. }
  559. this.addMessage('消息处理出错,请重试', 'ai');
  560. }
  561. },
  562. // 设置轮询 - 仅当需要时才开始轮询,有结果就停止
  563. setupPolling() {
  564. console.log('设置轮询检查');
  565. // 先清除之前的轮询
  566. this.stopPolling();
  567. // 记录轮询次数
  568. this.pollingCount = 0;
  569. const maxPollingCount = 20; // 最多轮询20次,约60秒
  570. // 开始新的轮询
  571. this.pollingInterval = setInterval(async () => {
  572. try {
  573. this.pollingCount++;
  574. console.log(`第${this.pollingCount}次轮询检查`);
  575. // 如果不在加载状态或轮询次数太多,停止轮询
  576. if (!this.isLoading || this.pollingCount > maxPollingCount) {
  577. console.log('条件不满足,停止轮询:', {
  578. isLoading: this.isLoading,
  579. pollingCount: this.pollingCount
  580. });
  581. this.stopPolling();
  582. return;
  583. }
  584. // 检查是否有新消息
  585. const response = await getLastAIReply(this.sessionId);
  586. console.log('轮询结果:', response);
  587. // 如果有回复内容,模拟收到消息事件
  588. if (response && response.code === 200 && response.reply) {
  589. console.log('轮询发现新消息');
  590. this.handleSocketMessage({
  591. data: JSON.stringify({
  592. type: 'message',
  593. content: response.reply
  594. })
  595. });
  596. // 收到回复后,模拟一个done消息
  597. setTimeout(() => {
  598. this.handleSocketMessage({
  599. data: JSON.stringify({
  600. type: 'done'
  601. })
  602. });
  603. }, 500);
  604. // 停止轮询
  605. this.stopPolling();
  606. }
  607. } catch (err) {
  608. console.error('轮询出错:', err);
  609. this.pollingErrorCount = (this.pollingErrorCount || 0) + 1;
  610. // 如果连续错误太多,停止轮询
  611. if (this.pollingErrorCount > 3) {
  612. console.log('轮询错误太多,停止轮询');
  613. this.stopPolling();
  614. }
  615. }
  616. }, 3000); // 每3秒轮询一次
  617. },
  618. // 停止轮询
  619. stopPolling() {
  620. if (this.pollingInterval) {
  621. console.log('停止轮询');
  622. clearInterval(this.pollingInterval);
  623. this.pollingInterval = null;
  624. this.pollingCount = 0;
  625. this.pollingErrorCount = 0;
  626. }
  627. },
  628. // 发送消息
  629. async sendMessage() {
  630. if (!this.inputMessage.trim() || this.isLoading) return;
  631. console.log('准备发送消息:', this.inputMessage);
  632. // 确保有sessionId
  633. if (!this.sessionId) {
  634. console.log('sessionId不存在,先创建新会话');
  635. try {
  636. const response = await startAISession();
  637. if (response && response.code === 200 && response.sessionId) {
  638. this.sessionId = response.sessionId;
  639. console.log('新会话创建成功,sessionId:', this.sessionId);
  640. } else {
  641. throw new Error('创建会话失败');
  642. }
  643. } catch (error) {
  644. console.error('创建会话失败:', error);
  645. uni.showToast({
  646. title: '创建会话失败,请重试',
  647. icon: 'none'
  648. });
  649. return;
  650. }
  651. }
  652. if (this.inputMessage.length > this.maxLength) {
  653. uni.showToast({
  654. title: `消息不能超过${this.maxLength}字`,
  655. icon: 'none'
  656. });
  657. return;
  658. }
  659. // 保存当前消息并清空输入框
  660. const messageToSend = this.inputMessage.trim();
  661. this.inputMessage = '';
  662. // 添加用户消息
  663. this.addMessage(messageToSend, 'user');
  664. // 设置加载状态
  665. this.isLoading = true;
  666. try {
  667. // 检查SSE连接状态
  668. if (!this.isConnected) {
  669. console.warn('SSE未连接,尝试重新连接');
  670. await this.setupWebSocket();
  671. console.log('SSE重连状态:', this.isConnected ? '已连接' : '未连接');
  672. }
  673. // 发送HTTP请求
  674. console.log('发送消息到服务器:', {
  675. sessionId: this.sessionId,
  676. message: messageToSend
  677. });
  678. // 发送消息并等待响应
  679. const response = await chatWithAI(this.sessionId, messageToSend);
  680. console.log('服务器响应:', response);
  681. if (response.code === 200) {
  682. // 直接显示服务器返回的消息作为AI助手回复
  683. if (response.msg) {
  684. console.log('服务器返回的消息:', response.msg);
  685. let formattedContent = this.formatAIResponse(response.msg);
  686. this.addMessage(formattedContent, 'ai');
  687. } else {
  688. // 如果没有msg字段,等待WebSocket响应
  689. console.log('服务器未返回消息,等待WebSocket响应');
  690. }
  691. // 小程序环境中启动轮询
  692. // #ifdef MP-WEIXIN || APP-PLUS
  693. console.log('小程序环境中启动轮询检查');
  694. this.setupPolling();
  695. // #endif
  696. } else if (response.code === 404) {
  697. console.warn('会话不存在,尝试重新开始会话');
  698. const newSessionResponse = await startAISession();
  699. if (newSessionResponse && newSessionResponse.code === 200 && newSessionResponse.sessionId) {
  700. this.sessionId = newSessionResponse.sessionId;
  701. // 重新发送消息
  702. const retryResponse = await chatWithAI(this.sessionId, messageToSend);
  703. console.log('重试响应:', retryResponse);
  704. if (retryResponse.code === 200) {
  705. // 显示重试后服务器返回的消息
  706. if (retryResponse.msg) {
  707. console.log('重试后服务器返回的消息:', retryResponse.msg);
  708. let formattedContent = this.formatAIResponse(retryResponse.msg);
  709. this.addMessage(formattedContent, 'ai');
  710. }
  711. } else {
  712. throw new Error(retryResponse.msg || '重试发送失败');
  713. }
  714. } else {
  715. throw new Error('重新创建会话失败');
  716. }
  717. } else {
  718. throw new Error(response.msg || '发送失败');
  719. }
  720. } catch (error) {
  721. console.error('发送消息失败:', error);
  722. uni.showToast({
  723. title: error.message || '发送失败,请重试',
  724. icon: 'none'
  725. });
  726. this.addMessage('抱歉,发送消息失败,请重试。', 'ai');
  727. } finally {
  728. this.isLoading = false;
  729. }
  730. },
  731. // 处理快捷问题
  732. async handleQuickQuestion(question) {
  733. if (this.isLoading) return;
  734. console.log('处理快捷问题:', question);
  735. this.isLoading = true;
  736. try {
  737. // 确保有sessionId
  738. if (!this.sessionId) {
  739. console.log('sessionId不存在,先创建新会话');
  740. try {
  741. const response = await startAISession();
  742. if (response && response.code === 200 && response.sessionId) {
  743. this.sessionId = response.sessionId;
  744. console.log('新会话创建成功,sessionId:', this.sessionId);
  745. } else {
  746. throw new Error('创建会话失败');
  747. }
  748. } catch (error) {
  749. console.error('创建会话失败:', error);
  750. uni.showToast({
  751. title: '创建会话失败,请重试',
  752. icon: 'none'
  753. });
  754. this.isLoading = false;
  755. return;
  756. }
  757. }
  758. // 添加用户消息
  759. this.addMessage(question, 'user');
  760. // 发送HTTP请求
  761. console.log('发送快捷问题到服务器:', {
  762. sessionId: this.sessionId,
  763. message: question
  764. });
  765. const response = await chatWithAI(this.sessionId, question);
  766. console.log('快捷问题服务器响应:', response);
  767. if (response.code === 200) {
  768. // 直接显示服务器返回的消息作为AI助手回复
  769. if (response.msg) {
  770. console.log('服务器返回的消息:', response.msg);
  771. let formattedContent = this.formatAIResponse(response.msg);
  772. this.addMessage(formattedContent, 'ai');
  773. } else {
  774. // 如果没有msg字段,等待WebSocket响应
  775. console.log('服务器未返回消息,等待WebSocket响应');
  776. }
  777. // 小程序环境中启动轮询
  778. // #ifdef MP-WEIXIN || APP-PLUS
  779. console.log('小程序环境中启动轮询检查');
  780. this.setupPolling();
  781. // #endif
  782. } else if (response.code === 404) {
  783. console.warn('会话不存在,尝试重新开始会话');
  784. this.addMessage('会话已过期,正在重新创建会话...', 'system');
  785. const newSessionResponse = await startAISession();
  786. if (newSessionResponse && newSessionResponse.code === 200 && newSessionResponse.sessionId) {
  787. this.sessionId = newSessionResponse.sessionId;
  788. // 重新发送消息
  789. this.handleQuickQuestion(question);
  790. } else {
  791. throw new Error('重新创建会话失败');
  792. }
  793. } else {
  794. throw new Error(response.msg || '发送失败');
  795. }
  796. } catch (error) {
  797. console.error('处理快捷问题失败:', error);
  798. uni.showToast({
  799. title: error.message || '发送失败,请重试',
  800. icon: 'none'
  801. });
  802. this.addMessage('抱歉,处理问题失败,请重试。', 'ai');
  803. } finally {
  804. this.isLoading = false;
  805. }
  806. },
  807. // 添加消息
  808. addMessage(content, type, isThinking = false) {
  809. console.log('添加新消息:', { content, type, isThinking });
  810. // 如果是用户消息,转义HTML特殊字符
  811. if (type === 'user') {
  812. content = content.replace(/</g, '&lt;').replace(/>/g, '&gt;');
  813. }
  814. const message = {
  815. type: type,
  816. content: content,
  817. isThinking: isThinking,
  818. timestamp: Date.now()
  819. };
  820. // 如果是思考状态的消息,检查是否需要替换之前的思考状态
  821. if (!isThinking && this.messages.length > 0) {
  822. const lastMessage = this.messages[this.messages.length - 1];
  823. if (lastMessage.isThinking && lastMessage.type === type) {
  824. console.log('替换思考状态消息');
  825. this.messages.splice(-1, 1);
  826. }
  827. }
  828. this.messages.push(message);
  829. console.log('当前消息列表:', this.messages);
  830. // 保存到本地存储
  831. try {
  832. uni.setStorageSync('chat_messages', JSON.stringify(this.messages));
  833. console.log('消息已保存到本地存储');
  834. } catch (e) {
  835. console.error('保存消息到本地存储失败:', e);
  836. }
  837. this.$nextTick(() => {
  838. this.scrollToBottom();
  839. });
  840. },
  841. // 滚动到底部
  842. scrollToBottom() {
  843. // 获取消息列表的高度
  844. const query = uni.createSelectorQuery().in(this);
  845. query.select('.message-list').boundingClientRect(data => {
  846. if (data) {
  847. this.scrollTop = data.height;
  848. }
  849. }).exec();
  850. },
  851. // 格式化时间
  852. formatTime(timestamp) {
  853. if (!timestamp) return '';
  854. const date = new Date(timestamp);
  855. const hours = date.getHours().toString().padStart(2, '0');
  856. const minutes = date.getMinutes().toString().padStart(2, '0');
  857. return `${hours}:${minutes}`;
  858. },
  859. // 加载更多消息
  860. loadMoreMessages() {
  861. // 这里可以实现加载历史消息的逻辑
  862. console.log('加载更多消息');
  863. },
  864. // 格式化AI响应的方法增加日志
  865. formatAIResponse(content) {
  866. console.log('开始格式化AI响应:', content);
  867. // 移除可能显示的CSS样式乱码
  868. content = content.replace(/\d+e\d+;margin:[\s\d]+px[\s\d]+px[\s\d]+;"/g, '"');
  869. // 保持换行符
  870. content = content.replace(/\n/g, '<br/>');
  871. // 增强标题格式化,添加图标
  872. content = content.replace(/最佳旅行时间[::]/g, '<div class="enhanced-title"><span class="title-icon">🕒</span><span class="title-text">最佳旅行时间:</span></div>');
  873. content = content.replace(/景点推荐[::]/g, '<div class="enhanced-title"><span class="title-icon">🏞️</span><span class="title-text">景点推荐:</span></div>');
  874. content = content.replace(/美食推荐[::]/g, '<div class="enhanced-title"><span class="title-icon">🍜</span><span class="title-text">美食推荐:</span></div>');
  875. content = content.replace(/住宿推荐[::]/g, '<div class="enhanced-title"><span class="title-icon">🏨</span><span class="title-text">住宿推荐:</span></div>');
  876. content = content.replace(/交通指南[::]/g, '<div class="enhanced-title"><span class="title-icon">🚗</span><span class="title-text">交通指南:</span></div>');
  877. content = content.replace(/旅游贴士[::]/g, '<div class="enhanced-title"><span class="title-icon">💡</span><span class="title-text">旅游贴士:</span></div>');
  878. content = content.replace(/费用预算[::]/g, '<div class="enhanced-title"><span class="title-icon">💰</span><span class="title-text">费用预算:</span></div>');
  879. content = content.replace(/行程安排[::]/g, '<div class="enhanced-title"><span class="title-icon">📅</span><span class="title-text">行程安排:</span></div>');
  880. // 标准Markdown标题格式化
  881. content = content.replace(/###\s*(.*?)(\*\*)?(\*\*)?(\<br\/\>|\s|$)/g, '<h3 class="md-heading">$1</h3>');
  882. content = content.replace(/##\s*(.*?)(\<br\/\>|\s|$)/g, '<h2 class="md-heading">$2</h2>');
  883. content = content.replace(/#\s*(.*?)(\<br\/\>|\s|$)/g, '<h1 class="md-heading">$1</h1>');
  884. // 分隔线
  885. content = content.replace(/---(\<br\/\>|\s|$)/g, '<hr style="border: 0; height: 1px; background: #eee; margin: 20px 0;">');
  886. // 美化列表项,添加彩色圆点
  887. content = content.replace(/- ([^<]+)(?:<br\/>|$)/g, '<div class="enhanced-list-item"><span class="item-bullet"></span>$1</div>');
  888. // 高亮景点名称
  889. content = content.replace(/([^<::]+)[::](?=\s*亚洲|世界|有"|\w)/g, '<span class="spot-name">$1:</span>');
  890. // 加粗
  891. content = content.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>');
  892. // 中文标题【】格式化
  893. content = content.replace(/【(.*?)】/g, '<span class="bracket-title">【$1】</span>');
  894. // 引用或强调样式
  895. content = content.replace(/\*(.*?)\*/g, '<i>$1</i>');
  896. // 旅游相关内容增强
  897. if (content.includes('行程') || content.includes('旅游') || content.includes('景点') || content.includes('住宿') ||
  898. content.includes('Day') || content.includes('预算')) {
  899. console.log('检测到旅游相关内容,进行格式化');
  900. // 高亮价格
  901. content = content.replace(/¥(\d+)/g, '<span class="price-highlight">¥$1</span>');
  902. content = content.replace(/(\d+)元/g, '<span class="price-highlight">$1元</span>');
  903. content = content.replace(/¥(\d+)/g, '<span class="price-highlight">¥$1</span>');
  904. content = content.replace(/¥(\d+[-~]\d+)/g, '<span class="price-highlight">¥$1</span>');
  905. // 高亮时间和日程
  906. content = content.replace(/(Day\d+)[::]/g, '<span class="day-highlight">$1:</span>');
  907. content = content.replace(/(第\d+天)[::]/g, '<span class="day-highlight">$1:</span>');
  908. content = content.replace(/(上午|下午|晚上|早上|中午|傍晚)[::]/g, '<span class="time-highlight">$1:</span>');
  909. // 高亮关键词
  910. content = content.replace(/(著名|必去|推荐|特色|知名)/g, '<span class="keyword-highlight">$1</span>');
  911. console.log('格式化后的内容:', content);
  912. }
  913. return content;
  914. },
  915. async testConnection() {
  916. console.log('开始测试连接');
  917. try {
  918. // 显示加载提示
  919. uni.showLoading({
  920. title: '测试连接中...'
  921. });
  922. // 测试WebSocket连接
  923. if (!this.isConnected) {
  924. console.log('WebSocket未连接,尝试重新连接');
  925. await this.startSession();
  926. }
  927. // 发送测试消息
  928. const testMessage = "测试消息:你好";
  929. console.log('发送测试消息:', testMessage);
  930. // 添加系统测试消息
  931. this.addMessage('开始连接测试...', 'system');
  932. // 测试WebSocket连接
  933. await this.testWebSocketConnection();
  934. // 直接发送测试消息,不设置inputMessage
  935. try {
  936. const response = await chatWithAI(this.sessionId, testMessage);
  937. if (response.code === 200) {
  938. console.log('测试消息发送成功');
  939. } else {
  940. throw new Error(response.msg || '测试消息发送失败');
  941. }
  942. } catch (error) {
  943. throw new Error('测试消息发送失败: ' + error.message);
  944. }
  945. // 隐藏加载提示
  946. uni.hideLoading();
  947. // 显示测试成功提示
  948. uni.showToast({
  949. title: '连接测试成功',
  950. icon: 'success'
  951. });
  952. } catch (error) {
  953. console.error('测试连接失败:', error);
  954. // 隐藏加载提示
  955. uni.hideLoading();
  956. // 显示错误提示
  957. uni.showToast({
  958. title: '测试失败',
  959. icon: 'error'
  960. });
  961. }
  962. },
  963. // WebSocket连接测试
  964. async testWebSocketConnection() {
  965. return new Promise((resolve, reject) => {
  966. try {
  967. // 检查WebSocket连接状态
  968. if (!this.isConnected) {
  969. console.log('WebSocket未连接,无法发送测试消息');
  970. reject(new Error('WebSocket未连接'));
  971. return;
  972. }
  973. console.log('尝试通过WebSocket发送测试消息');
  974. // 发送测试消息
  975. uni.sendSocketMessage({
  976. data: JSON.stringify({type: 'test', message: 'WebSocket测试消息'}),
  977. success: () => {
  978. console.log('WebSocket测试消息发送成功');
  979. this.addMessage('WebSocket测试消息已发送,等待回应...', 'system');
  980. // 设置超时检测,5秒内没收到回应就认为失败
  981. setTimeout(() => {
  982. resolve(); // 我们不等待回应,只测试消息是否能发出去
  983. }, 1000);
  984. },
  985. fail: (error) => {
  986. console.error('WebSocket测试消息发送失败:', error);
  987. this.addMessage('WebSocket测试消息发送失败', 'system');
  988. reject(error);
  989. }
  990. });
  991. } catch (error) {
  992. console.error('WebSocket测试过程出错:', error);
  993. reject(error);
  994. }
  995. });
  996. },
  997. // 轮询获取AI回复
  998. async pollForAIReply(sessionId, maxAttempts = 3, interval = 5000) {
  999. console.log(`开始轮询获取AI回复,最多${maxAttempts}次,间隔${interval}ms`);
  1000. // 添加轮询提示消息
  1001. this.addMessage('正在获取AI回复...', 'system');
  1002. // 检查是否已有回复(通过WebSocket接收)
  1003. const lastMessage = this.messages[this.messages.length - 1];
  1004. if (lastMessage && lastMessage.type === 'ai' && !lastMessage.isThinking) {
  1005. console.log('已通过WebSocket收到回复,停止轮询');
  1006. return;
  1007. }
  1008. let attempt = 0;
  1009. const poll = async () => {
  1010. if (attempt >= maxAttempts) {
  1011. console.log(`已达到最大轮询次数(${maxAttempts}),停止轮询`);
  1012. // 添加轮询失败消息
  1013. this.addMessage('获取AI回复超时,尝试从服务器直接获取回复...', 'system');
  1014. // 如果轮询失败,尝试直接从服务器获取回复
  1015. try {
  1016. // 直接使用chatWithAI请求,可能会返回完整响应
  1017. console.log('尝试通过直接聊天请求获取回复');
  1018. const response = await chatWithAI(sessionId, "获取上一条回复");
  1019. if (response && response.data && response.data.reply) {
  1020. console.log('成功通过chatWithAI获取回复:', response.data.reply);
  1021. // 如果有正在思考的消息,先移除
  1022. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  1023. this.messages.pop();
  1024. }
  1025. // 检查是否是旅游行程信息
  1026. const isPlanContent = this.checkIfTravelPlanContent(response.data.reply);
  1027. if (isPlanContent) {
  1028. console.log('轮询检测到旅游行程信息,解析为卡片显示');
  1029. const planData = this.parseTravelPlanContent(response.data.reply);
  1030. this.addTravelPlanMessage(planData);
  1031. } else {
  1032. // 显示回复
  1033. let formattedContent = this.formatAIResponse(response.data.reply);
  1034. this.addMessage(formattedContent, 'ai');
  1035. }
  1036. this.isLoading = false;
  1037. return;
  1038. }
  1039. } catch (error) {
  1040. console.error('通过直接聊天请求获取回复失败:', error);
  1041. }
  1042. // 如果仍有"思考中"消息,移除它
  1043. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  1044. this.messages.pop();
  1045. this.addMessage('获取回复失败,请重新发送消息', 'ai');
  1046. }
  1047. this.isLoading = false;
  1048. return;
  1049. }
  1050. attempt++;
  1051. console.log(`第${attempt}次轮询获取AI回复`);
  1052. try {
  1053. const response = await getLastAIReply(sessionId);
  1054. console.log(`第${attempt}次轮询响应:`, response);
  1055. if (response && response.code === 200 && response.reply) {
  1056. console.log('轮询成功获取到AI回复:', response.reply);
  1057. // 检查是否已有这条消息
  1058. const lastMessage = this.messages[this.messages.length - 1];
  1059. if (lastMessage && lastMessage.type === 'ai' && !lastMessage.isThinking &&
  1060. lastMessage.content === response.reply) {
  1061. console.log('已有此消息,不重复显示');
  1062. return;
  1063. }
  1064. // 检查是否是旅游行程信息
  1065. const isPlanContent = this.checkIfTravelPlanContent(response.reply);
  1066. if (isPlanContent) {
  1067. console.log('轮询检测到旅游行程信息,解析为卡片显示');
  1068. const planData = this.parseTravelPlanContent(response.reply);
  1069. this.addTravelPlanMessage(planData);
  1070. } else {
  1071. // 显示回复
  1072. let formattedContent = this.formatAIResponse(response.reply);
  1073. this.addMessage(formattedContent, 'ai');
  1074. }
  1075. this.isLoading = false;
  1076. return;
  1077. }
  1078. // 如果未获取到回复,继续轮询
  1079. setTimeout(poll, interval);
  1080. } catch (error) {
  1081. console.error(`第${attempt}次轮询出错:`, error);
  1082. // 如果出错,继续轮询,但不计入失败次数
  1083. setTimeout(poll, interval);
  1084. }
  1085. };
  1086. // 开始第一次轮询
  1087. poll();
  1088. },
  1089. async fetchServerResponse() {
  1090. console.log('开始获取服务器响应');
  1091. try {
  1092. // 显示加载提示
  1093. uni.showLoading({
  1094. title: '获取服务器响应中...'
  1095. });
  1096. // 发送HTTP请求
  1097. console.log('发送HTTP请求到服务器:', {
  1098. sessionId: this.sessionId,
  1099. message: '获取服务器响应',
  1100. baseUrl,
  1101. url: `${baseUrl}/api/ai/travel/fetchServerResponse`
  1102. });
  1103. const response = await fetchServerResponse(this.sessionId);
  1104. console.log('服务器响应:', response);
  1105. if (response.code === 200) {
  1106. console.log('服务器响应获取成功');
  1107. // 如果有正在思考的消息,先移除
  1108. if (this.messages.length > 0 && this.messages[this.messages.length - 1].isThinking) {
  1109. this.messages.pop();
  1110. }
  1111. let formattedContent = this.formatAIResponse(response.data.reply);
  1112. this.addMessage(formattedContent, 'ai');
  1113. } else {
  1114. throw new Error(response.msg || '获取服务器响应失败');
  1115. }
  1116. } catch (error) {
  1117. console.error('获取服务器响应失败:', error);
  1118. uni.showToast({
  1119. title: error.message || '获取服务器响应失败,请重试',
  1120. icon: 'none'
  1121. });
  1122. this.addMessage('抱歉,获取服务器响应失败,请重试。', 'ai');
  1123. } finally {
  1124. this.isLoading = false;
  1125. // 隐藏加载提示
  1126. uni.hideLoading();
  1127. }
  1128. },
  1129. // 检查是否有未接收的消息
  1130. async checkForMissedMessages() {
  1131. if (!this.sessionId) return;
  1132. console.log('检查是否有未接收的消息');
  1133. try {
  1134. // 请求最新消息
  1135. if (this.socketTask && this.isConnected) {
  1136. console.log('WebSocket已连接,发送请求获取最新消息');
  1137. this.socketTask.send({
  1138. data: JSON.stringify({
  1139. type: 'get_last_message',
  1140. sessionId: this.sessionId
  1141. }),
  1142. success() {
  1143. console.log('请求最新消息发送成功');
  1144. },
  1145. fail(error) {
  1146. console.error('请求最新消息发送失败:', error);
  1147. }
  1148. });
  1149. }
  1150. // 通过HTTP接口获取最新回复
  1151. console.log('尝试通过HTTP接口获取最新回复');
  1152. const response = await getLastAIReply(this.sessionId);
  1153. if (response && response.code === 200 && response.reply) {
  1154. console.log('成功获取到最新回复:', response.reply);
  1155. // 检查是否已有这条消息
  1156. const lastMessage = this.messages[this.messages.length - 1];
  1157. if (lastMessage && lastMessage.type === 'ai' && !lastMessage.isThinking &&
  1158. lastMessage.content === response.reply) {
  1159. console.log('已有此消息,不重复显示');
  1160. return;
  1161. }
  1162. // 检查是否是旅游行程信息
  1163. const isPlanContent = this.checkIfTravelPlanContent(response.reply);
  1164. if (isPlanContent) {
  1165. console.log('检测到旅游行程信息,解析为卡片显示');
  1166. const planData = this.parseTravelPlanContent(response.reply);
  1167. this.addTravelPlanMessage(planData);
  1168. } else {
  1169. // 显示普通回复
  1170. let formattedContent = this.formatAIResponse(response.reply);
  1171. this.addMessage(formattedContent, 'ai');
  1172. }
  1173. }
  1174. } catch (error) {
  1175. console.error('检查未接收消息失败:', error);
  1176. }
  1177. },
  1178. // 检查是否是旅游行程信息
  1179. checkIfTravelPlanContent(content) {
  1180. // 检查内容是否包含旅游行程相关关键字
  1181. const keywords = [
  1182. '行程安排', '旅游计划', '旅行计划',
  1183. '计划名称', '计划简介', '行程特色',
  1184. '行程总花费', '行程总费用', '旅游地点',
  1185. '¥', '¥', '预算', '第一天', '第二天',
  1186. '第三天', 'Day1', 'Day2', '第.*天'
  1187. ];
  1188. // 使用正则表达式检查是否包含关键字和行程特征结构
  1189. const contentHasKeywords = keywords.some(keyword => {
  1190. const regex = new RegExp(keyword, 'i');
  1191. return regex.test(content);
  1192. });
  1193. // 检查是否包含旅游格式特征(至少有3个冒号和行程日期特征)
  1194. const hasColonFormat = (content.match(/[::]/g) || []).length >= 3;
  1195. const hasDayPattern = /第\s*\d+\s*天|Day\s*\d+/i.test(content);
  1196. return contentHasKeywords && (hasColonFormat || hasDayPattern);
  1197. },
  1198. // 解析旅游行程信息
  1199. parseTravelPlanContent(content) {
  1200. console.log('开始解析旅游行程内容');
  1201. // 初始化空的计划数据
  1202. const planData = {
  1203. name: '',
  1204. description: '',
  1205. features: '',
  1206. price: '',
  1207. currentDay: '1',
  1208. city: '',
  1209. attractions: '',
  1210. food: '',
  1211. arrangement: '',
  1212. note: '',
  1213. days: '5天4晚',
  1214. spots: '12个地点'
  1215. };
  1216. // 提取行程名称
  1217. const nameMatch = content.match(/计划名称[::]\s*([^\n]+)/);
  1218. if (nameMatch) planData.name = nameMatch[1].trim();
  1219. // 如果没提取到名称,尝试其他模式
  1220. if (!planData.name) {
  1221. const altNameMatch = content.match(/(.*)[日游|旅行|旅游计划]/);
  1222. if (altNameMatch) planData.name = altNameMatch[1].trim();
  1223. }
  1224. // 如果仍未提取到,使用默认值
  1225. if (!planData.name) planData.name = '旅游计划';
  1226. // 提取行程简介
  1227. const descMatch = content.match(/计划简介[::]\s*([^\n]+)/);
  1228. if (descMatch) planData.description = descMatch[1].trim();
  1229. else {
  1230. // 尝试其他可能的简介模式
  1231. const altDescMatch = content.match(/简介[::]\s*([^\n]+)/);
  1232. if (altDescMatch) planData.description = altDescMatch[1].trim();
  1233. }
  1234. // 提取行程特色
  1235. const featuresMatch = content.match(/行程特色[::]\s*([^\n]+)/);
  1236. if (featuresMatch) planData.features = featuresMatch[1].trim();
  1237. // 提取行程总花费
  1238. const priceMatch = content.match(/行程总花费[::]\s*([^\n]+)|预算[::]\s*([^\n]+)|总花费[::]\s*([^\n]+)|花费[::]\s*([^\n]+)/);
  1239. if (priceMatch) {
  1240. planData.price = (priceMatch[1] || priceMatch[2] || priceMatch[3] || priceMatch[4]).trim();
  1241. // 移除价格中的¥或¥符号,统一用前端显示
  1242. planData.price = planData.price.replace(/[¥¥]/g, '');
  1243. }
  1244. // 提取当前天数
  1245. const dayMatch = content.match(/第\s*(\d+)\s*天|Day\s*(\d+)/i);
  1246. if (dayMatch) {
  1247. planData.currentDay = dayMatch[1] || dayMatch[2];
  1248. }
  1249. // 提取城市/国家
  1250. const cityMatch = content.match(/城市[::]\s*([^\n]+)|地点[::]\s*([^\n]+)|国家[::]\s*([^\n]+)/);
  1251. if (cityMatch) planData.city = (cityMatch[1] || cityMatch[2] || cityMatch[3]).trim();
  1252. // 提取景点/地点
  1253. const attractionsMatch = content.match(/地点[::]\s*([^\n]+)|景点[::]\s*([^\n]+)|旅游地点[::]\s*([^\n]+)/);
  1254. if (attractionsMatch) planData.attractions = (attractionsMatch[1] || attractionsMatch[2] || attractionsMatch[3]).trim();
  1255. // 提取美食
  1256. const foodMatch = content.match(/周边美食[::]\s*([^\n]+)|美食[::]\s*([^\n]+)|特色美食[::]\s*([^\n]+)/);
  1257. if (foodMatch) planData.food = (foodMatch[1] || foodMatch[2] || foodMatch[3]).trim();
  1258. // 提取安排说明
  1259. const arrangementMatch = content.match(/安排说明[::]\s*([^\n]+)|安排[::]\s*([^\n]+)|行程安排[::]\s*([^\n]+)/);
  1260. if (arrangementMatch) planData.arrangement = (arrangementMatch[1] || arrangementMatch[2] || arrangementMatch[3]).trim();
  1261. // 提取备注信息
  1262. const noteMatch = content.match(/备注[::]\s*([^\n]+)/);
  1263. if (noteMatch) planData.note = noteMatch[1].trim();
  1264. // 提取总天数
  1265. const totalDaysMatch = content.match(/(\d+)\s*天\s*(\d+)\s*晚|(\d+)\s*日\s*(\d+)\s*晚/);
  1266. if (totalDaysMatch) {
  1267. const days = totalDaysMatch[1] || totalDaysMatch[3];
  1268. const nights = totalDaysMatch[2] || totalDaysMatch[4];
  1269. planData.days = `${days}天${nights}晚`;
  1270. }
  1271. // 提取景点个数
  1272. const spotsMatch = content.match(/(\d+)\s*个景点|(\d+)\s*个地点/);
  1273. if (spotsMatch) {
  1274. const count = spotsMatch[1] || spotsMatch[2];
  1275. planData.spots = `${count}个地点`;
  1276. }
  1277. console.log('解析后的旅游行程数据:', planData);
  1278. return planData;
  1279. },
  1280. // 添加旅游行程消息
  1281. addTravelPlanMessage(planData) {
  1282. console.log('添加旅游行程卡片消息:', planData);
  1283. const message = {
  1284. type: 'ai-plan',
  1285. planData: planData,
  1286. isThinking: false,
  1287. timestamp: Date.now()
  1288. };
  1289. this.messages.push(message);
  1290. console.log('当前消息列表:', this.messages);
  1291. // 保存到本地存储
  1292. try {
  1293. uni.setStorageSync('chat_messages', JSON.stringify(this.messages));
  1294. console.log('消息已保存到本地存储');
  1295. } catch (e) {
  1296. console.error('保存消息到本地存储失败:', e);
  1297. }
  1298. this.$nextTick(() => {
  1299. this.scrollToBottom();
  1300. });
  1301. },
  1302. async testConsoleLog() {
  1303. console.log('开始测试控制台输出');
  1304. try {
  1305. // 显示加载提示
  1306. uni.showLoading({
  1307. title: '测试控制台输出中...'
  1308. });
  1309. // 确保有sessionId
  1310. if (!this.sessionId) {
  1311. console.log('sessionId不存在,先创建新会话');
  1312. const response = await startAISession();
  1313. if (response && response.code === 200 && response.sessionId) {
  1314. this.sessionId = response.sessionId;
  1315. console.log('新会话创建成功,sessionId:', this.sessionId);
  1316. } else {
  1317. throw new Error('创建会话失败');
  1318. }
  1319. }
  1320. // 发送测试消息
  1321. const testMessage = "[CONSOLE_TEST] 这是一条测试控制台输出的消息 " + new Date().toLocaleString();
  1322. console.log('发送测试消息:', testMessage);
  1323. // 添加系统测试消息
  1324. this.addMessage('开始测试控制台输出...', 'system');
  1325. // 发送消息到后端
  1326. const response = await chatWithAI(this.sessionId, testMessage);
  1327. console.log('测试消息发送结果:', response);
  1328. if (response.code === 200) {
  1329. // 添加成功消息
  1330. this.addMessage('测试消息已发送到后端,请检查后端控制台输出', 'system');
  1331. } else {
  1332. throw new Error(response.msg || '发送测试消息失败');
  1333. }
  1334. // 隐藏加载提示
  1335. uni.hideLoading();
  1336. // 显示测试成功提示
  1337. uni.showToast({
  1338. title: '消息已发送',
  1339. icon: 'success'
  1340. });
  1341. } catch (error) {
  1342. console.error('控制台输出测试失败:', error);
  1343. // 隐藏加载提示
  1344. uni.hideLoading();
  1345. // 显示错误提示
  1346. uni.showToast({
  1347. title: '控制台输出测试失败',
  1348. icon: 'error'
  1349. });
  1350. // 添加错误消息
  1351. this.addMessage(`控制台输出测试失败: ${error.message}`, 'system');
  1352. }
  1353. }
  1354. }
  1355. }
  1356. </script>
  1357. <style>
  1358. @font-face {
  1359. font-family: 'iconfont';
  1360. src: url('//at.alicdn.com/t/font_1234567_abcdefg.eot');
  1361. }
  1362. .content {
  1363. display: flex;
  1364. flex-direction: column;
  1365. height: 100vh;
  1366. background: #e6f6ff;
  1367. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1368. padding-top: 0; /* 移除顶部padding */
  1369. padding-bottom: 120rpx; /* 为底部输入框留出空间 */
  1370. box-sizing: border-box;
  1371. }
  1372. /* 底部留白 */
  1373. .bottom-space {
  1374. height: 120rpx;
  1375. }
  1376. /* 合并后的顶部区域 */
  1377. .header-combined {
  1378. display: flex;
  1379. justify-content: space-between;
  1380. align-items: center;
  1381. padding: 20rpx 30rpx;
  1382. background-color: #ffffff;
  1383. position: fixed;
  1384. top: 0;
  1385. left: 0;
  1386. right: 0;
  1387. z-index: 110;
  1388. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
  1389. }
  1390. .left-section {
  1391. flex: 1;
  1392. display: flex;
  1393. align-items: center;
  1394. }
  1395. .middle-section {
  1396. flex: 2;
  1397. display: flex;
  1398. flex-direction: column;
  1399. align-items: center;
  1400. }
  1401. .right-section {
  1402. flex: 1;
  1403. display: flex;
  1404. justify-content: flex-end;
  1405. align-items: center;
  1406. gap: 10rpx;
  1407. }
  1408. .logo-container {
  1409. display: flex;
  1410. align-items: center;
  1411. justify-content: center;
  1412. margin-bottom: 5rpx;
  1413. }
  1414. .logo-icon {
  1415. font-size: 36rpx;
  1416. margin-right: 10rpx;
  1417. }
  1418. .main-title {
  1419. font-size: 36rpx;
  1420. font-weight: bold;
  1421. color: #3a9eeb;
  1422. }
  1423. .sub-title {
  1424. font-size: 24rpx;
  1425. color: #666;
  1426. }
  1427. .back-btn {
  1428. display: flex;
  1429. align-items: center;
  1430. padding: 16rpx;
  1431. border-radius: 30rpx;
  1432. background-color: rgba(0, 153, 255, 0.08);
  1433. transition: all 0.2s ease;
  1434. }
  1435. .back-btn:active {
  1436. background-color: rgba(0, 153, 255, 0.15);
  1437. }
  1438. .back-icon {
  1439. font-size: 32rpx;
  1440. color: #0099ff;
  1441. font-weight: bold;
  1442. margin-right: 5rpx;
  1443. }
  1444. .back-text {
  1445. font-size: 28rpx;
  1446. color: #0099ff;
  1447. font-weight: 500;
  1448. }
  1449. .test-btn {
  1450. display: flex;
  1451. align-items: center;
  1452. padding: 10rpx 16rpx;
  1453. border-radius: 30rpx;
  1454. background-color: rgba(255, 193, 7, 0.1);
  1455. transition: all 0.2s ease;
  1456. }
  1457. .test-btn:active {
  1458. background-color: rgba(255, 193, 7, 0.2);
  1459. }
  1460. .test-text {
  1461. font-size: 24rpx;
  1462. color: #ffc107;
  1463. font-weight: 500;
  1464. }
  1465. .console-btn {
  1466. display: flex;
  1467. align-items: center;
  1468. padding: 10rpx 16rpx;
  1469. border-radius: 30rpx;
  1470. background-color: rgba(255, 193, 7, 0.1);
  1471. transition: all 0.2s ease;
  1472. }
  1473. .console-btn:active {
  1474. background-color: rgba(255, 193, 7, 0.2);
  1475. }
  1476. .console-text {
  1477. font-size: 24rpx;
  1478. color: #ffc107;
  1479. font-weight: 500;
  1480. }
  1481. .new-chat-btn {
  1482. display: flex;
  1483. align-items: center;
  1484. color: #0099ff;
  1485. padding: 10rpx 16rpx;
  1486. border-radius: 30rpx;
  1487. background-color: rgba(0, 153, 255, 0.08);
  1488. transition: all 0.2s ease;
  1489. }
  1490. .new-chat-btn:active {
  1491. background-color: rgba(0, 153, 255, 0.15);
  1492. }
  1493. .new-chat-icon {
  1494. font-size: 28rpx;
  1495. font-weight: bold;
  1496. margin-right: 5rpx;
  1497. color: #0099ff;
  1498. }
  1499. .new-chat-text {
  1500. font-size: 24rpx;
  1501. color: #0099ff;
  1502. font-weight: 500;
  1503. }
  1504. /* 聊天头部 */
  1505. .chat-header {
  1506. padding: 20rpx 30rpx;
  1507. background-color: rgba(255, 255, 255, 0.8);
  1508. border-bottom: 1px solid #f0f0f0;
  1509. margin-top: 120rpx; /* 调整顶部边距,为合并后的标题栏留出空间 */
  1510. margin-bottom: 10rpx;
  1511. position: fixed;
  1512. top: 0;
  1513. left: 0;
  1514. right: 0;
  1515. z-index: 90;
  1516. }
  1517. .assistant-name {
  1518. display: flex;
  1519. align-items: center;
  1520. font-size: 28rpx;
  1521. color: #333;
  1522. font-weight: 500;
  1523. }
  1524. .dot {
  1525. width: 12rpx;
  1526. height: 12rpx;
  1527. background-color: #4facfe;
  1528. border-radius: 50%;
  1529. margin-right: 10rpx;
  1530. }
  1531. /* 消息容器 */
  1532. .message-container {
  1533. flex: 1;
  1534. margin-top: 180rpx; /* 调整顶部边距,为合并后的标题栏和聊天头部留出空间 */
  1535. padding-bottom: 120rpx; /* 为底部输入框留出空间 */
  1536. }
  1537. /* 消息列表 */
  1538. .message-list {
  1539. width: 100%;
  1540. height: calc(100vh - 400rpx); /* 调整高度 */
  1541. padding: 20rpx;
  1542. box-sizing: border-box;
  1543. }
  1544. /* 欢迎消息容器样式 */
  1545. .welcome-container {
  1546. background-color: #fff;
  1547. border-radius: 20rpx;
  1548. margin-bottom: 30rpx;
  1549. overflow: hidden;
  1550. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.03);
  1551. }
  1552. .welcome-message {
  1553. display: flex;
  1554. padding: 30rpx;
  1555. }
  1556. .welcome-message .avatar {
  1557. width: 70rpx;
  1558. height: 70rpx;
  1559. border-radius: 50%;
  1560. overflow: hidden;
  1561. margin-right: 15rpx;
  1562. border: 2rpx solid #f5f5f5;
  1563. flex-shrink: 0;
  1564. }
  1565. .welcome-message .message-bubble {
  1566. background-color: transparent;
  1567. box-shadow: none;
  1568. padding: 0;
  1569. color: #333;
  1570. line-height: 1.6;
  1571. flex: 1;
  1572. }
  1573. /* 快捷问题 */
  1574. .quick-questions {
  1575. padding: 20rpx;
  1576. display: flex;
  1577. flex-direction: column;
  1578. gap: 16rpx;
  1579. border-top: 1px solid rgba(0, 0, 0, 0.03);
  1580. }
  1581. .quick-question {
  1582. background-color: #f8fbfe;
  1583. padding: 24rpx;
  1584. border-radius: 16rpx;
  1585. font-size: 32rpx;
  1586. color: #333;
  1587. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
  1588. display: flex;
  1589. align-items: center;
  1590. position: relative;
  1591. }
  1592. .dot-marker {
  1593. width: 15rpx;
  1594. height: 15rpx;
  1595. background-color: #72cbff;
  1596. border-radius: 50%;
  1597. margin-right: 20rpx;
  1598. flex-shrink: 0;
  1599. }
  1600. .question-text {
  1601. flex: 1;
  1602. font-weight: 500;
  1603. color: #333;
  1604. }
  1605. .arrow-icon {
  1606. width: 50rpx;
  1607. height: 50rpx;
  1608. display: flex;
  1609. align-items: center;
  1610. justify-content: center;
  1611. border-radius: 50%;
  1612. background-color: rgba(114, 203, 255, 0.1);
  1613. flex-shrink: 0;
  1614. color: #72cbff;
  1615. }
  1616. .arrow-icon text {
  1617. color: #72cbff;
  1618. font-size: 36rpx;
  1619. font-weight: bold;
  1620. line-height: 1;
  1621. }
  1622. .message-item {
  1623. margin-bottom: 30rpx;
  1624. animation: fadeIn 0.3s ease;
  1625. }
  1626. .message-header {
  1627. display: flex;
  1628. align-items: center;
  1629. margin-bottom: 10rpx;
  1630. padding: 0 20rpx;
  1631. }
  1632. .message-time {
  1633. font-size: 22rpx;
  1634. color: #999;
  1635. margin-right: 10rpx;
  1636. }
  1637. .message-role {
  1638. font-size: 22rpx;
  1639. color: #666;
  1640. }
  1641. .message-content {
  1642. display: flex;
  1643. align-items: flex-start;
  1644. padding: 0 20rpx;
  1645. }
  1646. .avatar {
  1647. width: 70rpx;
  1648. height: 70rpx;
  1649. border-radius: 50%;
  1650. overflow: hidden;
  1651. margin-right: 15rpx;
  1652. border: 2rpx solid #f5f5f5;
  1653. }
  1654. .avatar image {
  1655. width: 100%;
  1656. height: 100%;
  1657. }
  1658. .message-bubble {
  1659. max-width: 70%;
  1660. padding: 30rpx;
  1661. border-radius: 16rpx;
  1662. font-size: 28rpx;
  1663. line-height: 1.6;
  1664. position: relative;
  1665. word-break: break-word;
  1666. }
  1667. .message-bubble rich-text {
  1668. line-height: 1.6;
  1669. }
  1670. .message-bubble b {
  1671. color: #0099ff;
  1672. font-weight: 600;
  1673. }
  1674. .message-bubble span {
  1675. display: inline-block;
  1676. margin: 0 4rpx;
  1677. }
  1678. .user .message-content {
  1679. flex-direction: row-reverse;
  1680. }
  1681. .user .avatar {
  1682. margin-right: 0;
  1683. margin-left: 15rpx;
  1684. }
  1685. .user .message-bubble {
  1686. background: linear-gradient(135deg, #72cbff 0%, #5bb8eb 100%);
  1687. color: #fff;
  1688. border-radius: 16rpx;
  1689. padding: 30rpx;
  1690. line-height: 1.6;
  1691. }
  1692. .ai .message-bubble {
  1693. background-color: #fff;
  1694. color: #333;
  1695. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.03);
  1696. padding: 30rpx;
  1697. line-height: 1.6;
  1698. border-radius: 16rpx;
  1699. }
  1700. .ai .message-bubble rich-text {
  1701. color: #333;
  1702. line-height: 1.6;
  1703. }
  1704. .ai .message-bubble rich-text ol,
  1705. .ai .message-bubble rich-text ul {
  1706. padding-left: 40rpx;
  1707. margin: 10rpx 0;
  1708. }
  1709. .ai .message-bubble rich-text ol li,
  1710. .ai .message-bubble rich-text ul li {
  1711. margin: 10rpx 0;
  1712. }
  1713. /* 思考中动画 */
  1714. .thinking-dots {
  1715. display: flex;
  1716. align-items: center;
  1717. height: 40rpx;
  1718. }
  1719. .thinking-dots text {
  1720. font-size: 40rpx;
  1721. animation: thinking 1s infinite;
  1722. margin: 0 2rpx;
  1723. color: #666;
  1724. }
  1725. .thinking-dots text:nth-child(2) {
  1726. animation-delay: 0.2s;
  1727. }
  1728. .thinking-dots text:nth-child(3) {
  1729. animation-delay: 0.4s;
  1730. }
  1731. @keyframes thinking {
  1732. 0%, 100% {
  1733. opacity: 0.3;
  1734. transform: translateY(0);
  1735. }
  1736. 50% {
  1737. opacity: 1;
  1738. transform: translateY(-4rpx);
  1739. }
  1740. }
  1741. /* 输入区域 */
  1742. .input-area {
  1743. display: flex;
  1744. padding: 15rpx 20rpx;
  1745. background-color: #ffffff;
  1746. border-top: 1px solid rgba(0, 0, 0, 0.05);
  1747. position: fixed;
  1748. bottom: 0;
  1749. left: 0;
  1750. right: 0;
  1751. z-index: 100;
  1752. }
  1753. .message-input {
  1754. flex: 1;
  1755. height: 70rpx;
  1756. background-color: #f5f7fa;
  1757. border-radius: 35rpx;
  1758. padding: 0 30rpx;
  1759. font-size: 28rpx;
  1760. color: #333;
  1761. }
  1762. .send-btn {
  1763. width: 140rpx;
  1764. height: 70rpx;
  1765. background-color: #72cbff;
  1766. color: #ffffff;
  1767. border-radius: 35rpx;
  1768. margin-left: 15rpx;
  1769. font-size: 28rpx;
  1770. display: flex;
  1771. align-items: center;
  1772. justify-content: center;
  1773. padding: 0;
  1774. }
  1775. .send-btn:active {
  1776. background-color: #5bb8eb;
  1777. }
  1778. .send-btn[disabled] {
  1779. background-color: #cccccc;
  1780. color: #ffffff;
  1781. }
  1782. @keyframes fadeIn {
  1783. from {
  1784. opacity: 0;
  1785. transform: translateY(10rpx);
  1786. }
  1787. to {
  1788. opacity: 1;
  1789. transform: translateY(0);
  1790. }
  1791. }
  1792. /* 系统消息样式 */
  1793. .system .message-bubble {
  1794. background-color: rgba(0, 0, 0, 0.05);
  1795. color: #666;
  1796. text-align: center;
  1797. font-size: 24rpx;
  1798. padding: 10rpx 20rpx;
  1799. margin: 20rpx auto;
  1800. max-width: 80%;
  1801. border-radius: 30rpx;
  1802. }
  1803. .system .avatar {
  1804. display: none;
  1805. }
  1806. .system .message-header {
  1807. justify-content: center;
  1808. }
  1809. /* 旅行计划卡片样式 */
  1810. .travel-plan-card {
  1811. background-color: #fff;
  1812. border-radius: 20rpx;
  1813. overflow: hidden;
  1814. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
  1815. width: 100%;
  1816. margin: 20rpx 0;
  1817. }
  1818. .travel-plan-header {
  1819. padding: 20rpx;
  1820. display: flex;
  1821. justify-content: center;
  1822. background-color: #f8f8f8;
  1823. border-bottom: 1px solid #eee;
  1824. }
  1825. .plan-logo text {
  1826. font-size: 32rpx;
  1827. font-weight: bold;
  1828. color: #4cd964;
  1829. }
  1830. .travel-plan-basic {
  1831. padding: 30rpx;
  1832. background-color: #fff;
  1833. border-bottom: 1px solid rgba(0, 0, 0, 0.05);
  1834. }
  1835. .travel-plan-title {
  1836. margin-bottom: 20rpx;
  1837. }
  1838. .travel-plan-title text {
  1839. font-size: 36rpx;
  1840. font-weight: bold;
  1841. color: #333;
  1842. }
  1843. .travel-plan-desc {
  1844. margin-bottom: 20rpx;
  1845. color: #666;
  1846. line-height: 1.5;
  1847. }
  1848. .travel-plan-feature {
  1849. margin-bottom: 20rpx;
  1850. color: #333;
  1851. line-height: 1.5;
  1852. }
  1853. .travel-plan-price {
  1854. font-weight: bold;
  1855. color: #72cbff;
  1856. font-size: 32rpx;
  1857. }
  1858. .travel-plan-price text:last-child {
  1859. font-size: 24rpx;
  1860. color: #999;
  1861. margin-left: 10rpx;
  1862. font-weight: normal;
  1863. }
  1864. .travel-plan-schedule {
  1865. padding: 30rpx;
  1866. background-color: #fff;
  1867. display: flex;
  1868. align-items: center;
  1869. border-bottom: 1px solid #f5f5f5;
  1870. }
  1871. .calendar-indicator {
  1872. background-color: #ff6b6b;
  1873. color: #fff;
  1874. width: 60rpx;
  1875. height: 80rpx;
  1876. display: flex;
  1877. flex-direction: column;
  1878. align-items: center;
  1879. justify-content: center;
  1880. border-radius: 6rpx;
  1881. margin-right: 20rpx;
  1882. }
  1883. .calendar-indicator .month {
  1884. font-size: 20rpx;
  1885. line-height: 1;
  1886. }
  1887. .calendar-indicator .day {
  1888. font-size: 32rpx;
  1889. font-weight: bold;
  1890. line-height: 1.2;
  1891. }
  1892. .schedule-title {
  1893. font-size: 34rpx;
  1894. font-weight: bold;
  1895. color: #333;
  1896. }
  1897. .day-indicator {
  1898. display: flex;
  1899. align-items: center;
  1900. padding: 20rpx 30rpx;
  1901. background-color: #f8fbff;
  1902. }
  1903. .pin-icon {
  1904. font-size: 36rpx;
  1905. margin-right: 10rpx;
  1906. color: #72cbff;
  1907. }
  1908. .day-text {
  1909. font-size: 32rpx;
  1910. color: #72cbff;
  1911. font-weight: bold;
  1912. }
  1913. .travel-details {
  1914. padding: 20rpx 30rpx;
  1915. background-color: #fff;
  1916. }
  1917. .detail-item {
  1918. margin-bottom: 20rpx;
  1919. display: flex;
  1920. }
  1921. .detail-label {
  1922. color: #72cbff;
  1923. font-size: 28rpx;
  1924. font-weight: bold;
  1925. width: 180rpx;
  1926. flex-shrink: 0;
  1927. }
  1928. .detail-value {
  1929. color: #333;
  1930. font-size: 28rpx;
  1931. line-height: 1.5;
  1932. flex: 1;
  1933. }
  1934. .travel-footer {
  1935. padding: 20rpx 30rpx;
  1936. background-color: #fff;
  1937. display: flex;
  1938. justify-content: space-around;
  1939. border-top: 1px solid #f0f0f0;
  1940. }
  1941. .footer-item {
  1942. text-align: center;
  1943. background-color: #f8f8f8;
  1944. padding: 10rpx 20rpx;
  1945. border-radius: 30rpx;
  1946. }
  1947. .footer-item text {
  1948. font-size: 24rpx;
  1949. color: #666;
  1950. }
  1951. .travel-note {
  1952. padding: 20rpx 30rpx;
  1953. background-color: #f8fbff;
  1954. border-top: 1px solid #f0f0f0;
  1955. }
  1956. .note-label {
  1957. color: #72cbff;
  1958. font-weight: bold;
  1959. font-size: 28rpx;
  1960. margin-right: 10rpx;
  1961. }
  1962. .note-content {
  1963. color: #666;
  1964. font-size: 28rpx;
  1965. line-height: 1.5;
  1966. }
  1967. /* 格式化样式 */
  1968. .md-heading {
  1969. color: #0077e6;
  1970. font-weight: bold;
  1971. margin: 20rpx 0 10rpx 0;
  1972. }
  1973. h1.md-heading {
  1974. font-size: 36rpx;
  1975. margin: 24rpx 0 12rpx 0;
  1976. }
  1977. h2.md-heading {
  1978. font-size: 32rpx;
  1979. margin: 20rpx 0 10rpx 0;
  1980. }
  1981. h3.md-heading {
  1982. font-size: 30rpx;
  1983. margin: 16rpx 0 8rpx 0;
  1984. }
  1985. .enhanced-title {
  1986. display: flex;
  1987. align-items: center;
  1988. margin: 30rpx 0 20rpx 0;
  1989. background-color: #f8fbff;
  1990. padding: 16rpx 20rpx;
  1991. border-radius: 10rpx;
  1992. }
  1993. .title-icon {
  1994. font-size: 36rpx;
  1995. margin-right: 10rpx;
  1996. line-height: 1;
  1997. }
  1998. .title-text {
  1999. font-size: 32rpx;
  2000. color: #0077e6;
  2001. font-weight: bold;
  2002. }
  2003. .enhanced-list-item {
  2004. display: flex;
  2005. align-items: flex-start;
  2006. margin: 16rpx 0;
  2007. padding-left: 10rpx;
  2008. }
  2009. .item-bullet {
  2010. width: 12rpx;
  2011. height: 12rpx;
  2012. background-color: #72cbff;
  2013. border-radius: 50%;
  2014. margin: 14rpx 15rpx 0 0;
  2015. flex-shrink: 0;
  2016. }
  2017. .spot-name {
  2018. color: #0077e6;
  2019. font-weight: bold;
  2020. }
  2021. .bracket-title {
  2022. color: #0077e6;
  2023. font-weight: bold;
  2024. }
  2025. .price-highlight {
  2026. color: #ff6b6b;
  2027. font-weight: bold;
  2028. }
  2029. .day-highlight {
  2030. color: #0099ff;
  2031. font-weight: bold;
  2032. }
  2033. .time-highlight {
  2034. color: #0099ff;
  2035. font-weight: bold;
  2036. }
  2037. .keyword-highlight {
  2038. color: #ff9500;
  2039. font-weight: bold;
  2040. }
  2041. </style>