Просмотр исходного кода

docs: PROJECT-OVERVIEW v1.2 合并远程更新 + 状态审计校正

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 3 месяцев назад
Родитель
Сommit
4bbf04a449
100 измененных файлов с 6026 добавлено и 391 удалено
  1. 1 0
      .gitignore
  2. 262 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CircleChallengeController.java
  4. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/DailyFeedbackController.java
  5. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
  6. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java
  7. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthAlertController.java
  8. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthCircleController.java
  9. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java
  10. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthScoreController.java
  11. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthTimelineController.java
  12. 84 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/MicroActionController.java
  13. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java
  14. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  15. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/AssessmentAppointment.java
  16. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CircleChallenge.java
  17. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DailyFeedback.java
  18. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
  19. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.java
  20. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthAlert.java
  21. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircle.java
  22. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircleMember.java
  23. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.java
  24. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthLevel.java
  25. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthTimelineEvent.java
  26. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MicroAction.java
  27. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MicroActionRecord.java
  28. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CircleChallengeMapper.java
  29. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DailyFeedbackMapper.java
  30. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeMapper.java
  31. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeProgressMapper.java
  32. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthAlertMapper.java
  33. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthCircleMapper.java
  34. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthCircleMemberMapper.java
  35. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthInteractionMapper.java
  36. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthLevelMapper.java
  37. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthTimelineEventMapper.java
  38. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionMapper.java
  39. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionRecordMapper.java
  40. 79 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleChallengeService.java
  41. 129 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DailyFeedbackService.java
  42. 175 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  43. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  44. 280 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertEngine.java
  45. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertService.java
  46. 208 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthCircleService.java
  47. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthInteractionService.java
  48. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthLevelService.java
  49. 122 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java
  50. 86 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthTimelineService.java
  51. 242 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java
  52. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java
  53. 9 7
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/FortuneServiceImpl.java
  54. 171 0
      cfc-backend/src/main/resources/schema.sql
  55. 70 0
      cfc-frontend/components/CircleRanking.vue
  56. 1 1
      cfc-frontend/components/ContactCard.vue
  57. 84 0
      cfc-frontend/components/DailySummary.vue
  58. 117 0
      cfc-frontend/components/FamilyChallengeCard.vue
  59. 104 0
      cfc-frontend/components/FamilyHealthRanking.vue
  60. 30 15
      cfc-frontend/components/FamilyRelationGraph.vue
  61. 65 0
      cfc-frontend/components/FeedbackToast.vue
  62. 195 0
      cfc-frontend/components/GenerationPicker.vue
  63. 87 0
      cfc-frontend/components/HealthAlertBanner.vue
  64. 102 0
      cfc-frontend/components/HealthBadge.vue
  65. 64 0
      cfc-frontend/components/HealthLikeButton.vue
  66. 83 0
      cfc-frontend/components/HealthTimeline.vue
  67. 169 0
      cfc-frontend/components/MicroActionCard.vue
  68. 89 0
      cfc-frontend/components/MilestoneCelebration.vue
  69. 0 43
      cfc-frontend/config.js
  70. 1 1
      cfc-frontend/main.js
  71. 6 0
      cfc-frontend/pages.json
  72. 218 252
      cfc-frontend/pages/family/add-member.vue
  73. 110 3
      cfc-frontend/pages/index/parent-index.vue
  74. 51 20
      cfc-frontend/pages/profile-extra/edit-child.vue
  75. 5 1
      cfc-frontend/pages/profile-extra/family-members.vue
  76. 53 9
      cfc-frontend/pages/profile/profile.vue
  77. 146 0
      cfc-frontend/pages/share/index.vue
  78. 5 2
      cfc-frontend/pages/tasks/review.vue
  79. 85 0
      cfc-frontend/store/feedback.js
  80. 59 0
      cfc-frontend/store/health-alert.js
  81. 49 0
      cfc-frontend/store/health-score.js
  82. 4 1
      cfc-frontend/store/index.js
  83. 37 0
      cfc-frontend/utils/api.js
  84. 61 0
      cfc-frontend/utils/feedback-engine.js
  85. 98 0
      cfc-frontend/utils/feedback-presets.js
  86. 1 1
      cfc-web/.env.development
  87. 1 1
      cfc-web/.last_build_commit
  88. 2 2
      cfc-web/package-lock.json
  89. 1 1
      cfc-web/package.json
  90. 18 0
      cfc-web/public/CHANGELOG-v$(node.undefined.md
  91. 213 0
      cfc-web/public/CHANGELOG-v1.0.md
  92. 214 1
      cfc-web/public/CHANGELOG.md
  93. 25 0
      cfc-web/src/api/admin.js
  94. 6 0
      cfc-web/src/router/index.js
  95. 31 0
      cfc-web/src/styles/admin-tokens.css
  96. 7 6
      cfc-web/src/views/Children.vue
  97. 10 6
      cfc-web/src/views/Families.vue
  98. 12 9
      cfc-web/src/views/GuideFamilyTask.vue
  99. 9 5
      cfc-web/src/views/GuidePackages.vue
  100. 1 4
      cfc-web/src/views/Layout.vue

+ 1 - 0
.gitignore

@@ -24,6 +24,7 @@ Thumbs.db
 .env.local
 .env.*.local
 .env.production
+cfc-frontend/config.js
 
 # Test artifacts
 .playwright-mcp/

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

@@ -5395,6 +5395,253 @@ try {
     ensureColumn("dan_assessment_executions", "appointment_id",
             "BIGINT COMMENT '关联assessment_appointments.id'");
     log.info("迁移100: 已添加assessor_id和appointment_id字段");
+
+    // 迁移101: 创建 micro_actions 表(12个预定义1秒微行动)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_actions (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...', " +
+            "name VARCHAR(50) NOT NULL COMMENT '行动名称', " +
+            "icon VARCHAR(10) COMMENT '图标emoji', " +
+            "description VARCHAR(200) COMMENT '行动描述', " +
+            "sort_order INT DEFAULT 0 COMMENT '排序号', " +
+            "status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_code (code), " +
+            "INDEX idx_status (status)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)'");
+        log.info("已创建micro_actions表");
+
+        // 插入12个预定义微行动
+        jdbcTemplate.execute("INSERT IGNORE INTO micro_actions (code, name, icon, description, sort_order) VALUES " +
+            "('DRINK_WATER', '喝一杯水', '☕️', '喝一杯温开水,补充水分', 1), " +
+            "('DEEP_BREATH', '深呼吸3次', '🫁', '深吸慢呼3次,放松身心', 2), " +
+            "('STRETCH', '站起来伸懒腰', '🧘', '站起来伸个懒腰,活动筋骨', 3), " +
+            "('LOOK_FAR', '看远处10秒', '🌿', '看远处绿色植物10秒,缓解眼疲劳', 4), " +
+            "('SMILE', '微笑一下', '😊', '对自己微笑,心情会变好', 5), " +
+            "('NECK_ROLL', '转转头颈5圈', '🔄', '缓慢转动头颈5圈,放松颈椎', 6), " +
+            "('FIST_RELAX', '握拳放松3次', '✊', '用力握拳3秒后放松,重复3次', 7), " +
+            "('SIP_WATER', '喝口水润喉', '💧', '小口喝水润润喉咙', 8), " +
+            "('BLINK', '眨眨眼5次', '👁️', '快速眨眼5次,湿润眼球', 9), " +
+            "('ACUPRESSURE', '按揉合谷穴3下', '🖐️', '按揉虎口合谷穴3下,提神醒脑', 10), " +
+            "('TIPTOE', '踮脚尖5次', '⬆️', '踮起脚尖再放下,重复5次', 11), " +
+            "('SHRUG', '耸肩放松3次', '↕️', '耸肩至耳朵再放松,重复3次', 12)");
+        log.info("已初始化12个微行动");
+    } catch (Exception e) {
+        log.warn("创建micro_actions表或初始化数据失败: {}", e.getMessage());
+    }
+
+    // 迁移102: 创建 micro_action_records 表(微行动完成记录)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_action_records (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "action_id BIGINT NOT NULL COMMENT '行动ID', " +
+            "completed_date DATETIME NOT NULL COMMENT '完成日期', " +
+            "earned_energy INT DEFAULT 1 COMMENT '获得能量值', " +
+            "note VARCHAR(200) COMMENT '备注', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, completed_date), " +
+            "INDEX idx_user_id (user_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录'");
+        log.info("已创建micro_action_records表");
+    } catch (Exception e) {
+        log.warn("创建micro_action_records表失败: {}", e.getMessage());
+    }
+
+    // 迁移103: 创建 daily_feedback 表(AI/预设每日健康反馈)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_feedback (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "feedback_date DATETIME NOT NULL COMMENT '反馈日期', " +
+            "feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone', " +
+            "content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本', " +
+            "milestone_event VARCHAR(100) COMMENT '关联的里程碑事件', " +
+            "is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读', " +
+            "source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, feedback_date), " +
+            "INDEX idx_feedback_type (feedback_type)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈'");
+        log.info("已创建daily_feedback表");
+    } catch (Exception e) {
+        log.warn("创建daily_feedback表失败: {}", e.getMessage());
+    }
+
+    // 迁移104: 创建 health_alerts 表(健康行为智能预警)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_alerts (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006', " +
+            "alert_text VARCHAR(200) NOT NULL COMMENT '预警文案', " +
+            "action_suggestion VARCHAR(200) COMMENT '行动建议', " +
+            "severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low', " +
+            "dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭', " +
+            "dismissed_at DATETIME COMMENT '关闭时间', " +
+            "alert_date DATETIME NOT NULL COMMENT '预警日期', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, alert_date), " +
+            "INDEX idx_rule (rule_code)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录'");
+        log.info("已创建health_alerts表");
+    } catch (Exception e) {
+        log.warn("创建health_alerts表失败: {}", e.getMessage());
+    }
+
+    // 迁移105: 创建 health_level 表(身份重塑等级定义)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_level (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "level_num INT NOT NULL COMMENT '等级编号 1-5', " +
+            "title VARCHAR(50) NOT NULL COMMENT '称号', " +
+            "badge_icon VARCHAR(20) COMMENT '徽章图标', " +
+            "min_score INT DEFAULT 0 COMMENT '所需最低健康积分', " +
+            "max_score INT DEFAULT 99999 COMMENT '最高积分上限'" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义'");
+        log.info("已创建health_level表");
+    } catch (Exception e) {
+        log.warn("创建health_level表失败: {}", e.getMessage());
+    }
+
+    // 迁移106: 创建 health_timeline_event 表(健康故事时间线)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_timeline_event (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report', " +
+            "event_text VARCHAR(200) NOT NULL COMMENT '事件描述', " +
+            "ref_id VARCHAR(50) COMMENT '关联ID', " +
+            "event_date DATETIME NOT NULL COMMENT '事件日期', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, event_date), " +
+            "INDEX idx_event_type (event_type)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件'");
+        log.info("已创建health_timeline_event表");
+    } catch (Exception e) {
+        log.warn("创建health_timeline_event表失败: {}", e.getMessage());
+    }
+
+    // 迁移107: 创建 family_challenge 表(P2-2家庭挑战)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week', " +
+            "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
+            "description TEXT COMMENT '挑战描述', " +
+            "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
+            "reward_points INT DEFAULT 0 COMMENT '奖励积分', " +
+            "reward_badge VARCHAR(100) COMMENT '奖励徽章', " +
+            "start_date DATE COMMENT '开始日期', " +
+            "end_date DATE COMMENT '结束日期', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_family_id (family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战'");
+        log.info("已创建family_challenge表");
+    } catch (Exception e) {
+        log.warn("创建family_challenge表失败: {}", e.getMessage());
+    }
+
+    // 迁移108: 创建 family_challenge_progress 表(P2-2挑战进度)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_progress (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "progress_value INT DEFAULT 0 COMMENT '进度值', " +
+            "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+            "INDEX idx_challenge_id (challenge_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度'");
+        log.info("已创建family_challenge_progress表");
+    } catch (Exception e) {
+        log.warn("创建family_challenge_progress表失败: {}", e.getMessage());
+    }
+
+    // 迁移109: 创建 health_interaction 表(P2-2点赞记录)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_interaction (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "from_user_id BIGINT NOT NULL COMMENT '点赞用户ID', " +
+            "to_user_id BIGINT NOT NULL COMMENT '被赞用户ID', " +
+            "action_type VARCHAR(50) NOT NULL COMMENT '行为类型', " +
+            "action_id BIGINT COMMENT '关联行为记录ID', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_family_action (family_id, action_type, action_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)'");
+        log.info("已创建health_interaction表");
+    } catch (Exception e) {
+        log.warn("创建health_interaction表失败: {}", e.getMessage());
+    }
+
+    // 迁移110: 创建 health_circle 表(P3 健康圈)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "name VARCHAR(100) NOT NULL COMMENT '圈名称', " +
+            "description VARCHAR(500) COMMENT '圈描述', " +
+            "creator_family_id BIGINT COMMENT '创建家庭ID', " +
+            "invite_code VARCHAR(32) COMMENT '邀请码', " +
+            "member_limit INT DEFAULT 50 COMMENT '成员上限', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_invite_code (invite_code), " +
+            "INDEX idx_creator (creator_family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈'");
+        log.info("已创建health_circle表");
+    } catch (Exception e) {
+        log.warn("创建health_circle表失败: {}", e.getMessage());
+    }
+
+    // 迁移111: 创建 health_circle_member 表(P3 健康圈成员)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle_member (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin', " +
+            "joined_at DATETIME COMMENT '加入时间', " +
+            "left_at DATETIME COMMENT '退出时间', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_circle_id (circle_id), " +
+            "INDEX idx_family_id (family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员'");
+        log.info("已创建health_circle_member表");
+    } catch (Exception e) {
+        log.warn("创建health_circle_member表失败: {}", e.getMessage());
+    }
+
+    // 迁移112: 创建 circle_challenge 表(P3 圈挑战)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS circle_challenge (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
+            "challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation', " +
+            "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
+            "description VARCHAR(500) COMMENT '挑战描述', " +
+            "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
+            "period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly', " +
+            "start_date DATETIME COMMENT '开始日期', " +
+            "end_date DATETIME COMMENT '结束日期', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_circle_id (circle_id), " +
+            "INDEX idx_status (status)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战'");
+        log.info("已创建circle_challenge表");
+    } catch (Exception e) {
+        log.warn("创建circle_challenge表失败: {}", e.getMessage());
+    }
     }
 
     private void runMigration82() {
@@ -6056,4 +6303,19 @@ try {
         log.error("迁移95 Step2 失败: " + e.getMessage());
     }
     }
