Explorar o código

feat(diet): 身体调研加饮食偏好 + 食材档案清真/素食标识

- report-survey.vue 新增3题:绝对忌口(必填)、必须要吃(可选)、宗教饮食
- 提交问卷时自动同步到 diet_preferences 表(absoluteAvoid/mustEat/religiousDiet)
- 迁移254: diet_preferences 新增 must_eat 列
- 迁移253: foods 新增 diet_type 列(清真/halal/素食/vegan 标识)
- Food/DietPreferences 实体及 DTO 同步更新
iwt hai 2 meses
pai
achega
327bc9ed47

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

@@ -2999,6 +2999,12 @@ log.info("已添加template_id列到tasks表");
 		} catch (Exception ex) {
 			log.warn("foods.diet_type 添加失败(可能已存在): " + ex.getMessage());
 		}
+		// 迁移254: diet_preferences表添加must_eat列(必须要吃的食物)
+		try {
+			ensureColumn("diet_preferences", "must_eat", "JSON DEFAULT NULL COMMENT '必须要吃的食物列表'");
+		} catch (Exception ex) {
+			log.warn("diet_preferences.must_eat 添加失败(可能已存在): " + ex.getMessage());
+		}
         ensureColumn("file_record", "file_type", "VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other'");
         ensureColumn("file_record", "description", "TEXT COMMENT '文件描述'");
 

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java

