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

feat(health): 报告编辑页支持成员选择/重选/解绑 + 删除重算维度

后端:
- /report/edit 移除 subjectId 必填,支持 null(解绑)/ 0(不变)/ 正值(绑定或切换)
- updateReportFromPayload 按 subjectId 语义重算维度分:
  * 正值:绑定或切换成员,重算新成员 + 原成员(含食材推荐)
  * null:解绑,重算原成员(移除报告影响)
  * <=0:保持不变(子页面局部编辑不触碰绑定)
- deleteReport 删除后重算原绑定成员的维度分(含食材推荐)
- 新增 FoodRecommendService 注入(@Lazy 打破循环依赖)

前端:
- report-detail.vue saveEdit 未选成员传 null(解绑),面板加「不绑定成员」选项
- gut-flora-detail.vue draft 模式补成员选择面板 + doConfirm 传 subjectId

行为:未选成员 → 报告不纳入任何用户/家庭维度计算;选中 → 纳入并重算;删除 → 重算
E2E Test Bot 1 неделя назад
Родитель
Сommit
36ed0acf09

+ 2 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -455,9 +455,8 @@ public class HealthReportController {
             return Result.error("reportId不能为空");
         }
         Long subjectId = ParamUtils.getLong(params.get("subjectId"));
-        if (subjectId == null) {
-            return Result.error("subjectId不能为空");
-        }
+        // subjectId 可为 null:用户取消绑定成员时传 null,表示解绑(不纳入家庭维度计算)
+        // subjectId 与旧值不同时:重算旧成员 + 新成员维度分
 
         @SuppressWarnings("unchecked")
         Map<String, Object> payload = (Map<String, Object>) params.get("payload");

+ 78 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -92,6 +92,11 @@ public class HealthReportService {
     @Resource
     private DimensionScoreService dimensionScoreService;
 
+    // @Lazy 打破循环依赖:FoodRecommendService 内部注入 HealthReportService
+    @Resource
+    @org.springframework.context.annotation.Lazy
+    private FoodRecommendService foodRecommendService;
+
     @Resource
     private ReportBlockService reportBlockService;
 
@@ -1669,12 +1674,56 @@ public class HealthReportService {
             }
         }
 
