Просмотр исходного кода

feat: add nutrition chat UI to frontend

Add nutrition mode toggle to chat.vue with mode=nutrition parameter, recommendation card rendering, and nutrition-specific quick questions. Add green FAB button on health-report.vue to navigate to nutrition chat. Add aiSendNutritionMessage() API wrapper.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 4 месяцев назад
Родитель
Сommit
b637db324e
3 измененных файлов с 184 добавлено и 17 удалено
  1. 139 16
      cfc-frontend/pages/ai/chat.vue
  2. 40 1
      cfc-frontend/pages/body/health-report.vue
  3. 5 0
      cfc-frontend/utils/api.js

+ 139 - 16
cfc-frontend/pages/ai/chat.vue

@@ -5,7 +5,7 @@
       <view class="nav-back" @click="goBack">
         <text class="nav-back-icon">‹</text>
       </view>
-      <text class="nav-title">AI 家庭助手</text>
+      <text class="nav-title">{{ isNutrition ? '精准营养助手' : 'AI 家庭助手' }}</text>
       <view class="nav-actions">
         <text class="nav-action" @click="showConversations = !showConversations">☰</text>
       </view>
@@ -34,22 +34,30 @@
       @scrolltoupper="loadMoreMessages" id="messageList">
       <view class="message-list-inner">
         <view class="welcome-card" v-if="messages.length === 0 && !sending">
-          <view class="welcome-icon">🤖</view>
-          <text class="welcome-title">你好!我是你的家庭助手</text>
-          <text class="welcome-desc">我可以帮你了解孩子的成长情况,比如:</text>
+          <view class="welcome-icon">{{ isNutrition ? '🥗' : '🤖' }}</view>
+          <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : '你好!我是你的家庭助手' }}</text>
+          <text class="welcome-desc">{{ isNutrition ? '我可以基于菌群检测报告,为你提供个性化的营养建议和推荐' : '我可以帮你了解孩子的成长情况,比如:' }}</text>
           <view class="welcome-suggestions">
-            <view class="welcome-suggestion" @click="quickQuestion('小明最近任务完成怎么样?')">
+            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('小明最近任务完成怎么样?')">
               <text class="suggestion-icon">📋</text>
               <text class="suggestion-text">小明最近任务完成怎么样?</text>
             </view>
-            <view class="welcome-suggestion" @click="quickQuestion('我家孩子的能量评分怎么样?')">
+            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('我家孩子的能量评分怎么样?')">
               <text class="suggestion-icon">⚡</text>
               <text class="suggestion-text">孩子的能量评分怎么样?</text>
             </view>
-            <view class="welcome-suggestion" @click="quickQuestion('今天有什么需要我做的事情?')">
+            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('今天有什么需要我做的事情?')">
               <text class="suggestion-icon">✅</text>
               <text class="suggestion-text">今天有什么需要我做的事情?</text>
             </view>
+            <view class="welcome-suggestion" v-if="isNutrition" @click="quickQuestion('根据报告,我的孩子需要补充哪些营养?')">
+              <text class="suggestion-icon">🥦</text>
+              <text class="suggestion-text">根据报告,我的孩子需要补充哪些营养?</text>
+            </view>
+            <view class="welcome-suggestion" v-if="isNutrition" @click="quickQuestion('有哪些食物或产品推荐?')">
+              <text class="suggestion-icon">🛒</text>
+              <text class="suggestion-text">有哪些食物或产品推荐?</text>
+            </view>
           </view>
         </view>
 
@@ -61,7 +69,21 @@
           <!-- AI 回复 -->
           <view class="message message-ai" v-if="msg.answer">
             <view class="message-avatar">🤖</view>
-            <view class="message-bubble ai-bubble">{{ msg.answer }}</view>
+            <view class="message-bubble ai-bubble">
+              <text class="ai-answer-text">{{ msg.answer }}</text>
+              <!-- 推荐卡片 -->
+              <view class="rec-list" v-if="msg.recommendations && msg.recommendations.length > 0">
+                <view class="rec-card" v-for="(rec, ri) in msg.recommendations" :key="ri" @click="gotoRec(rec)">
+                  <image class="rec-cover" v-if="rec.coverImage" :src="rec.coverImage" mode="aspectFill" />
+                  <view class="rec-info">
+                    <text class="rec-type-tag">{{ rec.type === 'product' ? '商品' : rec.type === 'activity' ? '活动' : '文章' }}</text>
+                    <text class="rec-name">{{ rec.name }}</text>
+                    <text class="rec-desc">{{ rec.description }}</text>
+                    <text class="rec-price" v-if="rec.price">¥{{ rec.price }}</text>
+                  </view>
+                </view>
+              </view>
+            </view>
           </view>
         </view>
 
@@ -94,7 +116,7 @@
 </template>
 
 <script>