@@ -8,6 +8,7 @@ public class DietPreferencesDTO {
     private Long familyMemberId;
     private List<String> allergies;
     private List<String> absoluteAvoid;
+    private List<String> mustEat;
     private String religiousDiet;
     private Integer spiceLevel;
     private List<String> flavorPref;

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java

@@ -15,6 +15,7 @@ public class DietPreferences implements Serializable {
     private Long familyMemberId;
     private String allergies;
     private String absoluteAvoid;
+    private String mustEat;
     private String religiousDiet;
     private Integer spiceLevel;
     private String flavorPref;

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java

@@ -47,6 +47,7 @@ public class DietPreferencesService {
         
         entity.setAllergies(toJSON(dto.getAllergies()));
         entity.setAbsoluteAvoid(toJSON(dto.getAbsoluteAvoid()));
+        entity.setMustEat(toJSON(dto.getMustEat()));
         entity.setReligiousDiet(dto.getReligiousDiet() != null ? dto.getReligiousDiet() : "none");
         entity.setSpiceLevel(dto.getSpiceLevel());
         entity.setFlavorPref(toJSON(dto.getFlavorPref()));
@@ -77,6 +78,7 @@ public class DietPreferencesService {
         dto.setFamilyMemberId(entity.getFamilyMemberId());
         dto.setAllergies(fromJSON(entity.getAllergies()));
         dto.setAbsoluteAvoid(fromJSON(entity.getAbsoluteAvoid()));
+        dto.setMustEat(fromJSON(entity.getMustEat()));
         dto.setReligiousDiet(entity.getReligiousDiet());
         dto.setSpiceLevel(entity.getSpiceLevel());
         dto.setFlavorPref(fromJSON(entity.getFlavorPref()));

+ 65 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportSurveyService.java

@@ -1,14 +1,19 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DietPreferences;
 import com.etotem.cfc.entity.ReportSurvey;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
 import com.etotem.cfc.mapper.ReportSurveyMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.List;
 
 /**
  * 报告调研问卷服务 — 报告上传后生成问卷,收集用户健康信息
@@ -20,6 +25,11 @@ public class ReportSurveyService {
     @Resource
     private ReportSurveyMapper reportSurveyMapper;
 
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     /**
      * 为报告创建调研问卷
      */
@@ -51,9 +61,64 @@ public class ReportSurveyService {
         survey.setUpdatedAt(new Date());
         reportSurveyMapper.updateById(survey);
         log.info("报告调研问卷已提交: surveyId={}", surveyId);
+        // 同步保存饮食偏好到 diet_preferences 表
+        try {
+            saveDietPreferencesFromSurvey(survey, surveyData);
+        } catch (Exception e) {
+            log.warn("同步饮食偏好失败(不影响问卷提交): {}", e.getMessage());
+        }
         return survey;
     }
 
+    private void saveDietPreferencesFromSurvey(ReportSurvey survey, String surveyData) {
+        JsonNode root = objectMapper.readTree(surveyData);
+        JsonNode surveyNode = root.has("survey") ? root.get("survey") : root;
+        Long memberId = survey.getChildId();
+        if (memberId == null) return;
+
+        // 绝对忌口
+        JsonNode absAvoidNode = surveyNode.get("absoluteAvoid");
+        List<String> absoluteAvoid = new java.util.ArrayList<>();
+        if (absAvoidNode != null && absAvoidNode.isArray()) {
+            for (JsonNode n : absAvoidNode) absoluteAvoid.add(n.asText());
+        }
+        // 必须要吃
+        JsonNode mustEatNode = surveyNode.get("mustEat");
+        List<String> mustEat = new java.util.ArrayList<>();
+        if (mustEatNode != null && mustEatNode.isArray()) {
+            for (JsonNode n : mustEatNode) mustEat.add(n.asText());
+        }
+        // 宗教饮食
+        String religiousDiet = surveyNode.get("religiousDiet") != null
+                ? surveyNode.get("religiousDiet").asText() : "none";
+
+        // 查已存在的记录
+        LambdaQueryWrapper<DietPreferences> w = new LambdaQueryWrapper<>();
+        w.eq(DietPreferences::getFamilyMemberId, memberId);
+        DietPreferences prefs = dietPreferencesMapper.selectOne(w);
+        if (prefs == null) {
+            prefs = new DietPreferences();
+            prefs.setFamilyMemberId(memberId);
+            prefs.setFilledAt(new Date());
+        }
+        prefs.setAbsoluteAvoid(toJSON(absoluteAvoid));
+        prefs.setMustEat(toJSON(mustEat));
+        prefs.setReligiousDiet(religiousDiet);
+        prefs.setUpdatedAt(new Date());
+        if (prefs.getId() == null) {
+            dietPreferencesMapper.insert(prefs);
+        } else {
+            dietPreferencesMapper.updateById(prefs);
+        }
+        log.info("饮食偏好已同步: memberId={}, absoluteAvoid={}, mustEat={}, religiousDiet={}",
+                memberId, absoluteAvoid, mustEat, religiousDiet);
+    }
+
+    private String toJSON(Object obj) {
+        try { return objectMapper.writeValueAsString(obj); }
+        catch (Exception e) { return null; }
+    }
+
     /**
      * 检查指定报告的调研问卷是否已完成
      */

+ 119 - 15
cfc-frontend/pages/health/report-survey.vue

@@ -29,7 +29,7 @@
 
       <!-- Q1: 饮食结构 -->
       <view class="question-card">
-        <text class="q-number">1/12</text>
+        <text class="q-number">1/15</text>
         <text class="q-title">孩子平时的饮食结构是怎样的?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.diet === 'balanced' }" @click="survey.diet = 'balanced'">
@@ -51,9 +51,85 @@
         </view>
       </view>
 
+      <!-- Q1b: 绝对忌口(必填) -->
+      <view class="question-card">
+        <text class="q-number">1b/15</text>
+        <text class="q-title">孩子有哪些绝对不能吃/喝的食物?(必填,含过敏原)</text>
+        <view class="q-options multi">
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('牛奶') !== -1 }" @click="toggleAbsAvoid('牛奶')">
+            <text class="q-text">牛奶/乳制品</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('鸡蛋') !== -1 }" @click="toggleAbsAvoid('鸡蛋')">
+            <text class="q-text">鸡蛋</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('海鲜') !== -1 }" @click="toggleAbsAvoid('海鲜')">
+            <text class="q-text">海鲜/虾蟹</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('花生') !== -1 }" @click="toggleAbsAvoid('花生')">
+            <text class="q-text">花生/坚果</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.indexOf('麸质') !== -1 }" @click="toggleAbsAvoid('麸质')">
+            <text class="q-text">麸质/小麦</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.absoluteAvoid.length === 0 }" @click="survey.absoluteAvoid = []">
+            <text class="q-text">无</text>
+          </view>
+        </view>
+        <view class="extra-input-row">
+          <input class="extra-input" placeholder="或其他忌口,回车添加..." v-model="absAvoidInput" @confirm="addAbsAvoidInput" />
+        </view>
+      </view>
+
+      <!-- Q1c: 必须要吃 -->
+      <view class="question-card">
+        <text class="q-number">1c/15</text>
+        <text class="q-title">有什么一定要保证摄入的食物?(可选)</text>
+        <view class="q-options multi">
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('蔬菜') !== -1 }" @click="toggleMustEat('蔬菜')">
+            <text class="q-text">🥬 蔬菜</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('水果') !== -1 }" @click="toggleMustEat('水果')">
+            <text class="q-text">🍎 水果</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('蛋白质') !== -1 }" @click="toggleMustEat('蛋白质')">
+            <text class="q-text">🥚 蛋白质</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.mustEat.indexOf('益生菌') !== -1 }" @click="toggleMustEat('益生菌')">
+            <text class="q-text">🥛 益生菌/酸奶</text>
+          </view>
+        </view>
+        <view class="extra-input-row">
+          <input class="extra-input" placeholder="或其他,回车添加..." v-model="mustEatInput" @confirm="addMustEatInput" />
+        </view>
+      </view>
+
+      <!-- Q1d: 宗教饮食 -->
+      <view class="question-card">
+        <text class="q-number">1d/15</text>
+        <text class="q-title">是否有宗教或特殊饮食要求?(可选)</text>
+        <view class="q-options">
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'none' }" @click="survey.religiousDiet = 'none'">
+            <text class="q-icon">✅</text>
+            <text class="q-text">无限制</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'halal' }" @click="survey.religiousDiet = 'halal'">
+            <text class="q-icon">☪️</text>
+            <text class="q-text">清真(Halal)</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'vegetarian' }" @click="survey.religiousDiet = 'vegetarian'">
+            <text class="q-icon">🥬</text>
+            <text class="q-text">素食</text>
+          </view>
+          <view class="q-option" :class="{ 'q-selected': survey.religiousDiet === 'vegan' }" @click="survey.religiousDiet = 'vegan'">
+            <text class="q-icon">🌱</text>
+            <text class="q-text">纯素(不含任何动物制品)</text>
+          </view>
+        </view>
+      </view>
+
       <!-- Q2: 消化状况 -->
       <view class="question-card">