-        // 5. 刷新 7 维健康评分
-        try {
-            dimensionScoreService.refreshFromGutReport(subjectId, reportId);
-            log.info("编辑后已刷新成员{}的7维健康评分", subjectId);
-        } catch (Exception e) {
-            log.warn("刷新7维健康评分失败: {}", e.getMessage());
+        // 5. 刷新 7 维健康评分(支持成员重选:旧成员和新成员都需重算)
+        Long oldSubjectId = oldReport.getSubjectId();
+        // subjectId 语义:
+        //   null  → 主动解绑(移除该报告对原成员的影响)
+        //   <= 0  → 保持不变(子页面局部编辑,不触碰成员绑定)
+        //   > 0   → 绑定或切换成员(重算新成员 + 原成员)
+        if (subjectId != null && subjectId > 0) {
+            // 更新报告绑定的成员(可能为新成员或切换成员)
+            if (oldSubjectId == null || !oldSubjectId.equals(subjectId)) {
+                bindReportToMember(reportId, subjectId);
+            }
+            try {
+                dimensionScoreService.refreshFromGutReport(subjectId, reportId);
+                log.info("编辑后已刷新成员{}的7维健康评分", subjectId);
+            } catch (Exception e) {
+                log.warn("刷新7维健康评分失败: {}", e.getMessage());
+            }
+            try {
+                foodRecommendService.recalculateForUser(subjectId);
+                log.info("编辑后已刷新成员{}的食材推荐指数", subjectId);
+            } catch (Exception e) {
+                log.warn("刷新食材推荐指数失败: {}", e.getMessage());
+            }
+            // 若有旧成员(解绑或切换成员),也需重算旧成员的维度
+            if (oldSubjectId != null && !oldSubjectId.equals(subjectId)) {
+                try {
+                    dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+                    log.info("编辑后已重算旧成员{}的7维健康评分(成员已切换)", oldSubjectId);
+                } catch (Exception e) {
+                    log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
+                }
+                try {
+                    foodRecommendService.recalculateForUser(oldSubjectId);
+                } catch (Exception e) {
+                    log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
+                }
+            }
+        } else if (subjectId == null && oldSubjectId != null) {
+            // 用户主动解绑(subjectId=null),重算旧成员(移除该报告的影响)
+            try {
+                dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+                log.info("解绑成员后已重算旧成员{}的7维健康评分", oldSubjectId);
+            } catch (Exception e) {
+                log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
+            }
+            try {
+                foodRecommendService.recalculateForUser(oldSubjectId);
+            } catch (Exception e) {
+                log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
+            }
         }
 
         // 6. 保存 payload 快照 + 编辑日志
@@ -1778,6 +1827,13 @@ public class HealthReportService {
      */
     @Transactional
     public void deleteReport(Long reportId) {
+        // 逻辑删除前记录 subjectId,用于删除后重算维度分
+        Long subjectId = null;
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report != null) {
+            subjectId = report.getSubjectId();
+        }
+
         // 清理关联指标
         healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
                 .eq(HealthIndicator::getReportId, reportId));
@@ -1800,12 +1856,26 @@ public class HealthReportService {
         reportFoodSuitabilityMapper.delete(new LambdaQueryWrapper<ReportFoodSuitability>()
                 .eq(ReportFoodSuitability::getReportId, reportId));
         // 逻辑删除报告本身
-        HealthReport report = healthReportMapper.selectById(reportId);
         if (report != null) {
             report.setStatus("deleted");
             report.setUpdatedAt(new Date());
             healthReportMapper.updateById(report);
-            log.info("健康报告已删除: id={}", reportId);
+            log.info("健康报告已删除: id={}, subjectId={}", reportId, subjectId);
+        }
+
+        // 删除后重算该成员的维度分(移除该报告的影响)
+        if (subjectId != null) {
+            try {
+                dimensionScoreService.refreshFromGutReport(subjectId, reportId);
+                log.info("报告删除后已重算成员{}的7维健康评分", subjectId);
+            } catch (Exception e) {
+                log.warn("报告删除后刷新成员{}健康评分失败: {}", subjectId, e.getMessage());
+            }
+            try {
+                foodRecommendService.recalculateForUser(subjectId);
+            } catch (Exception e) {
+                log.warn("报告删除后刷新成员{}食材推荐指数失败: {}", subjectId, e.getMessage());
+            }
         }
     }
 

+ 192 - 3
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -253,6 +253,81 @@
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
     </view>
+    <!-- draft 模式:成员选择 -->
+    <view class="member-select-wrap" v-if="isDraftMode && !confirming && !parsing">
+      <view class="member-select-row" @tap="toggleMemberPanel">
+        <text class="field-label">被检测人</text>
+        <text class="field-value">{{ draftMemberText }}</text>
+        <text class="member-select-arrow">{{ selectedMemberObj ? '修改 ›' : '选择 ›' }}</text>
+      </view>
+      <view class="member-panel" v-if="showMemberPanel">
+        <!-- 候选匹配列表 -->
+        <view class="candidate-section" v-if="candidates.length > 0">
+          <text class="candidate-section-hint">以下成员与报告信息匹配,请选择:</text>
+          <view
+            class="candidate-item"
+            v-for="(cand, idx) in candidates"
+            :key="idx"
+            :class="{ 'candidate-selected': selectedMemberId === (cand.id || cand.userId) }"
+            @tap="selectAndCloseDraft(cand)"
+          >
+            <view class="candidate-left">
+              <text class="candidate-avatar">{{ (cand.nickname || cand.name || '?').substring(0, 1) }}</text>
+              <view class="candidate-info">
+                <text class="candidate-name">{{ cand.nickname || cand.name || '未知' }}</text>
+                <text class="candidate-detail" v-if="cand.gender || cand.age">{{ genderText(cand.gender) }}{{ cand.age ? ' · ' + cand.age + '岁' : '' }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+        <!-- 全量成员 -->
+        <view class="member-list" v-if="familyMembers.length > 0">
+          <text class="member-list-hint">家庭成员</text>
+          <view
+            class="member-item"
+            v-for="(member, mIdx) in familyMembers"
+            :key="mIdx"
+            :class="{ 'member-selected': selectedMemberId === (member.userId || member.id) }"
+            @tap="selectAndCloseDraft(member)"
+          >
+            <text class="member-avatar">{{ (member.nickname || '?').substring(0, 1) }}</text>
+            <text class="member-name">{{ member.nickname || '未知' }}</text>
+          </view>
+        </view>
+        <!-- 不绑定成员 -->
+        <view class="member-item unbind-member" @tap="unbindDraftMember">
+          <text class="member-avatar unbind-avatar">✕</text>
+          <text class="member-name">不绑定成员</text>
+        </view>
+        <!-- 新建成员入口 -->
+        <view class="create-member-entry" @tap="toggleAutoCreateDraft">
+          <text class="create-member-icon">+</text>
+          <text class="create-member-text">{{ showAutoCreateForm ? '收起新建表单' : '不在列表中?新建家庭成员' }}</text>
+        </view>
+        <!-- 新建成员表单 -->
+        <view class="auto-create-inline" v-if="showAutoCreateForm">
+          <view class="form-item">
+            <text class="form-item-label">姓名</text>
+            <input class="form-item-input" v-model="newMember.name" placeholder="请输入姓名" />
+          </view>
+          <view class="form-item">
+            <text class="form-item-label">性别</text>
+            <view class="gender-selector">
+              <text class="gender-option" :class="{ 'gender-selected': newMember.gender === 'male' }" @tap="newMember.gender = 'male'">男</text>
+              <text class="gender-option" :class="{ 'gender-selected': newMember.gender === 'female' }" @tap="newMember.gender = 'female'">女</text>
+            </view>
+          </view>
+          <view class="form-item">
+            <text class="form-item-label">年龄</text>
+            <input class="form-item-input" type="number" v-model="newMember.age" placeholder="请输入年龄" />
+          </view>
+          <view class="btn-create-submit" :class="{ 'btn-disabled': creatingMember }" @tap="doAutoCreateDraft">
+            <text v-if="creatingMember">创建中...</text>
+            <text v-else>确认创建</text>
+          </view>
+        </view>
+      </view>
+    </view>
     <view class="draft-bar" v-if="isDraftMode && !confirming && !parsing">
       <view class="btn-discard" @tap="doDiscard">放弃</view>
       <view class="btn-confirm" @tap="doConfirm">确认入库</view>
@@ -271,7 +346,7 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember } from '../../utils/api.js'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 import FeedbackPopup from '../../components/feedback-popup.vue'
 import { parseDate } from '../../utils/format.js'
@@ -321,7 +396,23 @@ export default {
       isDraftMode: false,
       payload: null,
       confirming: false,
-      parsing: false
+      parsing: false,
+      // 被检测人选择(draft 模式)
+      needBind: false,
+      selectedMemberId: null,
+      selectedMemberObj: null,
+      familyMembers: [],
+      showMemberPanel: false,
+      showAutoCreateForm: false,
+      newMember: { name: '', gender: 'male', age: '' },
+      creatingMember: false,
+      createdMemberId: null,
+      matchConfidence: null,
+      matchLabel: '',
+      candidates: [],
+      extractedName: '',
+      extractedGender: '',
+      extractedAge: '',
     }
   },
   computed: {
@@ -357,6 +448,11 @@ export default {
         if (level !== '低风险') count++
       }
       return count
+    },
+    draftMemberText: function() {
+      if (this.selectedMemberObj) return this.selectedMemberObj.nickname || this.selectedMemberObj.name || '已选择'
+      if (this.extractedName) return this.extractedName + '(待绑定)'
+      return '未选择'
     }
   },
   onLoad: function(options) {
@@ -399,6 +495,75 @@ export default {
       var map = { '良好': 'good', '注意': 'warning', '需关注': 'danger', '关注': 'danger', '正常': 'normal', '偏高': 'high', '偏低': 'low' }
       return map[s] || 'normal'
     },
+    genderText: function(g) {
+      if (g === 'male') return '男'
+      if (g === 'female') return '女'
+      return g || ''
+    },
+    toggleMemberPanel: function() {
+      this.showMemberPanel = !this.showMemberPanel
+      if (this.showMemberPanel && this.familyMembers.length === 0) {
+        this.loadFamilyMembers()
+      }
+    },
+    loadFamilyMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        if (res.code === 200) {
+          self.familyMembers = res.data || []
+        }
+      }).catch(function() {})
+    },
+    selectAndCloseDraft: function(member) {
+      this.selectedMemberId = member.id || member.userId
+      this.selectedMemberObj = member
+      this.createdMemberId = null
+      this.needBind = false
+      this.showMemberPanel = false
+      this.showAutoCreateForm = false
+    },
+    unbindDraftMember: function() {
+      this.selectedMemberId = null
+      this.selectedMemberObj = null
+      this.createdMemberId = null
+      this.needBind = false
+      this.showMemberPanel = false
+      this.showAutoCreateForm = false
+    },
+    toggleAutoCreateDraft: function() {
+      this.showAutoCreateForm = !this.showAutoCreateForm
+    },
+    doAutoCreateDraft: function() {
+      var self = this
+      if (!self.newMember.name) {
+        uni.showToast({ title: '请填写姓名', icon: 'none' })
+        return
+      }
+      self.creatingMember = true
+      addFamilyMember({
+        name: self.newMember.name,
+        gender: self.newMember.gender,
+        age: self.newMember.age ? parseInt(self.newMember.age) : null
+      }).then(function(res) {
+        self.creatingMember = false
+        if (res.code === 200) {
+          var member = res.data || {}
+          self.selectedMemberId = member.id || member.userId
+          self.selectedMemberObj = member
+          self.createdMemberId = self.selectedMemberId
+          self.needBind = false
+          self.showMemberPanel = false
+          self.showAutoCreateForm = false
+          self.loadFamilyMembers()
+          uni.showToast({ title: '创建成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.creatingMember = false
+        uni.showToast({ title: '创建异常', icon: 'none' })
+      })
+    },
     loadData: function() {
       var self = this
       getReportDetail(this.reportId).then(function(res) {
@@ -470,7 +635,28 @@ export default {
         }
         self.payload = res.data.payload || {}
         self.isDraftMode = true
+        // 填充成员匹配信息(后端解析时已按姓名/性别/年龄尝试匹配家庭成员)
+        self.extractedName = res.data.extractedName || ''
+        self.extractedGender = res.data.extractedGender || ''
+        self.extractedAge = res.data.extractedAge || ''
+        self.matchedMemberId = res.data.matchedMemberId || null
+        self.needBind = !!res.data.needBind
+        self.matchConfidence = res.data.confidence !== undefined ? parseFloat(res.data.confidence) : null
+        self.matchLabel = res.data.matchLabel || ''
+        self.candidates = res.data.candidates || []
+        // 高置信度自动选中
+        if (self.matchedMemberId && self.matchConfidence >= 0.8) {
+          self.selectedMemberId = self.matchedMemberId
+          // 在 candidates 中找对应对象
+          for (var i = 0; i < self.candidates.length; i++) {
+            if ((self.candidates[i].id || self.candidates[i].userId) === self.selectedMemberId) {
+              self.selectedMemberObj = self.candidates[i]
+              break
+            }
+          }
+        }
         self.applyDraftPayload(self.payload)
+        self.loadFamilyMembers()
       }).catch(function() {
         self.parsing = false
         uni.showToast({ title: '加载异常', icon: 'none' })
@@ -714,7 +900,10 @@ report.gutType = s.gutType || ''
       var self = this
       if (!self.draftId) return
       self.confirming = true
-      confirmReportPreview({ draftId: self.draftId, payload: self.payload }).then(function(res) {
+      // 有选中成员时传 subjectId,否则传 null:后端确认入库但不绑定任何用户/家庭,也不重算维度
+      var req = { draftId: self.draftId, payload: self.payload }
+      if (self.selectedMemberId) req.subjectId = self.selectedMemberId
+      confirmReportPreview(req).then(function(res) {
         self.confirming = false
         if (res.code === 200) {
           var reportId = res.data && res.data.reportId

+ 18 - 1
cfc-frontend/pages/health/report-detail.vue

@@ -102,6 +102,12 @@
                 </view>
               </view>
 
+              <!-- 不绑定成员(仅编辑模式:解绑当前报告,不纳入家庭维度计算) -->
+              <view class="member-item unbind-member" v-if="pageMode === 'edit'" @click="unbindMember">
+                <text class="member-avatar unbind-avatar">✕</text>
+                <text class="member-name">不绑定成员</text>
+              </view>
+
               <!-- 新建成员入口 -->
               <view class="create-member-entry" @click="toggleAutoCreate">
                 <text class="create-member-icon">+</text>
@@ -768,6 +774,15 @@ export default {
       this.showMemberPanel = false
       this.showAutoCreateForm = false
     },
+    // 解绑:不绑定任何成员(报告不纳入家庭/用户维度计算)
+    unbindMember() {
+      this.selectedMemberId = null
+      this.selectedMemberObj = null
+      this.createdMemberId = null
+      this.needBind = false
+      this.showMemberPanel = false
+      this.showAutoCreateForm = false
+    },
     genderText(g) {
       if (g === 'male') return '男'
       if (g === 'female') return '女'
@@ -1044,7 +1059,9 @@ export default {
       var self = this
       if (!self.reportId) return
       uni.showLoading({ title: '保存中...' })
-      var subjectId = self.selectedMemberId || self.createdMemberId || 0
+      // 未选择成员 → 传 null 表示解绑(不纳入任何用户/家庭维度计算)
+      // 注意:不传 0(0 会被后端当作无成员处理但语义不明,null 明确解绑)
+      var subjectId = self.selectedMemberId || self.createdMemberId || null
       editHealthReport(self.reportId, self.payload, subjectId).then(function(res) {
         uni.hideLoading()
         if (res.code === 200) {