Browse Source

chore: auto bump version and changelog [skip ci]

iwt 1 week ago
parent
commit
a775b4a352

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java

@@ -1,14 +1,17 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
+import com.etotem.cfc.service.FamilyMemberService;
 import com.etotem.cfc.service.FoodRecommendService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
@@ -21,6 +24,9 @@ public class FoodRecommendController {
     @Resource
     private FoodRecommendService foodRecommendService;
 
+    @Resource
+    private FamilyMemberService familyMemberService;
+
     @Operation(summary = "获取用户食材推荐指数")
     @PostMapping("/index")
     public Result<List<FoodRecommendIndex>> getIndex(@RequestBody Map<String, Object> params) {
@@ -67,4 +73,34 @@ public class FoodRecommendController {
         List<FoodRecommendIndex> foods = foodRecommendService.getCautionFoods(memberId != null ? memberId : userId);
         return Result.success(foods);
     }
+
+    @Operation(summary = "获取家庭成员列表(用于戒备页面切换)")
+    @PostMapping("/members")
+    public Result<List<FamilyMemberVO>> getFamilyMembers(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+        return Result.success(members);
+    }
+
+    @Operation(summary = "获取戒备食材趋势数据(历史变化)")
+    @PostMapping("/trend")
+    public Result<List<Map<String, Object>>> getTrend(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) return Result.error("请先登录");
+        Long memberId = null;
+        if (params != null && params.get("memberId") instanceof Number) {
+            memberId = ((Number) params.get("memberId")).longValue();
+        }
+        List<Long> foodIds = new ArrayList<>();
+        if (params != null && params.get("foodIds") instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Object> ids = (List<Object>) params.get("foodIds");
+            for (Object id : ids) {
+                if (id instanceof Number) foodIds.add(((Number) id).longValue());
+            }
+        }
+        List<Map<String, Object>> trend = foodRecommendService.getTrend(memberId != null ? memberId : userId, foodIds);
+        return Result.success(trend);
+    }
 }

+ 64 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -265,23 +265,73 @@ public class FoodRecommendService {
     }
 
     /**
-     * 获取家庭成员列表(含当前切换的成员信息)
+     * 获取戒备食材趋势数据:返回每个食材的 indexScore 历史序列(最近N条变更记录)
+     * 每个元素: { foodId, foodName, currentScore, trend: [reportDate, score], changeDir: "up"|"down"|"flat" }
      */
-    public List<Map<String, Object>> getFamilyMembersForCaution(Long familyId) {
-        List<FamilyMember> members = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getFamilyId, familyId)
-                        .eq(FamilyMember::getShowToFamily, 1)
-        );
+    public List<Map<String, Object>> getTrend(Long userId, List<Long> foodIds) {
+        List<FoodRecommendIdxLog> logs;
+        if (foodIds != null && !foodIds.isEmpty()) {
+            logs = foodRecommendIdxLogMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIdxLog>()
+                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .in(FoodRecommendIdxLog::getFoodId, foodIds)
+                            .orderByDesc(FoodRecommendIdxLog::getChangedAt)
+                            .last("LIMIT 60")
+            );
+        } else {
+            logs = foodRecommendIdxLogMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIdxLog>()
+                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .orderByDesc(FoodRecommendIdxLog::getChangedAt)
+                            .last("LIMIT 120")
+            );
+        }
+        if (logs.isEmpty()) return new ArrayList<>();
+
+        // 按 foodId 分组
+        Map<Long, List<FoodRecommendIdxLog>> byFood = new LinkedHashMap<>();
+        for (FoodRecommendIdxLog log : logs) {
+            byFood.computeIfAbsent(log.getFoodId(), k -> new ArrayList<>()).add(log);
+        }
+
         List<Map<String, Object>> result = new ArrayList<>();
