chat.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. <template>
  2. <view class="chat-container">
  3. <!-- 导航栏 -->
  4. <view class="chat-nav">
  5. <view class="nav-back" @click="goBack">
  6. <text class="nav-back-icon">‹</text>
  7. </view>
  8. <text class="nav-title">{{ isNutrition ? '精准营养助手' : 'AI 家庭助手' }}</text>
  9. <view class="nav-actions">
  10. <text class="nav-action" @click="showConversations = !showConversations">☰</text>
  11. </view>
  12. </view>
  13. <!-- 会话列表(侧拉) -->
  14. <view class="conv-overlay" v-if="showConversations" @click="showConversations = false"></view>
  15. <view class="conv-sidebar" :class="{ 'conv-sidebar-open': showConversations }">
  16. <view class="conv-header">
  17. <text class="conv-header-title">会话列表</text>
  18. <text class="conv-header-new" @click="newConversation">+ 新会话</text>
  19. </view>
  20. <scroll-view class="conv-list" scroll-y>
  21. <view class="conv-item" v-for="conv in conversations" :key="conv.id"
  22. :class="{ 'conv-item-active': conv.id === currentConversationId }"
  23. @click="switchConversation(conv.id)">
  24. <text class="conv-item-name">{{ conv.name || '新会话' }}</text>
  25. <text class="conv-item-time">{{ formatTime(conv.updated_at) }}</text>
  26. </view>
  27. <BaseEmpty v-if="conversations.length === 0" text="暂无会话" icon="💬" />
  28. </scroll-view>
  29. </view>
  30. <!-- 消息列表 -->
  31. <scroll-view class="message-list" scroll-y :scroll-into-view="scrollToId" scroll-with-animation
  32. @scrolltoupper="loadMoreMessages" id="messageList">
  33. <view class="message-list-inner">
  34. <view class="welcome-card" v-if="messages.length === 0 && !sending">
  35. <view class="welcome-icon">{{ isNutrition ? '🥗' : '🤖' }}</view>
  36. <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : '你好!我是你的家庭助手' }}</text>
  37. <text class="welcome-desc">{{ isNutrition ? '我可以基于菌群检测报告,为你提供个性化的营养建议和推荐' : '我可以帮你了解孩子的成长情况,比如:' }}</text>
  38. <view class="welcome-suggestions">
  39. <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('小明最近任务完成怎么样?')">
  40. <text class="suggestion-icon">📋</text>
  41. <text class="suggestion-text">小明最近任务完成怎么样?</text>
  42. </view>
  43. <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('我家孩子的能量评分怎么样?')">
  44. <text class="suggestion-icon">⚡</text>
  45. <text class="suggestion-text">孩子的能量评分怎么样?</text>
  46. </view>
  47. <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('今天有什么需要我做的事情?')">
  48. <text class="suggestion-icon">✅</text>
  49. <text class="suggestion-text">今天有什么需要我做的事情?</text>
  50. </view>
  51. <view class="welcome-suggestion" v-if="isNutrition" @click="quickQuestion('根据报告,我的孩子需要补充哪些营养?')">
  52. <text class="suggestion-icon">🥦</text>
  53. <text class="suggestion-text">根据报告,我的孩子需要补充哪些营养?</text>
  54. </view>
  55. <view class="welcome-suggestion" v-if="isNutrition" @click="quickQuestion('有哪些食物或产品推荐?')">
  56. <text class="suggestion-icon">🛒</text>
  57. <text class="suggestion-text">有哪些食物或产品推荐?</text>
  58. </view>
  59. </view>
  60. </view>
  61. <view class="message-group" v-for="(msg, idx) in messages" :key="msg.id">
  62. <!-- 用户消息 -->
  63. <view class="message message-user" v-if="msg.query">
  64. <view class="message-bubble user-bubble">{{ msg.query }}</view>
  65. </view>
  66. <!-- AI 回复 -->
  67. <view class="message message-ai" v-if="msg.answer">
  68. <view class="message-avatar">🤖</view>
  69. <view class="message-bubble ai-bubble">
  70. <text class="ai-answer-text">{{ msg.answer }}</text>
  71. <!-- 推荐卡片 -->
  72. <view class="rec-list" v-if="msg.recommendations && msg.recommendations.length > 0">
  73. <view class="rec-card" v-for="(rec, ri) in msg.recommendations" :key="ri" @click="gotoRec(rec)">
  74. <image class="rec-cover" v-if="rec.coverImage" :src="rec.coverImage" mode="aspectFill" />
  75. <view class="rec-info">
  76. <text class="rec-type-tag">{{ rec.type === 'product' ? '商品' : rec.type === 'activity' ? '活动' : '文章' }}</text>
  77. <text class="rec-name">{{ rec.name }}</text>
  78. <text class="rec-desc">{{ rec.description }}</text>
  79. <text class="rec-price" v-if="rec.price">¥{{ rec.price }}</text>
  80. </view>
  81. </view>
  82. </view>
  83. </view>
  84. </view>
  85. </view>
  86. <!-- 加载中 -->
  87. <view class="message message-ai" v-if="sending">
  88. <view class="message-avatar">🤖</view>
  89. <view class="message-bubble ai-bubble typing">
  90. <text class="typing-dot">.</text>
  91. <text class="typing-dot">.</text>
  92. <text class="typing-dot">.</text>
  93. </view>
  94. </view>
  95. <view id="scrollBottom"></view>
  96. </view>
  97. </scroll-view>
  98. <!-- 输入区 -->
  99. <view class="chat-input-area">
  100. <view class="chat-input-wrap">
  101. <input class="chat-input" v-model="inputText" placeholder="问我关于家庭和孩子的问题..."
  102. :disabled="sending" @confirm="sendMessage" confirm-type="send" />
  103. </view>
  104. <view class="chat-send-btn" :class="{ 'chat-send-active': inputText.trim() }"
  105. @click="sendMessage">
  106. <text class="send-icon">➤</text>
  107. </view>
  108. </view>
  109. </view>
  110. </template>
  111. <script>
  112. import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage } from '../../utils/api.js'
  113. import BaseEmpty from '../../components/BaseEmpty.vue'
  114. export default {
  115. components: { BaseEmpty },
  116. data() {
  117. return {
  118. inputText: '',
  119. messages: [],
  120. conversations: [],
  121. currentConversationId: '',
  122. sending: false,
  123. showConversations: false,
  124. scrollToId: '',
  125. pageLoaded: false,
  126. reportId: null,
  127. surveyId: null,
  128. pendingFirstMessage: '',
  129. mode: '',
  130. recommendations: []
  131. }
  132. },
  133. computed: {
  134. isNutrition: function() {
  135. return this.mode === 'nutrition'
  136. }
  137. },
  138. onLoad: function(options) {
  139. this.reportId = options.reportId ? parseInt(options.reportId) : null
  140. this.surveyId = options.surveyId ? parseInt(options.surveyId) : null
  141. this.mode = options.mode || ''
  142. if (options.firstMessage) {
  143. this.pendingFirstMessage = decodeURIComponent(options.firstMessage)
  144. }
  145. this.loadConversations()
  146. // 如果有预填消息,自动发送
  147. var self = this
  148. if (this.pendingFirstMessage) {
  149. this.$nextTick(function() {
  150. self.inputText = self.pendingFirstMessage
  151. self.pendingFirstMessage = ''
  152. self.sendMessage()
  153. })
  154. }
  155. },
  156. onShow() {
  157. if (this.pageLoaded) {
  158. this.loadConversations()
  159. }
  160. this.pageLoaded = true
  161. },
  162. methods: {
  163. goBack() {
  164. uni.navigateBack()
  165. },
  166. async loadConversations() {
  167. try {
  168. const res = await aiGetConversations()
  169. this.conversations = res.data || []
  170. } catch (e) {
  171. console.log('加载会话列表失败', e)
  172. }
  173. },
  174. async switchConversation(convId) {
  175. this.currentConversationId = convId
  176. this.showConversations = false
  177. this.messages = []
  178. this.$nextTick(() => {
  179. this.scrollToBottom()
  180. })
  181. try {
  182. const res = await aiGetMessages({ conversationId: convId })
  183. this.messages = res.data || []
  184. this.$nextTick(() => {
  185. this.scrollToBottom()
  186. })
  187. } catch (e) {
  188. console.log('加载消息失败', e)
  189. }
  190. },
  191. newConversation() {
  192. this.currentConversationId = ''
  193. this.showConversations = false
  194. this.messages = []
  195. this.inputText = ''
  196. this.$nextTick(() => {
  197. this.scrollToBottom()
  198. })
  199. },
  200. async sendMessage() {
  201. const text = this.inputText.trim()
  202. if (!text || this.sending) return
  203. // 立即显示用户消息
  204. const tempMsg = { id: 'temp_' + Date.now(), query: text, answer: '' }
  205. this.messages.push(tempMsg)
  206. this.inputText = ''
  207. this.sending = true
  208. this.$nextTick(() => {
  209. this.scrollToBottom()
  210. })
  211. try {
  212. var params = {
  213. query: text,
  214. conversationId: this.currentConversationId
  215. }
  216. if (this.reportId) {
  217. params.reportId = this.reportId
  218. }
  219. if (this.surveyId) {
  220. params.surveyId = this.surveyId
  221. }
  222. var res, data
  223. if (this.isNutrition && this.reportId) {
  224. // 营养模式:调精准营养助手
  225. res = await aiSendNutritionMessage(params)
  226. data = res.data || {}
  227. // 更新最后一条消息的回复 + 推荐列表
  228. const lastMsg = this.messages[this.messages.length - 1]
  229. if (lastMsg) {
  230. lastMsg.answer = data.answer || ''
  231. lastMsg.recommendations = data.recommendations || []
  232. }
  233. } else {
  234. res = await aiSendMessage(params)
  235. data = res.data || {}
  236. // 更新最后一条消息的回复
  237. const lastMsg = this.messages[this.messages.length - 1]
  238. if (lastMsg) {
  239. lastMsg.answer = data.answer || ''
  240. }
  241. }
  242. // 更新 conversationId(新建会话时 Dify 返回)
  243. if (data.conversationId && !this.currentConversationId) {
  244. this.currentConversationId = data.conversationId
  245. // 刷新会话列表
  246. this.loadConversations()
  247. }
  248. } catch (e) {
  249. // 显示错误
  250. const lastMsg = this.messages[this.messages.length - 1]
  251. if (lastMsg) {
  252. lastMsg.answer = '抱歉,我暂时无法回答,请稍后再试。'
  253. }
  254. } finally {
  255. this.sending = false
  256. this.$nextTick(() => {
  257. this.scrollToBottom()
  258. })
  259. }
  260. },
  261. quickQuestion(text) {
  262. this.inputText = text
  263. this.sendMessage()
  264. },
  265. gotoRec(rec) {
  266. if (rec.url) {
  267. uni.navigateTo({ url: rec.url })
  268. }
  269. },
  270. loadMoreMessages() {
  271. // 分页加载历史(当前简化)
  272. },
  273. scrollToBottom() {
  274. this.scrollToId = 'scrollBottom'
  275. },
  276. formatTime(timestamp) {
  277. if (!timestamp) return ''
  278. const d = new Date(timestamp * 1000)
  279. const now = new Date()
  280. const diff = now - d
  281. if (diff < 86400000) {
  282. return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
  283. }
  284. return (d.getMonth() + 1) + '/' + d.getDate()
  285. }
  286. }
  287. }
  288. </script>
  289. <style scoped>
  290. .chat-container {
  291. display: flex;
  292. flex-direction: column;
  293. height: 100vh;
  294. background: var(--bg, #FFF7ED);
  295. position: relative;
  296. }
  297. /* 导航栏 */
  298. .chat-nav {
  299. display: flex;
  300. align-items: center;
  301. justify-content: space-between;
  302. padding: 88rpx 24rpx 16rpx;
  303. background: #fff;
  304. border-bottom: 1rpx solid var(--border-light, #E2E8F0);
  305. position: relative;
  306. z-index: 100;
  307. }
  308. .nav-back {
  309. width: 60rpx;
  310. height: 60rpx;
  311. display: flex;
  312. align-items: center;
  313. justify-content: center;
  314. }
  315. .nav-back-icon {
  316. font-size: 40rpx;
  317. color: var(--text, #1E293B);
  318. font-weight: 600;
  319. }
  320. .nav-title {
  321. font-size: 32rpx;
  322. font-weight: 600;
  323. color: var(--text, #1E293B);
  324. }
  325. .nav-actions {
  326. width: 60rpx;
  327. height: 60rpx;
  328. display: flex;
  329. align-items: center;
  330. justify-content: center;
  331. }
  332. .nav-action {
  333. font-size: 36rpx;
  334. color: var(--text-secondary, #64748B);
  335. }
  336. /* 会话列表侧拉 */
  337. .conv-overlay {
  338. position: fixed;
  339. top: 0;
  340. left: 0;
  341. right: 0;
  342. bottom: 0;
  343. background: rgba(0,0,0,0.3);
  344. z-index: 200;
  345. }
  346. .conv-sidebar {
  347. position: fixed;
  348. top: 0;
  349. right: -500rpx;
  350. width: 460rpx;
  351. height: 100vh;
  352. background: #fff;
  353. z-index: 210;
  354. transition: right 0.3s ease;
  355. display: flex;
  356. flex-direction: column;
  357. }
  358. .conv-sidebar-open {
  359. right: 0;
  360. }
  361. .conv-header {
  362. display: flex;
  363. justify-content: space-between;
  364. align-items: center;
  365. padding: 100rpx 24rpx 16rpx;
  366. border-bottom: 1rpx solid var(--border-light, #E2E8F0);
  367. }
  368. .conv-header-title {
  369. font-size: 28rpx;
  370. font-weight: 600;
  371. color: var(--text, #1E293B);
  372. }
  373. .conv-header-new {
  374. font-size: 24rpx;
  375. color: var(--color-primary, #5B9BD5);
  376. font-weight: 500;
  377. }
  378. .conv-list {
  379. flex: 1;
  380. overflow-y: auto;
  381. }
  382. .conv-item {
  383. padding: 20rpx 24rpx;
  384. border-bottom: 1rpx solid var(--border-light, #E2E8F0);
  385. }
  386. .conv-item-active {
  387. background: rgba(91, 155, 213, 0.06);
  388. }
  389. .conv-item-name {
  390. font-size: 26rpx;
  391. color: var(--text, #1E293B);
  392. display: block;
  393. margin-bottom: 6rpx;
  394. overflow: hidden;
  395. text-overflow: ellipsis;
  396. white-space: nowrap;
  397. }
  398. .conv-item-time {
  399. font-size: 20rpx;
  400. color: var(--muted, #94A3B8);
  401. }
  402. /* 消息列表 */
  403. .message-list {
  404. flex: 1;
  405. overflow-y: auto;
  406. padding: 20rpx 24rpx;
  407. }
  408. .message-list-inner {
  409. min-height: 100%;
  410. }
  411. /* 欢迎卡片 */
  412. .welcome-card {
  413. text-align: center;
  414. padding: 60rpx 20rpx 40rpx;
  415. }
  416. .welcome-icon {
  417. font-size: 80rpx;
  418. margin-bottom: 20rpx;
  419. }
  420. .welcome-title {
  421. font-size: 32rpx;
  422. font-weight: 600;
  423. color: var(--text, #1E293B);
  424. display: block;
  425. margin-bottom: 12rpx;
  426. }
  427. .welcome-desc {
  428. font-size: 24rpx;
  429. color: var(--text-secondary, #64748B);
  430. display: block;
  431. margin-bottom: 32rpx;
  432. }
  433. .welcome-suggestions {
  434. display: flex;
  435. flex-direction: column;
  436. gap: 16rpx;
  437. padding: 0 20rpx;
  438. }
  439. .welcome-suggestion {
  440. display: flex;
  441. align-items: center;
  442. background: #fff;
  443. padding: 20rpx 24rpx;
  444. border-radius: 16rpx;
  445. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
  446. }
  447. .suggestion-icon {
  448. font-size: 28rpx;
  449. margin-right: 12rpx;
  450. }
  451. .suggestion-text {
  452. font-size: 24rpx;
  453. color: var(--text, #1E293B);
  454. }
  455. /* 消息 */
  456. .message {
  457. margin-bottom: 24rpx;
  458. }
  459. .message-user {
  460. display: flex;
  461. justify-content: flex-end;
  462. }
  463. .message-ai {
  464. display: flex;
  465. align-items: flex-start;
  466. }
  467. .message-avatar {
  468. width: 48rpx;
  469. height: 48rpx;
  470. font-size: 32rpx;
  471. margin-right: 12rpx;
  472. flex-shrink: 0;
  473. display: flex;
  474. align-items: center;
  475. justify-content: center;
  476. }
  477. .message-bubble {
  478. max-width: 70%;
  479. padding: 16rpx 20rpx;
  480. font-size: 26rpx;
  481. line-height: 1.6;
  482. border-radius: 16rpx;
  483. word-break: break-word;
  484. }
  485. .user-bubble {
  486. background: var(--color-primary, #5B9BD5);
  487. color: #fff;
  488. border-bottom-right-radius: 4rpx;
  489. }
  490. .ai-bubble {
  491. background: #fff;
  492. color: var(--text, #1E293B);
  493. border-bottom-left-radius: 4rpx;
  494. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
  495. }
  496. /* 输入区 */
  497. .chat-input-area {
  498. display: flex;
  499. align-items: center;
  500. padding: 16rpx 24rpx;
  501. padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
  502. background: #fff;
  503. border-top: 1rpx solid var(--border-light, #E2E8F0);
  504. }
  505. .chat-input-wrap {
  506. flex: 1;
  507. background: var(--bg, #FFF7ED);
  508. border-radius: 40rpx;
  509. padding: 0 24rpx;
  510. margin-right: 16rpx;
  511. }
  512. .chat-input {
  513. height: 72rpx;
  514. font-size: 26rpx;
  515. color: var(--text, #1E293B);
  516. }
  517. .chat-send-btn {
  518. width: 72rpx;
  519. height: 72rpx;
  520. border-radius: 50%;
  521. background: var(--border-light, #E2E8F0);
  522. display: flex;
  523. align-items: center;
  524. justify-content: center;
  525. flex-shrink: 0;
  526. }
  527. .chat-send-active {
  528. background: var(--color-primary, #5B9BD5);
  529. }
  530. .send-icon {
  531. font-size: 28rpx;
  532. color: #fff;
  533. }
  534. /* 打字动画 */
  535. .typing {
  536. display: flex;
  537. align-items: center;
  538. gap: 4rpx;
  539. min-height: 40rpx;
  540. }
  541. .typing-dot {
  542. font-size: 40rpx;
  543. line-height: 1;
  544. animation: typingBounce 1.4s infinite;
  545. }
  546. .typing-dot:nth-child(2) {
  547. animation-delay: 0.2s;
  548. }
  549. .typing-dot:nth-child(3) {
  550. animation-delay: 0.4s;
  551. }
  552. @keyframes typingBounce {
  553. 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
  554. 30% { opacity: 1; transform: translateY(-8rpx); }
  555. }
  556. /* 推荐卡片 */
  557. .ai-answer-text {
  558. display: block;
  559. margin-bottom: 16rpx;
  560. line-height: 1.7;
  561. }
  562. .rec-list {
  563. border-top: 1rpx solid var(--border-light, #E2E8F0);
  564. padding-top: 16rpx;
  565. display: flex;
  566. flex-direction: column;
  567. gap: 12rpx;
  568. }
  569. .rec-card {
  570. display: flex;
  571. flex-direction: row;
  572. background: var(--bg, #FFF7ED);
  573. border-radius: 12rpx;
  574. overflow: hidden;
  575. padding: 12rpx;
  576. }
  577. .rec-cover {
  578. width: 120rpx;
  579. height: 120rpx;
  580. border-radius: 8rpx;
  581. margin-right: 12rpx;
  582. flex-shrink: 0;
  583. background: #E2E8F0;
  584. }
  585. .rec-info {
  586. flex: 1;
  587. display: flex;
  588. flex-direction: column;
  589. gap: 4rpx;
  590. min-width: 0;
  591. }
  592. .rec-type-tag {
  593. display: inline-block;
  594. font-size: 18rpx;
  595. color: var(--color-primary, #5B9BD5);
  596. background: rgba(91, 155, 213, 0.1);
  597. padding: 2rpx 12rpx;
  598. border-radius: 8rpx;
  599. align-self: flex-start;
  600. }
  601. .rec-name {
  602. font-size: 26rpx;
  603. font-weight: 600;
  604. color: var(--text, #1E293B);
  605. overflow: hidden;
  606. text-overflow: ellipsis;
  607. white-space: nowrap;
  608. }
  609. .rec-desc {
  610. font-size: 22rpx;
  611. color: var(--text-secondary, #64748B);
  612. overflow: hidden;
  613. text-overflow: ellipsis;
  614. white-space: nowrap;
  615. }
  616. .rec-price {
  617. font-size: 26rpx;
  618. color: #F97316;
  619. font-weight: 600;
  620. }
  621. </style>