Browse Source

feat(dan-assessment): DAN 报告多维度解析 + 认知能力雷达图(工作区 WIP 代为提交)

- DanReportParseService:新增大五人格 / 情绪稳定性 / 社交依恋 / 身体健康
  四个维度解析器(parseBigFiveQuoted / parseEmotionStability /
  parseSocialAttachment / parsePhysicalHealth)
- ReportBlockAssembler:新维度报告块组装
- report-result.vue:认知能力 3 项及以上数值切换雷达图展示(RadarChart,
  含百分位制说明);保留原 metric-row 列表为降级形态
liaoxg 1 tuần trước cách đây
mục cha
commit
34ebf3572a

+ 235 - 51
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportParseService.java

@@ -124,16 +124,15 @@ public class DanReportParseService {
             }
         }
 
-        // 2. 大五人格 (Big Five)
-        Map<String, String> bigFive = new LinkedHashMap<>();
-        int bigFiveStart = findSectionStart(lines, "大五人格", "人格特质", "人格分析", "人格测评");
-        if (bigFiveStart >= 0) {
-            bigFive.putAll(parseBigFive(lines, bigFiveStart));
-        }
+        // 2. 大五人格 (Big Five,1-5 分制)
+        //    PDF 为自由排版多列文本,按行扫描易漏维度;优先用全文句式正则一次取齐 5 维
+        Map<String, String> bigFive = parseBigFiveQuoted(fullText);
         if (bigFive.isEmpty()) {
-            bigFive.putAll(searchBigFiveGlobally(lines));
+            int bigFiveStart = findSectionStart(lines, "大五人格", "人格特质", "人格分析", "人格测评");
+            if (bigFiveStart >= 0) {
+                bigFive.putAll(parseBigFive(lines, bigFiveStart));
+            }
         }
-        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"));
@@ -141,31 +140,34 @@ public class DanReportParseService {
             extra.put("bigFive", bigFive);
         }
 
-        // 3. 社会关系(与父母/同伴)
-        int socialStart = findSectionStart(lines, "社会关系", "人际关系", "家庭关系", "同伴关系");
-        if (socialStart >= 0) {
-            Map<String, String> social = parseSocialRelations(lines, socialStart);
-            if (!social.isEmpty()) {
-                for (Map.Entry<String, String> entry : social.entrySet()) {
-                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "social"));
-                }
-                extra.put("social", social);
+        // 3. 情绪稳定性(自卑/抑郁/焦虑/无力感 4 分测验 + 总分/平均分)
+        Map<String, String> emotion = parseEmotionStability(fullText);
+        if (!emotion.isEmpty()) {
+            for (Map.Entry<String, String> entry : emotion.entrySet()) {
+                items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "emotion"));
             }
+            extra.put("emotion", emotion);
         }
 
-        // 4. 情绪状态
-        int emotionStart = findSectionStart(lines, "情绪状态", "情绪", "情感");
-        if (emotionStart >= 0) {
-            Map<String, String> emotion = parseEmotionState(lines, emotionStart);
-            if (!emotion.isEmpty()) {
-                for (Map.Entry<String, String> entry : emotion.entrySet()) {
-                    items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "emotion"));
-                }
-                extra.put("emotion", emotion);
+        // 4. 社会关系(依恋:信任/沟通/亲近 × 母亲/父亲/同伴)
+        Map<String, String> social = parseSocialAttachment(fullText);
+        if (!social.isEmpty()) {
+            for (Map.Entry<String, String> entry : social.entrySet()) {
+                items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "social"));
             }
+            extra.put("social", social);
         }
 
-        // 5. 综合能力
+        // 5. 体质健康(身高/体重/BMI + 饮食/睡眠/运动习惯)
+        Map<String, String> body = parsePhysicalHealth(fullText);
+        if (!body.isEmpty()) {
+            for (Map.Entry<String, String> entry : body.entrySet()) {
+                items.add(new DataItem(entry.getKey(), entry.getKey(), entry.getValue(), "body"));
+            }
+            extra.put("body", body);
+        }
+
+        // 6. 综合能力(兜底:多数 A2 报告无独立综合能力页)
         int abilityStart = findSectionStart(lines, "综合能力", "综合评估", "综合");
         if (abilityStart >= 0) {
             Map<String, String> ability = parseComprehensiveAbility(lines, abilityStart);
@@ -177,12 +179,13 @@ public class DanReportParseService {
             }
         }
 
