Ver código fonte

feat(health): two-phase health report upload with preview confirm page

Sisyphus 2 meses atrás
pai
commit
a2c2ad984f

+ 13 - 0
cfc-frontend/pages.json

@@ -108,6 +108,19 @@
         "navigationBarTitleText": "上传健康报告"
       }
     },
+    {
+      "path": "pages/health/report-confirm",
+      "style": {
+        "navigationBarTitleText": "确认健康报告",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/health/report-survey",
+      "style": {
+        "navigationBarTitleText": "健康调研问卷"
+      }
+    },
     {
       "path": "pages/health/report-list",
       "style": {

+ 587 - 0
cfc-frontend/pages/health/report-confirm.vue

@@ -0,0 +1,587 @@
+<template>
+  <view class="confirm-container">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">确认健康报告</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="tip-banner">
+      <text class="tip-icon">✅</text>
+      <text class="tip-text">请核对以下解析内容,可修改后确认入库</text>
+    </view>
+
+    <view class="form-section">
+      <!-- 基本信息 -->
+      <view class="card">
+        <view class="card-title">基本信息</view>
+        <view class="field-row">
+          <text class="field-label">被检测人</text>
+          <text class="field-value" v-if="!needBind">{{ extractedName || '未识别' }}</text>
+          <text class="field-value binding-required" v-else>需手动绑定</text>
+        </view>
+        <view class="field-row" v-if="payload.summary">
+          <text class="field-label">综合评分</text>
+          <input class="field-input" type="number" v-model="payload.summary.overallScore" />
+        </view>
+        <view class="field-row" v-if="payload.summary">
+          <text class="field-label">报告摘要</text>
+          <textarea class="field-textarea" v-model="payload.summary.summary" maxlength="500" />
+        </view>
+      </view>
+
+      <!-- 家庭成员绑定 -->
+      <view class="card" v-if="needBind">
+        <view class="card-title">绑定家庭成员</view>
+        <view class="member-list">
+          <view
+            class="member-item"
+            v-for="member in familyMembers"
+            :key="member.userId"
+            :class="{ 'member-selected': selectedMemberId === member.userId }"
+            @click="selectMember(member)"
+          >
+            <text class="member-avatar">{{ member.nickname && member.nickname.substring(0, 1) || '?' }}</text>
+            <text class="member-name">{{ member.nickname || '未知' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 健康指标 -->
+      <view class="card" v-if="payload.indicators && payload.indicators.length">
+        <view class="card-title">健康指标 ({{ payload.indicators.length }})</view>
+        <view class="indicator-item" v-for="(ind, idx) in payload.indicators" :key="idx">
+          <view class="ind-header">
+            <input class="ind-name" v-model="payload.indicators[idx].name" />
+            <input class="ind-value" v-model="payload.indicators[idx].value" />
+          </view>
+          <view class="ind-detail" v-if="payload.indicators[idx].unit || payload.indicators[idx].referenceRange">
+            <text class="ind-ref" v-if="payload.indicators[idx].referenceRange">参考: {{ payload.indicators[idx].referenceRange }}</text>
+            <text class="ind-unit" v-if="payload.indicators[idx].unit">{{ payload.indicators[idx].unit }}</text>
+          </view>
+          <view class="ind-symptoms" v-if="payload.indicators[idx].symptoms">
+            <text class="ind-symptom-label">症状:</text>
+            <input class="ind-symptom-input" v-model="payload.indicators[idx].symptoms" />
+          </view>
+          <view class="ind-status-row">
+            <view class="status-tag" :class="'status-' + (payload.indicators[idx].status || 'normal')">
+              {{ statusText(payload.indicators[idx].status) }}
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 肠道菌群 -->
+      <view class="card" v-if="payload.gutFlora && payload.gutFlora.length">
+        <view class="card-title">肠道菌群 ({{ payload.gutFlora.length }})</view>
+        <view class="flora-item" v-for="(flora, idx) in payload.gutFlora" :key="idx">
+          <input class="flora-name" v-model="payload.gutFlora[idx].name" />
+          <input class="flora-abundance" type="digit" v-model="payload.gutFlora[idx].abundance" />
+          <view class="status-tag" :class="'status-' + (flora.status || 'normal')">
+            {{ statusText(flora.status) }}
+          </view>
+        </view>
+      </view>
+
+      <!-- 疾病风险 -->
+      <view class="card" v-if="payload.diseaseRisks && payload.diseaseRisks.length">
+        <view class="card-title">疾病风险 ({{ payload.diseaseRisks.length }})</view>
+        <view class="risk-item" v-for="(risk, idx) in payload.diseaseRisks" :key="idx">
+          <input class="risk-name" v-model="payload.diseaseRisks[idx].diseaseName" />
+          <view class="risk-level" :class="'risk-' + (risk.riskLevel || 'low')">
+            {{ riskLevelText(risk.riskLevel) }}
+          </view>
+        </view>
+      </view>
+
+      <!-- 饮食推荐 -->
+      <view class="card" v-if="payload.foods && payload.foods.length">
+        <view class="card-title">饮食推荐 ({{ payload.foods.length }})</view>
+        <view class="food-item" v-for="(food, idx) in payload.foods" :key="idx">
+          <input class="food-name" v-model="payload.foods[idx].foodName" />
+          <input class="food-category" v-model="payload.foods[idx].category" />
+        </view>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="action-row">
+        <view class="btn-discard" @click="doDiscard">
+          <text>放弃</text>
+        </view>
+        <view class="btn-confirm" :class="{ 'btn-disabled': !canConfirm }" @click="doConfirm">
+          <text v-if="confirming">确认中...</text>
+          <text v-else>确认入库</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import { confirmReportPreview, discardReportDraft } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      draftId: null,
+      childId: null,
+      familyId: null,
+      payload: {
+        summary: {},
+        indicators: [],
+        gutFlora: [],
+        probioticSpecies: [],
+        foods: [],
+        diseaseRisks: []
+      },
+      extractedName: '',
+      matchedMemberId: null,
+      needBind: false,
+      selectedMemberId: null,
+      familyMembers: [],
+      confirming: false
+    }
+  },
+  computed: {
+    canConfirm() {
+      if (this.needBind && !this.selectedMemberId) return false
+      return this.draftId && !this.confirming
+    }
+  },
+  onLoad: function(options) {
+    this.draftId = options.draftId ? parseInt(options.draftId) : null
+    this.childId = options.childId ? parseInt(options.childId) : null
+    this.familyId = options.familyId ? parseInt(options.familyId) : null
+
+    var payloadStr = decodeURIComponent(options.payload || '{}')
+    try {
+      var parsed = typeof payloadStr === 'string' ? JSON.parse(payloadStr) : payloadStr
+      this.payload = Object.assign({
+        summary: {},
+        indicators: [],
+        gutFlora: [],
+        probioticSpecies: [],
+        foods: [],
+        diseaseRisks: []
+      }, parsed)
+    } catch (e) {
+      console.log('解析payload失败', e)
+    }
+
+    this.extractedName = options.extractedName || ''
+    this.matchedMemberId = options.matchedMemberId ? parseInt(options.matchedMemberId) : null
+    this.needBind = options.needBind === 'true' || options.needBind === '1'
+
+    if (this.needBind) {
+      this.loadFamilyMembers()
+    }
+  },
+  methods: {
+    loadFamilyMembers() {
+      var store = uni.getStorageSync('familyChildren')
+      if (store) {
+        try {
+          this.familyMembers = typeof store === 'string' ? JSON.parse(store) : store
+        } catch (e) {
+          this.familyMembers = []
+        }
+      }
+    },
+    selectMember(member) {
+      this.selectedMemberId = member.userId || member.id
+    },
+    statusText(status) {
+      if (status === 'high') return '偏高'
+      if (status === 'low') return '偏低'
+      if (status === 'abnormal') return '异常'
+      return '正常'
+    },
+    riskLevelText(level) {
+      if (level === 'high') return '高风险'
+      if (level === 'medium' || level === 'moderate') return '中风险'
+      return '低风险'
+    },
+    doConfirm() {
+      if (!this.canConfirm) return
+      var self = this
+      self.confirming = true
+      uni.showLoading({ title: '确认入库...' })
+
+      var postData = {
+        draftId: self.draftId,
+        payload: self.payload
+      }
+      if (self.needBind && self.selectedMemberId) {
+        postData.subjectId = self.selectedMemberId
+      } else if (self.matchedMemberId) {
+        postData.subjectId = self.matchedMemberId
+      } else if (self.childId) {
+        postData.subjectId = self.childId
+      }
+
+      confirmReportPreview(postData).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          uni.showToast({ title: '入库成功', icon: 'success' })
+          var reportId = res.data && res.data.reportId
+          var reportType = res.data && res.data.reportType || ''
+          setTimeout(function() {
+            if (reportId) {
+              if (reportType === 'gut_flora') {
+                uni.redirectTo({ url: '/pages/health/report-survey?reportId=' + reportId + '&childId=' + (postData.subjectId || '') })
+              } else {
+                uni.redirectTo({ url: '/pages/body/health-report?reportId=' + reportId })
+              }
+            } else {
+              uni.navigateBack()
+            }
+          }, 500)
+        } else {
+          uni.showToast({ title: res.message || '确认失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: '确认失败,请重试', icon: 'none' })
+        console.log('确认入库失败', e)
+      }).finally(function() {
+        self.confirming = false
+      })
+    },
+    doDiscard() {
+      var self = this
+      uni.showModal({
+        title: '确认放弃',
+        content: '放弃后解析数据将被删除,需要重新上传',
+        success: function(res) {
+          if (res.confirm) {
+            discardReportDraft(self.draftId).then(function() {
+              uni.showToast({ title: '已放弃', icon: 'none' })
+              setTimeout(function() { uni.navigateBack() }, 500)
+            }).catch(function() {
+              uni.showToast({ title: '操作失败', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    goBack() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.confirm-container {
+  min-height: 100vh;
+  background: #F5F7FA;
+}
+.nav-bar {
+  position: sticky;
+  top: 0;
+  background: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+  z-index: 100;
+}
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+
+.tip-banner {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background: #E8F5E9;
+  margin: 20rpx 30rpx;
+  padding: 20rpx 24rpx;
+  border-radius: 16rpx;
+}
+.tip-icon { font-size: 32rpx; margin-right: 16rpx; flex-shrink: 0; }
+.tip-text { font-size: 24rpx; color: #2E7D32; line-height: 1.5; }
+
+.form-section { margin: 0 30rpx; }
+
+.card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.04);
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+  padding-bottom: 12rpx;
+  border-bottom: 2rpx solid #F0F0F0;
+}
+
+.field-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.field-label {
+  font-size: 26rpx;
+  color: #666;
+  width: 160rpx;
+  flex-shrink: 0;
+}
+.field-value {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  flex: 1;
+}
+.field-value.binding-required {
+  color: #E65100;
+}
+.field-input {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  padding: 8rpx 16rpx;
+  background: #F5F7FA;
+  border-radius: 8rpx;
+}
+.field-textarea {
+  width: 100%;
+  min-height: 120rpx;
+  font-size: 26rpx;
+  color: #333;
+  padding: 16rpx;
+  background: #F5F7FA;
+  border-radius: 12rpx;
+  box-sizing: border-box;
+  margin-top: 8rpx;
+}
+
+.member-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.member-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  background: #F5F7FA;
+  border-radius: 16rpx;
+  border: 2rpx solid transparent;
+  min-width: 120rpx;
+}
+.member-selected {
+  background: #E3F2FD;
+  border-color: #5B9BD5;
+}
+.member-avatar {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: #5B9BD5;
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+  text-align: center;
+  line-height: 64rpx;
+  margin-bottom: 8rpx;
+}
+.member-name { font-size: 24rpx; color: #333; }
+
+.indicator-item {
+  background: #F8FAFB;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  margin-bottom: 12rpx;
+}
+.ind-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+}
+.ind-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.ind-value {
+  width: 160rpx;
+  font-size: 26rpx;
+  color: #1565C0;
+  font-weight: bold;
+  text-align: right;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.ind-detail {
+  display: flex;
+  flex-direction: row;
+  margin-top: 8rpx;
+  gap: 16rpx;
+}
+.ind-ref { font-size: 22rpx; color: #94A3B8; }
+.ind-unit { font-size: 22rpx; color: #64748B; }
+.ind-symptoms {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 8rpx;
+  gap: 8rpx;
+}
+.ind-symptom-label { font-size: 22rpx; color: #94A3B8; flex-shrink: 0; }
+.ind-symptom-input {
+  flex: 1;
+  font-size: 22rpx;
+  color: #333;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.ind-status-row { margin-top: 8rpx; }
+.status-tag {
+  display: inline-block;
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+  font-weight: 500;
+}
+.status-normal { background: #E8F5E9; color: #2E7D32; }
+.status-high { background: #FFF3E0; color: #E65100; }
+.status-low { background: #E3F2FD; color: #1565C0; }
+.status-abnormal { background: #FFEBEE; color: #C62828; }
+
+.flora-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 16rpx;
+  background: #F8FAFB;
+  border-radius: 10rpx;
+  margin-bottom: 8rpx;
+}
+.flora-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.flora-abundance {
+  width: 140rpx;
+  font-size: 26rpx;
+  color: #1565C0;
+  text-align: right;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+
+.risk-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 16rpx;
+  background: #F8FAFB;
+  border-radius: 10rpx;
+  margin-bottom: 8rpx;
+}
+.risk-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.risk-level {
+  font-size: 22rpx;
+  font-weight: 500;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.risk-low { background: #E8F5E9; color: #2E7D32; }
+.risk-medium, .risk-moderate { background: #FFF3E0; color: #E65100; }
+.risk-high { background: #FFEBEE; color: #C62828; }
+
+.food-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 16rpx;
+  background: #F8FAFB;
+  border-radius: 10rpx;
+  margin-bottom: 8rpx;
+}
+.food-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+.food-category {
+  width: 160rpx;
+  font-size: 24rpx;
+  color: #64748B;
+  text-align: right;
+  padding: 4rpx 8rpx;
+  background: #fff;
+  border-radius: 6rpx;
+}
+
+.action-row {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+  margin-top: 10rpx;
+  margin-bottom: 20rpx;
+}
+.btn-discard {
+  flex: 1;
+  background: #fff;
+  border: 2rpx solid #EF4444;
+  color: #EF4444;
+  font-size: 28rpx;
+  font-weight: bold;
+  text-align: center;
+  padding: 24rpx;
+  border-radius: 16rpx;
+}
+.btn-discard:active { opacity: 0.7; }
+.btn-confirm {
+  flex: 2;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+  text-align: center;
+  padding: 24rpx;
+  border-radius: 16rpx;
+}
+.btn-confirm:active { opacity: 0.85; }
+.btn-disabled { opacity: 0.5; }
+
+.bottom-spacer { height: 80rpx; }
+</style>

+ 38 - 119
cfc-frontend/pages/health/report-upload.vue

@@ -157,7 +157,7 @@
 </template>
 
 <script>
-import { createHealthReport } from '../../utils/api.js'
+import { createHealthReport, parseReportPreview } from '../../utils/api.js'
 import config from '@/config.js'
 
 export default {
@@ -183,9 +183,8 @@ export default {
   },
   computed: {
     canSubmit() {
-      // 有文件时需要先解析并匹配成功(或已选成员)
       if (this.selectedFile) {
-        return this.parsedInfo && !this.parsedInfo.needManualSelect
+        return this.parsedInfo && this.parsedInfo.draftId && !this.parsedInfo.needManualSelect
       }
       return this.form.reportType && this.form.reportDate
     }
@@ -264,46 +263,24 @@ export default {
       if (!this.selectedFile || !this.familyId) return
       this.parsingPreview = true
 
-      uni.uploadFile({
-        url: config.API_BASE_URL + '/api/health/report/upload',
-        filePath: this.selectedFile.path,
-        name: 'file',
-        formData: {
-          childId: this.childId || '',
-          familyId: this.familyId || ''
-        },
-        header: {
-          Authorization: uni.getStorageSync('token') || ''
-        },
-        success: function(res) {
-          var data
-          try {
-            data = JSON.parse(res.data)
-          } catch (e) {
-            data = res.data
-          }
-          if (data && data.code === 200) {
-            var detail = data.data
-            self.parsedInfo = {
-              personName: detail.extractedName || detail.matchedMemberName,
-              reportNumber: detail.extractedReportNumber,
-              reportDate: detail.extractedReportDate,
-              needManualSelect: detail.needManualSelect
-            }
-            if (detail.needManualSelect) {
-              self.showMemberSelector = true
-              self.loadFamilyMembers()
-            }
-          } else if (data && data.message) {
-            uni.showToast({ title: '解析失败: ' + data.message, icon: 'none' })
-          }
-        },
-        fail: function(e) {
-          console.log('预览解析失败', e)
-        },
-        complete: function() {
-          self.parsingPreview = false
+      parseReportPreview(this.selectedFile.path, this.childId, this.familyId).then(function(res) {
+        var detail = res.data
+        self.parsedInfo = {
+          draftId: detail.draftId,
+          personName: detail.extractedName || detail.matchedMemberName,
+          reportNumber: detail.extractedReportNumber,
+          reportDate: detail.extractedReportDate,
+          needManualSelect: detail.needBind
+        }
+        if (detail.needBind) {
+          self.showMemberSelector = true
+          self.loadFamilyMembers()
         }
+      }).catch(function(e) {
+        var msg = (e && e.message) || '解析失败'
+        uni.showToast({ title: msg, icon: 'none' })
+      }).finally(function() {
+        self.parsingPreview = false
       })
     },
     loadFamilyMembers() {
@@ -343,91 +320,35 @@ export default {
       if (this.submitting) return
       var self = this
 
-      // 有文件则走上传流程
       if (self.selectedFile) {
-        // 如果需要手动选择但未选,不允许提交
         if (self.parsedInfo && self.parsedInfo.needManualSelect && !self.selectedMemberId) {
           uni.showToast({ title: '请先选择成员', icon: 'none' })
           return
         }
+        if (!self.parsedInfo || !self.parsedInfo.draftId) {
+          uni.showToast({ title: '请先等待解析完成', icon: 'none' })
+          return
+        }
 
-        self.submitting = true
-        uni.showLoading({ title: '上传中...' })
-
-        // 确定使用的childId
-        var submitChildId = self.selectedMemberId || self.childId || ''
+        var payloadStr = encodeURIComponent(JSON.stringify(self.parsedInfo.payload || {}))
+        var params = 'draftId=' + self.parsedInfo.draftId
+          + '&extractedName=' + encodeURIComponent(self.parsedInfo.personName || '')
+          + '&payload=' + payloadStr
+          + '&needBind=' + (self.parsedInfo.needManualSelect ? 'true' : 'false')
+        if (self.parsedInfo.matchedMemberId) {
+          params += '&matchedMemberId=' + self.parsedInfo.matchedMemberId
+        }
+        if (self.childId) {
+          params += '&childId=' + self.childId
+        }
+        if (self.familyId) {
+          params += '&familyId=' + self.familyId
+        }
 
-        uni.uploadFile({
-          url: config.API_BASE_URL + '/api/health/report/upload',
-          filePath: self.selectedFile.path,
-          name: 'file',
-          formData: {
-            childId: String(submitChildId),
-            familyId: self.familyId || '',
-            reportType: self.form.reportType,
-            reportDate: self.form.reportDate,
-            overallScore: String(self.form.overallScore || 0),
-            description: self.form.description || ''
-          },
-          header: {
-            Authorization: uni.getStorageSync('token') || ''
-          },
-          success: function(res) {
-            uni.hideLoading()
-            var data
-            try {
-              data = JSON.parse(res.data)
-            } catch (e) {
-              data = res.data
-            }
-            if (data && data.code === 200) {
-              var detail = data.data
-              // 如果后端仍返回needManualSelect,说明需要重新选择
-              if (detail && detail.needManualSelect) {
-                self.parsedInfo = {
-                  personName: detail.extractedName,
-                  reportNumber: detail.extractedReportNumber,
-                  reportDate: detail.extractedReportDate,
-                  needManualSelect: true
-                }
-                self.showMemberSelector = true
-                self.loadFamilyMembers()
-                uni.showToast({ title: '请手动选择成员', icon: 'none' })
-                return
-              }
-              uni.showToast({ title: '上传成功', icon: 'success' })
-              var reportId = detail && (detail.report ? detail.report.id : detail.id)
-              if (reportId) {
-                setTimeout(function() {
-                  if (self.form.reportType === 'gut_flora') {
-                    uni.redirectTo({ url: '/pages/health/report-survey?reportId=' + reportId + '&childId=' + submitChildId })
-                  } else {
-                    uni.redirectTo({ url: '/pages/body/health-report?reportId=' + reportId })
-                  }
-                }, 500)
-              } else {
-                setTimeout(function() {
-                  uni.navigateBack()
-                }, 500)
-              }
-            } else {
-              var msg = (data && data.message) || '上传失败'
-              uni.showToast({ title: msg, icon: 'none' })
-            }
-          },
-          fail: function(e) {
-            uni.hideLoading()
-            uni.showToast({ title: '上传失败,请重试', icon: 'none' })
-            console.log('上传文件失败', e)
-          },
-          complete: function() {
-            self.submitting = false
-          }
-        })
+        uni.navigateTo({ url: '/pages/health/report-confirm?' + params })
         return
       }
 
-      // 无文件,走手动录入
       self.submitting = true
       uni.showLoading({ title: '提交中...' })
       createHealthReport({
@@ -439,12 +360,10 @@ export default {
         uni.hideLoading()
         if (res.code === 200) {
           uni.showToast({ title: '提交成功', icon: 'success' })
-          // 跳转到报告详情
           var reportId = res.data && res.data.id
           if (reportId) {
             setTimeout(function() {
               if (self.form.reportType === 'gut_flora') {
-                // 肠道检测 → 调研问卷 → AI 解读
                 uni.redirectTo({
                   url: '/pages/health/report-survey?reportId=' + reportId + '&childId=' + (self.childId || '')
                 })

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

@@ -1375,13 +1375,48 @@ export const getProductsByDomain = (domain, page, size) => {
   return danshopProductList({ domain: domain, page: page || 1, size: size || 6 })
 }
 
-// ===== 健康报告 =====
+// ===== 健康报告(两阶段入库) =====
 export const getLatestHealthReport = (childId) => request('/api/health/report/latest', 'POST', { childId })
 export const getHealthIndicatorList = (childId) => request('/api/health/indicator/list', 'POST', { childId })
 export const createHealthReport = (data) => request('/api/health/report/create', 'POST', data)
 export const getReportList = (childId) => request('/api/health/report/list', 'POST', { childId })
 export const getReportDetail = (reportId) => request('/api/health/report/detail', 'POST', { reportId })
 
+// 阶段1:上传PDF→解析→存draft→返回draftId+payload+成员匹配(不落库)
+export const parseReportPreview = (filePath, childId, familyId) => {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: BASE_URL + '/api/health/report/parse-preview',
+      filePath: filePath,
+      name: 'file',
+      formData: {
+        childId: childId || '',
+        familyId: familyId || ''
+      },
+      header: {
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: (res) => {
+        var data
+        try { data = JSON.parse(res.data) } catch (e) { data = res.data }
+        if (data && data.code === 200) {
+          resolve(data)
+        } else {
+          reject(data)
+        }
+      },
+      fail: (err) => { reject(err) }
+    })
+  })
+}
+
+// 阶段2:确认落库(可选传修改后的payload和subjectId)
+export const confirmReportPreview = (data) => request('/api/health/report/confirm', 'POST', data)
+
+// 放弃草稿
+export const discardReportDraft = (draftId) => request('/api/health/report/discard', 'POST', { draftId })
+
 // ===== 报告调研问卷 =====
 export const createReportSurvey = (reportId, childId) => request('/api/health/survey/create', 'POST', { reportId, childId })
 export const submitReportSurvey = (surveyId, surveyData) => request('/api/health/survey/submit', 'POST', { surveyId, surveyData })