Răsfoiți Sursa

feat: health report PDF upload + login redirect fix

- PdfParseService: 募极生物肠道菌群报告PDF解析器
- HealthReportController: POST /api/health/report/upload (multipart upload)
- HealthReportService: 事务写入报告/指标/菌群/疾病风险四表
- DatabaseInitializer: ALTER TABLE 迁移新增字段
- report-upload.vue: 新增PDF文件选择+上传组件
- health-report.vue: 新增SVG环形评分仪表盘可视化
- App.vue: onShow 同步 storage → Vuex token
- login.vue: 移除 setTimeout,登录后 Promise 链内直接跳转
User 4 luni în urmă
părinte
comite
c8395f079a

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

@@ -1478,6 +1478,21 @@ log.info("已添加template_id列到tasks表");
             // 列已存在,忽略错误
         }
 
+        // 迁移: health_reports 表添加报告编号和姓名字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN report_number VARCHAR(50) DEFAULT NULL COMMENT '报告编号'");
+            log.info("已添加report_number列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN person_name VARCHAR(50) DEFAULT NULL COMMENT '被检测人姓名'");
+            log.info("已添加person_name列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
         // ==================== 迁移: health_gut_flora 表添加新字段 ====================
         try {
             jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN population_level VARCHAR(50) DEFAULT NULL COMMENT '处于人群%水平'");

+ 50 - 12
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -203,12 +203,13 @@ public class HealthReportController {
 
     /**
      * 上传并解析肠道菌群报告PDF
+     * 支持根据PDF中提取的姓名自动匹配家庭成员
      */
     @Operation(summary = "上传并解析肠道菌群报告PDF")
     @PostMapping("/report/upload")
     public Result<Map<String, Object>> uploadReport(
             @RequestParam("file") MultipartFile file,
-            @RequestParam("childId") Long childId,
+            @RequestParam(value = "childId", required = false) Long childId,
             @RequestParam(value = "familyId", required = false) Long familyId) {
 
         if (file.isEmpty()) {
@@ -230,15 +231,49 @@ public class HealthReportController {
                 return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
             }
 
-            // 2. 保存文件到本地存储
-            String fileUrl = saveUploadFile(file, childId);
+            // 2. 如果未提供childId,尝试从PDF提取的姓名匹配家庭成员
+            Long matchedChildId = childId;
+            String matchedMemberName = null;
+            if (matchedChildId == null && familyId != null && parsed.getPersonName() != null) {
+                matchedChildId = healthReportService.matchChildIdByName(parsed.getPersonName(), familyId);
+                if (matchedChildId != null) {
+                    matchedMemberName = parsed.getPersonName();
+                    log.info("姓名[{}]自动匹配到childId={}", parsed.getPersonName(), matchedChildId);
+                }
+            }
+
+            // 如果仍无法确定childId,返回匹配提示让前端让用户选择
+            if (matchedChildId == null) {
+                Map<String, Object> matchHint = new java.util.HashMap<>();
+                matchHint.put("needManualSelect", true);
+                matchHint.put("extractedName", parsed.getPersonName());
+                matchHint.put("extractedReportNumber", parsed.getReportNumber());
+                matchHint.put("extractedReportDate", parsed.getReportDate());
+                matchHint.put("message", "无法自动匹配到家庭成员,请手动选择");
+                return Result.success(matchHint);
+            }
 
-            // 3. 构建健康报告实体
+            // 3. 保存文件到本地存储
+            String fileUrl = saveUploadFile(file, matchedChildId);
+
+            // 4. 构建健康报告实体
             HealthReport report = new HealthReport();
-            report.setUserId(childId);
+            report.setUserId(matchedChildId);
             report.setFamilyId(familyId);
             report.setReportType("gut_flora");
-            report.setReportDate(new Date());
+            // 报告日期:优先使用PDF提取的日期
+            if (parsed.getReportDate() != null && !parsed.getReportDate().isEmpty()) {
+                try {
+                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                    report.setReportDate(sdf.parse(parsed.getReportDate()));
+                } catch (Exception e) {
+                    report.setReportDate(new Date());
+                }
+            } else {
+                report.setReportDate(new Date());
+            }
+            report.setReportNumber(parsed.getReportNumber());
+            report.setPersonName(parsed.getPersonName());
             report.setOverallScore(parsed.getOverallScore());
             report.setGutHealthScore(parsed.getGutHealthScore());
             report.setChronicDiseaseScore(parsed.getChronicDiseaseScore());
@@ -253,7 +288,7 @@ public class HealthReportController {
             report.setFileUrl(fileUrl);
             report.setRemark("募极生物肠道菌群检测报告");
 
-            // 4. 转换指标列表
+            // 5. 转换指标列表
             List<HealthIndicator> indicators = new ArrayList<>();
             if (parsed.getIndicators() != null) {
                 for (int i = 0; i < parsed.getIndicators().size(); i++) {
@@ -269,7 +304,7 @@ public class HealthReportController {
                 }
             }
 
-            // 5. 转换菌群数据(核心菌属 + 有害菌属)
+            // 6. 转换菌群数据(核心菌属 + 有害菌属)
             List<HealthGutFlora> gutFloraList = new ArrayList<>();
             if (parsed.getGutFlora() != null) {
                 for (ParsedGutFlora pgf : parsed.getGutFlora()) {
@@ -286,7 +321,7 @@ public class HealthReportController {
                 }
             }
 
-            // 6. 转换益生菌(level=SPECIES)
+            // 7. 转换益生菌(level=SPECIES)
             if (parsed.getProbioticSpecies() != null) {
                 for (ParsedGutFlora pgf : parsed.getProbioticSpecies()) {
                     HealthGutFlora gf = new HealthGutFlora();
@@ -300,7 +335,7 @@ public class HealthReportController {
                 }
             }
 
-            // 7. 转换疾病风险评估
+            // 8. 转换疾病风险评估
             List<HealthDiseaseRisk> diseaseRiskList = new ArrayList<>();
             if (parsed.getDiseaseRisks() != null) {
                 for (ParsedDiseaseRisk pdr : parsed.getDiseaseRisks()) {
@@ -312,11 +347,14 @@ public class HealthReportController {
                 }
             }
 
-            // 8. 创建完整报告
+            // 9. 创建完整报告
             HealthReport created = healthReportService.createReport(report, indicators, gutFloraList, diseaseRiskList);
 
-            // 9. 返回详情
+            // 10. 返回详情(包含匹配信息)
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
+            detail.put("matchedMemberName", matchedMemberName);
+            detail.put("extractedReportNumber", parsed.getReportNumber());
+            detail.put("extractedReportDate", parsed.getReportDate());
             return Result.success(detail);
 
         } catch (IOException e) {

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java

@@ -42,6 +42,15 @@ public class ParsedReportResult {
     /** 肠型 */
     private String gutType;
 
+    /** 报告编号 */
+    private String reportNumber;
+
+    /** 报告日期(采样日期) */
+    private String reportDate;
+
+    /** 被检测人姓名 */
+    private String personName;
+
     // ==================== 列表数据 ====================
 
     /** 疾病风险评估 */
@@ -146,6 +155,30 @@ public class ParsedReportResult {
         this.gutType = gutType;
     }
 
+    public String getReportNumber() {
+        return reportNumber;
+    }
+
+    public void setReportNumber(String reportNumber) {
+        this.reportNumber = reportNumber;
+    }
+
+    public String getReportDate() {
+        return reportDate;
+    }
+
+    public void setReportDate(String reportDate) {
+        this.reportDate = reportDate;
+    }
+
+    public String getPersonName() {
+        return personName;
+    }
+
+    public void setPersonName(String personName) {
+        this.personName = personName;
+    }
+
     public List<ParsedDiseaseRisk> getDiseaseRisks() {
         return diseaseRisks;
     }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -28,6 +28,12 @@ public class HealthReport implements Serializable {
     /** 报告类型: physical_exam(体检报告), gut_flora(菌群检测报告) */
     private String reportType;
 
+    /** 报告编号 */
+    private String reportNumber;
+
+    /** 被检测人姓名(从PDF提取) */
+    private String personName;
+
     /** 报告日期 */
     private Date reportDate;
 

+ 157 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1,19 +1,24 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Child;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ChildMapper;
 import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
 import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthIndicatorMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.Collections;
 import java.util.HashMap;
@@ -40,6 +45,12 @@ public class HealthReportService {
     @Resource
     private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
 
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
     /**
      * 创建健康报告(含指标列表)
      */
@@ -282,4 +293,150 @@ public class HealthReportService {
         if (score == null) return 0;
         return Math.min(Math.max(score, 0), 100);
     }
+
+    /**
+     * 根据姓名模糊匹配家庭成员(优先匹配 Child,其次匹配 User)
+     *
+     * @param name     从PDF提取的姓名
+     * @param familyId 家庭ID
+     * @return 匹配到的成员ID(Child.id 或 User.id),未匹配返回 null
+     */
+    public Long matchFamilyMemberByName(String name, Long familyId) {
+        if (name == null || name.trim().isEmpty() || familyId == null) {
+            return null;
+        }
+
+        String cleanName = name.trim().replaceAll("\\s+", "");
+        if (cleanName.isEmpty()) {
+            return null;
+        }
+
+        // 第1轮:匹配 Child 表(孩子昵称)
+        Long childId = matchChildByName(cleanName, familyId);
+        if (childId != null) {
+            return childId;
+        }
+
+        // 第2轮:匹配 User 表(父母的 realName / nickname)
+        Long userId = matchUserByName(cleanName, familyId);
+        if (userId != null) {
+            return userId;
+        }
+
+        log.warn("无法匹配姓名[{}]到家庭{}的成员", name, familyId);
+        return null;
+    }
+
+    /**
+     * 在 Child 表中匹配姓名
+     */
+    private Long matchChildByName(String cleanName, Long familyId) {
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getFamilyId, familyId)
+        );
+        if (children == null || children.isEmpty()) {
+            return null;
+        }
+
+        // 精确匹配
+        for (Child child : children) {
+            if (child.getNickname() != null) {
+                String nn = child.getNickname().trim().replaceAll("\\s+", "");
+                if (nn.equals(cleanName)) {
+                    log.info("Child 姓名精确匹配: {} -> childId={}", cleanName, child.getId());
+                    return child.getId();
+                }
+            }
+        }
+
+        // 模糊包含匹配
+        for (Child child : children) {
+            if (child.getNickname() != null) {
+                String nn = child.getNickname();
+                if (cleanName.contains(nn) || nn.contains(cleanName)) {
+                    log.info("Child 姓名模糊匹配: {} -> childId={}", cleanName, child.getId());
+                    return child.getId();
+                }
+            }
+        }
+
+        // fuzzyMatch 匹配
+        for (Child child : children) {
+            if (child.getNickname() != null && fuzzyMatch(cleanName, child.getNickname())) {
+                log.info("Child 姓名fuzzy匹配: {} -> childId={}", cleanName, child.getId());
+                return child.getId();
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 在 User 表中匹配姓名(realName / nickname)
+     */
+    private Long matchUserByName(String cleanName, Long familyId) {
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+        );
+        if (users == null || users.isEmpty()) {
+            return null;
+        }
+
+        for (User user : users) {
+            // 优先匹配 realName,其次 nickname
+            String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
+            if (nameToMatch == null) continue;
+
+            String nm = nameToMatch.trim().replaceAll("\\s+", "");
+            if (nm.equals(cleanName)) {
+                log.info("User 姓名精确匹配: {} -> userId={}", cleanName, user.getId());
+                return user.getId();
+            }
+        }
+
+        for (User user : users) {
+            String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
+            if (nameToMatch == null) continue;
+
+            if (cleanName.contains(nameToMatch) || nameToMatch.contains(cleanName)) {
+                log.info("User 姓名模糊匹配: {} -> userId={}", cleanName, user.getId());
+                return user.getId();
+            }
+        }
+
+        for (User user : users) {
+            String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
+            if (nameToMatch == null) continue;
+
+            if (fuzzyMatch(cleanName, nameToMatch)) {
+                log.info("User 姓名fuzzy匹配: {} -> userId={}", cleanName, user.getId());
+                return user.getId();
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 模糊匹配:检查 str1 是否大致包含 str2(用于处理生僻字等情况)
+     */
+    private boolean fuzzyMatch(String str1, String str2) {
+        if (str1 == null || str2 == null) return false;
+        if (str1.contains(str2) || str2.contains(str1)) return true;
+
+        // 检查 str1 的前2个字符是否在 str2 中(处理"王小明"匹配"小明"的情况)
+        if (str1.length() >= 2) {
+            String prefix = str1.substring(0, 2);
+            if (str2.contains(prefix)) return true;
+        }
+        // 检查 str2 的前2个字符是否在 str1 中
+        if (str2.length() >= 2) {
+            String prefix = str2.substring(0, 2);
+            if (str1.contains(prefix)) return true;
+        }
+
+        return false;
+    }
 }

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java

@@ -136,6 +136,15 @@ public class PdfParseService {
         Integer coreGenus = findScoreAfter(lines, "核心菌属");
         if (coreGenus != null) result.setCoreGenusScore(coreGenus);
 
+        // 报告编号
+        result.setReportNumber(findReportNumber(lines));
+
+        // 报告日期(采样日期)
+        result.setReportDate(findReportDate(lines));
+
+        // 被检测人姓名
+        result.setPersonName(findPersonName(lines));
+
         // 肠道预测年龄
         result.setGutAge(findTextAfter(lines, "肠道预测年龄:"));
 
@@ -177,6 +186,70 @@ public class PdfParseService {
         return null;
     }
 
+    /**
+     * 从lines中查找报告编号
+     * 支持格式: "报告编号: XXXXXX" 或 "编号: XXXXXX"
+     */
+    private String findReportNumber(List<String> lines) {
+        // 方法1: 查找"报告编号:"
+        String value = findTextAfter(lines, "报告编号:");
+        if (value != null && !value.isEmpty()) return value.trim();
+
+        // 方法2: 查找"编号:"(排除其他包含编号的行)
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i).trim();
+            if (line.equals("编号:") && i + 1 < lines.size()) {
+                String next = lines.get(i + 1).trim();
+                if (!next.isEmpty() && next.length() <= 20) {
+                    return next;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 从lines中查找报告日期(采样日期/报告日期/检测日期)
+     * 尝试解析多种日期格式,返回 "yyyy-MM-dd" 格式
+     */
+    private String findReportDate(List<String> lines) {
+        String[] keywords = {"采样日期:", "报告日期:", "检测日期:"};
+        for (String keyword : keywords) {
+            String value = findTextAfter(lines, keyword);
+            if (value != null && !value.isEmpty()) {
+                String cleaned = value.trim();
+                // 尝试解析并标准化日期格式
+                // 支持: 2024年01月15日, 2024-01-15, 2024/01/15, 20240115
+                cleaned = cleaned.replaceAll("[年月日]", "-").replaceAll("[/]", "-");
+                // 去掉多余的分隔符
+                cleaned = cleaned.replaceAll("-+", "-").replaceAll("-$", "");
+                if (cleaned.matches("\\d{4}-\\d{2}-\\d{2}")) {
+                    return cleaned;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 从lines中查找被检测人姓名
+     * 支持: 姓名:, 宝宝姓名:, 受检人姓名:
+     */
+    private String findPersonName(List<String> lines) {
+        String[] keywords = {"姓名:", "宝宝姓名:", "受检人姓名:"};
+        for (String keyword : keywords) {
+            String value = findTextAfter(lines, keyword);
+            if (value != null && !value.isEmpty()) {
+                String name = value.trim();
+                // 过滤掉明显不是姓名的内容(太长或包含特殊字符)
+                if (name.length() <= 20 && !name.matches(".*[0-9]{5,}.*")) {
+                    return name;
+                }
+            }
+        }
+        return null;
+    }
+
     // ======================== 疾病风险评估 ========================
 
     private void parseDiseaseRisks(List<String> lines, List<ParsedDiseaseRisk> result) {

+ 5 - 1
cfc-frontend/App.vue

@@ -17,7 +17,11 @@ export default {
   },
   onShow: function() {
     console.log('App Show')
-    // TabBar 始终可见,无需根据登录状态 hide/show
+    // 每次 app 展示时同步 storage → Vuex(防止 reLaunch/navigateTo 后 Vuex 未更新的问题)
+    var token = uni.getStorageSync('token')
+    if (token && token !== this.$store.state.token) {
+      this.$store.commit('setToken', token)
+    }
   },
   onHide: function() {
     console.log('App Hide')

+ 142 - 0
cfc-frontend/pages/body/health-report.vue

@@ -33,6 +33,31 @@
       <text class="report-date">报告日期:{{ formatDate(report.reportDate) }}</text>
     </view>
 
+    <!-- ===== 评分可视化面板 (仅菌群报告) ===== -->
+    <view class="score-dashboard" v-if="report && report.reportType === 'gut_flora'">
+      <view class="main-scores">
+        <view class="main-score-item" v-for="item in mainScores" :key="item.key">
+          <view class="score-ring-wrap">
+            <svg width="140" height="140" viewBox="0 0 120 120">
+              <circle cx="60" cy="60" r="50" fill="none" stroke="#EEEEEE" stroke-width="8"/>
+              <circle cx="60" cy="60" r="50" fill="none" v-bind:stroke="item.color" stroke-width="8" v-bind:stroke-dasharray="item.circumference" v-bind:stroke-dashoffset="item.offset" stroke-linecap="round" transform="rotate(-90 60 60)"/>
+              <text x="60" y="52" text-anchor="middle" font-size="32" font-weight="bold" fill="#333">{{ item.score }}</text>
+              <text x="60" y="72" text-anchor="middle" font-size="14" fill="#999">分</text>
+            </svg>
+          </view>
+          <text class="main-score-label">{{ item.label }}</text>
+        </view>
+      </view>
+      <view class="flora-score-row" v-if="hasFloraScores">
+        <view class="flora-score-item" v-for="item in floraScores" :key="item.key">
+          <view class="flora-score-circle" v-bind:style="{ background: item.color }">
+            <text class="flora-score-num">{{ item.score }}</text>
+          </view>
+          <text class="flora-score-label">{{ item.label }}</text>
+        </view>
+      </view>
+    </view>
+
     <!-- 指标列表 -->
     <view class="section">
       <view class="section-title">
@@ -113,6 +138,7 @@ export default {
       childId: null,
       reportId: null,
       report: null,
+      reportDetail: null,
       indicators: [],
       loading: false
     }
@@ -159,6 +185,55 @@ export default {
         groupMap[cat].items.push(ind)
       }
       return result
+    },
+    circumference: function() {
+      return Math.round(2 * Math.PI * 50)
+    },
+    mainScores: function() {
+      if (!this.report) return []
+      var items = []
+      var r = this.report
+      var pairs = [
+        {key:'overall', label:'综合评分', score: r.overallScore, color:'#5B9BD5'},
+        {key:'gut', label:'肠道健康', score: r.gutHealthScore, color:'#4CAF50'},
+        {key:'chronic', label:'慢病控制', score: r.chronicDiseaseScore, color:'#FF9800'},
+        {key:'nutrition', label:'营养均衡', score: r.nutritionScore, color:'#2196F3'}
+      ]
+      var circ = Math.round(2 * Math.PI * 50)
+      for (var i = 0; i < pairs.length; i++) {
+        var p = pairs[i]
+        var s = p.score || 0
+        items.push({key: p.key, label: p.label, score: s, color: p.color, circumference: circ, offset: Math.round(circ - (s / 100) * circ)})
+      }
+      return items
+    },
+    hasFloraScores: function() {
+      if (!this.report) return false
+      var r = this.report
+      return (r.balanceScore != null) || (r.diversityScore != null) || (r.beneficialScore != null) || (r.harmfulScore != null) || (r.coreGenusScore != null)
+    },
+    floraScores: function() {
+      if (!this.report) return []
+      var r = this.report
+      var items = []
+      var pairs = [
+        {key:'balance', label:'菌群平衡', score: r.balanceScore},
+        {key:'diversity', label:'菌群多样性', score: r.diversityScore},
+        {key:'beneficial', label:'有益菌', score: r.beneficialScore},
+        {key:'harmful', label:'有害菌', score: r.harmfulScore},
+        {key:'coreGenus', label:'核心菌属', score: r.coreGenusScore}
+      ]
+      for (var i = 0; i < pairs.length; i++) {
+        var p = pairs[i]
+        if (p.score != null) {
+          var s = p.score
+          var color = '#4CAF50'
+          if (s < 30) { color = '#F44336' }
+          else if (s < 60) { color = '#FFC107' }
+          items.push({key: p.key, label: p.label, score: s, color: color})
+        }
+      }
+      return items
     }
   },
   onLoad: function(options) {
@@ -196,6 +271,13 @@ export default {
             }).catch(function(e) {
               console.log('获取指标失败', e)
             })
+            healthRequest('/api/health/report/detail', { reportId: self.reportId }).then(function(res) {
+              if (res.data) {
+                self.reportDetail = res.data
+              }
+            }).catch(function(e) {
+              console.log('获取报告详情失败', e)
+            })
           }
         }
       }).catch(function(e) {
@@ -475,4 +557,64 @@ export default {
   font-size: 30rpx;
   font-weight: 600;
 }
+
+.score-dashboard {
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.main-scores {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: space-around;
+}
+.main-score-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 20rpx;
+  width: 45%;
+}
+.score-ring-wrap {
+  margin-bottom: 8rpx;
+}
+.main-score-label {
+  font-size: 24rpx;
+  color: #666;
+  font-weight: 500;
+}
+.flora-score-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+  margin-top: 16rpx;
+  padding-top: 20rpx;
+  border-top: 2rpx solid #F0F0F0;
+}
+.flora-score-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.flora-score-circle {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 40rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8rpx;
+}
+.flora-score-num {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #fff;
+}
+.flora-score-label {
+  font-size: 20rpx;
+  color: #888;
+}
 </style>

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

@@ -13,6 +13,49 @@
       <text class="tip-text">上传体检报告或肠道检测报告,AI 将为您解读并提供个性化营养方案</text>
     </view>
 
+    <!-- PDF解析预览(提取到信息时显示) -->
+    <view class="parsed-preview" v-if="parsedInfo">
+      <view class="parsed-title">PDF解析结果</view>
+      <view class="parsed-row" v-if="parsedInfo.personName">
+        <text class="parsed-label">被检测人</text>
+        <text class="parsed-value">{{ parsedInfo.personName }}</text>
+      </view>
+      <view class="parsed-row" v-if="parsedInfo.reportNumber">
+        <text class="parsed-label">报告编号</text>
+        <text class="parsed-value">{{ parsedInfo.reportNumber }}</text>
+      </view>
+      <view class="parsed-row" v-if="parsedInfo.reportDate">
+        <text class="parsed-label">报告日期</text>
+        <text class="parsed-value">{{ parsedInfo.reportDate }}</text>
+      </view>
+      <!-- 匹配状态 -->
+      <view class="match-status" v-if="parsedInfo.needManualSelect">
+        <text class="match-icon">⚠️</text>
+        <text class="match-text">无法自动匹配到家庭成员,请手动选择</text>
+      </view>
+      <view class="match-status success" v-else-if="parsedInfo.matchedMemberName">
+        <text class="match-icon">✅</text>
+        <text class="match-text">已自动匹配: {{ parsedInfo.matchedMemberName }}</text>
+      </view>
+    </view>
+
+    <!-- 家庭成员选择器(需要手动选择时显示) -->
+    <view class="form-item member-selector" v-if="showMemberSelector">
+      <text class="form-label">选择成员</text>
+      <view class="member-list">
+        <view
+          class="member-item"
+          v-for="member in familyMembers"
+          :key="member.id"
+          :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="form-section">
       <!-- 报告类型 -->
@@ -38,6 +81,28 @@
         </view>
       </view>
 
+      <!-- 文件上传 -->
+      <view class="form-item">
+        <text class="form-label">上传PDF文件(选填)</text>
+        <view class="file-upload-area" @click="pickFile">
+          <view class="file-upload-placeholder" v-if="!selectedFile">
+            <text class="file-upload-icon">📄</text>
+            <text class="file-upload-text">点击选择PDF文件</text>
+            <text class="file-upload-hint">支持从聊天记录中选择</text>
+          </view>
+          <view class="file-uploading" v-else>
+            <text class="file-upload-icon">⏳</text>
+            <text class="file-upload-text">正在解析...</text>
+          </view>
+        </view>
+        <!-- 已选择的文件 -->
+        <view class="file-info" v-if="selectedFile && !parsingPreview">
+          <text class="file-name">{{ selectedFile.name }}</text>
+          <text class="file-size">{{ formatFileSize(selectedFile.size) }}</text>
+          <text class="file-remove" @click.stop="removeFile">移除</text>
+        </view>
+      </view>
+
       <!-- 报告日期 -->
       <view class="form-item">
         <text class="form-label">报告日期</text>
@@ -93,11 +158,13 @@
 
 <script>
 import { createHealthReport } from '../../utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
     return {
       childId: null,
+      familyId: null,
       form: {
         reportType: 'physical_exam',
         reportDate: '',
@@ -105,11 +172,21 @@ export default {
         description: ''
       },
       showDatePicker: false,
-      submitting: false
+      submitting: false,
+      selectedFile: null,
+      parsingPreview: false,
+      parsedInfo: null,
+      showMemberSelector: false,
+      selectedMemberId: null,
+      familyMembers: []
     }
   },
   computed: {
     canSubmit() {
+      // 有文件时需要先解析并匹配成功(或已选成员)
+      if (this.selectedFile) {
+        return this.parsedInfo && !this.parsedInfo.needManualSelect
+      }
       return this.form.reportType && this.form.reportDate
     }
   },
@@ -122,19 +199,235 @@ export default {
     var d = ('' + now.getDate()).padStart(2, '0')
     this.form.reportDate = y + '-' + m + '-' + d
   },
+  onShow: function() {
+    // 每次显示时从store获取familyId和家庭成员
+    this.loadFamilyInfo()
+  },
   methods: {
+    loadFamilyInfo() {
+      var self = this
+      // 从Vuex获取familyId和家庭成员
+      var userInfo = uni.getStorageSync('userInfo')
+      if (userInfo) {
+        try {
+          var info = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
+          this.familyId = info.familyId || null
+          // 加载家庭成员列表(从本地存储或请求)
+          var storedMembers = uni.getStorageSync('familyMembers')
+          if (storedMembers) {
+            try {
+              this.familyMembers = typeof storedMembers === 'string' ? JSON.parse(storedMembers) : storedMembers
+            } catch (e) {
+              this.familyMembers = []
+            }
+          }
+        } catch (e) {
+          console.log('解析userInfo失败', e)
+        }
+      }
+    },
     onScoreChanging(e) {
       this.form.overallScore = e.detail.value
     },
     onScoreChange(e) {
       this.form.overallScore = e.detail.value
     },
+    pickFile() {
+      var self = this
+      // 重置解析状态
+      this.parsedInfo = null
+      this.showMemberSelector = false
+      this.selectedMemberId = null
+
+      // 从微信聊天记录中选择文件
+      uni.chooseMessageFile({
+        count: 1,
+        type: 'file',
+        extension: ['pdf'],
+        success: function(res) {
+          var files = res.tempFiles
+          if (files && files.length > 0) {
+            self.selectedFile = files[0]
+            // 如果选择了文件且有familyId,先预览解析
+            if (self.familyId) {
+              self.previewParse()
+            }
+          }
+        },
+        fail: function(e) {
+          console.log('选择文件取消或失败', e)
+        }
+      })
+    },
+    previewParse() {
+      var self = this
+      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
+        }
+      })
+    },
+    loadFamilyMembers() {
+      var self = this
+      // 从Vuex store获取家庭成员
+      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
+      this.parsedInfo = this.parsedInfo || {}
+      this.parsedInfo.needManualSelect = false
+      this.showMemberSelector = false
+    },
+    removeFile() {
+      this.selectedFile = null
+      this.parsedInfo = null
+      this.showMemberSelector = false
+      this.selectedMemberId = null
+    },
+    formatFileSize(bytes) {
+      if (!bytes) return ''
+      if (bytes < 1024) return bytes + 'B'
+      if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
+      return (bytes / 1024 / 1024).toFixed(1) + 'MB'
+    },
     goBack() {
       uni.navigateBack()
     },
     doSubmit() {
-      if (!this.canSubmit || this.submitting) return
+      if (this.submitting) return
       var self = this
+
+      // 有文件则走上传流程
+      if (self.selectedFile) {
+        // 如果需要手动选择但未选,不允许提交
+        if (self.parsedInfo && self.parsedInfo.needManualSelect && !self.selectedMemberId) {
+          uni.showToast({ title: '请先选择成员', icon: 'none' })
+          return
+        }
+
+        self.submitting = true
+        uni.showLoading({ title: '上传中...' })
+
+        // 确定使用的childId
+        var submitChildId = self.selectedMemberId || self.childId || ''
+
+        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
+          }
+        })
+        return
+      }
+
+      // 无文件,走手动录入
       self.submitting = true
       uni.showLoading({ title: '提交中...' })
       createHealthReport({
@@ -280,6 +573,67 @@ export default {
   font-weight: 500;
 }
 
+/* 文件上传 */
+.file-upload-area {
+  border: 2rpx dashed #CBD5E1;
+  border-radius: 16rpx;
+  padding: 32rpx 20rpx;
+  text-align: center;
+  background: #FAFBFC;
+  cursor: pointer;
+}
+.file-upload-area:active {
+  background: #F1F5F9;
+}
+.file-upload-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.file-upload-icon {
+  font-size: 48rpx;
+  margin-bottom: 12rpx;
+}
+.file-upload-text {
+  font-size: 28rpx;
+  color: #5B9BD5;
+  font-weight: 500;
+}
+.file-upload-hint {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 8rpx;
+}
+.file-info {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background: #E3F2FD;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-top: 16rpx;
+}
+.file-name {
+  flex: 1;
+  font-size: 24rpx;
+  color: #1565C0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.file-size {
+  font-size: 22rpx;
+  color: #64748B;
+  margin: 0 16rpx;
+  flex-shrink: 0;
+}
+.file-remove {
+  font-size: 24rpx;
+  color: #EF4444;
+  flex-shrink: 0;
+  padding: 4rpx 8rpx;
+}
+
 /* 日期 */
 .date-display {
   display: flex;
@@ -345,6 +699,103 @@ export default {
   opacity: 0.5;
 }
 
+/* ===== PDF解析预览 ===== */
+.parsed-preview {
+  background: #E8F5E9;
+  border-radius: 16rpx;
+  margin: 0 30rpx 20rpx;
+  padding: 20rpx 24rpx;
+  border: 2rpx solid #A5D6A7;
+}
+.parsed-title {
+  font-size: 24rpx;
+  font-weight: bold;
+  color: #2E7D32;
+  margin-bottom: 12rpx;
+}
+.parsed-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.parsed-label {
+  font-size: 24rpx;
+  color: #388E3C;
+  width: 140rpx;
+  flex-shrink: 0;
+}
+.parsed-value {
+  font-size: 24rpx;
+  color: #1B5E20;
+  font-weight: 500;
+}
+.match-status {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 12rpx;
+  padding: 12rpx 16rpx;
+  background: #FFF3E0;
+  border-radius: 8rpx;
+}
+.match-status.success {
+  background: #E8F5E9;
+}
+.match-icon {
+  font-size: 24rpx;
+  margin-right: 8rpx;
+}
+.match-text {
+  font-size: 24rpx;
+  color: #E65100;
+}
+.match-status.success .match-text {
+  color: #2E7D32;
+}
+
+/* ===== 家庭成员选择器 ===== */
+.member-selector {
+  margin: 0 30rpx 20rpx;
+}
+.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;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8rpx;
+}
+.member-name {
+  font-size: 24rpx;
+  color: #333;
+}
+
 /* ===== 底部 ===== */
 .bottom-spacer {
   height: 60rpx;

+ 33 - 10
cfc-frontend/pages/login/login.vue

@@ -135,18 +135,41 @@ export default {
               uni.setStorageSync('teacherRejectReason', res.data.teacherRejectReason)
             }
 
+            // 保存 token 和用户信息(同步写入,立即持久化)
+            uni.setStorageSync('token', res.data.token)
+            uni.setStorageSync('userId', res.data.userId)
+            uni.setStorageSync('role', res.data.role)
+            uni.setStorageSync('familyId', res.data.familyId)
+            uni.setStorageSync('currentRole', res.data.role)
+            uni.setStorageSync('isSwitchedChild', false)
+            uni.setStorageSync('isSwitchedTeacher', false)
+            // 保存 openid,用于后续自动登录
+            if (res.data.openid) {
+              uni.setStorageSync('openid', res.data.openid)
+            }
+            uni.setStorageSync('userInfo', {
+              nickname: res.data.nickname,
+              userId: res.data.userId
+            })
+
+            // 存储成长规划师审核状态
+            if (res.data.teacherStatus) {
+              uni.setStorageSync('teacherStatus', res.data.teacherStatus)
+            }
+            if (res.data.teacherRejectReason) {
+              uni.setStorageSync('teacherRejectReason', res.data.teacherRejectReason)
+            }
+
             uni.showToast({ title: '登录成功', icon: 'success' })
 
-            // 根据角色跳转到对应首页,或进入信息编辑页面
-            setTimeout(function() {
-              if (res.data.needsRoleSelect) {
-                uni.redirectTo({
-                  url: '/pages/user-edit/user-edit?token=' + res.data.token + '&userId=' + res.data.userId + '&needsRoleSelect=true'
-                })
-              } else {
-                self.handlePostLoginRouting()
-              }
-            }, 1000)
+            // 立即跳转到对应页面(不等 setTimeout,直接在 Promise 链内)
+            if (res.data.needsRoleSelect) {
+              uni.redirectTo({
+                url: '/pages/user-edit/user-edit?token=' + res.data.token + '&userId=' + res.data.userId + '&needsRoleSelect=true'
+              })
+            } else {
+              self.handlePostLoginRouting()
+            }
           }).catch(function(e) {
             uni.showToast({ title: e.message || '登录失败', icon: 'none' })
             self.loading = false