Explorar el Código

feat(报告): 菌群报告统一走个性化详情页并支持任意类型通用组装

后端:
- ReportBlockAssembler.assembleWithTemplate 无模板时对任意报告类型默认调用通用组装(评分+风险+指标+菌种),不再只支持 gut_flora
- 确认入库与编辑保存两处改传 reportType,走 assembleWithTemplate 模板驱动+默认兜底

前端:
- report-list / health-main / report-upload / report-confirm 的 gut_flora 跳转统一到 gut-flora-detail 个性化页
- gut-flora-detail 新增内嵌 draftId 模式(解析→确认入库/放弃),修复评分圆环字段错位 bug(兼容 balanceScore 等新键),saveEdit 载荷改标准嵌套结构

BREAKING CHANGE: 肠道菌群报告详情页由通用 report-detail 切换为个性化 gut-flora-detail,入口 URL 与参数(draftId 支持)变更
iwt hace 2 meses
padre
commit
3c145debfd

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

@@ -1143,10 +1143,11 @@ public class HealthReportController {
 
             // 落库通用展示块(blocks 组装失败不阻断确认)
             try {
-                reportBlockService.save("gut_flora", created.getId(),
-                        reportBlockAssembler.assembleGutFlora(payload));
+                String reportType = created.getReportType();
+                reportBlockService.save(reportType, created.getId(),
+                        reportBlockAssembler.assembleWithTemplate(reportType, payload));
             } catch (Exception e) {
-                log.warn("保存gut_flora展示块失败 reportId={}: {}", created.getId(), e.getMessage());
+                log.warn("保存展示块失败 reportId={}: {}", created.getId(), e.getMessage());
             }
 
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1651,8 +1651,9 @@ public class HealthReportService {
         // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
         try {
             ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
-            reportBlockService.save("gut_flora", reportId,
-                    reportBlockAssembler.assembleGutFlora(editedPayload));
+            String reportType = oldReport.getReportType();
+            reportBlockService.save(reportType, reportId,
+                    reportBlockAssembler.assembleWithTemplate(reportType, editedPayload));
         } catch (Exception e) {
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
         }

+ 4 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java

@@ -35,17 +35,16 @@ public class ReportBlockAssembler {
      * 根据报告类型+模板配置组装 blocks。
      * 若该 reportType 有启用模板,优先使用模板 fixedBlocks;
      * freeDisplay 开启时,将未覆盖的指标追加到末尾。
+     * 无模板/模板未启用:默认调用 assembleGutFlora 做通用兜底(评分+风险+指标+菌种),
+     * 保证任何报告类型都不返回空列表,前端可依赖 blocks 展示而非旧字段回退。
      */
     public List<Map<String, Object>> assembleWithTemplate(String reportType, ParsedReportPayload.Payload payload) {
         if (payload == null) return new ArrayList<>();
 
         ReportTemplate template = reportTemplateService.getByReportType(reportType);
         if (template == null || template.getIsActive() == null || template.getIsActive() != 1) {
-            // 无模板或已停用 → 使用默认逻辑
-            if ("gut_flora".equals(reportType)) {
-                return assembleGutFlora(payload);
-            }
-            return new ArrayList<>();
+            // 无模板或已停用 → 通用默认组装(复用 gut_flora 逻辑,标题已通用)
+            return assembleGutFlora(payload);
         }
 
         return parseAndAssemble(template.getConfig(), reportType, payload);

+ 6 - 6
cfc-frontend/pages/health-main/index.vue

@@ -505,13 +505,13 @@ export default {
     goReportDetail: function(report) {
       if (!report || !report.id) return
       var type = report.reportType || 'gut_flora'
+      var url
       if (type === 'dan') {
-        uni.navigateTo({ url: '/pages/dan-assessment/report-result?reportId=' + report.id })
-        return
-      }
-      var url = '/pages/health/report-detail?reportType=' + type + '&reportId=' + report.id
-      if (type === 'gut_flora') {
-        url += '&mode=edit'
+        url = '/pages/dan-assessment/report-result?reportId=' + report.id
+      } else if (type === 'gut_flora') {
+        url = '/pages/health/gut-flora-detail?reportId=' + report.id
+      } else {
+        url = '/pages/health/report-detail?reportType=' + type + '&reportId=' + report.id
       }
       uni.navigateTo({ url: url })
     },

+ 180 - 18
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -213,18 +213,32 @@
 
     <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
 
-    <view class="fab" v-if="!isEditing" @tap="toggleEdit">
+    <view class="fab" v-if="!isEditing && !isDraftMode" @tap="toggleEdit">
       <text class="fab-icon">✏️</text>
     </view>
     <view class="bottom-bar" v-if="isEditing">
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-save" @tap="saveEdit">保存</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>
+    </view>
+    <view class="draft-bar" v-if="parsing">
+      <view class="btn-parsing">
+        <text class="parsing-text">AI 解析中...</text>
+      </view>
+    </view>
+    <view class="draft-bar" v-if="confirming">
+      <view class="btn-parsing">
+        <text class="parsing-text">确认中...</text>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft } from '../../utils/api.js'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 import FeedbackPopup from '../../components/feedback-popup.vue'
 
@@ -262,12 +276,22 @@ export default {
       knowledgeData: {},
       feedbackVisible: false,
       indStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多', '异常'],
-      analysisData: null
+      analysisData: null,
+      // draft 模式字段
+      draftId: null,
+      isDraftMode: false,
+      payload: null,
+      confirming: false,
+      parsing: false
     }
   },
   onLoad: function(options) {
     this.reportId = options.reportId ? parseInt(options.reportId) : null
-    if (this.reportId) {
+    this.draftId = options.draftId ? parseInt(options.draftId) : null
+    if (this.draftId) {
+      this.isDraftMode = true
+      this.loadDraftData()
+    } else if (this.reportId) {
       this.loadData()
     }
   },
@@ -322,24 +346,109 @@ export default {
         }
       }).catch(function() {})
     },
+    loadDraftData: function() {
+      var self = this
+      self.parsing = true
+      parseReportDraft(self.draftId).then(function(res) {
+        self.parsing = false
+        if (res.code !== 200 || !res.data) {
+          uni.showToast({ title: res.message || '加载失败', icon: 'none' })
+          return
+        }
+        self.payload = res.data.payload || {}
+        self.isDraftMode = true
+        self.applyDraftPayload(self.payload)
+      }).catch(function() {
+        self.parsing = false
+        uni.showToast({ title: '加载异常', icon: 'none' })
+      })
+    },
+    applyDraftPayload: function(payload) {
+      // 将 draft payload 映射到模板所需的数据结构(report/scoreItems/indicators)
+      var report = {}
+      if (payload.summary) {
+        var s = payload.summary
+        report.personName = s.personName || ''
+        report.reportNumber = s.reportNumber || ''
+        report.gutAge = s.gutAge || ''
+        report.gutType = s.gutType || ''
+        report.overallScore = s.overallScore
+        report.gutHealthScore = s.gutHealthScore
+        report.chronicDiseaseScore = s.chronicDiseaseScore
+        report.nutritionScore = s.nutritionScore
+        report.balanceScore = s.balanceScore
+        report.diversityScore = s.diversityScore
+        report.beneficialScore = s.beneficialScore
+        report.harmfulScore = s.harmfulScore
+        report.coreGenusScore = s.coreGenusScore
+      }
+      this.report = report
+      this.buildScores({ report: report })
+      this.buildDraftIndicators(payload.indicators || [])
+    },
+    buildDraftIndicators: function(indicators) {
+      // 复用 buildIndicators 的分组逻辑,输入为 payload.indicators
+      var all = []
+      for (var i = 0; i < indicators.length; i++) {
+        var ind = indicators[i]
+        var item = {
+          indicatorName: ind.indicatorName || ind.name || '',
+          indicatorValue: ind.indicatorValue || ind.value || '',
+          status: ind.status || '正常',
+          refRange: ind.refRange || '',
+          category: ind.category || '其他'
+        }
+        item._cssClass = this._cssForStatus(item.status)
+        all.push(item)
+      }
+      this.allIndicators = all
+      this.indicatorCount = all.length
+      this._groupIndicators(all)
+    },
+    _groupIndicators: function(all) {
+      var self = this
+      var gut = []
+      var nutriGroups = []
+      var nutriMap = {}
+      for (var i = 0; i < all.length; i++) {
+        var ind = all[i]
+        var g = ind.category
+        var isGutBarrier = g === '肠道屏障与代谢物' || g === '短链脂肪酸' ||
+          g === '神经递质与激素' || g === '抗生素风险评估'
+        if (isGutBarrier) {
+          gut.push(ind)
+        } else {
+          if (!nutriMap[g]) nutriMap[g] = []
+          nutriMap[g].push(ind)
+        }
+      }
+      this.gutIndicators = gut
+      for (var key in nutriMap) {
+        nutriGroups.push({ category: key, items: nutriMap[key] })
+      }
+      this.nutritionGroups = nutriGroups
+    },
     buildScores: function(data) {
+      // 兼容两套字段名:实体存 balanceScore/diversityScore 等,旧视图读 gutBalanceScore 等
       var r = data.report || data
+      var s = r.summary || r
       var items = []
       var scoreFields = [
-        { key: 'overallScore', label: '综合', color: '#4A9BD7' },
-        { key: 'gutHealthScore', label: '菌群健康', color: '#10B981' },
-        { key: 'chronicDiseaseScore', label: '慢病控制', color: '#F59E0B' },
-        { key: 'nutritionScore', label: '营养均衡', color: '#FF8C42' },
-        { key: 'gutBalanceScore', label: '平衡', color: '#6366F1' },
-        { key: 'gutDiversityScore', label: '多样性', color: '#FF6B9D' },
-        { key: 'beneficialBacteriaScore', label: '有益菌', color: '#10B981' },
-        { key: 'harmfulBacteriaScore', label: '有害菌', color: '#C62828' },
-        { key: 'coreSpeciesScore', label: '核心菌属', color: '#4A9BD7' }
+        { key: 'overallScore',         label: '综合',            color: '#4A9BD7' },
+        { key: 'gutHealthScore',       label: '菌群健康',        color: '#10B981' },
+        { key: 'chronicDiseaseScore',  label: '慢病控制',        color: '#F59E0B' },
+        { key: 'nutritionScore',       label: '营养均衡',        color: '#FF8C42' },
+        { key: 'balanceScore',         label: '平衡',            color: '#6366F1' },
+        { key: 'diversityScore',       label: '多样性',          color: '#FF6B9D' },
+        { key: 'beneficialScore',      label: '有益菌',          color: '#10B981' },
+        { key: 'harmfulScore',         label: '有害菌',          color: '#C62828' },
+        { key: 'coreGenusScore',       label: '核心菌属',        color: '#4A9BD7' }
       ]
       for (var i = 0; i < scoreFields.length; i++) {
         var f = scoreFields[i]
-        if (r[f.key] != null) {
-          items.push({ value: r[f.key], label: f.label, color: f.color })
+        var val = s[f.key] != null ? s[f.key] : r[f.key]
+        if (val != null) {
+          items.push({ value: val, label: f.label, color: f.color })
         }
       }
       this.scoreItems = items
@@ -442,9 +551,17 @@ export default {
     saveEdit: function() {
       var self = this
       var payload = {
-        overallScore: this.report.overallScore,
-        gutHealthScore: this.report.gutHealthScore,
-        nutritionScore: this.report.nutritionScore,
+        summary: {
+          overallScore: this.report.overallScore,
+          gutHealthScore: this.report.gutHealthScore,
+          chronicDiseaseScore: this.report.chronicDiseaseScore,
+          nutritionScore: this.report.nutritionScore,
+          balanceScore: this.report.balanceScore,
+          diversityScore: this.report.diversityScore,
+          beneficialScore: this.report.beneficialScore,
+          harmfulScore: this.report.harmfulScore,
+          coreGenusScore: this.report.coreGenusScore
+        },
         indicators: this.allIndicators
       }
       editHealthReport(this.reportId, payload, 0).then(function(res) {
@@ -457,6 +574,45 @@ export default {
         }
       })
     },
+    doConfirm: function() {
+      var self = this
+      if (!self.draftId) return
+      self.confirming = true
+      confirmReportPreview({ draftId: self.draftId, payload: self.payload }).then(function(res) {
+        self.confirming = false
+        if (res.code === 200) {
+          var reportId = res.data && res.data.reportId
+          if (reportId) {
+            uni.redirectTo({ url: '/pages/health/gut-flora-detail?reportId=' + reportId })
+          } else {
+            uni.navigateBack()
+          }
+        } else {
+          uni.showToast({ title: res.message || '确认失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.confirming = false
+        uni.showToast({ title: '确认异常', icon: 'none' })
+      })
+    },
+    doDiscard: function() {
+      var self = this
+      if (!self.draftId) return
+      uni.showModal({
+        title: '提示',
+        content: '确定放弃该报告草稿吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          discardReportDraft(self.draftId).then(function(res2) {
+            if (res2.code === 200) {
+              uni.navigateBack()
+            } else {
+              uni.showToast({ title: res2.message || '操作失败', icon: 'none' })
+            }
+          })
+        }
+      })
+    },
     goToSpecies: function() {
       uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId })
     },
@@ -546,6 +702,12 @@ export default {
 .fab { position: fixed; right: 30rpx; bottom: 40rpx; width: 100rpx; height: 100rpx; background: #4A9BD7; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
 .fab-icon { font-size: 40rpx; color: #fff; }
 
+.draft-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
+.btn-discard { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
+.btn-confirm { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
+.btn-parsing { flex: 1; padding: 20rpx; background: #f0f0f0; border-radius: 12rpx; text-align: center; }
+.parsing-text { font-size: 28rpx; color: #999; }
+
 .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
 .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
 .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }

+ 15 - 2
cfc-frontend/pages/health/report-confirm.vue

@@ -501,7 +501,12 @@ export default {
   },
   onLoad: function(options) {
     this.draftId = options.draftId ? parseInt(options.draftId) : null
-    if (this.draftId) { uni.redirectTo({ url: '/pages/health/report-detail?draftId=' + this.draftId }); return }
+    if (this.draftId) {
+      // gut_flora 走个性化详情页,其他类型走通用详情页
+      var gotoUrl = '/pages/health/gut-flora-detail?draftId=' + this.draftId
+      uni.redirectTo({ url: gotoUrl })
+      return
+    }
     this.memberId = options.memberId ? parseInt(options.memberId) : null
     this.familyId = options.familyId ? parseInt(options.familyId) : null
 
@@ -827,7 +832,15 @@ export default {
             uni.showToast({ title: '入库成功', icon: 'success' })
             setTimeout(function() {
               var reportType = data.reportType || ''
-              uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + reportType + '&reportId=' + reportId })
+              var gotoUrl
+              if (reportType === 'gut_flora') {
+                gotoUrl = '/pages/health/gut-flora-detail?reportId=' + reportId
+              } else if (reportType === 'dan') {
+                gotoUrl = '/pages/dan-assessment/report-result?reportId=' + reportId
+              } else {
+                gotoUrl = '/pages/health/report-detail?reportType=' + reportType + '&reportId=' + reportId
+              }
+              uni.redirectTo({ url: gotoUrl })
             }, 500)
           } else {
             uni.navigateBack()

+ 14 - 14
cfc-frontend/pages/health/report-list.vue

@@ -128,20 +128,20 @@ export default {
         self.loading = false
       })
     },
-	goToDetail(reportId, reportType) {
-		var detailPages = {
-		'gut_flora': '/pages/health/report-detail?reportType=gut_flora',
-		'dan': '/pages/dan-assessment/report-result',
-		'physical_exam': '/pages/health/physical-exam-detail',
-		'tongue': '/pages/health/tongue-index'
-	  }
-	  var url = detailPages[reportType] || '/pages/body/health-report'
-	  var sep = url.indexOf('?') >= 0 ? '&' : '?'
-	  // 肠道菌群报告:从列表点击直接进入编辑模式,跳过查看过渡页
-	  if (reportType === 'gut_flora') {
-	    url += '&mode=edit'
-	  }
-	  uni.navigateTo({ url: url + sep + 'reportId=' + reportId })
+    goToDetail(reportId, reportType) {
+		var url
+		if (reportType === 'gut_flora') {
+			url = '/pages/health/gut-flora-detail?reportId=' + reportId
+		} else if (reportType === 'dan') {
+			url = '/pages/dan-assessment/report-result?reportId=' + reportId
+		} else if (reportType === 'physical_exam') {
+			url = '/pages/health/physical-exam-detail?reportId=' + reportId
+		} else if (reportType === 'tongue') {
+			url = '/pages/health/tongue-index?reportId=' + reportId
+		} else {
+			url = '/pages/body/health-report?reportId=' + reportId
+		}
+		uni.navigateTo({ url: url })
 	},
     goToUpload() {
       uni.navigateTo({ url: '/pages/health/report-upload' })

+ 1 - 1
cfc-frontend/pages/health/report-upload.vue

@@ -237,7 +237,7 @@ export default {
           uni.showToast({ title: (res && res.message) || '上传失败', icon: 'none' })
           return
         }
-        uni.redirectTo({ url: '/pages/health/report-detail?draftId=' + draftId })
+        uni.redirectTo({ url: '/pages/health/gut-flora-detail?draftId=' + draftId })
       }).catch((err) => {
         uni.hideLoading()
         this.uploading = false