Browse Source

feat: health-dimensions-phase1 implementation + compile fixes

Health Dimensions Phase 1 (health-dimensions-phase1.md):
- DB: health_dimension_score, health_data_source_record, health_norm_reference tables
- Entities: HealthDimensionScore, HealthDataSourceRecord, HealthNormReference
- Mappers: HealthDimensionScoreMapper, HealthDataSourceRecordMapper, HealthNormReferenceMapper
- Services: HealthDimensionScoreService, HealthDataSourceRecordService, DimensionScoreService
- Controller: HealthDimensionController with /overview, /upload, /questionnaire, /history endpoints
- Frontend: pages/body/health-dimensions.vue with CSS radar chart
- Gut report integration:肠道健康评分 saved as body dimension score
- API: getDimensionOverview, uploadDimensionScore

Compile fixes (pre-existing bugs blocking build):
- DimensionScoreVO: add 5-arg constructor for DEFAULT_DIMENSIONS
- HealthReportController: add BigDecimal import, Integer->BigDecimal conversion
- DatabaseInitializer: fix batchUpdate(Object[][]) -> Arrays.asList(Object[][])
- Result.ok() -> Result.success() in EmotionCheckinController, MindController, TraditionalMirrorController
- AIService: add moodScore, emotionType, stressLevel fields to EmotionCheckin entity

Other additions from previous session:
- EmotionCheckin flow (controller, service, mapper, DTOs)
- BaziReadingTemplate and NumSoulConfig entities with seed data
- HealthReportPermission entity and mapper

Backend compiles: mvn clean compile BUILD SUCCESS
Frontend: pages.json + api.js + health-dimensions.vue + food-list.vue
Sisyphus 4 months ago
parent
commit
a239a291f0
44 changed files with 2656 additions and 5 deletions
  1. 247 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 77 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java
  3. 24 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java
  5. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindController.java
  6. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/TraditionalMirrorController.java
  7. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionScoreVO.java
  8. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionUploadVO.java
  9. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmiReportDTO.java
  10. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinDTO.java
  11. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinVO.java
  12. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionStatsVO.java
  13. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionTrendVO.java
  14. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionQuestionnaireVO.java
  15. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionVO.java
  16. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/BaziReadingTemplate.java
  17. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/EmotionCheckin.java
  18. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDataSourceRecord.java
  19. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDimensionScore.java
  20. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthNormReference.java
  21. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportPermission.java
  22. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulConfig.java
  23. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/BaziReadingTemplateMapper.java
  24. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/EmotionCheckinMapper.java
  25. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.java
  26. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java
  27. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.java
  28. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportPermissionMapper.java
  29. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/NumSoulConfigMapper.java
  30. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  31. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java
  32. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EmiReportService.java
  33. 218 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java
  34. 63 4
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java
  35. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthDataSourceRecordService.java
  36. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthDimensionScoreService.java
  37. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  38. 177 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java
  39. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDataSourceRecordServiceImpl.java
  40. 48 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java
  41. 6 0
      cfc-frontend/pages.json
  42. 286 0
      cfc-frontend/pages/body/food-list.vue
  43. 605 0
      cfc-frontend/pages/body/health-dimensions.vue
  44. 4 0
      cfc-frontend/utils/api.js

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

@@ -2198,7 +2198,254 @@ log.info("已添加template_id列到tasks表");
             log.warn("nutrition_indicator_mapping 种子数据初始化失败: {}", e.getMessage());
         }
 