-        <text class="q-number">2/12</text>
+        <text class="q-number">2/15</text>
         <text class="q-title">孩子近期消化状况如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.digestion === 'good' }" @click="survey.digestion = 'good'">
@@ -77,7 +153,7 @@
 
       <!-- Q3: 睡眠质量 -->
       <view class="question-card">
-        <text class="q-number">3/12</text>
+        <text class="q-number">3/15</text>
         <text class="q-title">孩子近期的睡眠质量如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.sleep === 'good' }" @click="survey.sleep = 'good'">
@@ -101,7 +177,7 @@
 
       <!-- Q4: 运动习惯 -->
       <view class="question-card">
-        <text class="q-number">4/12</text>
+        <text class="q-number">4/15</text>
         <text class="q-title">孩子每周运动情况?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.exercise === 'frequent' }" @click="survey.exercise = 'frequent'">
@@ -121,7 +197,7 @@
 
       <!-- Q5: 饮水习惯 -->
       <view class="question-card">
-        <text class="q-number">5/12</text>
+        <text class="q-number">5/15</text>
         <text class="q-title">孩子每日饮水量如何?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.water === 'enough' }" @click="survey.water = 'enough'">
@@ -141,7 +217,7 @@
 
       <!-- Q6: 用药情况 -->
       <view class="question-card">
-        <text class="q-number">6/12</text>
+        <text class="q-number">6/15</text>
         <text class="q-title">孩子近期是否有以下用药情况?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': hasMedication('antibiotic') }" @click="toggleMedication('antibiotic')">
@@ -170,7 +246,7 @@
 
       <!-- Q7: 慢性病/系统性疾病 -->
       <view class="question-card">
-        <text class="q-number">7/12</text>
+        <text class="q-number">7/15</text>
         <text class="q-title">孩子是否有以下慢性或系统性疾病?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': hasChronic('none') }" @click="toggleChronic('none')">
@@ -207,7 +283,7 @@
 
       <!-- Q8: 过敏史 -->
       <view class="question-card">
-        <text class="q-number">8/12</text>
+        <text class="q-number">8/15</text>
         <text class="q-title">孩子是否有以下过敏情况?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.allergies.indexOf('none') !== -1 }" @click="toggleAllergy('none')">
@@ -232,7 +308,7 @@
 
       <!-- Q9: 手术/住院史 -->
       <view class="question-card">
-        <text class="q-number">9/12</text>
+        <text class="q-number">9/15</text>
         <text class="q-title">孩子是否有手术或住院史?</text>
         <view class="q-options">
           <view class="q-option" :class="{ 'q-selected': survey.surgery === 'none' }" @click="survey.surgery = 'none'">
