Sfoglia il codice sorgente

feat(diet-checkin): 接入图片识别食材 + 语音自动填入食物清单

Sisyphus 3 giorni fa
parent
commit
148eb3a926

+ 0 - 95
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2983,12 +2983,6 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         runSystemMigrations();
         registerCollectExpectedIndicators();
-
-        // 减脂专题表创建
-        createFatWeightRecordTable();
-        createFatDietRecordTable();
-        createFatExerciseRecordTable();
-        createFatGoalTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12055,7 +12049,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         return sb.append(']').toString();
     }
 
-
     // 迁移354: 家庭共餐选中食材表(持久化共餐场景食材选择)
     private void createDietFamilySelectedIngredientsTable() {
         try {
@@ -12081,93 +12074,5 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         } catch (Exception e) {
             log.warn("迁移354: 创建 diet_family_selected_ingredients 表失败: {}", e.getMessage());
         }
-    }    // 迁移355: 创建减脂专题体重记录表
-    private void createFatWeightRecordTable() {
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_weight_record (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "member_id BIGINT NOT NULL, " +
-                    "weight DECIMAL(5,2) NOT NULL, " +
-                    "body_fat_percent DECIMAL(4,2) DEFAULT NULL, " +
-                    "waist_circumference DECIMAL(4,2) DEFAULT NULL, " +
-                    "measure_date DATE NOT NULL, " +
-                    "remark VARCHAR(255) DEFAULT NULL, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "INDEX idx_fat_weight_member (member_id, measure_date)) " +
-                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录'");
-            log.info("迁移355: 已创建fat_weight_record表");
-        } catch (Exception e) {
-            log.warn("迁移355: 创建fat_weight_record表失败: {}", e.getMessage());
-        }
-    }
-
-    // 迁移356: 创建减脂专题饮食记录表
-    private void createFatDietRecordTable() {
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_diet_record (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "member_id BIGINT NOT NULL, " +
-                    "meal_type VARCHAR(20) NOT NULL, " +
-                    "food_name VARCHAR(255) NOT NULL, " +
-                    "food_weight DECIMAL(6,2) DEFAULT NULL, " +
-                    "calories INT DEFAULT 0, " +
-                    "protein DECIMAL(6,2) DEFAULT 0, " +
-                    "fat DECIMAL(6,2) DEFAULT 0, " +
-                    "carbs DECIMAL(6,2) DEFAULT 0, " +
-                    "eat_date DATE NOT NULL, " +
-                    "remark VARCHAR(255) DEFAULT NULL, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "INDEX idx_fat_diet_member (member_id, eat_date)) " +
-                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录'");
-            log.info("迁移356: 已创建fat_diet_record表");
-        } catch (Exception e) {
-            log.warn("迁移356: 创建fat_diet_record表失败: {}", e.getMessage());
-        }
     }
-
-    // 迁移357: 创建减脂专题运动记录表
-    private void createFatExerciseRecordTable() {
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_exercise_record (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "member_id BIGINT NOT NULL, " +
-                    "exercise_type VARCHAR(50) NOT NULL, " +
-                    "duration_min INT DEFAULT 0, " +
-                    "distance_km DECIMAL(5,2) DEFAULT NULL, " +
-                    "calories_burned INT DEFAULT 0, " +
-                    "exercise_date DATE NOT NULL, " +
-                    "remark VARCHAR(255) DEFAULT NULL, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "INDEX idx_fat_exercise_member (member_id, exercise_date)) " +
-                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录'");
-            log.info("迁移357: 已创建fat_exercise_record表");
-        } catch (Exception e) {
-            log.warn("迁移357: 创建fat_exercise_record表失败: {}", e.getMessage());
-        }
-    }
-
-    // 迁移358: 创建减脂专题目标表
-    private void createFatGoalTable() {
-        try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS fat_goal (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "member_id BIGINT NOT NULL, " +
-                    "target_weight DECIMAL(5,2) NOT NULL, " +
-                    "start_weight DECIMAL(5,2) NOT NULL, " +
-                    "target_body_fat DECIMAL(4,2) DEFAULT NULL, " +
-                    "target_date DATE DEFAULT NULL, " +
-                    "status VARCHAR(20) DEFAULT 'active', " +
-                    "start_date DATE NOT NULL, " +
-                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-                    "INDEX idx_fat_goal_member (member_id)) " +
-                    "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标'");
-            log.info("迁移358: 已创建fat_goal表");
-        } catch (Exception e) {
-            log.warn("迁移358: 创建fat_goal表失败: {}", e.getMessage());
-        }
-    }
-
-
- (docs: add fatloss mini-program implementation plan)
 }