-        // 6. 通用正则:提取所有 "维度名: 分数" 模式
+        // 7. 通用正则:提取所有 "维度名: 分数" 模式(兜底)
         Pattern scorePattern = Pattern.compile("([\\u4e00-\\u9fa5]{2,8})[::]\\s*(\\d+(\\.\\d+)?)");
         Matcher matcher = scorePattern.matcher(fullText);
         while (matcher.find()) {
             String key = matcher.group(1).trim();
             String val = matcher.group(2).trim();
+            if (isNoiseKey(key)) continue;
             // 去重:避免与已解析项重复
             boolean exists = false;
             for (DataItem item : items) {
@@ -198,7 +201,7 @@ public class DanReportParseService {
 
         // 总结和建议
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
 
         return new DanParsedReport("A2", "mind", items, extra, summary.toString(), suggestions.toString());
     }
@@ -256,7 +259,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
 
         return new DanParsedReport("B4", "wisdom", items, extra, summary.toString(), suggestions.toString());
     }
@@ -295,12 +298,13 @@ public class DanReportParseService {
         String[] cognitiveDims = {"感知觉", "注意力", "记忆力", "推理能力", "空间能力", "加工速度"};
         String[] dimCodes = {"perception", "attention", "memory", "reasoning", "spatial", "processingSpeed"};
 
-        // 方法1: 从summary页面提取百分位 (格式: "感知觉 |  Perception\n百分位(%)\n14")
+        // 方法1: 从summary页面提取百分位(DOTALL 支持跨行描述文本)
         for (int i = 0; i < cognitiveDims.length; i++) {
             String dim = cognitiveDims[i];
             String code = dimCodes[i];
             Pattern p1 = Pattern.compile(
-                    Pattern.quote(dim) + "\\s*\\|[^\\n]*\\n\\s*百分位(%)\\s*\\n\\s*(\\d+)");
+                    Pattern.quote(dim) + "\\s*\\|[^\\n]*\\n\\s*百分位(%)\\s*\\n\\s*(\\d+)",
+                    Pattern.DOTALL);
             Matcher m1 = p1.matcher(fullText);
             if (m1.find()) {
                 String val = m1.group(1);
@@ -309,16 +313,30 @@ public class DanReportParseService {
             }
         }
 
-        // 方法2: 从detail页面提取原始分和百分位 (格式: "我的感知觉得分 41  | 14%")
+        // 方法2: 从detail页面提取原始分和百分位
+        // PDF 格式: "我的感知觉得分\n41|46%"(分隔符为 U+FF5C 全角竖线,非 ASCII |)
+        // 策略:取「我的xxx得分」下一行的所有数字,取前两个
         for (int i = 0; i < cognitiveDims.length; i++) {
             String dim = cognitiveDims[i];
             String code = dimCodes[i];
-            Pattern p2 = Pattern.compile(
-                    "我的" + Pattern.quote(dim) + "得分\\s*(\\d+)\\s*\\|\\s*(\\d+)%");
-            Matcher m2 = p2.matcher(fullText);
-            if (m2.find()) {
-                String score = m2.group(1);
-                String pct = m2.group(2);
+            int idx = fullText.indexOf("我的" + dim + "得分");
+            if (idx < 0) continue;
+            // 跳到下一行
+            int lineStart = fullText.indexOf('\n', idx);
+            if (lineStart < 0) continue;
+            lineStart++;
+            int lineEnd = fullText.indexOf('\n', lineStart);
+            if (lineEnd < 0) lineEnd = Math.min(lineStart + 30, fullText.length());
+            String line = fullText.substring(lineStart, lineEnd);
+            java.util.regex.Matcher numMatcher =
+                    Pattern.compile("\\d+").matcher(line);
+            java.util.List<String> nums = new java.util.ArrayList<>();
+            while (numMatcher.find() && nums.size() < 2) {
+                nums.add(numMatcher.group());
+            }
+            if (nums.size() >= 2) {
+                String score = nums.get(0);
+                String pct = nums.get(1);
                 items.add(new DataItem(code + "_score", dim + "得分", score, "cognitive"));
                 extra.put(code + "_score", score);
                 if (!extra.containsKey(code + "_pct")) {
@@ -328,7 +346,7 @@ public class DanReportParseService {
             }
         }
 
-        // 方法3: 后备 - 直接搜索 "维度名\n百分位(%)\n数字" (某些PDF格式)
+        // 方法3: 后备 - 精确寻找「维度名...百分位(%)...数字」三者在同一窗口内
         int foundPct = 0;
         for (String code : dimCodes) {
             if (extra.containsKey(code + "_pct")) foundPct++;
@@ -338,11 +356,15 @@ public class DanReportParseService {
                 String dim = cognitiveDims[i];
                 String code = dimCodes[i];
                 if (!extra.containsKey(code + "_pct")) {
-                    Pattern p3 = Pattern.compile(
-                            Pattern.quote(dim) + ".*?百分位(%).*?(\\d+)", Pattern.DOTALL);
-                    Matcher m3 = p3.matcher(fullText);
-                    if (m3.find()) {
-                        String val = m3.group(1);
+                    int dimIdx = fullText.indexOf(dim);
+                    if (dimIdx < 0) continue;
+                    // 在该维度名之后 200 字符内找百分位数字
+                    int searchEnd = Math.min(dimIdx + 300, fullText.length());
+                    String window = fullText.substring(dimIdx, searchEnd);
+                    java.util.regex.Matcher pctMatcher =
+                            Pattern.compile("百分位[((]%[))].*?(\\d+)").matcher(window);
+                    if (pctMatcher.find()) {
+                        String val = pctMatcher.group(1);
                         items.add(new DataItem(code + "_pct", dim + "百分位", val, "cognitive"));
                         extra.put(code + "_pct", val);
                     }
@@ -370,7 +392,7 @@ public class DanReportParseService {
 
         // 总结和建议
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
 
         return new DanParsedReport("A1", "cognition", items, extra, summary.toString(), suggestions.toString());
     }
@@ -469,7 +491,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
 
         return new DanParsedReport("B3", "learning", items, extra, summary.toString(), suggestions.toString());
     }
@@ -589,7 +611,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
         return new DanParsedReport("B2", "behavior", items, extra, summary.toString(), suggestions.toString());
     }
 
@@ -721,7 +743,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
         return new DanParsedReport("B6", "career", items, extra, summary.toString(), suggestions.toString());
     }
 
@@ -843,7 +865,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
         return new DanParsedReport("C1", "campus", items, extra, summary.toString(), suggestions.toString());
     }
 
@@ -1032,7 +1054,7 @@ public class DanReportParseService {
         }
 
         summary.append(extractSection(fullText, "测评总结", "成长建议"));
-        suggestions.append(extractSection(fullText, "成长建议", null));
+        suggestions.append(sanitizeDanSuggestions(extractSection(fullText, "成长建议", null)));
         return new DanParsedReport("B5", "adolescent", items, extra, summary.toString(), suggestions.toString());
     }
 
@@ -1232,6 +1254,168 @@ public class DanReportParseService {
         }
     }
 
+    /**
+     * 判断通用正则提取到的 key 是否为报告头噪音(联系方式/证件/日期等)。
+     * 兜底正则会把「联系电话:135****」这类非测评数据也抓成指标。
+     */
+    private boolean isNoiseKey(String key) {
+        if (key == null || key.isEmpty()) return true;
+        return key.contains("电话") || key.contains("手机") || key.contains("编码")
+                || key.contains("编号") || key.contains("证件") || key.contains("年份")
+                || key.contains("日期") || key.contains("地址") || key.contains("联系")
+                || key.contains("身份证") || key.contains("姓名") || key.contains("性别")
+                || key.contains("年龄") || key.contains("学校") || key.contains("邮箱")
+                || key.contains("时间") || key.contains("具体");
+    }
+
+    /**
+     * 稳健解析大五人格:用全文句式正则一次取齐 5 维。
+     * PDF 为多列自由排版,逐行扫描只取「行内第一个数字」会漏维度/取错值。
+     * 句式:您在“开放性”上的得分是 3.9 分,同龄人的平均分是3分
+     */
+    private Map<String, String> parseBigFiveQuoted(String fullText) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (fullText == null || fullText.isEmpty()) return result;
+        String[] dims = {"开放性", "宜人性", "责任心", "外倾性", "神经质"};
+        Pattern p = Pattern.compile(
+                "您在[\"“”「」'']?(" + String.join("|", dims) + ")[\"“”「」'']?[^。]*?得分是\\s*(\\d+(?:\\.\\d+)?)",
+                Pattern.DOTALL);
+        Matcher m = p.matcher(fullText);
+        while (m.find()) {
+            result.put(normalizeBigFiveName(m.group(1)), m.group(2));
+        }
+        return result;
+    }
+
+    /**
+     * 解析情绪稳定性(A2 艾森克情绪稳定性测验)
+     * - 4 个分测验(自卑-自尊、抑郁-愉快、焦虑-安详、无力感-掌控感)
+     * - 分测验总分 + 同龄平均分
+     */
+    private Map<String, String> parseEmotionStability(String fullText) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (fullText == null || fullText.isEmpty()) return result;
+        // 总分与平均分:您在4个分测验中的总得分是42.5分,同龄人的平均分是40分(中间可能夹换行)
+        Pattern totalP = Pattern.compile(
+                "总得分是\\s*(\\d+(?:\\.\\d+)?)\\s*分[,,]?[\\s\\S]{0,30}?平均分是\\s*(\\d+(?:\\.\\d+)?)\\s*分");
+        Matcher totalM = totalP.matcher(fullText);
+        if (totalM.find()) {
+            result.put("情绪总得分", totalM.group(1));
+            result.put("情绪平均分", totalM.group(2));
+        }
+        // 4 个分测验:PDFBox 排版为「分数行\n维度行」如 "7.5\n自卑 自尊"
+        String[][] subs = {
+                {"自卑", "自尊"},
+                {"抑郁", "愉快"},
+                {"焦虑", "安详"},
+                {"无力感", "掌控感"}
+        };
+        for (String[] sub : subs) {
+            Pattern p = Pattern.compile(
+                    "(\\d+(?:\\.\\d+)?)\\s*\\n\\s*" + Pattern.quote(sub[0]) + "\\s+" + Pattern.quote(sub[1]),
+                    Pattern.DOTALL);
+            Matcher m = p.matcher(fullText);
+            if (m.find()) {
+                result.put(sub[0], m.group(1));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析社会关系(A2 社会性测评:依恋模式)
+     * 句式:您和母亲、父亲和同伴在信任方面的得分分别是37分、40分、34分;…在沟通上的得分分别是32分、32分、25分;…在亲近上的得分分别是15分、16分、17分。
+     */
+    private Map<String, String> parseSocialAttachment(String fullText) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (fullText == null || fullText.isEmpty()) return result;
+        Pattern p = Pattern.compile(
+                "信任方\\s*面的得分分别\\s*是\\s*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分"
+                        + "[\\s\\S]{0,80}?"
+                        + "沟通上的\\s*得分分别\\s*是\\s*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分"
+                        + "[\\s\\S]{0,80}?"
+                        + "亲近上的\\s*得分分别\\s*是\\s*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分[、,,\\s]*(\\d+)\\s*分",
+                Pattern.DOTALL);
+        Matcher m = p.matcher(fullText);
+        if (m.find()) {
+            String[][] keys = {
+                    {"母亲信任", "父亲信任", "同伴信任"},
+                    {"母亲沟通", "父亲沟通", "同伴沟通"},
+                    {"母亲亲近", "父亲亲近", "同伴亲近"}
+            };
+            for (int row = 0; row < keys.length; row++) {
+                for (int col = 0; col < 3; col++) {
+                    result.put(keys[row][col], m.group(row * 3 + col + 1));
+                }
+            }
+        }
+        // 依恋类型:母亲/父亲/同伴 的安全型/回避型/焦虑-矛盾型
+        Pattern typeP = Pattern.compile(
+                "母亲\\s+父亲\\s+同伴[\\s\\S]{0,200}?(安全型|回避型|焦虑-矛盾型)\\s+(?:[A-Za-z-]+\\s+)?"
+                        + "(安全型|回避型|焦虑-矛盾型)\\s+(?:[A-Za-z-]+\\s+)?"
+                        + "(安全型|回避型|焦虑-矛盾型)");
+        Matcher typeM = typeP.matcher(fullText);
+        if (typeM.find()) {
+            result.put("依恋类型", typeM.group(1) + "/" + typeM.group(2) + "/" + typeM.group(3));
+        }
+        return result;
+    }
+
+    /**
+     * 解析体质健康(A2):BMI 得分/等级、身高、体重、饮食/睡眠/运动习惯
+     */
+    private Map<String, String> parsePhysicalHealth(String fullText) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (fullText == null || fullText.isEmpty()) return result;
+        // BMI 得分与等级("17 正常")
+        Pattern bmiP = Pattern.compile(
+                "(?:^|\\n)\\s*(\\d+(?:\\.\\d+)?)\\s*(正常|偏瘦|偏重|超重|肥胖|消瘦)\\s*(?=\\n|$)");
+        Matcher bmiM = bmiP.matcher(fullText);
+        if (bmiM.find()) {
+            result.put("BMI得分", bmiM.group(1));
+            result.put("BMI等级", bmiM.group(2));
+        }
+        // 身高/体重/BMI 值:身高:154CM  体重:40KG  BMI:17KG/M²
+        Matcher hM = Pattern.compile("身高:\\s*(\\d+(?:\\.\\d+)?)\\s*CM").matcher(fullText);
+        if (hM.find()) result.put("身高", hM.group(1));
+        Matcher wM = Pattern.compile("体重:\\s*(\\d+(?:\\.\\d+)?)\\s*KG").matcher(fullText);
+        if (wM.find()) result.put("体重", wM.group(1));
+        Matcher bmiNumM = Pattern.compile("BMI:\\s*(\\d+(?:\\.\\d+)?)").matcher(fullText);
+        if (bmiNumM.find()) result.put("BMI值", bmiNumM.group(1));
+        // 生活习惯:三列合并在同一行 "基本均衡 中等 8.8小时/每天 优秀 5小时/周 中等"。
+        // 不依赖固定行序,直接匹配 6 个 token(数值/时长可能带单位)。
+        Pattern habitsP = Pattern.compile(
+                "([一-龥]{2,12})\\s+([一-龥]{2,4})\\s+(\\d+(?:\\.\\d+)?\\s*小时/每天)\\s+([一-龥]{2,4})"
+                        + "\\s+(\\d+(?:\\.\\d+)?\\s*小时/周)\\s+([一-龥]{2,4})");
+        Matcher habitsM = habitsP.matcher(fullText);
+        if (habitsM.find()) {
+            result.put("饮食习惯", habitsM.group(1));
+            result.put("饮食评价", habitsM.group(2));
+            result.put("睡眠时长", habitsM.group(3));
+            result.put("睡眠评价", habitsM.group(4));
+            result.put("运动时长", habitsM.group(5));
+            result.put("运动评价", habitsM.group(6));
+        }
+        return result;
+    }
+
+    /**
+     * 清洗 DAN「成长建议」提取结果:DAN 各类型 PDF 的页眉固定印有「成长建议」字样,
+     * extractSection(fullText, "成长建议", null) 会截取整份报告正文(含页码的噪音长文),
+     * 只有真正独立的建议段落才保留;判断为噪音则置空,由展示层按指标生成建议。
+     */
+    private String sanitizeDanSuggestions(String raw) {
+        if (raw == null) return "";
+        String text = raw.trim();
+        if (text.isEmpty()) return "";
+        if (text.length() > 500) return "";
+        if (text.contains("Results Summary") || text.contains("测评报告")
+                || text.contains("第 1 页") || text.contains("Total Score")) {
+            return "";
+        }
+        return text;
+    }
+
     /**
      * 解析社会关系:与父母信任、沟通、亲近等
      */

+ 197 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java

@@ -489,6 +489,7 @@ public class ReportBlockAssembler {
         m.put("bigFive", "大五人格");
         m.put("social", "社会关系");
         m.put("emotion", "情绪状态");
+        m.put("body", "体质健康");
         m.put("ability", "综合能力");
         m.put("selfConcept", "自我概念");
         m.put("growthMindset", "成长型思维");
@@ -570,10 +571,205 @@ public class ReportBlockAssembler {
         }
         // text 块:summary / suggestions
         if (summary != null && !summary.isEmpty()) blocks.add(textBlock("报告评语", summary));
-        if (suggestions != null && !suggestions.isEmpty()) blocks.add(textBlock("成长建议", suggestions));
+        // DAN PDF 页眉固定印有「成长建议」,extractSection 按页眉截取会把整份报告正文
+        // (含页码/英文标题)当成建议。判为噪音或为空时,改为按实际指标生成建议汇总。
+        String effSuggestions = (suggestions != null && !isScrapSuggestions(suggestions)) ? suggestions : null;
+        if (effSuggestions == null || effSuggestions.trim().isEmpty()) {
+            effSuggestions = buildIndicatorSuggestions(byCategory);
+        }
+        if (effSuggestions != null && !effSuggestions.trim().isEmpty()) {
+            blocks.add(textBlock("成长建议", effSuggestions));
+        }
         return blocks;
     }
 
+    /** 判断提取到的「成长建议」是否为报告正文噪音(页码/英文标题/整篇长文) */
+    private boolean isScrapSuggestions(String s) {
+        if (s == null) return true;
+        String t = s.trim();
+        if (t.isEmpty()) return true;
+        return t.length() > 500
+                || t.contains("Results Summary")
+                || t.contains("测评报告")
+                || t.contains("第 1 页")
+                || t.contains("Total Score");
+    }
+
+    /**
+     * 按已采集的指标数据生成「成长建议」汇总(不直接引用报告原文)。
+     * - 大五人格:1-5 分制判读
+     * - 认知能力:百分位判读
+     * - 情绪状态:总分 vs 同龄平均
+     * - 社会关系 / 体质健康:列关键值 + 简单判读
+     * byCategory 的 value 为 String(历史数据同样适用)。
+     */
+    private String buildIndicatorSuggestions(Map<String, List<Map<String, Object>>> byCategory) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("以下建议依据本次测评采集到的各项指标数据生成,供日常养育与陪伴参考:");
+        boolean hasAny = false;
+
+        // 1. 大五人格(1-5 分制:>=4 优势 / <=2.5 低于同龄平均 / 其余持平)
+        List<Map<String, Object>> bigFive = byCategory.get("bigFive");
+        if (bigFive != null && !bigFive.isEmpty()) {
+            sb.append("\n\n【大五人格】");
+            for (Map<String, Object> it : bigFive) {
+                double v = toDouble(it.get("value"), -1);
+                String verdict;
+                if (v < 0) {
+                    verdict = "";
+                } else if (v >= 4) {
+                    verdict = "(表现突出,是孩子的优势项)";
+                } else if (v <= 2.5) {
+                    verdict = "(低于同龄平均水平,建议有意识地给予肯定与锻炼机会)";
+                } else {
+                    verdict = "(与同龄平均基本持平)";
+                }
+                sb.append("\n· ").append(str(it.get("name"))).append(":").append(str(it.get("value")))
+                        .append(" 分").append(verdict);
+            }
+            hasAny = true;
+        }
+
+        // 2. 认知能力(百分位:>=70 优秀 / 50-69 良好 / <50 低于同龄)
+        List<Map<String, Object>> cognitive = byCategory.get("cognitive");
+        if (cognitive != null && !cognitive.isEmpty()) {
+            sb.append("\n\n【认知能力】");
+            for (Map<String, Object> it : cognitive) {
+                double v = toDouble(it.get("value"), -1);
+                String verdict;
+                if (v < 0) verdict = "";
+                else if (v >= 70) verdict = "(超过 70% 同龄人,表现优秀)";
+                else if (v >= 50) verdict = "(超过 50% 同龄人,处于良好水平)";
+                else verdict = "(低于同龄平均水平,建议通过专项练习有针对性地提升)";
+                // 名称统一去掉「百分位」后缀,展示更自然:感知觉百分位 → 感知觉
+                String name = str(it.get("name")).replace("百分位", "");
+                sb.append("\n· ").append(name).append(":").append(str(it.get("value")))
+                        .append("%").append(verdict);
+            }
+            hasAny = true;
+        }
+
+        // 3. 情绪状态(不稳定性总分 vs 同龄平均,分高代表情绪波动更大)
+        List<Map<String, Object>> emotion = byCategory.get("emotion");
+        if (emotion != null && !emotion.isEmpty()) {
+            sb.append("\n\n【情绪状态】");
+            double total = -1, avg = -1;
+            for (Map<String, Object> it : emotion) {
+                String name = str(it.get("name"));
+                if ("情绪总得分".equals(name)) total = toDouble(it.get("value"), -1);
+                else if ("情绪平均分".equals(name)) avg = toDouble(it.get("value"), -1);
+            }
+            for (Map<String, Object> it : emotion) {
+                String name = str(it.get("name"));
+                if ("情绪总得分".equals(name) || "情绪平均分".equals(name)) continue;
+                sb.append("\n· ").append(name).append(":").append(str(it.get("value")));
+            }
+            if (total >= 0 && avg >= 0) {
+                sb.append("\n· 情绪不稳定总分 ").append(trimNum(total)).append(" 分,同龄平均 ")
+                        .append(trimNum(avg)).append(" 分,");
+                double diff = total - avg;
+                if (diff > 3) {
+                    sb.append("高于同龄平均,日常可多关注情绪调节,遇挫败时及时陪伴与疏导");
+                } else if (diff > 0) {
+                    sb.append("略高于同龄平均,属常见波动范围,保持规律作息与倾诉习惯即可");
+                } else {
+                    sb.append("不高于同龄平均,情绪稳定性表现良好");
+                }
+            }
+            hasAny = true;
+        }
+
+        // 4. 社会关系(信任/沟通/亲近 × 母亲/父亲/同伴)
+        List<Map<String, Object>> social = byCategory.get("social");
+        if (social != null && !social.isEmpty()) {
+            sb.append("\n\n【社会关系】");
+            for (Map<String, Object> it : social) {
+                String name = str(it.get("name"));
+                String raw = str(it.get("value"));
+                // 文本型指标(依恋类型)不追加「分」
+                boolean numeric = toDouble(it.get("value"), -1) >= 0;
+                sb.append("\n· ").append(name).append(":").append(raw)
+                        .append(numeric ? " 分" : "");
+            }
+            java.util.List<String> lowDims = new ArrayList<>();
+            double sum = 0;
+            int cnt = 0;
+            for (Map<String, Object> it : social) {
+                double v = toDouble(it.get("value"), -1);
+                if (v < 0) continue;
+                sum += v;
+                cnt++;
+                // 亲近维度分值天然低于信任/沟通(PDF 标尺不同),阈值单独放宽
+                if (v < 20) lowDims.add(str(it.get("name")));
+            }
+            if (!lowDims.isEmpty()) {
+                sb.append("\n· ").append(String.join("、", lowDims))
+                        .append(" 分值偏低,建议多创造一对一相处与倾听的机会");
+            } else if (cnt > 0) {
+                double avg = sum / cnt;
+                if (avg >= 30) {
+                    sb.append("\n· 与母亲、父亲及同伴的信任/沟通/亲近整体良好,可继续保持高频的亲子互动");
+                } else {
+                    sb.append("\n· 各关系维度处于中等水平,可通过共同活动与日常倾听进一步拉近");
+                }
+            }
+            hasAny = true;
+        }
+
+        // 5. 体质健康(BMI + 身高体重 + 饮食/睡眠/运动习惯)
+        List<Map<String, Object>> body = byCategory.get("body");
+        if (body != null && !body.isEmpty()) {
+            sb.append("\n\n【体质健康】");
+            String bmiGrade = null, bmiScore = null, height = null, weight = null;
+            java.util.List<String> habitLines = new ArrayList<>();
+            for (Map<String, Object> it : body) {
+                String name = str(it.get("name"));
+                String value = str(it.get("value"));
+                if ("BMI等级".equals(name)) bmiGrade = value;
+                else if ("BMI得分".equals(name)) bmiScore = value;
+                else if ("身高".equals(name)) height = value;
+                else if ("体重".equals(name)) weight = value;
+                else if ("BMI值".equals(name)) continue;
+                else habitLines.add(name + ":" + value);
+            }
+            if (height != null || weight != null) {
+                sb.append("\n· 身高/体重:").append(height == null ? "-" : height).append(" cm / ")
+                        .append(weight == null ? "-" : weight).append(" kg");
+            }
+            if (bmiScore != null || bmiGrade != null) {
+                sb.append("\n· BMI ").append(bmiScore == null ? "-" : bmiScore).append(",等级 ")
+                        .append(bmiGrade == null ? "-" : bmiGrade);
+                if ("正常".equals(bmiGrade)) {
+                    sb.append(",体型发育良好");
+                } else if (bmiGrade != null) {
+                    sb.append(",建议结合饮食与运动习惯适度调整");
+                }
+            }
+            if (!habitLines.isEmpty()) {
+                sb.append("\n· 生活习惯:").append(String.join(";", habitLines));
+            }
+            hasAny = true;
+        }
+
+        if (!hasAny) return null;
+        return sb.toString();
+    }
+
+    private double toDouble(Object v, double def) {
+        if (v == null) return def;
+        try {
+            return Double.parseDouble(v.toString().trim());
+        } catch (Exception e) {
+            return def;
+        }
+    }
+
+    /** 去掉小数尾随的 .0,展示更自然:42.0 → 42,42.5 → 42.5 */
+    private String trimNum(double v) {
+        if (v == Math.rint(v)) return String.valueOf((long) v);
+        return String.valueOf(v);
+    }
+
     private Map<String, Object> scoreItem(String label, String value) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("label", label);

+ 246 - 41
cfc-frontend/pages/dan-assessment/report-result.vue

@@ -52,49 +52,82 @@
           <view class="card-title-row">
             <view class="card-title-bar"></view>
             <text class="card-title">{{ abilityBlock.title }}</text>
+            <text class="card-title-sub" v-if="!showAbilityRadar">百分位制,50 为同龄平均</text>
           </view>
-          <view class="metric-row" v-for="(m, mi) in abilityMetrics" :key="mi">
-            <view class="metric-head">
-              <text class="metric-name">{{ m.name }}</text>
-              <view class="metric-value-wrap">
-                <text class="metric-warn-badge" v-if="m.warn">需关注</text>
-                <text class="metric-value" :class="m.warn ? 'metric-value-warn' : ''">{{ m.value }}<text class="metric-unit">%</text></text>
+          <!-- 认知能力:3 项及以上数值 → 雷达图 -->
+          <view class="radar-wrap" v-if="showAbilityRadar">
+            <RadarChart
+              :dimensions="abilityRadarDims"
+              :scores="abilityRadarScores"
+              :population-scores="abilityRadarPopulation"
+              fillColor="#6366F1"
+              gridColor="#EEF0F6"
+              labelColor="#1F2937"
+              :width="600"
+              :height="600"
+              :child-name="childName" />
+          </view>
+          <view v-else>
+            <view class="metric-row" v-for="(m, mi) in abilityMetrics" :key="mi">
+              <view class="metric-head">
+                <text class="metric-name">{{ m.name }}</text>
+                <view class="metric-value-wrap">
+                  <text class="metric-warn-badge" v-if="m.warn">需关注</text>
+                  <text class="metric-value" :class="m.warn ? 'metric-value-warn' : ''">{{ m.value }}<text class="metric-unit">%</text></text>
+                </view>
+              </view>
+              <view class="metric-track">
+                <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
+                      :style="'width:' + m.pct + '%'"></view>
+                <view class="metric-ref-line" style="left:50%"></view>
               </view>
-            </view>
-            <view class="metric-track">
-              <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
-                    :style="'width:' + m.pct + '%'"></view>
-              <view class="metric-ref-line" style="left:50%"></view>
             </view>
           </view>
         </view>
 
-        <!-- ==================== 大五人格(1-5 分制) ==================== -->
+        <!-- ==================== 大五人格(1-5 分制,5 项 → 雷达图) ==================== -->
         <view class="card" v-if="bigFiveBlock">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
             <text class="card-title">{{ bigFiveBlock.title }}</text>
-            <text class="card-title-sub">1 ~ 5 分,3 分为同龄平均</text>
+            <text class="card-title-sub">1 ~ 5 分,虚线为同龄平均</text>
           </view>
-          <view class="bf-row" v-for="(m, mi) in bigFiveMetrics" :key="mi">
-            <view class="bf-head">
-              <text class="bf-name">{{ m.name }}</text>
-              <text class="bf-value">{{ m.value }}</text>
-            </view>
-            <view class="bf-track">
-              <view class="bf-fill" :class="m.tone" :style="'width:' + m.pct + '%'"></view>
-              <view class="bf-avg-line"></view>
+          <view class="radar-wrap" v-if="showBigFiveRadar">
+            <RadarChart
+              :dimensions="bigFiveRadarDims"
+              :scores="bigFiveRadarScores"
+              :population-scores="bigFiveRadarPopulation"
+              fillColor="#8B5CF6"
+              gridColor="#F0ECFA"
+              labelColor="#5B21B6"
+              :width="600"
+              :height="600"
+              :child-name="childName" />
+          </view>
+          <view v-else>
+            <view class="bf-row" v-for="(m, mi) in bigFiveMetrics" :key="mi">
+              <view class="bf-head">
+                <text class="bf-name">{{ m.name }}</text>
+                <text class="bf-value">{{ m.value }}</text>
+              </view>
+              <view class="bf-track">
+                <view class="bf-fill" :class="m.tone" :style="'width:' + m.pct + '%'"></view>
+                <view class="bf-avg-line"></view>
+              </view>
+              <text class="bf-hint" v-if="m.hint">{{ m.hint }}</text>
             </view>
-            <text class="bf-hint" v-if="m.hint">{{ m.hint }}</text>
           </view>
         </view>
 
-        <!-- ==================== 情绪状态(0-10 分制) ==================== -->
+        <!-- ==================== 情绪状态(4 分测验,0-15 分制) ==================== -->
         <view class="card" v-if="emotionBlock">
           <view class="card-title-row">
             <view class="card-title-bar"></view>
             <text class="card-title">{{ emotionBlock.title }}</text>
           </view>
+          <view class="emo-summary" v-if="emotionSummary.total || emotionSummary.avg">
+            <text class="emo-summary-text">情绪不稳定总分 {{ emotionSummary.total || '-' }} 分,同龄平均 {{ emotionSummary.avg || '-' }} 分</text>
+          </view>
           <view class="emo-row" v-for="(m, mi) in emotionMetrics" :key="mi">
             <view class="emo-chip">
               <text class="emo-name">{{ m.name }}</text>
@@ -106,6 +139,24 @@
           </view>
         </view>
 
+        <!-- ==================== 体质健康(身高/体重/BMI + 生活习惯) ==================== -->
+        <view class="card" v-if="bodyBaseMetrics.length > 0 || bodyHabitMetrics.length > 0">
+          <view class="card-title-row">
+            <view class="card-title-bar"></view>
+            <text class="card-title">体质健康</text>
+          </view>
+          <view class="info-grid" v-if="bodyBaseMetrics.length > 0">
+            <view class="info-cell" v-for="(m, mi) in bodyBaseMetrics" :key="mi">
+              <text class="info-value">{{ m.value }}<text class="info-unit" v-if="m.unit">{{ m.unit }}</text></text>
+              <text class="info-name">{{ m.name }}</text>
+            </view>
+          </view>
+          <view class="habit-row" v-for="(m, mi) in bodyHabitMetrics" :key="mi">
+            <text class="habit-name">{{ m.name }}</text>
+            <text class="habit-value">{{ m.value }}</text>
+          </view>
+        </view>
+
         <!-- ==================== 通用指标块(自我概念/自驱力等,量纲自适应) ==================== -->
         <view class="card" v-for="(blk, bi) in genericBlocks" :key="getBlockKey(bi)">
           <view class="card-title-row">
@@ -113,17 +164,24 @@
             <text class="card-title">{{ blk.title }}</text>
           </view>
           <view class="metric-row" v-for="(m, mi) in blk.metrics" :key="mi">
-            <view class="metric-head">
+            <!-- 非数值指标(如依恋类型)只展示名称与取值,不画条 -->
+            <view class="metric-plain-row" v-if="m.nonNumeric">
               <text class="metric-name">{{ m.name }}</text>
-              <view class="metric-value-wrap">
-                <text class="metric-warn-badge" v-if="m.warn">需关注</text>
-                <text class="metric-value" :class="m.warn ? 'metric-value-warn' : ''">{{ m.value }}</text>
-              </view>
-            </view>
-            <view class="metric-track">
-              <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
-                    :style="'width:' + m.pct + '%'"></view>
+              <text class="metric-plain-value">{{ m.value }}</text>
             </view>
+            <template v-else>
+              <view class="metric-head">
+                <text class="metric-name">{{ m.name }}</text>
+                <view class="metric-value-wrap">
+                  <text class="metric-warn-badge" v-if="m.warn">需关注</text>
+                  <text class="metric-value" :class="m.warn ? 'metric-value-warn' : ''">{{ m.value }}</text>
+                </view>
+              </view>
+              <view class="metric-track">
+                <view class="metric-fill" :class="m.warn ? 'metric-fill-warn' : 'metric-fill-main'"
+                      :style="'width:' + m.pct + '%'"></view>
+              </view>
+            </template>
           </view>
         </view>
 
@@ -178,13 +236,16 @@
 
 <script>
 import { getDanReportDetail } from '../../utils/api.js'
+import RadarChart from '../../components/RadarChart.vue'
 
-// 噪音指标过滤(OCR 带出的非测评数据)
-var NOISE_NAME_KEYS = ['联系电话', '报告编码', '测试时间', '档案', '具体情况', '页', '公众号']
+// 噪音指标过滤(报告头/OCR 带出的非测评数据,对历史数据同样生效)
+var NOISE_NAME_KEYS = ['联系电话', '报告编码', '测试时间', '档案', '具体情况', '页', '公众号',
+  '出生', '年份', '编码', '编号', '手机', '身份证', '总均分', '邮箱']
 // 百分位制 category(0-100)
 var PERCENT_CATEGORIES = ['overall', 'cognitive']
 
 export default {
+  components: { RadarChart },
   data() {
     return {
       reportId: null,
@@ -251,6 +312,30 @@ export default {
         }
       })
     },
+    // 认知能力雷达图模式:标题含「认知」且数值项 >= 3
+    showAbilityRadar() {
+      var b = this.abilityBlock
+      if (!b) return false
+      var t = String(b.title || '')
+      return t.indexOf('认知') >= 0 && this.abilityMetrics.length >= 3
+    },
+    abilityRadarDims() {
+      return this.abilityMetrics.map(function(m) {
+        return { label: m.name + ' ' + m.value + '%', key: m.name }
+      })
+    },
+    abilityRadarScores() {
+      return this.abilityMetrics.map(function(m) {
+        var v = parseFloat(m.value)
+        return isNaN(v) ? 0 : Math.min(100, Math.max(0, Math.round(v)))
+      })
+    },
+    // 同龄平均位置:50 百分位
+    abilityRadarPopulation() {
+      var arr = []
+      for (var i = 0; i < this.abilityMetrics.length; i++) { arr.push(50) }
+      return arr
+    },
     // 大五人格块
     bigFiveBlock() {
       for (var i = 0; i < this.blocks.length; i++) {
@@ -275,6 +360,27 @@ export default {
         return { name: it.name, value: it.value, pct: Math.round(pct), tone: tone, hint: hint }
       })
     },
+    // 大五人格雷达图模式:5 个维度齐全时用雷达图(1-5 分制换算成 0-100)
+    showBigFiveRadar() {
+      return this.bigFiveMetrics.length >= 3
+    },
+    bigFiveRadarDims() {
+      return this.bigFiveMetrics.map(function(m) {
+        return { label: m.name + ' ' + m.value, key: m.name }
+      })
+    },
+    bigFiveRadarScores() {
+      return this.bigFiveMetrics.map(function(m) {
+        var v = parseFloat(m.value)
+        return isNaN(v) ? 0 : Math.round(v / 5 * 100)
+      })
+    },
+    // 同龄平均位置:3 分(1-5 分制)
+    bigFiveRadarPopulation() {
+      var arr = []
+      for (var i = 0; i < this.bigFiveMetrics.length; i++) { arr.push(60) }
+      return arr
+    },
     // 情绪状态块
     emotionBlock() {
       for (var i = 0; i < this.blocks.length; i++) {
@@ -289,13 +395,67 @@ export default {
       if (!this.emotionBlock) return []
       var self = this
       return (this.emotionBlock.items || []).filter(function(it) {
-        return !self.isNoise(it.name)
+        if (self.isNoise(it.name)) return false
+        var n = String(it.name)
+        return n.indexOf('总得分') < 0 && n.indexOf('平均分') < 0
       }).map(function(it) {
         var v = parseFloat(it.value)
-        var pct = isNaN(v) ? 0 : Math.min(100, Math.max(0, v * 10))
+        // 分测验为 0-15 分制(PDF 标尺 1/10/15)
+        var pct = isNaN(v) ? 0 : Math.min(100, Math.max(0, v / 15 * 100))
         return { name: it.name, value: it.value, pct: Math.round(pct) }
       })
     },
+    // 情绪稳定性总分 / 同龄平均分(单独成行展示,不与分测验混在同一量纲)
+    emotionSummary() {
+      var out = { total: '', avg: '' }
+      if (!this.emotionBlock) return out
+      var items = this.emotionBlock.items || []
+      for (var i = 0; i < items.length; i++) {
+        var n = String(items[i].name)
+        if (n.indexOf('总得分') >= 0) out.total = items[i].value
+        else if (n.indexOf('平均分') >= 0) out.avg = items[i].value
+      }
+      return out
+    },
+    // 体质健康块(身高/体重/BMI + 饮食/睡眠/运动习惯)
+    bodyBlock() {
+      for (var i = 0; i < this.blocks.length; i++) {
+        var b = this.blocks[i]
+        if (b.type !== 'indicator' || !b.items || !b.items.length) continue
+        var t = String(b.title || '')
+        if (t.indexOf('体质') >= 0 || t.indexOf('健康') >= 0 || t.indexOf('身体') >= 0) {
+          return b
+        }
+      }
+      return null
+    },
+    bodyBlockItems() {
+      var self = this
+      if (!this.bodyBlock) return []
+      return (this.bodyBlock.items || []).filter(function(it) {
+        return !self.isNoise(it.name)
+      })
+    },
+    // 体质卡片上部:数值型指标(身高/体重/BMI)
+    bodyBaseMetrics() {
+      return this.bodyBlockItems.filter(function(it) {
+        var n = String(it.name)
+        return n.indexOf('身高') >= 0 || n.indexOf('体重') >= 0 || n.indexOf('BMI') >= 0
+      }).map(function(it) {
+        var name = String(it.name)
+        var unit = ''
+        if (name.indexOf('身高') >= 0) unit = 'cm'
+        else if (name.indexOf('体重') >= 0) unit = 'kg'
+        return { name: name, value: it.value, unit: unit }
+      })
+    },
+    // 体质卡片下部:等级与生活习惯(文本/量纲不一,用列表行展示)
+    bodyHabitMetrics() {
+      return this.bodyBlockItems.filter(function(it) {
+        var n = String(it.name)
+        return n.indexOf('身高') < 0 && n.indexOf('体重') < 0 && n.indexOf('BMI') < 0
+      })
+    },
     // 其他指标("其他指标"块,过滤噪音,提取身高体重等)
     otherMetrics() {
       for (var i = 0; i < this.blocks.length; i++) {
@@ -321,6 +481,7 @@ export default {
       if (this.abilityBlock) taken.push(this.abilityBlock)
       if (this.bigFiveBlock) taken.push(this.bigFiveBlock)
       if (this.emotionBlock) taken.push(this.emotionBlock)
+      if (this.bodyBlock) taken.push(this.bodyBlock)
       var self = this
       return this.blocks.filter(function(b) {
         if (b.type !== 'indicator') return false
@@ -328,12 +489,13 @@ export default {
         if (b.title && String(b.title).indexOf('其他') >= 0) return false
         return b.items && b.items.length > 0
       }).map(function(b) {
+        var title = String(b.title || '')
         return {
           title: b.title,
           metrics: (b.items || []).filter(function(it) {
             return !self.isNoise(it.name)
           }).map(function(it) {
-            return self.toMetric(it)
+            return self.toMetric(it, title)
           })
         }
       }).filter(function(b) { return b.metrics.length > 0 })
@@ -370,20 +532,28 @@ export default {
     resolveMax(name, category) {
       if (name && String(name).indexOf('百分位') >= 0) return 100
       if (category === 'bigFive') return 5
+      if (category === 'social') return 100
       if (PERCENT_CATEGORIES.indexOf(category) >= 0) return 100
       return 10
     },
-    toMetric(it) {
+    // blockTitle:调用方(通用指标块)可显式指定量纲,如社会关系传 100
+    toMetric(it, blockTitle) {
       var v = parseFloat(it.value)
       if (isNaN(v)) {
-        return { name: it.name, value: it.value, pct: 0, warn: false }
+        // 非数值(如依恋类型「安全型/安全型/安全型」)不画条,仅展示取值
+        return { name: it.name, value: it.value, pct: 0, warn: false, nonNumeric: true }
       }
       var cat = this.catMap[it.name] || ''
-      var max = this.resolveMax(it.name, cat)
+      var max = null
+      if (blockTitle) {
+        var t = String(blockTitle)
+        if (t.indexOf('社会') >= 0 || t.indexOf('关系') >= 0) max = 100
+      }
+      if (max === null) max = this.resolveMax(it.name, cat)
       var pct = Math.min(100, Math.max(0, v / max * 100))
       // 仅百分位制指标(A2 认知等)低于 50 才告警;人格/自我概念等非能力项不告警
       var warn = (max === 100) && v < 50
-      return { name: it.name, value: it.value, pct: Math.round(pct), warn: warn }
+      return { name: it.name, value: it.value, pct: Math.round(pct), warn: warn, nonNumeric: false }
     },
     loadData() {
       var self = this
@@ -570,6 +740,12 @@ export default {
   position: absolute; top: -4rpx; width: 2rpx; height: 24rpx;
   background: #C7C9D9; border-radius: 1rpx;
 }
+/* 非数值指标行(名称 + 取值,无进度条) */
+.metric-plain-row {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 10rpx 0;
+}
+.metric-plain-value { font-size: 26rpx; color: #4F46E5; font-weight: 600; }
 
 /* ==================== 大五人格 ==================== */
 .bf-row { margin-bottom: 24rpx; }
@@ -601,6 +777,35 @@ export default {
 .emo-track { flex: 1; height: 12rpx; border-radius: 6rpx; background: #F0ECFA; overflow: hidden; }
 .emo-fill { height: 100%; border-radius: 6rpx; background: linear-gradient(90deg, #A78BFA, #7C3AED); }
 
+/* ==================== 雷达图容器 ==================== */
+.radar-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+
+/* ==================== 情绪总分行 ==================== */
+.emo-summary {
+  background: #F5F3FF;
+  border-radius: 14rpx;
+  padding: 16rpx 20rpx;
+  margin-bottom: 20rpx;
+}
+.emo-summary-text { font-size: 24rpx; color: #6D28D9; }
+
+/* ==================== 体质健康 ==================== */
+.habit-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 14rpx 0;
+  border-bottom: 1rpx solid #F1F2F7;
+}
+.habit-row:last-child { border-bottom: none; }
+.habit-name { font-size: 26rpx; color: #374151; }
+.habit-value { font-size: 26rpx; color: #4F46E5; font-weight: 600; }
+
 /* ==================== 基本信息 ==================== */
 .info-grid { display: flex; flex-wrap: wrap; }
 .info-cell {