Explorar o código

feat(cfc-frontend): health report Phase 2 - match confidence, candidate selection, auto-create member

- report-upload.vue: pass matchConfidence/candidates/extractedGender/extractedAge to confirm page; fix payload passthrough bug
- report-confirm.vue: add colored confidence badge + progress bar
- report-confirm.vue: add ranked candidate member list with similarity scores
- report-confirm.vue: add auto-create member form with pre-filled extracted data
Xiaogang Liao hai 2 meses
pai
achega
1a70c64ac5

+ 433 - 9
cfc-frontend/pages/health/report-confirm.vue

@@ -11,6 +11,18 @@
       <text class="tip-text">请核对以下解析内容,可修改后确认入库</text>
     </view>
 
+    <!-- 匹配可信度 -->
+    <view class="confidence-card" v-if="confidenceLevelClass">
+      <view class="confidence-header">
+        <text class="confidence-label">匹配可信度</text>
+        <text class="confidence-badge" :class="'badge-' + confidenceLevelClass">{{ confidenceLevelText }}</text>
+      </view>
+      <view class="confidence-bar-track">
+        <view class="confidence-bar-fill" :class="'fill-' + confidenceLevelClass" :style="'width:' + confidencePercent + '%'"></view>
+      </view>
+      <text class="confidence-desc">{{ confidenceDesc }}</text>
+    </view>
+
     <view class="form-section">
       <!-- 基本信息 -->
       <view class="card">
@@ -20,6 +32,14 @@
           <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="extractedGender">
+          <text class="field-label">性别</text>
+          <text class="field-value">{{ extractedGender === 'male' ? '男' : extractedGender === 'female' ? '女' : extractedGender }}</text>
+        </view>
+        <view class="field-row" v-if="extractedAge">
+          <text class="field-label">年龄</text>
+          <text class="field-value">{{ extractedAge }}</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" />
@@ -30,21 +50,80 @@
         </view>
       </view>
 
-      <!-- 家庭成员绑定 -->
+      <!-- 家庭成员绑定(含候选推荐) -->
       <view class="card" v-if="needBind">
-        <view class="card-title">绑定家庭成员</view>
-        <view class="member-list">
+        <view class="card-title">选择家庭成员</view>
+
+        <!-- 候选匹配列表(按置信度排序) -->
+        <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="cand.id || idx"
+            :class="{ 'candidate-selected': selectedMemberId === (cand.id || cand.userId) }"
+            @click="selectCandidate(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>
+                <text class="candidate-fields" v-if="cand.matchFields">匹配: {{ cand.matchFields }}</text>
+              </view>
+            </view>
+            <view class="candidate-right">
+              <view class="score-bar-track">
+                <view class="score-bar-fill" :style="'width:' + (cand.similarityScore * 100) + '%'"></view>
+              </view>
+              <text class="score-text">{{ Math.round(cand.similarityScore * 100) }}%</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 全量家庭成员列表(后备) -->
+        <view class="member-list" v-if="familyMembers.length > 0 && (candidates.length === 0 || !showCandidatesOnly)">
           <view
             class="member-item"
             v-for="member in familyMembers"
-            :key="member.userId"
-            :class="{ 'member-selected': selectedMemberId === member.userId }"
+            :key="member.userId || member.id"
+            :class="{ 'member-selected': selectedMemberId === (member.userId || member.id) }"
             @click="selectMember(member)"
           >
-            <text class="member-avatar">{{ member.nickname && member.nickname.substring(0, 1) || '?' }}</text>
+            <text class="member-avatar">{{ (member.nickname || '?').substring(0, 1) }}</text>
             <text class="member-name">{{ member.nickname || '未知' }}</text>
           </view>
         </view>
+
+        <!-- 新建成员入口 -->
+        <view class="create-member-entry" @click="toggleAutoCreate">
+          <text class="create-member-icon">+</text>
+          <text class="create-member-text">{{ showAutoCreateForm ? '收起新建表单' : '不在列表中?新建家庭成员' }}</text>
+        </view>
+      </view>
+
+      <!-- 新建家庭成员表单 -->
+      <view class="card auto-create-card" v-if="showAutoCreateForm">
+        <view class="card-title">新建家庭成员</view>
+        <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' }" @click="newMember.gender = 'male'">男</text>
+            <text class="gender-option" :class="{ 'gender-selected': newMember.gender === 'female' }" @click="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 }" @click="doAutoCreate">
+          <text v-if="creatingMember">创建中...</text>
+          <text v-else>确认创建</text>
+        </view>
       </view>
 
       <!-- 健康指标 -->