+ 136 - 5
cfc-frontend/pages/growth/diet-checkin.vue

@@ -131,13 +131,37 @@
       <!-- 底部留白 -->
       <view class="bottom-spacer"></view>
     </scroll-view>
+
+    <!-- 图片识别结果确认弹窗 -->
+    <view class="recognize-overlay" v-if="showRecognizeModal" @tap="cancelRecognize">
+      <view class="recognize-modal" @tap.stop>
+        <view class="recognize-header">
+          <text class="recognize-title">🍽️ 识别结果</text>
+          <text class="recognize-count">{{ recognizedFoods.length }} 种食材</text>
+        </view>
+        <view class="recognize-body">
+          <view class="food-chip" v-for="(f, idx) in recognizedFoods" :key="getFoodsKey(f, idx)">
+            <text class="food-chip-name">{{ f.name }}</text>
+            <text class="food-chip-cat" v-if="f.category && f.category !== 'unknown'">{{ f.category }}</text>
+            <text class="food-chip-del" @tap="removeRecognizedFood(idx)">×</text>
+          </view>
+          <view class="recognize-hint">
+            <text>可手动删除不需要的食材,确认后自动填入食物清单</text>
+          </view>
+        </view>
+        <view class="recognize-footer">
+          <view class="btn-cancel" @tap="cancelRecognize"><text>取消</text></view>
+          <view class="btn-confirm" @tap="confirmRecognize"><text>✓ 确认填入</text></view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
 import config from '../../config'
 import { parseDate } from '../../utils/format.js'
