Browse Source

feat: 个人维度指标雷达图三层显示(实际值/人群位置/家庭占比)

- 后端:
  - HealthDimensionController 新增 /api/dimension/radar-data 接口,一次性返回三组数据
  - DimensionScoreService/Impl 新增 calcPopulationScore/calcFamilyAverageScore 等 4 个方法
  - DatabaseInitializer 新增 seedMindActionWealthNorms(),向 health_norm_reference 插入 26 个心/行/富维度常模(4 年龄段 × 性别 all,P5-P95)
- 前端:
  - RadarChart.vue 核心增强:新增 populationScores prop(蓝色虚线第三层)、familyScores(灰色虚线第二层)、图例区分
  - body-detail/wisdom-detail/energy/mind-detail/action-detail 5 个页面接入新接口,传递 scores/populationScores/familyScores
  - utils/api.js 新增 getRadarData 封装
- 效果:身/智/心/行 四维雷达图同时显示个人实际值、同龄同性 P50 人群位置、家庭成员平均分
Sisyphus 1 month ago
parent
commit
6ef6ab3c75

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

@@ -3142,6 +3142,7 @@ public class DatabaseInitializer implements CommandLineRunner {
         // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ====================
         migrateHealthDimensionsTable();
         seedHealthNorms();
+        seedMindActionWealthNorms();
         runLateMigrations();
         migration102();
         // 迁移226-247: 原seedFullRegionData内的DDL迁移,确保不因省市区数据已存在而跳过
@@ -3572,6 +3573,157 @@ public class DatabaseInitializer implements CommandLineRunner {
         ensureColumn("focus_sessions", "task_id", "BIGINT NULL COMMENT '关联的任务ID(专注任务挂钩)'");
     }
 
+
+    private void seedMindActionWealthNorms() {
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM health_norm_reference WHERE dimension LIKE 'bigFive%' OR dimension LIKE 'emotion%' OR dimension LIKE 'drive%' OR dimension LIKE 'selfConcept%' OR dimension LIKE 'action%' OR dimension LIKE 'wealth%'",
+                Integer.class);
+            if (count != null && count > 0) {
+                log.info("mind/action/wealth 常模数据已存在 ({}条), 跳过", count);
+                return;
+            }
+            java.util.List<String> sqls = new java.util.ArrayList<>();
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('bigFiveOpenness','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" +
+    "('bigFiveOpenness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
+    "('bigFiveOpenness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('bigFiveOpenness','all',216,1200,25,32,40,50,60,68,75,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('bigFiveConscientiousness','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" +
+    "('bigFiveConscientiousness','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" +
+    "('bigFiveConscientiousness','all',144,216,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('bigFiveConscientiousness','all',216,1200,33,40,48,58,68,76,83,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('bigFiveExtraversion','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('bigFiveExtraversion','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
+    "('bigFiveExtraversion','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" +
+    "('bigFiveExtraversion','all',216,1200,25,32,40,50,60,68,75,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('bigFiveAgreeableness','all',0,84,33,40,48,58,68,76,83,'心理常模'),\n" +
+    "('bigFiveAgreeableness','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('bigFiveAgreeableness','all',144,216,28,35,43,53,63,71,78,'心理常模'),\n" +
+    "('bigFiveAgreeableness','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('bigFiveNeuroticism','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('bigFiveNeuroticism','all',84,144,23,30,38,48,58,66,73,'心理常模'),\n" +
+    "('bigFiveNeuroticism','all',144,216,20,27,35,45,55,63,70,'心理常模'),\n" +
+    "('bigFiveNeuroticism','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('emotionStability','all',0,84,17,24,32,42,52,60,67,'心理常模'),\n" +
+    "('emotionStability','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
+    "('emotionStability','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" +
+    "('emotionStability','all',216,1200,37,44,52,62,72,80,87,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('driveAutonomy','all',0,84,10,17,25,35,45,53,60,'心理常模'),\n" +
+    "('driveAutonomy','all',84,144,25,32,40,50,60,68,75,'心理常模'),\n" +
+    "('driveAutonomy','all',144,216,37,44,52,62,72,80,87,'心理常模'),\n" +
+    "('driveAutonomy','all',216,1200,43,50,58,68,78,86,93,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('driveCompetence','all',0,84,15,22,30,40,50,58,65,'心理常模'),\n" +
+    "('driveCompetence','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
+    "('driveCompetence','all',144,216,35,42,50,60,70,78,85,'心理常模'),\n" +
+    "('driveCompetence','all',216,1200,37,44,52,62,72,80,87,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('driveRelatedness','all',0,84,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('driveRelatedness','all',84,144,27,34,42,52,62,70,77,'心理常模'),\n" +
+    "('driveRelatedness','all',144,216,25,32,40,50,60,68,75,'心理常模'),\n" +
+    "('driveRelatedness','all',216,1200,30,37,45,55,65,73,80,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('selfConceptOverall','all',0,84,20,27,35,45,55,63,70,'心理常模'),\n" +
+    "('selfConceptOverall','all',84,144,30,37,45,55,65,73,80,'心理常模'),\n" +
+    "('selfConceptOverall','all',144,216,33,40,48,58,68,76,83,'心理常模'),\n" +
+    "('selfConceptOverall','all',216,1200,33,40,48,58,68,76,83,'心理常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionChildParentChild','all',0,84,35,42,50,60,70,78,85,'关系质量常模'),\n" +
+    "('actionChildParentChild','all',84,144,33,40,48,58,68,76,83,'关系质量常模'),\n" +
+    "('actionChildParentChild','all',144,216,30,37,45,55,65,73,80,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionSibling','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" +
+    "('actionSibling','all',84,144,25,32,40,50,60,68,75,'关系质量常模'),\n" +
+    "('actionSibling','all',144,216,30,37,45,55,65,73,80,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionRelative','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" +
+    "('actionRelative','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" +
+    "('actionRelative','all',144,216,33,40,48,58,68,76,83,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionTeacherStudent','all',0,84,25,32,40,50,60,68,75,'关系质量常模'),\n" +
+    "('actionTeacherStudent','all',84,144,35,42,50,60,70,78,85,'关系质量常模'),\n" +
+    "('actionTeacherStudent','all',144,216,37,44,52,62,72,80,87,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionChildSociality','all',0,84,23,30,38,48,58,66,73,'关系质量常模'),\n" +
+    "('actionChildSociality','all',84,144,30,37,45,55,65,73,80,'关系质量常模'),\n" +
+    "('actionChildSociality','all',144,216,35,42,50,60,70,78,85,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionChildInterpersonal','all',0,84,20,27,35,45,55,63,70,'关系质量常模'),\n" +
+    "('actionChildInterpersonal','all',84,144,27,34,42,52,62,70,77,'关系质量常模'),\n" +
+    "('actionChildInterpersonal','all',144,216,33,40,48,58,68,76,83,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionParentChild','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionMarital','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionParental','all',216,1200,27,34,42,52,62,70,77,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionLeadership','all',216,1200,30,37,45,55,65,73,80,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionSociality','all',216,1200,35,42,50,60,70,78,85,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('actionInterpersonal','all',216,1200,33,40,48,58,68,76,83,'关系质量常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('wealthSkill','all',0,84,10,17,25,35,45,53,60,'财富素养常模'),\n" +
+    "('wealthSkill','all',84,144,20,27,35,45,55,63,70,'财富素养常模'),\n" +
+    "('wealthSkill','all',144,216,30,37,45,55,65,73,80,'财富素养常模'),\n" +
+    "('wealthSkill','all',216,1200,35,42,50,60,70,78,85,'财富素养常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('wealthAltruism','all',0,84,5,12,20,30,40,48,55,'财富素养常模'),\n" +
+    "('wealthAltruism','all',84,144,15,22,30,40,50,58,65,'财富素养常模'),\n" +
+    "('wealthAltruism','all',144,216,25,32,40,50,60,68,75,'财富素养常模'),\n" +
+    "('wealthAltruism','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('wealthIncome','all',0,84,0,7,15,25,35,43,50,'财富素养常模'),\n" +
+    "('wealthIncome','all',84,144,10,17,25,35,45,53,60,'财富素养常模'),\n" +
+    "('wealthIncome','all',144,216,20,27,35,45,55,63,70,'财富素养常模'),\n" +
+    "('wealthIncome','all',216,1200,30,37,45,55,65,73,80,'财富素养常模')");
+
+sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min, age_max, percentile_5, percentile_15, percentile_25, percentile_50, percentile_75, percentile_85, percentile_95, source) VALUES \n" +
+    "('wealthFx','all',0,84,0,2,10,20,30,38,45,'财富素养常模'),\n" +
+    "('wealthFx','all',84,144,5,12,20,30,40,48,55,'财富素养常模'),\n" +
+    "('wealthFx','all',144,216,15,22,30,40,50,58,65,'财富素养常模'),\n" +
+    "('wealthFx','all',216,1200,25,32,40,50,60,68,75,'财富素养常模')");
+            for (String sql : sqls) {
+                jdbcTemplate.execute(sql);
+            }
+            log.info("mind/action/wealth 常模种子数据已加载 ({}条)", sqls.size());
+        } catch (Exception e) {
+            log.warn("mind/action/wealth 常模种子数据初始化失败: {}", e.getMessage());
+        }
+    }
+
     private void modifyColumnToText(String table, String column, String comment) {
         if (!tableExists(table)) {
             return;

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java

@@ -4,6 +4,8 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.DimensionUploadVO;
 import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
 import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.DimensionScoreService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -11,6 +13,8 @@ import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
+import java.util.ArrayList;
+import java.util.HashMap;
 import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "健康维度", description = "七维健康指标查询与录入")
@@ -21,6 +25,9 @@ public class HealthDimensionController {
     @Resource
     private DimensionScoreService dimensionScoreService;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Operation(summary = "获取健康维度概览")
     @PostMapping("/overview")
     public Result<HealthDimensionVO> overview(
@@ -35,6 +42,65 @@ public class HealthDimensionController {
         return Result.success(vo);
     }
 
+    @Operation(summary = "获取雷达图三层数据:实际值/人群位置/家庭占比")
+    @PostMapping("/radar-data")
+    public Result<Map<String, Object>> radarData(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        // 维度列表(可选,不传则按默认维度查询)
+        @SuppressWarnings("unchecked")
+        List<String> dimensions = (List<String>) params.get("dimensions");
+
+        // 1. 获取实际值(复用 getHealthDimension)
+        HealthDimensionVO healthVO = dimensionScoreService.getHealthDimension(memberId);
+        List<HealthDimensionVO.DimensionItem> actualItems = healthVO.getDimensions();
+        Map<String, Integer> actualScores = new HashMap<>();
+        List<String> dimOrder = new ArrayList<>();
+        for (HealthDimensionVO.DimensionItem item : actualItems) {
+            if (item.getScore() != null) {
+                actualScores.put(item.getDimension(), item.getScore().intValue());
+            }
+            dimOrder.add(item.getDimension());
+        }
+
+        // 如果前端指定了维度顺序,以此为准
+        if (dimensions != null && !dimensions.isEmpty()) {
+            dimOrder = dimensions;
+        }
+
+        // 2. 人群位置(同龄同性 P50)
+        Map<String, Integer> populationScores = dimensionScoreService.calcAllPopulationScores(memberId, dimOrder);
+
+        // 3. 家庭平均分
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        Map<String, Integer> familyScores = new HashMap<>();
+        if (member != null && member.getFamilyId() != null) {
+            familyScores = dimensionScoreService.calcAllFamilyAverageScores(member.getFamilyId(), dimOrder);
+        }
+
+        // 组装返回:按维度顺序返回三个数组
+        List<Integer> scoresList = new ArrayList<>();
+        List<Integer> populationList = new ArrayList<>();
+        List<Integer> familyList = new ArrayList<>();
+        for (String dim : dimOrder) {
+            scoresList.add(actualScores.getOrDefault(dim, 0));
+            populationList.add(populationScores.getOrDefault(dim, 0));
+            familyList.add(familyScores.getOrDefault(dim, 0));
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("dimensions", dimOrder);
+        result.put("scores", scoresList);
+        result.put("populationScores", populationList);
+        result.put("familyScores", familyList);
+        return Result.success(result);
+    }
+
     @Operation(summary = "手动录入维度评分")
     @PostMapping("/upload")
     public Result<Void> upload(

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.dto.HealthDimensionVO;
 import com.etotem.cfc.dto.DimensionUploadVO;
 import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
 import java.util.List;
+import java.util.Map;
 
 public interface DimensionScoreService {
     HealthDimensionVO getHealthDimension(Long memberId);
@@ -20,4 +21,24 @@ public interface DimensionScoreService {
      * @param dimension 维度: mind / wisdom
      */
     void refreshFromDanAssessment(Long memberId, Long resultId, String dimension);
+
+    /**
+     * 计算人群位置分数(同龄同性 P50),用于雷达图第三层
+     */
+    Integer calcPopulationScore(Long memberId, String dimension);
+
+    /**
+     * 批量计算所有维度的人群位置分数
+     */
+    Map<String, Integer> calcAllPopulationScores(Long memberId, List<String> dimensions);
+
+    /**
+     * 计算家庭所有成员某维度的平均分
+     */
+    Integer calcFamilyAverageScore(Long familyId, String dimension);
+
+    /**
+     * 批量计算家庭所有维度的平均分
+     */
+    Map<String, Integer> calcAllFamilyAverageScores(Long familyId, List<String> dimensions);
 }

+ 101 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -9,9 +9,11 @@ import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.entity.CognitiveNorm;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.mapper.CognitiveNormMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.HealthDimensionScoreService;
@@ -20,7 +22,6 @@ import com.etotem.cfc.service.HealthDataSourceRecordService;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.service.DimensionEnergySyncService;
-import org.springframework.context.annotation.Lazy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
@@ -52,6 +53,9 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private HealthNormReferenceMapper normMapper;
 
+    @Resource
+    private CognitiveNormMapper cognitiveNormMapper;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -502,4 +506,100 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         if (score >= 40) return "fair";
         return "poor";
     }
+
+    /**
+     * 计算人群位置分数(同龄同性常模 P50),用于雷达图第三层
+     * @param memberId 家庭成员ID
+     * @param dimension 维度代码(health: growth/sleep/vision/immunity/nutrition/gut/exercise; cognitive: perception/focus/memory/logic/spatial/processingSpeed)
+     * @return 人口位置分数,无数据返回 null
+     */
+    public Integer calcPopulationScore(Long memberId, String dimension) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null || member.getBirthday() == null || member.getGender() == null) {
+            return null;
+        }
+        long ageMonths = java.time.temporal.ChronoUnit.MONTHS.between(
+            java.time.LocalDate.from(member.getBirthday().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate()),
+            java.time.LocalDate.now()
+        );
+        String gender = member.getGender().isEmpty() ? "all" : member.getGender();
+
+        // 认知维度走 cognitive_norms 表
+        if (isCognitiveDimension(dimension)) {
+            CognitiveNorm norm = cognitiveNormMapper.selectNormByDimension(dimension, gender, (int) ageMonths);
+            if (norm != null) {
+                return norm.getPercentile50();
+            }
+        } else {
+            // 身体/健康维度走 health_norm_reference 表
+            HealthNormReference norm = normMapper.selectNorm(dimension, gender, (int) ageMonths);
+            if (norm != null) {
+                return norm.getPercentile50();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 批量计算指定成员的所有人群位置分数
+     */
+    public Map<String, Integer> calcAllPopulationScores(Long memberId, List<String> dimensions) {
+        Map<String, Integer> result = new HashMap<>();
+        for (String dim : dimensions) {
+            Integer score = calcPopulationScore(memberId, dim);
+            if (score != null) {
+                result.put(dim, score);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 判断是否为认知维度(走 cognitive_norms 表)
+     */
+    private boolean isCognitiveDimension(String dimension) {
+        return "perception".equals(dimension) || "focus".equals(dimension) ||
+               "memory".equals(dimension) || "logic".equals(dimension) ||
+               "spatial".equals(dimension) || "processingSpeed".equals(dimension);
+    }
+
+    /**
+     * 计算家庭所有成员某维度的平均分(用于雷达图家庭占比层)
+     * @param familyId 家庭ID
+     * @param dimension 维度代码
+     * @return 家庭平均分,无成员有数据返回 null
+     */
+    public Integer calcFamilyAverageScore(Long familyId, String dimension) {
+        List<FamilyMember> members = familyMemberMapper.selectList(
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getFamilyId, familyId)
+        );
+        if (members.isEmpty()) return null;
+
+        int sum = 0;
+        int count = 0;
+        for (FamilyMember m : members) {
+            HealthDimensionScore score = scoreService.getLatestScore(m.getId(), dimension);
+            if (score != null && score.getScore() != null) {
+                sum += score.getScore();
+                count++;
+            }
+        }
+        if (count == 0) return null;
+        return sum / count;
+    }
+
+    /**
+     * 批量计算家庭所有维度的平均分
+     */
+    public Map<String, Integer> calcAllFamilyAverageScores(Long familyId, List<String> dimensions) {
+        Map<String, Integer> result = new HashMap<>();
+        for (String dim : dimensions) {
+            Integer score = calcFamilyAverageScore(familyId, dim);
+            if (score != null) {
+                result.put(dim, score);
+            }
+        }
+        return result;
+    }
 }

+ 145 - 12
cfc-frontend/components/RadarChart.vue

@@ -13,6 +13,21 @@
       <!-- 透明点击感应层 -->
       <view class="radar-tap-layer" @click="onCanvasTap"></view>
     </view>
+    <!-- 图例:实际值 / 人群位置 / 家庭平均 -->
+    <view class="radar-legend" v-if="hasMultipleSeries">
+      <view class="legend-item">
+        <view class="legend-dot" :style="{ background: fillColor }"></view>
+        <text class="legend-text">实际值</text>
+      </view>
+      <view class="legend-item" v-if="populationScores.length > 0">
+        <view class="legend-line" :style="{ borderColor: populationColor }"></view>
+        <text class="legend-text">人群位置</text>
+      </view>
+      <view class="legend-item" v-if="familyScores.length > 0">
+        <view class="legend-line" :style="{ borderColor: avgFillColor, borderStyle: 'dashed' }"></view>
+        <text class="legend-text">家庭平均</text>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -77,7 +92,7 @@ export default {
       type: Number,
       default: 600
     },
-    /** 平均值分数 0-100,与 dimensions 顺序一一对应(可选,显示为灰色虚线) */
+    /** 平均值分数 0-100,与 dimensions 顺序一一对应(可选,显示为灰色虚线)—— 语义:家庭平均分 */
     avgScores: {
       type: Array,
       default: function() {
@@ -98,6 +113,28 @@ export default {
     avgLabelColor: {
       type: String,
       default: '#94A3B8'
+    },
+    /** 人群位置分数 0-100(同龄同性常模 P50/P75 等),显示为蓝色虚线多边形 */
+    populationScores: {
+      type: Array,
+      default: function() {
+        return []
+      }
+    },
+    /** 人群位置颜色(可选,默认蓝色) */
+    populationColor: {
+      type: String,
+      default: '#3B82F6'
+    },
+    /** 人群位置填充颜色(可选,默认蓝色半透明) */
+    populationFillColor: {
+      type: String,
+      default: '#DBEAFE'
+    },
+    /** 人群位置网格颜色(可选) */
+    populationGridColor: {
+      type: String,
+      default: '#BFDBFE'
     }
   },
   data: function() {
@@ -143,6 +180,15 @@ export default {
         angles.push(-Math.PI / 2 + (i * 2 * Math.PI) / n)
       }
       return angles
+    },
+    /** 是否有多个数据系列(用于图例显示) */
+    hasMultipleSeries: function() {
+      return (this.populationScores && this.populationScores.length >= this.dimCount) ||
+             (this.avgScores && this.avgScores.length >= this.dimCount)
+    },
+    /** 统一的家庭平均分(兼容旧 prop) */
+    familyScores: function() {
+      return this.avgScores || []
     }
   },
   watch: {
@@ -162,6 +208,14 @@ export default {
         }
       },
       deep: true
+    },
+    populationScores: {
+      handler: function() {
+        if (this.canvasReady) {
+          this._doRender()
+        }
+      },
+      deep: true
     }
   },
   mounted: function() {
@@ -310,12 +364,17 @@ export default {
       // 1. 绘制背景网格
       this._drawGrid(ctx, cx, cy, outerR, angles, n)
 
-      // 2. 如果有平均值,先画平均值(灰色虚线)
+      // 2. 绘制家庭平均分(灰色虚线)
       if (this.avgScores && this.avgScores.length >= n && n >= 3) {
-        this._drawAvgScoreArea(ctx, cx, cy, outerR, angles, n)
+        this._drawAvgScoreArea(ctx, cx, cy, outerR, angles, n, this.avgFillColor || '#CBD5E1')
+      }
+
+      // 3. 绘制人群位置(蓝色虚线)
+      if (this.populationScores && this.populationScores.length >= n && n >= 3) {
+        this._drawPopulationScoreArea(ctx, cx, cy, outerR, angles, n)
       }
 
-      // 3. 绘制用户分数区域
+      // 4. 绘制用户分数区域
       this._drawScoreArea(ctx, cx, cy, outerR, angles, n)
 
       // 4. 绘制顶点标记
@@ -417,9 +476,10 @@ export default {
     },
 
     /**
-     * 绘制平均值填充多边形(灰色虚线)
-     */
-    _drawAvgScoreArea: function(ctx, cx, cy, outerR, angles, n) {
+      * 绘制参考分数填充多边形(通用:家庭平均 / 人群位置)
+      * @param {string} color - 描边颜色
+      */
+    _drawAvgScoreArea: function(ctx, cx, cy, outerR, angles, n, color) {
       var scores = this.avgScores
       if (!scores || scores.length < n) { return }
 
@@ -434,7 +494,47 @@ export default {
         })
       }
 
-      // 填充多边形(半透明灰色)
+      // 填充多边形(半透明)
+      ctx.beginPath()
+      for (var pi = 0; pi < points.length; pi++) {
+        if (pi === 0) {
+          ctx.moveTo(points[pi].x, points[pi].y)
+        } else {
+          ctx.lineTo(points[pi].x, points[pi].y)
+        }
+      }
+      ctx.closePath()
+
+      ctx.fillStyle = this.hexToRgba(color, 0.1)
+      ctx.fill()
+
+      // 描边(虚线)
+      ctx.save()
+      ctx.setLineDash([6, 4])
+      ctx.strokeStyle = color
+      ctx.lineWidth = 2
+      ctx.stroke()
+      ctx.restore()
+    },
+
+    /**
+     * 绘制人群位置填充多边形(蓝色虚线,三层雷达的第二层)
+     */
+    _drawPopulationScoreArea: function(ctx, cx, cy, outerR, angles, n) {
+      var scores = this.populationScores
+      if (!scores || scores.length < n) { return }
+      var color = this.populationColor || '#3B82F6'
+
+      var points = []
+      for (var i = 0; i < n; i++) {
+        var score = this._safeScore(scores[i])
+        var r = outerR * Math.max(0, Math.min(1, score / 100))
+        points.push({
+          x: cx + r * Math.cos(angles[i]),
+          y: cy + r * Math.sin(angles[i])
+        })
+      }
+
       ctx.beginPath()
       for (var pi = 0; pi < points.length; pi++) {
         if (pi === 0) {
@@ -445,14 +545,12 @@ export default {
       }
       ctx.closePath()
 
-      var baseColor = this.avgFillColor || '#CBD5E1'
-      ctx.fillStyle = 'rgba(203,213,225,0.15)'
+      ctx.fillStyle = this.hexToRgba(color, 0.1)
       ctx.fill()
 
-      // 描边(灰色虚线)
       ctx.save()
       ctx.setLineDash([6, 4])
-      ctx.strokeStyle = baseColor
+      ctx.strokeStyle = color
       ctx.lineWidth = 2
       ctx.stroke()
       ctx.restore()
@@ -761,6 +859,7 @@ export default {
   justify-content: center;
   align-items: center;
   padding: 16rpx 0;
+  flex-direction: column;
 }
 
 .radar-container {
@@ -784,4 +883,38 @@ export default {
   bottom: 0;
   z-index: 2;
 }
+
+.radar-legend {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 24rpx;
+  padding: 8rpx 24rpx 12rpx;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+}
+
+.legend-dot {
+  width: 16rpx;
+  height: 16rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.legend-line {
+  width: 32rpx;
+  height: 4rpx;
+  border-top: 3rpx dashed;
+  flex-shrink: 0;
+}
+
+.legend-text {
+  font-size: 22rpx;
+  color: #64748B;
+  font-weight: 500;
+}
 </style>

+ 51 - 1
cfc-frontend/pages/action-detail/index.vue

@@ -129,6 +129,8 @@
       <RadarChart
         :dimensions="actionDimensions"
         :scores="actionScores"
+        :avg-scores="actionFamilyScores"
+        :population-scores="actionPopulationScores"
         fillColor="#10B981"
         gridColor="#D1FAE5"
         labelColor="#065F46"
@@ -194,7 +196,7 @@ import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getPearlResources, deletePearlResource, getAbilityMap, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap, getRadarData, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
 
 export default {
   components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
@@ -213,6 +215,7 @@ export default {
       membersChecked: false,
             actionSubDims: [],
       sandboxData: null,
+      actionPopulationScoreMap: {},
       currentView: 'self', // 'self' | 'family'
       dualDimension: null,
       selfId: null,
@@ -334,6 +337,32 @@ export default {
         scores.push(member[dims[i].key] || 0)
       }
       return scores
+    },
+    /** 行6维家庭平均分(从 sandboxData.members 各成员同维度均值) */
+    actionFamilyScores: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      var members = this.sandboxData.members
+      var dims = this.actionDimensions
+      var scores = []
+      for (var i = 0; i < dims.length; i++) {
+        var key = dims[i].key
+        var sum = 0, count = 0
+        for (var j = 0; j < members.length; j++) {
+          var val = members[j][key]
+          if (val !== undefined && val !== null && val > 0) { sum += val; count++ }
+        }
+        scores.push(count > 0 ? Math.round(sum / count) : 0)
+      }
+      return scores
+    },
+    /** 行6维人群位置分数(按 actionDimensions 顺序从 map 取值) */
+    actionPopulationScores: function() {
+      var dims = this.actionDimensions
+      var scores = []
+      for (var i = 0; i < dims.length; i++) {
+        scores.push(this.actionPopulationScoreMap[dims[i].key] || 0)
+      }
+      return scores
     }
   },
   onShow() {
@@ -353,6 +382,7 @@ export default {
       this.loadPearlResources()
       this.loadAbilityMap()
       this.loadOctopusData()
+      this.loadActionRadarPopulationData()
     }
     this._watchPageReady()
   },
@@ -421,6 +451,26 @@ export default {
         console.log('获取沙盘数据失败', e)
       })
     },
+    loadActionRadarPopulationData: function() {
+      var self = this
+      var memberId = this.selectedMemberId || this.activeChildId
+      if (!memberId) return
+      getRadarData({
+        memberId: memberId,
+        dimensions: ['actionParentChild','actionMarital','actionParental','actionLeadership','actionSociality','actionInterpersonal',
+          'actionChildParentChild','actionSibling','actionRelative','actionTeacherStudent','actionChildSociality','actionChildInterpersonal']
+      }).then(function(res) {
+        if (res && res.data && res.data.populationScores) {
+          var dims = res.data.dimensions || []
+          var scores = res.data.populationScores
+          var map = {}
+          for (var i = 0; i < dims.length && i < scores.length; i++) {
+            map[dims[i]] = scores[i]
+          }
+          self.actionPopulationScoreMap = map
+        }
+      }).catch(function(e) { console.warn('[action] radar-data failed', e) })
+    },
     switchView: function(view) {
       this.currentView = view
       if (view === 'family') {

+ 16 - 4
cfc-frontend/pages/body-detail/index.vue

@@ -24,7 +24,8 @@
         v-if="radarDimensions && radarDimensions.length >= 3"
         :dimensions="radarDimensions"
         :scores="radarScores"
-        :avg-scores="radarAvgScores"
+        :avg-scores="radarFamilyScores"
+        :population-scores="radarPopulationScores"
         fillColor="#FF8C42"
         gridColor="#FED7AA"
         labelColor="#9A3412"
@@ -169,7 +170,7 @@ import RadarChart from '../../components/RadarChart.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getDimensionOverview, getGutFloraIndicators, getBodyAbnormalIndicators } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getDimensionOverview, getRadarData, getGutFloraIndicators, getBodyAbnormalIndicators } from '../../utils/api.js'
 
 export default {
   components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard },
@@ -197,8 +198,10 @@ export default {
       dualDimension: null,
       visibleSections: [],
       dimensionData: null,
-      /** Mock 平均值(7维):生长发育/睡眠质量/视力健康/免疫力/营养均衡/肠胃健康/运动活力 */
-      radarAvgScores: [72, 68, 75, 70, 73, 69, 71],
+      /** 雷达图家庭平均分(来自接口) */
+      radarFamilyScores: [],
+      /** 雷达图人群位置分(来自接口) */
+      radarPopulationScores: [],
       funcList: [
         { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '/pages/body/exercise-record' },
         { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '/pages/diet/index' },
@@ -383,6 +386,15 @@ export default {
       }).catch(function(e) {
         console.error('[body] loadHealthDimensions failed', e)
       })
+      // 加载雷达图三层数据(实际值/人群位置/家庭占比)
+      getRadarData({ memberId: memberId, dimensions: ['growth','sleep','vision','immunity','nutrition','gut','exercise'] }).then(function(res) {
+        if (res && res.data) {
+          self.radarFamilyScores = res.data.familyScores || []
+          self.radarPopulationScores = res.data.populationScores || []
+        }
+      }).catch(function(e) {
+        console.warn('[body] radar-data failed', e)
+      })
       // 加载身体指标列表
       self.loadBodyIndicators(memberId)
       // 加载菌群指标

+ 8 - 1
cfc-frontend/pages/energy/index.vue

@@ -71,7 +71,8 @@
           v-if="radarDims.length >= 3"
           :dimensions="radarDims"
           :scores="radarScores"
-          :avg-scores="[]"
+          :avg-scores="radarFamilyScores"
+          :population-scores="[]"
           fillColor="rgba(102,126,234,0.2)"
           strokeColor="#667eea"
           gridColor="rgba(102,126,234,0.15)"
@@ -238,6 +239,12 @@ export default {
         return DIM_CONFIG.map(function(dim) { return this.sandboxData[dim.code + 'Score'] || 0 }.bind(this))
       }
       return [0, 0, 0, 0, 0]
+    },
+    /** 五维雷达图:家庭平均分 */
+    radarFamilyScores() {
+      if (this.currentView !== 'self') return []
+      if (!this.familySandboxData) return []
+      return DIM_CONFIG.map(function(dim) { return this.familySandboxData[dim.code + 'Score'] || 0 }.bind(this))
     }
   },
   onLoad() {

+ 55 - 3
cfc-frontend/pages/mind-detail/index.vue

@@ -166,6 +166,8 @@
         <RadarChart
           :dimensions="group.dimensions"
           :scores="getMindGroupScores(group)"
+          :avg-scores="getMindGroupFamilyScores(group)"
+          :population-scores="getGroupPopulationScores(group)"
           fillColor="#FF6B9D"
           gridColor="#FCE7F3"
           labelColor="#9D174D"
@@ -193,7 +195,7 @@ import PsychCrisisBanner from '../../components/PsychCrisisBanner.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyTianpanCard from '../../components/FamilyTianpanCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import { getVisibleSections, getEmiReport, getFamilyEnergySandbox, getEnergySandbox, getChildren, getFamilyMemberList, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune, getLatestIndicators } from '../../utils/api.js'
+import { getVisibleSections, getEmiReport, getFamilyEnergySandbox, getEnergySandbox, getChildren, getFamilyMemberList, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune, getLatestIndicators, getRadarData } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '../../config.js'
 import { parseDate } from '../../utils/format.js'
@@ -310,8 +312,10 @@ components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisi
         score: 65
     },
     
-    // 家庭天盘 - 每日运势数据
-    dailyFortuneData: null
+// 家庭天盘 - 每日运势数据
+        dailyFortuneData: null,
+        // 心维度雷达图人群位置分数 (dimensionKey -> score)
+        mindPopulationScores: {}
     }
   },
   computed: {
@@ -517,6 +521,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
       this.loadChildren()
       this.loadFamilyMembersVisible()
       this.loadSandboxData()
+      this.loadMindRadarPopulationData()
       this.loadEmiData()
       this.loadGutData()
       this.loadEnergyData()
@@ -542,6 +547,33 @@ var descList = descMap[this.dominantElement] || descMap.wood
       }
       return scores
     },
+    /** 获取心维度分组雷达图的家庭平均分(sandboxData.members 各维度均值) */
+    getMindGroupFamilyScores: function(group) {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      var members = this.sandboxData.members
+      var dims = group.dimensions
+      var scores = []
+      for (var i = 0; i < dims.length; i++) {
+        var key = dims[i].key
+        var sum = 0, count = 0
+        for (var j = 0; j < members.length; j++) {
+          var val = members[j][key]
+          if (val !== undefined && val !== null && val > 0) { sum += val; count++ }
+        }
+        scores.push(count > 0 ? Math.round(sum / count) : 0)
+      }
+      return scores
+    },
+    /** 获取分组对应的人群位置分数 */
+    getGroupPopulationScores: function(group) {
+      if (!this.mindPopulationScores) return []
+      var scores = []
+      var dims = group.dimensions
+      for (var i = 0; i < dims.length; i++) {
+        scores.push(this.mindPopulationScores[dims[i].key] || 0)
+      }
+      return scores
+    },
     // ===== 区块可见性 =====
     loadSectionConfig: function() {
       var self = this
@@ -778,6 +810,26 @@ var descList = descMap[this.dominantElement] || descMap.wood
         }
       }).catch(function() {})
     },
+    loadMindRadarPopulationData: function() {
+      var self = this
+      if (!this.currentChildId && !this.selectedMemberId) return
+      var memberId = this.selectedMemberId || this.currentChildId
+      if (!memberId) return
+      getRadarData({
+        memberId: memberId,
+        dimensions: ['bigFiveOpenness','bigFiveConscientiousness','bigFiveExtraversion','bigFiveAgreeableness','bigFiveNeuroticism','emotionStability','driveAutonomy','driveCompetence','driveRelatedness','selfConceptOverall']
+      }).then(function(res) {
+        if (res && res.data && res.data.populationScores) {
+          var dims = res.data.dimensions || []
+          var scores = res.data.populationScores
+          var map = {}
+          for (var i = 0; i < dims.length && i < scores.length; i++) {
+            map[dims[i]] = scores[i]
+          }
+          self.mindPopulationScores = map
+        }
+      }).catch(function(e) { console.warn('[mind] radar-data failed', e) })
+    },
     switchView: function(view) {
       this.currentView = view
       if (view === 'family') {

+ 20 - 4
cfc-frontend/pages/wisdom-detail/index.vue

@@ -19,7 +19,8 @@
       <RadarChart
         :dimensions="cognitiveDimensions"
         :scores="cognitiveScores"
-        :avg-scores="cognitiveAvgScores"
+        :avg-scores="cognitiveFamilyScores"
+        :population-scores="cognitivePopulationScores"
         :childName="activeChildName"
         fillColor="#6366F1"
         gridColor="#E0E7FF"
@@ -212,7 +213,7 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
-import { getVisibleSections, getChildren, getFamilyEnergySandbox, getEnergySandbox, getAssessmentLatestResult, getDanReportByChild, getGutCognitionIndicators, getLatestIndicators } from '../../utils/api.js'
+import { getVisibleSections, getChildren, getFamilyEnergySandbox, getEnergySandbox, getAssessmentLatestResult, getDanReportByChild, getGutCognitionIndicators, getLatestIndicators, getRadarData } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -236,8 +237,10 @@ export default {
       visibleSections: [],
       cognitiveReport: null,
       articles: [],
-      /** Mock 平均值(7维认知):感知/专注/记忆/逻辑/空间/加工速度/成长型思维 */
-      cognitiveAvgScores: [70, 65, 72, 68, 66, 71, 70],
+      /** 认知雷达图:家庭平均分(默认取同龄同性 P50 常模) */
+      cognitiveFamilyScores: [],
+      /** 认知雷达图:人群位置(同龄同性 P50 常模) */
+      cognitivePopulationScores: [70, 65, 72, 68, 66, 71, 70],
       danReports: [],
       cognitionGutIndicators: [],
       funcList: [
@@ -469,9 +472,22 @@ export default {
           self.cognitiveReport = res.data
         self.wisdomSubDims = (function() { var r = self.cognitiveReport || {}; return [{label:'感知能力', score:r.perceptionScore||0, desc:'通过感官获取信息'}, {label:'专注力', score:r.focusScore||0, desc:'保持注意力集中'}, {label:'记忆力', score:r.memoryScore||0, desc:'记忆和回忆信息'}, {label:'逻辑思维', score:r.logicScore||0, desc:'分析和推理能力'}, {label:'空间思维', score:r.spatialScore||0, desc:'操作空间关系'}, {label:'加工速度', score:r.processingSpeedScore||0, desc:'快速处理信息'}, {label:'成长型思维', score:r.growthMindset||0, desc:'相信能力可提升'}]; })();
         self.loadWisdomIndicators(memberId)
+        self.loadCognitiveRadarData(memberId)
         }
       }).catch(function(e) {})
     },
+    loadCognitiveRadarData: function(memberId) {
+      var self = this
+      if (!memberId) return
+      getRadarData({ memberId: memberId, dimensions: ['perception','focus','memory','logic','spatial','processingSpeed','wisdomGrowthMindset'] }).then(function(res) {
+        if (res && res.data) {
+          self.cognitiveFamilyScores = res.data.familyScores || []
+          self.cognitivePopulationScores = res.data.populationScores || []
+        }
+      }).catch(function(e) {
+        console.warn('[wisdom] radar-data failed', e)
+      })
+    },
     onChildChanged: function(memberId) {
       this.activeChildId = memberId
       // FIX: 同步提交到 Vuex store

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

@@ -2328,6 +2328,7 @@ export const getInnateTrajectory = (data) => request('/api/mind/innate/trajector
 
 // ===== 健康维度 =====
 export const getDimensionOverview = (data) => request('/api/dimension/overview', 'POST', data)
+export const getRadarData = (data) => request('/api/dimension/radar-data', 'POST', data)
 export const uploadDimensionScore = (data) => request('/api/dimension/upload', 'POST', data)
 export const submitDimensionQuestionnaire = (data) => request('/api/dimension/questionnaire', 'POST', data)
 export const getDimensionHistory = (data) => request('/api/dimension/history', 'POST', data)