-        for (FamilyMember m : members) {
+        for (Map.Entry<Long, List<FoodRecommendIdxLog>> entry : byFood.entrySet()) {
+            Long foodId = entry.getKey();
+            List<FoodRecommendIdxLog> foodLogs = entry.getValue();
+            // 最新一条的 newIndex 是当前值,旧的那条是 previousIndex
+            // logs 已按 changedAt 降序,所以第一条是最新的
+            FoodRecommendIdxLog latest = foodLogs.get(0);
+            Integer currentScore = latest.getNewIndex() != null ? latest.getNewIndex() : latest.getPreviousIndex();
+
+            // 取最近4条变更构建趋势(如果不足4条则全部)
+            int count = Math.min(4, foodLogs.size());
+            List<Map<String, Object>> points = new ArrayList<>();
+            for (int i = 0; i < count; i++) {
+                FoodRecommendIdxLog l = foodLogs.get(i);
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportDate", l.getChangedAt());
+                p.put("score", l.getNewIndex() != null ? l.getNewIndex() : l.getPreviousIndex());
+                p.put("reportId", l.getHealthReportId());
+                points.add(p);
+            }
+
+            // 计算变化方向:最新 score vs 上条 previousIndex
+            String changeDir = "flat";
+            if (foodLogs.size() >= 2) {
+                Integer prev = foodLogs.get(1).getPreviousIndex();
+                Integer cur = latest.getNewIndex();
+                if (prev != null && cur != null) {
+                    if (cur > prev) changeDir = "up";
+                    else if (cur < prev) changeDir = "down";
+                }
+            }
+
             Map<String, Object> item = new LinkedHashMap<>();
-            item.put("id", m.getId());
-            item.put("userId", m.getUserId());
-            item.put("nickname", m.getNickname());
-            item.put("avatar", m.getAvatar());
-            item.put("effectiveRole", m.getEffectiveRole());
-            item.put("roleLabel", m.getRoleLabel());
+            item.put("foodId", foodId);
+            item.put("foodName", latest.getFoodName());
+            item.put("currentScore", currentScore);
+            item.put("trend", points);
+            item.put("changeDir", changeDir);
             result.add(item);
         }
         return result;

+ 28 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -184,15 +184,37 @@ public class HealthReportService {
         );
     }
 