@@ -121,6 +200,7 @@
 
 <script>
 import { confirmReportPreview, discardReportDraft } from '../../utils/api.js'
+import { addFamilyMember } from '../../utils/api.js'
 
 export default {
   data() {
@@ -137,17 +217,56 @@ export default {
         diseaseRisks: []
       },
       extractedName: '',
+      extractedGender: '',
+      extractedAge: '',
       matchedMemberId: null,
       needBind: false,
       selectedMemberId: null,
       familyMembers: [],
-      confirming: false
+      confirming: false,
+      // Phase 2: 匹配可信度与候选
+      matchConfidence: null,
+      matchLabel: '',
+      candidates: [],
+      showCandidatesOnly: false,
+      // 新建成员
+      showAutoCreateForm: false,
+      newMember: {
+        name: '',
+        gender: '',
+        age: ''
+      },
+      creatingMember: false,
+      createdMemberId: null
     }
   },
   computed: {
     canConfirm() {
-      if (this.needBind && !this.selectedMemberId) return false
+      if (this.needBind && !this.selectedMemberId && !this.createdMemberId) return false
       return this.draftId && !this.confirming
+    },
+    confidenceLevelClass() {
+      if (!this.matchConfidence && this.matchConfidence !== 0) return ''
+      var val = parseFloat(this.matchConfidence)
+      if (this.matchLabel === 'exact' || val >= 0.8) return 'high'
+      if (this.matchLabel === 'fuzzy' || val >= 0.5) return 'medium'
+      if (this.matchLabel === 'profile_match' || val >= 0.2) return 'low'
+      return 'none'
+    },
+    confidenceLevelText() {
+      var map = { high: '高', medium: '中', low: '低', none: '未匹配' }
+      return map[this.confidenceLevelClass] || ''
+    },
+    confidencePercent() {
+      if (!this.matchConfidence && this.matchConfidence !== 0) return 0
+      return Math.round(parseFloat(this.matchConfidence) * 100)
+    },
+    confidenceDesc() {
+      if (this.matchLabel === 'exact') return '根据姓名、性别、年龄自动精确匹配到家庭成员'
+      if (this.matchLabel === 'fuzzy') return '部分字段匹配,请确认是否为以下家庭成员'
+      if (this.matchLabel === 'profile_match') return '根据档案信息推测匹配,请手动确认'
+      if (this.matchLabel === 'no_match') return '无法匹配到现有家庭成员,可新建成员'
+      return ''
     }
   },
   onLoad: function(options) {
@@ -171,11 +290,34 @@ export default {
     }
 
     this.extractedName = options.extractedName || ''
+    this.extractedGender = options.extractedGender || ''
+    this.extractedAge = options.extractedAge || ''
     this.matchedMemberId = options.matchedMemberId ? parseInt(options.matchedMemberId) : null
     this.needBind = options.needBind === 'true' || options.needBind === '1'
+    this.matchConfidence = options.matchConfidence ? parseFloat(options.matchConfidence) : null
+    this.matchLabel = decodeURIComponent(options.matchLabel || '')
+
+    // 解析候选成员列表
+    var candidatesStr = decodeURIComponent(options.candidates || '')
+    if (candidatesStr) {
+      try {
+        this.candidates = JSON.parse(candidatesStr)
+      } catch (e) {
+        this.candidates = []
+      }
+    }
+
+    // 有候选时优先展示候选列表
+    if (this.candidates.length > 0) {
+      this.showCandidatesOnly = true
+    }
 
     if (this.needBind) {
       this.loadFamilyMembers()
+      // 高置信度且已匹配 → 直接选中
+      if (this.matchedMemberId && this.matchConfidence >= 0.8) {
+        this.selectedMemberId = this.matchedMemberId
+      }
     }
   },
   methods: {
@@ -192,6 +334,58 @@ export default {
     selectMember(member) {
       this.selectedMemberId = member.userId || member.id
     },
+    // === Phase 2: 候选匹配与新建成员 ===
+    genderText(gender) {
+      if (gender === 'male') return '男'
+      if (gender === 'female') return '女'
+      return gender || ''
+    },
+    selectCandidate(candidate) {
+      this.selectedMemberId = candidate.id || candidate.userId
+      this.showAutoCreateForm = false
+    },
+    toggleAutoCreate() {
+      this.showAutoCreateForm = !this.showAutoCreateForm
+      if (this.showAutoCreateForm) {
+        this.newMember.name = this.extractedName || ''
+        this.newMember.gender = this.extractedGender || ''
+        this.newMember.age = this.extractedAge || ''
+        this.selectedMemberId = null
+      }
+    },
+    doAutoCreate() {
+      var self = this
+      if (!this.newMember.name) {
+        uni.showToast({ title: '请输入成员姓名', icon: 'none' })
+        return
+      }
+      self.creatingMember = true
+      uni.showLoading({ title: '创建成员...' })
+
+      addFamilyMember({
+        nickname: self.newMember.name,
+        gender: self.newMember.gender || '',
+        age: self.newMember.age ? parseInt(self.newMember.age) : null
+      }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          var memberId = res.data && (res.data.userId || res.data.id)
+          if (memberId) {
+            self.createdMemberId = memberId
+            self.selectedMemberId = memberId
+            self.showAutoCreateForm = false
+            uni.showToast({ title: '创建成功', icon: 'success' })
+          }
+        } else {
+          uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: '创建失败', icon: 'none' })
+      }).finally(function() {
+        self.creatingMember = false
+      })
+    },
     statusText(status) {
       if (status === 'high') return '偏高'
       if (status === 'low') return '偏低'
@@ -213,8 +407,11 @@ export default {
         draftId: self.draftId,
         payload: self.payload
       }
-      if (self.needBind && self.selectedMemberId) {
+      // 优先级: 已选成员 > 新建成员 > 自动匹配(高置信度) > 子ID
+      if (self.selectedMemberId) {
         postData.subjectId = self.selectedMemberId
+      } else if (self.createdMemberId) {
+        postData.subjectId = self.createdMemberId
       } else if (self.matchedMemberId) {
         postData.subjectId = self.matchedMemberId
       } else if (self.childId) {
@@ -584,4 +781,231 @@ export default {
 .btn-disabled { opacity: 0.5; }
 
 .bottom-spacer { height: 80rpx; }
+
+/* ===== Phase 2: 匹配可信度 ===== */
+.confidence-card {
+  margin: 0 30rpx 20rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.04);
+}
+.confidence-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.confidence-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.confidence-badge {
+  font-size: 22rpx;
+  font-weight: bold;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+.badge-high { background: #E8F5E9; color: #2E7D32; }
+.badge-medium { background: #FFF8E1; color: #F57F17; }
+.badge-low { background: #FFF3E0; color: #E65100; }
+.badge-none { background: #FFEBEE; color: #C62828; }
+
+.confidence-bar-track {
+  height: 12rpx;
+  background: #E2E8F0;
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 12rpx;
+}
+.confidence-bar-fill {
+  height: 100%;
+  border-radius: 6rpx;
+  transition: width 0.3s ease;
+}
+.fill-high { background: linear-gradient(90deg, #66BB6A, #43A047); }
+.fill-medium { background: linear-gradient(90deg, #FFD54F, #FFC107); }
+.fill-low { background: linear-gradient(90deg, #FF8A65, #FF5722); }
+.fill-none { background: linear-gradient(90deg, #EF9A9A, #E53935); }
+
+.confidence-desc {
+  font-size: 22rpx;
+  color: #94A3B8;
+  line-height: 1.4;
+}
+
+/* ===== Phase 2: 候选成员列表 ===== */
+.candidate-section {
+  margin-bottom: 16rpx;
+}
+.candidate-section-hint {
+  display: block;
+  font-size: 24rpx;
+  color: #64748B;
+  margin-bottom: 12rpx;
+}
+.candidate-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx;
+  background: #F8FAFB;
+  border-radius: 12rpx;
+  margin-bottom: 10rpx;
+  border: 2rpx solid transparent;
+}
+.candidate-item:active { opacity: 0.8; }
+.candidate-selected {
+  background: #E3F2FD;
+  border-color: #5B9BD5;
+}
+.candidate-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+}
+.candidate-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: #5B9BD5;
+  color: #fff;
+  font-size: 24rpx;
+  font-weight: bold;
+  text-align: center;
+  line-height: 56rpx;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+.candidate-info {
+  flex: 1;
+  min-width: 0;
+}
+.candidate-name {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+}
+.candidate-detail {
+  font-size: 22rpx;
+  color: #94A3B8;
+  display: block;
+}
+.candidate-fields {
+  font-size: 20rpx;
+  color: #5B9BD5;
+  display: block;
+  margin-top: 2rpx;
+}
+.candidate-right {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 12rpx;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+.score-bar-track {
+  width: 80rpx;
+  height: 10rpx;
+  background: #E2E8F0;
+  border-radius: 5rpx;
+  overflow: hidden;
+}
+.score-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #5B9BD5, #3A7CC4);
+  border-radius: 5rpx;
+}
+.score-text {
+  font-size: 24rpx;
+  color: #1565C0;
+  font-weight: bold;
+  min-width: 48rpx;
+  text-align: right;
+}
+
+/* ===== Phase 2: 新建成员入口 ===== */
+.create-member-entry {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx;
+  border: 2rpx dashed #CBD5E1;
+  border-radius: 12rpx;
+  margin-top: 8rpx;
+}
+.create-member-entry:active { background: #F1F5F9; }
+.create-member-icon {
+  font-size: 28rpx;
+  color: #5B9BD5;
+  font-weight: bold;
+  margin-right: 8rpx;
+}
+.create-member-text {
+  font-size: 24rpx;
+  color: #5B9BD5;
+}
+
+/* ===== Phase 2: 新建成员表单 ===== */
+.auto-create-card {
+  border: 2rpx solid #E3F2FD;
+}
+.form-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.form-item-label {
+  font-size: 26rpx;
+  color: #333;
+  width: 100rpx;
+  flex-shrink: 0;
+}
+.form-item-input {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  padding: 12rpx 16rpx;
+  background: #F5F7FA;
+  border-radius: 10rpx;
+}
+.gender-selector {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+.gender-option {
+  font-size: 26rpx;
+  color: #333;
+  padding: 12rpx 32rpx;
+  background: #F5F7FA;
+  border-radius: 10rpx;
+  border: 2rpx solid transparent;
+}
+.gender-option:active { opacity: 0.7; }
+.gender-selected {
+  background: #E3F2FD;
+  border-color: #5B9BD5;
+  color: #1565C0;
+}
+.btn-create-submit {
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+  text-align: center;
+  padding: 22rpx;
+  border-radius: 14rpx;
+  margin-top: 10rpx;
+}
+.btn-create-submit:active { opacity: 0.85; }
 </style>

+ 17 - 2
cfc-frontend/pages/health/report-upload.vue

@@ -267,10 +267,17 @@ export default {
         var detail = res.data
         self.parsedInfo = {
           draftId: detail.draftId,
-          personName: detail.extractedName || detail.matchedMemberName,
+          personName: detail.extractedName || '',
+          extractedGender: detail.extractedGender || '',
+          extractedAge: detail.extractedAge || '',
           reportNumber: detail.extractedReportNumber,
           reportDate: detail.extractedReportDate,
-          needManualSelect: detail.needBind
+          needManualSelect: detail.needBind,
+          payload: detail.payload || {},
+          matchedMemberId: detail.matchedMemberId,
+          confidence: detail.confidence,
+          matchLabel: detail.matchLabel,
+          candidates: detail.candidates || []
         }
         if (detail.needBind) {
           self.showMemberSelector = true
@@ -331,10 +338,18 @@ export default {
         }
 
         var payloadStr = encodeURIComponent(JSON.stringify(self.parsedInfo.payload || {}))
+        var candidatesStr = (self.parsedInfo.candidates && self.parsedInfo.candidates.length > 0)
+          ? encodeURIComponent(JSON.stringify(self.parsedInfo.candidates))
+          : ''
         var params = 'draftId=' + self.parsedInfo.draftId
           + '&extractedName=' + encodeURIComponent(self.parsedInfo.personName || '')
+          + '&extractedGender=' + encodeURIComponent(self.parsedInfo.extractedGender || '')
+          + '&extractedAge=' + (self.parsedInfo.extractedAge || '')
           + '&payload=' + payloadStr
           + '&needBind=' + (self.parsedInfo.needManualSelect ? 'true' : 'false')
+          + '&matchConfidence=' + (self.parsedInfo.confidence || '')
+          + '&matchLabel=' + encodeURIComponent(self.parsedInfo.matchLabel || '')
+          + '&candidates=' + candidatesStr
         if (self.parsedInfo.matchedMemberId) {
           params += '&matchedMemberId=' + self.parsedInfo.matchedMemberId
         }