Pārlūkot izejas kodu

上传报告分类

jiapu 4 dienas atpakaļ
vecāks
revīzija
e471817780

+ 88 - 27
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportParseService.java

@@ -93,7 +93,7 @@ public class DanReportParseService {
         StringBuilder suggestions = new StringBuilder();
 
         // 1. 基本信息和日期
-        String reportDate = findFieldValue(lines, "测评日期", "报告日期", "评估日期");
+        String reportDate = findFieldValue(lines, "测评日期", "报告日期", "评估日期", "测试日期");
         String name = findFieldValue(lines, "姓名", "学生姓名", "被评估人");
         String gender = findFieldValue(lines, "性别");
         String age = findFieldValue(lines, "年龄");
@@ -103,24 +103,42 @@ public class DanReportParseService {
         extra.put("age", age);
         extra.put("reportDate", reportDate);
 
+        Map<String, String> scorePct = extractTotalScoreAndPercentile(fullText);
+        if (scorePct.get("总分") != null) {
+            items.add(new DataItem("total_score", "总分", scorePct.get("总分"), "overall"));
+            extra.put("totalScore", scorePct.get("总分"));
+        }
+        if (scorePct.get("百分位") != null) {
+            items.add(new DataItem("percentile", "百分位", scorePct.get("百分位"), "overall"));
+            extra.put("percentile", scorePct.get("百分位"));
+        }
+
+        String[] cogDims = {"感知觉", "记忆力", "注意力"};
+        String[] cogCodes = {"perception", "memory", "attention"};
+        for (int i = 0; i < cogDims.length; i++) {
+            Pattern cogP = Pattern.compile(Pattern.quote(cogDims[i]) + "\\s*(\\d+)%");
+            Matcher cogM = cogP.matcher(fullText);
+            if (cogM.find()) {
+                items.add(new DataItem(cogCodes[i] + "_pct", cogDims[i] + "百分位", cogM.group(1), "cognitive"));
+                extra.put(cogCodes[i] + "_pct", cogM.group(1));
+            }
+        }
+
         // 2. 大五人格 (Big Five)
-        // 查找大五人格区域
-        int bigFiveStart = findSectionStart(lines, "大五人格", "人格特质", "人格分析");
+        Map<String, String> bigFive = new LinkedHashMap<>();
+        int bigFiveStart = findSectionStart(lines, "大五人格", "人格特质", "人格分析", "人格测评");
         if (bigFiveStart >= 0) {
-            Map<String, String> bigFive = parseBigFive(lines, bigFiveStart);
+            bigFive.putAll(parseBigFive(lines, bigFiveStart));
+        }
+        if (bigFive.isEmpty()) {
+            bigFive.putAll(searchBigFiveGlobally(lines));
+        }
+        extractQuotedBigFiveScores(fullText, bigFive);
+        if (!bigFive.isEmpty()) {
             for (Map.Entry<String, String> entry : bigFive.entrySet()) {
                 items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "bigFive"));
             }
             extra.put("bigFive", bigFive);
-        } else {
-            // 全局搜索大五维度
-            Map<String, String> bigFive = searchBigFiveGlobally(lines);
-            if (!bigFive.isEmpty()) {
-                for (Map.Entry<String, String> entry : bigFive.entrySet()) {
-                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "bigFive"));
-                }
-                extra.put("bigFive", bigFive);
-            }
         }
 
         // 3. 社会关系(与父母/同伴)