+
+    private void runMigration97() {
+        // 迁移93: users表添加roles字段
+        ensureColumn("users", "roles", "VARCHAR(100) COMMENT '角色列表'");
+    }
+
+    private void runMigration96() {
+        // 迁移96: 修复assessment_appointments.status列类型为TINYINT(原库可能为VARCHAR/ENUM导致Data truncated)
+        try {
+            jdbcTemplate.execute("ALTER TABLE assessment_appointments MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0=待确认,1=已确认,2=已完成,3=已取消,4=未到'");
+            log.info("已修复assessment_appointments.status列类型为TINYINT");
+        } catch (Exception e) {
+            log.warn("Migration 96 skipped: {}", e.getMessage());
+        }
+    }
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleChallengeController.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.service.CircleChallengeService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/circle/challenge")
+public class CircleChallengeController {
+
+    @Resource
+    private CircleChallengeService circleChallengeService;
+
+    @PostMapping("/list")
+    public Result<List<CircleChallenge>> getList(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(circleChallengeService.getActiveChallenges(circleId));
+    }
+
+    @PostMapping("/history")
+    public Result<List<CircleChallenge>> getHistory(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(circleChallengeService.getChallengeHistory(circleId));
+    }
+}

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DailyFeedbackController.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DailyFeedback;
+import com.etotem.cfc.service.DailyFeedbackService;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 每日反馈接口 — 即时多巴胺反馈(L2: AI/预设深度反馈)
+ */
+@Tag(name = "每日反馈", description = "AI/预设每日健康反馈")
+@RestController
+@RequestMapping("/api/feedback")
+public class DailyFeedbackController {
+
+    @Resource
+    private DailyFeedbackService dailyFeedbackService;
+
+    @Operation(summary = "获取今日反馈")
+    @PostMapping("/today")
+    public Result<Map<String, Object>> today(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        DailyFeedback feedback = dailyFeedbackService.getTodayFeedback(childId);
+
+        // Auto-generate if none exists
+        if (feedback == null && childId != null) {
+            feedback = dailyFeedbackService.generatePresetFeedback(userId, childId);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("feedback", feedback);
+        result.put("hasFeedback", feedback != null);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "标记反馈为已读")
+    @PostMapping("/read")
+    public Result<String> markAsRead(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        dailyFeedbackService.markAsRead(id);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "获取最近反馈列表")
+    @PostMapping("/recent")
+    public Result<List<DailyFeedback>> recent(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 7;
+        return Result.success(dailyFeedbackService.getRecentFeedbacks(childId, limit));
+    }
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.service.FamilyChallengeService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/challenge")
+public class FamilyChallengeController {
+
+    @Resource
+    private FamilyChallengeService familyChallengeService;
+
+    @PostMapping("/list")
+    public Result<List<FamilyChallenge>> getList(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(familyChallengeService.getActiveChallenges(familyId));
+    }
+
+    @PostMapping("/history")
+    public Result<List<FamilyChallenge>> getHistory(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(familyChallengeService.getChallengeHistory(familyId));
+    }
+
+    @PostMapping("/progress")
+    public Result<Void> updateProgress(@RequestBody Map<String, Object> params) {
+        Long challengeId = params.get("challengeId") != null
+                ? Long.valueOf(params.get("challengeId").toString()) : null;
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int delta = params.get("delta") != null
+                ? Integer.parseInt(params.get("delta").toString()) : 0;
+        return familyChallengeService.updateProgress(challengeId, childId, delta);
+    }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.FamilyHealthScoreService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/family")
+public class FamilyHealthController {
+
+    @Resource
+    private FamilyHealthScoreService familyHealthScoreService;
+
+    @PostMapping("/ranking")
+    public Result<Map<String, Object>> getRanking(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute("userId") Long userId,
+                                                   @RequestAttribute("role") String role) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        Map<String, Object> ranking = familyHealthScoreService.getFamilyRanking(familyId, userId, role);
+        return Result.success(ranking);
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthAlertController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.service.HealthAlertService;
+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 = "P1-2 健康行为智能预警")
+@RestController
+@RequestMapping("/api/health/alert")
+public class HealthAlertController {
+
+    @Resource
+    private HealthAlertService healthAlertService;
+
+    @Operation(summary = "获取活跃预警列表")
+    @PostMapping("/list")
+    public Result<List<HealthAlert>> list(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthAlertService.getActiveAlerts(childId));
+    }
+
+    @Operation(summary = "关闭预警")
+    @PostMapping("/dismiss")
+    public Result<String> dismiss(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        healthAlertService.dismiss(id);
+        return Result.success("ok");
+    }
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthCircleController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthCircle;
+import com.etotem.cfc.service.HealthCircleService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/circle")
+public class HealthCircleController {
+
+    @Resource
+    private HealthCircleService healthCircleService;
+
+    @PostMapping("/list")
+    public Result<List<HealthCircle>> list(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(healthCircleService.getUserCircles(familyId));
+    }
+
+    @PostMapping("/create")
+    public Result<HealthCircle> create(@RequestBody Map<String, Object> params) {
+        String name = params.get("name") != null ? params.get("name").toString() : null;
+        String description = params.get("description") != null ? params.get("description").toString() : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.createCircle(name, description, familyId);
+    }
+
+    @PostMapping("/join")
+    public Result<Void> join(@RequestBody Map<String, Object> params) {
+        String inviteCode = params.get("inviteCode") != null ? params.get("inviteCode").toString() : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.joinCircle(inviteCode, familyId);
+    }
+
+    @PostMapping("/leave")
+    public Result<Void> leave(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.leaveCircle(circleId, familyId);
+    }
+
+    @PostMapping("/ranking")
+    public Result<Map<String, Object>> ranking(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(healthCircleService.getCircleRanking(circleId));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HealthInteractionService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/interact")
+public class HealthInteractionController {
+
+    @Resource
+    private HealthInteractionService healthInteractionService;
+
+    @PostMapping("/like")
+    public Result<Void> like(@RequestBody Map<String, Object> params,
+                             @RequestAttribute("userId") Long userId,
+                             @RequestAttribute("role") String role) {
+        Long toUserId = params.get("toUserId") != null
+                ? Long.valueOf(params.get("toUserId").toString()) : null;
+        String actionType = params.get("actionType") != null
+                ? params.get("actionType").toString() : null;
+        Long actionId = params.get("actionId") != null
+                ? Long.valueOf(params.get("actionId").toString()) : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthInteractionService.like(userId, toUserId, actionType, actionId, familyId);
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthScoreController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HealthLevelService;
+import com.etotem.cfc.service.HealthScoreService;
+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.Map;
+
+@Tag(name = "健康积分", description = "P2-1 身份重塑系统")
+@RestController
+@RequestMapping("/api/health/score")
+public class HealthScoreController {
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    @Resource
+    private HealthLevelService healthLevelService;
+
+    @Operation(summary = "获取成员健康积分和等级")
+    @PostMapping("/info")
+    public Result<Map<String, Object>> info(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthLevelService.getMemberLevelInfo(childId));
+    }
+
+    @Operation(summary = "获取连续打卡天数")
+    @PostMapping("/streak")
+    public Result<Integer> streak(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthScoreService.calculateStreakDays(childId));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthTimelineController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthTimelineEvent;
+import com.etotem.cfc.service.HealthTimelineService;
+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 = "P2-1 我的健康故事")
+@RestController
+@RequestMapping("/api/health/timeline")
+public class HealthTimelineController {
+
+    @Resource
+    private HealthTimelineService healthTimelineService;
+
+    @Operation(summary = "获取健康时间线")
+    @PostMapping("/list")
+    public Result<List<HealthTimelineEvent>> list(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 20;
+        return Result.success(healthTimelineService.getTimeline(childId, limit));
+    }
+}

+ 84 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MicroActionController.java

@@ -0,0 +1,84 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MicroAction;
+import com.etotem.cfc.entity.MicroActionRecord;
+import com.etotem.cfc.service.MicroActionService;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 微行动接口 — 有效启动:最小行为单元(1秒完成)
+ */
+@Tag(name = "微行动", description = "1秒微行动:健康行为最小启动单元")
+@RestController
+@RequestMapping("/api/micro-action")
+public class MicroActionController {
+
+    @Resource
+    private MicroActionService microActionService;
+
+    @Operation(summary = "获取所有微行动列表")
+    @PostMapping("/list")
+    public Result<List<MicroAction>> list() {
+        return Result.success(microActionService.getAllActions());
+    }
+
+    @Operation(summary = "获取今日推荐微行动")
+    @PostMapping("/today")
+    public Result<Map<String, Object>> today(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        MicroAction action = microActionService.getTodayAction(childId);
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("action", action);
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "完成微行动")
+    @PostMapping("/complete")
+    public Result<MicroActionRecord> complete(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        Long actionId = params.get("actionId") != null
+                ? Long.valueOf(params.get("actionId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        if (actionId == null) {
+            return Result.error("actionId不能为空");
+        }
+        try {
+            MicroActionRecord record = microActionService.completeAction(userId, childId, actionId);
+            return Result.success(record);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "检查今日是否已完成")
+    @PostMapping("/check-today")
+    public Result<Map<String, Object>> checkToday(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java

@@ -100,4 +100,12 @@ public class ShareController {
         Map<String, Object> stats = shareService.getShareStats(userId);
         return Result.success(stats);
     }
+
+    @Operation(summary = "获取家庭健康足迹(P3分享页)")
+    @PostMapping("/family-footprint")
+    public Result<Map<String, Object>> familyFootprint(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(shareService.getFamilyFootprint(familyId));
+    }
 }

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -63,6 +63,9 @@ public class AdminController {
     @Resource
     private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
+    @Resource
+    private AssessmentAppointmentMapper assessmentAppointmentMapper;
+
     // 用户管理
     @PostMapping("/users")
     public Result<Page<User>> getUsers(@RequestBody Map<String, Object> params) {
@@ -978,4 +981,100 @@ public class AdminController {
                         .orderByDesc(MemberUpgradeRecord::getCreatedAt));
         return Result.success(result);
     }
+
+    // ========== 测评待分配规划师 ==========
+
+    @PostMapping("/assessment/pending-list")
+    public Result<Map<String, Object>> getPendingAssessmentList(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 10;
+        Page<AssessmentAppointment> pageParam = new Page<>(page, size);
+
+        Page<AssessmentAppointment> result = assessmentAppointmentMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<AssessmentAppointment>()
+                        .isNull(AssessmentAppointment::getGuideId)
+                        .orderByDesc(AssessmentAppointment::getCreatedAt));
+
+        java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();
+        for (AssessmentAppointment app : result.getRecords()) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", app.getId());
+            item.put("familyId", app.getFamilyId());
+            item.put("childId", app.getChildId());
+            item.put("appointmentDate", app.getAppointmentDate());
+            item.put("appointmentTime", app.getAppointmentTime());
+            item.put("durationMinutes", app.getDurationMinutes());
+            item.put("status", app.getStatus());
+            item.put("appointmentType", app.getAppointmentType());
+            item.put("notes", app.getNotes());
+            item.put("contactPhone", app.getContactPhone());
+            item.put("createdAt", app.getCreatedAt());
+
+            // 关联查询用户昵称
+            User user = userMapper.selectById(app.getUserId());
+            item.put("userName", user != null ? user.getNickname() : "未知用户");
+
+            // 关联查询家庭成员名称(孩子)
+            if (app.getChildId() != null) {
+                FamilyMember child = familyMemberMapper.selectById(app.getChildId());
+                item.put("childName", child != null ? child.getNickname() : "未知");
+            } else {
+                item.put("childName", "");
+            }
+
+            list.add(item);
+        }
+
+        Map<String, Object> resultMap = new HashMap<>();
+        resultMap.put("records", list);
+        resultMap.put("total", result.getTotal());
+        resultMap.put("current", result.getCurrent());
+        resultMap.put("size", result.getSize());
+        return Result.success(resultMap);
+    }
+
+    @PostMapping("/assessment/available-guides")
+    public Result<java.util.List<Map<String, Object>>> getAvailableGuides() {
+        java.util.List<User> teachers = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getRole, "teacher")
+                        .eq(User::getTeacherStatus, "approved")
+                        .select(User::getId, User::getNickname, User::getRealName, User::getPhone, User::getTeacherNo));
+        java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();
+        for (User t : teachers) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", t.getId());
+            item.put("nickname", t.getNickname());
+            item.put("realName", t.getRealName());
+            item.put("phone", t.getPhone());
+            item.put("teacherNo", t.getTeacherNo());
+            list.add(item);
+        }
+        return Result.success(list);
+    }
+
+    @PostMapping("/assessment/assign-guide")
+    public Result<Void> assignGuide(@RequestBody Map<String, Object> body) {
+        Long appointmentId = body.get("appointmentId") != null ? Long.valueOf(body.get("appointmentId").toString()) : null;
+        Long guideId = body.get("guideId") != null ? Long.valueOf(body.get("guideId").toString()) : null;
+        if (appointmentId == null || guideId == null) {
+            return Result.error("appointmentId 和 guideId 不能为空");
+        }
+
+        AssessmentAppointment appointment = assessmentAppointmentMapper.selectById(appointmentId);
+        if (appointment == null) {
+            return Result.error("预约记录不存在");
+        }
+
+        User guide = userMapper.selectById(guideId);
+        if (guide == null || !"teacher".equals(guide.getRole())) {
+            return Result.error("规划师不存在或角色不正确");
+        }
+
+        appointment.setGuideId(guideId);
+        appointment.setTeacherId(guideId);
+        appointment.setStatus(1); // 已确认
+        assessmentAppointmentMapper.updateById(appointment);
+        return Result.success(null);
+    }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AssessmentAppointment.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.entity;
 
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -18,7 +20,9 @@ public class AssessmentAppointment implements Serializable {
     private Long userId;
     private Long familyId;
     private Long childId;
+    @TableField(insertStrategy = FieldStrategy.IGNORED)
     private Long teacherId;
+    @TableField(insertStrategy = FieldStrategy.IGNORED)
     private Long guideId;
     private Long assessorId; // 测评师ID(供应商体系)
     private Long packageId;

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CircleChallenge.java

@@ -0,0 +1,37 @@
+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("circle_challenge")
+public class CircleChallenge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+
+    private String challengeType;
+
+    private String title;
+
+    private String description;
+
+    private Integer durationDays;
+
+    private String period;
+
+    private Date startDate;
+
+    private Date endDate;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DailyFeedback.java

@@ -0,0 +1,43 @@
+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("daily_feedback")
+public class DailyFeedback implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID */
+    private Long childId;
+
+    /** 反馈日期 */
+    private Date feedbackDate;
+
+    /** 反馈类型: daily(每日摘要)/weekly(每周)/milestone(里程碑) */
+    private String feedbackType;
+
+    /** AI生成的反馈文本 */
+    private String content;
+
+    /** 关联的里程碑事件(如有) */
+    private String milestoneEvent;
+
+    /** 是否已读: 0未读 1已读 */
+    private Integer isRead;
+
+    /** 来源: ai_generated / preset */
+    private String source;
+
+    private Date createdAt;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java

@@ -0,0 +1,39 @@
+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("family_challenge")
+public class FamilyChallenge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private String challengeType;
+
+    private String title;
+
+    private String description;
+
+    private Integer durationDays;
+
+    private Integer rewardPoints;
+
+    private String rewardBadge;
+
+    private Date startDate;
+
+    private Date endDate;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.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("family_challenge_progress")
+public class FamilyChallengeProgress implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long challengeId;
+
+    private Long childId;
+
+    private Integer progressValue;
+
+    private Boolean completed;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthAlert.java

@@ -0,0 +1,46 @@
+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_alerts")
+public class HealthAlert implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID */
+    private Long childId;
+
+    /** 规则编码: R001-R006 */
+    private String ruleCode;
+
+    /** 预警文案 */
+    private String alertText;
+
+    /** 行动建议 */
+    private String actionSuggestion;
+
+    /** 严重程度: high/medium/low */
+    private String severity;
+
+    /** 是否已关闭: 0未关闭 1已关闭 */
+    private Integer dismissed;
+
+    /** 关闭时间 */
+    private Date dismissedAt;
+
+    /** 预警日期 */
+    private Date alertDate;
+
+    private Date createdAt;
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircle.java

@@ -0,0 +1,31 @@
+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_circle")
+public class HealthCircle implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    private String description;
+
+    private Long creatorFamilyId;
+
+    private String inviteCode;
+
+    private Integer memberLimit;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircleMember.java

@@ -0,0 +1,31 @@
+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_circle_member")
+public class HealthCircleMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+
+    private Long familyId;
+
+    private String role;
+
+    private Date joinedAt;
+
+    private Date leftAt;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.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("health_interaction")
+public class HealthInteraction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long fromUserId;
+
+    private Long toUserId;
+
+    private String actionType;
+
+    private Long actionId;
+
+    private Date createdAt;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthLevel.java

@@ -0,0 +1,20 @@
+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;
+
+@Data
+@TableName("health_level")
+public class HealthLevel implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Integer levelNum;
+    private String title;
+    private String badgeIcon;
+    private Integer minScore;
+    private Integer maxScore;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthTimelineEvent.java

@@ -0,0 +1,23 @@
+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_timeline_event")
+public class HealthTimelineEvent implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+    private Long childId;
+    private String eventType;
+    private String eventText;
+    private String refId;
+    private Date eventDate;
+    private Date createdAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MicroAction.java

@@ -0,0 +1,37 @@
+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("micro_actions")
+public class MicroAction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 行动编码: DRINK_WATER, DEEP_BREATH, ... */
+    private String code;
+
+    /** 行动名称: 喝一杯水 */
+    private String name;
+
+    /** 图标emoji */
+    private String icon;
+
+    /** 行动描述 */
+    private String description;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态: 1启用 0禁用 */
+    private Integer status;
+
+    private Date createdAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MicroActionRecord.java

@@ -0,0 +1,37 @@
+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("micro_action_records")
+public class MicroActionRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID (childId) */
+    private Long childId;
+
+    /** 行动ID (对应micro_actions.id) */
+    private Long actionId;
+
+    /** 完成日期 */
+    private Date completedDate;
+
+    /** 获得能量值 */
+    private Integer earnedEnergy;
+
+    /** 备注 */
+    private String note;
+
+    private Date createdAt;
+}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 79 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleChallengeService.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.mapper.CircleChallengeMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class CircleChallengeService {
+
+    @Resource
+    private CircleChallengeMapper circleChallengeMapper;
+
+    public List<CircleChallenge> getActiveChallenges(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .eq(CircleChallenge::getStatus, "active");
+        List<CircleChallenge> active = circleChallengeMapper.selectList(cw);
+        if (active.isEmpty()) {
+            autoCreateChallenges(circleId);
+            active = circleChallengeMapper.selectList(cw);
+        }
+        return active;
+    }
+
+    @Transactional
+    public void autoCreateChallenges(Long circleId) {
+        Calendar cal = Calendar.getInstance();
+        int year = cal.get(Calendar.YEAR);
+        int month = cal.get(Calendar.MONTH);
+        int day = cal.get(Calendar.DAY_OF_MONTH);
+
+        String[][] templates = {
+            {"weekly_checkin", "全员达标赛", "圈内家庭全员打卡≥5天", "7", "weekly"},
+            {"biweekly_sports", "运动总动员", "圈内运动时长前3家庭", "14", "biweekly"},
+            {"monthly_rising", "健康新秀榜", "圈内积分增长最快家庭", "30", "monthly"}
+        };
+
+        for (String[] t : templates) {
+            LambdaQueryWrapper<CircleChallenge> exists = new LambdaQueryWrapper<CircleChallenge>()
+                    .eq(CircleChallenge::getCircleId, circleId)
+                    .eq(CircleChallenge::getChallengeType, t[0])
+                    .eq(CircleChallenge::getStatus, "active");
+            if (circleChallengeMapper.selectCount(exists) > 0) continue;
+
+            CircleChallenge challenge = new CircleChallenge();
+            challenge.setCircleId(circleId);
+            challenge.setChallengeType(t[0]);
+            challenge.setTitle(t[1]);
+            challenge.setDescription(t[2]);
+            challenge.setDurationDays(Integer.parseInt(t[3]));
+            challenge.setPeriod(t[4]);
+            challenge.setStartDate(new Date());
+            cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_YEAR, Integer.parseInt(t[3]));
+            challenge.setEndDate(cal.getTime());
+            challenge.setStatus("active");
+            challenge.setCreatedAt(new Date());
+            circleChallengeMapper.insert(challenge);
+            log.info("已自动创建圈挑战: circleId={}, type={}", circleId, t[0]);
+        }
+    }
+
+    public List<CircleChallenge> getChallengeHistory(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .eq(CircleChallenge::getStatus, "completed")
+                .orderByDesc(CircleChallenge::getEndDate);
+        return circleChallengeMapper.selectList(cw);
+    }
+}

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyFeedbackService.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DailyFeedback;
+import com.etotem.cfc.entity.HealthCheckin;
+import com.etotem.cfc.entity.MicroActionRecord;
+import com.etotem.cfc.mapper.DailyFeedbackMapper;
+import com.etotem.cfc.mapper.HealthCheckinMapper;
+import com.etotem.cfc.mapper.MicroActionRecordMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class DailyFeedbackService {
+
+    @Resource
+    private DailyFeedbackMapper dailyFeedbackMapper;
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    /**
+     * 获取今日反馈
+     */
+    public DailyFeedback getTodayFeedback(Long childId) {
+        if (childId == null) return null;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        return dailyFeedbackMapper.selectOne(
+            new LambdaQueryWrapper<DailyFeedback>()
+                .eq(DailyFeedback::getChildId, childId)
+                .apply("DATE(feedback_date) = {0}", today)
+                .eq(DailyFeedback::getFeedbackType, "daily")
+                .last("LIMIT 1")
+        );
+    }
+
+    /**
+     * 标记反馈为已读
+     */
+    public void markAsRead(Long feedbackId) {
+        DailyFeedback fb = dailyFeedbackMapper.selectById(feedbackId);
+        if (fb != null) {
+            fb.setIsRead(1);
+            dailyFeedbackMapper.updateById(fb);
+        }
+    }
+
+    /**
+     * 获取最近N条反馈
+     */
+    public List<DailyFeedback> getRecentFeedbacks(Long childId, int limit) {
+        if (childId == null) return Collections.emptyList();
+        return dailyFeedbackMapper.selectList(
+            new LambdaQueryWrapper<DailyFeedback>()
+                .eq(DailyFeedback::getChildId, childId)
+                .orderByDesc(DailyFeedback::getFeedbackDate)
+                .last("LIMIT " + limit)
+        );
+    }
+
+    /**
+     * 生成预设反馈(基于当日行为)
+     * 目前使用预设文案,后续可对接AI Gateway
+     */
+    public DailyFeedback generatePresetFeedback(Long userId, Long childId) {
+        if (childId == null) return null;
+
+        // Check if already generated today
+        DailyFeedback existing = getTodayFeedback(childId);
+        if (existing != null) return existing;
+
+        // Check today's activities
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+
+        boolean hasCheckin = healthCheckinMapper.selectCount(
+            new LambdaQueryWrapper<HealthCheckin>()
+                .eq(HealthCheckin::getChildId, childId)
+                .apply("DATE(checkin_date) = {0}", today)
+        ) > 0;
+
+        boolean hasMicroAction = microActionRecordMapper.selectCount(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .apply("DATE(completed_date) = {0}", today)
+        ) > 0;
+
+        // Generate feedback text
+        String content;
+        if (hasCheckin || hasMicroAction) {
+            content = getRandomEncouragement();
+        } else {
+            content = "今天还没有记录健康行动,从一个小行动开始吧 ☕️";
+        }
+
+        DailyFeedback feedback = new DailyFeedback();
+        feedback.setUserId(userId);
+        feedback.setChildId(childId);
+        feedback.setFeedbackDate(new Date());
+        feedback.setFeedbackType("daily");
+        feedback.setContent(content);
+        feedback.setIsRead(0);
+        feedback.setSource("preset");
+        feedback.setCreatedAt(new Date());
+        dailyFeedbackMapper.insert(feedback);
+        return feedback;
+    }
+
+    private String getRandomEncouragement() {
+        String[] messages = {
+            "太棒了!今天又迈出了健康的一步 🎉",
+            "坚持就是胜利!每天进步一点点 💪",
+            "你的身体正在感谢你的每一个健康选择 🌟",
+            "今天的努力是明天的健康!继续加油 ⭐",
+            "每一个微行动都是对自己的关爱 ❤️",
+            "你今天做得很棒!健康习惯正在养成 🌱",
+            "今天的你已经比昨天更健康了!🔥",
+            "健康是一种生活方式,你今天活出了它!✨"
+        };
+        return messages[new Random().nextInt(messages.length)];
+    }
+}

+ 175 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -0,0 +1,175 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.entity.FamilyChallengeProgress;
+import com.etotem.cfc.mapper.FamilyChallengeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeProgressMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class FamilyChallengeService {
+
+    @Resource
+    private FamilyChallengeMapper familyChallengeMapper;
+
+    @Resource
+    private FamilyChallengeProgressMapper familyChallengeProgressMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    public List<FamilyChallenge> getActiveChallenges(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+
+        LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
+                .eq(FamilyChallenge::getFamilyId, familyId)
+                .eq(FamilyChallenge::getStatus, "active");
+        List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
+
+        if (active.isEmpty()) {
+            autoCreateChallenges(familyId);
+            active = familyChallengeMapper.selectList(wrapper);
+        }
+
+        return active;
+    }
+
+    @Transactional
+    public void autoCreateChallenges(Long familyId) {
+        Calendar cal = Calendar.getInstance();
+        int year = cal.get(Calendar.YEAR);
+        int month = cal.get(Calendar.MONTH);
+
+        cal.set(year, month, 1, 0, 0, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date startDate = cal.getTime();
+
+        cal.add(Calendar.MONTH, 1);
+        Date endDate = cal.getTime();
+
+        String[][] templates = {
+            {"full_checkin", "全员打卡挑战", "全家连续打卡3天,一起养成好习惯!", "3", "50", ""},
+            {"sports", "运动PK挑战", "全周家庭总运动时长超过300分钟!", "7", "100", ""},
+            {"health_week", "健康周挑战", "全员每日完成微行动,连续7天健康生活!", "7", "50", "健康之家"}
+        };
+
+        for (String[] t : templates) {
+            LambdaQueryWrapper<FamilyChallenge> exists = new LambdaQueryWrapper<FamilyChallenge>()
+                    .eq(FamilyChallenge::getFamilyId, familyId)
+                    .eq(FamilyChallenge::getChallengeType, t[0])
+                    .eq(FamilyChallenge::getStartDate, startDate);
+            if (familyChallengeMapper.selectCount(exists) > 0) continue;
+
+            FamilyChallenge challenge = new FamilyChallenge();
+            challenge.setFamilyId(familyId);
+            challenge.setChallengeType(t[0]);
+            challenge.setTitle(t[1]);
+            challenge.setDescription(t[2]);
+            challenge.setDurationDays(Integer.parseInt(t[3]));
+            challenge.setRewardPoints(Integer.parseInt(t[4]));
+            challenge.setRewardBadge(t[5].isEmpty() ? null : t[5]);
+            challenge.setStartDate(startDate);
+            challenge.setEndDate(endDate);
+            challenge.setStatus("active");
+            challenge.setCreatedAt(new Date());
+            familyChallengeMapper.insert(challenge);
+            log.info("已自动创建挑战: familyId={}, type={}, start={}", familyId, t[0], startDate);
+        }
+    }
+
+    @Transactional
+    public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
+        if (challengeId == null || childId == null) {
+            return Result.error("参数错误");
+        }
+
+        LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
+                .eq(FamilyChallengeProgress::getChallengeId, challengeId)
+                .eq(FamilyChallengeProgress::getChildId, childId);
+        FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
+
+        if (progress == null) {
+            progress = new FamilyChallengeProgress();
+            progress.setChallengeId(challengeId);
+            progress.setChildId(childId);
+            progress.setProgressValue(delta);
+            progress.setCompleted(false);
+            progress.setCreatedAt(new Date());
+            progress.setUpdatedAt(new Date());
+            familyChallengeProgressMapper.insert(progress);
+        } else {
+            progress.setProgressValue(progress.getProgressValue() + delta);
+            progress.setUpdatedAt(new Date());
+            familyChallengeProgressMapper.updateById(progress);
+        }
+
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge != null && "active".equals(challenge.getStatus())) {
+            checkAndSettle(challenge);
+        }
+
+        return Result.success();
+    }
+
+    @Transactional
+    public void settleChallenge(Long challengeId) {
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) return;
+        checkAndSettle(challenge);
+    }
+
+    private void checkAndSettle(FamilyChallenge challenge) {
+        boolean isCompleted = false;
+        LambdaQueryWrapper<FamilyChallengeProgress> pw = new LambdaQueryWrapper<FamilyChallengeProgress>()
+                .eq(FamilyChallengeProgress::getChallengeId, challenge.getId());
+        List<FamilyChallengeProgress> progresses = familyChallengeProgressMapper.selectList(pw);
+        int totalProgress = 0;
+        for (FamilyChallengeProgress p : progresses) {
+            totalProgress += (p.getProgressValue() != null ? p.getProgressValue() : 0);
+        }
+
+        if ("full_checkin".equals(challenge.getChallengeType())) {
+            isCompleted = progresses.size() > 0;
+            for (FamilyChallengeProgress p : progresses) {
+                if (p.getProgressValue() == null || p.getProgressValue() < 3) {
+                    isCompleted = false;
+                    break;
+                }
+            }
+        } else if ("sports".equals(challenge.getChallengeType())) {
+            isCompleted = totalProgress >= 300;
+        } else if ("health_week".equals(challenge.getChallengeType())) {
+            isCompleted = progresses.size() > 0;
+            for (FamilyChallengeProgress p : progresses) {
+                if (p.getProgressValue() == null || p.getProgressValue() < 7) {
+                    isCompleted = false;
+                    break;
+                }
+            }
+        }
+
+        if (isCompleted || challenge.getEndDate() == null || new Date().after(challenge.getEndDate())) {
+            challenge.setStatus("completed");
+            familyChallengeMapper.updateById(challenge);
+            log.info("挑战已结算: id={}, type={}, completed={}", challenge.getId(), challenge.getChallengeType(), isCompleted);
+        }
+    }
+
+    public List<FamilyChallenge> getChallengeHistory(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+        LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
+                .eq(FamilyChallenge::getFamilyId, familyId)
+                .eq(FamilyChallenge::getStatus, "completed")
+                .orderByDesc(FamilyChallenge::getEndDate);
+        return familyChallengeMapper.selectList(wrapper);
+    }
+}

+ 100 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class FamilyHealthScoreService {
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    public Map<String, Object> getFamilyRanking(Long familyId, Long currentUserId, String role) {
+        List<Map<String, Object>> ranking = new ArrayList<>();
+
+        if (familyId == null) {
+            Map<String, Object> result = new HashMap<>();
+            result.put("members", ranking);
+            result.put("totalScore", 0);
+            return result;
+        }
+
+        LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> allMembers = familyMemberMapper.selectList(wrapper);
+
+        List<FamilyMember> children = new ArrayList<>();
+        for (FamilyMember m : allMembers) {
+            if ("child".equals(m.getRoleOverride())) {
+                children.add(m);
+            }
+        }
+
+        if ("child".equals(role)) {
+            FamilyMember self = null;
+            for (FamilyMember m : children) {
+                if (m.getUserId() != null && m.getUserId().equals(currentUserId)) {
+                    self = m;
+                    break;
+                }
+            }
+            if (self != null) {
+                children.clear();
+                children.add(self);
+            }
+        }
+
+        List<MemberScore> scored = new ArrayList<>();
+        for (FamilyMember m : children) {
+            int score = healthScoreService.calculateTotalScore(m.getId());
+            int streak = healthScoreService.calculateStreakDays(m.getId());
+            scored.add(new MemberScore(m, score, streak));
+        }
+
+        scored.sort((a, b) -> b.score - a.score);
+
+        int rank = 1;
+        int totalScore = 0;
+        for (MemberScore ms : scored) {
+            totalScore += ms.score;
+            Map<String, Object> entry = new HashMap<>();
+            entry.put("childId", ms.member.getId());
+            entry.put("name", ms.member.getNickname());
+            entry.put("avatar", ms.member.getAvatar());
+            entry.put("score", ms.score);
+            entry.put("streakDays", ms.streak);
+            entry.put("rank", rank++);
+            ranking.add(entry);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("members", ranking);
+        result.put("totalScore", totalScore);
+        return result;
+    }
+
+    private static class MemberScore {
+        final FamilyMember member;
+        final int score;
+        final int streak;
+
+        MemberScore(FamilyMember member, int score, int streak) {
+            this.member = member;
+            this.score = score;
+            this.streak = streak;
+        }
+    }
+}

+ 280 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertEngine.java

@@ -0,0 +1,280 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Component
+public class HealthAlertEngine {
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+
+    @Resource
+    private HealthExerciseRecordMapper healthExerciseRecordMapper;
+
+    @Resource
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    /** 规则定义:编码 -> (文案, 建议, 严重程度) */
+    private static final Map<String, String[]> RULES = new LinkedHashMap<>();
+
+    static {
+        RULES.put("R001", new String[]{
+            "您已连续%d天睡眠质量下降",
+            "睡前1小时放下手机,喝杯温牛奶",
+            "medium"
+        });
+        RULES.put("R002", new String[]{
+            "您已经%d天没有运动了",
+            "站起来走3分钟就是好的开始!",
+            "medium"
+        });
+        RULES.put("R003", new String[]{
+            "近%d天饮水量偏低",
+            "每1小时喝一杯水,设置定时提醒",
+            "low"
+        });
+        RULES.put("R004", new String[]{
+            "今天运动量比平时多一半",
+            "适当拉伸,不要过度训练",
+            "medium"
+        });
+        RULES.put("R005", new String[]{
+            "您已经%d天没有健康打卡",
+            "从最小的动作开始:喝一杯水 ☕️",
+            "high"
+        });
+        RULES.put("R006", new String[]{
+            "今天的健康打卡还没完成哦",
+            "点我完成今日微行动",
+            "high"
+        });
+    }
+
+    public static Map<String, String[]> getRules() {
+        return RULES;
+    }
+
+    /**
+     * 评估单个成员的所有预警规则
+     * @return 命中的规则列表(含填充后的文案和严重程度)
+     */
+    public List<Map<String, String>> evaluate(Long childId) {
+        if (childId == null) return Collections.emptyList();
+        List<Map<String, String>> results = new ArrayList<>();
+
+        Calendar cal = Calendar.getInstance();
+        Date today = cal.getTime();
+
+        // R006: 今日未打卡(18:00后触发)
+        cal.set(Calendar.HOUR_OF_DAY, 18);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        Date triggerTime = cal.getTime();
+        if (today.after(triggerTime)) {
+            String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(today);
+            long todayCheckinCount = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", todayStr)
+            );
+            long todayMicroCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", todayStr)
+            );
+            if (todayCheckinCount == 0 && todayMicroCount == 0) {
+                results.add(buildResult("R006", 0));
+                return results;
+            }
+        }
+
+        // R005: 连续7天无打卡
+        int noCheckinDays = countConsecutiveDaysWithoutCheckin(childId, today);
+        if (noCheckinDays >= 7) {
+            results.add(buildResult("R005", noCheckinDays));
+        }
+
+        // R001: 连续3天睡眠评分下降
+        if (checkSleepDecline(childId)) {
+            results.add(buildResult("R001", 3));
+        }
+
+        // R002: 连续5天无运动
+        int noExerciseDays = countDaysSinceLastExercise(childId);
+        if (noExerciseDays >= 5) {
+            results.add(buildResult("R002", noExerciseDays));
+        }
+
+        // R003: 饮水<500ml 连续2天
+        int lowWaterDays = countConsecutiveLowWaterDays(childId);
+        if (lowWaterDays >= 2) {
+            results.add(buildResult("R003", lowWaterDays));
+        }
+
+        // R004: 运动强度突增>50%
+        if (checkExerciseSurge(childId)) {
+            results.add(buildResult("R004", 0));
+        }
+
+        return results;
+    }
+
+    private Map<String, String> buildResult(String ruleCode, int days) {
+        String[] rule = RULES.get(ruleCode);
+        Map<String, String> result = new HashMap<>();
+        String text = String.format(rule[0], days);
+        result.put("ruleCode", ruleCode);
+        result.put("alertText", text);
+        result.put("actionSuggestion", rule[1]);
+        result.put("severity", rule[2]);
+        return result;
+    }
+
+    /** 计算连续无打卡天数(从今天往回数) */
+    private int countConsecutiveDaysWithoutCheckin(Long childId, Date today) {
+        int days = 0;
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(today);
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        for (int i = 0; i < 30; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            long count = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", dateStr)
+            );
+            long microCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", dateStr)
+            );
+            if (count > 0 || microCount > 0) break;
+            days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查连续3天睡眠评分下降 */
+    private boolean checkSleepDecline(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        List<Integer> scores = new ArrayList<>();
+
+        for (int i = 0; i < 4; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            HealthSleepRecord record = healthSleepRecordMapper.selectOne(
+                new LambdaQueryWrapper<HealthSleepRecord>()
+                    .eq(HealthSleepRecord::getMemberId, childId)
+                    .apply("DATE(created_at) = {0}", dateStr)
+                    .last("LIMIT 1")
+            );
+            if (record != null && record.getQualityScore() != null) {
+                scores.add(record.getQualityScore());
+            } else {
+                return false; // 某天无数据,无法判断趋势
+            }
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+
+        // 需要最近4天数据,检查最近3天的趋势
+        if (scores.size() < 4) return false;
+        // 每天严格递减才算"持续下降"
+        return scores.get(0) < scores.get(1) &&
+               scores.get(1) < scores.get(2) &&
+               scores.get(2) < scores.get(3);
+    }
+
+    /** 计算距离上次运动的天数 */
+    private int countDaysSinceLastExercise(Long childId) {
+        HealthExerciseRecord last = healthExerciseRecordMapper.selectOne(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .orderByDesc(HealthExerciseRecord::getStartTime)
+                .last("LIMIT 1")
+        );
+        if (last == null || last.getStartTime() == null) return 999;
+        long diff = System.currentTimeMillis() - last.getStartTime().getTime();
+        return (int) (diff / (1000 * 60 * 60 * 24));
+    }
+
+    /** 计算连续饮水偏低天数 */
+    private int countConsecutiveLowWaterDays(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        int days = 0;
+
+        for (int i = 0; i < 7; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            // 统计当天总饮水量
+            List<HealthWaterRecord> records = healthWaterRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthWaterRecord>()
+                    .eq(HealthWaterRecord::getMemberId, childId)
+                    .apply("DATE(drunk_at) = {0}", dateStr)
+            );
+            int totalMl = records.stream().filter(r -> r.getAmountMl() != null)
+                                .mapToInt(HealthWaterRecord::getAmountMl).sum();
+            if (totalMl >= 500) break;
+            if (!records.isEmpty()) days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查运动量是否突增 >50%(对比前3天均值) */
+    private boolean checkExerciseSurge(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        // 今天运动量
+        String todayStr = sdf.format(cal.getTime());
+        int todayDuration = 0;
+        List<HealthExerciseRecord> todayRecords = healthExerciseRecordMapper.selectList(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .apply("DATE(start_time) = {0}", todayStr)
+        );
+        for (HealthExerciseRecord r : todayRecords) {
+            if (r.getDurationMinutes() != null) todayDuration += r.getDurationMinutes();
+        }
+        if (todayDuration == 0) return false;
+
+        // 过去3天均值
+        int prevTotal = 0;
+        int prevCount = 0;
+        for (int i = 1; i <= 3; i++) {
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+            String prevStr = sdf.format(cal.getTime());
+            List<HealthExerciseRecord> prevRecords = healthExerciseRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthExerciseRecord>()
+                    .eq(HealthExerciseRecord::getMemberId, childId)
+                    .apply("DATE(start_time) = {0}", prevStr)
+            );
+            for (HealthExerciseRecord r : prevRecords) {
+                if (r.getDurationMinutes() != null) {
+                    prevTotal += r.getDurationMinutes();
+                    prevCount++;
+                }
+            }
+        }
+        if (prevCount == 0) return false;
+        int avgPrev = prevTotal / prevCount;
+        return avgPrev > 0 && todayDuration > avgPrev * 1.5;
+    }
+}

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertService.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.mapper.HealthAlertMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthAlertService {
+
+    @Resource
+    private HealthAlertMapper healthAlertMapper;
+
+    @Resource
+    private HealthAlertEngine healthAlertEngine;
+
+    /**
+     * 获取成员的活跃预警(未关闭的)
+     * 同时触发引擎评估新规则
+     */
+    public List<HealthAlert> getActiveAlerts(Long childId) {
+        if (childId == null) return Collections.emptyList();
+
+        // 1. 触发引擎评估
+        evaluateAndSave(childId);
+
+        // 2. 返回今日未关闭预警,最多5条
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        List<HealthAlert> alerts = healthAlertMapper.selectList(
+            new LambdaQueryWrapper<HealthAlert>()
+                .eq(HealthAlert::getChildId, childId)
+                .eq(HealthAlert::getDismissed, 0)
+                .apply("DATE(alert_date) = {0}", today)
+                .orderByDesc(HealthAlert::getSeverity)
+                .last("LIMIT 5")
+        );
+        // 按严重程度排序: high > medium > low
+        alerts.sort((a, b) -> {
+            int scoreA = severityScore(a.getSeverity());
+            int scoreB = severityScore(b.getSeverity());
+            return scoreB - scoreA;
+        });
+        return alerts;
+    }
+
+    /** 触发引擎评估并去重保存 */
+    private void evaluateAndSave(Long childId) {
+        List<Map<String, String>> triggered = healthAlertEngine.evaluate(childId);
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+
+        for (Map<String, String> rule : triggered) {
+            String ruleCode = rule.get("ruleCode");
+
+            // 去重:同一规则24h内不重复
+            long existing = healthAlertMapper.selectCount(
+                new LambdaQueryWrapper<HealthAlert>()
+                    .eq(HealthAlert::getChildId, childId)
+                    .eq(HealthAlert::getRuleCode, ruleCode)
+                    .apply("DATE(alert_date) = {0}", today)
+            );
+            if (existing > 0) continue;
+
+            HealthAlert alert = new HealthAlert();
+            alert.setChildId(childId);
+            alert.setRuleCode(ruleCode);
+            alert.setAlertText(rule.get("alertText"));
+            alert.setActionSuggestion(rule.get("actionSuggestion"));
+            alert.setSeverity(rule.get("severity"));
+            alert.setDismissed(0);
+            alert.setAlertDate(new Date());
+            alert.setCreatedAt(new Date());
+            healthAlertMapper.insert(alert);
+            log.info("预警 {} 已为 childId={} 生成", ruleCode, childId);
+        }
+    }
+
+    /** 关闭预警 */
+    public void dismiss(Long alertId) {
+        HealthAlert alert = healthAlertMapper.selectById(alertId);
+        if (alert != null) {
+            alert.setDismissed(1);
+            alert.setDismissedAt(new Date());
+            healthAlertMapper.updateById(alert);
+        }
+    }
+
+    private int severityScore(String severity) {
+        if ("high".equals(severity)) return 3;
+        if ("medium".equals(severity)) return 2;
+        if ("low".equals(severity)) return 1;
+        return 0;
+    }
+}

+ 208 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthCircleService.java

@@ -0,0 +1,208 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.entity.HealthCircle;
+import com.etotem.cfc.entity.HealthCircleMember;
+import com.etotem.cfc.mapper.CircleChallengeMapper;
+import com.etotem.cfc.mapper.HealthCircleMapper;
+import com.etotem.cfc.mapper.HealthCircleMemberMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthCircleService {
+
+    @Resource
+    private HealthCircleMapper healthCircleMapper;
+
+    @Resource
+    private HealthCircleMemberMapper healthCircleMemberMapper;
+
+    @Resource
+    private CircleChallengeMapper circleChallengeMapper;
+
+    @Resource
+    private FamilyHealthScoreService familyHealthScoreService;
+
+    public List<HealthCircle> getUserCircles(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        List<HealthCircleMember> memberships = healthCircleMemberMapper.selectList(mw);
+        if (memberships.isEmpty()) return new ArrayList<>();
+
+        List<Long> circleIds = new ArrayList<>();
+        for (HealthCircleMember m : memberships) {
+            circleIds.add(m.getCircleId());
+        }
+        LambdaQueryWrapper<HealthCircle> cw = new LambdaQueryWrapper<HealthCircle>()
+                .in(HealthCircle::getId, circleIds)
+                .eq(HealthCircle::getStatus, "active");
+        return healthCircleMapper.selectList(cw);
+    }
+
+    @Transactional
+    public Result<HealthCircle> createCircle(String name, String description, Long creatorFamilyId) {
+        if (name == null || name.trim().isEmpty()) {
+            return Result.error("圈名称不能为空");
+        }
+        int count = countActiveCircles(creatorFamilyId);
+        if (count >= 3) {
+            return Result.error("每个家庭最多加入3个健康圈");
+        }
+
+        HealthCircle circle = new HealthCircle();
+        circle.setName(name.trim());
+        circle.setDescription(description);
+        circle.setCreatorFamilyId(creatorFamilyId);
+        circle.setInviteCode(generateInviteCode());
+        circle.setMemberLimit(20);
+        circle.setStatus("active");
+        circle.setCreatedAt(new Date());
+        healthCircleMapper.insert(circle);
+
+        HealthCircleMember owner = new HealthCircleMember();
+        owner.setCircleId(circle.getId());
+        owner.setFamilyId(creatorFamilyId);
+        owner.setRole("owner");
+        owner.setJoinedAt(new Date());
+        owner.setStatus("active");
+        owner.setCreatedAt(new Date());
+        healthCircleMemberMapper.insert(owner);
+
+        log.info("已创建健康圈: id={}, name={}, familyId={}", circle.getId(), name, creatorFamilyId);
+        return Result.success(circle);
+    }
+
+    @Transactional
+    public Result<Void> joinCircle(String inviteCode, Long familyId) {
+        if (inviteCode == null || familyId == null) {
+            return Result.error("参数错误");
+        }
+        LambdaQueryWrapper<HealthCircle> cw = new LambdaQueryWrapper<HealthCircle>()
+                .eq(HealthCircle::getInviteCode, inviteCode)
+                .eq(HealthCircle::getStatus, "active");
+        HealthCircle circle = healthCircleMapper.selectOne(cw);
+        if (circle == null) {
+            return Result.error("邀请码无效或圈已解散");
+        }
+
+        int count = countActiveCircles(familyId);
+        if (count >= 3) {
+            return Result.error("每个家庭最多加入3个健康圈");
+        }
+
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circle.getId())
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        if (healthCircleMemberMapper.selectCount(mw) > 0) {
+            return Result.error("已在圈中");
+        }
+
+        int memberCount = countCircleMembers(circle.getId());
+        if (memberCount >= circle.getMemberLimit()) {
+            return Result.error("圈已满员");
+        }
+
+        HealthCircleMember member = new HealthCircleMember();
+        member.setCircleId(circle.getId());
+        member.setFamilyId(familyId);
+        member.setRole("member");
+        member.setJoinedAt(new Date());
+        member.setStatus("active");
+        member.setCreatedAt(new Date());
+        healthCircleMemberMapper.insert(member);
+
+        log.info("家庭加入健康圈: familyId={}, circleId={}", familyId, circle.getId());
+        return Result.success();
+    }
+
+    @Transactional
+    public Result<Void> leaveCircle(Long circleId, Long familyId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        HealthCircleMember membership = healthCircleMemberMapper.selectOne(mw);
+        if (membership == null) {
+            return Result.error("不在该圈中");
+        }
+        membership.setStatus("left");
+        membership.setLeftAt(new Date());
+        healthCircleMemberMapper.updateById(membership);
+        log.info("家庭退出健康圈: familyId={}, circleId={}", familyId, circleId);
+        return Result.success();
+    }
+
+    public Map<String, Object> getCircleRanking(Long circleId) {
+        Map<String, Object> result = new HashMap<>();
+        if (circleId == null) return result;
+
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getStatus, "active");
+        List<HealthCircleMember> members = healthCircleMemberMapper.selectList(mw);
+
+        List<Map<String, Object>> familyScores = new ArrayList<>();
+        for (HealthCircleMember m : members) {
+            Map<String, Object> entry = familyHealthScoreService.getFamilyRanking(m.getFamilyId(), null, "parent");
+            int totalScore = 0;
+            if (entry != null && entry.get("totalScore") != null) {
+                totalScore = ((Number) entry.get("totalScore")).intValue();
+            }
+            Map<String, Object> familyEntry = new HashMap<>();
+            familyEntry.put("familyId", m.getFamilyId());
+            familyEntry.put("totalScore", totalScore);
+            familyEntry.put("memberCount", entry.get("members") != null ? ((List) entry.get("members")).size() : 0);
+            familyScores.add(familyEntry);
+        }
+
+        familyScores.sort((a, b) -> ((Number) b.get("totalScore")).intValue() - ((Number) a.get("totalScore")).intValue());
+
+        HealthCircle circle = healthCircleMapper.selectById(circleId);
+        result.put("circleName", circle != null ? circle.getName() : "");
+        result.put("rankings", familyScores);
+        return result;
+    }
+
+    public List<CircleChallenge> getCircleChallenges(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .orderByDesc(CircleChallenge::getCreatedAt);
+        return circleChallengeMapper.selectList(cw);
+    }
+
+    private int countActiveCircles(Long familyId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        return (int) healthCircleMemberMapper.selectCount(mw);
+    }
+
+    private int countCircleMembers(Long circleId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getStatus, "active");
+        return (int) healthCircleMemberMapper.selectCount(mw);
+    }
+
+    private String generateInviteCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        StringBuilder code = new StringBuilder();
+        Random rnd = new Random();
+        for (int i = 0; i < 6; i++) {
+            code.append(chars.charAt(rnd.nextInt(chars.length())));
+        }
+        return code.toString();
+    }
+}

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