-import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation } from '../../utils/api.js'
+import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 
 export default {
@@ -111,12 +133,20 @@ export default {
       pageLoaded: false,
       reportId: null,
       surveyId: null,
-      pendingFirstMessage: ''
+      pendingFirstMessage: '',
+      mode: '',
+      recommendations: []
+    }
+  },
+  computed: {
+    isNutrition: function() {
+      return this.mode === 'nutrition'
     }
   },
   onLoad: function(options) {
     this.reportId = options.reportId ? parseInt(options.reportId) : null
     this.surveyId = options.surveyId ? parseInt(options.surveyId) : null
+    this.mode = options.mode || ''
     if (options.firstMessage) {
       this.pendingFirstMessage = decodeURIComponent(options.firstMessage)
     }
@@ -204,13 +234,26 @@ export default {
         if (this.surveyId) {
           params.surveyId = this.surveyId
         }
-        const res = await aiSendMessage(params)
-        const data = res.data || {}
 
-        // 更新最后一条消息的回复
-        const lastMsg = this.messages[this.messages.length - 1]
-        if (lastMsg) {
-          lastMsg.answer = data.answer || ''
+        var res, data
+        if (this.isNutrition && this.reportId) {
+          // 营养模式:调精准营养助手
+          res = await aiSendNutritionMessage(params)
+          data = res.data || {}
+          // 更新最后一条消息的回复 + 推荐列表
+          const lastMsg = this.messages[this.messages.length - 1]
+          if (lastMsg) {
+            lastMsg.answer = data.answer || ''
+            lastMsg.recommendations = data.recommendations || []
+          }
+        } else {
+          res = await aiSendMessage(params)
+          data = res.data || {}
+          // 更新最后一条消息的回复
+          const lastMsg = this.messages[this.messages.length - 1]
+          if (lastMsg) {
+            lastMsg.answer = data.answer || ''
+          }
         }
 
         // 更新 conversationId(新建会话时 Dify 返回)
@@ -238,6 +281,12 @@ export default {
       this.sendMessage()
     },
 
+    gotoRec(rec) {
+      if (rec.url) {
+        uni.navigateTo({ url: rec.url })
+      }
+    },
+
     loadMoreMessages() {
       // 分页加载历史(当前简化)
     },
@@ -572,4 +621,78 @@ export default {
   0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
   30% { opacity: 1; transform: translateY(-8rpx); }
 }
+
+/* 推荐卡片 */
+.ai-answer-text {
+  display: block;
+  margin-bottom: 16rpx;
+  line-height: 1.7;
+}
+
+.rec-list {
+  border-top: 1rpx solid var(--border-light, #E2E8F0);
+  padding-top: 16rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+
+.rec-card {
+  display: flex;
+  flex-direction: row;
+  background: var(--bg, #FFF7ED);
+  border-radius: 12rpx;
+  overflow: hidden;
+  padding: 12rpx;
+}
+
+.rec-cover {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 8rpx;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+  background: #E2E8F0;
+}
+
+.rec-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+  min-width: 0;
+}
+
+.rec-type-tag {
+  display: inline-block;
+  font-size: 18rpx;
+  color: var(--color-primary, #5B9BD5);
+  background: rgba(91, 155, 213, 0.1);
+  padding: 2rpx 12rpx;
+  border-radius: 8rpx;
+  align-self: flex-start;
+}
+
+.rec-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: var(--text, #1E293B);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rec-desc {
+  font-size: 22rpx;
+  color: var(--text-secondary, #64748B);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rec-price {
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: 600;
+}
 </style>

+ 40 - 1
cfc-frontend/pages/body/health-report.vue

@@ -70,6 +70,12 @@
 
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
+
+    <!-- 营养分析入口按钮 -->
+    <view class="nutrition-fab" @click="goNutritionChat" v-if="report">
+      <text class="fab-icon">🥗</text>
+      <text class="fab-text">精准营养分析</text>
+    </view>
   </view>
 </template>
 
@@ -200,6 +206,12 @@ export default {
     goBack: function() {
       uni.navigateBack()
     },
+    goNutritionChat: function() {
+      if (!this.reportId) return
+      uni.navigateTo({
+        url: '/pages/ai/chat?mode=nutrition&reportId=' + this.reportId + '&firstMessage=' + encodeURIComponent('请根据这份菌群检测报告,分析我孩子的营养状况并提出建议')
+      })
+    },
     formatDate: function(dateStr) {
       if (!dateStr) return '--'
       try {
@@ -434,6 +446,33 @@ export default {
 
 /* ===== 底部 ===== */
 .bottom-spacer {
-  height: 60rpx;
+  height: 130rpx;
+}
+
+/* ===== 营养分析入口 FAB ===== */
+.nutrition-fab {
+  position: fixed;
+  bottom: 40rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  background: linear-gradient(135deg, #43A047, #66BB6A);
+  color: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20rpx 40rpx;
+  border-radius: 50rpx;
+  box-shadow: 0 8rpx 24rpx rgba(67, 160, 71, 0.4);
+  z-index: 200;
+}
+
+.fab-icon {
+  font-size: 36rpx;
+  margin-right: 12rpx;
+}
+
+.fab-text {
+  font-size: 30rpx;
+  font-weight: 600;
 }
 </style>

+ 5 - 0
cfc-frontend/utils/api.js

@@ -839,6 +839,11 @@ export const aiDeleteConversation = (id) => {
   return request('/api/ai/chat/conversations/' + id + '/delete', 'POST', {})
 }
 
+// 精准营养助手(基于菌群报告)
+export const aiSendNutritionMessage = (data) => {
+  return request('/api/ai/nutrition/send', 'POST', data)
+}
+
 // ===================== 浠艾福俱乐部 - 服务商模块 =====================
 
 // 提交服务商入驻申请