-    public HealthReport getLatestReport(Long userId) {
-        return healthReportMapper.selectList(
+    /**
+     * 按文件MD5查找已入库的报告ID(同一报告类型下),用于重复上传检测
+     */
+    public Long findReportIdByFileHash(String fileHash, String reportType) {
+        if (fileHash == null || reportType == null) {
+            return null;
+        }
+        HealthReport report = healthReportMapper.selectOne(
                 new LambdaQueryWrapper<HealthReport>()
-                        .and(w -> w.eq(HealthReport::getSubjectId, userId)
-                                .or(w2 -> w2.isNull(HealthReport::getSubjectId)
-                                        .eq(HealthReport::getUserId, userId)))
+                        .eq(HealthReport::getFileHash, fileHash)
+                        .eq(HealthReport::getReportType, reportType)
                         .eq(HealthReport::getStatus, "active")
-                        .orderByDesc(HealthReport::getReportDate)
+                        .last("LIMIT 1")
+        );
+        return report != null ? report.getId() : null;
+    }
+
+    /**
+     * 按文件MD5查找正在采集中的草稿ID,用于避免重复提交采集
+     */
+    public Long findCollectingDraftIdByFileHash(String fileHash) {
+        if (fileHash == null) {
+            return null;
+        }
+        HealthReportDraft draft = draftMapper.selectOne(
+                new LambdaQueryWrapper<HealthReportDraft>()
+                        .eq(HealthReportDraft::getFileHash, fileHash)
+                        .eq(HealthReportDraft::getParseStatus, "collecting")
+                        .last("LIMIT 1")
         );
+        return draft != null ? draft.getId() : null;
     }
 
     /**

+ 5 - 2
cfc-frontend/pages/growth-main/index.vue

@@ -308,7 +308,10 @@ export default {
     goMoreActivities: function() { uni.navigateTo({ url: '/pages/activity/index' }) },
     goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
-    goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
+    goToCaution: function() {
+      var mid = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+      uni.navigateTo({ url: '/pages/growth/caution-detail' + (mid ? '?memberId=' + mid : '') })
+    },
     goToFoods: function() {
       var mid = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
       uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail' + (mid ? '?memberId=' + mid : '') })
@@ -426,7 +429,7 @@ export default {
     async loadCautionData() {
       var self = this
       try {
-        var res = await getFoodCautionList()
+        var res = await getFoodCautionList(self.memberId)
         if (res && res.code === 200) self.cautionCount = (res.data || []).length
       } catch(e) {}
     },

+ 104 - 13
cfc-frontend/pages/growth/caution-detail.vue

@@ -6,11 +6,26 @@
       <text class="banner-text">以下食材推荐指数低于 0,建议谨慎食用或避免</text>
     </view>
 
+    <view class="member-bar" v-if="memberList.length > 1">
+      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
+        v-for="item in memberList" :key="item.id || item.userId"
+        @tap="switchMember(item)">
+        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
+      </view>
+    </view>
+
     <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
-      <view class="food-card" v-for="(item, idx) in foods" :key="idx">
+      <view class="food-card" v-for="(item, idx) in foods" :key="getItemKey(item)">
         <view class="food-header">
           <text class="food-name">{{ item.foodName }}</text>
-          <view class="caution-badge">戒备</view>
+          <view class="score-badge-group">
+            <text class="score-value danger">{{ item.indexScore }}</text>
+            <view class="caution-badge">戒备</view>
+          </view>
+        </view>
+        <view class="change-row" v-if="item.changeDir">
+          <text class="change-label">较上次</text>
+          <text :class="item.changeClass">{{ item.changeText }}</text>
         </view>
         <view class="food-meta">
           <text class="meta-item" v-if="item.category">{{ item.category }}</text>
@@ -20,10 +35,10 @@
             <view class="score-bar-bg">
               <view class="score-bar-fill danger" :style="'width:' + Math.min(Math.abs(item.indexScore) / 50 * 100, 100) + '%'"></view>
             </view>
-            <text class="score-value danger">{{ item.indexScore }}</text>
+            <text class="score-label">推荐指数</text>
           </view>
-          <text class="score-label">推荐指数</text>
         </view>
+        <mini-trend :data="item.trendPoints" v-if="item.trendPoints && item.trendPoints.length >= 3"></mini-trend>
         <view class="food-nutrition" v-if="item.nutrition">
           <text class="nut-item">蛋白 {{ item.nutrition.protein }}g</text>
           <text class="nut-item">脂肪 {{ item.nutrition.fat }}g</text>
@@ -47,32 +62,68 @@
 </template>
 
 <script>
-import { getFoodCautionList } from '../../utils/api.js'
+import { getFoodCautionList, getFoodCautionTrend, getCautionFamilyMembers } from '../../utils/api.js'
+import MiniTrend from './mini-trend.vue'
 
 export default {
+  components: { MiniTrend },
   data() {
     return {
       foods: [],
       loading: true,
       refreshing: false,
-      nutritionCache: {}
+      nutritionCache: {},
+      currentMemberId: null,
+      memberList: [],
+      trendMap: {}
     }
   },
-  onLoad() {
+  onLoad: function(options) {
+    this.currentMemberId = options.memberId ? parseInt(options.memberId) : null
+    this.loadMemberList()
     this.loadFoods()
   },
   methods: {
-    goBack() {
+    getItemKey: function(item) { return item.foodId || item.id || Math.random() },
+    goBack: function() {
       uni.navigateBack()
     },
+    switchMember: function(item) {
+      this.currentMemberId = item.id || item.userId
+      this.loadFoods()
+    },
+    async loadMemberList() {
+      try {
+        var res = await getCautionFamilyMembers()
+        if (res && res.code === 200 && res.data && res.data.length > 0) {
+          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
+          this.memberList = [allItem].concat(res.data)
+          if (!this.currentMemberId) {
+            this.currentMemberId = -1
+          }
+        }
+      } catch(e) {}
+    },
     async loadFoods() {
       this.loading = true
       try {
-        var res = await getFoodCautionList()
+        var res = await getFoodCautionList(this.currentMemberId > 0 ? this.currentMemberId : null)
         if (res && res.code === 200 && res.data) {
-          this.foods = res.data
-          // 批量加载营养数据
+          this.foods = (res.data || []).map(function(f) {
+            return {
+              foodId: f.foodId || f.id,
+              foodName: f.foodName,
+              indexScore: f.indexScore,
+              category: f.category,
+              nutrition: null,
+              changeDir: null,
+              changeText: '',
+              changeClass: '',
+              trendPoints: null
+            }
+          })
           this.loadNutrition()
+          this.loadTrends()
         } else {
           this.foods = []
         }
@@ -116,7 +167,47 @@ export default {
         console.warn('加载营养数据失败', e)
       }
     },
-    getKnowledgeSection(key) {
+    loadTrends: function() {
+      if (!this.foods.length) return
+      var self = this
+      var foodIds = this.foods.map(function(f) { return f.foodId }).filter(function(id) { return id != null })
+      if (!foodIds.length) return
+      getFoodCautionTrend(this.currentMemberId > 0 ? this.currentMemberId : null, foodIds).then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          var trendMap = {}
+          var dataArray = res.data || []
+          for (var i = 0; i < dataArray.length; i++) {
+            var t = dataArray[i]
+            trendMap[t.foodId] = t
+          }
+          self.trendMap = trendMap
+          self.foods = self.foods.map(function(item) {
+            var t = trendMap[item.foodId]
+            if (!t) return item
+            var trendPoints = []
+            var pts = t.trend || []
+            for (var j = 0; j < pts.length; j++) {
+              trendPoints.push(pts[j].score)
+            }
+            var changeDir = t.changeDir || 'flat'
+            var changeText = ''
+            var changeClass = ''
+            if (changeDir === 'up') { changeText = '↑ 上升'
+              changeClass = 'change-up' }
+            else if (changeDir === 'down') { changeText = '↓ 下降'
+              changeClass = 'change-down' }
+            return Object.assign({}, item, {
+              trendPoints: trendPoints.length >= 3 ? trendPoints : null,
+              changeDir: changeDir,
+              changeText: changeText,
+              changeClass: changeClass
+            })
+          })
+          self.$forceUpdate()
+        }
+      }).catch(function() {})
+    },
+    getKnowledgeSection: function(key) {
       return new Promise(function(resolve) {
         uni.request({
           url: getApp().globalData.baseApi + '/api/kb/section',
@@ -131,7 +222,7 @@ export default {
         })
       })
     },
-    onRefresh() {
+    onRefresh: function() {
       this.refreshing = true
       this.loadFoods()
     }

+ 16 - 1
cfc-frontend/utils/api.js

@@ -2750,7 +2750,22 @@ export const getGrowthRecommendations = (category = 'health', limit = 3) =>
   request('/api/growth/recommendations', 'POST', { category, limit })
 
 // ===== 食材戒备匹配 =====
-export const getFoodCautionList = () => request('/api/food/recommendation/caution', 'POST', {})
+export const getFoodCautionList = (memberId) => {
+  var data = {}
+  if (memberId) data.memberId = memberId
+  return request('/api/food/recommendation/caution', 'POST', data)
+}
+
+// ===== 戒备页面家庭成员列表 =====
+export const getCautionFamilyMembers = () => request('/api/food/recommendation/members', 'POST', {})
+
+// ===== 戒备食材趋势数据 =====
+export const getFoodCautionTrend = (memberId, foodIds) => {
+  var data = {}
+  if (memberId) data.memberId = memberId
+  if (foodIds && foodIds.length) data.foodIds = foodIds
+  return request('/api/food/recommendation/trend', 'POST', data)
+}
 
 // ===== 健康报告食材推荐(共用 /api/health/foods)=====
 export const getHealthFoods = (reportId, memberId) => {

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-b9bdcb9a43fa0bd12a9ae892590707d262533715
+ce4a7adc71861fe36ac1019b8a0f04391ba90356