+        // ==================== 心版块 Phase 1: emotion_checkin 表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                    "user_id BIGINT NOT NULL COMMENT '打卡人用户ID', " +
+                    "emotion_type INT NOT NULL COMMENT '情绪类型: 1快乐/2平和平静/3平静/4有点不舒服/5难受/6烦躁/7生气/8焦虑/9恐惧', " +
+                    "emotion_name VARCHAR(20) COMMENT '情绪名称: happy/calm/peaceful/uncomfortable/sad/irritable/angry/anxious/fearful', " +
+                    "energy_level INT NOT NULL DEFAULT 3 COMMENT '能量状态: 1很低/2低/3中等/4高/5很高', " +
+                    "note TEXT COMMENT '打卡备注', " +
+                    "photo_url VARCHAR(500) COMMENT '照片URL', " +
+                    "favorite_thing VARCHAR(500) COMMENT '今日最喜欢的一件事', " +
+                    "points_awarded INT DEFAULT 0 COMMENT '奖励积分', " +
+                    "parent_visible TINYINT DEFAULT 1 COMMENT '家长可见: 1可见/0不可见', " +
+                    "checkin_date DATE NOT NULL COMMENT '打卡日期', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_child_id (child_id), " +
+                    "INDEX idx_user_id (user_id), " +
+                    "INDEX idx_checkin_date (checkin_date), " +
+                    "INDEX idx_child_date (child_id, checkin_date)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡表'");
+            log.info("已创建 emotion_checkin 情绪打卡表");
+        } catch (Exception e) {
+            log.warn("创建 emotion_checkin 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1a-2: DAN Schema 修复 ====================
+        // v1.2 EMI 心理子维度
+        String[] danEmiFields = {"emotion_score", "resilience_score", "stress_coping_score", "self_awareness_score"};
+        for (String field : danEmiFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI子维度 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+        // 大五人格维度
+        String[] danBigFiveFields = {"openness_score", "conscientiousness_score", "extraversion_score", "agreeableness_score", "neuroticism_score", "big_five_overall_score"};
+        for (String field : danBigFiveFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT '大五人格 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+        // EMI 维度(情绪商数评估字段)
+        String[] danEmiDimFields = {"emotion_management_score", "empathy_score", "social_adaptability_score", "self_motivation_score"};
+        for (String field : danEmiDimFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI维度 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+
+        // ==================== 1b-1: bazi_reading_template 五行命理解读表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS bazi_reading_template (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "element VARCHAR(20) NOT NULL COMMENT '五行: wood/fire/earth/metal/water', " +
+                    "condition VARCHAR(20) NOT NULL COMMENT '状态: strong/weak/balanced', " +
+                    "personal_trait TEXT COMMENT '性格特质描述', " +
+                    "mind_growth_advice TEXT COMMENT '心理成长建议', " +
+                    "daily_tip_template TEXT COMMENT '每日宜忌模板', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_element_condition (element, condition)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五行命理解读模板'");
+            log.info("已创建 bazi_reading_template 表");
+        } catch (Exception e) {
+            log.warn("创建 bazi_reading_template 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: numsoul_config 生命灵数配置表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_config (" +
+                    "number INT PRIMARY KEY COMMENT '灵数1-9', " +
+                    "title VARCHAR(50) NOT NULL COMMENT '称号', " +
+                    "keywords VARCHAR(200) COMMENT '性格关键词', " +
+                    "mind_growth_advice TEXT COMMENT '心理成长建议', " +
+                    "recommended_article_tags VARCHAR(200) COMMENT '关联文章标签', " +
+                    "color_hex VARCHAR(7) COMMENT '代表色' " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生命灵数配置'");
+            log.info("已创建 numsoul_config 表");
+        } catch (Exception e) {
+            log.warn("创建 numsoul_config 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: bazi_reading_template 种子数据 ====================
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM bazi_reading_template", Integer.class);
+            if (count == null || count == 0) {
+                jdbcTemplate.batchUpdate(
+                    "INSERT INTO bazi_reading_template (element, condition, personal_trait, mind_growth_advice, daily_tip_template) VALUES (?, ?, ?, ?, ?)",
+                    java.util.Arrays.asList(new Object[][]{
+                        {"wood", "strong", "性格坚韧、有进取心,具有领导力和开拓精神", "学会适当放松,避免过刚易折,培养弹性思维", "宜进行伸展运动,忌高强度冲突"},
+                        {"wood", "weak", "温和有礼、善于合作,但决策时容易犹豫", "增强自信心训练,培养果断决策能力", "宜与自然相处,忌过度迁就他人"},
+                        {"wood", "balanced", "性格中庸有度,既有韧性又有灵活性", "保持现状,适当拓展社交圈", "宜户外活动,忌久坐不动"},
+                        {"fire", "strong", "热情开朗、充满活力,富有感染力和创造力", "注意情绪管理,避免急躁冲动", "宜冷水刺激冷静,忌暴怒争执"},
+                        {"fire", "weak", "内敛含蓄、心思细腻,对艺术有敏锐感知", "适当表达情感,培养社交自信", "宜温暖陪伴,忌独处过久"},
+                        {"fire", "balanced", "性格温润有度,既有热情又懂克制", "保持兴趣爱好,适度拓展表达", "宜参与社交活动,忌情绪压抑"},
+                        {"earth", "strong", "稳重可靠、有责任心,是团队中的中流砥柱", "避免过度承担,学会说'不'", "宜适量运动,忌思虑过重"},
+                        {"earth", "weak", "随和友善、适应性强,容易与他人相处", "增强主见性,培养独立思考能力", "宜接触新事物,忌固守不变"},
+                        {"earth", "balanced", "性情敦厚、处事稳妥,人际关系和谐", "保持学习,适当挑战自我", "宜亲近自然,忌暴饮暴食"},
+                        {"metal", "strong", "意志坚定、追求完美,具有卓越的执行力", "学会灵活变通,避免过于严苛", "宜艺术鉴赏,忌过度批评"},
+                        {"metal", "weak", "温和寡言、善于倾听,是值得信赖的朋友", "增强表达勇气,培养自信心", "宜团体活动,忌过度自我封闭"},
+                        {"metal", "balanced", "为人正直、做事有度,不乏柔韧与包容", "保持开放心态,适度多元发展", "宜阅读学习,忌钻牛角尖"},
+                        {"water", "strong", "智慧深沉、善于谋略,具有出色洞察力", "避免过度缜密,适当放松心情", "宜静坐冥想,忌思虑过深"},
+                        {"water", "weak", "敏感细腻、有同理心,富有创作天赋", "增强抵抗力,培养情绪韧性", "宜温暖环境,忌受寒受凉"},
+                        {"water", "balanced", "性格圆融、随机应变,处事灵活得宜", "保持好奇心,适度扩展影响力", "宜游泳泡澡,忌情绪波动"},
+                    })
+                );
+                log.info("bazi_reading_template 种子数据已加载 (15条)");
+            }
+        } catch (Exception e) {
+            log.warn("bazi_reading_template 种子数据初始化失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: numsoul_config 种子数据 ====================
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM numsoul_config", Integer.class);
+            if (count == null || count == 0) {
+                jdbcTemplate.batchUpdate(
+                    "INSERT INTO numsoul_config (number, title, keywords, mind_growth_advice, recommended_article_tags, color_hex) VALUES (?, ?, ?, ?, ?, ?)",
+                    java.util.Arrays.asList(new Object[][]{
+                        {1, "开创者", "独立、领导、创新、自信", "培养团队协作意识,避免独断专行", "领导力,自信培养", "#FF4444"},
+                        {2, "和平者", "温和、合作、敏感、耐心", "增强自我主张,避免过度迎合他人", "人际交往,情绪表达", "#FF8C00"},
+                        {3, "表达者", "创意、社交、乐观、表达", "保持专注,避免浅尝辄止", "创造力,社交能力", "#FFD700"},
+                        {4, "建设者", "务实、稳定、有序、勤奋", "学会变通,避免过于固执", "专注力,学习习惯", "#4CAF50"},
+                        {5, "自由者", "冒险、变化、好奇、灵活", "培养持久力,避免缺乏恒心", "探索精神,适应能力", "#2196F3"},
+                        {6, "关怀者", "责任、关爱、奉献、平衡", "学会自我关爱,避免过度付出", "家庭关系,责任心", "#9C27B0"},
+                        {7, "探索者", "智慧、分析、内省、完美", "与人分享见解,避免过度封闭", "思维能力,学习力", "#673AB7"},
+                        {8, "成就者", "雄心、管理、决策、权威", "关注内在成长,避免过于追求外在成就", "目标管理,理财思维", "#3F51B5"},
+                        {9, "博爱者", "包容、智慧、善良、豁达", "设定个人边界,避免过度牺牲", "人际关系,情绪管理", "#E91E63"},
+                    })
+                );
+                log.info("numsoul_config 种子数据已加载 (9条)");
+            }
+        } catch (Exception e) {
+            log.warn("numsoul_config 种子数据初始化失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
+
+        // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ====================
+        migrateHealthDimensionsTable();
+        seedHealthNorms();
+    }
+
+    private void migrateHealthDimensionsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_dimension_score` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" +
+                "  `dimension` varchar(50) NOT NULL COMMENT '维度: body/fitness/cardio/blood/sleep/psyche/diet',\n" +
+                "  `score` decimal(5,2) NOT NULL COMMENT '得分 0-100',\n" +
+                "  `data_source` varchar(50) NOT NULL COMMENT '数据来源: manual/assessment/report/device',\n" +
+                "  `record_date` date DEFAULT NULL COMMENT '记录日期',\n" +
+                "  `source_id` bigint DEFAULT NULL COMMENT '来源记录ID(体检报告等)',\n" +
+                "  `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新',\n" +
+                "  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_member_dimension` (`member_id`,`dimension`,`is_latest`),\n" +
+                "  KEY `idx_record_date` (`record_date`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'");
+        } catch (Exception e) {
+            log.warn("health_dimension_score 表创建失败: {}", e.getMessage());
+        }
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_data_source_record` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" +
+                "  `data_source` varchar(50) NOT NULL COMMENT '数据源: manual/assessment/report/device',\n" +
+                "  `source_id` bigint NOT NULL COMMENT '来源记录ID',\n" +
+                "  `dimension` varchar(50) DEFAULT NULL COMMENT '关联维度',\n" +
+                "  `record_time` datetime NOT NULL COMMENT '记录时间',\n" +
+                "  `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新',\n" +
+                "  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_member_source` (`member_id`,`data_source`,`is_latest`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据来源记录表'");
+        } catch (Exception e) {
+            log.warn("health_data_source_record 表创建失败: {}", e.getMessage());
+        }
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_norm_reference` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `dimension` varchar(50) NOT NULL COMMENT '维度',\n" +
+                "  `indicator` varchar(100) NOT NULL COMMENT '指标名',\n" +
+                "  `min_value` decimal(10,3) DEFAULT NULL COMMENT '正常最小值',\n" +
+                "  `max_value` decimal(10,3) DEFAULT NULL COMMENT '正常最大值',\n" +
+                "  `unit` varchar(20) DEFAULT NULL COMMENT '单位',\n" +
+                "  `age_group` varchar(20) DEFAULT NULL COMMENT '年龄段: child/adolescent/adult',\n" +
+                "  `gender` varchar(10) DEFAULT NULL COMMENT '性别: male/female/unisex',\n" +
+                "  `source` varchar(100) DEFAULT NULL COMMENT '参考标准来源',\n" +
+                "  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_dimension_indicator` (`dimension`,`indicator`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标参考标准表'");
+        } catch (Exception e) {
+            log.warn("health_norm_reference 表创建失败: {}", e.getMessage());
+        }
+    }
+
+    private void seedHealthNorms() {
+        try {
+            int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM health_norm_reference", Integer.class);
+            if (count == 0) {
+                jdbcTemplate.execute("INSERT INTO `health_norm_reference` (`dimension`, `indicator`, `min_value`, `max_value`, `unit`, `age_group`, `gender`, `source`) VALUES\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'child', 'unisex', '中国儿童青少年BMI标准'),\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'adolescent', 'unisex', '中国儿童青少年BMI标准'),\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'adult', 'unisex', '中国成人BMI标准'),\n" +
+                    "('sleep', 'duration', 8.0, 12.0, '小时', 'child', 'unisex', '中国儿童睡眠指南'),\n" +
+                    "('sleep', 'duration', 9.0, 11.0, '小时', 'adolescent', 'unisex', '中国儿童青少年睡眠指南'),\n" +
+                    "('sleep', 'duration', 7.0, 9.0, '小时', 'adult', 'unisex', '中国成人睡眠指南'),\n" +
+                    "('fitness', 'situp', 30.0, NULL, '次', 'child', 'male', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 25.0, NULL, '次', 'child', 'female', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 40.0, NULL, '次', 'adolescent', 'male', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 35.0, NULL, '次', 'adolescent', 'female', '国家学生体质健康标准')");
+                log.info("health_norm_reference 种子数据已加载 (10条)");
+            } else {
+                log.info("health_norm_reference 种子数据已存在 ({}条), 跳过", count);
+            }
+        } catch (Exception e) {
+            log.warn("health_norm_reference 种子数据初始化失败: {}", e.getMessage());
+        }
     }
 
     private void initializeDefaultData() {

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

@@ -0,0 +1,77 @@
+package com.etotem.cfc.controller;
+
+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.service.DimensionScoreService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "健康维度", description = "七维健康指标查询与录入")
+@RestController
+@RequestMapping("/api/dimension")
+public class HealthDimensionController {
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    @Operation(summary = "获取健康维度概览")
+    @PostMapping("/overview")
+    public Result<HealthDimensionVO> overview(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        HealthDimensionVO vo = dimensionScoreService.getHealthDimension(memberId);
+        return Result.success(vo);
+    }
+
+    @Operation(summary = "手动录入维度评分")
+    @PostMapping("/upload")
+    public Result<Void> upload(
+            @RequestBody DimensionUploadVO vo,
+            @RequestAttribute("userId") Long userId) {
+        if (vo.getMemberId() == null || vo.getDimension() == null || vo.getScore() == null) {
+            return Result.error("memberId, dimension, score不能为空");
+        }
+        dimensionScoreService.uploadScore(vo);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "问卷提交维度评分")
+    @PostMapping("/questionnaire")
+    public Result<Void> questionnaire(
+            @RequestBody HealthDimensionQuestionnaireVO vo,
+            @RequestAttribute("userId") Long userId) {
+        if (vo.getMemberId() == null || vo.getDimension() == null || vo.getAnswers() == null) {
+            return Result.error("memberId, dimension, answers不能为空");
+        }
+        dimensionScoreService.submitQuestionnaire(vo);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "获取维度历史记录")
+    @PostMapping("/history")
+    public Result<List<HealthDimensionVO.DimensionItem>> history(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String dimension = (String) params.get("dimension");
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 10;
+        if (memberId == null || dimension == null) {
+            return Result.error("memberId和dimension不能为空");
+        }
+        List<HealthDimensionVO.DimensionItem> items = dimensionScoreService.getDimensionHistory(memberId, dimension, limit);
+        return Result.success(items);
+    }
+}

+ 24 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedIndicator;
 import com.etotem.cfc.dto.ParsedFoodSuitability;
 import com.etotem.cfc.dto.ParsedReportResult;
+import java.math.BigDecimal;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthGutFlora;
@@ -16,6 +17,7 @@ import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.mapper.FoodMapper;
 import com.etotem.cfc.service.ChildNutritionProfileService;
+import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportService;
@@ -69,6 +71,9 @@ public class HealthReportController {
     @Resource
     private FoodService foodService;
 
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -377,7 +382,25 @@ public class HealthReportController {
             // 9. 创建完整报告
             HealthReport created = healthReportService.createReport(report, indicators, gutFloraList, diseaseRiskList);
 
-            // 10. 保存食材推荐数据
+            // 10. 保存肠道健康评分到维度表
+            if (parsed.getGutHealthScore() != null) {
+                try {
+                    com.etotem.cfc.dto.DimensionUploadVO vo = new com.etotem.cfc.dto.DimensionUploadVO();
+                    vo.setMemberId(matchedChildId);
+                    vo.setDimension("body");
+                    vo.setScore(parsed.getGutHealthScore() != null ? BigDecimal.valueOf(parsed.getGutHealthScore()) : null);
+                    vo.setDataSource("report");
+                    if (parsed.getReportDate() != null && !parsed.getReportDate().isEmpty()) {
+                        vo.setRecordDate(parsed.getReportDate());
+                    }
+                    dimensionScoreService.uploadScore(vo);
+                    log.info("已保存肠道健康评分到维度表: childId={}, score={}", matchedChildId, parsed.getGutHealthScore());
+                } catch (Exception e) {
+                    log.warn("保存肠道健康评分到维度表失败: {}", e.getMessage());
+                }
+            }
+
+            // 11. 保存食材推荐数据
             if (parsed.getFoodSuitability() != null && !parsed.getFoodSuitability().isEmpty()) {
                 int sortOrder = 0;
                 for (ParsedFoodSuitability pfs : parsed.getFoodSuitability()) {

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmotionCheckinDTO;
+import com.etotem.cfc.dto.EmotionTrendVO;
+import com.etotem.cfc.dto.EmotionStatsVO;
+import com.etotem.cfc.service.EmotionCheckinService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/checkin")
+public class EmotionCheckinController {
+
+    @Resource
+    private EmotionCheckinService emotionCheckinService;
+
+    @PostMapping("/create")
+    public Result<?> create(@RequestBody EmotionCheckinDTO dto,
+                            @RequestAttribute("userId") Long userId) {
+        try {
+            Object result = emotionCheckinService.createCheckin(dto, userId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("创建情绪打卡失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/list")
+    public Result<?> list(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long userId,
+                          @RequestAttribute("role") String role) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        int page = params.containsKey("page") ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.containsKey("size") ? Integer.parseInt(params.get("size").toString()) : 20;
+        boolean hidePhoto = !"child".equals(role);
+        Object result = emotionCheckinService.getCheckinList(childId, page, size, userId, hidePhoto);
+        return Result.success(result);
+    }
+
+    @PostMapping("/latest")
+    public Result<?> latest(@RequestBody Map<String, Object> params,
+                            @RequestAttribute("userId") Long userId,
+                            @RequestAttribute("role") String role) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        boolean hidePhoto = !"child".equals(role);
+        Object result = emotionCheckinService.getLatestCheckin(childId, userId, hidePhoto);
+        return Result.success(result);
+    }
+
+    @PostMapping("/trend")
+    public Result<?> trend(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        String period = params.containsKey("period") ? params.get("period").toString() : "week";
+        EmotionTrendVO result = emotionCheckinService.getTrend(childId, period);
+        return Result.success(result);
+    }
+
+    @PostMapping("/stats")
+    public Result<?> stats(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        EmotionStatsVO result = emotionCheckinService.getStats(childId);
+        return Result.success(result);
+    }
+}

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindController.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmiReportDTO;
+import com.etotem.cfc.service.EmiReportService;
+import com.etotem.cfc.service.HealthReportService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind")
+public class MindController {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private EmiReportService emiReportService;
+
+    /**
+     * Get neurotransmitter indicators from latest gut microbiome report.
+     * Returns indicators categorized as "神经递质与激素" and "短链脂肪酸".
+     */
+    @PostMapping("/neurotransmitters")
+    public Result<?> neurotransmitters(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            Object result = healthReportService.getMindRelatedIndicators(childId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取神经递质指标失败", e);
+            return Result.success(Collections.emptyList());
+        }
+    }
+
+    /**
+     * Get gut-emotion insight summary based on gut microbiome data.
+     */
+    @PostMapping("/gut-emotion-insight")
+    public Result<?> gutEmotionInsight(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            Map<String, Object> result = healthReportService.getGutEmotionInsight(childId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取肠脑情绪洞察失败", e);
+            Map<String, Object> empty = new java.util.HashMap<>();
+            empty.put("summary", "暂无菌群报告数据");
+            return Result.success(empty);
+        }
+    }
+
+    /**
+     * Get the latest EMI report for a child.
+     */
+    @PostMapping("/emireport/latest")
+    public Result<?> emiReportLatest(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            EmiReportDTO result = emiReportService.getChildLatestEmiReport(childId);
+            if (result == null) {
+                return Result.success(new java.util.HashMap<>());
+            }
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取EMI报告失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 100 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/TraditionalMirrorController.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.BaziReadingTemplate;
+import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.entity.NumSoulConfig;
+import com.etotem.cfc.service.FamilyMemberAttributeService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/traditional")
+public class TraditionalMirrorController {
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    /**
+     * Get traditional culture mirror data for a family member.
+     * Returns BaZi (eight characters), wuxing elements, zodiac, life number,
+     * wuxing reading interpretation, and numsoul config.
+     */
+    @PostMapping("/mirror")
+    public Result<?> mirror(@RequestBody Map<String, Object> params) {
+        try {
+            Long memberId = Long.valueOf(params.get("memberId").toString());
+            String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
+
+            FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+            if (attrs == null) {
+                return Result.success(new HashMap<>());
+            }
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("eightCharacters", parseJsonField(attrs.getEightCharacters()));
+            result.put("wuxingElements", parseJsonField(attrs.getWuxingElements()));
+            result.put("zodiac", attrs.getZodiac());
+            result.put("bloodType", attrs.getBloodType());
+            result.put("mindBaseScore", attrs.getMindBaseScore());
+            result.put("wisdomBaseScore", attrs.getWisdomBaseScore());
+
+            // Parse behaviorModifier for lifeNumber
+            Map<String, Object> modifier = parseJsonField(attrs.getBehaviorModifier());
+            result.put("lifeNumber", modifier.getOrDefault("lifeNumber", null));
+
+            // Wuxing reading
+            BaziReadingTemplate reading = familyMemberAttributeService.getWuxingReading(attrs.getWuxingElements());
+            result.put("wuxingReading", reading);
+
+            // Num soul config
+            Object lifeNum = modifier.get("lifeNumber");
+            if (lifeNum instanceof Number) {
+                NumSoulConfig numSoul = familyMemberAttributeService.getNumSoulConfig(((Number) lifeNum).intValue());
+                result.put("numSoul", numSoul);
+            }
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取传统文化镜数据失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> parseJsonField(String json) {
+        if (json == null || json.isEmpty() || !json.startsWith("{")) {
+            return new HashMap<>();
+        }
+        // Use the service's fromJson parser via a simple approach
+        Map<String, Object> map = new HashMap<>();
+        String content = json.substring(1, json.length() - 1).trim();
+        if (content.isEmpty()) return map;
+        String[] pairs = content.split(",");
+        for (String pair : pairs) {
+            String[] kv = pair.split(":", 2);
+            if (kv.length == 2) {
+                String key = kv[0].trim().replace("\"", "");
+                String value = kv[1].trim().replace("\"", "");
+                try {
+                    if (value.contains(".")) {
+                        map.put(key, Double.parseDouble(value));
+                    } else {
+                        map.put(key, Integer.parseInt(value));
+                    }
+                } catch (NumberFormatException e) {
+                    map.put(key, value);
+                }
+            }
+        }
+        return map;
+    }
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionScoreVO.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class DimensionScoreVO {
+    private String dimension;
+    private BigDecimal score;
+    private String label;
+    private String dataSource;
+    private String recordDate;
+
+    public DimensionScoreVO() {}
+
+    public DimensionScoreVO(String dimension, String label, BigDecimal score, String dataSource, String recordDate) {
+        this.dimension = dimension;
+        this.label = label;
+        this.score = score;
+        this.dataSource = dataSource;
+        this.recordDate = recordDate;
+    }
+
+    public static List<DimensionScoreVO> DEFAULT_DIMENSIONS = java.util.Arrays.asList(
+        new DimensionScoreVO("body", "身体", null, null, null),
+        new DimensionScoreVO("fitness", "运动", null, null, null),
+        new DimensionScoreVO("cardio", "心肺", null, null, null),
+        new DimensionScoreVO("blood", "血液", null, null, null),
+        new DimensionScoreVO("sleep", "睡眠", null, null, null),
+        new DimensionScoreVO("psyche", "心理", null, null, null),
+        new DimensionScoreVO("diet", "饮食", null, null, null)
+    );
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionUploadVO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class DimensionUploadVO {
+    private Long memberId;
+    private String dimension;
+    private BigDecimal score;
+    private String dataSource;
+    private String recordDate;
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmiReportDTO.java

@@ -27,6 +27,11 @@ public class EmiReportDTO implements Serializable {
     private Integer neuroticismScore;
     private Integer bigFiveOverallScore;
 
+    private Integer emotionManagementScore;
+    private Integer empathyScore;
+    private Integer socialAdaptabilityScore;
+    private Integer selfMotivationScore;
+
     private Integer overallScore;
 
     private String analysisReport;

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinDTO.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class EmotionCheckinDTO implements Serializable {
+    private Long childId;
+    private String moodWeather;
+    private String emotionTags;
+    private String photoUrl;
+    private String note;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinVO.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class EmotionCheckinVO implements Serializable {
+    private Long id;
+    private Long childId;
+    private String childName;
+    private String moodWeather;
+    private String emotionTags;
+    private String photoUrl;
+    private String note;
+    private Integer energyAwarded;
+    private Date checkinDate;
+    private Date createdAt;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionStatsVO.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Map;
+
+@Data
+public class EmotionStatsVO implements Serializable {
+    private Integer currentStreak;
+    private Integer totalCheckins;
+    private Integer totalEnergy;
+    private Map<String, Integer> weatherBreakdown;
+    private Integer thisMonthCheckins;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionTrendVO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class EmotionTrendVO implements Serializable {
+    private String period;
+    private Map<String, Integer> weatherDistribution;
+    private List<Map<String, Object>> dailyData;
+    private Integer totalCheckins;
+    private Integer totalEnergy;
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionQuestionnaireVO.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class HealthDimensionQuestionnaireVO {
+    private Long memberId;
+    private String dimension;
+    private List<QuestionAnswer> answers;
+
+    @Data
+    public static class QuestionAnswer {
+        private String questionId;
+        private String answer;
+        private BigDecimal score;
+    }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionVO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class HealthDimensionVO {
+    private Long memberId;
+    private BigDecimal healthIndex;
+    private List<DimensionItem> dimensions;
+    private Date updateTime;
+
+    @Data
+    public static class DimensionItem {
+        private String dimension;
+        private String label;
+        private BigDecimal score;
+        private BigDecimal normMin;
+        private BigDecimal normMax;
+        private String level;
+        private String trend;
+        private String recordDate;
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/BaziReadingTemplate.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("bazi_reading_template")
+public class BaziReadingTemplate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String element;  // 五行: wood/fire/earth/metal/water
+
+    private String condition;  // 状态: strong/weak/balanced
+
+    private String personalTrait;  // 性格特质描述
+
+    private String mindGrowthAdvice;  // 心理成长建议
+
+    private String dailyTipTemplate;  // 每日宜忌模板
+
+    private Date createdAt;
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/EmotionCheckin.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("emotion_checkin")
+public class EmotionCheckin implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long childId;
+
+    private String moodWeather;
+
+    private String emotionTags;
+
+    private String photoUrl;
+
+    private String note;
+
+    private Integer energyAwarded;
+
+    private Date checkinDate;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+
+    private Integer moodScore;
+
+    private String emotionType;
+
+    private Integer stressLevel;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDataSourceRecord.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("health_data_source_record")
+public class HealthDataSourceRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String dataSource;
+    private Long sourceId;
+    private String dimension;
+    private Date recordTime;
+    private Boolean isLatest;
+    private Date createdAt;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDimensionScore.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_dimension_score")
+public class HealthDimensionScore implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String dimension;
+    private BigDecimal score;
+    private String dataSource;
+    private Date recordDate;
+    private Long sourceId;
+    private Boolean isLatest;
+    private Date createdAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthNormReference.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_norm_reference")
+public class HealthNormReference implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String dimension;
+    private String indicator;
+    private BigDecimal minValue;
+    private BigDecimal maxValue;
+    private String unit;
+    private String ageGroup;
+    private String gender;
+    private String source;
+    private Date updatedAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportPermission.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("health_report_permissions")
+public class HealthReportPermission implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 报告ID */
+    private Long reportId;
+
+    /** 有查看权限的用户ID */
+    private Long userId;
+
+    private Date createdAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulConfig.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@TableName("numsoul_config")
+public class NumSoulConfig implements Serializable {
+
+    @TableId
+    private Integer number;  // 灵数1-9
+
+    private String title;  // 称号
+
+    private String keywords;  // 性格关键词
+
+    private String mindGrowthAdvice;  // 心理成长建议
+
+    private String recommendedArticleTags;  // 关联文章标签
+
+    private String colorHex;  // 代表色
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/BaziReadingTemplateMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.BaziReadingTemplate;
+
+public interface BaziReadingTemplateMapper extends BaseMapper<BaziReadingTemplate> {
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/EmotionCheckinMapper.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.EmotionCheckin;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface EmotionCheckinMapper extends BaseMapper<EmotionCheckin> {
+
+    @Select("SELECT * FROM emotion_checkin WHERE child_id = #{childId} ORDER BY checkin_date DESC LIMIT #{limit}")
+    List<EmotionCheckin> selectLatestByChildId(@Param("childId") Long childId, @Param("limit") int limit);
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import java.util.List;
+
+@Mapper
+public interface HealthDataSourceRecordMapper extends BaseMapper<HealthDataSourceRecord> {
+
+    @Select("SELECT * FROM health_data_source_record WHERE member_id = #{memberId} AND data_source = #{dataSource} AND is_latest = 1 ORDER BY record_time DESC LIMIT 1")
+    HealthDataSourceRecord selectLatest(@Param("memberId") Long memberId, @Param("dataSource") String dataSource);
+
+    @Select("SELECT * FROM health_data_source_record WHERE member_id = #{memberId} AND data_source = #{dataSource} ORDER BY record_time DESC")
+    List<HealthDataSourceRecord> selectByMemberAndSource(@Param("memberId") Long memberId, @Param("dataSource") String dataSource);
+
+    @Update("UPDATE health_data_source_record SET is_latest = 0 WHERE member_id = #{memberId} AND data_source = #{dataSource} AND id != #{sourceId}")
+    void updateNotLatest(@Param("memberId") Long memberId, @Param("sourceId") Long sourceId);
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthDimensionScore;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import java.util.List;
+
+@Mapper
+public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND is_latest = 1 ORDER BY record_date DESC")
+    List<HealthDimensionScore> selectLatestByMember(@Param("memberId") Long memberId);
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND dimension = #{dimension} AND is_latest = 1 LIMIT 1")
+    HealthDimensionScore selectLatest(@Param("memberId") Long memberId, @Param("dimension") String dimension);
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND dimension = #{dimension} ORDER BY record_date DESC LIMIT #{limit}")
+    List<HealthDimensionScore> selectHistory(@Param("memberId") Long memberId, @Param("dimension") String dimension, @Param("limit") int limit);
+
+    @Update("UPDATE health_dimension_score SET is_latest = 0 WHERE member_id = #{memberId} AND dimension = #{dimension} AND is_latest = 1")
+    void updateExpired(@Param("memberId") Long memberId, @Param("dimension") String dimension);
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthNormReference;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+@Mapper
+public interface HealthNormReferenceMapper extends BaseMapper<HealthNormReference> {
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} LIMIT 1")
+    HealthNormReference selectByDimension(@Param("dimension") String dimension);
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} AND indicator = #{indicator} LIMIT 1")
+    HealthNormReference selectByDimensionIndicator(@Param("dimension") String dimension, @Param("indicator") String indicator);
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} AND age_group = #{ageGroup} AND (gender = #{gender} OR gender = 'unisex') LIMIT 1")
+    HealthNormReference selectNorm(@Param("dimension") String dimension, @Param("ageGroup") String ageGroup, @Param("gender") String gender);
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportPermissionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthReportPermission;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HealthReportPermissionMapper extends BaseMapper<HealthReportPermission> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NumSoulConfigMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NumSoulConfig;
+
+public interface NumSoulConfigMapper extends BaseMapper<NumSoulConfig> {
+}

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -5,8 +5,11 @@ import org.springframework.http.*;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import javax.annotation.Resource;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * Dify AI API 封装
@@ -27,6 +30,9 @@ public class AIService {
     @Value("${dify.nutrition-api-key}")
     private String nutritionApiKey;
 
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
     private HttpHeaders authHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -137,4 +143,48 @@ public class AIService {
         HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
         restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class);
     }
+
+    /**
+     * Build an emotional context prompt for the Dify AI conversation
+     * based on the child's recent checkin history.
+     * Detects consecutive negative checkins and includes them as context.
+     */
+    public String buildMindContextPrompt(Long childId) {
+        // Get latest 7 checkins
+        List<EmotionCheckin> recent = emotionCheckinMapper.selectLatestByChildId(childId, 7);
+        if (recent == null || recent.isEmpty()) {
+            return "用户暂无情绪打卡记录。";
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("用户最近的情绪打卡记录:\n");
+        for (EmotionCheckin c : recent) {
+            sb.append("- ").append(c.getCheckinDate()).append(": ");
+            sb.append("情绪值=").append(c.getMoodScore()).append("/10");
+            sb.append(", 情绪类型=").append(c.getEmotionType());
+            if (c.getStressLevel() != null) {
+                sb.append(", 压力值=").append(c.getStressLevel()).append("/10");
+            }
+            sb.append("\n");
+        }
+
+        // Detect consecutive negative checkins (moodScore <= 4)
+        int consecutiveNegative = 0;
+        for (EmotionCheckin c : recent) {
+            if (c.getMoodScore() != null && c.getMoodScore() <= 4) {
+                consecutiveNegative++;
+            } else {
+                break;
+            }
+        }
+        if (consecutiveNegative >= 3) {
+            sb.append("\n注意:用户已连续").append(consecutiveNegative)
+              .append("天情绪偏低,建议优先进行情绪疏导和倾听。");
+        } else if (consecutiveNegative >= 1) {
+            sb.append("\n注意:用户近").append(consecutiveNegative)
+              .append("天情绪偏低,可适当关注。");
+        }
+
+        return sb.toString();
+    }
 }

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

@@ -0,0 +1,13 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import java.util.List;
+
+public interface DimensionScoreService {
+    HealthDimensionVO getHealthDimension(Long memberId);
+    void uploadScore(DimensionUploadVO vo);
+    void submitQuestionnaire(HealthDimensionQuestionnaireVO vo);
+    List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit);
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmiReportService.java

@@ -39,6 +39,10 @@ public class EmiReportService {
         dto.setAgreeablenessScore(result.getAgreeablenessScore());
         dto.setNeuroticismScore(result.getNeuroticismScore());
         dto.setBigFiveOverallScore(result.getBigFiveOverallScore());
+        dto.setEmotionManagementScore(result.getEmotionManagementScore());
+        dto.setEmpathyScore(result.getEmpathyScore());
+        dto.setSocialAdaptabilityScore(result.getSocialAdaptabilityScore());
+        dto.setSelfMotivationScore(result.getSelfMotivationScore());
         dto.setAnalysisReport(result.getAnalysisReport());
         dto.setGrowthSuggestions(result.getGrowthSuggestions());
         dto.setAssessmentDate(result.getAssessmentDate());

+ 218 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java

@@ -0,0 +1,218 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmotionCheckinDTO;
+import com.etotem.cfc.dto.EmotionCheckinVO;
+import com.etotem.cfc.dto.EmotionStatsVO;
+import com.etotem.cfc.dto.EmotionTrendVO;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class EmotionCheckinService {
+
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Transactional
+    public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
+        Child child = childMapper.selectById(dto.getChildId());
+        if (child == null) {
+            throw new RuntimeException("孩子不存在");
+        }
+
+        EmotionCheckin checkin = new EmotionCheckin();
+        checkin.setChildId(dto.getChildId());
+        checkin.setMoodWeather(dto.getMoodWeather());
+        checkin.setEmotionTags(dto.getEmotionTags());
+        checkin.setPhotoUrl(dto.getPhotoUrl());
+        checkin.setNote(dto.getNote());
+        checkin.setEnergyAwarded(5);
+        checkin.setCheckinDate(new Date());
+        emotionCheckinMapper.insert(checkin);
+
+        awardEnergy(checkin.getId(), dto.getChildId());
+
+        return toVO(checkin, child.getNickname(), false);
+    }
+
+    private void awardEnergy(Long checkinId, Long childId) {
+        try {
+            energyService.awardEnergy(childId, "emotion_checkin", checkinId, 5, "情绪打卡奖励", 0);
+        } catch (Exception e) {
+            log.error("奖励心能量失败: checkinId={}, childId={}", checkinId, childId, e);
+        }
+    }
+
+    public List<EmotionCheckinVO> getCheckinList(Long childId, int page, int size, Long currentUserId, boolean hidePhoto) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> list = emotionCheckinMapper.selectList(wrapper);
+
+        Child child = childMapper.selectById(childId);
+        String childName = child != null ? child.getNickname() : "";
+
+        int start = (page - 1) * size;
+        int end = Math.min(start + size, list.size());
+        if (start >= list.size()) return new ArrayList<>();
+
+        List<EmotionCheckinVO> vos = new ArrayList<>();
+        for (EmotionCheckin ec : list.subList(start, end)) {
+            boolean isOwner = currentUserId != null && currentUserId.equals(childId);
+            vos.add(toVO(ec, childName, hidePhoto && !isOwner));
+        }
+        return vos;
+    }
+
+    public EmotionCheckinVO getLatestCheckin(Long childId, Long currentUserId, boolean hidePhoto) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCreatedAt)
+                .last("LIMIT 1");
+        EmotionCheckin checkin = emotionCheckinMapper.selectOne(wrapper);
+        if (checkin == null) return null;
+
+        Child child = childMapper.selectById(childId);
+        String childName = child != null ? child.getNickname() : "";
+        boolean isOwner = currentUserId != null && currentUserId.equals(childId);
+        return toVO(checkin, childName, hidePhoto && !isOwner);
+    }
+
+    public EmotionTrendVO getTrend(Long childId, String period) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> all = emotionCheckinMapper.selectList(wrapper);
+
+        Calendar cal = Calendar.getInstance();
+        if ("week".equals(period)) {
+            cal.add(Calendar.DAY_OF_YEAR, -7);
+        } else {
+            cal.add(Calendar.DAY_OF_YEAR, -30);
+        }
+        Date cutoff = cal.getTime();
+
+        List<EmotionCheckin> filtered = all.stream()
+                .filter(e -> e.getCheckinDate() != null && e.getCheckinDate().after(cutoff))
+                .collect(Collectors.toList());
+
+        Map<String, Integer> distribution = new HashMap<>();
+        List<Map<String, Object>> dailyData = new ArrayList<>();
+        int totalEnergy = 0;
+
+        for (EmotionCheckin e : filtered) {
+            String weather = e.getMoodWeather();
+            distribution.put(weather, distribution.getOrDefault(weather, 0) + 1);
+            if (e.getEnergyAwarded() != null) totalEnergy += e.getEnergyAwarded();
+
+            Map<String, Object> day = new HashMap<>();
+            day.put("date", e.getCheckinDate());
+            day.put("weather", e.getMoodWeather());
+            dailyData.add(day);
+        }
+
+        EmotionTrendVO vo = new EmotionTrendVO();
+        vo.setPeriod(period);
+        vo.setWeatherDistribution(distribution);
+        vo.setDailyData(dailyData);
+        vo.setTotalCheckins(filtered.size());
+        vo.setTotalEnergy(totalEnergy);
+        return vo;
+    }
+
+    public EmotionStatsVO getStats(Long childId) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> all = emotionCheckinMapper.selectList(wrapper);
+
+        Map<String, Integer> breakdown = new HashMap<>();
+        int totalEnergy = 0;
+        for (EmotionCheckin e : all) {
+            String weather = e.getMoodWeather();
+            breakdown.put(weather, breakdown.getOrDefault(weather, 0) + 1);
+            if (e.getEnergyAwarded() != null) totalEnergy += e.getEnergyAwarded();
+        }
+
+        int currentStreak = calculateStreak(all);
+        int thisMonthCount = countThisMonth(all);
+
+        EmotionStatsVO vo = new EmotionStatsVO();
+        vo.setCurrentStreak(currentStreak);
+        vo.setTotalCheckins(all.size());
+        vo.setTotalEnergy(totalEnergy);
+        vo.setWeatherBreakdown(breakdown);
+        vo.setThisMonthCheckins(thisMonthCount);
+        return vo;
+    }
+
+    private int calculateStreak(List<EmotionCheckin> list) {
+        if (list.isEmpty()) return 0;
+        Calendar cal = Calendar.getInstance();
+        int streak = 0;
+        for (EmotionCheckin e : list) {
+            if (e.getCheckinDate() == null) continue;
+            Calendar ec = Calendar.getInstance();
+            ec.setTime(e.getCheckinDate());
+            if (isSameDay(cal, ec)) {
+                streak++;
+                cal.add(Calendar.DAY_OF_YEAR, -1);
+            }
+        }
+        return streak;
+    }
+
+    private boolean isSameDay(Calendar a, Calendar b) {
+        return a.get(Calendar.YEAR) == b.get(Calendar.YEAR)
+                && a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR);
+    }
+
+    private int countThisMonth(List<EmotionCheckin> list) {
+        Calendar now = Calendar.getInstance();
+        int year = now.get(Calendar.YEAR);
+        int month = now.get(Calendar.MONTH);
+        int count = 0;
+        for (EmotionCheckin e : list) {
+            if (e.getCheckinDate() == null) continue;
+            Calendar ec = Calendar.getInstance();
+            ec.setTime(e.getCheckinDate());
+            if (ec.get(Calendar.YEAR) == year && ec.get(Calendar.MONTH) == month) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private EmotionCheckinVO toVO(EmotionCheckin ec, String childName, boolean hidePhoto) {
+        EmotionCheckinVO vo = new EmotionCheckinVO();
+        vo.setId(ec.getId());
+        vo.setChildId(ec.getChildId());
+        vo.setChildName(childName);
+        vo.setMoodWeather(ec.getMoodWeather());
+        vo.setEmotionTags(ec.getEmotionTags());
+        vo.setPhotoUrl(hidePhoto ? null : ec.getPhotoUrl());
+        vo.setNote(ec.getNote());
+        vo.setEnergyAwarded(ec.getEnergyAwarded());
+        vo.setCheckinDate(ec.getCheckinDate());
+        vo.setCreatedAt(ec.getCreatedAt());
+        return vo;
+    }
+}

+ 63 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java

@@ -1,12 +1,11 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.*;
-import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
-import com.etotem.cfc.mapper.ZodiacConfigMapper;
-import com.etotem.cfc.mapper.BaziConfigMapper;
-import com.etotem.cfc.mapper.BloodTypeConfigMapper;
+import com.etotem.cfc.mapper.*;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -25,6 +24,12 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
     @Resource
     private BloodTypeConfigMapper bloodTypeConfigMapper;
 
+    @Resource
+    private BaziReadingTemplateMapper baziReadingTemplateMapper;
+
+    @Resource
+    private NumSoulConfigMapper numSoulConfigMapper;
+
     // ========== Chinese Zodiac constants ==========
     private static final String[] ZODIAC_CODES = {"rat", "ox", "tiger", "rabbit", "dragon", "snake",
             "horse", "sheep", "monkey", "rooster", "dog", "pig"};
@@ -298,6 +303,60 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
         return result;
     }
 
+    /**
+     * Get wuxing reading based on wuxing element distribution.
+     * Identifies the dominant element and its strength to match bazi_reading_template.
+     *
+     * @param wuxingElementsJson JSON with wuxing percentages: {"wood":30,"fire":45,...}
+     * @return BaziReadingTemplate matching the dominant element+condition, or null
+     */
+    public BaziReadingTemplate getWuxingReading(String wuxingElementsJson) {
+        if (wuxingElementsJson == null || wuxingElementsJson.isEmpty()) {
+            return null;
+        }
+        Map<String, Object> wuxingMap = fromJson(wuxingElementsJson);
+        if (wuxingMap.isEmpty()) return null;
+
+        // Find dominant element (highest percentage)
+        String dominantElement = null;
+        double maxPct = 0;
+        for (Map.Entry<String, Object> entry : wuxingMap.entrySet()) {
+            double val = entry.getValue() instanceof Number ? ((Number) entry.getValue()).doubleValue() : 0;
+            if (val > maxPct) {
+                maxPct = val;
+                dominantElement = entry.getKey();
+            }
+        }
+        if (dominantElement == null) return null;
+
+        // Determine condition: > 30% strong, < 15% weak, otherwise balanced
+        String condition;
+        if (maxPct >= 30) {
+            condition = "strong";
+        } else if (maxPct <= 15) {
+            condition = "weak";
+        } else {
+            condition = "balanced";
+        }
+
+        // Match from template
+        LambdaQueryWrapper<BaziReadingTemplate> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(BaziReadingTemplate::getElement, dominantElement);
+        wrapper.eq(BaziReadingTemplate::getCondition, condition);
+        return baziReadingTemplateMapper.selectOne(wrapper);
+    }
+
+    /**
+     * Get numsoul config by life number.
+     *
+     * @param lifeNumber the life number (1-9) from behaviorModifier
+     * @return NumSoulConfig for that number, or null
+     */
+    public NumSoulConfig getNumSoulConfig(Integer lifeNumber) {
+        if (lifeNumber == null || lifeNumber < 1 || lifeNumber > 9) return null;
+        return numSoulConfigMapper.selectById(lifeNumber);
+    }
+
     // ========== Helper methods ==========
 
     /**

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthDataSourceRecordService.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import java.util.List;
+
+public interface HealthDataSourceRecordService {
+    void saveRecord(HealthDataSourceRecord record);
+    void markLatest(Long memberId, Long sourceId);
+    List<HealthDataSourceRecord> getRecords(Long memberId, String dataSource);
+    HealthDataSourceRecord getLatest(Long memberId, String dataSource);
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthDimensionScoreService.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthDimensionScore;
+import java.util.List;
+
+public interface HealthDimensionScoreService {
+    void saveScore(HealthDimensionScore score);
+    List<HealthDimensionScore> getLatestScores(Long memberId);
+    HealthDimensionScore getLatestScore(Long memberId, String dimension);
+    List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit);
+    void markExpired(Long memberId, String dimension);
+}

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

@@ -453,4 +453,65 @@ public class HealthReportService {
         }
         return null;
     }
+
+    /**
+     * Get mind-related indicators from the latest gut microbiome report.
+     * Filters indicators by categories related to mood/emotion:
+     * - 神经递质与激素 (neurotransmitters & hormones)
+     * - 短链脂肪酸 (short-chain fatty acids, gut-brain axis)
+     *
+     * @param childId the child's ID
+     * @return list of mood-related HealthIndicator
+     */
+    public List<HealthIndicator> getMindRelatedIndicators(Long childId) {
+        // Get latest gut flora report for this child
+        LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
+        reportWrapper.eq(HealthReport::getUserId, childId);
+        reportWrapper.eq(HealthReport::getReportType, "gut_flora");
+        reportWrapper.orderByDesc(HealthReport::getCreatedAt);
+        reportWrapper.last("LIMIT 1");
+        HealthReport report = healthReportMapper.selectOne(reportWrapper);
+        if (report == null) {
+            return Collections.emptyList();
+        }
+
+        // Get indicators matching mood-related categories
+        LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
+        indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
+        indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
+        indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
+        return healthIndicatorMapper.selectList(indicatorWrapper);
+    }
+
+    /**
+     * Get gut-emotion insight summary based on neurotransmitter and SCFA indicators.
+     *
+     * @param childId the child's ID
+     * @return map with summary text and indicator highlights
+     */
+    public Map<String, Object> getGutEmotionInsight(Long childId) {
+        List<HealthIndicator> indicators = getMindRelatedIndicators(childId);
+        if (indicators.isEmpty()) {
+            Map<String, Object> empty = new HashMap<>();
+            empty.put("summary", "暂无菌群报告数据");
+            empty.put("indicators", Collections.emptyList());
+            return empty;
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("indicators", indicators);
+
+        // Simple summary based on indicator statuses
+        long abnormalCount = indicators.stream()
+                .filter(i -> "abnormal".equals(i.getStatus()) || "偏高".equals(i.getStatus()) || "偏低".equals(i.getStatus()))
+                .count();
+        if (abnormalCount == 0) {
+            result.put("summary", "神经递质指标正常,肠道情绪状态良好");
+        } else if (abnormalCount <= 2) {
+            result.put("summary", "部分神经递质指标略有波动,可关注相关营养补充");
+        } else {
+            result.put("summary", "多项神经递质指标异常,建议咨询专业医生或营养师");
+        }
+        return result;
+    }
 }

+ 177 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -0,0 +1,177 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import com.etotem.cfc.entity.HealthDimensionScore;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.service.DimensionScoreService;
+import com.etotem.cfc.service.HealthDimensionScoreService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class DimensionScoreServiceImpl implements DimensionScoreService {
+
+    private static final List<String> DIMENSION_ORDER = java.util.Arrays.asList(
+        "body", "fitness", "cardio", "blood", "sleep", "psyche", "diet");
+
+    private static final List<String[]> DIMENSION_LABELS = java.util.Arrays.asList(
+        new String[]{"body", "身体"},
+        new String[]{"fitness", "运动"},
+        new String[]{"cardio", "心肺"},
+        new String[]{"blood", "血液"},
+        new String[]{"sleep", "睡眠"},
+        new String[]{"psyche", "心理"},
+        new String[]{"diet", "饮食"}
+    );
+
+    @Resource
+    private HealthDimensionScoreService scoreService;
+
+    @Resource
+    private HealthNormReferenceMapper normMapper;
+
+    @Override
+    public HealthDimensionVO getHealthDimension(Long memberId) {
+        List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
+        BigDecimal healthIndex = calculateHealthIndex(latest);
+
+        List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
+        for (String[] dim : DIMENSION_LABELS) {
+            String code = dim[0];
+            String label = dim[1];
+            HealthDimensionScore found = latest.stream()
+                .filter(s -> code.equals(s.getDimension()))
+                .findFirst().orElse(null);
+
+            HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
+            item.setDimension(code);
+            item.setLabel(label);
+            if (found != null) {
+                item.setScore(found.getScore());
+                item.setTrend("stable");
+                String level = calcLevel(found.getScore());
+                item.setLevel(level);
+            } else {
+                item.setScore(null);
+                item.setLevel("none");
+                item.setTrend(null);
+            }
+
+            HealthNormReference norm = normMapper.selectByDimension(code);
+            if (norm != null) {
+                item.setNormMin(norm.getMinValue());
+                item.setNormMax(norm.getMaxValue());
+            }
+            items.add(item);
+        }
+
+        HealthDimensionVO vo = new HealthDimensionVO();
+        vo.setMemberId(memberId);
+        vo.setHealthIndex(healthIndex);
+        vo.setDimensions(items);
+        vo.setUpdateTime(new Date());
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public void uploadScore(DimensionUploadVO vo) {
+        scoreService.markExpired(vo.getMemberId(), vo.getDimension());
+
+        HealthDimensionScore score = new HealthDimensionScore();
+        score.setMemberId(vo.getMemberId());
+        score.setDimension(vo.getDimension());
+        score.setScore(vo.getScore());
+        score.setDataSource(vo.getDataSource() != null ? vo.getDataSource() : "manual");
+        if (vo.getRecordDate() != null) {
+            score.setRecordDate(java.sql.Date.valueOf(vo.getRecordDate()));
+        } else {
+            score.setRecordDate(new java.sql.Date(System.currentTimeMillis()));
+        }
+        score.setIsLatest(true);
+        score.setSourceId(null);
+        scoreService.saveScore(score);
+    }
+
+    @Override
+    @Transactional
+    public void submitQuestionnaire(HealthDimensionQuestionnaireVO vo) {
+        BigDecimal total = BigDecimal.ZERO;
+        int count = 0;
+        for (HealthDimensionQuestionnaireVO.QuestionAnswer qa : vo.getAnswers()) {
+            if (qa.getScore() != null) {
+                total = total.add(qa.getScore());
+                count++;
+            }
+        }
+        BigDecimal avg = count > 0 ? total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP) : null;
+
+        if (avg != null) {
+            scoreService.markExpired(vo.getMemberId(), vo.getDimension());
+            HealthDimensionScore score = new HealthDimensionScore();
+            score.setMemberId(vo.getMemberId());
+            score.setDimension(vo.getDimension());
+            score.setScore(avg);
+            score.setDataSource("questionnaire");
+            score.setRecordDate(new java.sql.Date(System.currentTimeMillis()));
+            score.setIsLatest(true);
+            scoreService.saveScore(score);
+        }
+    }
+
+    @Override
+    public List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit) {
+        List<HealthDimensionScore> history = scoreService.getHistoryScores(memberId, dimension, limit);
+        List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        for (HealthDimensionScore s : history) {
+            HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
+            item.setDimension(s.getDimension());
+            item.setScore(s.getScore());
+            item.setLevel(calcLevel(s.getScore()));
+            item.setTrend("stable");
+            if (s.getRecordDate() != null) {
+                item.setRecordDate(sdf.format(s.getRecordDate()));
+            }
+            HealthNormReference norm = normMapper.selectByDimension(dimension);
+            if (norm != null) {
+                item.setNormMin(norm.getMinValue());
+                item.setNormMax(norm.getMaxValue());
+            }
+            items.add(item);
+        }
+        return items;
+    }
+
+    private BigDecimal calculateHealthIndex(List<HealthDimensionScore> scores) {
+        if (scores.isEmpty()) return null;
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (HealthDimensionScore s : scores) {
+            if (s.getScore() != null) {
+                sum = sum.add(s.getScore());
+                count++;
+            }
+        }
+        if (count == 0) return null;
+        return sum.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP);
+    }
+
+    private String calcLevel(BigDecimal score) {
+        if (score == null) return "none";
+        if (score.compareTo(new BigDecimal("85")) >= 0) return "excellent";
+        if (score.compareTo(new BigDecimal("70")) >= 0) return "good";
+        if (score.compareTo(new BigDecimal("60")) >= 0) return "fair";
+        return "poor";
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDataSourceRecordServiceImpl.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.mapper.HealthDataSourceRecordMapper;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class HealthDataSourceRecordServiceImpl implements HealthDataSourceRecordService {
+
+    @Resource
+    private HealthDataSourceRecordMapper recordMapper;
+
+    @Override
+    @Transactional
+    public void saveRecord(HealthDataSourceRecord record) {
+        recordMapper.insert(record);
+    }
+
+    @Override
+    @Transactional
+    public void markLatest(Long memberId, Long sourceId) {
+        recordMapper.updateNotLatest(memberId, sourceId);
+    }
+
+    @Override
+    public List<HealthDataSourceRecord> getRecords(Long memberId, String dataSource) {
+        return recordMapper.selectByMemberAndSource(memberId, dataSource);
+    }
+
+    @Override
+    public HealthDataSourceRecord getLatest(Long memberId, String dataSource) {
+        return recordMapper.selectLatest(memberId, dataSource);
+    }
+}

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.HealthDimensionScore;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
+import com.etotem.cfc.service.HealthDimensionScoreService;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class HealthDimensionScoreServiceImpl implements HealthDimensionScoreService {
+
+    @Resource
+    private HealthDimensionScoreMapper scoreMapper;
+
+    @Resource
+    private HealthDataSourceRecordService dataSourceRecordService;
+
+    @Override
+    @Transactional
+    public void saveScore(HealthDimensionScore score) {
+        scoreMapper.insert(score);
+        dataSourceRecordService.markLatest(score.getMemberId(), score.getId());
+    }
+
+    @Override
+    public List<HealthDimensionScore> getLatestScores(Long memberId) {
+        return scoreMapper.selectLatestByMember(memberId);
+    }
+
+    @Override
+    public HealthDimensionScore getLatestScore(Long memberId, String dimension) {
+        return scoreMapper.selectLatest(memberId, dimension);
+    }
+
+    @Override
+    public List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit) {
+        return scoreMapper.selectHistory(memberId, dimension, limit);
+    }
+
+    @Override
+    @Transactional
+    public void markExpired(Long memberId, String dimension) {
+        scoreMapper.updateExpired(memberId, dimension);
+    }
+}

+ 6 - 0
cfc-frontend/pages.json

@@ -71,6 +71,12 @@
         "navigationBarTitleText": "健康报告详情"
       }
     },
+    {
+      "path": "pages/body/health-dimensions",
+      "style": {
+        "navigationBarTitleText": "健康维度全景"
+      }
+    },
     {
       "path": "pages/body/checkin",
       "style": {

+ 286 - 0
cfc-frontend/pages/body/food-list.vue

@@ -0,0 +1,286 @@
+<template>
+  <view class="food-list-container">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">食材推荐</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <!-- Tab切换 -->
+    <view class="tab-bar">
+      <text class="tab-item" :class="{'active': activeTab === 'total'}" @click="switchTab('total')">全部食材</text>
+      <text class="tab-item" :class="{'active': activeTab === 'byMember'}" @click="switchTab('byMember')">按成员</text>
+    </view>
+
+    <!-- 全部食材模式 -->
+    <view v-if="activeTab === 'total'" class="food-section">
+      <view class="food-card" v-for="food in totalFoods" :key="food.id">
+        <view class="food-header">
+          <text class="food-name">{{ food.name }}</text>
+          <view class="food-score" :class="getScoreClass(food.score)">
+            <text>{{ food.score || '--' }}分</text>
+          </view>
+        </view>
+        <text class="food-category" v-if="food.category">{{ food.category }}</text>
+        <view class="food-nutrition" v-if="hasNutrition(food)">
+          <text class="nutri-item" v-if="food.energyKj">能量: {{ food.energyKj }}kJ</text>
+          <text class="nutri-item" v-if="food.protein">蛋白质: {{ food.protein }}g</text>
+          <text class="nutri-item" v-if="food.fat">脂肪: {{ food.fat }}g</text>
+          <text class="nutri-item" v-if="food.carbs">碳水: {{ food.carbs }}g</text>
+        </view>
+      </view>
+      <view class="empty-state" v-if="totalFoods.length === 0 && !loading">
+        <text>暂无食材推荐</text>
+        <text class="empty-hint">上传菌群检测报告后可获得个性化食材推荐</text>
+      </view>
+    </view>
+
+    <!-- 按成员模式 -->
+    <view v-if="activeTab === 'byMember'" class="food-section">
+      <view v-for="(foods, childId) in byMemberFoods" :key="childId" class="member-group">
+        <view class="member-header">
+          <text class="member-name">{{ getMemberName(childId) }}</text>
+          <text class="member-count">{{ foods.length }}项</text>
+        </view>
+        <view class="food-card" v-for="food in foods" :key="food.id">
+          <view class="food-header">
+            <text class="food-name">{{ food.name }}</text>
+            <view class="food-score" :class="getScoreClass(food.score)">
+              <text>{{ food.score || '--' }}分</text>
+            </view>
+          </view>
+          <text class="food-category" v-if="food.category">{{ food.category }}</text>
+          <view class="food-nutrition" v-if="hasNutrition(food)">
+            <text class="nutri-item" v-if="food.energyKj">能量: {{ food.energyKj }}kJ</text>
+            <text class="nutri-item" v-if="food.protein">蛋白质: {{ food.protein }}g</text>
+            <text class="nutri-item" v-if="food.fat">脂肪: {{ food.fat }}g</text>
+            <text class="nutri-item" v-if="food.carbs">碳水: {{ food.carbs }}g</text>
+          </view>
+        </view>
+      </view>
+      <view class="empty-state" v-if="Object.keys(byMemberFoods).length === 0 && !loading">
+        <text>暂无食材推荐</text>
+        <text class="empty-hint">上传菌群检测报告后可获得个性化食材推荐</text>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+const healthRequest = function(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        if (res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          reject(res.data)
+        }
+      },
+      fail: function(err) {
+        reject(err)
+      }
+    })
+  })
+}
+
+export default {
+  data: function() {
+    return {
+      familyId: null,
+      activeTab: 'total',
+      totalFoods: [],
+      byMemberFoods: {},
+      memberNames: {},
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    var familyId = options.familyId
+    this.familyId = familyId ? parseInt(familyId) : null
+    this.loadData()
+  },
+  methods: {
+    loadData: function() {
+      var self = this
+      if (!this.familyId) {
+        uni.showToast({ title: '缺少familyId', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '加载中...' })
+      healthRequest('/api/health/foods/by-family', { familyId: this.familyId }).then(function(res) {
+        uni.hideLoading()
+        if (res.data) {
+          self.totalFoods = res.data.total || []
+          self.byMemberFoods = res.data.byMember || {}
+          self.memberNames = res.data.memberNames || {}
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        console.log('获取食材推荐失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      })
+    },
+    switchTab: function(tab) {
+      this.activeTab = tab
+    },
+    getScoreClass: function(score) {
+      if (score == null) return 'score-unknown'
+      if (score >= 80) return 'score-high'
+      if (score >= 60) return 'score-mid'
+      return 'score-low'
+    },
+    hasNutrition: function(food) {
+      return food.energyKj || food.protein || food.fat || food.carbs
+    },
+    getMemberName: function(childId) {
+      return this.memberNames[childId] || '成员' + childId
+    },
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.food-list-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+}
+.nav-bar {
+  position: sticky;
+  top: 0;
+  background: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+  z-index: 100;
+}
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.tab-bar {
+  display: flex;
+  flex-direction: row;
+  background: #fff;
+  padding: 0 30rpx;
+  border-bottom: 2rpx solid #eee;
+}
+.tab-item {
+  padding: 20rpx 0;
+  margin-right: 40rpx;
+  font-size: 28rpx;
+  color: #666;
+  border-bottom: 4rpx solid transparent;
+}
+.tab-item.active {
+  color: #F97316;
+  border-bottom-color: #F97316;
+  font-weight: bold;
+}
+.food-section {
+  padding: 20rpx 30rpx;
+}
+.member-group {
+  margin-bottom: 30rpx;
+}
+.member-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.member-name {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.member-count {
+  font-size: 22rpx;
+  color: #999;
+}
+.food-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.food-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.food-name {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+}
+.food-score {
+  font-size: 24rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 16rpx;
+  font-weight: 600;
+}
+.score-high { background: #DCFCE7; color: #16A34A; }
+.score-mid { background: #FEF3C7; color: #D97706; }
+.score-low { background: #FEE2E2; color: #DC2626; }
+.score-unknown { background: #F3F4F6; color: #6B7280; }
+.food-category {
+  font-size: 22rpx;
+  color: #6B7280;
+  background: #F3F4F6;
+  padding: 2rpx 12rpx;
+  border-radius: 12rpx;
+  display: inline-block;
+  margin-bottom: 8rpx;
+}
+.food-nutrition {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  margin-top: 8rpx;
+}
+.nutri-item {
+  font-size: 22rpx;
+  color: #374151;
+}
+.empty-state {
+  text-align: center;
+  padding: 100rpx 40rpx;
+}
+.empty-state text:first-child {
+  font-size: 28rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #bbb;
+}
+.bottom-spacer {
+  height: 40rpx;
+}
+</style>

+ 605 - 0
cfc-frontend/pages/body/health-dimensions.vue

@@ -0,0 +1,605 @@
+<template>
+  <view class="hd-container">
+    <view class="hd-header">
+      <text class="hd-title">健康维度全景</text>
+      <text class="hd-subtitle" v-if="memberName">{{ memberName }}的健康档案</text>
+    </view>
+
+    <view class="hd-health-index" v-if="healthData && healthData.healthIndex">
+      <view class="index-circle">
+        <text class="index-value">{{ healthData.healthIndex }}</text>
+        <text class="index-label">健康指数</text>
+      </view>
+    </view>
+
+    <view class="radar-section" v-if="healthData && healthData.dimensions">
+      <view class="radar-title">七维健康雷达</view>
+      <view class="radar-wrap">
+        <view class="radar-center">
+          <view class="radar-grid">
+            <view class="grid-ring ring-3"></view>
+            <view class="grid-ring ring-2"></view>
+            <view class="grid-ring ring-1"></view>
+          </view>
+          <view class="radar-spokes">
+            <view
+              class="spoke-item"
+              v-for="(dim, idx) in healthData.dimensions"
+              :key="dim.dimension"
+              :style="getSpokeStyle(idx, dim.score)"
+            >
+              <view class="spoke-dot" :class="'level-' + dim.level"></view>
+              <view class="spoke-label">{{ dim.label }}</view>
+              <view class="spoke-score">{{ dim.score || '--' }}</view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="dimension-list" v-if="healthData && healthData.dimensions">
+      <view
+        class="dim-card"
+        v-for="dim in healthData.dimensions"
+        :key="dim.dimension"
+      >
+        <view class="dim-header">
+          <text class="dim-label">{{ dim.label }}</text>
+          <text class="dim-score" :class="'score-' + dim.level">{{ dim.score || '--' }}分</text>
+        </view>
+        <view class="dim-bar-wrap">
+          <view class="dim-bar-bg">
+            <view
+              class="dim-bar-fill"
+              :class="'fill-' + dim.level"
+              :style="{ width: (dim.score || 0) + '%' }"
+            ></view>
+          </view>
+          <text class="dim-norms" v-if="dim.normMin && dim.normMax">
+            正常范围 {{ dim.normMin }}-{{ dim.normMax }}
+          </text>
+        </view>
+        <view class="dim-footer">
+          <text class="dim-level-tag" :class="'tag-' + dim.level">{{ getLevelText(dim.level) }}</text>
+          <text class="dim-source" v-if="dim.trend">{{ dim.trend === 'up' ? '↑ 上升' : dim.trend === 'down' ? '↓ 下降' : '→ 稳定' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="hd-empty" v-if="!healthData && !loading">
+      <text class="empty-tip">暂无维度数据</text>
+      <text class="empty-sub">上传健康报告或填写问卷后可查看</text>
+    </view>
+
+    <view class="loading-wrap" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <view class="fab-upload" @click="showUploadModal">
+      <text class="fab-icon">+</text>
+    </view>
+
+    <view class="modal-mask" v-if="showModal" @click="showModal = false">
+      <view class="modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">录入维度评分</text>
+          <text class="modal-close" @click="showModal = false">×</text>
+        </view>
+        <view class="modal-body">
+          <view class="form-item">
+            <text class="form-label">选择维度</text>
+            <picker
+              mode="selector"
+              :range="dimensionOptions"
+              range-key="label"
+              @change="onDimensionChange"
+            >
+              <view class="picker-value">
+                {{ selectedDimension.label || '请选择' }}
+              </view>
+            </picker>
+          </view>
+          <view class="form-item">
+            <text class="form-label">评分 (0-100)</text>
+            <input
+              class="form-input"
+              type="number"
+              v-model="uploadForm.score"
+              placeholder="请输入评分"
+              min="0"
+              max="100"
+            />
+          </view>
+          <view class="form-item">
+            <text class="form-label">数据来源</text>
+            <picker
+              mode="selector"
+              :range="sourceOptions"
+              @change="onSourceChange"
+            >
+              <view class="picker-value">
+                {{ selectedSource.label || '请选择' }}
+              </view>
+            </picker>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="showModal = false">取消</view>
+          <view class="btn-confirm" @click="submitUpload">确认提交</view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getDimensionOverview, uploadDimensionScore } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      memberName: '',
+      healthData: null,
+      loading: false,
+      showModal: false,
+      selectedDimension: {},
+      selectedSource: {},
+      uploadForm: {
+        memberId: null,
+        dimension: '',
+        score: '',
+        dataSource: ''
+      },
+      dimensionOptions: [
+        { code: 'body', label: '身体' },
+        { code: 'fitness', label: '运动' },
+        { code: 'cardio', label: '心肺' },
+        { code: 'blood', label: '血液' },
+        { code: 'sleep', label: '睡眠' },
+        { code: 'psyche', label: '心理' },
+        { code: 'diet', label: '饮食' }
+      ],
+      sourceOptions: [
+        { code: 'manual', label: '手动录入' },
+        { code: 'report', label: '健康报告' },
+        { code: 'device', label: '设备' },
+        { code: 'questionnaire', label: '问卷' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    var childId = options.childId || uni.getStorageSync('currentChildId')
+    var childName = options.name || ''
+    this.memberId = Number(childId)
+    this.memberName = childName
+    this.uploadForm.memberId = this.memberId
+    if (this.memberId) {
+      this.loadDimensionData()
+    }
+  },
+  onShow: function() {
+    if (this.memberId) {
+      this.loadDimensionData()
+    }
+  },
+  methods: {
+    loadDimensionData: function() {
+      var self = this
+      self.loading = true
+      getDimensionOverview({ memberId: this.memberId }).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.healthData = res.data
+        }
+      }).catch(function(e) {
+        self.loading = false
+        console.log('获取维度数据失败', e)
+      })
+    },
+    getSpokeStyle: function(idx, score) {
+      var angle = idx * 51.4 - 90
+      var radius = 85
+      var normalizedScore = (score || 0) / 100
+      var translateY = -radius * normalizedScore
+      var rotate = angle
+      return {
+        transform: 'rotate(' + rotate + 'deg) translateY(' + translateY + 'px)',
+        '-webkit-transform': 'rotate(' + rotate + 'deg) translateY(' + translateY + 'px)'
+      }
+    },
+    getLevelText: function(level) {
+      var map = {
+        'excellent': '优秀',
+        'good': '良好',
+        'fair': '一般',
+        'poor': '较差',
+        'none': '暂无数据'
+      }
+      return map[level] || ''
+    },
+    showUploadModal: function() {
+      this.showModal = true
+      this.selectedDimension = {}
+      this.selectedSource = {}
+      this.uploadForm.dimension = ''
+      this.uploadForm.score = ''
+      this.uploadForm.dataSource = ''
+    },
+    onDimensionChange: function(e) {
+      var idx = e.detail.value
+      this.selectedDimension = this.dimensionOptions[idx]
+      this.uploadForm.dimension = this.selectedDimension.code
+    },
+    onSourceChange: function(e) {
+      var idx = e.detail.value
+      this.selectedSource = this.sourceOptions[idx]
+      this.uploadForm.dataSource = this.selectedSource.code
+    },
+    submitUpload: function() {
+      if (!this.uploadForm.dimension) {
+        uni.showToast({ title: '请选择维度', icon: 'none' })
+        return
+      }
+      if (!this.uploadForm.score || this.uploadForm.score < 0 || this.uploadForm.score > 100) {
+        uni.showToast({ title: '评分需在0-100之间', icon: 'none' })
+        return
+      }
+      if (!this.uploadForm.dataSource) {
+        uni.showToast({ title: '请选择数据来源', icon: 'none' })
+        return
+      }
+      var self = this
+      var payload = {
+        memberId: this.memberId,
+        dimension: this.uploadForm.dimension,
+        score: Number(this.uploadForm.score),
+        dataSource: this.uploadForm.dataSource
+      }
+      uploadDimensionScore(payload).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          self.showModal = false
+          self.loadDimensionData()
+        }
+      }).catch(function(e) {
+        console.log('提交失败', e)
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hd-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 120rpx;
+}
+.hd-header {
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  padding: 40rpx 30rpx 30rpx;
+  color: #fff;
+}
+.hd-title {
+  font-size: 40rpx;
+  font-weight: bold;
+  display: block;
+}
+.hd-subtitle {
+  font-size: 26rpx;
+  opacity: 0.9;
+  display: block;
+  margin-top: 8rpx;
+}
+.hd-health-index {
+  display: flex;
+  justify-content: center;
+  padding: 30rpx;
+  background: #fff;
+}
+.index-circle {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+}
+.index-value {
+  font-size: 48rpx;
+  font-weight: bold;
+  line-height: 1;
+}
+.index-label {
+  font-size: 22rpx;
+  opacity: 0.9;
+  margin-top: 8rpx;
+}
+.radar-section {
+  background: #fff;
+  margin-top: 20rpx;
+  padding: 30rpx;
+}
+.radar-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 30rpx;
+  text-align: center;
+}
+.radar-wrap {
+  display: flex;
+  justify-content: center;
+}
+.radar-center {
+  width: 440rpx;
+  height: 440rpx;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.radar-grid {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+.grid-ring {
+  position: absolute;
+  border-radius: 50%;
+  border: 1px solid #ddd;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+}
+.ring-1 { width: 33%; height: 33%; }
+.ring-2 { width: 66%; height: 66%; }
+.ring-3 { width: 100%; height: 100%; }
+.radar-spokes {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.spoke-item {
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  transform-origin: center center;
+  width: 80rpx;
+  margin-left: -40rpx;
+  top: 10rpx;
+}
+.spoke-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  background: #FF8C42;
+  margin-bottom: 6rpx;
+}
+.spoke-dot.level-excellent { background: #10B981; }
+.spoke-dot.level-good { background: #3B82F6; }
+.spoke-dot.level-fair { background: #F59E0B; }
+.spoke-dot.level-poor { background: #EF4444; }
+.spoke-dot.level-none { background: #ddd; }
+.spoke-label {
+  font-size: 22rpx;
+  color: #333;
+  white-space: nowrap;
+}
+.spoke-score {
+  font-size: 20rpx;
+  color: #666;
+  margin-top: 2rpx;
+}
+.dimension-list {
+  padding: 20rpx 30rpx;
+}
+.dim-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.dim-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.dim-label {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.dim-score {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #FF8C42;
+}
+.dim-score.score-excellent { color: #10B981; }
+.dim-score.score-good { color: #3B82F6; }
+.dim-score.score-fair { color: #F59E0B; }
+.dim-score.score-poor { color: #EF4444; }
+.dim-bar-wrap {
+  margin-bottom: 12rpx;
+}
+.dim-bar-bg {
+  height: 16rpx;
+  background: #f0f0f0;
+  border-radius: 8rpx;
+  overflow: hidden;
+  margin-bottom: 6rpx;
+}
+.dim-bar-fill {
+  height: 100%;
+  border-radius: 8rpx;
+  background: #FF8C42;
+  transition: width 0.3s;
+}
+.dim-bar-fill.fill-excellent { background: #10B981; }
+.dim-bar-fill.fill-good { background: #3B82F6; }
+.dim-bar-fill.fill-fair { background: #F59E0B; }
+.dim-bar-fill.fill-poor { background: #EF4444; }
+.dim-norms {
+  font-size: 22rpx;
+  color: #999;
+}
+.dim-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.dim-level-tag {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  background: #f0f0f0;
+  color: #666;
+}
+.dim-level-tag.tag-excellent { background: #D1FAE5; color: #10B981; }
+.dim-level-tag.tag-good { background: #DBEAFE; color: #3B82F6; }
+.dim-level-tag.tag-fair { background: #FEF3C7; color: #F59E0B; }
+.dim-level-tag.tag-poor { background: #FEE2E2; color: #EF4444; }
+.dim-source {
+  font-size: 22rpx;
+  color: #999;
+}
+.hd-empty {
+  padding: 100rpx 30rpx;
+  text-align: center;
+}
+.empty-tip {
+  font-size: 30rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.empty-sub {
+  font-size: 24rpx;
+  color: #ccc;
+  display: block;
+}
+.loading-wrap {
+  padding: 80rpx 30rpx;
+  text-align: center;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+.fab-upload {
+  position: fixed;
+  right: 40rpx;
+  bottom: 60rpx;
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 24rpx rgba(255, 140, 66, 0.4);
+}
+.fab-icon {
+  font-size: 48rpx;
+  color: #fff;
+  font-weight: bold;
+  line-height: 1;
+}
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
+.modal-content {
+  width: 100%;
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  padding-bottom: 60rpx;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  border-bottom: 1px solid #f0f0f0;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.modal-close {
+  font-size: 48rpx;
+  color: #999;
+  line-height: 1;
+}
+.modal-body {
+  padding: 30rpx;
+}
+.form-item {
+  margin-bottom: 30rpx;
+}
+.form-label {
+  font-size: 28rpx;
+  color: #666;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.picker-value {
+  height: 80rpx;
+  line-height: 80rpx;
+  border: 1px solid #e0e0e0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+}
+.form-input {
+  height: 80rpx;
+  border: 1px solid #e0e0e0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+}
+.modal-footer {
+  display: flex;
+  padding: 0 30rpx;
+  gap: 20rpx;
+}
+.btn-cancel {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: #f0f0f0;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  color: #666;
+}
+.btn-confirm {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  color: #fff;
+}
+</style>

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

@@ -1336,3 +1336,7 @@ export const getDeficiencySummary = (childId) => request('/api/health/nutrition/
 export const getNutritionTrend = (childId) => request('/api/health/nutrition/trend', 'POST', { childId })
 export const getHealthAnalysis = (childId) => request('/api/health/analysis', 'POST', { childId })
 export const getNutritionMappings = (data) => request('/api/health/nutrition/mappings', 'POST', data || {})
+
+// ===== 健康维度 =====
+export const getDimensionOverview = (data) => request('/api/dimension/overview', 'POST', data)
+export const uploadDimensionScore = (data) => request('/api/dimension/upload', 'POST', data)