@@ -253,7 +329,7 @@
 
       <!-- Q10: 家族病史 -->
       <view class="question-card">
-        <text class="q-number">10/12</text>
+        <text class="q-number">10/15</text>
         <text class="q-title">直系亲属是否有以下疾病史?(可多选)</text>
         <view class="family-grid">
           <view class="family-col">
@@ -303,7 +379,7 @@
 
       <!-- Q11: 祖父母病史 -->
       <view class="question-card">
-        <text class="q-number">11/12</text>
+        <text class="q-number">11/15</text>
         <text class="q-title">祖父母/外祖父母是否有以下疾病史?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.grandparentHistory.indexOf('unknown') !== -1 }" @click="toggleGrandparent('unknown')">
@@ -341,7 +417,7 @@
 
       <!-- Q12: 健康目标 -->
       <view class="question-card">
-        <text class="q-number">12/12</text>
+        <text class="q-number">12/15</text>
         <text class="q-title">您希望通过本次健康管理改善什么?(可多选)</text>
         <view class="q-options multi">
           <view class="q-option" :class="{ 'q-selected': survey.goals.indexOf('immunity') !== -1 }" @click="toggleGoal('immunity')">
@@ -402,6 +478,9 @@ export default {
       survey: {
         // 生活习惯
         diet: '',
+        absoluteAvoid: [],    // 绝对忌口(必填)
+        mustEat: [],          // 必须要吃(可选)
+        religiousDiet: 'none', // none/清真/halal/素食/vegan
         digestion: '',
         sleep: '',
         exercise: '',
@@ -414,6 +493,8 @@ export default {
         },
         allergies: [],
         allergyDetail: '',
+        absAvoidInput: '',
+        mustEatInput: '',
         surgery: '',
         surgeryDetail: '',
         // 家族史
@@ -444,8 +525,11 @@ export default {
   computed: {
     progressPercent: function() {
       var answered = 0
-      var total = 12
+      var total = 15
       if (this.survey.diet) answered++
+      if (this.survey.absoluteAvoid.length > 0) answered++
+      if (this.survey.mustEat.length > 0 || true) answered++
+      if (this.survey.religiousDiet && this.survey.religiousDiet !== 'none') answered++
       if (this.survey.digestion) answered++
       if (this.survey.sleep) answered++
       if (this.survey.exercise) answered++
@@ -454,7 +538,6 @@ export default {
       if (this.survey.diseaseHistory.chronic.length > 0) answered++
       if (this.survey.allergies.length > 0) answered++
       if (this.survey.surgery) answered++
-      // 家族史:至少填了父亲或母亲或兄弟姐妹或祖父母之一
       if (this.survey.familyHistory.father.length > 0
         || this.survey.familyHistory.mother.length > 0
         || this.survey.familyHistory.sibling.length > 0) answered++
@@ -463,7 +546,8 @@ export default {
       return Math.round((answered / total) * 100)
     },
     canSubmit: function() {
-      return this.survey.diet && this.survey.sleep && this.survey.exercise
+      return this.survey.diet && this.survey.absoluteAvoid.length > 0
+        && this.survey.sleep && this.survey.exercise
         && this.survey.diseaseHistory.chronic.length > 0
         && this.survey.allergies.length > 0
         && this.survey.goals.length > 0
@@ -608,6 +692,26 @@ export default {
         this.survey.goals.splice(idx, 1)
       }
     },
+    toggleAbsAvoid: function(item) {
+      var idx = this.survey.absoluteAvoid.indexOf(item)
+      if (idx >= 0) this.survey.absoluteAvoid.splice(idx, 1)
+      else this.survey.absoluteAvoid.push(item)
+    },
+    addAbsAvoidInput: function() {
+      var v = this.absAvoidInput.trim()
+      if (v && this.survey.absoluteAvoid.indexOf(v) === -1) this.survey.absoluteAvoid.push(v)
+      this.absAvoidInput = ''
+    },
+    toggleMustEat: function(item) {
+      var idx = this.survey.mustEat.indexOf(item)
+      if (idx >= 0) this.survey.mustEat.splice(idx, 1)
+      else this.survey.mustEat.push(item)
+    },
+    addMustEatInput: function() {
+      var v = this.mustEatInput.trim()
+      if (v && this.survey.mustEat.indexOf(v) === -1) this.survey.mustEat.push(v)
+      this.mustEatInput = ''
+    },
     goBack: function() {
       uni.navigateBack()
     },