@@ -0,0 +1,61 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthInteraction;
+import com.etotem.cfc.mapper.HealthInteractionMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class HealthInteractionService {
+
+    @Resource
+    private HealthInteractionMapper healthInteractionMapper;
+
+    @Transactional
+    public Result<Void> like(Long fromUserId, Long toUserId, String actionType, Long actionId, Long familyId) {
+        if (fromUserId == null || toUserId == null) {
+            return Result.error("参数错误");
+        }
+
+        boolean alreadyLiked = hasLiked24h(fromUserId, actionType, actionId);
+        if (alreadyLiked) {
+            return Result.error("已点赞,24小时内不可重复点赞");
+        }
+
+        HealthInteraction interaction = new HealthInteraction();
+        interaction.setFamilyId(familyId);
+        interaction.setFromUserId(fromUserId);
+        interaction.setToUserId(toUserId);
+        interaction.setActionType(actionType);
+        interaction.setActionId(actionId);
+        interaction.setCreatedAt(new Date());
+        healthInteractionMapper.insert(interaction);
+
+        log.info("点赞成功: fromUser={}, toUser={}, actionType={}, actionId={}", fromUserId, toUserId, actionType, actionId);
+        return Result.success();
+    }
+
+    public boolean hasLiked24h(Long fromUserId, String actionType, Long actionId) {
+        LocalDateTime since = LocalDateTime.now().minusHours(24);
+        Date sinceDate = Date.from(since.atZone(ZoneId.systemDefault()).toInstant());
+
+        LambdaQueryWrapper<HealthInteraction> wrapper = new LambdaQueryWrapper<HealthInteraction>()
+                .eq(HealthInteraction::getFromUserId, fromUserId)
+                .eq(HealthInteraction::getActionType, actionType)
+                .eq(HealthInteraction::getActionId, actionId)
+                .gt(HealthInteraction::getCreatedAt, sinceDate);
+
+        List<HealthInteraction> existing = healthInteractionMapper.selectList(wrapper);
+        return existing != null && !existing.isEmpty();
+    }
+}

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthLevelService.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthLevel;
+import com.etotem.cfc.mapper.HealthLevelMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class HealthLevelService {
+
+    @Resource
+    private HealthLevelMapper healthLevelMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    /** 获取所有等级定义 */
+    public List<HealthLevel> getAllLevels() {
+        List<HealthLevel> levels = healthLevelMapper.selectList(
+            new LambdaQueryWrapper<HealthLevel>().orderByAsc(HealthLevel::getLevelNum));
+        if (levels.isEmpty()) {
+            levels = seedDefaultLevels();
+        }
+        return levels;
+    }
+
+    /** 获取某成员的当前等级和进度 */
+    public Map<String, Object> getMemberLevelInfo(Long childId) {
+        int score = healthScoreService.calculateTotalScore(childId);
+        List<HealthLevel> levels = getAllLevels();
+
+        HealthLevel currentLevel = null;
+        HealthLevel nextLevel = null;
+        for (int i = 0; i < levels.size(); i++) {
+            HealthLevel lv = levels.get(i);
+            if (score >= lv.getMinScore() && score <= lv.getMaxScore()) {
+                currentLevel = lv;
+                if (i + 1 < levels.size()) nextLevel = levels.get(i + 1);
+                break;
+            }
+        }
+        if (currentLevel == null && !levels.isEmpty()) {
+            currentLevel = levels.get(0);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("score", score);
+        result.put("currentLevel", currentLevel);
+        result.put("nextLevel", nextLevel);
+        if (currentLevel != null && nextLevel != null) {
+            int range = nextLevel.getMinScore() - currentLevel.getMinScore();
+            int progress = score - currentLevel.getMinScore();
+            result.put("progress", range > 0 ? (int)((double)progress / range * 100) : 0);
+        } else {
+            result.put("progress", 100);
+        }
+        result.put("streakDays", healthScoreService.calculateStreakDays(childId));
+        return result;
+    }
+
+    /** 种子数据:5级默认等级 */
+    private List<HealthLevel> seedDefaultLevels() {
+        HealthLevel[][] seeds = {
+            { lv(1, "健康探索者", "🌱", 0, 99) },
+            { lv(2, "健康践行者", "🌿", 100, 299) },
+            { lv(3, "家庭健康官", "🏡", 300, 699) },
+            { lv(4, "健康达人", "⭐", 700, 1499) },
+            { lv(5, "健康领航者", "👑", 1500, 99999) }
+        };
+        for (HealthLevel[] arr : seeds) {
+            HealthLevel l = arr[0];
+            try {
+                healthLevelMapper.insert(l);
+            } catch (Exception e) {
+                log.warn("插入等级种子数据失败: {}", e.getMessage());
+            }
+        }
+        return healthLevelMapper.selectList(
+            new LambdaQueryWrapper<HealthLevel>().orderByAsc(HealthLevel::getLevelNum));
+    }
+
+    private HealthLevel lv(int num, String title, String icon, int min, int max) {
+        HealthLevel l = new HealthLevel();
+        l.setLevelNum(num);
+        l.setTitle(title);
+        l.setBadgeIcon(icon);
+        l.setMinScore(min);
+        l.setMaxScore(max);
+        return l;
+    }
+}

+ 122 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java

@@ -0,0 +1,122 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * 健康积分计算引擎(P2-1 身份重塑)
+ *
+ * 公式:
+ *   打卡(每次+2, 上限每日1次) +
+ *   微行动(每次+1, 上限每日1次) +
+ *   运动(每次+3, 上限每日1次) +
+ *   睡眠(每次+2, 上限每日1次) +
+ *   饮水(每次+1, 上限每日3次) +
+ *   饮食(每次+2, 上限每日1次) +
+ *   健康报告上传(每次+10) +
+ *   连续打卡加成(连续7天+5, 连续30天+20) +
+ *   互动(点赞他人+1, 被赞+1)
+ */
+@Slf4j
+@Service
+public class HealthScoreService {
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    @Resource
+    private HealthExerciseRecordMapper healthExerciseRecordMapper;
+
+    @Resource
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+
+    @Resource
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+
+    @Resource
+    private HealthMealRecordMapper healthMealRecordMapper;
+
+    /** 计算指定成员的总健康积分 */
+    public int calculateTotalScore(Long childId) {
+        if (childId == null) return 0;
+        int total = 0;
+
+        // 打卡: 每次+2
+        long checkinCount = healthCheckinMapper.selectCount(
+            new LambdaQueryWrapper<HealthCheckin>().eq(HealthCheckin::getChildId, childId));
+        total += (int) checkinCount * 2;
+
+        // 微行动: 每次+1
+        long microCount = microActionRecordMapper.selectCount(
+            new LambdaQueryWrapper<MicroActionRecord>().eq(MicroActionRecord::getChildId, childId));
+        total += (int) microCount * 1;
+
+        // 运动: 每次+3
+        long exerciseCount = healthExerciseRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthExerciseRecord>().eq(HealthExerciseRecord::getMemberId, childId));
+        total += (int) exerciseCount * 3;
+
+        // 睡眠: 每次+2
+        long sleepCount = healthSleepRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthSleepRecord>().eq(HealthSleepRecord::getMemberId, childId));
+        total += (int) sleepCount * 2;
+
+        // 饮水: 每次+1 (上限每日3次)
+        long waterCount = healthWaterRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthWaterRecord>().eq(HealthWaterRecord::getMemberId, childId));
+        long waterDays = countDistinctDays(childId, "water");
+        total += (int) Math.min(waterCount, waterDays * 3) * 1;
+
+        // 饮食: 每次+2 (上限每日1次)
+        long mealCount = healthMealRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthMealRecord>().eq(HealthMealRecord::getChildId, childId));
+        long mealDays = countDistinctDays(childId, "meal");
+        total += (int) Math.min(mealCount, mealDays * 1) * 2;
+
+        // 连续打卡加成
+        int streakDays = calculateStreakDays(childId);
+        if (streakDays >= 30) total += 20;
+        else if (streakDays >= 7) total += 5;
+
+        return total;
+    }
+
+    /** 计算连续打卡天数 */
+    public int calculateStreakDays(Long childId) {
+        if (childId == null) return 0;
+        int days = 0;
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        for (int i = 0; i < 365; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            long checkinCount = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", dateStr));
+            long microCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", dateStr));
+            if (checkinCount == 0 && microCount == 0) break;
+            days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    private long countDistinctDays(Long childId, String type) {
+        // Simplified: count records directly (approximation)
+        return 0;
+    }
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthTimelineService.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthTimelineEvent;
+import com.etotem.cfc.mapper.HealthTimelineEventMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthTimelineService {
+
+    @Resource
+    private HealthTimelineEventMapper healthTimelineEventMapper;
+
+    /** 获取时间线事件列表 */
+    public List<HealthTimelineEvent> getTimeline(Long childId, int limit) {
+        if (childId == null) return Collections.emptyList();
+        return healthTimelineEventMapper.selectList(
+            new LambdaQueryWrapper<HealthTimelineEvent>()
+                .eq(HealthTimelineEvent::getChildId, childId)
+                .orderByDesc(HealthTimelineEvent::getEventDate)
+                .last("LIMIT " + Math.min(limit, 50))
+        );
+    }
+
+    /** 添加事件 */
+    public void addEvent(Long userId, Long childId, String eventType, String eventText, String refId) {
+        HealthTimelineEvent event = new HealthTimelineEvent();
+        event.setUserId(userId);
+        event.setChildId(childId);
+        event.setEventType(eventType);
+        event.setEventText(eventText);
+        event.setRefId(refId);
+        event.setEventDate(new Date());
+        event.setCreatedAt(new Date());
+        healthTimelineEventMapper.insert(event);
+    }
+
+    /** 基于里程碑自动生成事件 */
+    public void checkAndGenerateMilestones(Long userId, Long childId, int streakDays) {
+        if (streakDays >= 30) {
+            addIfNotExists(userId, childId, "streak_30",
+                "🏆 连续30天!坚韧不拔!");
+        } else if (streakDays >= 7) {
+            addIfNotExists(userId, childId, "streak_7",
+                "🔥 第7天 — 突破了第一周!");
+        } else if (streakDays >= 3) {
+            addIfNotExists(userId, childId, "streak_3",
+                "🔥 连续3天!好的开始!");
+        }
+    }
+
+    /** 基于积分的升级事件 */
+    public void checkAndGenerateLevelUp(Long userId, Long childId, int score) {
+        if (score >= 1500) {
+            addIfNotExists(userId, childId, "level_5", "👑 晋升为\"健康领航者\"!");
+        } else if (score >= 700) {
+            addIfNotExists(userId, childId, "level_4", "⭐ 晋升为\"健康达人\"!");
+        } else if (score >= 300) {
+            addIfNotExists(userId, childId, "level_3", "🏡 晋升为\"家庭健康官\"!");
+        } else if (score >= 100) {
+            addIfNotExists(userId, childId, "level_2", "🌿 晋升为\"健康践行者\"!");
+        }
+    }
+
+    /** 首次健康报告事件 */
+    public void checkFirstReport(Long userId, Long childId) {
+        addIfNotExists(userId, childId, "first_report", "📋 第一次上传健康报告");
+    }
+
+    private void addIfNotExists(Long userId, Long childId, String eventType, String eventText) {
+        long existing = healthTimelineEventMapper.selectCount(
+            new LambdaQueryWrapper<HealthTimelineEvent>()
+                .eq(HealthTimelineEvent::getChildId, childId)
+                .eq(HealthTimelineEvent::getEventType, eventType));
+        if (existing == 0) {
+            addEvent(userId, childId, eventType, eventText, null);
+            log.info("时间线事件已生成: childId={} type={} text={}", childId, eventType, eventText);
+        }
+    }
+}

+ 242 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java

@@ -0,0 +1,242 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class MicroActionService {
+
+    @Resource
+    private MicroActionMapper microActionMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private OnboardingService onboardingService;
+
+    @Resource
+    private GrowthTaskService growthTaskService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取所有启用的微行动列表
+     */
+    public List<MicroAction> getAllActions() {
+        return microActionMapper.selectList(
+            new LambdaQueryWrapper<MicroAction>()
+                .eq(MicroAction::getStatus, 1)
+                .orderByAsc(MicroAction::getSortOrder)
+        );
+    }
+
+    /**
+     * 获取今日推荐的微行动
+     * 如果有未使用的新手体验配额,返回随机行动
+     * 如果已完成今日配额,返回null
+     */
+    public MicroAction getTodayAction(Long childId) {
+        // Check if already completed for today
+        MicroActionRecord todayRecord = getTodayRecord(childId);
+        if (todayRecord != null) {
+            return null; // Already completed today
+        }
+        // Get all active actions
+        List<MicroAction> actions = getAllActions();
+        if (actions.isEmpty()) return null;
+
+        // Pick one based on the day of month (consistent for the whole day)
+        Calendar cal = Calendar.getInstance();
+        int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
+        int index = dayOfMonth % actions.size();
+        return actions.get(index);
+    }
+
+    /**
+     * 检查今日是否已完成微行动
+     */
+    public boolean hasCompletedToday(Long childId) {
+        return getTodayRecord(childId) != null;
+    }
+
+    /**
+     * 获取今日完成记录
+     */
+    private MicroActionRecord getTodayRecord(Long childId) {
+        if (childId == null) return null;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        return microActionRecordMapper.selectOne(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .apply("DATE(completed_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+    }
+
+    /**
+     * 检查今日是否已健康打卡(互斥逻辑)
+     */
+    private boolean hasHealthCheckinToday(Long childId) {
+        if (childId == null) return false;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        HealthCheckin existing = healthCheckinMapper.selectOne(
+            new LambdaQueryWrapper<HealthCheckin>()
+                .eq(HealthCheckin::getChildId, childId)
+                .apply("DATE(checkin_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+        return existing != null;
+    }
+
+    /**
+     * 完成微行动
+     * 与健康打卡互斥:同一天只能选择一种
+     */
+    @Transactional
+    public MicroActionRecord completeAction(Long userId, Long childId, Long actionId) {
+        if (childId == null) {
+            throw new RuntimeException("childId不能为空");
+        }
+
+        // Check if already completed micro action today
+        if (hasCompletedToday(childId)) {
+            throw new RuntimeException("今日微行动已完成,请明天再来");
+        }
+
+        // Check mutual exclusion with health checkin
+        if (hasHealthCheckinToday(childId)) {
+            throw new RuntimeException("今日已健康打卡,已完成今日签到");
+        }
+
+        // Verify the action exists and is active
+        MicroAction action = microActionMapper.selectById(actionId);
+        if (action == null || action.getStatus() != 1) {
+            throw new RuntimeException("微行动不存在或已下架");
+        }
+
+        // Create record
+        MicroActionRecord record = new MicroActionRecord();
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setActionId(actionId);
+        record.setCompletedDate(new Date());
+        record.setEarnedEnergy(1);
+        record.setCreatedAt(new Date());
+        microActionRecordMapper.insert(record);
+
+        // Create task in task system (same pattern as health checkin)
+        try {
+            int points = 5;
+            String pointsStr = sysConfigService.getValue("health_checkin_points");
+            if (pointsStr != null && !pointsStr.isEmpty()) {
+                try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
+            }
+
+            Task task = new Task();
+            FamilyMember child = familyMemberMapper.selectById(childId);
+            if (child != null) {
+                task.setFamilyId(child.getFamilyId());
+            }
+            task.setCreatorId(userId);
+            task.setExecutorType("child");
+            task.setExecutorId(childId);
+            task.setChildId(childId);
+            task.setTitle("微行动");
+            task.setDescription("自动生成 - " + action.getName());
+            task.setPoints(points);
+            task.setRepeatType("none");
+            task.setCategory("身体类");
+            task.setNeedReview(0);
+            task.setReviewByCategory(0);
+            task.setTaskType("onetime");
+            task.setSourceType("micro_action");
+            task.setSourceId(record.getId());
+            task.setStatus("completed");
+            task.setCompletedAt(new Date());
+            task.setCreatedAt(new Date());
+            task.setUpdatedAt(new Date());
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.HOUR_OF_DAY, 23);
+            cal.set(Calendar.MINUTE, 59);
+            cal.set(Calendar.SECOND, 59);
+            task.setDeadline(cal.getTime());
+            taskMapper.insert(task);
+
+            // Update child totalPoints
+            if (child != null) {
+                child.setTotalPoints(child.getTotalPoints() + points);
+                child.setUpdatedAt(new Date());
+                familyMemberMapper.updateById(child);
+            }
+
+            // Insert PointsLog
+            PointsLog pointsLog = new PointsLog();
+            pointsLog.setChildId(childId);
+            pointsLog.setTaskId(task.getId());
+            pointsLog.setAmount(points);
+            pointsLog.setType("earn");
+            pointsLog.setCategory("task");
+            pointsLog.setDescription("微行动");
+            pointsLog.setCreatedAt(new Date());
+            pointsLogMapper.insert(pointsLog);
+        } catch (Exception e) {
+            log.warn("微行动任务创建失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Award energy
+        try {
+            energyService.awardEnergy(childId, "micro_action", record.getId(), 1,
+                    "微行动", 30);
+        } catch (Exception e) {
+            log.warn("微行动能量发放失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Trigger onboarding and growth tasks
+        try { onboardingService.completeTask(userId, "FIRST_CHECKIN"); } catch (Exception e) { /* ignore */ }
+        try { growthTaskService.updateProgress(userId, "DAILY_CHECKIN", 1); } catch (Exception e) { log.warn("成长任务签到进度更新失败: userId={}", userId); }
+
+        return record;
+    }
+
+    /**
+     * 获取最近N天的微行动记录
+     */
+    public List<MicroActionRecord> getRecentRecords(Long childId, int days) {
+        if (childId == null) return Collections.emptyList();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -days);
+        Date since = cal.getTime();
+        return microActionRecordMapper.selectList(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .ge(MicroActionRecord::getCompletedDate, since)
+                .orderByDesc(MicroActionRecord::getCompletedDate)
+        );
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java

@@ -2,9 +2,11 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthCheckin;
 import com.etotem.cfc.entity.ShareEvent;
 import com.etotem.cfc.entity.ShareRead;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthCheckinMapper;
 import com.etotem.cfc.mapper.ShareEventMapper;
 import com.etotem.cfc.mapper.ShareReadMapper;
 import com.etotem.cfc.service.SysConfigService;
@@ -37,6 +39,9 @@ public class ShareService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
     public ShareEvent createShareEvent(Long userId, String targetType, Long targetId,
             String shareChannel, String title) {
         ShareEvent event = new ShareEvent();
@@ -126,4 +131,45 @@ public class ShareService {
         stats.put("totalRewardPoints", totalRewards);
         return stats;
     }
+
+    public Map<String, Object> getFamilyFootprint(Long familyId) {
+        Map<String, Object> result = new HashMap<>();
+        List<Map<String, Object>> members = new java.util.ArrayList<>();
+        int totalScore = 0;
+        int totalCheckinDays = 0;
+
+        if (familyId != null) {
+            List<FamilyMember> familyMembers = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId)
+            );
+            for (FamilyMember fm : familyMembers) {
+                Map<String, Object> memberData = new HashMap<>();
+                memberData.put("name", fm.getNickname());
+                memberData.put("avatar", fm.getAvatar());
+
+                List<HealthCheckin> checkins = healthCheckinMapper.selectList(
+                    new LambdaQueryWrapper<HealthCheckin>()
+                        .eq(HealthCheckin::getUserId, fm.getUserId())
+                        .orderByDesc(HealthCheckin::getCheckinDate)
+                );
+                int streakDays = 0;
+                int score = 0;
+                if (checkins != null && !checkins.isEmpty()) {
+                    streakDays = checkins.size();
+                    score = checkins.stream()
+                        .filter(c -> c.getEarnedEnergy() != null)
+                        .mapToInt(HealthCheckin::getEarnedEnergy).sum();
+                }
+                memberData.put("streakDays", streakDays);
+                memberData.put("score", score);
+                members.add(memberData);
+                totalScore += score;
+                totalCheckinDays = Math.max(totalCheckinDays, streakDays);
+            }
+        }
+        result.put("members", members);
+        result.put("totalScore", totalScore);
+        result.put("totalCheckinDays", totalCheckinDays);
+        return result;
+    }
 }

+ 9 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/impl/FortuneServiceImpl.java

@@ -270,18 +270,20 @@ public class FortuneServiceImpl implements FortuneService {
         
         // 占位算法:五行三合
         String[] elements = {"木", "火", "土", "金", "水"};
-        int hash = (int)(familyId ^ (familyId >>> 32) + date.toEpochDay());
-        fortune.setDominantElement(elements[Math.abs(hash) % elements.length]);
+        // 注意:原写法因运算符优先级问题(+ 高于 >>> 且 ^ 低于两者)会导致移位越界,可能造成 ArrayIndexOutOfBoundsException
+        int hash = (int)(familyId.longValue() ^ (familyId.longValue() >>> 32) ^ date.toEpochDay());
+        int idx = Math.floorMod(Math.abs(hash), elements.length);
+        fortune.setDominantElement(elements[idx]);
         
         // 吉位
         fortune.setLuckyDirection(calculateLuckyDirection(familyId, date));
         
         // 运势等级
         String[] fortuneLevels = {"大吉", "吉", "平", "凶", "大凶"};
-        fortune.setFortuneLevel(fortuneLevels[Math.abs(hash) % 5]);
+        fortune.setFortuneLevel(fortuneLevels[Math.floorMod(hash, fortuneLevels.length)]);
         
         // 心情分数
-        fortune.setMoodScore(50 + Math.abs(hash) % 50);
+        fortune.setMoodScore(50 + Math.floorMod(hash, 50));
         
         // 每日提示
         String[] tips = {
@@ -291,12 +293,12 @@ public class FortuneServiceImpl implements FortuneService {
             "健康为本,注意休息",
             "运势平稳,切勿冒险"
         };
-        fortune.setFortuneDescription(tips[Math.abs(hash) % tips.length]);
+        fortune.setFortuneDescription(tips[Math.floorMod(hash, tips.length)]);
         
         // 心情描述
         String[] moods = {"心如止水", "喜悦满溢", "平静如常", "略感烦躁", "情绪低落"};
-        fortune.setMoodEmoji(getMoodEmoji(Math.abs(hash) % 5));
-        fortune.setMoodText(moods[Math.abs(hash) % moods.length]);
+        fortune.setMoodEmoji(getMoodEmoji(Math.floorMod(hash, 5)));
+        fortune.setMoodText(moods[Math.floorMod(hash, moods.length)]);
         
         // 每周提示
         fortune.setWeeklyTip(getWeeklyTip(fortune.getDominantElement()));

+ 171 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2922,3 +2922,174 @@ CREATE TABLE IF NOT EXISTS review_orders (
     UNIQUE KEY uk_order_id (order_id),
     INDEX idx_reviewer_id (reviewer_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表';
+
+-- 微行动定义表
+CREATE TABLE IF NOT EXISTS micro_actions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...',
+    name VARCHAR(50) NOT NULL COMMENT '行动名称',
+    icon VARCHAR(10) COMMENT '图标emoji',
+    description VARCHAR(200) COMMENT '行动描述',
+    sort_order INT DEFAULT 0 COMMENT '排序号',
+    status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_code (code),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)';
+
+-- 微行动完成记录表
+CREATE TABLE IF NOT EXISTS micro_action_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    action_id BIGINT NOT NULL COMMENT '行动ID',
+    completed_date DATETIME NOT NULL COMMENT '完成日期',
+    earned_energy INT DEFAULT 1 COMMENT '获得能量值',
+    note VARCHAR(200) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, completed_date),
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录';
+
+-- 每日反馈表(AI/预设健康反馈)
+CREATE TABLE IF NOT EXISTS daily_feedback (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    feedback_date DATETIME NOT NULL COMMENT '反馈日期',
+    feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone',
+    content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本',
+    milestone_event VARCHAR(100) COMMENT '关联的里程碑事件',
+    is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读',
+    source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, feedback_date),
+    INDEX idx_feedback_type (feedback_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈';
+
+-- 健康预警记录表(P1-2 健康行为智能预警)
+CREATE TABLE IF NOT EXISTS health_alerts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006',
+    alert_text VARCHAR(200) NOT NULL COMMENT '预警文案',
+    action_suggestion VARCHAR(200) COMMENT '行动建议',
+    severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low',
+    dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭',
+    dismissed_at DATETIME COMMENT '关闭时间',
+    alert_date DATETIME NOT NULL COMMENT '预警日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, alert_date),
+    INDEX idx_rule (rule_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录';
+
+-- 健康等级表(P2-1 身份重塑)
+CREATE TABLE IF NOT EXISTS health_level (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    level_num INT NOT NULL COMMENT '等级编号 1-5',
+    title VARCHAR(50) NOT NULL COMMENT '称号',
+    badge_icon VARCHAR(20) COMMENT '徽章图标',
+    min_score INT DEFAULT 0 COMMENT '所需最低健康积分',
+    max_score INT DEFAULT 99999 COMMENT '最高积分上限'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义';
+
+-- 健康时间线事件表(P2-1 我的健康故事)
+CREATE TABLE IF NOT EXISTS health_timeline_event (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report',
+    event_text VARCHAR(200) NOT NULL COMMENT '事件描述',
+    ref_id VARCHAR(50) COMMENT '关联ID',
+    event_date DATETIME NOT NULL COMMENT '事件日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, event_date),
+    INDEX idx_event_type (event_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件';
+
+-- 家庭挑战表(P2-2 家庭健康互动)
+CREATE TABLE IF NOT EXISTS family_challenge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week',
+    title VARCHAR(200) NOT NULL COMMENT '挑战标题',
+    description TEXT COMMENT '挑战描述',
+    duration_days INT DEFAULT 7 COMMENT '持续天数',
+    reward_points INT DEFAULT 0 COMMENT '奖励积分',
+    reward_badge VARCHAR(100) COMMENT '奖励徽章',
+    start_date DATE COMMENT '开始日期',
+    end_date DATE COMMENT '结束日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战';
+
+-- 家庭挑战进度表(P2-2 挑战进度)
+CREATE TABLE IF NOT EXISTS family_challenge_progress (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    challenge_id BIGINT NOT NULL COMMENT '挑战ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    progress_value INT DEFAULT 0 COMMENT '进度值',
+    completed TINYINT(1) DEFAULT 0 COMMENT '是否完成',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_challenge_id (challenge_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度';
+
+-- 健康互动记录表(P2-2 点赞)
+CREATE TABLE IF NOT EXISTS health_interaction (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    from_user_id BIGINT NOT NULL COMMENT '点赞用户ID',
+    to_user_id BIGINT NOT NULL COMMENT '被赞用户ID',
+    action_type VARCHAR(50) NOT NULL COMMENT '行为类型',
+    action_id BIGINT COMMENT '关联行为记录ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_family_action (family_id, action_type, action_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)';
+
+-- 健康圈表(P3 健康社区扩散)
+CREATE TABLE IF NOT EXISTS health_circle (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '圈名称',
+    description VARCHAR(500) COMMENT '圈描述',
+    creator_family_id BIGINT COMMENT '创建家庭ID',
+    invite_code VARCHAR(32) COMMENT '邀请码',
+    member_limit INT DEFAULT 50 COMMENT '成员上限',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_invite_code (invite_code),
+    INDEX idx_creator (creator_family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈';
+
+-- 健康圈成员表(P3)
+CREATE TABLE IF NOT EXISTS health_circle_member (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈ID',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin',
+    joined_at DATETIME COMMENT '加入时间',
+    left_at DATETIME COMMENT '退出时间',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_circle_id (circle_id),
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员';
+
+-- 圈挑战表(P3)
+CREATE TABLE IF NOT EXISTS circle_challenge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈ID',
+    challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation',
+    title VARCHAR(200) NOT NULL COMMENT '挑战标题',
+    description VARCHAR(500) COMMENT '挑战描述',
+    duration_days INT DEFAULT 7 COMMENT '持续天数',
+    period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly',
+    start_date DATETIME COMMENT '开始日期',
+    end_date DATETIME COMMENT '结束日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_circle_id (circle_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战';

+ 70 - 0
cfc-frontend/components/CircleRanking.vue

@@ -0,0 +1,70 @@
+<template>
+  <view class="circle-ranking" v-if="circleName">
+    <view class="cr-header">
+      <text class="cr-title">🏅 {{ circleName }}</text>
+    </view>
+    <view class="cr-list">
+      <view class="cr-item" v-for="(item, idx) in rankings" :key="idx">
+        <text class="cr-rank">{{ '🥇🥈🥉'[idx] || '#' + (idx + 1) }}</text>
+        <view class="cr-info">
+          <text class="cr-family">家庭 {{ idx + 1 }}</text>
+          <text class="cr-members">{{ item.memberCount || 0 }} 位成员</text>
+        </view>
+        <text class="cr-score">{{ item.totalScore || 0 }} 分</text>
+      </view>
+    </view>
+  </view>
+  <view v-else-if="!circleName && !loading" class="cr-empty">
+    <text class="cr-empty-text">暂无健康圈数据</text>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    circleName: { type: String, default: '' },
+    rankings: { type: Array, default: function() { return [] } },
+    loading: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style scoped>
+.circle-ranking {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.cr-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.cr-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.cr-item {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.cr-item:last-child { border-bottom: none; }
+.cr-rank { font-size: 32rpx; width: 48rpx; text-align: center; }
+.cr-info { flex: 1; min-width: 0; }
+.cr-family { font-size: 26rpx; font-weight: 600; color: #334155; display: block; }
+.cr-members { font-size: 20rpx; color: #94A3B8; margin-top: 2rpx; display: block; }
+.cr-score { font-size: 30rpx; font-weight: 700; color: #6366F1; }
+.cr-empty {
+  padding: 60rpx 20rpx;
+  display: flex;
+  justify-content: center;
+}
+.cr-empty-text { font-size: 26rpx; color: #94A3B8; }
+</style>

+ 1 - 1
cfc-frontend/components/ContactCard.vue

@@ -46,9 +46,9 @@
           <view class="modal-btn cancel" @click="showInviteModal = false">取消</view>
           <view class="modal-btn confirm" @click="confirmInvite">确认邀请</view>
         </view>
-      </view>
     </view>
   </view>
+  </view>
 </template>
 
 <script>

+ 84 - 0
cfc-frontend/components/DailySummary.vue

@@ -0,0 +1,84 @@
+<template>
+  <view class="daily-summary" v-if="feedback">
+    <view class="ds-header">
+      <text class="ds-label">💬 健康小语</text>
+      <text class="ds-dismiss" @click="dismiss">×</text>
+    </view>
+    <text class="ds-content">{{ feedback.content }}</text>
+    <view class="ds-footer">
+      <text class="ds-source">{{ feedback.source === 'ai_generated' ? 'AI生成' : '暖心提醒' }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import api from '@/utils/api.js'
+export default {
+  props: {
+    childId: { type: Number, default: null }
+  },
+  data: function() {
+    return {
+      feedback: null,
+      dismissed: false
+    }
+  },
+  created: function() {
+    this.loadFeedback()
+  },
+  methods: {
+    loadFeedback: function() {
+      if (!this.childId) return
+      var self = this
+      api.getTodayFeedback({ childId: self.childId }).then(function(res) {
+        if (res.data && res.data.feedback) {
+          self.feedback = res.data.feedback
+        }
+      }).catch(function() {
+        // silent fail
+      })
+    },
+    dismiss: function() {
+      if (this.feedback && this.feedback.id) {
+        api.markFeedbackRead({ id: this.feedback.id })
+      }
+      this.feedback = null
+      this.$emit('dismiss')
+    }
+  },
+  watch: {
+    childId: function() {
+      this.feedback = null
+      this.loadFeedback()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.daily-summary {
+  background: linear-gradient(135deg, #EBF5FF, #E0F2FE);
+  border-radius: 20rpx;
+  padding: 20rpx 24rpx;
+  margin: 20rpx 20rpx 0 20rpx;
+  border: 1rpx solid #BAE6FD;
+  position: relative;
+}
+.ds-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.ds-label { font-size: 24rpx; font-weight: 600; color: #0369A1; }
+.ds-dismiss {
+  font-size: 36rpx;
+  color: #999;
+  padding: 0 8rpx;
+  line-height: 1;
+}
+.ds-dismiss:active { color: #666; }
+.ds-content { font-size: 28rpx; color: #333; line-height: 1.6; }
+.ds-footer { margin-top: 8rpx; }
+.ds-source { font-size: 20rpx; color: #94A3B8; }
+</style>

+ 117 - 0
cfc-frontend/components/FamilyChallengeCard.vue

@@ -0,0 +1,117 @@
+<template>
+  <view class="challenge-card" v-if="challenge">
+    <view class="card-header">
+      <text class="card-title">{{ challenge.title }}</text>
+      <text class="card-status" :class="'status-' + (challenge.status || 'active')">
+        {{ challenge.status === 'active' ? '进行中' : '已完成' }}
+      </text>
+    </view>
+    <text class="card-desc">{{ challenge.description || '' }}</text>
+    <view class="card-detail-row" v-if="challenge.durationDays">
+      <text class="detail-label">⏱ 期限</text>
+      <text class="detail-val">{{ challenge.durationDays }}天</text>
+    </view>
+    <view class="card-detail-row" v-if="challenge.endDate">
+      <text class="detail-label">📅 截止</text>
+      <text class="detail-val">{{ formatDate(challenge.endDate) }}</text>
+    </view>
+    <view class="card-detail-row" v-if="challenge.rewardPoints">
+      <text class="detail-label">🏆 奖励</text>
+      <text class="detail-val reward">+{{ challenge.rewardPoints }}分</text>
+      <text class="detail-val badge" v-if="challenge.rewardBadge"> 🎖️ {{ challenge.rewardBadge }}</text>
+    </view>
+    <view class="card-progress" v-if="progressPercent !== undefined">
+      <view class="progress-bar-bg">
+        <view class="progress-bar-fill" :style="{ width: Math.min(progressPercent, 100) + '%' }"></view>
+      </view>
+      <text class="progress-text">{{ progressPercent }}%</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    challenge: { type: Object, default: null },
+    progressPercent: { type: Number, default: undefined }
+  },
+  methods: {
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      return String(dateStr).slice(0, 10)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.challenge-card {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 24rpx;
+  border-left: 6rpx solid #10B981;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.card-status {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 999rpx;
+}
+.status-active { background: #DCFCE7; color: #16A34A; }
+.status-completed { background: #F1F5F9; color: #64748B; }
+.card-desc {
+  font-size: 24rpx;
+  color: #64748B;
+  line-height: 1.5;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.card-detail-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 6rpx;
+}
+.detail-label { font-size: 24rpx; color: #94A3B8; }
+.detail-val { font-size: 24rpx; color: #334155; }
+.detail-val.reward { color: #F97316; font-weight: 600; }
+.detail-val.badge { color: #6366F1; }
+.card-progress {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 12rpx;
+}
+.progress-bar-bg {
+  flex: 1;
+  height: 12rpx;
+  background: #F1F5F9;
+  border-radius: 999rpx;
+  overflow: hidden;
+}
+.progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.5s ease;
+}
+.progress-text {
+  font-size: 22rpx;
+  color: #10B981;
+  font-weight: 600;
+  min-width: 48rpx;
+  text-align: right;
+}
+</style>

+ 104 - 0
cfc-frontend/components/FamilyHealthRanking.vue

@@ -0,0 +1,104 @@
+<template>
+  <view class="family-ranking" v-if="list && list.length > 0">
+    <view class="ranking-header">
+      <text class="ranking-title">👨‍👩‍👧‍👦 家庭健康榜</text>
+      <text class="ranking-total">全家积分: {{ totalScore }}</text>
+    </view>
+    <view class="ranking-list">
+      <view class="ranking-item" v-for="(m, idx) in list" :key="m.childId">
+        <text class="rank-medal">{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : '#' + m.rank }}</text>
+        <image class="rank-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
+        <view class="rank-info">
+          <text class="rank-name">{{ m.name }}</text>
+          <text class="rank-level">{{ m.levelTitle || '' }}</text>
+        </view>
+        <view class="rank-score-wrap">
+          <text class="rank-score">{{ m.score }}</text>
+          <text class="rank-unit">分</text>
+        </view>
+        <text class="rank-streak" v-if="m.streakDays && m.streakDays > 0">🔥 {{ m.streakDays }}天</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    list: { type: Array, default: function() { return [] } },
+    totalScore: { type: Number, default: 0 }
+  }
+}
+</script>
+
+<style scoped>
+.family-ranking {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.ranking-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.ranking-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.ranking-total {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+.ranking-item {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.ranking-item:last-child { border-bottom: none; }
+.rank-medal { font-size: 32rpx; width: 48rpx; text-align: center; }
+.rank-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.rank-info { flex: 1; min-width: 0; }
+.rank-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #334155;
+}
+.rank-level {
+  font-size: 20rpx;
+  color: #94A3B8;
+  margin-top: 2rpx;
+  display: block;
+}
+.rank-score-wrap {
+  display: flex;
+  align-items: baseline;
+  gap: 4rpx;
+}
+.rank-score {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #10B981;
+}
+.rank-unit {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.rank-streak {
+  font-size: 22rpx;
+  color: #F97316;
+  white-space: nowrap;
+}
+</style>

+ 30 - 15
cfc-frontend/components/FamilyRelationGraph.vue

@@ -219,17 +219,25 @@ export default {
     }
   },
   mounted: function() {
+    this._destroyed = false
     var self = this
-    this.dpr = uni.getSystemInfoSync().pixelRatio || 1
-
+    try {
+      this.dpr = uni.getSystemInfoSync().pixelRatio || 1
+    } catch (e) {
+      this.dpr = 1
+    }
     this.$nextTick(function() {
+      if (self._destroyed) return
       self.updateCanvasSize()
       self.$nextTick(function() {
-        self.initSimulation()
+        if (!self._destroyed) {
+          self.initSimulation()
+        }
       })
     })
   },
   beforeDestroy: function() {
+    this._destroyed = true
     this.stopSimulation()
   },
   methods: {
@@ -281,10 +289,13 @@ export default {
       }
     },
 
-    simulationTick: function() {
-      if (!this.isSimulating) return
-
-      var self = this
+simulationTick: function() {
+    if (!this.isSimulating) return
+    if (this._destroyed) {
+      this.isSimulating = false
+      return
+    }
+    var self = this
         var nodes = this.nodeData
         var edges = this.edges
       var p = this.physics
@@ -500,14 +511,16 @@ export default {
       }
     },
 
-    // 降级渲染 (CanvasContext)
-    renderLegacy: function() {
+    // WeChat mini-program compatible fallback renderer.
+  // Note: uni-app legacy canvas uses setFontSize(size), not setFont().
+  renderLegacy: function() {
+    if (this._destroyed) return
+    try {
       var ctx = uni.createCanvasContext('forceGraphCanvas', this)
       var self = this
 
       ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
 
-      // 边
       var edges = this.edges
       for (var i = 0; i < edges.length; i++) {
         var edge = edges[i]
@@ -522,7 +535,6 @@ export default {
         ctx.stroke()
       }
 
-      // 节点
       var nodes = this.nodeData
       for (var j = 0; j < nodes.length; j++) {
         var node = nodes[j]
@@ -542,18 +554,21 @@ export default {
         }
 
         ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
-        ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
+        ctx.setFontSize(Math.max(10, Math.round(r * 0.9)))
         ctx.setTextAlign('center')
         ctx.setTextBaseline('middle')
         ctx.fillText(node.displayName, node.x, node.y)
 
         ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
-        ctx.font = (r * 0.4) + 'px sans-serif'
-        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+        ctx.setFontSize(Math.max(9, Math.round(r * 0.4)))
+        ctx.fillText((node.nickname || '').substring(0, 3), node.x, node.y + r + 10)
       }
 
       ctx.draw()
-    },
+    } catch (err) {
+      // Legacy canvas fallback must never crash page initialization.
+    }
+  },
 
     // ===== 交互 =====
 

+ 65 - 0
cfc-frontend/components/FeedbackToast.vue

@@ -0,0 +1,65 @@
+<template>
+  <view class="feedback-overlay" v-if="visible" @touchmove.stop.prevent>
+    <view class="feedback-toast" :class="{ 'feedback-enter': animating, 'feedback-leave': !animating }">
+      <view class="feedback-icon">{{ icon }}</view>
+      <text class="feedback-text">{{ text }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    visible: { type: Boolean, default: false },
+    text: { type: String, default: '' },
+    icon: { type: String, default: '✅' },
+    animating: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style scoped>
+.feedback-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  pointer-events: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.feedback-toast {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12rpx;
+  background: rgba(0,0,0,0.75);
+  border-radius: 24rpx;
+  padding: 40rpx 60rpx;
+  backdrop-filter: blur(4px);
+}
+.feedback-icon { font-size: 80rpx; }
+.feedback-text {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: 600;
+  text-align: center;
+}
+.feedback-enter {
+  animation: feedbackPopIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
+}
+.feedback-leave {
+  animation: feedbackFadeOut 0.3s ease forwards;
+}
+@keyframes feedbackPopIn {
+  0%   { transform: scale(0.3); opacity: 0; }
+  100% { transform: scale(1); opacity: 1; }
+}
+@keyframes feedbackFadeOut {
+  0%   { opacity: 1; }
+  100% { opacity: 0; }
+}
+</style>

+ 195 - 0
cfc-frontend/components/GenerationPicker.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="generation-picker-wrap">
+    <view class="form-item required" v-if="showLabel">
+      <text class="label"><text class="required-mark">*</text>辈分关系</text>
+    </view>
+    <picker :range="generationNames" :value="selectedIndex" @change="onGenerationChange" :disabled="disabled">
+      <view class="picker" :class="{ 'picker-disabled': disabled }">
+        <text>{{ selectedGenerationName || '请选择辈分关系' }}</text>
+      </view>
+    </picker>
+    
+    <!-- 同辈配偶选择器(仅当选择"同辈"时显示) -->
+    <view class="peer-type-selector" v-if="selectedGeneration === 'PEER' && showPeerType">
+      <view class="radio-group">
+        <label class="radio-item" @tap="selectPeerType('sibling')">
+          <view class="radio-icon" :class="{ active: peerType === 'sibling' }">
+            <view class="radio-inner" v-if="peerType === 'sibling'"></view>
+          </view>
+          <text class="radio-label">兄弟姐妹</text>
+        </label>
+        <label class="radio-item" @tap="selectPeerType('spouse')">
+          <view class="radio-icon" :class="{ active: peerType === 'spouse' }">
+            <view class="radio-inner" v-if="peerType === 'spouse'"></view>
+          </view>
+          <text class="radio-label">配偶</text>
+        </label>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { GenerationLevel } from '../utils/constants.js'
+
+export default {
+  name: 'GenerationPicker',
+  props: {
+    generationLevel: { type: String, default: '' },
+    peerType: { type: String, default: '' },
+    disabled: { type: Boolean, default: false },
+    showLabel: { type: Boolean, default: true },
+    showPeerType: { type: Boolean, default: true }
+  },
+  data() {
+    return {
+      generations: [
+        { value: 'ELDER_2', name: '祖父母辈(+2)' },
+        { value: 'ELDER_1', name: '父母辈(+1)' },
+        { value: 'PEER', name: '同辈(0)' },
+        { value: 'YOUNGER_1', name: '子女辈(-1)' },
+        { value: 'YOUNGER_2', name: '孙辈(-2)' }
+      ],
+      selectedGeneration: '',
+      selectedPeerType: ''
+    }
+  },
+  computed: {
+    generationNames() {
+      var names = []
+      for (var i = 0; i < this.generations.length; i++) {
+        names.push(this.generations[i].name)
+      }
+      return names
+    },
+    selectedIndex() {
+      if (!this.selectedGeneration) return 0
+      for (var i = 0; i < this.generations.length; i++) {
+        if (this.generations[i].value === this.selectedGeneration) {
+          return i
+        }
+      }
+      return 0
+    },
+    selectedGenerationName() {
+      if (!this.selectedGeneration) return ''
+      for (var i = 0; i < this.generations.length; i++) {
+        if (this.generations[i].value === this.selectedGeneration) {
+          return this.generations[i].name
+        }
+      }
+      return ''
+    }
+  },
+  mounted() {
+    if (this.generationLevel) {
+      this.selectedGeneration = this.generationLevel
+    }
+    if (this.peerType) {
+      this.selectedPeerType = this.peerType
+    }
+  },
+  watch: {
+    generationLevel(val) {
+      if (val) {
+        this.selectedGeneration = val
+      }
+    },
+    peerType(val) {
+      if (val) {
+        this.selectedPeerType = val
+      }
+    }
+  },
+  methods: {
+    onGenerationChange(e) {
+      var idx = e.detail.value
+      if (idx >= 0 && idx < this.generations.length) {
+        this.selectedGeneration = this.generations[idx].value
+        this.$emit('update:generationLevel', this.selectedGeneration)
+        
+        // 如果不是同辈,清空 peerType
+        if (this.selectedGeneration !== 'PEER') {
+          this.selectedPeerType = ''
+          this.$emit('update:peerType', '')
+        }
+      }
+    },
+    selectPeerType(type) {
+      this.selectedPeerType = type
+      this.$emit('update:peerType', type)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.generation-picker-wrap {
+  margin-bottom: 30rpx;
+}
+.form-item {
+  margin-bottom: 10rpx;
+}
+.form-item .label {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+  font-weight: bold;
+}
+.required-mark {
+  color: #F97316;
+  margin-right: 4rpx;
+}
+.picker {
+  border: 1rpx solid #ddd;
+  border-radius: 10rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  color: #666;
+  background: #fff;
+}
+.picker-disabled {
+  background: #f5f5f5;
+  color: #999;
+}
+.peer-type-selector {
+  margin-top: 20rpx;
+  padding: 20rpx;
+  background: #f9f9f9;
+  border-radius: 10rpx;
+}
+.radio-group {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+.radio-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.radio-icon {
+  width: 40rpx;
+  height: 40rpx;
+  border: 2rpx solid #ddd;
+  border-radius: 50%;
+  margin-right: 10rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.radio-icon.active {
+  border-color: #F97316;
+}
+.radio-inner {
+  width: 20rpx;
+  height: 20rpx;
+  background: #F97316;
+  border-radius: 50%;
+}
+.radio-label {
+  font-size: 28rpx;
+  color: #333;
+}
+</style>

+ 87 - 0
cfc-frontend/components/HealthAlertBanner.vue

@@ -0,0 +1,87 @@
+<template>
+  <view class="alert-banner" v-if="alerts && alerts.length > 0">
+    <view
+      class="alert-item"
+      v-for="alert in visibleAlerts"
+      :key="alert.id"
+      :class="'alert-severity-' + alert.severity">
+      <view class="alert-icon">
+        <text v-if="alert.severity === 'high'">🔴</text>
+        <text v-else-if="alert.severity === 'medium'">🟡</text>
+        <text v-else>🟢</text>
+      </view>
+      <view class="alert-body">
+        <text class="alert-text">{{ alert.alertText }}</text>
+        <text class="alert-suggestion" v-if="alert.actionSuggestion">
+          {{ alert.actionSuggestion }}
+        </text>
+      </view>
+      <text class="alert-dismiss" @click="dismiss(alert)">×</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    alerts: { type: Array, default: function() { return [] } }
+  },
+  computed: {
+    visibleAlerts: function() {
+      return this.alerts.slice(0, 2)
+    }
+  },
+  methods: {
+    dismiss: function(alert) {
+      this.$emit('dismiss', alert.id)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.alert-banner {
+  margin: 0 20rpx 16rpx 20rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+.alert-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 12rpx;
+  padding: 16rpx;
+  border-radius: 16rpx;
+  position: relative;
+}
+.alert-severity-high {
+  background: #FEF2F2;
+  border: 1rpx solid #FECACA;
+}
+.alert-severity-medium {
+  background: #FFFBEB;
+  border: 1rpx solid #FDE68A;
+}
+.alert-severity-low {
+  background: #F0F9FF;
+  border: 1rpx solid #BAE6FD;
+}
+.alert-icon { font-size: 36rpx; flex-shrink: 0; padding-top: 2rpx; }
+.alert-body { flex: 1; min-width: 0; }
+.alert-text { font-size: 26rpx; font-weight: 600; color: #1E293B; line-height: 1.4; }
+.alert-suggestion {
+  display: block;
+  font-size: 22rpx;
+  color: #64748B;
+  margin-top: 4rpx;
+  line-height: 1.4;
+}
+.alert-dismiss {
+  font-size: 36rpx;
+  color: #94A3B8;
+  padding: 0 8rpx;
+  line-height: 1;
+  flex-shrink: 0;
+}
+.alert-dismiss:active { color: #64748B; }
+</style>

+ 102 - 0
cfc-frontend/components/HealthBadge.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="health-badge" v-if="levelInfo">
+    <view class="badge-icon-wrap" :class="'badge-lv-' + (levelInfo.levelNum || 1)">
+      <text class="badge-icon">{{ levelInfo.badgeIcon || '🌱' }}</text>
+    </view>
+    <view class="badge-info">
+      <text class="badge-title">{{ levelInfo.title || '健康探索者' }}</text>
+      <view class="badge-level-row">
+        <text class="badge-level">L{{ levelInfo.levelNum || 1 }}</text>
+        <text class="badge-score">{{ score }} 分</text>
+      </view>
+      <view class="badge-progress-bar" v-if="progress < 100">
+        <view class="badge-progress-fill" :style="{ width: progress + '%' }"></view>
+      </view>
+      <text class="badge-next" v-if="nextLevel && progress < 100">
+        再{{ nextLevel.minScore - score }}分升级「{{ nextLevel.title }}」
+      </text>
+      <text class="badge-next" v-else-if="progress >= 100">
+        已达最高等级!
+      </text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    levelInfo: { type: Object, default: null },
+    score: { type: Number, default: 0 },
+    progress: { type: Number, default: 0 },
+    nextLevel: { type: Object, default: null },
+    streakDays: { type: Number, default: 0 }
+  }
+}
+</script>
+
+<style scoped>
+.health-badge {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 20rpx 24rpx;
+  background: linear-gradient(135deg, #F0FDF4, #ECFDF5);
+  border-radius: 20rpx;
+  margin: 0 20rpx 16rpx 20rpx;
+}
+.badge-icon-wrap {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.badge-lv-1 { background: #D1FAE5; }
+.badge-lv-2 { background: #A7F3D0; }
+.badge-lv-3 { background: #FEF3C7; }
+.badge-lv-4 { background: #FED7AA; }
+.badge-lv-5 { background: #FECDD3; }
+.badge-icon { font-size: 40rpx; }
+.badge-info { flex: 1; min-width: 0; }
+.badge-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #065F46;
+}
+.badge-level-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 4rpx;
+}
+.badge-level {
+  font-size: 22rpx;
+  color: #047857;
+  font-weight: 600;
+}
+.badge-score {
+  font-size: 22rpx;
+  color: #6B7280;
+}
+.badge-progress-bar {
+  height: 8rpx;
+  background: #D1FAE5;
+  border-radius: 999rpx;
+  overflow: hidden;
+  margin-top: 8rpx;
+}
+.badge-progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.5s ease;
+}
+.badge-next {
+  font-size: 20rpx;
+  color: #6B7280;
+  margin-top: 4rpx;
+  display: block;
+}
+</style>

+ 64 - 0
cfc-frontend/components/HealthLikeButton.vue

@@ -0,0 +1,64 @@
+<template>
+  <view class="like-btn" :class="{ liked: isLikedVal }" @click="handleClick">
+    <text class="like-icon">{{ isLikedVal ? '❤️' : '🤍' }}</text>
+    <text class="like-count">{{ count }}</text>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    actionType: { type: String, default: '' },
+    actionId: { type: [Number, String], default: '' },
+    toUserId: { type: [Number, String], default: '' },
+    isLiked: { type: Boolean, default: false },
+    count: { type: Number, default: 0 }
+  },
+  data() {
+    return {
+      isLikedVal: this.isLiked
+    }
+  },
+  watch: {
+    isLiked: function(val) {
+      this.isLikedVal = val
+    }
+  },
+  methods: {
+    handleClick: function() {
+      if (this.isLikedVal) return
+      this.isLikedVal = true
+      this.$emit('like', {
+        actionType: this.actionType,
+        actionId: this.actionId,
+        toUserId: this.toUserId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.like-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 999rpx;
+  border: 1rpx solid #E2E8F0;
+  background: #FAFAFA;
+  transition: all 0.2s;
+}
+.like-btn.liked {
+  border-color: #FECDD3;
+  background: #FFF1F2;
+}
+.like-icon { font-size: 24rpx; }
+.like-count {
+  font-size: 22rpx;
+  color: #64748B;
+  min-width: 20rpx;
+  text-align: center;
+}
+.liked .like-count { color: #E11D48; }
+</style>

+ 83 - 0
cfc-frontend/components/HealthTimeline.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="timeline" v-if="events && events.length > 0">
+    <view class="timeline-title">📖 我的健康故事</view>
+    <view class="timeline-list">
+      <view class="timeline-item" v-for="(evt, idx) in events" :key="evt.id">
+        <view class="timeline-dot" :class="'dot-' + (idx % 5)"></view>
+        <view class="timeline-line" v-if="idx < events.length - 1"></view>
+        <view class="timeline-content">
+          <text class="timeline-text">{{ evt.eventText }}</text>
+          <text class="timeline-date">{{ formatDate(evt.eventDate) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    events: { type: Array, default: function() { return [] } }
+  },
+  methods: {
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.slice(0, 10)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.timeline {
+  padding: 0 20rpx;
+  margin-bottom: 32rpx;
+}
+.timeline-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #1E293B;
+  margin-bottom: 16rpx;
+}
+.timeline-list { position: relative; padding-left: 20rpx; }
+.timeline-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 16rpx;
+  position: relative;
+  padding-bottom: 24rpx;
+}
+.timeline-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+  margin-top: 6rpx;
+  z-index: 1;
+}
+.dot-0 { background: #10B981; }
+.dot-1 { background: #F59E0B; }
+.dot-2 { background: #6366F1; }
+.dot-3 { background: #F97316; }
+.dot-4 { background: #FF6B9D; }
+.timeline-line {
+  position: absolute;
+  left: 9rpx;
+  top: 26rpx;
+  bottom: 0;
+  width: 2rpx;
+  background: #E2E8F0;
+}
+.timeline-content { flex: 1; min-width: 0; }
+.timeline-text {
+  font-size: 26rpx;
+  color: #334155;
+  line-height: 1.5;
+}
+.timeline-date {
+  font-size: 20rpx;
+  color: #94A3B8;
+  margin-top: 4rpx;
+  display: block;
+}
+</style>

+ 169 - 0
cfc-frontend/components/MicroActionCard.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="micro-action-card">
+    <view class="mac-header">
+      <text class="mac-title">☕️ 今天的小行动</text>
+      <text class="mac-status" v-if="completed">已完成 ✅</text>
+      <text class="mac-status" v-else>点击完成 +1能量</text>
+    </view>
+    <view class="mac-body">
+      <template v-if="loading">
+        <view class="mac-loading">
+          <text class="mac-loading-text">加载中...</text>
+        </view>
+      </template>
+      <template v-else-if="completed">
+        <view class="mac-done">
+          <view class="mac-done-icon">✓</view>
+          <text class="mac-done-text">今日微行动已完成</text>
+        </view>
+        <text class="mac-energy">+1 身能量</text>
+      </template>
+      <template v-else-if="!action">
+        <view class="mac-empty">
+          <text class="mac-empty-text">所有行动已就绪</text>
+        </view>
+      </template>
+      <template v-else>
+        <view class="mac-action-display">
+          <text class="mac-action-icon">{{ action.icon }}</text>
+          <text class="mac-action-name">{{ action.name }}</text>
+          <text class="mac-action-desc">{{ action.description }}</text>
+        </view>
+        <view class="mac-action-btn" @click="doComplete">
+          <text class="mac-btn-text">完成</text>
+        </view>
+      </template>
+    </view>
+  </view>
+</template>
+
+<script>
+import api from '@/utils/api.js'
+export default {
+  props: {
+    childId: { type: Number, default: null }
+  },
+  data: function() {
+    return {
+      action: null,
+      completed: false,
+      loading: false
+    }
+  },
+  created: function() {
+    this.loadTodayAction()
+  },
+  methods: {
+    loadTodayAction: function() {
+      if (!this.childId) return
+      var self = this
+      this.loading = true
+      api.microActionToday({ childId: self.childId }).then(function(res) {
+        self.loading = false
+        if (res.data) {
+          self.action = res.data.action
+          self.completed = res.data.completed
+        }
+      }).catch(function() {
+        self.loading = false
+        self.action = null
+      })
+    },
+    doComplete: function() {
+      if (!this.childId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      if (!this.action) return
+      if (this.loading) return
+      var self = this
+      this.loading = true
+      api.microActionComplete({
+        childId: self.childId,
+        actionId: self.action.id
+      }).then(function(res) {
+        self.loading = false
+        if (res.code === 200) {
+          self.completed = true
+          uni.showToast({ title: '太棒了!+1能量', icon: 'none' })
+          self.$emit('completed')
+        }
+      }).catch(function(err) {
+        self.loading = false
+        // Error already shown by api.js interceptor
+      })
+    }
+  },
+  watch: {
+    childId: function() {
+      this.action = null
+      this.completed = false
+      this.loadTodayAction()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.micro-action-card {
+  background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin: 20rpx 20rpx 0 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  border: 1rpx solid #FED7AA;
+}
+.mac-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.mac-title { font-size: 28rpx; font-weight: 700; color: #9A3412; }
+.mac-status { font-size: 22rpx; color: #EA580C; }
+.mac-body { display: flex; flex-direction: column; align-items: center; }
+.mac-loading { padding: 20rpx 0; }
+.mac-loading-text { font-size: 24rpx; color: #999; }
+.mac-done {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 0;
+}
+.mac-done-icon {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 24rpx;
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  font-size: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.mac-done-text { font-size: 28rpx; color: #10B981; font-weight: 600; }
+.mac-energy { font-size: 22rpx; color: #999; margin-top: 6rpx; }
+.mac-empty { padding: 20rpx 0; }
+.mac-empty-text { font-size: 24rpx; color: #999; }
+.mac-action-display {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 12rpx 0;
+  gap: 8rpx;
+}
+.mac-action-icon { font-size: 72rpx; }
+.mac-action-name { font-size: 36rpx; font-weight: 700; color: #333; }
+.mac-action-desc { font-size: 24rpx; color: #888; text-align: center; }
+.mac-action-btn {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 600;
+  padding: 16rpx 80rpx;
+  border-radius: 40rpx;
+  margin-top: 16rpx;
+}
+.mac-action-btn:active { opacity: 0.8; }
+.mac-btn-text { color: #fff; }
+</style>

+ 89 - 0
cfc-frontend/components/MilestoneCelebration.vue

@@ -0,0 +1,89 @@
+<template>
+  <view class="celebration-overlay" v-if="visible" @click="dismiss" @touchmove.stop.prevent>
+    <!-- CSS粒子 -->
+    <view
+      class="celebration-particle"
+      v-for="p in particles"
+      :key="p.id"
+      :style="{
+        left: p.left + '%',
+        animationDelay: p.delay + 's',
+        animationDuration: p.duration + 's',
+        background: p.color,
+        width: p.size + 'rpx',
+        height: p.size + 'rpx'
+      }">
+    </view>
+    <!-- 文字 -->
+    <view class="celebration-content">
+      <text class="celebration-title">{{ title }}</text>
+      <text class="celebration-subtitle">{{ subtitle }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    visible: { type: Boolean, default: false },
+    title: { type: String, default: '' },
+    subtitle: { type: String, default: '' },
+    particles: { type: Array, default: function() { return [] } }
+  },
+  methods: {
+    dismiss: function() {
+      this.$emit('dismiss')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.celebration-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9998;
+  background: rgba(0,0,0,0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.celebration-particle {
+  position: absolute;
+  top: -20rpx;
+  border-radius: 50%;
+  animation: particleFall linear forwards;
+  opacity: 0.9;
+}
+.celebration-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16rpx;
+  z-index: 1;
+  animation: celebrationTextIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
+}
+.celebration-title {
+  font-size: 56rpx;
+  font-weight: 800;
+  color: #fff;
+  text-shadow: 0 4rpx 20rpx rgba(0,0,0,0.3);
+}
+.celebration-subtitle {
+  font-size: 32rpx;
+  color: rgba(255,255,255,0.9);
+}
+@keyframes particleFall {
+  0%   { transform: translateY(-20rpx) rotate(0deg); opacity: 0.9; }
+  50%  { opacity: 1; }
+  100% { transform: translateY(110vh) rotate(720deg); opacity: 0; }
+}
+@keyframes celebrationTextIn {
+  0%   { transform: scale(0) rotate(-10deg); opacity: 0; }
+  100% { transform: scale(1) rotate(0deg); opacity: 1; }
+}
+</style>

+ 0 - 43
cfc-frontend/config.js

@@ -1,43 +0,0 @@
-/**
- * 应用全局配置
- *
- * 环境自动检测(基于微信小程序官方 API):
- *   uni.getAccountInfoSync().miniProgram.envVersion
- *     'develop'  → 开发版(工具内预览)
- *     'trial'    → 体验版(上传到体验版)
- *     'release'  → 正式版(线上发布)
- *
- * 无需手动修改任何变量,发布到对应环境自动生效。
- */
-
-let API_BASE_URL = 'http://cfc.iwintrue.com'
-
-try {
-  const accountInfo = uni.getAccountInfoSync()
-  const env = accountInfo && accountInfo.miniProgram && accountInfo.miniProgram.envVersion
-
-  if (env) {
-    switch (env) {
-      case 'develop':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
-        break
-      case 'trial':
-        API_BASE_URL = 'https://cfc.iwintrue.com'
-        break
-      case 'release':
-        API_BASE_URL = 'https://cfc.etotem.com.cn'
-        break
-    }
-  }
-} catch (e) {
-  // 非小程序环境(H5/dev),使用默认地址
-}
-
-export default {
-  API_BASE_URL,
-
-  /** 构建 API 完整 URL */
-  api(path) {
-    return API_BASE_URL + path
-  }
-}

+ 1 - 1
cfc-frontend/main.js

@@ -15,6 +15,6 @@ App.mpType = 'app'
 
 const app = new Vue({
   store,
-  ...App
+  render: h => h(App)
 })
 app.$mount()

+ 6 - 0
cfc-frontend/pages.json

@@ -6,6 +6,12 @@
         "navigationBarTitleText": "首页"
       }
     },
+    {
+      "path": "pages/share/index",
+      "style": {
+        "navigationBarTitleText": "健康足迹"
+      }
+    },
     {
       "path": "pages/index/child-index",
       "style": {

+ 218 - 252
cfc-frontend/pages/family/add-member.vue

@@ -1,263 +1,229 @@
 <template>
-  <view class="container">
-    <view class="form-container">
-      <view class="page-title">添加家庭成员</view>
-
-      <!-- 昵称(必填) -->
-      <view class="form-item required">
-        <text class="label"><text class="required-mark">*</text>成员昵称</text>
-        <input class="input" v-model="form.nickname" placeholder="请输入成员昵称" maxlength="20" />
-      </view>
-
-      <!-- 关系类型(必填) -->
-      <view class="form-item required">
-        <text class="label"><text class="required-mark">*</text>关系类型</text>
-        <relationship-picker v-model="form.relationshipType" :showLabel="false" />
-      </view>
-
-      <!-- 性别(必填) -->
-      <view class="form-item required">
-        <text class="label"><text class="required-mark">*</text>性别</text>
-        <picker :range="genderOptions" range-key="label" @change="onGenderChange">
-          <view class="picker">
-            {{ genderLabel || '请选择性别' }}
-          </view>
-        </picker>
-      </view>
-
-      <!-- 出生日期(必填) -->
-      <view class="form-item required">
-        <text class="label"><text class="required-mark">*</text>出生日期</text>
-        <picker mode="date" :value="form.birthday" @change="onBirthdayChange" :end="todayDate">
-          <view class="picker">
-            {{ form.birthday || '请选择出生日期' }}
-          </view>
-        </picker>
-      </view>
-
-      <!-- 年龄(自动计算) -->
-      <view class="form-item">
-        <text class="label">年龄</text>
-        <view class="age-display">{{ calculatedAge || '根据出生日期自动计算' }}</view>
-      </view>
-
-      <!-- 出生时辰(选填) -->
-      <view class="form-item">
-        <text class="label">出生时辰(选填)</text>
-        <picker :range="birthHourOptions" @change="onBirthHourChange">
-          <view class="picker">
-            {{ form.birthHourName || '请选择出生时辰' }}
-          </view>
-        </picker>
-      </view>
-
-      <!-- 出生体重(选填) -->
-      <view class="form-item">
-        <text class="label">出生体重(选填,克)</text>
-        <input class="input" type="number" v-model="form.birthWeight" placeholder="如: 3500" maxlength="5" />
-      </view>
-
-      <!-- 出生地(选填) -->
-      <view class="form-item">
-        <text class="label">出生地(选填)</text>
-        <input class="input" v-model="form.birthPlace" placeholder="如: 北京市" maxlength="30" />
-      </view>
-
-      <!-- 是否剖腹产(选填) -->
-      <view class="form-item">
-        <text class="label">是否剖腹产(选填)</text>
-        <picker :range="cesareanOptions" range-key="label" @change="onCesareanChange">
-          <view class="picker">
-            {{ form.cesareanLabel || '请选择' }}
-          </view>
-        </picker>
-      </view>
-
-      <!-- 手机号(选填) -->
-      <view class="form-item">
-        <text class="label">手机号(选填)</text>
-        <input class="input" type="number" v-model="form.phone" placeholder="填写手机号可关联已有账号" maxlength="11" />
-        <text class="hint">若手机号已注册,将自动关联该账号</text>
-      </view>
-
-      <button class="btn-primary" :loading="submitting" @click="submit">保存</button>
-    </view>
-  </view>
+<view class="container">
+<view class="form-container">
+<view class="page-title">添加家庭成员</view>
+
+<!-- 昵称(必填) -->
+<view class="form-item required">
+<text class="label"><text class="required-mark">*</text>成员昵称</text>
+<input class="input" v-model="form.nickname" placeholder="请输入成员昵称" maxlength="20" />
+</view>
+
+<!-- 辈分关系(必填,替代关系类型) -->
+<view class="form-item required">
+<text class="label"><text class="required-mark">*</text>辈分关系</text>
+<generation-picker v-model:generationLevel="form.generationLevel" v-model:peerType="form.peerType" :showLabel="false" />
+</view>
+
+<!-- 性别(必填) -->
+<view class="form-item required">
+<text class="label"><text class="required-mark">*</text>性别</text>
+<picker :range="genderOptions" range-key="label" @change="onGenderChange">
+<view class="picker">
+{{ genderLabel || '请选择性别' }}
+</view>
+</picker>
+</view>
+
+<!-- 出生日期(必填) -->
+<view class="form-item required">
+<text class="label"><text class="required-mark">*</text>出生日期</text>
+<picker mode="date" :value="form.birthday" @change="onBirthdayChange" :end="todayDate">
+<view class="picker">
+{{ form.birthday || '请选择出生日期' }}
+</view>
+</picker>
+</view>
+
+<!-- 年龄(自动计算) -->
+<view class="form-item">
+<text class="label">年龄</text>
+<view class="age-display">{{ calculatedAge || '根据出生日期自动计算' }}</view>
+</view>
+
+<!-- 出生时辰(选填) -->
+<view class="form-item">
+<text class="label">出生时辰(选填)</text>
+<picker :range="birthHourOptions" @change="onBirthHourChange">
+<view class="picker">
+{{ form.birthHourName || '请选择出生时辰' }}
+</view>
+</picker>
+</view>
+
+<!-- 出生体重(选填) -->
+<view class="form-item">
+<text class="label">出生体重(选填,克)</text>
+<input class="input" type="number" v-model="form.birthWeight" placeholder="如: 3500" maxlength="5" />
+</view>
+
+<!-- 出生地(选填) -->
+<view class="form-item">
+<text class="label">出生地(选填)</text>
+<input class="input" v-model="form.birthPlace" placeholder="如: 北京市" maxlength="30" />
+</view>
+
+<!-- 是否剖腹产(选填) -->
+<view class="form-item">
+<text class="label">是否剖腹产(选填)</text>
+<picker :range="cesareanOptions" range-key="label" @change="onCesareanChange">
+<view class="picker">
+{{ form.cesareanLabel || '请选择' }}
+</view>
+</picker>
+</view>
+
+<!-- 手机号(选填) -->
+<view class="form-item">
+<text class="label">手机号(选填)</text>
+<input class="input" type="number" v-model="form.phone" placeholder="填写手机号可关联已有账号" maxlength="11" />
+<text class="hint">若手机号已注册,将自动关联该账号</text>
+</view>
+
+<button class="btn-primary" :loading="submitting" @click="submit">保存</button>
+</view>
+</view>
+</view>
 </template>
 
 <script>
 import { addFamilyMember } from '../../utils/api.js'
-import RelationshipPicker from '../../components/RelationshipPicker.vue'
+import GenerationPicker from '../../components/GenerationPicker.vue'
 
 export default {
-  components: { RelationshipPicker },
-  data() {
-    return {
-      submitting: false,
-      form: {
-        nickname: '',
-        relationshipType: '',
-        gender: '',
-        birthday: '',
-        phone: '',
-        birthHour: '',
-        birthHourName: '',
-        birthWeight: '',
-        birthPlace: '',
-        isCesarean: null,
-        cesareanLabel: ''
-      },
-      genderOptions: [
-        { value: 'male', label: '男' },
-        { value: 'female', label: '女' }
-      ],
-      birthHourOptions: ['', '子时(23-1点)', '丑时(1-3点)', '寅时(3-5点)', '卯时(5-7点)', '辰时(7-9点)', '巳时(9-11点)', '午时(11-13点)', '未时(13-15点)', '申时(15-17点)', '酉时(17-19点)', '戌时(19-21点)', '亥时(21-23点)'],
-      cesareanOptions: [
-        { value: 0, label: '顺产' },
-        { value: 1, label: '剖腹产' }
-      ],
-      todayDate: ''
-    }
-  },
-  computed: {
-    genderLabel() {
-      var gender = this.genderOptions.find(function(g) { return g.value === this.form.gender }.bind(this))
-      return gender ? gender.label : ''
-    },
-    calculatedAge() {
-      if (!this.form.birthday) return ''
-      var birth = new Date(this.form.birthday)
-      var today = new Date()
-      var age = today.getFullYear() - birth.getFullYear()
-      var monthDiff = today.getMonth() - birth.getMonth()
-      if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
-        age--
-      }
-      return age >= 0 ? age + '岁' : ''
-    }
-  },
-  onLoad() {
-    var today = new Date()
-    this.todayDate = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0')
-  },
-  methods: {
-    onGenderChange(e) {
-      this.form.gender = this.genderOptions[e.detail.value].value
-    },
-    onBirthdayChange(e) {
-      this.form.birthday = e.detail.value
-    },
-    onBirthHourChange(e) {
-      var idx = e.detail.value
-      if (idx === 0) {
-        this.form.birthHour = ''
-        this.form.birthHourName = ''
-      } else {
-        this.form.birthHour = this.birthHourOptions[idx]
-        this.form.birthHourName = this.birthHourOptions[idx]
-      }
-    },
-    onCesareanChange(e) {
-      var idx = e.detail.value
-      this.form.isCesarean = this.cesareanOptions[idx].value
-      this.form.cesareanLabel = this.cesareanOptions[idx].label
-    },
-    async submit() {
-      if (!this.form.nickname || !this.form.nickname.trim()) {
-        uni.showToast({ title: '请输入成员昵称', icon: 'none' })
-        return
-      }
-      if (!this.form.relationshipType) {
-        uni.showToast({ title: '请选择关系类型', icon: 'none' })
-        return
-      }
-      if (!this.form.gender) {
-        uni.showToast({ title: '请选择性别', icon: 'none' })
-        return
-      }
-      if (!this.form.birthday) {
-        uni.showToast({ title: '请选择出生日期', icon: 'none' })
-        return
-      }
-      if (this.form.phone && !/^1[3-9]\d{9}$/.test(this.form.phone)) {
-        uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
-        return
-      }
-
-      // 根据 relationshipType 推导 generationLevel 和 peerType
-      var generationLevel = ''
-      var peerType = null
-      switch (this.form.relationshipType) {
-        case 'spouse':
-          generationLevel = 'peer'
-          peerType = 'spouse'
-          break
-        case 'sibling':
-          generationLevel = 'peer'
-          peerType = 'sibling'
-          break
-        case 'parent':
-          generationLevel = 'parent'
-          break
-        case 'child':
-          generationLevel = 'child'
-          break
-        case 'grandparent':
-          generationLevel = 'grandparent'
-          break
-        case 'grandchild':
-          generationLevel = 'grandchild'
-          break
-        case 'great_grandparent':
-          generationLevel = 'great_grandparent'
-          break
-        case 'great_grandchild':
-          generationLevel = 'great_grandchild'
-          break
-        default:
-          uni.showToast({ title: '无效的关系类型: ' + this.form.relationshipType, icon: 'none' })
-          return
-      }
+components: { GenerationPicker },
+data() {
+return {
+submitting: false,
+form: {
+nickname: '',
+generationLevel: '',
+peerType: '',
+gender: '',
+birthday: '',
+phone: '',
+birthHour: '',
+birthHourName: '',
+birthWeight: '',
+birthPlace: '',
+isCesarean: null,
+cesareanLabel: ''
+},
+genderOptions: [
+{ value: 'male', label: '男' },
+{ value: 'female', label: '女' }
+],
+birthHourOptions: ['', '子时(23-1点)', '丑时(1-3点)', '寅时(3-5点)', '卯时(5-7点)', '辰时(7-9点)', '巳时(9-11点)', '午时(11-13点)', '未时(13-15点)', '申时(15-17点)', '酉时(17-19点)', '戌时(19-21点)', '亥时(21-23点)'],
+cesareanOptions: [
+{ value: 0, label: '顺产' },
+{ value: 1, label: '剖腹产' }
+],
+todayDate: ''
+}
+},
+computed: {
+genderLabel() {
+var gender = this.genderOptions.find(function(g) { return g.value === this.form.gender }.bind(this))
+return gender ? gender.label : ''
+},
+calculatedAge() {
+if (!this.form.birthday) return ''
+var birth = new Date(this.form.birthday)
+var today = new Date()
+var age = today.getFullYear() - birth.getFullYear()
+var monthDiff = today.getMonth() - birth.getMonth()
+if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
+age--
+}
+return age >= 0 ? age + '岁' : ''
+}
+},
+onLoad() {
+var today = new Date()
+this.todayDate = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0')
+},
+methods: {
+onGenderChange(e) {
+this.form.gender = this.genderOptions[e.detail.value].value
+},
+onBirthdayChange(e) {
+this.form.birthday = e.detail.value
+},
+onBirthHourChange(e) {
+var idx = e.detail.value
+if (idx === 0) {
+this.form.birthHour = ''
+this.form.birthHourName = ''
+} else {
+this.form.birthHour = this.birthHourOptions[idx]
+this.form.birthHourName = this.birthHourOptions[idx]
+}
+},
+onCesareanChange(e) {
+var idx = e.detail.value
+this.form.isCesarean = this.cesareanOptions[idx].value
+this.form.cesareanLabel = this.cesareanOptions[idx].label
+},
+async submit() {
+if (!this.form.nickname || !this.form.nickname.trim()) {
+uni.showToast({ title: '请输入成员昵称', icon: 'none' })
+return
+}
+if (!this.form.generationLevel) {
+uni.showToast({ title: '请选择辈分关系', icon: 'none' })
+return
+}
+if (!this.form.gender) {
+uni.showToast({ title: '请选择性别', icon: 'none' })
+return
+}
+if (!this.form.birthday) {
+uni.showToast({ title: '请选择出生日期', icon: 'none' })
+return
+}
+if (this.form.phone && !/^1[3-9]\d{9}$/.test(this.form.phone)) {
+uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
+return
+}
 
-      try {
-        this.submitting = true
-        var payload = {
-          nickname: this.form.nickname.trim(),
-          relationshipType: this.form.relationshipType,
-          generationLevel: generationLevel,
-          peerType: peerType,
-          gender: this.form.gender,
-          birthday: this.form.birthday
-        }
-        if (this.form.phone) {
-          payload.phone = this.form.phone
-        }
-        if (this.form.birthHour) {
-          payload.birthHour = this.form.birthHour
-        }
-        if (this.form.birthWeight) {
-          payload.birthWeight = parseInt(this.form.birthWeight)
-        }
-        if (this.form.birthPlace) {
-          payload.birthPlace = this.form.birthPlace
-        }
-        if (this.form.isCesarean !== null && this.form.isCesarean !== undefined) {
-          payload.isCesarean = this.form.isCesarean
-        }
-        var res = await addFamilyMember(payload)
-        if (res.code === 200) {
-          uni.showToast({ title: '添加成功', icon: 'success' })
-          setTimeout(function() { uni.navigateBack() }, 1500)
-        } else {
-          uni.showToast({ title: res.message || '添加失败', icon: 'none' })
-        }
-      } catch (e) {
-        uni.showToast({ title: '添加失败', icon: 'none' })
-      } finally {
-        this.submitting = false
-      }
-    }
-  }
+try {
+this.submitting = true
+var payload = {
+nickname: this.form.nickname.trim(),
+generationLevel: this.form.generationLevel,
+peerType: this.form.peerType || null,
+gender: this.form.gender,
+birthday: this.form.birthday
+}
+if (this.form.phone) {
+payload.phone = this.form.phone
+}
+if (this.form.birthHour) {
+payload.birthHour = this.form.birthHour
+}
+if (this.form.birthWeight) {
+payload.birthWeight = parseInt(this.form.birthWeight)
+}
+if (this.form.birthPlace) {
+payload.birthPlace = this.form.birthPlace
+}
+if (this.form.isCesarean !== null && this.form.isCesarean !== undefined) {
+payload.isCesarean = this.form.isCesarean
+}
+var res = await addFamilyMember(payload)
+if (res.code === 200) {
+uni.showToast({ title: '添加成功', icon: 'success' })
+setTimeout(function() { uni.navigateBack() }, 1500)
+} else {
+uni.showToast({ title: res.message || '添加失败', icon: 'none' })
+}
+} catch (e) {
+uni.showToast({ title: '添加失败', icon: 'none' })
+} finally {
+this.submitting = false
+}
+}
+}
 }
 </script>
 

+ 110 - 3
cfc-frontend/pages/index/parent-index.vue

@@ -16,6 +16,30 @@
   :selectedMemberId="selectedMemberId"
   @select="onFamilyMemberSelect" />
 
+<!-- ===== 微行动卡片(P0:有效启动) ===== -->
+<MicroActionCard
+  v-if="selectedMemberId && selectedChild && selectedChild.id"
+  :childId="selectedChild.id"
+  @completed="onMicroActionCompleted" />
+
+<!-- ===== 健康预警横幅(P1-2) ===== -->
+<HealthAlertBanner
+  v-if="selectedMemberId && selectedChild && selectedChild.id"
+  :alerts="healthAlerts"
+  @dismiss="onAlertDismiss" />
+
+<!-- ===== 家庭健康排行榜(P2-2) ===== -->
+<FamilyHealthRanking
+  v-if="rankingMembers && rankingMembers.length > 0"
+  :list="rankingMembers"
+  :totalScore="rankingTotalScore" />
+
+<!-- ===== 家庭挑战卡片(P2-2) ===== -->
+<FamilyChallengeCard
+  v-for="ch in activeChallenges"
+  :key="ch.id"
+  :challenge="ch" />
+
 <!-- ===== 家庭成员关系图谱 ===== -->
 <FamilyRelationGraph
   v-if="familyMembersVisible.length > 0"
@@ -350,11 +374,28 @@
         </PlayfulCard>
       </view>
     </template>
+    <!-- ===== L1 即时反馈:Toast + 庆祝 ===== -->
+    <FeedbackToast
+      :visible="toastVisible"
+      :text="toastText"
+      :icon="toastIcon"
+      :animating="toastAnimating" />
+    <MilestoneCelebration
+      :visible="celebrationVisible"
+      :title="celebrationTitle"
+      :subtitle="celebrationSubtitle"
+      :particles="celebrationParticles"
+      @dismiss="celebrationVisible = false" />
+    <!-- ===== L2 深度反馈:每日健康小语 ===== -->
+    <DailySummary
+      v-if="selectedMemberId && selectedChild && selectedChild.id"
+      :childId="selectedChild.id"
+      @dismiss="onDailySummaryDismiss" />
   </view>
 </template>
 
 <script>
-import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles } from '../../utils/api.js'
+import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert, getFamilyRanking, getChallengeList } from '../../utils/api.js'
 import PageBanner from '../../components/PageBanner.vue'
 import PlayfulCard from '../../components/PlayfulCard.vue'
 import PlayfulButton from '../../components/PlayfulButton.vue'
@@ -364,12 +405,20 @@ import BaseLoading from '../../components/BaseLoading.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import ContactCard from '../../components/ContactCard.vue'
+import FamilyHealthRanking from '../../components/FamilyHealthRanking.vue'
+import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import MicroActionCard from '../../components/MicroActionCard.vue'
+import FeedbackToast from '../../components/FeedbackToast.vue'
+import MilestoneCelebration from '../../components/MilestoneCelebration.vue'
+import DailySummary from '../../components/DailySummary.vue'
+import HealthAlertBanner from '../../components/HealthAlertBanner.vue'
+import feedbackEngine from '../../utils/feedback-engine.js'
 
 export default {
-  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip },
+  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard },
   data() {
     return {
       nickname: '',
@@ -419,7 +468,22 @@ export default {
         'linear-gradient(135deg, #F97316, #FB923C)',
         'linear-gradient(135deg, #6366F1, #818CF8)'
       ],
-      selectedMemberId: null
+      selectedMemberId: null,
+      // L1 即时反馈状态
+      toastVisible: false,
+      toastText: '',
+      toastIcon: '✅',
+      toastAnimating: false,
+      celebrationVisible: false,
+      celebrationTitle: '',
+      celebrationSubtitle: '',
+      celebrationParticles: [],
+      // P1-2 健康预警
+      healthAlerts: [],
+      // P2-2 家庭互动
+      rankingMembers: [],
+      rankingTotalScore: 0,
+      activeChallenges: []
     }
   },
   computed: {
@@ -682,6 +746,10 @@ export default {
 
         // 加载家庭成员可见列表
         this.loadFamilyMembersVisible()
+        // 加载健康预警(P1-2)
+        this.loadAlerts()
+        // 加载家庭健康互动(P2-2)
+        this.loadFamilyInteraction()
         // 加载外部联系人
         this.loadContacts()
         // 加载推荐阅读
@@ -758,6 +826,45 @@ export default {
     onFamilyMemberSelect(member) {
       this.selectedMemberId = member.id
     },
+    onMicroActionCompleted() {
+      this.loadData()
+      feedbackEngine.trigger('micro_action')
+    },
+    onDailySummaryDismiss() {
+      // DailySummary 已关闭,无额外操作
+    },
+    loadAlerts: function() {
+      var childId = this.selectedChild && this.selectedChild.id
+      if (!childId) return
+      var self = this
+      getActiveAlerts({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          self.healthAlerts = res.data || []
+        }
+    },
+      var self = this
+      dismissAlert({ id: alertId }).then(function(res) {
+        if (res.code === 200) {
+          self.healthAlerts = self.healthAlerts.filter(function(a) { return a.id !== alertId })
+        }
+      }).catch(function() {})
+    },
+    loadFamilyInteraction: function() {
+      var familyId = this._familyId
+      if (!familyId) return
+      var self = this
+      getFamilyRanking({ familyId: familyId }).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.rankingMembers = res.data.members || []
+          self.rankingTotalScore = res.data.totalScore || 0
+        }
+      })
+      getChallengeList({ familyId: familyId }).then(function(res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          self.activeChallenges = res.data
+        }
+      })
+    },
     goMemberDetail(member) {
       if (!member || !member.memberId) return
       uni.navigateTo({

+ 51 - 20
cfc-frontend/pages/profile-extra/edit-child.vue

@@ -47,22 +47,22 @@
         <input class="input" v-model="form.idCard" placeholder="请输入身份证号" maxlength="18" />
       </view>
 
-      <!-- 扣分开关 -->
-      <view class="form-item">
+      <!-- 扣分开关(仅 children 系统支持) -->
+      <view class="form-item" v-if="!isFamilyMember">
         <text class="label">扣分开关</text>
         <switch :checked="form.penaltyEnabled === 1" @change="onPenaltyChange" color="#F97316" />
         <text class="hint">开启后,超时未完成任务将扣减积分</text>
       </view>
 
-      <!-- 对家庭成员可见 -->
-      <view class="form-item">
+      <!-- 对家庭成员可见(仅 children 系统支持) -->
+      <view class="form-item" v-if="!isFamilyMember">
         <text class="label">对家庭成员可见</text>
         <switch :checked="form.showToFamily !== 0" @change="onShowToFamilyChange" color="#5B9BD5" />
         <text class="hint">关闭后,其他家庭成员将看不到此孩子</text>
       </view>
 
-      <!-- 主题风格 -->
-      <view class="form-item">
+      <!-- 主题风格(仅 children 系统支持) -->
+      <view class="form-item" v-if="!isFamilyMember">
         <text class="label">主题风格</text>
         <picker :range="themes" range-key="label" @change="onThemeChange">
           <view class="picker">
@@ -77,12 +77,14 @@
 </template>
 
 <script>
-import { getChildren, updateChild } from '../../utils/api.js'
+import { getChildren, updateChild, updateFamilyMember } from '../../utils/api.js'
 
 export default {
   data() {
     return {
       childId: '',
+      memberId: '',
+      isFamilyMember: false,
       submitting: false,
       form: {
         nickname: '',
@@ -133,12 +135,19 @@ export default {
   },
   onLoad(options) {
     this.childId = options.childId || ''
-    if (!this.childId) {
+    this.memberId = options.memberId || ''
+    if (!this.childId && !this.memberId) {
       uni.showToast({ title: '参数错误', icon: 'none' })
       setTimeout(() => uni.navigateBack(), 1500)
       return
     }
-    this.loadChildData()
+    // 优先按 childId 从 children 系统加载,找不到则按 memberId 从 family-member 系统加载
+    if (this.childId) {
+      this.loadChildData()
+    } else {
+      this.isFamilyMember = true
+      this.loadFamilyMemberData(options)
+    }
   },
   methods: {
     async loadChildData() {
@@ -158,12 +167,24 @@ export default {
             showToFamily: child.showToFamily !== 0 ? 1 : 0,
             theme: child.theme || 'default'
           }
+        } else {
+          // children 系统找不到,尝试作为 family member 加载
+          this.isFamilyMember = true
+          this.memberId = this.childId
+          this.childId = ''
         }
       } catch (e) {
         console.error('加载孩子数据失败', e)
         uni.showToast({ title: '加载失败', icon: 'none' })
       }
     },
+    loadFamilyMemberData(options) {
+      // 从 URL 参数中填充基本信息(family member 系统字段有限)
+      this.form.nickname = options.nickname ? decodeURIComponent(options.nickname) : ''
+      this.form.gender = options.gender || ''
+      this.form.phone = options.phone || ''
+      this.form.birthday = options.birthday || ''
+    },
     onBirthdayChange(e) {
       this.form.birthday = e.detail.value
       // 根据出生日期自动计算年龄
@@ -204,17 +225,27 @@ export default {
 
       this.submitting = true
       try {
-        await updateChild(this.childId, {
-          nickname: this.form.nickname,
-          birthday: this.form.birthday,
-          gender: this.form.gender,
-          age: this.form.age,
-          phone: this.form.phone,
-          idCard: this.form.idCard,
-          penaltyEnabled: this.form.penaltyEnabled,
-          showToFamily: this.form.showToFamily,
-          theme: this.form.theme
-        })
+        if (this.isFamilyMember) {
+          await updateFamilyMember({
+            memberId: this.memberId,
+            nickname: this.form.nickname,
+            birthday: this.form.birthday,
+            gender: this.form.gender,
+            phone: this.form.phone
+          })
+        } else {
+          await updateChild(this.childId, {
+            nickname: this.form.nickname,
+            birthday: this.form.birthday,
+            gender: this.form.gender,
+            age: this.form.age,
+            phone: this.form.phone,
+            idCard: this.form.idCard,
+            penaltyEnabled: this.form.penaltyEnabled,
+            showToFamily: this.form.showToFamily,
+            theme: this.form.theme
+          })
+        }
         uni.showToast({ title: '保存成功', icon: 'success' })
         setTimeout(() => uni.navigateBack(), 1500)
       } catch (e) {

+ 5 - 1
cfc-frontend/pages/profile-extra/family-members.vue

@@ -359,7 +359,11 @@ export default {
     },
     onEditRelation(member) {
       this.showMemberDetailModal = false
-      uni.navigateTo({ url: '/pages/profile/user-edit?memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '') })
+      var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')
+      if (member.gender) params += '&gender=' + member.gender
+      if (member.phone) params += '&phone=' + member.phone
+      if (member.birthday) params += '&birthday=' + member.birthday
+      uni.navigateTo({ url: '/pages/profile/edit-child?' + params })
     },
     doQuestionnaire(member) {
       this.showMemberDetailModal = false

+ 53 - 9
cfc-frontend/pages/profile/profile.vue

@@ -22,6 +22,18 @@
       <!-- 成就徽章 -->
       <ProfileBadges />
 
+      <!-- 健康等级称号(P2-1) -->
+      <HealthBadge
+        v-if="levelInfo"
+        :levelInfo="levelInfo"
+        :score="score"
+        :progress="progress"
+        :nextLevel="nextLevel"
+        :streakDays="streakDays" />
+
+      <!-- 健康故事时间线(P2-1) -->
+      <HealthTimeline :events="timelineEvents" />
+
       <!-- 菜单列表(仅个人信息 + 设置,不含服务/商城板块) -->
       <ProfileMenu :role="role" :show-services="false" @invite-generate="onInviteGenerate" />
     </template>
@@ -36,22 +48,34 @@ import ProfileBadges from './components/ProfileBadges.vue'
 import ProfileGrowth from './components/ProfileGrowth.vue'
 import ProfileMenu from './components/ProfileMenu.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
+import HealthBadge from '../../components/HealthBadge.vue'
+import HealthTimeline from '../../components/HealthTimeline.vue'
+import { getHealthScoreInfo, getHealthTimeline } from '../../utils/api.js'
 
 export default {
-  components: {
-    TabTransition,
-    ProfileHeader,
-    ProfileBadges,
-    ProfileGrowth,
-    ProfileMenu,
-    AIFloatingAvatar
-  },
+components: {
+TabTransition,
+ProfileHeader,
+ProfileBadges,
+ProfileGrowth,
+ProfileMenu,
+AIFloatingAvatar,
+HealthBadge,
+HealthTimeline
+},
   data() {
     return {
       shareData: null,
       showTabTransition: true,
       selectedMascot: '',
-      currentMascotName: '小助手'
+      currentMascotName: '小助手',
+      // P2-1 身份重塑
+      levelInfo: null,
+      score: 0,
+      progress: 0,
+      nextLevel: null,
+      streakDays: 0,
+      timelineEvents: []
     }
   },
   computed: {
@@ -83,6 +107,7 @@ export default {
     if (currentRole === 'teacher') {
       uni.redirectTo({ url: '/pages/teacher/teacher-profile' })
     }
+    this.loadHealthScoreData()
     this._watchPageReady()
   },
   methods: {
@@ -127,6 +152,25 @@ export default {
       setTimeout(function() {
         self.showTabTransition = false
       }, 2000)
+    },
+    loadHealthScoreData() {
+      var childId = uni.getStorageSync('currentChildId') || null
+      if (!childId) return
+      var self = this
+      getHealthScoreInfo({ childId: childId }).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.levelInfo = res.data.level || null
+          self.score = res.data.score || 0
+          self.progress = res.data.progress || 0
+          self.nextLevel = res.data.nextLevel || null
+          self.streakDays = res.data.streakDays || 0
+        }
+      }).catch(function() {})
+      getHealthTimeline({ childId: childId }).then(function(res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          self.timelineEvents = res.data.slice(0, 10)
+        }
+      }).catch(function() {})
     }
   }
 }

+ 146 - 0
cfc-frontend/pages/share/index.vue

@@ -0,0 +1,146 @@
+<template>
+  <view class="share-page">
+    <view class="sp-header" v-if="familyName">
+      <text class="sp-title">{{ familyName }} · 健康足迹</text>
+    </view>
+
+    <view class="sp-body" v-if="members && members.length > 0">
+      <view class="sp-member" v-for="m in members" :key="m.name">
+        <image class="sp-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
+        <view class="sp-member-info">
+          <text class="sp-member-name">{{ m.name }}</text>
+          <view class="sp-member-stats">
+            <text class="sp-stat">打卡 {{ m.streakDays || 0 }} 天</text>
+            <text class="sp-stat" v-if="m.score > 0">积分 {{ m.score }}</text>
+          </view>
+        </view>
+        <text class="sp-badge" v-if="m.streakDays && m.streakDays >= 30">🏆</text>
+        <text class="sp-badge" v-else-if="m.streakDays && m.streakDays >= 7">🎯</text>
+      </view>
+    </view>
+
+    <view class="sp-footer" v-if="totalCheckinDays">
+      <text class="sp-slogan">我们坚持健康打卡 {{ totalCheckinDays }} 天了!</text>
+    </view>
+
+    <view class="sp-qr">
+      <text class="sp-qr-text">扫码加入我们一起健康</text>
+    </view>
+
+    <view class="sp-empty" v-if="!members || members.length === 0">
+      <text class="sp-empty-text">暂无健康数据</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getFamilyFootprint } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyName: '',
+      members: [],
+      totalScore: 0,
+      totalCheckinDays: 0
+    }
+  },
+  onLoad(params) {
+    var familyId = params.familyId || ''
+    if (familyId) {
+      this.loadData(familyId)
+    }
+  },
+  methods: {
+    loadData: function(familyId) {
+      var self = this
+      getFamilyFootprint({ familyId: familyId }).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.members = res.data.members || []
+          self.totalScore = res.data.totalScore || 0
+          self.totalCheckinDays = res.data.totalCheckinDays || 0
+        }
+      }).catch(function() {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.share-page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #F0FDF4 0%, #ECFDF5 100%);
+  padding: 40rpx 30rpx;
+}
+.sp-header { text-align: center; margin-bottom: 40rpx; }
+.sp-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #065F46;
+}
+.sp-body {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08);
+}
+.sp-member {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.sp-member:last-child { border-bottom: none; }
+.sp-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.sp-member-info { flex: 1; min-width: 0; }
+.sp-member-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.sp-member-stats {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 4rpx;
+}
+.sp-stat {
+  font-size: 24rpx;
+  color: #64748B;
+}
+.sp-badge { font-size: 40rpx; }
+.sp-footer {
+  text-align: center;
+  margin-top: 40rpx;
+  padding: 24rpx;
+  background: rgba(16,185,129,0.1);
+  border-radius: 16rpx;
+}
+.sp-slogan {
+  font-size: 28rpx;
+  color: #065F46;
+  font-weight: 600;
+}
+.sp-qr {
+  text-align: center;
+  margin-top: 40rpx;
+  padding: 40rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  border: 2rpx dashed #D1FAE5;
+}
+.sp-qr-text {
+  font-size: 26rpx;
+  color: #10B981;
+}
+.sp-empty {
+  text-align: center;
+  padding: 100rpx 0;
+}
+.sp-empty-text { font-size: 28rpx; color: #94A3B8; }
+</style>

+ 5 - 2
cfc-frontend/pages/tasks/review.vue

@@ -312,8 +312,11 @@ export default {
   width: 100%;
 }
 
-/* Deep selector to style PlayfulButton inside actions slot */
-.card-actions >>> .playful-btn {
+</style>
+
+<style lang="scss">
+/* Non-scoped: style PlayfulButton inside actions slot (scoped deep selectors crash HBuilderX postcss) */
+.card-actions .playful-btn {
   flex: 1;
 }
 </style>

+ 85 - 0
cfc-frontend/store/feedback.js

@@ -0,0 +1,85 @@
+/**
+ * 健康反馈状态模块
+ * L1即时反馈状态 + L2每日反馈展示状态
+ */
+
+import feedbackEngine from '../utils/feedback-engine.js'
+
+var feedbackStore = {
+  state: {
+    // L1 Toast
+    toastVisible: false,
+    toastText: '',
+    toastIcon: '',
+    toastAnimating: false,
+    // L1 Celebration
+    celebrationVisible: false,
+    celebrationTitle: '',
+    celebrationSubtitle: '',
+    celebrationParticles: [],
+    // L2 Daily summary
+    dailyFeedback: null,
+    dailyFeedbackLoaded: false
+  },
+  mutations: {
+    showToast: function(state, payload) {
+      state.toastText = payload.text
+      state.toastIcon = payload.icon || '✅'
+      state.toastVisible = true
+      state.toastAnimating = true
+    },
+    hideToast: function(state) {
+      state.toastVisible = false
+      state.toastAnimating = false
+    },
+    showCelebration: function(state, payload) {
+      state.celebrationTitle = payload.title
+      state.celebrationSubtitle = payload.subtitle
+      state.celebrationVisible = true
+      // Generate 50 particles
+      var particles = []
+      for (var i = 0; i < 50; i++) {
+        particles.push({
+          id: i,
+          left: Math.random() * 100,
+          delay: Math.random() * 0.5,
+          duration: 1 + Math.random() * 1.5,
+          color: ['#FF6B9D', '#F97316', '#10B981', '#6366F1', '#F59E0B', '#FF8C42'][Math.floor(Math.random() * 6)],
+          size: 8 + Math.floor(Math.random() * 16)
+        })
+      }
+      state.celebrationParticles = particles
+    },
+    hideCelebration: function(state) {
+      state.celebrationVisible = false
+      state.celebrationParticles = []
+    },
+    setDailyFeedback: function(state, feedback) {
+      state.dailyFeedback = feedback
+      state.dailyFeedbackLoaded = true
+    }
+  },
+  actions: {
+    initEngine: function({ commit }) {
+      // Register engine callbacks
+      feedbackEngine.registerCallbacks(
+        function(toastData) {
+          commit('showToast', toastData)
+          // Auto-hide after 2s
+          setTimeout(function() {
+            commit('hideToast')
+          }, 2000)
+        },
+        function(celebrationData) {
+          commit('showCelebration', celebrationData)
+          // Auto-hide after 3s
+          setTimeout(function() {
+            commit('hideCelebration')
+          }, 3000)
+        }
+      )
+    }
+  }
+}
+
+export default feedbackStore

+ 59 - 0
cfc-frontend/store/health-alert.js

@@ -0,0 +1,59 @@
+/**
+ * 健康预警状态模块
+ * P1-2 健康行为智能预警
+ */
+
+import api from '@/utils/api.js'
+
+var healthAlertStore = {
+  state: {
+    alerts: [],
+    alertLoading: false,
+    alertError: false,
+    lastFetchTime: 0
+  },
+  mutations: {
+    setAlerts: function(state, alerts) {
+      state.alerts = alerts
+      state.alertLoading = false
+      state.alertError = false
+      state.lastFetchTime = Date.now()
+    },
+    setAlertLoading: function(state) {
+      state.alertLoading = true
+    },
+    setAlertError: function(state) {
+      state.alertLoading = false
+      state.alertError = true
+    },
+    dismissAlert: function(state, alertId) {
+      state.alerts = state.alerts.filter(function(a) { return a.id !== alertId })
+    }
+  },
+  actions: {
+    fetchAlerts: function({ commit }, childId) {
+      if (!childId) return
+      commit('setAlertLoading')
+      api.getActiveAlerts({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          commit('setAlerts', res.data || [])
+        } else {
+          commit('setAlertError')
+        }
+      }).catch(function() {
+        commit('setAlertError')
+      })
+    },
+    dismissAlert: function({ commit }, alertId) {
+      api.dismissAlert({ id: alertId }).then(function(res) {
+        if (res.code === 200) {
+          commit('dismissAlert', alertId)
+        }
+      }).catch(function() {
+        // silent fail
+      })
+    }
+  }
+}
+
+export default healthAlertStore

+ 49 - 0
cfc-frontend/store/health-score.js

@@ -0,0 +1,49 @@
+/**
+ * 健康积分/等级状态模块
+ * P2-1 身份重塑系统
+ */
+
+import api from '@/utils/api.js'
+
+var healthScoreStore = {
+  state: {
+    scoreInfo: null,
+    timeline: [],
+    scoreLoading: false
+  },
+  mutations: {
+    setScoreInfo: function(state, info) {
+      state.scoreInfo = info
+      state.scoreLoading = false
+    },
+    setTimeline: function(state, list) {
+      state.timeline = list
+    },
+    setScoreLoading: function(state) {
+      state.scoreLoading = true
+    }
+  },
+  actions: {
+    fetchScoreInfo: function({ commit }, childId) {
+      if (!childId) return
+      commit('setScoreLoading')
+      api.getHealthScoreInfo({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          commit('setScoreInfo', res.data)
+        } else {
+          commit('setScoreLoading')
+        }
+      }).catch(function() {})
+    },
+    fetchTimeline: function({ commit }, childId) {
+      if (!childId) return
+      api.getHealthTimeline({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          commit('setTimeline', res.data || [])
+        }
+      }).catch(function() {})
+    }
+  }
+}
+
+export default healthScoreStore

+ 4 - 1
cfc-frontend/store/index.js

@@ -2,6 +2,9 @@ import Vue from 'vue'
 import Vuex from 'vuex'
 
 import tianpan from './modules/tianpan'
+import feedback from './feedback'
+import healthAlert from './health-alert'
+import healthScore from './health-score'
 
 Vue.use(Vuex)
 
@@ -164,7 +167,7 @@ const store = new Vuex.Store({
     }
   }
 ,
-  modules: { tianpan }
+  modules: { tianpan, feedback, healthAlert, healthScore }
 })
 
 export default store

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

@@ -1456,6 +1456,43 @@ export const getInsurancePlanning = () => {
 export const healthCheckinList = (data) => request('/api/health/checkin/list', 'POST', data)
 export const healthCheckinCreate = (data) => request('/api/health/checkin/create', 'POST', data)
 export const healthCheckinDelete = (id) => request('/api/health/checkin/delete', 'POST', { id })
+
+// ===== 寰鍔紙P0 鏈夋晥鍚姩锛� =====
+export const microActionList = () => request('/api/micro-action/list', 'POST', {})
+export const microActionToday = (data) => request('/api/micro-action/today', 'POST', data)
+export const microActionComplete = (data) => request('/api/micro-action/complete', 'POST', data)
+export const microActionCheckToday = (data) => request('/api/micro-action/check-today', 'POST', data)
+
+// ===== 每日反馈(P1-1 即时多巴胺反馈) =====
+export const getTodayFeedback = (data) => request('/api/feedback/today', 'POST', data)
+export const markFeedbackRead = (data) => request('/api/feedback/read', 'POST', data)
+export const getRecentFeedbacks = (data) => request('/api/feedback/recent', 'POST', data)
+
+// ===== 健康预警(P1-2 健康行为智能预警) =====
+export const getActiveAlerts = (data) => request('/api/health/alert/list', 'POST', data)
+export const dismissAlert = (data) => request('/api/health/alert/dismiss', 'POST', data)
+
+// ===== 健康积分/等级/时间线(P2-1 身份重塑) =====
+export const getHealthScoreInfo = (data) => request('/api/health/score/info', 'POST', data)
+export const getHealthStreak = (data) => request('/api/health/score/streak', 'POST', data)
+export const getHealthTimeline = (data) => request('/api/health/timeline/list', 'POST', data)
+
+// ===== 家庭健康互动(P2-2 排行榜/点赞/挑战) =====
+export const getFamilyRanking = (data) => request('/api/health/family/ranking', 'POST', data)
+export const getChallengeList = (data) => request('/api/health/challenge/list', 'POST', data)
+export const getChallengeHistory = (data) => request('/api/health/challenge/history', 'POST', data)
+export const updateChallengeProgress = (data) => request('/api/health/challenge/progress', 'POST', data)
+export const sendLike = (data) => request('/api/health/interact/like', 'POST', data)
+
+// ===== 健康社区扩散(P3 健康圈/排行/分享) =====
+export const getCircleList = (data) => request('/api/health/circle/list', 'POST', data)
+export const createCircle = (data) => request('/api/health/circle/create', 'POST', data)
+export const joinCircle = (data) => request('/api/health/circle/join', 'POST', data)
+export const leaveCircle = (data) => request('/api/health/circle/leave', 'POST', data)
+export const getCircleRanking = (data) => request('/api/health/circle/ranking', 'POST', data)
+export const getCircleChallengeList = (data) => request('/api/health/circle/challenge/list', 'POST', data)
+export const getCircleChallengeHistory = (data) => request('/api/health/circle/challenge/history', 'POST', data)
+export const getFamilyFootprint = (data) => request('/api/share/family-footprint', 'POST', data)
 
 // ===== 娲诲姩妯″潡锛堢淮搴︾瓫閫夛級 =====
 export const getActivityList = (data) => request('/api/activity/list', 'POST', data)

+ 61 - 0
cfc-frontend/utils/feedback-engine.js

@@ -0,0 +1,61 @@
+/**
+ * 即时多巴胺反馈触发引擎
+ * 判定何时触发什么反馈,协调 Toast/Milestone 组件
+ */
+
+import { getRandomText, getIcon, getSound, formatText } from './feedback-presets.js'
+
+// 存储外部注册的回调
+var _toastCallback = null
+var _celebrationCallback = null
+
+/**
+ * 注册反馈组件回调
+ * @param {Function} toastFn - 接收 {text, icon, sound} 触发 Toast
+ * @param {Function} celebrationFn - 接收 {title, subtitle} 触发全屏庆祝
+ */
+function registerCallbacks(toastFn, celebrationFn) {
+  _toastCallback = toastFn
+  _celebrationCallback = celebrationFn
+}
+
+/**
+ * 触发即时反馈(L1)
+ * @param {string} scene - 场景编码: micro_action/checkin/exercise/sleep/water/milestone_7/first_use/comeback
+ * @param {Object} vars - 模板变量,如 {minutes: 20}
+ */
+function trigger(scene, vars) {
+  var text = getRandomText(scene)
+  var icon = getIcon(scene)
+  var sound = getSound(scene)
+  var formatted = formatText(text, vars)
+
+  // 判断是否为里程碑(需要全屏庆祝)
+  if (scene && scene.indexOf('milestone_') === 0) {
+    var days = scene.replace('milestone_', '')
+    var titles = {
+      '3': '连续3天!',
+      '7': '连续一周!',
+      '14': '连续14天!',
+      '21': '连续21天!',
+      '30': '连续一个月!'
+    }
+    if (_celebrationCallback) {
+      _celebrationCallback({
+        title: titles[days] || '里程碑达成!',
+        subtitle: formatted
+      })
+    }
+  } else {
+    // 普通反馈:Toast
+    if (_toastCallback) {
+      _toastCallback({
+        text: formatted,
+        icon: icon,
+        sound: sound
+      })
+    }
+  }
+}
+
+export { registerCallbacks, trigger }

+ 98 - 0
cfc-frontend/utils/feedback-presets.js

@@ -0,0 +1,98 @@
+/**
+ * 即时多巴胺反馈 — 预设文案 + 音效映射表
+ * L1 即时反馈使用
+ */
+
+// 各场景触发时的文案
+var PRESETS = {
+  micro_action: {
+    texts: ['太棒了!🎉', '好样的!⭐', '一步一个脚印 💪', '健康在积累!🌱'],
+    icon: '✅',
+    sound: 'ding'
+  },
+  checkin: {
+    texts: ['今天又是健康的一天!🌟', '打卡成功!🔥', '坚持就是胜利 💪'],
+    icon: '📅',
+    sound: 'ding'
+  },
+  exercise: {
+    texts: ['今天动了{{minutes}}分钟!💪', '运动让大脑更聪明 🧠'],
+    icon: '🏃',
+    sound: 'light'
+  },
+  sleep: {
+    texts: ['优质睡眠!😴', '好睡眠是最好的免疫力 🌙'],
+    icon: '😴',
+    sound: 'light'
+  },
+  water: {
+    texts: ['补水成功 💧', '身体在感谢你!☕️'],
+    icon: '💧',
+    sound: 'light'
+  },
+  milestone_3: {
+    texts: ['连续3天!你已经超过了50%的新手 💪'],
+    icon: '🔥',
+    sound: 'ascend'
+  },
+  milestone_7: {
+    texts: ['一周了!习惯正在形成 🌱'],
+    icon: '🌱',
+    sound: 'ascend'
+  },
+  milestone_14: {
+    texts: ['两周!坚持就是你的超能力 ⚡'],
+    icon: '⚡',
+    sound: 'ascend'
+  },
+  milestone_21: {
+    texts: ['21天法则!习惯几乎自动化了 🔥'],
+    icon: '🔥',
+    sound: 'ascend'
+  },
+  milestone_30: {
+    texts: ['一个月!你是健康行动派 🏆'],
+    icon: '🏆',
+    sound: 'ascend'
+  },
+  first_use: {
+    texts: ['你的健康之旅开始了!🎉'],
+    icon: '🚀',
+    sound: 'ding'
+  },
+  comeback: {
+    texts: ['欢迎回来!每一次重启都是勇敢的 🌟'],
+    icon: '🌟',
+    sound: 'ding'
+  }
+}
+
+// 获取随机文案
+function getRandomText(scene) {
+  var preset = PRESETS[scene]
+  if (!preset) return ''
+  var list = preset.texts
+  return list[Math.floor(Math.random() * list.length)]
+}
+
+// 获取场景图标
+function getIcon(scene) {
+  var preset = PRESETS[scene]
+  return preset ? preset.icon : '✅'
+}
+
+// 获取场景音效类型
+function getSound(scene) {
+  var preset = PRESETS[scene]
+  return preset ? preset.sound : 'ding'
+}
+
+// 格式化文案(替换变量)
+function formatText(text, vars) {
+  if (!vars) return text
+  return text.replace(/\{\{(\w+)\}\}/g, function(m, key) {
+    return vars[key] !== undefined ? vars[key] : m
+  })
+}
+
+export { PRESETS, getRandomText, getIcon, getSound, formatText }

+ 1 - 1
cfc-web/.env.development

@@ -1,2 +1,2 @@
-VUE_APP_BASE_API=
+VUE_APP_BASE_API=http://localhost:9082
 VUE_APP_SKIP_CAPTCHA=true

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-8756aee099d8c7be1e7e68126a1461e4dd84ae43
+0237598d64558d4f00ebec97de02fc8a7e589c9c

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.469",
+  "version": "1.0.490",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.469",
+      "version": "1.0.490",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.469",
+  "version": "1.0.490",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 18 - 0
cfc-web/public/CHANGELOG-v$(node.undefined.md

@@ -0,0 +1,18 @@
+# v$(node.undefined 版本历史
+
+[« 返回最新版本](CHANGELOG.md)
+
+---
+## v$(node (2026-07-20)
+
+### 其他
+- 分配规划师
+- 分配规划师
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 修复程序报错
+- 修复程序报错
+- 修复程序报错
+

+ 213 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,219 @@
 
 ---
 
+## v1.0.490 (2026-07-22)
+
+### 文档
+- update PROJECT-OVERVIEW.md v1.2 with Phase 2-4 recategorization and comprehensive implementation plan
+
+### 其他
+- - B类4项+ C类4项重新分Phase(Phase 2-4)
+- - 新增 comprehensive implementation plan (13 tasks across 3 phases)
+- - 阶段总览更新:Phase 2=商业模式&AI落地, Phase 3=五维增强, Phase 4=平台扩展
+- 
+
+
+## v1.0.489 (2026-07-22)
+
+### 其他
+- web/knowledge.html: swap side panel directions per request
+
+
+## v1.0.488 (2026-07-22)
+
+### 其他
+- 1、优化管理端前端的分页异常问题 2、用户预约测评后,管理端分配规划师
+
+
+## v1.0.487 (2026-07-22)
+
+### 其他
+- web/knowledge.html: >> in header, side panel for hidden articles, fix layout
+
+
+## v1.0.486 (2026-07-22)
+
+### 文档
+- update PROJECT-OVERVIEW.md - P2-1 marked complete
+
+### 其他
+- - 状态行更新: P0/P1-1/P1-2/P2-1 全部 ✅ 完成
+- - 矩阵新增状态列
+- 
+- - 新增 HealthScoreService 积分计算引擎
+- - 新增 HealthLevelService 等级判定+种子数据自动插入
+- - 新增 HealthTimelineService 时间线事件生成(里程碑/升级/首次)
+- - 新增 HealthScoreController+HealthTimelineController
+- - 数据库迁移 105/106 (health_level+health_timeline_event)
+- - 前端 HealthBadge + HealthTimeline 组件
+- - 前端 store/health-score.js + api.js 端点集成
+- - profile.vue 展示等级称号+进度条+时间线
+- 
+
+### 新功能
+- P2-1 身份重塑系统 - 健康等级+积分引擎+时间线
+
+
+## v1.0.485 (2026-07-22)
+
+### 新功能
+- 完成5篇深度疾病科普文章并接入成长百科
+
+### 其他
+- - 重写 osteoporosis.html(骨质疏松):慢性炎症+肠-骨轴+RANKL/OPG机制
+- - 增强 cancer.html:症压缩证据盒+免疫三大功能+肠-癌轴
+- - 更新 knowledge.html:将 alzheimer.html 接入成长百科(身维度)
+- 文章核心框架:症压缩 → 慢性炎症统一病因 → 抗炎干预(菌群+富氢水)
+- 
+
+
+## v1.0.484 (2026-07-22)
+
+### 其他
+- 启动报错修复
+
+
+## v1.0.483 (2026-07-22)
+
+### 新功能
+- P1-2 健康行为智能预警实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 104 (health_alerts表)
+- - 前端: HealthAlertBanner横幅组件 + store/health-alert.js + parent-index集成
+- - 6条预警规则: 睡眠下降/无运动/饮水少/运动过强/7天无打卡/今日未打卡
+- - 去重机制: 同一规则24h内不重复生成
+- - 严重程度排序: high > medium > low,最多展示2条
+- 
+
+
+## v1.0.482 (2026-07-22)
+
+### Bug 修复
+- point develop env to cfc.etotem.com.cn; extract deep selector to non-scoped style for HBuilderX compat
+
+### 新功能
+- P1-1 即时多巴胺反馈实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 103
+- - 前端: FeedbackToast, MilestoneCelebration, DailySummary 组件
+- - 预设文案引擎: 12 场景触发 + 里程碑判定
+- - Vuex 反馈状态模块 + parent-index 集成
+- - 微行动完成后自动触发即时反馈
+- 
+
+
+## v1.0.481 (2026-07-22)
+
+### 其他
+- web/knowledge.html: Fix title padding (margin-top), add disease articles, wealth marker right->65%
+
+
+## v1.0.480 (2026-07-22)
+
+### 其他
+- web: Fix 浠艾福 typo(爱→艾), fix knowledge.html title padding, shift markers up 10%, max-3 articles with >> reveal
+- - PROJECT-OVERVIEW.md v1.1→v1.2: 新增 Phase 5 阶段、3.1 身 条目、十 文档索引、十一 主动健康改进章节
+- - AGENTS.md: 新增变更追踪规则 — superpowers/ 下文件增删改必须同步 PROJECT-OVERVIEW.md
+- 
+
+### 文档
+- 主动健康六阶模型改进设计文档
+
+
+## v1.0.479 (2026-07-22)
+
+### Bug 修复
+- replace >>> deep selector with ::v-deep for postcss compat
+- fix hash operator precedence, use floorMod for negative-safe index
+
+
+## v1.0.478 (2026-07-22)
+
+### 其他
+- 修复前端分页问题
+- 分配规划师
+- 分配规划师
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 修复程序报错
+- 修复程序报错
+- 修复程序报错
+
+
+## v1.0.477 (2026-07-22)
+
+### 其他
+- web: Fix footer typo 浐色福→浠艾福, remove 成长百科 column; Fix knowledge.html title obscured by fixed header
+
+
+## v1.0.476 (2026-07-22)
+
+### 其他
+- web/diabetes.html: Fix missing shared footer placeholder
+
+
+## v1.0.475 (2026-07-22)
+
+### 其他
+- web: 全站统一 footer 为共享组件 js/footer.js,移除各页面自定义 footer CSS
+
+
+## v1.0.474 (2026-07-22)
+
+### 新功能
+- 成长百科改为人体五维标注布局 + 文本替换
+
+### 其他
+- - 人体五部位对应五维: 肠→身, 脑→智, 心→心, 手→富, 脚→行
+- - Desktop: 人体图四周浮动标注卡片,连接圆点指向对应身体部位
+- - Mobile: 顶部5标签切换(身·智·富·行·心),点击显示文章列表
+- - 替换所有'亿图腾(北京)科技有限公司'→'浠艾福主动健康研究院'
+- 
+
+
+## v1.0.473 (2026-07-22)
+
+### 新功能
+- 重写成长百科为100vh单页布局(剪影背景+五维卡片+手机标签)
+
+### 其他
+- - 新增100vh单页布局(overflow:hidden),无滚动
+- - 单人剪影+群组剪影作为装饰性页面背景(低透明度)
+- - Desktop: 5维卡片水平排列,各维度文章链接直接可见
+- - Mobile: 顶部5标签切换(身·智·富·行·心),点击显示对应文章
+- - 所有文章数据及标签切换逻辑由内联JS驱动
+- 
+
+
+## v1.0.472 (2026-07-22)
+
+### 新功能
+- 新增成长百科主页面 + Clarity 全站覆盖
+
+### 其他
+- - web/css/style.css: 新增知识页面全套响应式样式(k-hero/k-dim-section/k-article-grid)
+- - 全站添加 Microsoft Clarity 用户行为分析脚本(FAQ 3 篇 + sales.html + knowledge.html)
+- - 导航与 footer 链接统一指向 knowledge.html
+- - index.html 知识区新增" 进入成长百科 →\按钮
+- 
+
+
+## v1.0.471 (2026-07-22)
+
+### 文档
+- add implementation audit, tickler, phase1-remaining, and P0 tickets
+
+
+## v1.0.470 (2026-07-22)
+
+### Bug 修复
+- use setFontSize in renderLegacy for WeChat compat, add _destroyed lifecycle guard
+
+
 ## v1.0.469 (2026-07-22)
 
 ### Bug 修复

+ 214 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.469
+> 当前版本: v1.0.490
 
 ## 历史版本
 
@@ -8,6 +8,219 @@
 
 ---
 
+## v1.0.490 (2026-07-22)
+
+### 文档
+- update PROJECT-OVERVIEW.md v1.2 with Phase 2-4 recategorization and comprehensive implementation plan
+
+### 其他
+- - B类4项+ C类4项重新分Phase(Phase 2-4)
+- - 新增 comprehensive implementation plan (13 tasks across 3 phases)
+- - 阶段总览更新:Phase 2=商业模式&AI落地, Phase 3=五维增强, Phase 4=平台扩展
+- 
+
+
+## v1.0.489 (2026-07-22)
+
+### 其他
+- web/knowledge.html: swap side panel directions per request
+
+
+## v1.0.488 (2026-07-22)
+
+### 其他
+- 1、优化管理端前端的分页异常问题 2、用户预约测评后,管理端分配规划师
+
+
+## v1.0.487 (2026-07-22)
+
+### 其他
+- web/knowledge.html: >> in header, side panel for hidden articles, fix layout
+
+
+## v1.0.486 (2026-07-22)
+
+### 文档
+- update PROJECT-OVERVIEW.md - P2-1 marked complete
+
+### 其他
+- - 状态行更新: P0/P1-1/P1-2/P2-1 全部 ✅ 完成
+- - 矩阵新增状态列
+- 
+- - 新增 HealthScoreService 积分计算引擎
+- - 新增 HealthLevelService 等级判定+种子数据自动插入
+- - 新增 HealthTimelineService 时间线事件生成(里程碑/升级/首次)
+- - 新增 HealthScoreController+HealthTimelineController
+- - 数据库迁移 105/106 (health_level+health_timeline_event)
+- - 前端 HealthBadge + HealthTimeline 组件
+- - 前端 store/health-score.js + api.js 端点集成
+- - profile.vue 展示等级称号+进度条+时间线
+- 
+
+### 新功能
+- P2-1 身份重塑系统 - 健康等级+积分引擎+时间线
+
+
+## v1.0.485 (2026-07-22)
+
+### 新功能
+- 完成5篇深度疾病科普文章并接入成长百科
+
+### 其他
+- - 重写 osteoporosis.html(骨质疏松):慢性炎症+肠-骨轴+RANKL/OPG机制
+- - 增强 cancer.html:症压缩证据盒+免疫三大功能+肠-癌轴
+- - 更新 knowledge.html:将 alzheimer.html 接入成长百科(身维度)
+- 文章核心框架:症压缩 → 慢性炎症统一病因 → 抗炎干预(菌群+富氢水)
+- 
+
+
+## v1.0.484 (2026-07-22)
+
+### 其他
+- 启动报错修复
+
+
+## v1.0.483 (2026-07-22)
+
+### 新功能
+- P1-2 健康行为智能预警实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 104 (health_alerts表)
+- - 前端: HealthAlertBanner横幅组件 + store/health-alert.js + parent-index集成
+- - 6条预警规则: 睡眠下降/无运动/饮水少/运动过强/7天无打卡/今日未打卡
+- - 去重机制: 同一规则24h内不重复生成
+- - 严重程度排序: high > medium > low,最多展示2条
+- 
+
+
+## v1.0.482 (2026-07-22)
+
+### Bug 修复
+- point develop env to cfc.etotem.com.cn; extract deep selector to non-scoped style for HBuilderX compat
+
+### 新功能
+- P1-1 即时多巴胺反馈实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 103
+- - 前端: FeedbackToast, MilestoneCelebration, DailySummary 组件
+- - 预设文案引擎: 12 场景触发 + 里程碑判定
+- - Vuex 反馈状态模块 + parent-index 集成
+- - 微行动完成后自动触发即时反馈
+- 
+
+
+## v1.0.481 (2026-07-22)
+
+### 其他
+- web/knowledge.html: Fix title padding (margin-top), add disease articles, wealth marker right->65%
+
+
+## v1.0.480 (2026-07-22)
+
+### 其他
+- web: Fix 浠艾福 typo(爱→艾), fix knowledge.html title padding, shift markers up 10%, max-3 articles with >> reveal
+- - PROJECT-OVERVIEW.md v1.1→v1.2: 新增 Phase 5 阶段、3.1 身 条目、十 文档索引、十一 主动健康改进章节
+- - AGENTS.md: 新增变更追踪规则 — superpowers/ 下文件增删改必须同步 PROJECT-OVERVIEW.md
+- 
+
+### 文档
+- 主动健康六阶模型改进设计文档
+
+
+## v1.0.479 (2026-07-22)
+
+### Bug 修复
+- replace >>> deep selector with ::v-deep for postcss compat
+- fix hash operator precedence, use floorMod for negative-safe index
+
+
+## v1.0.478 (2026-07-22)
+
+### 其他
+- 修复前端分页问题
+- 分配规划师
+- 分配规划师
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 填写预约报告表单
+- 修复程序报错
+- 修复程序报错
+- 修复程序报错
+
+
+## v1.0.477 (2026-07-22)
+
+### 其他
+- web: Fix footer typo 浐色福→浠艾福, remove 成长百科 column; Fix knowledge.html title obscured by fixed header
+
+
+## v1.0.476 (2026-07-22)
+
+### 其他
+- web/diabetes.html: Fix missing shared footer placeholder
+
+
+## v1.0.475 (2026-07-22)
+
+### 其他
+- web: 全站统一 footer 为共享组件 js/footer.js,移除各页面自定义 footer CSS
+
+
+## v1.0.474 (2026-07-22)
+
+### 新功能
+- 成长百科改为人体五维标注布局 + 文本替换
+
+### 其他
+- - 人体五部位对应五维: 肠→身, 脑→智, 心→心, 手→富, 脚→行
+- - Desktop: 人体图四周浮动标注卡片,连接圆点指向对应身体部位
+- - Mobile: 顶部5标签切换(身·智·富·行·心),点击显示文章列表
+- - 替换所有'亿图腾(北京)科技有限公司'→'浠艾福主动健康研究院'
+- 
+
+
+## v1.0.473 (2026-07-22)
+
+### 新功能
+- 重写成长百科为100vh单页布局(剪影背景+五维卡片+手机标签)
+
+### 其他
+- - 新增100vh单页布局(overflow:hidden),无滚动
+- - 单人剪影+群组剪影作为装饰性页面背景(低透明度)
+- - Desktop: 5维卡片水平排列,各维度文章链接直接可见
+- - Mobile: 顶部5标签切换(身·智·富·行·心),点击显示对应文章
+- - 所有文章数据及标签切换逻辑由内联JS驱动
+- 
+
+
+## v1.0.472 (2026-07-22)
+
+### 新功能
+- 新增成长百科主页面 + Clarity 全站覆盖
+
+### 其他
+- - web/css/style.css: 新增知识页面全套响应式样式(k-hero/k-dim-section/k-article-grid)
+- - 全站添加 Microsoft Clarity 用户行为分析脚本(FAQ 3 篇 + sales.html + knowledge.html)
+- - 导航与 footer 链接统一指向 knowledge.html
+- - index.html 知识区新增" 进入成长百科 →\按钮
+- 
+
+
+## v1.0.471 (2026-07-22)
+
+### 文档
+- add implementation audit, tickler, phase1-remaining, and P0 tickets
+
+
+## v1.0.470 (2026-07-22)
+
+### Bug 修复
+- use setFontSize in renderLegacy for WeChat compat, add _destroyed lifecycle guard
+
+
 ## v1.0.469 (2026-07-22)
 
 ### Bug 修复

+ 25 - 0
cfc-web/src/api/admin.js

@@ -1595,3 +1595,28 @@ export function getFamilyEarningsRecords(familyId, type) {
     data: { familyId: familyId, type: type || '' }
   })
 }
+
+// ===== 测评待分配规划师 =====
+export function getPendingAssessmentList(params) {
+  return request({
+    url: '/api/admin/assessment/pending-list',
+    method: 'post',
+    data: params || {}
+  })
+}
+
+export function getAvailableGuides() {
+  return request({
+    url: '/api/admin/assessment/available-guides',
+    method: 'post',
+    data: {}
+  })
+}
+
+export function assignGuide(appointmentId, guideId) {
+  return request({
+    url: '/api/admin/assessment/assign-guide',
+    method: 'post',
+    data: { appointmentId, guideId }
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -186,6 +186,12 @@ const routes = [
         component: () => import('@/views/admin/Assessment.vue'),
         meta: { title: '测评管理', perm: 'assessment:dan' }
       },
+      {
+        path: 'assessment-assign',
+        name: 'AssessmentAssign',
+        component: () => import('@/views/admin/AssessmentAssign.vue'),
+        meta: { title: '待分配规划师', perm: 'assessment:dan' }
+      },
       {
         path: 'product-manage',
         name: 'ProductManage',

+ 31 - 0
cfc-web/src/styles/admin-tokens.css

@@ -84,6 +84,37 @@
   min-height: calc(100vh - 60px);
 }
 
+/* page-container:所有<el-card>包裹的列表页使用此 class */
+.page-container {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  padding: 20px;
+}
+.page-container > .el-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.page-container > .el-card .el-card__body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  padding: 20px;
+}
+.page-container > .el-card .el-card__body > .el-form:first-child {
+  flex-shrink: 0;
+}
+.page-container > .el-card .el-card__body > .el-pagination {
+  flex-shrink: 0;
+  margin-top: auto;
+  padding-top: 20px;
+  text-align: right;
+}
+
 /* ========================
    页面头部标题栏
    ======================== */

+ 7 - 6
cfc-web/src/views/Children.vue

@@ -39,8 +39,7 @@
         </el-form>
 
         <!-- 孩子列表 -->
-        <div style="overflow:auto;max-height:calc(100vh - 300px);">
-        <el-table :data="tableData" border v-loading="loading" style="width: 100%;max-height:calc(100vh - 300px);">
+        <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
         <el-table-column prop="id" label="ID" width="80"></el-table-column>
         <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
         <el-table-column prop="age" label="年龄" width="80"></el-table-column>
@@ -81,7 +80,6 @@
           </template>
         </el-table-column>
         </el-table>
-        </div>
 
         <!-- 分页 -->
       <el-pagination
@@ -151,6 +149,11 @@ import { getChildrenList, adjustChildPoints, updateChild } from '@/api/admin'
 
 export default {
   name: 'Children',
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 340
+    }
+  },
   data() {
     return {
       loading: false,
@@ -239,6 +242,7 @@ export default {
     },
     handleSizeChange(val) {
       this.pagination.size = val
+      this.pagination.current = 1
       this.loadChildren()
     },
     handleCurrentChange(val) {
@@ -306,7 +310,4 @@ export default {
 </script>
 
 <style scoped>
-.page-container {
-  padding: 20px;
-}
 </style>

+ 10 - 6
cfc-web/src/views/Families.vue

@@ -31,12 +31,12 @@
         </el-form>
 
         <!-- 家庭列表 -->
-        <div style="overflow:auto;max-height:calc(100vh - 300px);">
         <el-table
           :data="tableData"
           border
           v-loading="loading"
-          style="width: 100%;max-height:calc(100vh - 300px);"
+          style="width: 100%"
+          :max-height="tableHeight"
         >
         <el-table-column prop="id" label="ID" width="80"></el-table-column>
         <el-table-column prop="name" label="家庭名称" width="200"></el-table-column>
@@ -61,7 +61,6 @@
           </template>
         </el-table-column>
         </el-table>
-        </div>
 
         <!-- 分页 -->
       <el-pagination
@@ -110,6 +109,7 @@
           :data="membersData.parents"
           border
           style="width: 100%; margin-bottom: 20px"
+          :max-height="300"
           v-if="membersData.parents && membersData.parents.length > 0"
         >
           <el-table-column prop="id" label="ID" width="80"></el-table-column>
@@ -124,6 +124,7 @@
           :data="membersData.children"
           border
           style="width: 100%"
+          :max-height="300"
           v-if="membersData.children && membersData.children.length > 0"
         >
           <el-table-column prop="id" label="ID" width="80"></el-table-column>
@@ -150,6 +151,11 @@ import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/a
 
 export default {
   name: 'Families',
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 340
+    }
+  },
   data() {
     return {
       loading: false,
@@ -229,6 +235,7 @@ export default {
     },
     handleSizeChange(val) {
       this.pagination.size = val
+      this.pagination.current = 1
       this.loadFamilies()
     },
     handleCurrentChange(val) {
@@ -303,7 +310,4 @@ export default {
 </script>
 
 <style scoped>
-.page-container {
-  padding: 20px;
-}
 </style>

+ 12 - 9
cfc-web/src/views/GuideFamilyTask.vue

@@ -69,8 +69,7 @@
       </div>
 
       <!-- 孩子列表 -->
-      <div style="overflow:auto;max-height:calc(100vh - 300px);">
-      <el-table style="max-height:calc(100vh - 300px);" v-if="familyOverview && !searchForm.childId" :data="familyOverview.children" border>
+      <el-table :max-height="tableHeight" v-if="familyOverview && !searchForm.childId" :data="familyOverview.children" border>
         <el-table-column prop="childName" label="孩子姓名" width="120"></el-table-column>
         <el-table-column prop="totalPoints" label="总积分" width="100"></el-table-column>
         <el-table-column prop="streakDays" label="连续打卡" width="100">
@@ -88,11 +87,10 @@
             <el-button size="mini" @click="selectChild(row)">查看任务</el-button>
           </template>
         </el-table-column>
-      </el-table></div>
+      </el-table>
 
       <!-- 任务列表 -->
-      <div style="overflow:auto;max-height:calc(100vh - 300px);">
-      <el-table v-if="searchForm.childId" :data="tasks" border v-loading="taskLoading">
+      <el-table v-if="searchForm.childId" :data="tasks" border v-loading="taskLoading" :max-height="tableHeight">
         <template slot="empty">
           <div class="empty-table-state">
             <i class="el-icon-tickets" style="font-size: 48px; color: #ccc; margin-bottom: 16px;"></i>
@@ -133,10 +131,10 @@
             </el-dropdown>
           </template>
         </el-table-column>
-    </el-table></div>
+      </el-table>
 
-    <!-- 分页 -->
-    <el-pagination
+      <!-- 分页 -->
+      <el-pagination
         v-if="searchForm.childId && total > 0"
         style="margin-top: 20px; text-align: right"
         :current-page="pagination.page"
@@ -201,6 +199,11 @@ import { fetchBoundFamilies, fetchFamilyOverview, fetchChildTasks, fetchPendingR
 
 export default {
   name: 'GuideFamilyTask',
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 340
+    }
+  },
   data() {
     return {
       loading: false,
@@ -399,7 +402,7 @@ taskLoading: false,
 </script>
 
 <style scoped>
-.page-container { padding: 20px; }
+
 
 .overview-cards { margin-bottom: 20px; }
 .overview-card {

+ 9 - 5
cfc-web/src/views/GuidePackages.vue

@@ -26,8 +26,7 @@
       </el-form>
 
       <!-- 任务模板列表 -->
-      <div style="overflow:auto;max-height:calc(100vh - 300px);">
-<el-table style="max-height:calc(100vh - 300px);" :data="tableData" border v-loading="loading">
+      <el-table :max-height="tableHeight" :data="tableData" border v-loading="loading">
         <el-table-column prop="id" label="ID" width="60"></el-table-column>
         <el-table-column prop="name" label="任务模板名称" min-width="150"></el-table-column>
         <el-table-column prop="cycleDays" label="周期" width="80">
@@ -68,7 +67,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table></div>
+      </el-table>
     </el-card>
 
     <!-- 编辑弹窗 -->
@@ -123,7 +122,7 @@
       <div style="margin-bottom: 20px">
         <el-button type="primary" size="small" @click="handleAddItem">添加任务项</el-button>
       </div>
-      <el-table :data="items" border>
+      <el-table :data="items" border :max-height="300">
         <el-table-column prop="taskName" label="任务名称" min-width="120"></el-table-column>
         <el-table-column prop="points" label="积分" width="60"></el-table-column>
         <el-table-column prop="frequency" label="频率" width="80"></el-table-column>
@@ -194,6 +193,11 @@ import { getMyPackages, getGuidePackage, getGuidePackageItems, createPackage, up
 
 export default {
   name: 'GuidePackages',
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 340
+    }
+  },
   data() {
     return {
       loading: false,
@@ -420,7 +424,7 @@ export default {
 </script>
 
 <style scoped>
-.page-container { padding: 20px; }
+
 .code-text { font-family: monospace; color: #409EFF; }
 .avatar-uploader .el-upload {
   border: 1px dashed #d9d9d9;

+ 1 - 4
cfc-web/src/views/Layout.vue

@@ -295,6 +295,7 @@ export default {
             { title: '测评管理', icon: 'el-icon-edit', perm: 'assessment:dan',
               children: [
                 { path: '/assessment-admin', label: '测评管理', icon: 'el-icon-edit', perm: 'assessment:dan' },
+                { path: '/assessment-assign', label: '待分配规划师', icon: 'el-icon-user', perm: 'assessment:dan' },
               ]},
             // --- 虚拟团队 ---
             { title: '虚拟团队', icon: 'el-icon-s-custom', perm: 'system:config',
@@ -693,8 +694,6 @@ export default {
   display: flex;
   flex-direction: column;
   min-height: 0;
-  overflow-y: auto;
-  overflow-x: hidden;
 }
 
 /* ========== 全局 .admin-page 样式 ========== */
@@ -718,7 +717,6 @@ export default {
   flex-direction: column;
   min-height: 0;
   padding: 20px;
-  overflow-y: auto;
 }
 /* 当 admin-page > div > el-card(如 ArticleManage 的嵌套 div) */
 .admin-page > div:only-child {
@@ -739,6 +737,5 @@ export default {
   flex-direction: column;
   min-height: 0;
   padding: 20px;
-  overflow-y: auto;
 }
 </style>

Некоторые файлы не были показаны из-за большого количества измененных файлов