-import { sttTranscribe } from '../../utils/api.js'
+import { sttTranscribe, recognizeDietFood } from '../../utils/api.js'
 
 export default {
   data() {
@@ -151,6 +175,8 @@ export default {
       recorderManager: null,
       history: [],
       todayText: '',
+      showRecognizeModal: false,
+      recognizedFoods: [],
       mealTypes: [
         { label: '早餐', value: 'breakfast' },
         { label: '午餐', value: 'lunch' },
@@ -205,6 +231,10 @@ export default {
       uni.showLoading({ title: '语音识别中...', mask: true })
       sttTranscribe(res.tempFilePath).then(function(text) {
         self.form.voiceText = text
+        // 语音转写成功:若食物清单为空则自动填入
+        if (!self.form.foodItems || !self.form.foodItems.trim()) {
+          self.form.foodItems = text.trim()
+        }
         uni.hideLoading()
         // 保存时停止录音 → 语音已就绪,继续提交
         if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
@@ -265,10 +295,36 @@ export default {
         count: 9 - self.form.photos.length,
         sizeType: ['compressed'],
         sourceType: ['album', 'camera'],
-        success: function(res) {
-          res.tempFilePaths.forEach(function(path) {
-            if (self.form.photos.length < 9) self.form.photos.push(path)
-          })
+        success: async function(res) {
+          for (var i = 0; i < res.tempFilePaths.length; i++) {
+            if (self.form.photos.length >= 9) break
+            var path = res.tempFilePaths[i]
+            self.form.photos.push(path)
+            try {
+              var url = await self.uploadFile(path)
+              if (url) {
+                var recognizeRes = await new Promise(function(resolve) {
+                  uni.request({
+                    url: config.API_BASE_URL + '/api/diet/record/recognize',
+                    method: 'POST',
+                    data: { image_url: url },
+                    header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+                    success: function(r) { resolve(r) },
+                    fail: function() { resolve({ data: { code: 500 } }) }
+                  })
+                })
+                if (recognizeRes.data && recognizeRes.data.code === 200 && recognizeRes.data.data && recognizeRes.data.data.foods) {
+                  var foods = recognizeRes.data.data.foods
+                  for (var f = 0; f < foods.length; f++) {
+                    self.recognizedFoods.push({ name: foods[f].name, category: foods[f].category })
+                  }
+                }
+              }
+            } catch (e) { /* ignore */ }
+          }
+          if (self.recognizedFoods.length > 0) {
+            self.showRecognizeResult()
+          }
         }
       })
     },
@@ -299,6 +355,38 @@ export default {
     deleteVoice: function() {
       this.form.voicePath = ''
     },
+    showRecognizeResult: function() {
+      this.showRecognizeModal = true
+    },
+    confirmRecognize: function() {
+      var self = this
+      var names = []
+      for (var i = 0; i < self.recognizedFoods.length; i++) {
+        if (self.recognizedFoods[i].name && self.recognizedFoods[i].name.trim()) {
+          names.push(self.recognizedFoods[i].name.trim())
+        }
+      }
+      if (names.length > 0) {
+        var newText = names.join('、')
+        if (self.form.foodItems && self.form.foodItems.trim()) {
+          self.form.foodItems = self.form.foodItems + '、' + newText
+        } else {
+          self.form.foodItems = newText
+        }
+      }
+      self.showRecognizeModal = false
+      self.recognizedFoods = []
+    },
+    cancelRecognize: function() {
+      this.showRecognizeModal = false
+      this.recognizedFoods = []
+    },
+    removeRecognizedFood: function(idx) {
+      this.recognizedFoods.splice(idx, 1)
+    },
+    getFoodsKey: function(item, idx) {
+      return item.name || ('food' + idx)
+    },
     uploadFile: function(filePath) {
       var self = this
       return new Promise(function(resolve, reject) {
@@ -477,4 +565,47 @@ success: function(res) {
 
 /* ===== 底部留白 ===== */
 .bottom-spacer { height: 40rpx; }
+
+/* ===== 识别结果弹窗 ===== */
+.recognize-overlay {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.45); z-index: 999;
+  display: flex; align-items: center; justify-content: center;
+}
+.recognize-modal {
+  width: 600rpx; background: #fff; border-radius: 28rpx;
+  overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(0,0,0,0.18);
+}
+.recognize-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 28rpx 32rpx 20rpx; border-bottom: 1rpx solid #f0f0f0;
+}
+.recognize-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.recognize-count { font-size: 24rpx; color: #F97316; font-weight: 500; }
+.recognize-body { padding: 24rpx 32rpx; max-height: 420rpx; overflow-y: auto; }
+.food-chip {
+  display: inline-flex; align-items: center; gap: 8rpx;
+  background: #FFF3E0; border: 1rpx solid #FFE0B2; border-radius: 24rpx;
+  padding: 8rpx 20rpx; margin: 6rpx 8rpx 6rpx 0;
+}
+.food-chip-name { font-size: 26rpx; color: #E65100; font-weight: 500; }
+.food-chip-cat { font-size: 20rpx; color: #999; }
+.food-chip-del {
+  width: 32rpx; height: 32rpx; display: flex; align-items: center; justify-content: center;
+  background: #eee; border-radius: 50%; color: #999; font-size: 24rpx;
+}
+.recognize-hint {
+  margin-top: 16rpx; padding: 12rpx 16rpx; background: #FAFAFA;
+  border-radius: 12rpx;
+}
+.recognize-hint text { font-size: 22rpx; color: #999; }
+.recognize-footer {
+  display: flex; padding: 20rpx 32rpx 32rpx; gap: 20rpx;
+}
+.btn-cancel, .btn-confirm {
+  flex: 1; text-align: center; padding: 18rpx 0; border-radius: 14rpx;
+  font-size: 28rpx; font-weight: 600;
+}
+.btn-cancel { background: #f5f5f5; color: #666; }
+.btn-confirm { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25); }
 </style>