@@ -1168,8 +1186,7 @@ public class DanReportParseService {
      */
     private Map<String, String> parseBigFive(List<String> lines, int startIdx) {
         Map<String, String> result = new LinkedHashMap<>();
-        String[] labels = {"开放性", "尽责性", "外倾性", "宜人性", "神经质"};
-        Set<String> labelSet = new HashSet<>(Arrays.asList(labels));
+        String[] labels = {"开放性", "尽责性", "责任心", "外倾性", "宜人性", "神经质"};
 
         for (int i = startIdx; i < Math.min(startIdx + 30, lines.size()); i++) {
             String line = lines.get(i);
@@ -1177,7 +1194,7 @@ public class DanReportParseService {
                 if (line.contains(label)) {
                     String score = extractScore(line);
                     if (score != null) {
-                        result.put(label, score);
+                        result.put(normalizeBigFiveName(label), score);
                     }
                     break;
                 }
@@ -1191,28 +1208,46 @@ public class DanReportParseService {
      */
     private Map<String, String> searchBigFiveGlobally(List<String> lines) {
         Map<String, String> result = new LinkedHashMap<>();
-        String[] labels = {"开放性", "尽责性", "外倾性", "宜人性", "神经质",
+        String[] labels = {"开放性", "尽责性", "责任心", "外倾性", "宜人性", "神经质",
                 "开放", "尽责", "外倾", "宜人", "神经"};
         for (String line : lines) {
             for (String label : labels) {
                 if (line.contains(label)) {
                     String score = extractScore(line);
                     if (score != null) {
-                        // 使用更精确的维度名
-                        String displayName = label;
-                        if (label.equals("开放")) displayName = "开放性";
-                        else if (label.equals("尽责")) displayName = "尽责性";
-                        else if (label.equals("外倾")) displayName = "外倾性";
-                        else if (label.equals("宜人")) displayName = "宜人性";
-                        else if (label.equals("神经")) displayName = "神经质";
-                        result.put(displayName, score);
+                        result.put(normalizeBigFiveName(label), score);
                     }
                 }
             }
         }
+        extractQuotedBigFiveScores(String.join("\n", lines), result);
         return result;
     }
 
+    private String normalizeBigFiveName(String label) {
+        if ("开放".equals(label) || "开放性".equals(label)) return "开放性";
+        if ("尽责".equals(label) || "尽责性".equals(label) || "责任心".equals(label)) return "尽责性";
+        if ("外倾".equals(label) || "外倾性".equals(label)) return "外倾性";
+        if ("宜人".equals(label) || "宜人性".equals(label)) return "宜人性";
+        if ("神经".equals(label) || "神经质".equals(label)) return "神经质";
+        return label;
+    }
+
+    /** 标准 A2 句式:您在“开放性”上的得分是 3.9 */
+    private void extractQuotedBigFiveScores(String fullText, Map<String, String> result) {
+        if (fullText == null || fullText.isEmpty()) return;
+        String[] dims = {"开放性", "尽责性", "责任心", "外倾性", "宜人性", "神经质"};
+        for (String dim : dims) {
+            Pattern p = Pattern.compile(
+                    "您在[\"\"“”「」']?" + Pattern.quote(dim) + "[\"\"“”「」']?.*?得分是\\s*(\\d+(?:\\.\\d+)?)\\s*分?",
+                    Pattern.DOTALL);
+            Matcher m = p.matcher(fullText);
+            if (m.find()) {
+                result.put(normalizeBigFiveName(dim), m.group(1));
+            }
+        }
+    }
+
     /**
      * 解析社会关系:与父母信任、沟通、亲近等
      */
@@ -1409,18 +1444,44 @@ public class DanReportParseService {
                     int colonIdx = Math.max(line.indexOf(':'), line.indexOf(':'));
                     if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
                         String val = line.substring(colonIdx + 1).trim();
-                        if (!val.isEmpty()) return val;
+                        if (!val.isEmpty()) return cleanFieldValue(val);
                     }
                 } else if (line.trim().equals(keyword) || line.trim().equals(keyword + ":") || line.trim().equals(keyword + ":")) {
                     // 下一行模式
                     String val = lines.get(i + 1).trim();
-                    if (!val.isEmpty() && val.length() < 50) return val;
+                    if (!val.isEmpty() && val.length() < 50) return cleanFieldValue(val);
+                }
+            }
+        }
+        // 兼容 PDF 抽字把「姓   名:俊辰」拆开空格的情况
+        for (String keyword : keywords) {
+            String compactKw = keyword.replaceAll("\\s+", "");
+            for (String line : lines) {
+                String compact = line.replaceAll("\\s+", "");
+                int idx = compact.indexOf(compactKw + ":");
+                if (idx < 0) idx = compact.indexOf(compactKw + ":");
+                if (idx >= 0) {
+                    String rest = compact.substring(idx + compactKw.length() + 1);
+                    String val = cleanFieldValue(rest);
+                    if (val != null && !val.isEmpty()) return val;
                 }
             }
         }
         return null;
     }
 
+    private String cleanFieldValue(String raw) {
+        if (raw == null) return null;
+        String s = raw.trim();
+        Matcher date = Pattern.compile("^(\\d{4}[-/]\\d{1,2}[-/]\\d{1,2})").matcher(s);
+        if (date.find()) return date.group(1);
+        Matcher name = Pattern.compile("^([\\u4e00-\\u9fa5]{1,12})").matcher(s);
+        if (name.find()) return name.group(1);
+        Matcher word = Pattern.compile("^([A-Za-z0-9_\\-]{1,32})").matcher(s);
+        if (word.find()) return word.group(1);
+        return s.length() > 40 ? s.substring(0, 40) : s;
+    }
+
     /**
      * 提取文本段落
      */

+ 129 - 130
cfc-backend/src/main/resources/application.yml

@@ -178,145 +178,144 @@ app:
 admin:
   skip-captcha: true  # 测试环境跳过验证码,方便自动化测试
 
-
 ai:
   prompts:
     questionnaire-version: "1"
     questionnaire-parent: |
-  你是一位专业的家庭关系评估顾问,擅长设计温暖、不具威胁性的关系问卷。
-  
-  你的任务是为填写者设计一份关系评估问卷,了解填写者与被评估成员之间的关系质量。
-  
-  ## 基本信息
-  - 填写者(答卷人):{respondent_name},{respondent_gender},{respondent_age}岁,星座:{respondent_zodiac}
-  - 被评估成员:{member_name},{member_gender},{member_age}岁,星座:{member_zodiac}
-  - 你(填写者)与被评估成员的关系:{relationship_desc}
-  - 家庭基本情况:{family_context}
-  
-  ## 角色与任务
-  填写者是家长,被评估成员是其孩子。家长对子女的关系感知是评估的重点。
-  
-  请生成 **15-18 道**情境化题目,涵盖三个维度,每个维度约 5-6 题:
-  
-  ### 信任维度(trust)
-  从以下间接角度设计题目:
-  - 遇到困难时是否愿意向对方求助
-  - 是否会向对方分享不愿让他人知道的事
-  - 与对方相处时是否感到安全、不被评判
-  - 对方是否值得信赖(守承诺、不泄露隐私等)
-  - 犯错后是否担心对方的反应
-  
-  ### 亲密维度(intimacy)
-  从以下间接角度设计题目:
-  - 是否愿意与对方共处(无需说话也能舒适)
-  - 对方是否是你情绪低落时的"避风港"
-  - 是否愿意与对方分享个人感受
-  - 对方是否让你感到被理解和接纳
-  - 是否觉得对方是"最懂你的人"之一
-  
-  ### 沟通维度(communication)
-  从以下间接角度设计题目:
-  - 意见不合时是否能平静沟通
-  - 对方是否认真倾听,而非急于评判或给建议
-  - 是否能自然表达自己的真实想法
-  - 对方是否经常打断你或没听完就下结论
-  - 是否觉得你们之间有"说不出口"的话题
-  
-  ## 题目设计要求
-  1. **情境化**:用具体场景代替抽象问法(如:"当你遇到困难时..." 而非 "你是否信任对方")
-  2. **温暖语气**:避免让填写者感到被审判,用"你是否觉得...""你是否愿意..."
-  3. **选项差异化**:4-5 个选项,描述具体行为或感受
-  4. **反向计分题**:至少 3 道题设置 direction="negative",用于检测答题一致性
-  5. **覆盖双方画像**:如有星座、年龄差距等信息,可在题目中适度融入(如"青春期""代沟"等)
-  
-  ## 返回格式(严格 JSON,无其他内容)
-  {{
-    "version": 1,
-    "questions": [
+      你是一位专业的家庭关系评估顾问,擅长设计温暖、不具威胁性的关系问卷。
+
+      你的任务是为填写者设计一份关系评估问卷,了解填写者与被评估成员之间的关系质量。
+
+      ## 基本信息
+      - 填写者(答卷人):{respondent_name},{respondent_gender},{respondent_age}岁,星座:{respondent_zodiac}
+      - 被评估成员:{member_name},{member_gender},{member_age}岁,星座:{member_zodiac}
+      - 你(填写者)与被评估成员的关系:{relationship_desc}
+      - 家庭基本情况:{family_context}
+
+      ## 角色与任务
+      填写者是家长,被评估成员是其孩子。家长对子女的关系感知是评估的重点。
+
+      请生成 **15-18 道**情境化题目,涵盖三个维度,每个维度约 5-6 题:
+
+      ### 信任维度(trust)
+      从以下间接角度设计题目:
+      - 遇到困难时是否愿意向对方求助
+      - 是否会向对方分享不愿让他人知道的事
+      - 与对方相处时是否感到安全、不被评判
+      - 对方是否值得信赖(守承诺、不泄露隐私等)
+      - 犯错后是否担心对方的反应
+
+      ### 亲密维度(intimacy)
+      从以下间接角度设计题目:
+      - 是否愿意与对方共处(无需说话也能舒适)
+      - 对方是否是你情绪低落时的"避风港"
+      - 是否愿意与对方分享个人感受
+      - 对方是否让你感到被理解和接纳
+      - 是否觉得对方是"最懂你的人"之一
+
+      ### 沟通维度(communication)
+      从以下间接角度设计题目:
+      - 意见不合时是否能平静沟通
+      - 对方是否认真倾听,而非急于评判或给建议
+      - 是否能自然表达自己的真实想法
+      - 对方是否经常打断你或没听完就下结论
+      - 是否觉得你们之间有"说不出口"的话题
+
+      ## 题目设计要求
+      1. **情境化**:用具体场景代替抽象问法(如:"当你遇到困难时..." 而非 "你是否信任对方")
+      2. **温暖语气**:避免让填写者感到被审判,用"你是否觉得...""你是否愿意..."
+      3. **选项差异化**:4-5 个选项,描述具体行为或感受
+      4. **反向计分题**:至少 3 道题设置 direction="negative",用于检测答题一致性
+      5. **覆盖双方画像**:如有星座、年龄差距等信息,可在题目中适度融入(如"青春期""代沟"等)
+
+      ## 返回格式(严格 JSON,无其他内容)
       {{
-        "id": "q1",
-        "dimension": "trust",
-        "direction": "positive",
-        "weight": 1.0,
-        "text": "题目文字",
-        "options": [
-          {{"id": "a", "score": 0}},
-          {{"id": "b", "score": 1}},
-          {{"id": "c", "score": 2}},
-          {{"id": "d", "score": 3}}
+        "version": 1,
+        "questions": [
+          {{
+            "id": "q1",
+            "dimension": "trust",
+            "direction": "positive",
+            "weight": 1.0,
+            "text": "题目文字",
+            "options": [
+              {{"id": "a", "score": 0}},
+              {{"id": "b", "score": 1}},
+              {{"id": "c", "score": 2}},
+              {{"id": "d", "score": 3}}
+            ]
+          }}
         ]
       }}
-    ]
-  }}
-  
-  确保 JSON 格式正确,所有题目覆盖三个维度,共 15-18 题。
+
+      确保 JSON 格式正确,所有题目覆盖三个维度,共 15-18 题。
     questionnaire-child: |
-  你是一位专业的家庭关系评估顾问,擅长设计温暖、不具威胁性的关系问卷。
-  
-  你的任务是为填写者设计一份关系评估问卷,了解填写者与被评估成员之间的关系质量。
-  
-  ## 基本信息
-  - 填写者(答卷人):{respondent_name},{respondent_gender},{respondent_age}岁,星座:{respondent_zodiac}
-  - 被评估成员:{member_name},{member_gender},{member_age}岁,星座:{member_zodiac}
-  - 你(填写者)与被评估成员的关系:{relationship_desc}
-  - 家庭基本情况:{family_context}
-  
-  ## 角色与任务
-  填写者是孩子,被评估成员是其家长。孩子对家长的关系感知是评估的重点。
-  
-  请生成 **15-18 道**情境化题目,涵盖三个维度,每个维度约 5-6 题:
-  
-  ### 信任维度(trust)
-  从以下间接角度设计题目:
-  - 遇到困难时是否愿意向对方求助
-  - 是否会向对方分享不愿让他人知道的事
-  - 与对方相处时是否感到安全、不被评判
-  - 对方是否值得信赖(守承诺、不泄露隐私等)
-  - 犯错后是否担心对方的反应
-  
-  ### 亲密维度(intimacy)
-  从以下间接角度设计题目:
-  - 是否愿意与对方共处(无需说话也能舒适)
-  - 对方是否是你情绪低落时的"避风港"
-  - 是否愿意与对方分享个人感受
-  - 对方是否让你感到被理解和接纳
-  - 是否觉得对方是"最懂你的人"之一
-  
-  ### 沟通维度(communication)
-  从以下间接角度设计题目:
-  - 意见不合时是否能平静沟通
-  - 对方是否认真倾听,而非急于评判或给建议
-  - 是否能自然表达自己的真实想法
-  - 对方是否经常打断你或没听完就下结论
-  - 是否觉得你们之间有"说不出口"的话题
-  
-  ## 题目设计要求
-  1. **情境化**:用具体场景代替抽象问法(如:"当你遇到困难时..." 而非 "你是否信任对方")
-  2. **温暖语气**:避免让填写者感到被审判,用"你是否觉得...""你是否愿意..."
-  3. **选项差异化**:4-5 个选项,描述具体行为或感受
-  4. **反向计分题**:至少 3 道题设置 direction="negative",用于检测答题一致性
-  5. **覆盖双方画像**:如有星座、年龄差距等信息,可在题目中适度融入(如"青春期""代沟"等)
-  
-  ## 返回格式(严格 JSON,无其他内容)
-  {{
-    "version": 1,
-    "questions": [
+      你是一位专业的家庭关系评估顾问,擅长设计温暖、不具威胁性的关系问卷。
+
+      你的任务是为填写者设计一份关系评估问卷,了解填写者与被评估成员之间的关系质量。
+
+      ## 基本信息
+      - 填写者(答卷人):{respondent_name},{respondent_gender},{respondent_age}岁,星座:{respondent_zodiac}
+      - 被评估成员:{member_name},{member_gender},{member_age}岁,星座:{member_zodiac}
+      - 你(填写者)与被评估成员的关系:{relationship_desc}
+      - 家庭基本情况:{family_context}
+
+      ## 角色与任务
+      填写者是孩子,被评估成员是其家长。孩子对家长的关系感知是评估的重点。
+
+      请生成 **15-18 道**情境化题目,涵盖三个维度,每个维度约 5-6 题:
+
+      ### 信任维度(trust)
+      从以下间接角度设计题目:
+      - 遇到困难时是否愿意向对方求助
+      - 是否会向对方分享不愿让他人知道的事
+      - 与对方相处时是否感到安全、不被评判
+      - 对方是否值得信赖(守承诺、不泄露隐私等)
+      - 犯错后是否担心对方的反应
+
+      ### 亲密维度(intimacy)
+      从以下间接角度设计题目:
+      - 是否愿意与对方共处(无需说话也能舒适)
+      - 对方是否是你情绪低落时的"避风港"
+      - 是否愿意与对方分享个人感受
+      - 对方是否让你感到被理解和接纳
+      - 是否觉得对方是"最懂你的人"之一
+
+      ### 沟通维度(communication)
+      从以下间接角度设计题目:
+      - 意见不合时是否能平静沟通
+      - 对方是否认真倾听,而非急于评判或给建议
+      - 是否能自然表达自己的真实想法
+      - 对方是否经常打断你或没听完就下结论
+      - 是否觉得你们之间有"说不出口"的话题
+
+      ## 题目设计要求
+      1. **情境化**:用具体场景代替抽象问法(如:"当你遇到困难时..." 而非 "你是否信任对方")
+      2. **温暖语气**:避免让填写者感到被审判,用"你是否觉得...""你是否愿意..."
+      3. **选项差异化**:4-5 个选项,描述具体行为或感受
+      4. **反向计分题**:至少 3 道题设置 direction="negative",用于检测答题一致性
+      5. **覆盖双方画像**:如有星座、年龄差距等信息,可在题目中适度融入(如"青春期""代沟"等)
+
+      ## 返回格式(严格 JSON,无其他内容)
       {{
-        "id": "q1",
-        "dimension": "trust",
-        "direction": "positive",
-        "weight": 1.0,
-        "text": "题目文字",
-        "options": [
-          {{"id": "a", "score": 0}},
-          {{"id": "b", "score": 1}},
-          {{"id": "c", "score": 2}},
-          {{"id": "d", "score": 3}}
+        "version": 1,
+        "questions": [
+          {{
+            "id": "q1",
+            "dimension": "trust",
+            "direction": "positive",
+            "weight": 1.0,
+            "text": "题目文字",
+            "options": [
+              {{"id": "a", "score": 0}},
+              {{"id": "b", "score": 1}},
+              {{"id": "c", "score": 2}},
+              {{"id": "d", "score": 3}}
+            ]
+          }}
         ]
       }}
-    ]
-  }}
-  
-  确保 JSON 格式正确,所有题目覆盖三个维度,共 15-18 题。
+
+      确保 JSON 格式正确,所有题目覆盖三个维度,共 15-18 题。
     tongue-system: |
-  你是资深中医舌诊专家。根据用户上传的舌象图片,输出结构化 JSON,不要输出任何 JSON 之外的文字。indicators 数组的 code 字段必须严格从以下 7 个值中选择,禁止自创或改写:tongue_color(舌色)、coating_color(苔色)、coating_texture(苔质)、fissure(裂纹)、teeth_mark(齿痕)、sublingual_vein(舌下络脉)、constitution(体质)。JSON 格式:{"overall_assessment": "整体舌象评估结论", "indicators": [{"code": "tongue_color", "value": "淡红"}, {"code": "coating_color", "value": "薄白"}]}指标值用简短中文描述,如舌色「淡红」、苔色「薄白」、裂纹「无」、齿痕「轻」。
+      你是资深中医舌诊专家。根据用户上传的舌象图片,输出结构化 JSON,不要输出任何 JSON 之外的文字。indicators 数组的 code 字段必须严格从以下 7 个值中选择,禁止自创或改写:tongue_color(舌色)、coating_color(苔色)、coating_texture(苔质)、fissure(裂纹)、teeth_mark(齿痕)、sublingual_vein(舌下络脉)、constitution(体质)。JSON 格式:{"overall_assessment": "整体舌象评估结论", "indicators": [{"code": "tongue_color", "value": "淡红"}, {"code": "coating_color", "value": "薄白"}]}指标值用简短中文描述,如舌色「淡红」、苔色「薄白」、裂纹「无」、齿痕「轻」。

+ 25 - 0
cfc-backend/src/test/java/com/etotem/cfc/integration/service/DanReportParseServiceTest.java

@@ -244,6 +244,31 @@ class DanReportParseServiceTest {
         assertEquals("A2", result.getReportType());
     }
 
+    @Test
+    void testParseA2_standardCoreLiteracyFormat() {
+        String text = "姓   名:俊辰\n性   别: 男\n测试日期:2026-07-03\n"
+                + "感知觉             86%        记忆力           84%         注意力                       49%\n"
+                + "本测评基于大五人格理论\n"
+                + "您在“开放性”上的得分是 3.9\n"
+                + "您在“宜人性”的得分是 3.9\n"
+                + "您在“责任心”上的得分是 4\n"
+                + "您在“外倾性”上的得分是 3.9\n"
+                + "您在“神经质”上的得分是 2.6\n";
+
+        DanParsedReport result = service.parseText(text, "mind");
+
+        assertFalse(result.isEmpty());
+        assertEquals("A2", result.getReportType());
+        assertEquals("俊辰", result.getExtra().get("name"));
+        assertEquals("2026-07-03", result.getExtra().get("reportDate"));
+        assertNotNull(findItemByCode(result.getItems(), "开放性"));
+        assertEquals("3.9", findItemByCode(result.getItems(), "开放性").getValue());
+        assertNotNull(findItemByCode(result.getItems(), "尽责性"));
+        assertEquals("4", findItemByCode(result.getItems(), "尽责性").getValue());
+        assertNotNull(findItemByCode(result.getItems(), "perception_pct"));
+        assertEquals("86", findItemByCode(result.getItems(), "perception_pct").getValue());
+    }
+
     @Test
     void testParseB4_regression() {
         // B4 解析仍正常工作

+ 2 - 2
cfc-frontend/config.js

@@ -20,8 +20,8 @@ try {
     switch (env) {
       case 'develop':
         //API_BASE_URL = 'http://cfc.iwintrue.com'
-        //API_BASE_URL = 'http://192.168.1.57:9082'
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
+        API_BASE_URL = 'http://192.168.1.57:9082'
+        //API_BASE_URL = 'https://cfc.etotem.com.cn'
         break
       case 'trial':
         API_BASE_URL = 'https://cfc.etotem.com.cn'

+ 301 - 14
cfc-frontend/pages/health/report-upload.vue

@@ -3,7 +3,50 @@
     <!-- 说明提示 -->
     <view class="tip-banner">
       <text class="tip-icon">📋</text>
-      <text class="tip-text">上传体检/菌群/DAN测评等报告,后台自动识别类型并解析</text>
+      <text class="tip-text">{{ tipText }}</text>
+    </view>
+
+    <!-- 报告类型 -->
+    <view class="form-section type-section">
+      <text class="form-label">请选择报告类型</text>
+      <view class="type-methods">
+        <view
+          class="type-card type-gut"
+          :class="{ 'type-card-active': reportKind === 'gut' }"
+          @click="selectKind('gut')">
+          <text class="type-card-icon">🦠</text>
+          <text class="type-card-title">菌群检测报告</text>
+          <text class="type-card-desc">肠道菌群 PDF,自动解析菌群指标</text>
+        </view>
+        <view
+          class="type-card type-a2"
+          :class="{ 'type-card-active': reportKind === 'a2' }"
+          @click="selectKind('a2')">
+          <text class="type-card-icon">🧠</text>
+          <text class="type-card-title">素质测评 A2</text>
+          <text class="type-card-desc">核心素养 PDF,解析大五人格等维度</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- A2 需指定成员 -->
+    <view class="form-section" v-if="reportKind === 'a2'">
+      <view class="member-box">
+        <text class="form-label">报告属于哪位成员</text>
+        <view class="member-list" v-if="familyMembers.length > 0">
+          <view
+            class="member-item"
+            v-for="(member, mIdx) in familyMembers"
+            :key="getMemberKey(member)"
+            :class="member._selected ? 'member-selected' : ''"
+            hover-class="member-hover"
+            @click="selectMemberByIndex(mIdx)">
+            <text class="member-avatar">{{ memberInitial(member) }}</text>
+            <text class="member-name">{{ member.nickname || '未命名' }}</text>
+          </view>
+        </view>
+        <text class="member-empty" v-else>暂无家庭成员,请先在家庭中添加</text>
+      </view>
     </view>
 
     <!-- 上传方式 -->
@@ -12,12 +55,12 @@
         <view class="upload-card card-chat" @click="pickFromChat">
           <view class="upload-card-icon">💬</view>
           <text class="upload-card-title">从聊天选取</text>
-          <text class="upload-card-desc">微信聊天中的 PDF/图片</text>
+          <text class="upload-card-desc">{{ reportKind === 'a2' ? '微信聊天中的 A2 PDF' : '微信聊天中的 PDF/图片' }}</text>
         </view>
         <view class="upload-card card-photo" @click="takePhoto">
           <view class="upload-card-icon">📷</view>
           <text class="upload-card-title">拍照上传</text>
-          <text class="upload-card-desc">拍摄纸质报告或舌象</text>
+          <text class="upload-card-desc">{{ reportKind === 'a2' ? 'A2 仅支持 PDF 文件' : '拍摄纸质报告或舌象' }}</text>
         </view>
       </view>
 
@@ -72,22 +115,22 @@
         <view class="guide-item">
           <view class="guide-icon-box guide-icon-blue"><text class="guide-icon">📄</text></view>
           <view class="guide-info">
-            <text class="guide-name">体检报告</text>
-            <text class="guide-desc">血常规、尿常规、生化检验等,支持 PDF 或清晰照片</text>
+            <text class="guide-name">菌群检测报告</text>
+            <text class="guide-desc">肠道菌群 PDF,后台采集解析菌群指标</text>
           </view>
         </view>
         <view class="guide-item">
-          <view class="guide-icon-box guide-icon-green"><text class="guide-icon">🦠</text></view>
+          <view class="guide-icon-box guide-icon-purple"><text class="guide-icon">🧠</text></view>
           <view class="guide-info">
-            <text class="guide-name">肠道菌群检测报告</text>
-            <text class="guide-desc">支持 PDF 或清晰照片,AI 自动解读菌群健康</text>
+            <text class="guide-name">素质测评 A2</text>
+            <text class="guide-desc">核心素养 PDF,解析认知、情绪、大五人格等</text>
           </view>
         </view>
         <view class="guide-item">
-          <view class="guide-icon-box guide-icon-purple"><text class="guide-icon">🧠</text></view>
+          <view class="guide-icon-box guide-icon-blue"><text class="guide-icon">📄</text></view>
           <view class="guide-info">
-            <text class="guide-name">DAN 测评报告</text>
-            <text class="guide-desc">A2/B4 等成长测评 PDF,自动解析维度数据</text>
+            <text class="guide-name">体检报告</text>
+            <text class="guide-desc">血常规、尿常规、生化检验等,支持 PDF 或清晰照片</text>
           </view>
         </view>
         <view class="guide-item">
@@ -107,7 +150,8 @@
       </view>
       <view class="guide-tips">
         <text class="guide-tips-title">温馨提示</text>
-        <text class="guide-tips-item">· 上传后系统自动识别报告类型,无需手动选择</text>
+        <text class="guide-tips-item">· 请先选择报告类型,再从聊天选取文件</text>
+        <text class="guide-tips-item">· 素质测评 A2 仅支持 PDF,需指定家庭成员</text>
         <text class="guide-tips-item">· 请确保报告清晰完整,以便准确解析</text>
         <text class="guide-tips-item">· 确认入库后将自动生成成长方案,可再分解为每日任务</text>
       </view>
@@ -116,12 +160,14 @@
 </template>
 
 <script>
-import { uploadReportOnly, getReportCollectStatus } from '../../utils/api.js'
+import { uploadReportOnly, getReportCollectStatus, danParsePreview, danConfirmReport, getFamilyMemberList } from '../../utils/api.js'
 
 export default {
   data() {
     return {
       memberId: null,
+      reportKind: '',
+      familyMembers: [],
       uploading: false,
       agreedToTerms: false,
       uploadingText: '正在上传文件...',
@@ -133,13 +179,93 @@ export default {
       collectPollCount: 0
     }
   },
+  computed: {
+    tipText: function() {
+      if (this.reportKind === 'a2') {
+        return '已选择素质测评 A2,请指定成员后上传核心素养 PDF'
+      }
+      if (this.reportKind === 'gut') {
+        return '已选择菌群检测报告,上传后后台自动解析菌群指标'
+      }
+      return '请先选择报告类型:菌群检测,或素质测评 A2'
+    }
+  },
   onLoad: function(options) {
     this.memberId = options.memberId ? parseInt(options.memberId) : null
+    if (options.type === 'a2' || options.reportKind === 'a2') {
+      this.reportKind = 'a2'
+    } else if (options.type === 'gut' || options.reportKind === 'gut') {
+      this.reportKind = 'gut'
+    }
+    this.loadFamilyMembers()
   },
   onUnload: function() {
     this.stopCollectPolling()
   },
   methods: {
+    selectKind: function(kind) {
+      this.reportKind = kind
+    },
+    resolveMemberId: function(member) {
+      if (!member) return null
+      var raw = member.id || member.userId
+      if (raw === 0 || raw === '0') return 0
+      if (!raw) return null
+      var id = parseInt(raw)
+      return isNaN(id) ? null : id
+    },
+    selectMemberByIndex: function(idx) {
+      var list = this.familyMembers
+      if (!list || idx < 0 || idx >= list.length) return
+      var id = this.resolveMemberId(list[idx])
+      if (id === null) {
+        uni.showToast({ title: '该成员缺少编号,无法选择', icon: 'none' })
+        return
+      }
+      this.memberId = id
+      this.markSelectedMember(id)
+    },
+    markSelectedMember: function(memberId) {
+      var list = this.familyMembers || []
+      var target = memberId === null || memberId === undefined ? null : parseInt(memberId)
+      for (var i = 0; i < list.length; i++) {
+        var cur = this.resolveMemberId(list[i])
+        this.$set(list[i], '_selected', target !== null && !isNaN(target) && cur === target)
+      }
+    },
+    getMemberKey: function(member) {
+      var id = this.resolveMemberId(member)
+      return id === null ? 'm0' : ('m' + id)
+    },
+    memberInitial: function(member) {
+      var name = (member && member.nickname) || '?'
+      return name.substring(0, 1)
+    },
+    loadFamilyMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var list = (res && res.data) || []
+        self.familyMembers = list instanceof Array ? list : []
+        if (!self.memberId && self.familyMembers.length === 1) {
+          self.memberId = self.resolveMemberId(self.familyMembers[0])
+        }
+        self.markSelectedMember(self.memberId)
+      }).catch(function() {
+        self.familyMembers = []
+      })
+    },
+    ensureTypeSelected: function() {
+      if (this.reportKind === 'gut' || this.reportKind === 'a2') {
+        return true
+      }
+      uni.showToast({ title: '请先选择报告类型', icon: 'none' })
+      return false
+    },
+    ensureA2Member: function() {
+      if (this.memberId) return true
+      uni.showToast({ title: '请先选择报告所属成员', icon: 'none' })
+      return false
+    },
     toggleAgree: function() {
       this.agreedToTerms = !this.agreedToTerms
     },
@@ -164,7 +290,13 @@ export default {
     },
     pickFromChat: function() {
       var self = this
+      if (!this.ensureTypeSelected()) return
+      if (this.reportKind === 'a2' && !this.ensureA2Member()) return
       this.ensureAgreed(function() {
+        if (self.reportKind === 'a2') {
+          self.chooseChatFile('file', '聊天文件')
+          return
+        }
         // type='all' 的选择器默认停留在图片 tab,用户难以发现可切换选文件。
         // 改为先弹 actionSheet 让用户明确选择图片或文件,分别走对应 type。
         uni.showActionSheet({
@@ -200,6 +332,11 @@ export default {
     },
     takePhoto: function() {
       var self = this
+      if (!this.ensureTypeSelected()) return
+      if (this.reportKind === 'a2') {
+        uni.showToast({ title: '素质测评 A2 请上传 PDF 文件', icon: 'none' })
+        return
+      }
       this.ensureAgreed(function() {
         uni.chooseImage({
           count: 1,
@@ -214,12 +351,16 @@ export default {
     },
     uploadReport: function(filePath, fileName) {
       if (!filePath) return
+      if (this.reportKind === 'a2') {
+        this.uploadA2Report(filePath, fileName)
+        return
+      }
       var self = this
       self.uploading = true
       self.uploadingText = '正在上传文件...'
       uni.showLoading({ title: '上传中...' })
       
-      // 不传 type,让后端通过指纹自动判断报告类型
+      // 菌群检测:走健康报告异步采集
       uploadReportOnly(filePath).then(function(res) {
         uni.hideLoading()
         self.uploading = false
@@ -259,6 +400,58 @@ export default {
         uni.showToast({ title: msg, icon: 'none' })
       })
     },
+    uploadA2Report: function(filePath, fileName) {
+      if (!this.ensureA2Member()) return
+      var name = (fileName || '').toLowerCase()
+      if (name && name.indexOf('.pdf') < 0 && name !== '聊天文件') {
+        uni.showToast({ title: '素质测评 A2 仅支持 PDF', icon: 'none' })
+        return
+      }
+      var self = this
+      self.uploading = true
+      self.uploadingText = '正在解析 A2 报告...'
+      uni.showLoading({ title: '解析中...' })
+      danParsePreview(filePath, 'mind', self.memberId).then(function(res) {
+        var data = (res && res.data) || {}
+        var uploadId = data.uploadId || data.draftId
+        if (!uploadId) {
+          uni.hideLoading()
+          self.uploading = false
+          uni.showToast({ title: (res && res.message) || 'A2 解析失败', icon: 'none' })
+          return
+        }
+        var extra = {}
+        try {
+          extra = typeof data.extraJson === 'string' ? JSON.parse(data.extraJson) : (data.extraJson || {})
+        } catch (e) {
+          extra = {}
+        }
+        var childName = extra.name || ''
+        var assessmentDate = extra.reportDate || ''
+        if (assessmentDate && assessmentDate.length >= 10) {
+          assessmentDate = assessmentDate.substring(0, 10)
+        }
+        return danConfirmReport({
+          draftId: uploadId,
+          childName: childName,
+          assessmentDate: assessmentDate
+        }).then(function(confirmRes) {
+          uni.hideLoading()
+          self.uploading = false
+          var confirmData = (confirmRes && confirmRes.data) || {}
+          var reportId = confirmData.uploadId || uploadId
+          uni.showToast({ title: 'A2 报告已入库', icon: 'success' })
+          setTimeout(function() {
+            uni.redirectTo({ url: '/pages/dan-assessment/report-result?reportId=' + reportId })
+          }, 600)
+        })
+      }).catch(function(err) {
+        uni.hideLoading()
+        self.uploading = false
+        var msg = (err && err.message) || 'A2 解析失败,请重试'
+        uni.showToast({ title: msg, icon: 'none', duration: 3000 })
+      })
+    },
     stopCollectPolling: function() {
       if (this.collectTimer) {
         clearInterval(this.collectTimer)
@@ -367,6 +560,100 @@ export default {
 .form-section {
   margin: 0 30rpx;
 }
+.type-section {
+  margin-bottom: 20rpx;
+}
+.type-methods {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+.type-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding: 24rpx 20rpx;
+  border-radius: 16rpx;
+  background: #fff;
+  border: 2rpx solid #E2E8F0;
+}
+.type-card-active {
+  border-color: #5B9BD5;
+  background: #F0F7FF;
+}
+.type-gut.type-card-active {
+  border-color: #22C55E;
+  background: #F0FDF4;
+}
+.type-a2.type-card-active {
+  border-color: #8B5CF6;
+  background: #F5F3FF;
+}
+.type-card-icon {
+  font-size: 40rpx;
+  margin-bottom: 10rpx;
+}
+.type-card-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 6rpx;
+}
+.type-card-desc {
+  font-size: 20rpx;
+  color: #94A3B8;
+  line-height: 1.5;
+}
+.member-box {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.member-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.member-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16rpx 20rpx;
+  background: #F5F7FA;
+  border-radius: 16rpx;
+  border: 2rpx solid transparent;
+  min-width: 120rpx;
+}
+.member-selected {
+  background: #EDE9FE;
+  border-color: #8B5CF6;
+}
+.member-hover {
+  opacity: 0.85;
+}
+.member-avatar {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: #8B5CF6;
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+  text-align: center;
+  line-height: 64rpx;
+  margin-bottom: 8rpx;
+}
+.member-name {
+  font-size: 24rpx;
+  color: #333;
+}
+.member-empty {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
 .form-item {
   background: #fff;
   border-radius: 16rpx;

+ 6 - 1
cfc-frontend/pages/mind-detail/index.vue

@@ -1016,7 +1016,12 @@ var descList = descMap[this.dominantElement] || descMap.wood
       }).catch(function() {})
     },
     goDanUpload: function(dimension) {
-      uni.navigateTo({ url: '/pages/health/report-upload' })
+      var memberId = this.selectedMemberId || this.currentChildId
+      var url = '/pages/health/report-upload?type=a2'
+      if (memberId) {
+        url += '&memberId=' + memberId
+      }
+      uni.navigateTo({ url: url })
     },
     viewDanReport: function(report) {
       var reportId = report.sourceReportId || report.id

+ 1 - 1
cfc-frontend/pages/wisdom-detail/index.vue

@@ -586,7 +586,7 @@ export default {
       uni.navigateTo({ url: '/pages/health/report-upload' })
     },
     goDanUpload: function() {
-      uni.navigateTo({ url: '/pages/health/report-upload' })
+      uni.navigateTo({ url: '/pages/health/report-upload?type=a2' })
     },
     viewDanReport: function(report) {
       var reportId = report.sourceReportId || report.id