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

feat: 关系质量评估+互动记录+智慧维度临时页面

- 后端: InteractionLog/RelationshipQuestionnaire/RelationshipQuality controller/service/mapper/dto/entity
- 后端: DatabaseInitializer新增interaction_logs/relationship问卷表
- 后端: FamilyMember实体新增关系质量相关字段
- 前端: interaction-log.vue + relationship-questionnaire.vue页面
- 前端: family-members.vue关系编辑增强
- 前端: wisdom_temp/下5个临时页面
- 前端: pages.json注册新页面路由
User 4 месяцев назад
Родитель
Сommit
3f2d8c45a2
35 измененных файлов с 4819 добавлено и 8 удалено
  1. 6 3
      cfc-backend/.classpath
  2. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 83 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java
  4. 105 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java
  5. 107 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java
  6. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/AddInteractionDTO.java
  7. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/GenerateQuestionnaireDTO.java
  8. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAlertVO.java
  9. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/InteractionLogVO.java
  10. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MilestoneVO.java
  11. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/QuestionnaireSnapshotVO.java
  12. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipScoreVO.java
  13. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/SubmitQuestionnaireDTO.java
  14. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  15. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/InteractionLog.java
  16. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipQuestionnaireResponse.java
  17. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipQuestionnaireSnapshot.java
  18. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/InteractionLogMapper.java
  19. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RelationshipQuestionnaireResponseMapper.java
  20. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RelationshipQuestionnaireSnapshotMapper.java
  21. 150 0
      cfc-backend/src/main/java/com/etotem/cfc/service/InteractionLogService.java
  22. 210 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java
  23. 214 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
  24. 3 3
      cfc-backend/src/main/resources/application.yml
  25. 215 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerTest.java
  26. 12 0
      cfc-frontend/pages.json
  27. 283 0
      cfc-frontend/pages/action/interaction-log.vue
  28. 372 0
      cfc-frontend/pages/action/relationship-questionnaire.vue
  29. 73 2
      cfc-frontend/pages/profile/family-members.vue
  30. 410 0
      cfc-frontend/pages/wisdom/wisdom_temp/cognitive-report.vue
  31. 367 0
      cfc-frontend/pages/wisdom/wisdom_temp/index.vue
  32. 311 0
      cfc-frontend/pages/wisdom/wisdom_temp/member-wisdom-detail.vue
  33. 921 0
      cfc-frontend/pages/wisdom/wisdom_temp/self-quiz.vue
  34. 359 0
      cfc-frontend/pages/wisdom/wisdom_temp/training-hub.vue
  35. 241 0
      docs/relationship-management-analysis.md

+ 6 - 3
cfc-backend/.classpath

@@ -36,15 +36,18 @@
 			<attribute name="maven.pomderived" value="true"/>
 			<attribute name="maven.pomderived" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
-	<classpathentry kind="src" path="target/generated-sources/annotations">
+	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
 		<attributes>
 		<attributes>
 			<attribute name="optional" value="true"/>
 			<attribute name="optional" value="true"/>
+			<attribute name="test" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="ignore_optional_problems" value="true"/>
+			<attribute name="m2e-apt" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
+	<classpathentry kind="src" path="target/generated-sources/annotations">
 		<attributes>
 		<attributes>
 			<attribute name="optional" value="true"/>
 			<attribute name="optional" value="true"/>
-			<attribute name="test" value="true"/>
 		</attributes>
 		</attributes>
 	</classpathentry>
 	</classpathentry>
 	<classpathentry kind="output" path="target/classes"/>
 	<classpathentry kind="output" path="target/classes"/>

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

@@ -3850,6 +3850,45 @@ try {
         ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced'");
         ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced'");
 
 
         log.info("食品相关表初始化完成");
         log.info("食品相关表初始化完成");
+
+        // 迁移: family_members 表添加关系质量字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度 0-100'");
+            log.info("已添加trust_score列到family_members表");
+        } catch (Exception e) {}
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度 0-100'");
+            log.info("已添加intimacy_score列到family_members表");
+        } catch (Exception e) {}
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量 0-100'");
+            log.info("已添加communication_score列到family_members表");
+        } catch (Exception e) {}
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN last_interaction_at DATETIME DEFAULT NULL COMMENT '最近互动时间'");
+            log.info("已添加last_interaction_at列到family_members表");
+        } catch (Exception e) {}
+
+        // 迁移: 创建互动记录表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS interaction_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, family_id BIGINT NOT NULL, from_member_id BIGINT NOT NULL, to_member_id BIGINT NOT NULL, interaction_type VARCHAR(32) NOT NULL COMMENT 'dining/outgoing/call/video/chat/play/gift', description VARCHAR(256), happened_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_family (family_id), INDEX idx_member (from_member_id), INDEX idx_happened (happened_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '家庭成员互动记录'");
+            log.info("已创建interaction_logs表");
+        } catch (Exception e) {}
+
+        // 迁移: 创建问卷快照表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (id BIGINT AUTO_INCREMENT PRIMARY KEY, family_member_id BIGINT NOT NULL, respondent_id BIGINT NOT NULL, member_name VARCHAR(64), relationship_type VARCHAR(32), ai_generated_json LONGTEXT NOT NULL COMMENT 'AI生成的题目JSON', version VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_member (family_member_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '关系问卷快照'");
+            log.info("已创建relationship_questionnaire_snapshots表");
+        } catch (Exception e) {}
+
+        // 迁移: 创建问卷回答结果表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (id BIGINT AUTO_INCREMENT PRIMARY KEY, snapshot_id BIGINT NOT NULL, respondent_id BIGINT NOT NULL, answers_json LONGTEXT NOT NULL COMMENT '用户回答JSON', trust_score DECIMAL(5,2), intimacy_score DECIMAL(5,2), communication_score DECIMAL(5,2), interaction_bonus DECIMAL(5,2) DEFAULT 0, total_score DECIMAL(5,2), calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_snapshot (snapshot_id), INDEX idx_respondent (respondent_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '关系问卷回答结果'");
+            log.info("已创建relationship_questionnaire_responses表");
+        } catch (Exception e) {}
     }
     }
 
 
     private void ensureColumn(String table, String column, String definition) {
     private void ensureColumn(String table, String column, String definition) {

+ 83 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java

@@ -0,0 +1,83 @@
+package com.etotem.cfc.controller.family;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AddInteractionDTO;
+import com.etotem.cfc.dto.InteractionLogVO;
+import com.etotem.cfc.service.InteractionLogService;
+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 javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+@Tag(name = "互动记录管理", description = "家庭成员互动记录 CRUD")
+@RestController
+@RequestMapping("/api/family/interaction")
+public class InteractionLogController {
+
+    @Resource
+    private InteractionLogService interactionLogService;
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        String userIdHeader = request.getHeader("X-User-Id");
+        if (userIdHeader != null) {
+            return Long.parseLong(userIdHeader);
+        }
+        return null;
+    }
+
+    @Operation(summary = "添加互动记录")
+    @PostMapping("/add")
+    public Result<InteractionLogVO> addInteraction(HttpServletRequest request,
+                                                     @RequestBody AddInteractionDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            InteractionLogVO vo = interactionLogService.addInteraction(dto);
+            return Result.success(vo);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "查询互动记录列表")
+    @PostMapping("/list")
+    public Result<Page<InteractionLogVO>> listInteractions(HttpServletRequest request,
+                                                            @RequestBody Map<String, Object> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long familyId = body.get("familyId") != null
+                ? Long.valueOf(body.get("familyId").toString()) : null;
+        Long fromMemberId = body.get("fromMemberId") != null
+                ? Long.valueOf(body.get("fromMemberId").toString()) : null;
+        Long toMemberId = body.get("toMemberId") != null
+                ? Long.valueOf(body.get("toMemberId").toString()) : null;
+        Integer page = body.get("page") != null
+                ? Integer.valueOf(body.get("page").toString()) : 1;
+        Integer size = body.get("size") != null
+                ? Integer.valueOf(body.get("size").toString()) : 10;
+
+        Page<InteractionLogVO> result = interactionLogService.listInteractions(
+                familyId, fromMemberId, toMemberId, page, size);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "获取互动类型列表")
+    @GetMapping("/types")
+    public Result<Map<String, String>> getInteractionTypes() {
+        return Result.success(interactionLogService.getInteractionTypes());
+    }
+}

+ 105 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java

@@ -0,0 +1,105 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.HealthAlertVO;
+import com.etotem.cfc.dto.MilestoneVO;
+import com.etotem.cfc.dto.RelationshipScoreVO;
+import com.etotem.cfc.service.RelationshipQualityService;
+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 javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "关系质量管理", description = "家庭成员关系质量评分、健康预警、里程碑")
+@RestController
+@RequestMapping("/api/family/relationship")
+public class RelationshipQualityController {
+
+    @Resource
+    private RelationshipQualityService relationshipQualityService;
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        String userIdHeader = request.getHeader("X-User-Id");
+        if (userIdHeader != null) {
+            return Long.parseLong(userIdHeader);
+        }
+        return null;
+    }
+
+    @Operation(summary = "获取家庭成员关系质量评分")
+    @PostMapping("/scores")
+    public Result<List<RelationshipScoreVO>> getScores(HttpServletRequest request,
+                                                        @RequestBody Map<String, Object> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long familyId = body.get("familyId") != null
+                ? Long.valueOf(body.get("familyId").toString()) : null;
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        List<RelationshipScoreVO> scores = relationshipQualityService.getScores(familyId);
+        return Result.success(scores);
+    }
+
+    @Operation(summary = "计算并保存成员关系质量评分")
+    @PostMapping("/calculate")
+    public Result<RelationshipScoreVO> calculateScore(@RequestBody Map<String, Object> body) {
+        Long memberId = body.get("memberId") != null
+                ? Long.valueOf(body.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        try {
+            RelationshipScoreVO vo = relationshipQualityService.calculateScore(memberId);
+            return Result.success(vo);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取健康预警列表")
+    @PostMapping("/health-alerts")
+    public Result<List<HealthAlertVO>> getHealthAlerts(HttpServletRequest request,
+                                                       @RequestBody Map<String, Object> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long familyId = body.get("familyId") != null
+                ? Long.valueOf(body.get("familyId").toString()) : null;
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        List<HealthAlertVO> alerts = relationshipQualityService.getHealthAlerts(familyId);
+        return Result.success(alerts);
+    }
+
+    @Operation(summary = "获取即将到来的里程碑")
+    @PostMapping("/milestones")
+    public Result<List<MilestoneVO>> getMilestones(HttpServletRequest request,
+                                                   @RequestBody Map<String, Object> body) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long familyId = body.get("familyId") != null
+                ? Long.valueOf(body.get("familyId").toString()) : null;
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        Integer daysAhead = body.get("daysAhead") != null
+                ? Integer.valueOf(body.get("daysAhead").toString()) : 7;
+        List<MilestoneVO> milestones = relationshipQualityService.getMilestones(familyId, daysAhead);
+        return Result.success(milestones);
+    }
+}

+ 107 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java

@@ -0,0 +1,107 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GenerateQuestionnaireDTO;
+import com.etotem.cfc.dto.QuestionnaireSnapshotVO;
+import com.etotem.cfc.dto.SubmitQuestionnaireDTO;
+import com.etotem.cfc.entity.RelationshipQuestionnaireResponse;
+import com.etotem.cfc.service.RelationshipQuestionnaireService;
+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 javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "关系问卷管理", description = "AI 生成问卷、提交答案、查询历史")
+@RestController
+@RequestMapping("/api/family/questionnaire")
+public class RelationshipQuestionnaireController {
+
+    @Resource
+    private RelationshipQuestionnaireService questionnaireService;
+
+    private Long getUserId(HttpServletRequest request) {
+        Object userIdObj = request.getAttribute("userId");
+        if (userIdObj != null) {
+            return (Long) userIdObj;
+        }
+        String userIdHeader = request.getHeader("X-User-Id");
+        if (userIdHeader != null) {
+            return Long.parseLong(userIdHeader);
+        }
+        return null;
+    }
+
+    @Operation(summary = "AI 生成关系问卷")
+    @PostMapping("/generate")
+    public Result<QuestionnaireSnapshotVO> generateQuestionnaire(HttpServletRequest request,
+                                                                    @RequestBody GenerateQuestionnaireDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        dto.setRespondentId(userId);
+        try {
+            QuestionnaireSnapshotVO vo = questionnaireService.generateQuestionnaire(dto);
+            return Result.success(vo);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "提交问卷答案")
+    @PostMapping("/submit")
+    public Result<RelationshipQuestionnaireResponse> submitQuestionnaire(HttpServletRequest request,
+                                                                           @RequestBody SubmitQuestionnaireDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        dto.setRespondentId(userId);
+        try {
+            RelationshipQuestionnaireResponse resp = questionnaireService.submitQuestionnaire(dto);
+            return Result.success(resp);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取最新问卷快照")
+    @GetMapping("/snapshot/{memberId}")
+    public Result<QuestionnaireSnapshotVO> getSnapshot(HttpServletRequest request,
+                                                        @PathVariable Long memberId) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            QuestionnaireSnapshotVO vo = questionnaireService.getSnapshot(memberId);
+            return Result.success(vo);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取问卷历史记录")
+    @GetMapping("/history/{memberId}")
+    public Result<List<RelationshipQuestionnaireResponse>> getHistory(HttpServletRequest request,
+                                                                        @PathVariable Long memberId) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            List<RelationshipQuestionnaireResponse> history = questionnaireService.getHistory(memberId);
+            return Result.success(history);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

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

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class AddInteractionDTO {
+
+    private Long familyId;
+
+    private Long fromMemberId;
+
+    private Long toMemberId;
+
+    /** 互动类型: dining/outgoing/call/video/chat/play/gift */
+    private String interactionType;
+
+    private String description;
+
+    private String happenedAt;
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/GenerateQuestionnaireDTO.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class GenerateQuestionnaireDTO {
+
+    private Long memberId;
+
+    private Long respondentId;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAlertVO.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class HealthAlertVO {
+
+    private Long memberId;
+
+    private String nickname;
+
+    private String relationshipType;
+
+    /** 无互动天数 */
+    private Integer daysSinceInteraction;
+
+    /** 预警级别: warning/critical */
+    private String alertLevel;
+
+    /** 建议内容 */
+    private String suggestion;
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InteractionLogVO.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class InteractionLogVO {
+
+    private Long id;
+
+    private Long familyId;
+
+    private Long fromMemberId;
+
+    private String fromMemberName;
+
+    private Long toMemberId;
+
+    private String toMemberName;
+
+    private String interactionType;
+
+    private String interactionTypeName;
+
+    private String description;
+
+    private Date happenedAt;
+
+    private Date createdAt;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MilestoneVO.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class MilestoneVO {
+
+    private Long memberId;
+
+    private String nickname;
+
+    /** 里程碑类型: birthday/anniversary */
+    private String milestoneType;
+
+    /** 事件名称 */
+    private String eventName;
+
+    /** 发生日期 */
+    private Date eventDate;
+
+    /** 距离天数 */
+    private Integer daysAhead;
+
+    /** 提醒内容 */
+    private String reminder;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/QuestionnaireSnapshotVO.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class QuestionnaireSnapshotVO {
+
+    private Long id;
+
+    private Long familyMemberId;
+
+    private String memberName;
+
+    private String relationshipType;
+
+    /** AI生成的题目JSON */
+    private String aiGeneratedJson;
+
+    private String version;
+
+    private Date createdAt;
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipScoreVO.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class RelationshipScoreVO {
+
+    private Long memberId;
+
+    private String nickname;
+
+    private String relationshipType;
+
+    /** 信任度 0-100 */
+    private BigDecimal trustScore;
+
+    /** 亲密度 0-100 */
+    private BigDecimal intimacyScore;
+
+    /** 沟通质量 0-100 */
+    private BigDecimal communicationScore;
+
+    /** 综合得分 */
+    private BigDecimal totalScore;
+
+    /** 最近互动时间 */
+    private Date lastInteractionAt;
+
+    /** 关系健康状态: healthy/warning/critical */
+    private String healthStatus;
+}

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

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class SubmitQuestionnaireDTO {
+
+    private Long snapshotId;
+
+    private Long respondentId;
+
+    /** 用户回答JSON */
+    private String answersJson;
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.Data;
 
 
 import java.io.Serializable;
 import java.io.Serializable;
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.Date;
 
 
 @Data
 @Data
@@ -48,6 +49,18 @@ public class FamilyMember implements Serializable {
     /** 总积分 */
     /** 总积分 */
     private Integer totalPoints;
     private Integer totalPoints;
 
 
+    /** 信任度 0-100 */
+    private BigDecimal trustScore;
+
+    /** 亲密度 0-100 */
+    private BigDecimal intimacyScore;
+
+    /** 沟通质量 0-100 */
+    private BigDecimal communicationScore;
+
+    /** 最近互动时间 */
+    private Date lastInteractionAt;
+
     private Date createdAt;
     private Date createdAt;
 
 
     private Date updatedAt;
     private Date updatedAt;

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/InteractionLog.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("interaction_logs")
+public class InteractionLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属家庭ID */
+    private Long familyId;
+
+    /** 互动发起方成员ID */
+    private Long fromMemberId;
+
+    /** 互动接收方成员ID */
+    private Long toMemberId;
+
+    /** 互动类型: dining/outgoing/call/video/chat/play/gift */
+    private String interactionType;
+
+    /** 互动描述 */
+    private String description;
+
+    /** 互动发生时间 */
+    private Date happenedAt;
+
+    private Date createdAt;
+}

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipQuestionnaireResponse.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("relationship_questionnaire_responses")
+public class RelationshipQuestionnaireResponse implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 问卷快照ID */
+    private Long snapshotId;
+
+    /** 答卷人用户ID */
+    private Long respondentId;
+
+    /** 用户回答JSON */
+    private String answersJson;
+
+    /** 信任度得分 */
+    private BigDecimal trustScore;
+
+    /** 亲密度得分 */
+    private BigDecimal intimacyScore;
+
+    /** 沟通质量得分 */
+    private BigDecimal communicationScore;
+
+    /** 互动记录加分 */
+    private BigDecimal interactionBonus;
+
+    /** 综合得分 */
+    private BigDecimal totalScore;
+
+    private Date calculatedAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipQuestionnaireSnapshot.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("relationship_questionnaire_snapshots")
+public class RelationshipQuestionnaireSnapshot implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭成员ID */
+    private Long familyMemberId;
+
+    /** 答卷人用户ID */
+    private Long respondentId;
+
+    /** 成员名称 */
+    private String memberName;
+
+    /** 关系类型 */
+    private String relationshipType;
+
+    /** AI生成的题目JSON */
+    private String aiGeneratedJson;
+
+    /** 版本号 */
+    private String version;
+
+    private Date createdAt;
+}

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

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

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

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

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

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

+ 150 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/InteractionLogService.java

@@ -0,0 +1,150 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.AddInteractionDTO;
+import com.etotem.cfc.dto.InteractionLogVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.InteractionLog;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.InteractionLogMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class InteractionLogService {
+
+    private static final Logger log = LoggerFactory.getLogger(InteractionLogService.class);
+
+    @Resource
+    private InteractionLogMapper interactionLogMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    private static final Map<String, String> INTERACTION_TYPES = new HashMap<>();
+    static {
+        INTERACTION_TYPES.put("dining", "一起用餐");
+        INTERACTION_TYPES.put("outgoing", "外出活动");
+        INTERACTION_TYPES.put("call", "电话联系");
+        INTERACTION_TYPES.put("video", "视频通话");
+        INTERACTION_TYPES.put("chat", "聊天交流");
+        INTERACTION_TYPES.put("play", "一起游戏");
+        INTERACTION_TYPES.put("gift", "赠送礼物");
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public InteractionLogVO addInteraction(AddInteractionDTO dto) {
+        if (dto.getFamilyId() == null) {
+            throw new IllegalArgumentException("familyId不能为空");
+        }
+        if (dto.getFromMemberId() == null || dto.getToMemberId() == null) {
+            throw new IllegalArgumentException("fromMemberId和toMemberId不能为空");
+        }
+        if (dto.getInteractionType() == null || dto.getInteractionType().isEmpty()) {
+            throw new IllegalArgumentException("互动类型不能为空");
+        }
+
+        InteractionLog log = new InteractionLog();
+        log.setFamilyId(dto.getFamilyId());
+        log.setFromMemberId(dto.getFromMemberId());
+        log.setToMemberId(dto.getToMemberId());
+        log.setInteractionType(dto.getInteractionType());
+        log.setDescription(dto.getDescription());
+        log.setCreatedAt(new Date());
+
+        if (dto.getHappenedAt() != null && !dto.getHappenedAt().isEmpty()) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+                log.setHappenedAt(sdf.parse(dto.getHappenedAt()));
+            } catch (Exception e) {
+                log.setHappenedAt(new Date());
+            }
+        } else {
+            log.setHappenedAt(new Date());
+        }
+
+        interactionLogMapper.insert(log);
+
+        // 更新成员的 lastInteractionAt
+        updateMemberLastInteraction(dto.getFromMemberId());
+        updateMemberLastInteraction(dto.getToMemberId());
+
+        return toVO(log);
+    }
+
+    public Page<InteractionLogVO> listInteractions(Long familyId, Long fromMemberId, Long toMemberId,
+                                                    Integer page, Integer size) {
+        if (page == null || page < 1) page = 1;
+        if (size == null || size < 1) size = 10;
+
+        LambdaQueryWrapper<InteractionLog> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(InteractionLog::getFamilyId, familyId);
+        }
+        if (fromMemberId != null) {
+            wrapper.eq(InteractionLog::getFromMemberId, fromMemberId);
+        }
+        if (toMemberId != null) {
+            wrapper.eq(InteractionLog::getToMemberId, toMemberId);
+        }
+        wrapper.orderByDesc(InteractionLog::getHappenedAt);
+
+        Page<InteractionLog> pageResult = interactionLogMapper.selectPage(new Page<>(page, size), wrapper);
+        Page<InteractionLogVO> voPage = new Page<>(pageResult.getCurrent(), pageResult.getSize(), pageResult.getTotal());
+
+        List<InteractionLogVO> voList = new ArrayList<>();
+        for (InteractionLog il : pageResult.getRecords()) {
+            voList.add(toVO(il));
+        }
+        voPage.setRecords(voList);
+        return voPage;
+    }
+
+    public Map<String, String> getInteractionTypes() {
+        return INTERACTION_TYPES;
+    }
+
+    private void updateMemberLastInteraction(Long memberId) {
+        if (memberId == null) return;
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null) {
+            member.setLastInteractionAt(new Date());
+            familyMemberMapper.updateById(member);
+        }
+    }
+
+    private InteractionLogVO toVO(InteractionLog il) {
+        InteractionLogVO vo = new InteractionLogVO();
+        vo.setId(il.getId());
+        vo.setFamilyId(il.getFamilyId());
+        vo.setFromMemberId(il.getFromMemberId());
+        vo.setToMemberId(il.getToMemberId());
+        vo.setInteractionType(il.getInteractionType());
+        vo.setInteractionTypeName(INTERACTION_TYPES.getOrDefault(il.getInteractionType(), il.getInteractionType()));
+        vo.setDescription(il.getDescription());
+        vo.setHappenedAt(il.getHappenedAt());
+        vo.setCreatedAt(il.getCreatedAt());
+
+        // 填充成员名称
+        FamilyMember from = familyMemberMapper.selectById(il.getFromMemberId());
+        if (from != null) {
+            vo.setFromMemberName(from.getNickname());
+        }
+        FamilyMember to = familyMemberMapper.selectById(il.getToMemberId());
+        if (to != null) {
+            vo.setToMemberName(to.getNickname());
+        }
+        return vo;
+    }
+}

+ 210 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java

@@ -0,0 +1,210 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.HealthAlertVO;
+import com.etotem.cfc.dto.MilestoneVO;
+import com.etotem.cfc.dto.RelationshipScoreVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class RelationshipQualityService {
+
+    private static final Logger log = LoggerFactory.getLogger(RelationshipQualityService.class);
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private FamilyMemberAttributesMapper familyMemberAttributesMapper;
+
+    private static final String TYPE_BIRTHDAY = "birthday";
+
+    /**
+     * 获取家庭所有成员的关系质量评分
+     */
+    public List<RelationshipScoreVO> getScores(Long familyId) {
+        LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> members = familyMemberMapper.selectList(wrapper);
+
+        List<RelationshipScoreVO> result = new ArrayList<>();
+        for (FamilyMember member : members) {
+            RelationshipScoreVO vo = new RelationshipScoreVO();
+            vo.setMemberId(member.getId());
+            vo.setNickname(member.getNickname());
+            vo.setRelationshipType(member.getRelationshipType());
+            vo.setTrustScore(member.getTrustScore() != null ? member.getTrustScore() : BigDecimal.ZERO);
+            vo.setIntimacyScore(member.getIntimacyScore() != null ? member.getIntimacyScore() : BigDecimal.ZERO);
+            vo.setCommunicationScore(member.getCommunicationScore() != null ? member.getCommunicationScore() : BigDecimal.ZERO);
+            vo.setLastInteractionAt(member.getLastInteractionAt());
+
+            // 计算综合得分
+            BigDecimal total = vo.getTrustScore()
+                    .add(vo.getIntimacyScore())
+                    .add(vo.getCommunicationScore())
+                    .divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
+            vo.setTotalScore(total);
+
+            // 计算健康状态
+            vo.setHealthStatus(calculateHealthStatus(member));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 计算并保存单个成员的评分
+     */
+    public RelationshipScoreVO calculateScore(Long memberId) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) {
+            throw new RuntimeException("成员不存在");
+        }
+
+        // 实际计算逻辑在 T16(评分计算逻辑)中实现
+        // 这里只做基础计算
+        RelationshipScoreVO vo = new RelationshipScoreVO();
+        vo.setMemberId(member.getId());
+        vo.setNickname(member.getNickname());
+        vo.setRelationshipType(member.getRelationshipType());
+        vo.setTrustScore(member.getTrustScore() != null ? member.getTrustScore() : BigDecimal.ZERO);
+        vo.setIntimacyScore(member.getIntimacyScore() != null ? member.getIntimacyScore() : BigDecimal.ZERO);
+        vo.setCommunicationScore(member.getCommunicationScore() != null ? member.getCommunicationScore() : BigDecimal.ZERO);
+        vo.setLastInteractionAt(member.getLastInteractionAt());
+
+        BigDecimal total = vo.getTrustScore()
+                .add(vo.getIntimacyScore())
+                .add(vo.getCommunicationScore())
+                .divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
+        vo.setTotalScore(total);
+        vo.setHealthStatus(calculateHealthStatus(member));
+
+        return vo;
+    }
+
+    /**
+     * 获取健康预警列表(30天无互动触发预警)
+     */
+    public List<HealthAlertVO> getHealthAlerts(Long familyId) {
+        LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> members = familyMemberMapper.selectList(wrapper);
+
+        List<HealthAlertVO> alerts = new ArrayList<>();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -30);
+        Date threshold = cal.getTime();
+
+        for (FamilyMember member : members) {
+            Date lastInteraction = member.getLastInteractionAt();
+            if (lastInteraction == null || lastInteraction.before(threshold)) {
+                HealthAlertVO alert = new HealthAlertVO();
+                alert.setMemberId(member.getId());
+                alert.setNickname(member.getNickname());
+                alert.setRelationshipType(member.getRelationshipType());
+
+                if (lastInteraction == null) {
+                    alert.setDaysSinceInteraction(-1);
+                    alert.setAlertLevel("critical");
+                    alert.setSuggestion("从未记录互动,建议立即安排一次家庭互动");
+                } else {
+                    long days = (System.currentTimeMillis() - lastInteraction.getTime()) / (1000 * 60 * 60 * 24);
+                    alert.setDaysSinceInteraction((int) days);
+                    alert.setAlertLevel(days > 60 ? "critical" : "warning");
+                    alert.setSuggestion("已 " + days + " 天无互动,建议安排一次面对面交流或电话联系");
+                }
+                alerts.add(alert);
+            }
+        }
+        return alerts;
+    }
+
+    /**
+     * 获取即将到来的里程碑(生日等)
+     */
+    public List<MilestoneVO> getMilestones(Long familyId, Integer daysAhead) {
+        if (daysAhead == null || daysAhead <= 0) {
+            daysAhead = 7;
+        }
+
+        LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> members = familyMemberMapper.selectList(wrapper);
+
+        List<MilestoneVO> milestones = new ArrayList<>();
+        Calendar now = Calendar.getInstance();
+        Calendar future = Calendar.getInstance();
+        future.add(Calendar.DAY_OF_MONTH, daysAhead);
+
+        for (FamilyMember member : members) {
+            // 查询成员的生日信息
+            LambdaQueryWrapper<FamilyMemberAttributes> attrWrapper = new LambdaQueryWrapper<>();
+            attrWrapper.eq(FamilyMemberAttributes::getMemberId, member.getId());
+            FamilyMemberAttributes attrs = familyMemberAttributesMapper.selectOne(attrWrapper);
+
+            if (attrs != null && attrs.getBirthDatetime() != null) {
+                Date birthDate = attrs.getBirthDatetime();
+                Calendar birthCal = Calendar.getInstance();
+                birthCal.setTime(birthDate);
+                birthCal.set(Calendar.YEAR, now.get(Calendar.YEAR));
+
+                // 如果今年的生日已过,检查明年
+                if (birthCal.before(now)) {
+                    birthCal.add(Calendar.YEAR, 1);
+                }
+
+                if (!birthCal.after(future)) {
+                    MilestoneVO milestone = new MilestoneVO();
+                    milestone.setMemberId(member.getId());
+                    milestone.setNickname(member.getNickname());
+                    milestone.setMilestoneType(TYPE_BIRTHDAY);
+                    milestone.setEventName(member.getNickname() + "的生日");
+                    milestone.setEventDate(birthCal.getTime());
+                    milestone.setDaysAhead((int) ((birthCal.getTimeInMillis() - now.getTimeInMillis()) / (1000 * 60 * 60 * 24)));
+                    milestone.setReminder("还有 " + milestone.getDaysAhead() + " 天是 " + member.getNickname() + " 的生日,记得准备惊喜!");
+                    milestones.add(milestone);
+                }
+            }
+        }
+        return milestones;
+    }
+
+    private String calculateHealthStatus(FamilyMember member) {
+        if (member.getLastInteractionAt() == null) {
+            return "critical";
+        }
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -30);
+        if (member.getLastInteractionAt().before(cal.getTime())) {
+            return "warning";
+        }
+
+        BigDecimal avgScore = BigDecimal.ZERO;
+        if (member.getTrustScore() != null && member.getIntimacyScore() != null && member.getCommunicationScore() != null) {
+            avgScore = member.getTrustScore()
+                    .add(member.getIntimacyScore())
+                    .add(member.getCommunicationScore())
+                    .divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
+        }
+
+        if (avgScore.compareTo(new BigDecimal("40")) < 0) {
+            return "warning";
+        }
+        return "healthy";
+    }
+}

+ 214 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java

@@ -0,0 +1,214 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.GenerateQuestionnaireDTO;
+import com.etotem.cfc.dto.QuestionnaireSnapshotVO;
+import com.etotem.cfc.dto.SubmitQuestionnaireDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.RelationshipQuestionnaireResponse;
+import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireResponseMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireSnapshotMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class RelationshipQuestionnaireService {
+
+    private static final Logger log = LoggerFactory.getLogger(RelationshipQuestionnaireService.class);
+
+    @Resource
+    private RelationshipQuestionnaireSnapshotMapper snapshotMapper;
+
+    @Resource
+    private RelationshipQuestionnaireResponseMapper responseMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private AIService aiService;
+
+    /**
+     * AI 生成关系问卷
+     * 实际 AI 调用在 T15 实现,这里生成模拟问卷
+     */
+    public QuestionnaireSnapshotVO generateQuestionnaire(GenerateQuestionnaireDTO dto) {
+        if (dto.getMemberId() == null) {
+            throw new IllegalArgumentException("memberId不能为空");
+        }
+        if (dto.getRespondentId() == null) {
+            throw new IllegalArgumentException("respondentId不能为空");
+        }
+
+        FamilyMember member = familyMemberMapper.selectById(dto.getMemberId());
+        if (member == null) {
+            throw new RuntimeException("成员不存在");
+        }
+
+        // 生成版本号
+        String version = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+
+        // 模拟 AI 生成的问卷 JSON(实际在 T15 调用 Dify)
+        String aiJson = generateMockQuestionnaireJson(member);
+
+        // 保存快照
+        RelationshipQuestionnaireSnapshot snapshot = new RelationshipQuestionnaireSnapshot();
+        snapshot.setFamilyMemberId(dto.getMemberId());
+        snapshot.setRespondentId(dto.getRespondentId());
+        snapshot.setMemberName(member.getNickname());
+        snapshot.setRelationshipType(member.getRelationshipType());
+        snapshot.setAiGeneratedJson(aiJson);
+        snapshot.setVersion(version);
+        snapshot.setCreatedAt(new Date());
+        snapshotMapper.insert(snapshot);
+
+        QuestionnaireSnapshotVO vo = new QuestionnaireSnapshotVO();
+        vo.setId(snapshot.getId());
+        vo.setFamilyMemberId(snapshot.getFamilyMemberId());
+        vo.setMemberName(snapshot.getMemberName());
+        vo.setRelationshipType(snapshot.getRelationshipType());
+        vo.setAiGeneratedJson(snapshot.getAiGeneratedJson());
+        vo.setVersion(snapshot.getVersion());
+        vo.setCreatedAt(snapshot.getCreatedAt());
+        return vo;
+    }
+
+    /**
+     * 提交问卷答案并计算评分
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public RelationshipQuestionnaireResponse submitQuestionnaire(SubmitQuestionnaireDTO dto) {
+        if (dto.getSnapshotId() == null) {
+            throw new IllegalArgumentException("snapshotId不能为空");
+        }
+        if (dto.getAnswersJson() == null || dto.getAnswersJson().isEmpty()) {
+            throw new IllegalArgumentException("answersJson不能为空");
+        }
+
+        RelationshipQuestionnaireSnapshot snapshot = snapshotMapper.selectById(dto.getSnapshotId());
+        if (snapshot == null) {
+            throw new RuntimeException("问卷快照不存在");
+        }
+
+        // 解析答案并计算评分(简化版,实际在 T16 实现复杂计算)
+        BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson());
+
+        // 保存回答结果
+        RelationshipQuestionnaireResponse response = new RelationshipQuestionnaireResponse();
+        response.setSnapshotId(dto.getSnapshotId());
+        response.setRespondentId(dto.getRespondentId());
+        response.setAnswersJson(dto.getAnswersJson());
+        response.setTrustScore(scores[0]);
+        response.setIntimacyScore(scores[1]);
+        response.setCommunicationScore(scores[2]);
+        response.setInteractionBonus(BigDecimal.ZERO);
+        response.setTotalScore(scores[0].add(scores[1]).add(scores[2]).divide(new BigDecimal("3"), 2, java.math.RoundingMode.HALF_UP));
+        response.setCalculatedAt(new Date());
+        responseMapper.insert(response);
+
+        // 更新成员的评分
+        updateMemberScores(snapshot.getFamilyMemberId(), scores);
+
+        return response;
+    }
+
+    /**
+     * 获取最新的问卷快照
+     */
+    public QuestionnaireSnapshotVO getSnapshot(Long memberId) {
+        if (memberId == null) {
+            throw new IllegalArgumentException("memberId不能为空");
+        }
+
+        LambdaQueryWrapper<RelationshipQuestionnaireSnapshot> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(RelationshipQuestionnaireSnapshot::getFamilyMemberId, memberId)
+                .orderByDesc(RelationshipQuestionnaireSnapshot::getCreatedAt)
+                .last("LIMIT 1");
+
+        RelationshipQuestionnaireSnapshot snapshot = snapshotMapper.selectOne(wrapper);
+        if (snapshot == null) {
+            return null;
+        }
+
+        QuestionnaireSnapshotVO vo = new QuestionnaireSnapshotVO();
+        vo.setId(snapshot.getId());
+        vo.setFamilyMemberId(snapshot.getFamilyMemberId());
+        vo.setMemberName(snapshot.getMemberName());
+        vo.setRelationshipType(snapshot.getRelationshipType());
+        vo.setAiGeneratedJson(snapshot.getAiGeneratedJson());
+        vo.setVersion(snapshot.getVersion());
+        vo.setCreatedAt(snapshot.getCreatedAt());
+        return vo;
+    }
+
+    /**
+     * 获取历史问卷回答记录
+     */
+    public List<RelationshipQuestionnaireResponse> getHistory(Long memberId) {
+        if (memberId == null) {
+            throw new IllegalArgumentException("memberId不能为空");
+        }
+
+        // 先找到该成员的所有快照
+        LambdaQueryWrapper<RelationshipQuestionnaireSnapshot> snapWrapper = new LambdaQueryWrapper<>();
+        snapWrapper.eq(RelationshipQuestionnaireSnapshot::getFamilyMemberId, memberId);
+        List<RelationshipQuestionnaireSnapshot> snapshots = snapshotMapper.selectList(snapWrapper);
+
+        if (snapshots.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<Long> snapshotIds = new ArrayList<>();
+        for (RelationshipQuestionnaireSnapshot s : snapshots) {
+            snapshotIds.add(s.getId());
+        }
+
+        // 查询所有相关回答
+        LambdaQueryWrapper<RelationshipQuestionnaireResponse> respWrapper = new LambdaQueryWrapper<>();
+        respWrapper.in(RelationshipQuestionnaireResponse::getSnapshotId, snapshotIds)
+                .orderByDesc(RelationshipQuestionnaireResponse::getCalculatedAt);
+
+        return responseMapper.selectList(respWrapper);
+    }
+
+    private String generateMockQuestionnaireJson(FamilyMember member) {
+        // 简化版模拟问卷,实际在 T15 由 AI 生成
+        return "[" +
+                "{\"id\":1,\"dimension\":\"trust\",\"question\":\"您对" + member.getNickname() + "的信任程度如何?\",\"options\":[\"完全信任\",\"比较信任\",\"一般\",\"不太信任\",\"完全不信任\"]}," +
+                "{\"id\":2,\"dimension\":\"intimacy\",\"question\":\"您与" + member.getNickname() + "的亲密程度如何?\",\"options\":[\"非常亲密\",\"比较亲密\",\"一般\",\"比较疏远\",\"非常疏远\"]}," +
+                "{\"id\":3,\"dimension\":\"communication\",\"question\":\"您与" + member.getNickname() + "的沟通质量如何?\",\"options\":[\"非常顺畅\",\"比较顺畅\",\"一般\",\"不太顺畅\",\"非常困难\"]}" +
+                "]";
+    }
+
+    private BigDecimal[] calculateScoresFromAnswers(String answersJson) {
+        // 简化评分计算,实际在 T16 实现复杂逻辑
+        // 这里假设答案格式为 JSON: {"1":"A","2":"B","3":"C"} 其中 A=100,B=75,C=50,D=25,E=0
+        BigDecimal trust = new BigDecimal("75.00");
+        BigDecimal intimacy = new BigDecimal("70.00");
+        BigDecimal communication = new BigDecimal("80.00");
+        return new BigDecimal[]{trust, intimacy, communication};
+    }
+
+    private void updateMemberScores(Long memberId, BigDecimal[] scores) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null) {
+            member.setTrustScore(scores[0]);
+            member.setIntimacyScore(scores[1]);
+            member.setCommunicationScore(scores[2]);
+            member.setLastInteractionAt(new Date());
+            familyMemberMapper.updateById(member);
+        }
+    }
+}

+ 3 - 3
cfc-backend/src/main/resources/application.yml

@@ -16,11 +16,11 @@ spring:
     name: cfc-backend
     name: cfc-backend
   datasource:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     driver-class-name: com.mysql.cj.jdbc.Driver
-    url: jdbc:mysql://localhost:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    url: jdbc:mysql://192.168.16.251:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     username: zxyj
     username: zxyj
     password: zxyj@123
     password: zxyj@123
     hikari:
     hikari:
-      jdbc-url: jdbc:mysql://localhost:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+      jdbc-url: jdbc:mysql://192.168.16.251:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
       username: zxyj
       username: zxyj
       password: zxyj@123
       password: zxyj@123
       driver-class-name: com.mysql.cj.jdbc.Driver
       driver-class-name: com.mysql.cj.jdbc.Driver
@@ -28,7 +28,7 @@ spring:
 sfms:
 sfms:
   datasource:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     driver-class-name: com.mysql.cj.jdbc.Driver
-    url: jdbc:mysql://localhost:3306/sfms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    url: jdbc:mysql://192.168.16.251:3306/ax?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     username: zxyj
     username: zxyj
     password: zxyj@123
     password: zxyj@123
 
 

+ 215 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerTest.java

@@ -0,0 +1,215 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.AddFamilyMemberDTO;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.dto.SwitchMemberVO;
+import com.etotem.cfc.entity.RelationshipType;
+import com.etotem.cfc.service.FamilyMemberService;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 家庭成员管理 API 测试
+ *
+ * 核心端点: POST /api/family/member/*
+ */
+@SpringBootTest
+public class FamilyMembersControllerTest {
+
+    @Resource
+    private FamilyMembersController controller;
+
+    @MockBean
+    private FamilyMemberService familyMemberService;
+
+    // ==================== mock helpers ====================
+
+    private MockHttpServletRequest mockRequest(Long userId) {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setAttribute("userId", userId);
+        return request;
+    }
+
+    private FamilyMemberVO mockMemberVO(Long id, String nickname, String relationshipType) {
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setMemberId(id);
+        vo.setNickname(nickname);
+        vo.setRelationshipType(relationshipType);
+        return vo;
+    }
+
+    private AddFamilyMemberDTO mockAddDTO(String nickname, String relationshipTypeKey) {
+        AddFamilyMemberDTO dto = new AddFamilyMemberDTO();
+        dto.setNickname(nickname);
+        dto.setRelationshipTypeKey(relationshipTypeKey);
+        return dto;
+    }
+
+    // ==================== addMember ====================
+
+    @Test
+    public void addMember_Success() {
+        MockHttpServletRequest request = mockRequest(1L);
+        AddFamilyMemberDTO dto = mockAddDTO("小明", "son");
+
+        when(familyMemberService.addMember(eq(1L), any(AddFamilyMemberDTO.class)))
+            .thenReturn(mockMemberVO(100L, "小明", "son"));
+
+        Result<FamilyMemberVO> result = controller.addMember(request, dto);
+        assertEquals(200, result.getCode());
+        assertEquals("小明", result.getData().getNickname());
+    }
+
+    @Test
+    public void addMember_NotLoggedIn() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        AddFamilyMemberDTO dto = mockAddDTO("小明", "son");
+
+        Result<FamilyMemberVO> result = controller.addMember(request, dto);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("请先登录"));
+    }
+
+    @Test
+    public void addMember_IllegalArgument() {
+        MockHttpServletRequest request = mockRequest(1L);
+        AddFamilyMemberDTO dto = mockAddDTO("", "");
+
+        when(familyMemberService.addMember(eq(1L), any(AddFamilyMemberDTO.class)))
+            .thenThrow(new IllegalArgumentException("关系类型不能为空"));
+
+        Result<FamilyMemberVO> result = controller.addMember(request, dto);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("关系类型不能为空"));
+    }
+
+    // ==================== listMembers ====================
+
+    @Test
+    public void listMembers_Success() {
+        MockHttpServletRequest request = mockRequest(1L);
+        List<FamilyMemberVO> members = Arrays.asList(
+            mockMemberVO(1L, "爸爸", "father"),
+            mockMemberVO(2L, "妈妈", "mother")
+        );
+
+        when(familyMemberService.listMembers(1L)).thenReturn(members);
+
+        Result<List<FamilyMemberVO>> result = controller.listMembers(request);
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().size());
+    }
+
+    @Test
+    public void listMembers_NotLoggedIn() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        Result<List<FamilyMemberVO>> result = controller.listMembers(request);
+        assertEquals(500, result.getCode());
+    }
+
+    // ==================== switchToMember ====================
+
+    @Test
+    public void switchToMember_Success() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Long> body = new HashMap<>();
+        body.put("memberId", 2L);
+
+        SwitchMemberVO vo = new SwitchMemberVO();
+        vo.setMemberId(2L);
+        vo.setNickname("小明");
+        when(familyMemberService.switchToMember(1L, 2L)).thenReturn(vo);
+
+        Result<SwitchMemberVO> result = controller.switchToMember(request, body);
+        assertEquals(200, result.getCode());
+        assertEquals(2L, result.getData().getMemberId().longValue());
+    }
+
+    @Test
+    public void switchToMember_MissingMemberId() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Long> body = new HashMap<>();
+
+        Result<SwitchMemberVO> result = controller.switchToMember(request, body);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("缺少成员ID"));
+    }
+
+    // ==================== kickMember ====================
+
+    @Test
+    public void kickMember_Success() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Long> body = new HashMap<>();
+        body.put("memberId", 3L);
+
+        doNothing().when(familyMemberService).kickMember(1L, 3L);
+
+        Result<String> result = controller.kickMember(request, body);
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData().contains("踢出成功"));
+    }
+
+    @Test
+    public void kickMember_MissingMemberId() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Long> body = new HashMap<>();
+
+        Result<String> result = controller.kickMember(request, body);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("缺少成员ID"));
+    }
+
+    // ==================== updateMember ====================
+
+    @Test
+    public void updateMember_Success() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 5L);
+        body.put("nickname", "新名字");
+
+        when(familyMemberService.updateMember(eq(1L), eq(5L), any(AddFamilyMemberDTO.class)))
+            .thenReturn(mockMemberVO(5L, "新名字", "son"));
+
+        Result<FamilyMemberVO> result = controller.updateMember(request, body);
+        assertEquals(200, result.getCode());
+        assertEquals("新名字", result.getData().getNickname());
+    }
+
+    @Test
+    public void updateMember_MissingMemberId() {
+        MockHttpServletRequest request = mockRequest(1L);
+        Map<String, Object> body = new HashMap<>();
+
+        Result<FamilyMemberVO> result = controller.updateMember(request, body);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("缺少成员ID"));
+    }
+
+    // ==================== getRelationshipTypes ====================
+
+    @Test
+    public void getRelationshipTypes_Success() {
+        List<RelationshipType> types = Arrays.asList(
+            new RelationshipType() {{ setTypeKey("father"); setTypeName("爸爸"); }},
+            new RelationshipType() {{ setTypeKey("mother"); setTypeName("妈妈"); }}
+        );
+
+        when(familyMemberService.getRelationshipTypes()).thenReturn(types);
+
+        Result<List<RelationshipType>> result = controller.getRelationshipTypes();
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().size());
+    }
+}

+ 12 - 0
cfc-frontend/pages.json

@@ -200,6 +200,18 @@
         "navigationBarTitleText": "行动"
         "navigationBarTitleText": "行动"
       }
       }
     },
     },
+    {
+      "path": "pages/action/interaction-log",
+      "style": {
+        "navigationBarTitleText": "记录互动"
+      }
+    },
+    {
+      "path": "pages/action/relationship-questionnaire",
+      "style": {
+        "navigationBarTitleText": "关系问卷"
+      }
+    },
     // ===== 智慧维度(从分包提级为主包,TabBar 页面)=====
     // ===== 智慧维度(从分包提级为主包,TabBar 页面)=====
     {
     {
       "path": "pages/wisdom/index",
       "path": "pages/wisdom/index",

+ 283 - 0
cfc-frontend/pages/action/interaction-log.vue

@@ -0,0 +1,283 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">记录互动</text>
+    </view>
+
+    <!-- 选择互动对象 -->
+    <view class="form-section">
+      <text class="section-label">互动对象</text>
+      <view class="member-select">
+        <view class="member-item" v-for="m in members" :key="m.id"
+              :class="{selected: selectedMemberId === m.id}"
+              @click="selectMember(m)">
+          <text class="member-name">{{ m.nickname }}</text>
+          <text class="member-rel">{{ m.relationshipTypeName || m.relationshipType }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 选择互动类型 -->
+    <view class="form-section">
+      <text class="section-label">互动类型</text>
+      <view class="type-grid">
+        <view class="type-item" v-for="(name, key) in interactionTypes" :key="key"
+              :class="{selected: selectedType === key}"
+              @click="selectedType = key">
+          <text class="type-icon">{{ typeIcons[key] || '💬' }}</text>
+          <text class="type-name">{{ name }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 互动描述 -->
+    <view class="form-section">
+      <text class="section-label">描述(选填)</text>
+      <textarea class="desc-input" v-model="description"
+                 placeholder="简单描述一下这次互动..." maxlength="256"></textarea>
+    </view>
+
+    <!-- 互动时间 -->
+    <view class="form-section">
+      <text class="section-label">互动时间</text>
+      <view class="time-selector">
+        <view class="time-option" :class="{selected: timeType === 'now'}" @click="timeType = 'now'">现在</view>
+        <view class="time-option" :class="{selected: timeType === 'custom'}" @click="timeType = 'custom'">选择时间</view>
+      </view>
+      <view v-if="timeType === 'custom'" class="custom-time">
+        <picker mode="dateTime" :value="customTime" @change="onTimeChange">
+          <view class="time-display">{{ customTime || '选择时间' }}</view>
+        </picker>
+      </view>
+    </view>
+
+    <!-- 提交按钮 -->
+    <view class="submit-bar">
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存记录</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { listFamilyMembers, addInteraction, getInteractionTypes } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      members: [],
+      selectedMemberId: null,
+      selectedType: '',
+      description: '',
+      timeType: 'now',
+      customTime: '',
+      interactionTypes: {
+        dining: '一起用餐',
+        outgoing: '外出活动',
+        call: '电话联系',
+        video: '视频通话',
+        chat: '聊天交流',
+        play: '一起游戏',
+        gift: '赠送礼物'
+      },
+      typeIcons: {
+        dining: '🍽️',
+        outgoing: '🚗',
+        call: '📞',
+        video: '📹',
+        chat: '💬',
+        play: '🎮',
+        gift: '🎁'
+      }
+    }
+  },
+  computed: {
+    canSubmit() {
+      return this.selectedMemberId && this.selectedType
+    }
+  },
+  onLoad(options) {
+    if (options.memberId) {
+      this.selectedMemberId = Number(options.memberId)
+    }
+    this.loadMembers()
+  },
+  methods: {
+    async loadMembers() {
+      try {
+        var res = await listFamilyMembers()
+        if (res.code === 200 && res.data) {
+          this.members = res.data || []
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载成员失败', icon: 'none' })
+      }
+    },
+    selectMember(member) {
+      this.selectedMemberId = member.id
+    },
+    onTimeChange(e) {
+      this.customTime = e.detail.value
+    },
+    async onSubmit() {
+      if (!this.canSubmit) {
+        uni.showToast({ title: '请选择对象和类型', icon: 'none' })
+        return
+      }
+      try {
+        var happenedAt = ''
+        if (this.timeType === 'custom' && this.customTime) {
+          happenedAt = this.customTime
+        }
+        var res = await addInteraction({
+          familyId: this.members[0] && this.members[0].familyId,
+          fromMemberId: this.selectedMemberId,
+          toMemberId: this.selectedMemberId,
+          interactionType: this.selectedType,
+          description: this.description,
+          happenedAt: happenedAt
+        })
+        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' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.section-label {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.member-select {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.member-item {
+  padding: 16rpx 24rpx;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.member-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.member-name {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+}
+.member-rel {
+  font-size: 22rpx;
+  color: #999;
+}
+.type-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.type-item {
+  width: calc(25% - 12rpx);
+  padding: 20rpx 0;
+  text-align: center;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.type-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.type-icon {
+  font-size: 40rpx;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.type-name {
+  font-size: 22rpx;
+  color: #666;
+}
+.desc-input {
+  width: 100%;
+  height: 160rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.time-selector {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 16rpx;
+}
+.time-option {
+  padding: 16rpx 32rpx;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  color: #666;
+}
+.time-option.selected {
+  border-color: #F97316;
+  color: #F97316;
+  background: #FFF7ED;
+}
+.custom-time {
+  margin-top: 12rpx;
+}
+.time-display {
+  padding: 20rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  color: #333;
+}
+.submit-bar {
+  margin-top: 40rpx;
+}
+.btn-submit {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  font-size: 32rpx;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+</style>

+ 372 - 0
cfc-frontend/pages/action/relationship-questionnaire.vue

@@ -0,0 +1,372 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">关系问卷</text>
+      <text class="page-subtitle" v-if="memberName">为「{{ memberName }}」评估关系质量</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">AI 正在生成问卷...</text>
+    </view>
+
+    <!-- 问卷表单 -->
+    <view v-else-if="questions.length > 0" class="questionnaire-form">
+      <view class="question-item" v-for="(q, index) in questions" :key="q.id">
+        <view class="question-header">
+          <text class="question-dimension dimension-tag" :class="q.dimension">{{ dimensionLabels[q.dimension] }}</text>
+          <text class="question-text">{{ q.question }}</text>
+        </view>
+        <view class="option-list">
+          <view class="option-item" v-for="(opt, oi) in q.options" :key="oi"
+                :class="{selected: answers[q.id] === opt}"
+                @click="selectOption(q.id, opt)">
+            <text class="option-letter">{{ optionLetters[oi] }}</text>
+            <text class="option-text">{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="submit-bar">
+        <button class="btn-submit" @click="onSubmit" :disabled="!allAnswered">提交问卷</button>
+      </view>
+    </view>
+
+    <!-- 无问卷 -->
+    <view v-else class="empty-wrap">
+      <text class="empty-text">暂无可用问卷</text>
+      <button class="btn-generate" @click="generateQuestionnaire">生成新问卷</button>
+    </view>
+
+    <!-- 结果展示 -->
+    <view v-if="showResult" class="result-overlay" @click="showResult = false">
+      <view class="result-card" @click.stop>
+        <text class="result-title">关系质量评估结果</text>
+        <view class="result-scores">
+          <view class="result-score-item">
+            <text class="result-score-label">信任</text>
+            <view class="result-bar-bg">
+              <view class="result-bar-fill trust" :style="{width: (result.trustScore || 0) + '%'}"></view>
+            </view>
+            <text class="result-score-value">{{ Math.round(result.trustScore || 0) }}</text>
+          </view>
+          <view class="result-score-item">
+            <text class="result-score-label">亲密</text>
+            <view class="result-bar-bg">
+              <view class="result-bar-fill intimacy" :style="{width: (result.intimacyScore || 0) + '%'}"></view>
+            </view>
+            <text class="result-score-value">{{ Math.round(result.intimacyScore || 0) }}</text>
+          </view>
+          <view class="result-score-item">
+            <text class="result-score-label">沟通</text>
+            <view class="result-bar-bg">
+              <view class="result-bar-fill communication" :style="{width: (result.communicationScore || 0) + '%'}"></view>
+            </view>
+            <text class="result-score-value">{{ Math.round(result.communicationScore || 0) }}</text>
+          </view>
+        </view>
+        <button class="btn-close" @click="showResult = false">关闭</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { generateQuestionnaire, submitQuestionnaire, getQuestionnaireSnapshot } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      memberName: '',
+      snapshotId: null,
+      questions: [],
+      answers: {},
+      loading: false,
+      showResult: false,
+      result: {},
+      dimensionLabels: {
+        trust: '信任',
+        intimacy: '亲密',
+        communication: '沟通'
+      },
+      optionLetters: ['A', 'B', 'C', 'D', 'E']
+    }
+  },
+  computed: {
+    allAnswered() {
+      return this.questions.length > 0 && this.questions.every(function(q) {
+        return this.answers[q.id]
+      }, this)
+    }
+  },
+  onLoad(options) {
+    if (options.memberId) {
+      this.memberId = Number(options.memberId)
+    }
+    this.loadSnapshot()
+  },
+  methods: {
+    async loadSnapshot() {
+      if (!this.memberId) return
+      this.loading = true
+      try {
+        var res = await getQuestionnaireSnapshot(this.memberId)
+        if (res.code === 200 && res.data) {
+          this.snapshotId = res.data.id
+          this.memberName = res.data.memberName
+          try {
+            this.questions = JSON.parse(res.data.aiGeneratedJson || '[]')
+          } catch (e) {
+            this.questions = []
+          }
+        } else {
+          // 没有问卷,生成新的
+          await this.generateQuestionnaire()
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    async generateQuestionnaire() {
+      if (!this.memberId) return
+      this.loading = true
+      try {
+        var res = await generateQuestionnaire(this.memberId)
+        if (res.code === 200 && res.data) {
+          this.snapshotId = res.data.id
+          this.memberName = res.data.memberName
+          try {
+            this.questions = JSON.parse(res.data.aiGeneratedJson || '[]')
+          } catch (e) {
+            this.questions = []
+          }
+        } else {
+          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    selectOption(questionId, option) {
+      this.answers[questionId] = option
+    },
+    async onSubmit() {
+      if (!this.allAnswered || !this.snapshotId) {
+        uni.showToast({ title: '请回答所有问题', icon: 'none' })
+        return
+      }
+      try {
+        var answersJson = JSON.stringify(this.answers)
+        var res = await submitQuestionnaire({
+          snapshotId: this.snapshotId,
+          answersJson: answersJson
+        })
+        if (res.code === 200 && res.data) {
+          this.result = res.data
+          this.showResult = true
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.page-subtitle {
+  font-size: 26rpx;
+  color: #999;
+  margin-top: 8rpx;
+  display: block;
+}
+.loading-wrap, .empty-wrap {
+  text-align: center;
+  padding: 100rpx 0;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 30rpx;
+}
+.btn-generate, .btn-submit {
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 60rpx;
+  font-size: 30rpx;
+}
+.questionnaire-form {
+  display: flex;
+  flex-direction: column;
+  gap: 24rpx;
+}
+.question-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 28rpx;
+}
+.question-header {
+  margin-bottom: 20rpx;
+}
+.dimension-tag {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 6rpx;
+  margin-right: 12rpx;
+}
+.dimension-tag.trust { background: #D1FAE5; color: #059669; }
+.dimension-tag.intimacy { background: #FCE7F3; color: #DB2777; }
+.dimension-tag.communication { background: #E0E7FF; color: #4338CA; }
+.question-text {
+  font-size: 30rpx;
+  color: #333;
+  line-height: 1.6;
+  display: block;
+  margin-top: 12rpx;
+}
+.option-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+.option-item {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 20rpx;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.option-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.option-letter {
+  width: 44rpx;
+  height: 44rpx;
+  line-height: 44rpx;
+  text-align: center;
+  background: #f0f0f0;
+  border-radius: 50%;
+  font-size: 24rpx;
+  color: #666;
+}
+.option-item.selected .option-letter {
+  background: #F97316;
+  color: #fff;
+}
+.option-text {
+  flex: 1;
+  font-size: 28rpx;
+  color: #333;
+}
+.submit-bar {
+  margin-top: 20rpx;
+}
+.btn-submit {
+  width: 100%;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+.result-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 100;
+}
+.result-card {
+  width: 600rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 40rpx;
+}
+.result-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  text-align: center;
+  margin-bottom: 30rpx;
+}
+.result-scores {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+  margin-bottom: 30rpx;
+}
+.result-score-item {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+.result-score-label {
+  width: 60rpx;
+  font-size: 26rpx;
+  color: #666;
+}
+.result-bar-bg {
+  flex: 1;
+  height: 16rpx;
+  background: #f0f0f0;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+.result-bar-fill {
+  height: 100%;
+  border-radius: 8rpx;
+}
+.result-bar-fill.trust { background: #10B981; }
+.result-bar-fill.intimacy { background: #FF6B9D; }
+.result-bar-fill.communication { background: #6366F1; }
+.result-score-value {
+  width: 50rpx;
+  font-size: 26rpx;
+  color: #333;
+  text-align: right;
+}
+.btn-close {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 20rpx 0;
+  font-size: 30rpx;
+}
+</style>

+ 73 - 2
cfc-frontend/pages/profile/family-members.vue

@@ -30,6 +30,30 @@
             <text class="member-role-tag">{{ m.roleLabel || '家长' }}</text>
             <text class="member-role-tag">{{ m.roleLabel || '家长' }}</text>
             <text class="member-source-tag" v-if="m.source === 'family_member'">手动添加</text>
             <text class="member-source-tag" v-if="m.source === 'family_member'">手动添加</text>
           </view>
           </view>
+          <!-- 三维评分进度条 -->
+          <view class="member-quality-scores" v-if="qualityScores[m.id]">
+            <view class="score-item">
+              <text class="score-label">信任</text>
+              <view class="score-bar-bg">
+                <view class="score-bar-fill trust" :style="{width: (qualityScores[m.id].trustScore || 0) + '%'}"></view>
+              </view>
+              <text class="score-value">{{ Math.round(qualityScores[m.id].trustScore || 0) }}</text>
+            </view>
+            <view class="score-item">
+              <text class="score-label">亲密</text>
+              <view class="score-bar-bg">
+                <view class="score-bar-fill intimacy" :style="{width: (qualityScores[m.id].intimacyScore || 0) + '%'}"></view>
+              </view>
+              <text class="score-value">{{ Math.round(qualityScores[m.id].intimacyScore || 0) }}</text>
+            </view>
+            <view class="score-item">
+              <text class="score-label">沟通</text>
+              <view class="score-bar-bg">
+                <view class="score-bar-fill communication" :style="{width: (qualityScores[m.id].communicationScore || 0) + '%'}"></view>
+              </view>
+              <text class="score-value">{{ Math.round(qualityScores[m.id].communicationScore || 0) }}</text>
+            </view>
+          </view>
         </view>
         </view>
         <button class="btn-switch" v-if="m.source !== 'parent' || m.id !== userId" @click="onSwitchMember(m)">切换</button>
         <button class="btn-switch" v-if="m.source !== 'parent' || m.id !== userId" @click="onSwitchMember(m)">切换</button>
         <button class="btn-edit" v-if="m.source === 'family_member'" @click="onEditRelation(m)">编辑</button>
         <button class="btn-edit" v-if="m.source === 'family_member'" @click="onEditRelation(m)">编辑</button>
@@ -122,7 +146,10 @@ import {
   kickFamilyMemberById,
   kickFamilyMemberById,
   generateFamilyInviteQrCode,
   generateFamilyInviteQrCode,
   getFamilyMembers,
   getFamilyMembers,
-  updateFamilyMember
+  updateFamilyMember,
+  getRelationshipScores,
+  generateQuestionnaire,
+  addInteraction
 } from '../../utils/api.js'
 } from '../../utils/api.js'
 import store from '../../store/index.js'
 import store from '../../store/index.js'
 import RelationshipPicker from '../../components/RelationshipPicker.vue'
 import RelationshipPicker from '../../components/RelationshipPicker.vue'
@@ -143,7 +170,8 @@ export default {
       showInviteCard: false,
       showInviteCard: false,
       showEditRelation: false,
       showEditRelation: false,
       editTargetMember: null,
       editTargetMember: null,
-      editRelationType: ''
+      editRelationType: '',
+      qualityScores: {}
     }
     }
   },
   },
   computed: {
   computed: {
@@ -270,6 +298,12 @@ export default {
       this.editRelationType = member.relationshipType || ''
       this.editRelationType = member.relationshipType || ''
       this.showEditRelation = true
       this.showEditRelation = true
     },
     },
+    doQuestionnaire(member) {
+      uni.navigateTo({ url: '/pages/action/relationship-questionnaire?memberId=' + member.id })
+    },
+    recordInteraction(member) {
+      uni.navigateTo({ url: '/pages/action/interaction-log?memberId=' + member.id })
+    },
     async onSaveRelation() {
     async onSaveRelation() {
       if (!this.editTargetMember || !this.editRelationType) {
       if (!this.editTargetMember || !this.editRelationType) {
         uni.showToast({ title: '请选择关系类型', icon: 'none' })
         uni.showToast({ title: '请选择关系类型', icon: 'none' })
@@ -623,4 +657,41 @@ export default {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #fff;
   color: #fff;
 }
 }
+/* 三维评分进度条 */
+.member-quality-scores {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+  padding: 8rpx 0;
+}
+.score-item {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.score-label {
+  width: 56rpx;
+  font-size: 20rpx;
+  color: #999;
+}
+.score-bar-bg {
+  flex: 1;
+  height: 10rpx;
+  background: #f0f0f0;
+  border-radius: 5rpx;
+  overflow: hidden;
+}
+.score-bar-fill {
+  height: 100%;
+  border-radius: 5rpx;
+}
+.score-bar-fill.trust { background: #10B981; }
+.score-bar-fill.intimacy { background: #FF6B9D; }
+.score-bar-fill.communication { background: #6366F1; }
+.score-value {
+  width: 44rpx;
+  font-size: 18rpx;
+  color: #999;
+  text-align: right;
+}
 </style>
 </style>

+ 410 - 0
cfc-frontend/pages/wisdom/wisdom_temp/cognitive-report.vue

@@ -0,0 +1,410 @@
+<template>
+  <view class="container">
+    <PageBanner theme="wisdom"
+      tagline="认知能力 · 全面评估"
+      quote="知人者智,自知者明" />
+
+    <view class="report-card" v-if="report">
+      <view class="report-header">
+        <text class="report-title">认知测评报告</text>
+        <text class="report-date">测评日期: {{ report.assessmentDate || '-' }}</text>
+      </view>
+
+      <!-- 综合认知评分 -->
+      <view class="overall-score-wrap">
+        <text class="overall-label">综合认知评分</text>
+        <view class="overall-row">
+          <text class="overall-value">{{ report.overallScore || 0 }}</text>
+          <view class="dan-badge">
+            <text class="dan-level">{{ report.danLevel || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 七维条形图 -->
+      <view class="dimension-list">
+        <view class="dimension-item" v-for="dim in dimensions" :key="dim.key">
+          <view class="dimension-header">
+            <view class="dimension-label-row">
+              <text class="dimension-icon">{{ dim.icon }}</text>
+              <text class="dimension-name">{{ dim.label }}</text>
+            </view>
+            <text class="dimension-score">{{ report[dim.key] || 0 }}</text>
+          </view>
+          <view class="dimension-bar">
+            <view class="dimension-bar-fill" :style="{ width: (report[dim.key] || 0) + '%' }"></view>
+          </view>
+          <text class="dimension-desc">{{ dim.desc }}</text>
+        </view>
+      </view>
+
+      <!-- 分析报告 -->
+      <view class="report-analysis" v-if="report.analysisReport">
+        <view class="analysis-header-row">
+          <text class="analysis-icon">&#x1F4DD;</text>
+          <text class="analysis-title">分析报告</text>
+        </view>
+        <text class="analysis-content">{{ report.analysisReport }}</text>
+      </view>
+
+      <!-- 成长建议 -->
+      <view class="report-suggestions" v-if="report.growthSuggestions">
+        <view class="analysis-header-row">
+          <text class="analysis-icon">&#x1F331;</text>
+          <text class="analysis-title">成长建议</text>
+        </view>
+        <text class="suggestion-content">{{ report.growthSuggestions }}</text>
+      </view>
+
+      <!-- 底部操作 -->
+      <view class="action-row">
+        <view class="action-btn secondary" @click="goAssessment">
+          <text class="action-btn-text">预约测评</text>
+        </view>
+        <view class="action-btn primary" @click="goTraining">
+          <text class="action-btn-text">认知训练</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view class="loading-state" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 无数据 -->
+    <view class="empty-state" v-if="!report && !loading">
+      <text class="empty-icon">&#x1F9E0;</text>
+      <text class="empty-title">暂无认知测评数据</text>
+      <text class="empty-desc">完成 DAN 认知评估后,可在此查看详细的六维认知分析报告</text>
+      <view class="empty-btn" @click="goAssessment">预约测评</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import PageBanner from '../../components/PageBanner.vue'
+import { getAssessmentLatestResult } from '../../utils/api.js'
+
+export default {
+  components: { PageBanner },
+  data: function() {
+    return {
+      childId: null,
+      report: null,
+      loading: true,
+      dimensions: [
+        { key: 'perceptionScore', label: '感知能力', icon: '\u{1F441}', desc: '感官信息处理能力' },
+        { key: 'focusScore', label: '专注力', icon: '\u{1F3AF}', desc: '持续集中和抗干扰能力' },
+        { key: 'memoryScore', label: '记忆力', icon: '\u{1F9E0}', desc: '信息编码和回忆能力' },
+        { key: 'logicScore', label: '逻辑思维', icon: '\u{1F9E9}', desc: '推理和问题解决能力' },
+        { key: 'spatialScore', label: '空间思维', icon: '\u{1F9CA}', desc: '空间想象和方位判断' },
+        { key: 'attentionScore', label: '注意力', icon: '\u{1F50D}', desc: '信息筛选和专注能力' },
+        { key: 'processingSpeedScore', label: '加工速度', icon: '\u26A1', desc: '信息处理和反应速度' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.childId) {
+      this.childId = options.childId
+    } else {
+      this.childId = uni.getStorageSync('currentChildId') || null
+    }
+    this.loadReport()
+  },
+  methods: {
+    loadReport: function() {
+      var self = this
+      var childId = self.childId
+      if (!childId) {
+        self.loading = false
+        return
+      }
+      self.loading = true
+      getAssessmentLatestResult(childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.report = res.data
+        }
+        self.loading = false
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    goAssessment: function() {
+      uni.navigateTo({ url: '/pages/assessment/apply' })
+    },
+    goTraining: function() {
+      uni.navigateTo({ url: '/pages/mind/training?childId=' + (this.childId || '') })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 120rpx;
+}
+
+/* ===== 报告卡 ===== */
+.report-card {
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 40rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+}
+
+.report-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 30rpx;
+}
+
+.report-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.report-date {
+  font-size: 24rpx;
+  color: #999;
+}
+
+/* ===== 综合评分 ===== */
+.overall-score-wrap {
+  text-align: center;
+  padding: 40rpx 0;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 20rpx;
+  margin-bottom: 40rpx;
+  color: #fff;
+}
+
+.overall-label {
+  font-size: 28rpx;
+  opacity: 0.9;
+  display: block;
+  margin-bottom: 16rpx;
+}
+
+.overall-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+}
+
+.overall-value {
+  font-size: 80rpx;
+  font-weight: bold;
+  margin-right: 20rpx;
+}
+
+.dan-badge {
+  background: rgba(255,255,255,0.25);
+  border-radius: 12rpx;
+  padding: 6rpx 20rpx;
+}
+
+.dan-level {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #FFD700;
+}
+
+/* ===== 七维条形图 ===== */
+.dimension-list {
+  margin-bottom: 30rpx;
+}
+
+.dimension-item {
+  margin-bottom: 28rpx;
+}
+
+.dimension-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.dimension-label-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.dimension-icon {
+  font-size: 28rpx;
+  margin-right: 10rpx;
+}
+
+.dimension-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.dimension-score {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #667eea;
+}
+
+.dimension-bar {
+  height: 16rpx;
+  background: #f0f0f0;
+  border-radius: 8rpx;
+  overflow: hidden;
+  margin-bottom: 8rpx;
+}
+
+.dimension-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667eea, #764ba2);
+  border-radius: 8rpx;
+  transition: width 0.5s;
+}
+
+.dimension-desc {
+  font-size: 22rpx;
+  color: #999;
+  line-height: 1.4;
+}
+
+/* ===== 分析/建议 ===== */
+.report-analysis, .report-suggestions {
+  margin-top: 30rpx;
+  padding: 28rpx;
+  border-radius: 16rpx;
+}
+
+.report-analysis {
+  background: #f9f9ff;
+}
+
+.report-suggestions {
+  background: #f0fdf4;
+}
+
+.analysis-header-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.analysis-icon {
+  font-size: 30rpx;
+  margin-right: 10rpx;
+}
+
+.analysis-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.analysis-content, .suggestion-content {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.7;
+  display: block;
+}
+
+/* ===== 底部操作 ===== */
+.action-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  margin-top: 40rpx;
+}
+
+.action-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+  border-radius: 40rpx;
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  margin-left: 16rpx;
+  box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.3);
+}
+
+.action-btn.secondary {
+  background: #fff;
+  border: 2rpx solid #ddd;
+  margin-right: 16rpx;
+}
+
+.action-btn-text {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #fff;
+}
+
+.action-btn.secondary .action-btn-text {
+  color: #666;
+}
+
+/* ===== 加载中 ===== */
+.loading-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 0;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+
+/* ===== 空状态 ===== */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 40rpx 80rpx;
+}
+
+.empty-icon {
+  font-size: 100rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 16rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  line-height: 1.6;
+  margin-bottom: 40rpx;
+}
+
+.empty-btn {
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+  padding: 16rpx 64rpx;
+  border-radius: 40rpx;
+  box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.3);
+}
+</style>

+ 367 - 0
cfc-frontend/pages/wisdom/wisdom_temp/index.vue

@@ -0,0 +1,367 @@
+<template>
+  <view class="container">
+    <PageBanner theme="wisdom"
+      tagline="学以启智 · 慧及未来"
+      quote="知者不惑,仁者不忧" />
+
+    <view class="section" v-if="sectionVisible('today_tasks') && isLoggedIn">
+      <view class="section-header">
+        <text class="section-title">📋 我的任务</text>
+        <text class="section-more" @click="goTasks">全部 ›</text>
+      </view>
+      <view class="task-wrap" v-if="todayTasks.length > 0">
+        <view class="task-item" v-for="task in todayTasks" :key="task.id">
+          <text class="task-name">{{ task.title }}</text>
+          <text class="task-points">+{{ task.points || 0 }}分</text>
+        </view>
+      </view>
+      <view class="task-empty" v-else>
+        <text class="task-empty-text">今日暂无任务</text>
+      </view>
+    </view>
+
+    <view class="func-section" v-if="sectionVisible('func_entries')">
+      <view class="func-grid">
+        <view class="func-item" v-for="item in funcList" :key="item.label" @click="onFuncClick(item)">
+          <view class="func-icon-wrap">
+            <text class="func-icon">{{ item.icon }}</text>
+          </view>
+          <text class="func-label">{{ item.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section" v-if="sectionVisible('hot_activities')">
+      <view class="section-header">
+        <text class="section-title">🔥 热门活动</text>
+        <text class="section-more" @click="goMoreActivities">更多 ›</text>
+      </view>
+      <scroll-view scroll-x class="activity-scroll" show-scrollbar="false">
+        <view class="activity-card" v-for="act in hotActivities" :key="act.id" @click="goActivityDetail(act.id)">
+          <image class="activity-img" :src="act.image || '/static/default-activity.png'" mode="aspectFill" />
+          <view class="activity-info">
+            <text class="activity-name">{{ act.name }}</text>
+            <text class="activity-meta">{{ act.date }} · {{ act.location }}</text>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <view class="section" v-if="sectionVisible('recommended_products')">
+      <view class="section-header">
+        <text class="section-title">🛍️ 推荐商品</text>
+        <text class="section-more" @click="goMoreProducts">更多 ›</text>
+      </view>
+      <view class="product-list">
+        <view class="product-card" v-for="prod in recommendedProducts" :key="prod.id" @click="goProductDetail(prod.id)">
+          <image class="product-img" :src="prod.image || '/static/default-product.png'" mode="aspectFill" />
+          <text class="product-name line-clamp-2">{{ prod.name }}</text>
+          <text class="product-price">{{ formatPriceWithSymbol(prod.price) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import PageBanner from '../../components/PageBanner.vue'
+import { getVisibleSections, getEnergyOverview, getTodayTasks } from '../../utils/api.js'
+
+export default {
+  components: { PageBanner },
+  data() {
+    return {
+      isLoggedIn: false,
+      role: null,
+      currentChildId: null,
+      totalEnergy: null,
+      visibleSections: [],
+      funcList: [
+        { icon: '\u{1F4DA}', label: '学习任务', needLogin: true, page: '/pages/tasks/tasks' },
+        { icon: '\u{1F3AE}', label: '小游戏', needLogin: false, page: '/pages/games/list' },
+        { icon: '\u{1F9E9}', label: '测评中心', needLogin: false, page: '/pages/assessment/apply' },
+        { icon: '\u{1F4D6}', label: '阅读天地', needLogin: false, page: '/pages/mind/articles' },
+        { icon: '\u{1F4A1}', label: '知识挑战', needLogin: false, page: '' }
+      ],
+      hotActivities: [
+        { id: 1, name: '少儿编程启蒙营', date: '周六', location: '线上', image: '' },
+        { id: 2, name: '经典阅读分享会', date: '周日', location: '浠艾福中心', image: '' },
+        { id: 3, name: '科学实验探索课', date: '下周六', location: '浠艾福中心', image: '' }
+      ],
+      recommendedProducts: [
+        { id: 1, name: '儿童百科全书套装', price: 199, image: '' },
+        { id: 2, name: '益智棋类礼盒', price: 159, image: '' },
+        { id: 3, name: '科学实验套装', price: 299, image: '' },
+        { id: 4, name: '中外名著精选', price: 129, image: '' }
+      ],
+      todayTasks: []
+    }
+  },
+  onShow() {
+    var token = uni.getStorageSync('token')
+    this.isLoggedIn = !!token
+    if (this.isLoggedIn) {
+      this.role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || null
+      this.currentChildId = uni.getStorageSync('currentChildId') || null
+    }
+    this.loadSectionConfig()
+    if (this.isLoggedIn) {
+      this.loadEnergyData()
+      this.loadTodayTasks()
+    }
+  },
+  methods: {
+    async loadSectionConfig() {
+      var baseSections = ['func_entries', 'hot_activities', 'recommended_products']
+      if (!uni.getStorageSync('token')) {
+        this.visibleSections = baseSections
+        return
+      }
+      var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || null
+      try {
+        var res = await getVisibleSections({ pageKey: 'wisdom', role: role })
+        if (res.code === 200 && res.data) {
+          var keys = res.data.map(function(s) { return s.sectionKey })
+          var merged = baseSections.slice()
+          keys.forEach(function(k) { if (merged.indexOf(k) === -1) merged.push(k) })
+          this.visibleSections = merged
+        }
+      } catch (e) {
+        this.visibleSections = ['func_entries', 'hot_activities', 'recommended_products']
+      }
+    },
+    async loadEnergyData() {
+      var childId = this.currentChildId
+      if (!childId) return
+      try {
+        var res = await getEnergyOverview(childId)
+        if (res && res.data) {
+          this.totalEnergy = res.data.totalEnergy || 0
+        }
+      } catch (e) { console.log('获取能量概览失败', e) }
+    },
+    async loadTodayTasks() {
+      var childId = this.currentChildId
+      if (!childId) return
+      try {
+        var res = await getTodayTasks(childId)
+        if (res && res.data) {
+          this.todayTasks = res.data.slice(0, 5)
+        }
+      } catch (e) { console.log('获取今日任务失败', e) }
+    },
+    sectionVisible(key) {
+      return this.visibleSections.length === 0 || this.visibleSections.indexOf(key) !== -1
+    },
+    onFuncClick(item) {
+      if (item.needLogin && !this.isLoggedIn) {
+        uni.showModal({
+          title: '提示',
+          content: '请先登录后使用此功能',
+          success: function(res) { if (res.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
+        })
+        return
+      }
+      if (item.page) {
+        var nativeTabs = ['/pages/tasks/tasks', '/pages/index/index', '/pages/body/index', '/pages/mind/index', '/pages/action/index', '/pages/wisdom/index', '/pages/profile/profile']
+        if (nativeTabs.indexOf(item.page) !== -1) {
+          uni.switchTab({ url: item.page })
+        } else {
+          uni.navigateTo({ url: item.page })
+        }
+      }
+    },
+    goMoreActivities() {},
+    goActivityDetail(id) {},
+    goMoreProducts() {},
+    goProductDetail(id) {
+      var token = uni.getStorageSync('token')
+      if (token) {
+        uni.navigateTo({ url: '/pages/discover/product-detail/product-detail?id=' + id })
+      } else {
+        uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/wisdom/index') })
+      }
+    },
+    goTasks() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
+    goLogin() { uni.navigateTo({ url: '/pages/login/login' }) }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 120rpx;
+}
+
+.func-section {
+  margin: -40rpx 20rpx 0;
+  position: relative;
+  z-index: 2;
+}
+.func-grid {
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx 16rpx;
+  display: flex;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+}
+.func-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.func-item:active {
+  opacity: 0.7;
+}
+.func-icon-wrap {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  background: #FFF8E1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 10rpx;
+}
+.func-icon {
+  font-size: 40rpx;
+}
+.func-label {
+  font-size: 24rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.section {
+  margin: 20rpx 20rpx;
+}
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.section-more {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.activity-scroll {
+  white-space: nowrap;
+}
+.activity-card {
+  display: inline-block;
+  width: 280rpx;
+  margin-right: 20rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.activity-img {
+  width: 280rpx;
+  height: 180rpx;
+  background: #f0f0f0;
+}
+.activity-info {
+  padding: 16rpx;
+}
+.activity-name {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.activity-meta {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+  display: block;
+}
+
+.product-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+.product-card {
+  width: calc(50% - 10rpx);
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.product-img {
+  width: 100%;
+  height: 240rpx;
+  background: #f0f0f0;
+}
+.product-name {
+  font-size: 26rpx;
+  color: #333;
+  padding: 12rpx 16rpx 0;
+}
+.product-price {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+  padding: 8rpx 16rpx 16rpx;
+  display: block;
+}
+.line-clamp-2 {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.task-wrap {
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.task-item {
+  display: flex;
+  justify-content: space-between;
+  padding: 28rpx 24rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.task-item:last-child {
+  border-bottom: none;
+}
+.task-name {
+  font-size: 26rpx;
+  color: #333;
+}
+.task-points {
+  font-size: 24rpx;
+  color: #F97316;
+}
+.task-empty {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx;
+  text-align: center;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.task-empty-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.bottom-spacer {
+  height: 20rpx;
+}
+</style>

+ 311 - 0
cfc-frontend/pages/wisdom/wisdom_temp/member-wisdom-detail.vue

@@ -0,0 +1,311 @@
+<template>
+  <view class="detail-container">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">
+        <text class="nav-back-icon">&#x2190;</text>
+      </view>
+      <text class="nav-title">智 · 成员详情</text>
+    </view>
+
+    <!-- 五维能量条 -->
+    <FamilyEnergyBar
+      dimensionCode="wisdom"
+      :sandboxData="sandboxData"
+      :dualDimension="dualDimension" />
+
+    <!-- 成员信息卡 -->
+    <view class="member-card" v-if="memberInfo">
+      <view class="member-avatar wisdom-avatar">
+        <text class="avatar-text">{{ memberInfo.name && memberInfo.name.charAt(0) || '孩' }}</text>
+      </view>
+      <view class="member-info">
+        <text class="member-name">{{ memberInfo.name || '孩子' }}</text>
+        <text class="member-role">智慧维度</text>
+      </view>
+      <view class="member-score" v-if="dualDimension">
+        <text class="score-value wisdom-score">{{ dualDimension.energy || 0 }}</text>
+        <text class="score-label">能量值</text>
+      </view>
+    </view>
+
+    <!-- 家庭成员关系图谱(只读) -->
+    <FamilyRelationGraph
+      dimensionCode="wisdom"
+      :selfId="selfId"
+      :members="graphMembers"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      :interactive="false" />
+
+    <!-- 今日任务 -->
+    <DimensionTasks
+      dimensionCode="wisdom"
+      :tasks="dimensionTasks"
+      @taskClick="onTaskClick"
+      @moreTasks="goTasks" />
+
+    <!-- 活动 -->
+    <DimensionActivities
+      dimensionCode="wisdom"
+      :activities="dimensionActivities"
+      @activityClick="goActivityDetail"
+      @moreActivities="goMoreActivities" />
+
+    <!-- 商品 -->
+    <DimensionProducts
+      dimensionCode="wisdom"
+      :products="dimensionProducts"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
+
+    <!-- 底部占位 -->
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+import DimensionTasks from '../../components/DimensionTasks.vue'
+import DimensionActivities from '../../components/DimensionActivities.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
+import { getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren } from '../../utils/api.js'
+
+export default {
+  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts },
+  data() {
+    return {
+      childId: null,
+      selfId: null,
+      memberInfo: null,
+      sandboxData: null,
+      dualDimension: null,
+      dimensionTasks: [],
+      dimensionActivities: [],
+      dimensionProducts: [],
+      loading: true
+    }
+  },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId || uni.getStorageSync('currentChildId') || null
+  },
+  onShow: function() {
+    var self = this
+    self.selfId = uni.getStorageSync('userId') || null
+    self.loadChildren()
+    self.loadSandboxData()
+    if (self.childId) {
+      self.loadEnergyData()
+      self.loadDimensionTasks()
+      self.loadDimensionActivities()
+      self.loadDimensionProducts()
+    }
+    self.loading = false
+  },
+  methods: {
+    loadChildren: function() {
+      var self = this
+      getChildren().then(function(res) {
+        if (res.code === 200 && res.data) {
+          for (var i = 0; i < res.data.length; i++) {
+            if (res.data[i].childId === self.childId) {
+              self.memberInfo = res.data[i]
+              break
+            }
+          }
+          if (!self.memberInfo && res.data.length > 0) {
+            self.memberInfo = res.data[0]
+            self.childId = res.data[0].childId
+          }
+        }
+      }).catch(function() {})
+    },
+    loadSandboxData: function() {
+      var self = this
+      getFamilyEnergySandbox().then(function(res) {
+        if (res && res.data) self.sandboxData = res.data
+      }).catch(function() {})
+    },
+    loadEnergyData: function() {
+      var self = this
+      getEnergyOverview(this.childId).then(function(res) {
+        if (res && res.data) {
+          var dims = res.data.dimensions
+          if (dims && dims.length > 0) {
+            for (var i = 0; i < dims.length; i++) {
+              if (dims[i].code === 'wisdom') {
+                self.dualDimension = dims[i]
+                break
+              }
+            }
+          }
+        }
+      }).catch(function() {})
+    },
+    loadDimensionTasks: function() {
+      var self = this
+      getTodayTasksByCategory(this.childId, 'wisdom').then(function(res) {
+        if (res && res.data) {
+          self.dimensionTasks = Array.isArray(res.data) ? res.data.slice(0, 5) : []
+        }
+      }).catch(function() { self.dimensionTasks = [] })
+    },
+    loadDimensionActivities: function() {
+      var self = this
+      getActivityList({ dimensionCode: 'wisdom', page: 1, size: 3 }).then(function(res) {
+        if (res && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.dimensionActivities = list.slice(0, 3)
+        }
+      }).catch(function() { self.dimensionActivities = [] })
+    },
+    loadDimensionProducts: function() {
+      var self = this
+      getProductsByDomain('wisdom', 1, 4).then(function(res) {
+        if (res && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.dimensionProducts = list.slice(0, 4)
+        }
+      }).catch(function() { self.dimensionProducts = [] })
+    },
+    onTaskClick: function(task) {
+      uni.navigateTo({ url: '/pages/tasks/tasks' })
+    },
+    goActivityDetail: function(act) {
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
+    },
+    goMoreActivities: function() {
+      uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=wisdom' })
+    },
+    goProductDetail: function(prod) {
+      if (prod && prod.id) uni.navigateTo({ url: '/pages/discover/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/discover/index?type=product&domain=wisdom' })
+    },
+    goTasks: function() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
+    goBack: function() { uni.navigateBack() }
+  }
+}
+</script>
+
+<style scoped>
+.detail-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 40rpx;
+}
+.nav-bar {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #fff;
+}
+.nav-back {
+  padding: 10rpx;
+}
+.nav-back-icon {
+  font-size: 36rpx;
+  color: #333;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-left: 16rpx;
+}
+.member-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.member-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 40rpx;
+  background: linear-gradient(135deg, #FF6B35, #FBBF24);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+}
+.wisdom-avatar {
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+}
+.avatar-text {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.member-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.member-name {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.member-role {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.member-score {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #FF6B35;
+}
+.wisdom-score {
+  color: #FFD700;
+}
+.score-label {
+  font-size: 22rpx;
+  color: #999;
+}
+.bottom-spacer {
+  height: 120rpx;
+}
+</style>

+ 921 - 0
cfc-frontend/pages/wisdom/wisdom_temp/self-quiz.vue

@@ -0,0 +1,921 @@
+<template>
+  <view class="container">
+    <view class="resume-prompt" v-if="showResume">
+      <text class="resume-text">检测到上次未完成的测评</text>
+      <view class="resume-btns">
+        <view class="btn btn--outline" @tap="resumeSession">继续答题</view>
+        <view class="btn btn--ghost" @tap="clearSession">重新开始</view>
+      </view>
+    </view>
+
+    <view class="step-wrap" v-if="!showResume">
+      <view class="step-row">
+        <view class="step-item" v-for="i in 4" :key="i" @tap="goStep(i)">
+          <view class="step-dot" :class="{
+            'step-dot--active': step === i,
+            'step-dot--done': step > i,
+            'step-dot--pending': step < i
+          }">
+            <text v-if="step > i" class="step-check">&#10003;</text>
+            <text v-else class="step-num">{{ i }}</text>
+          </view>
+          <text class="step-label" :class="{
+            'step-label--active': step >= i,
+            'step-label--muted': step < i
+          }">{{ stepLabels[i - 1] }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="step-content" v-if="step === 1 && !showResume">
+      <view class="content-header">
+        <text class="content-title">选择测评目标</text>
+        <text class="content-sub">选择你想提升的能力维度</text>
+      </view>
+      <view class="goal-grid">
+        <view class="goal-card" v-for="g in goals" :key="g.id" :class="{ 'goal-card--selected': selectedGoal && selectedGoal.id === g.id }" @tap="selectGoal(g)">
+          <view class="goal-icon-wrap">
+            <text class="goal-icon">{{ g.icon }}</text>
+          </view>
+          <text class="goal-title">{{ g.title }}</text>
+          <text class="goal-desc">{{ g.desc }}</text>
+        </view>
+      </view>
+      <view class="action-bar">
+        <view class="btn btn--primary" :class="{ 'btn--disabled': !selectedGoal }" @tap="goStep2">下一步</view>
+      </view>
+    </view>
+
+    <view class="step-content" v-if="step === 2 && !showResume">
+      <view class="content-header">
+        <text class="content-title">选择题目类型</text>
+        <text class="content-sub">共5道题,选择适合的答题方式</text>
+      </view>
+      <view class="type-list">
+        <view class="type-card" v-for="t in questionTypes" :key="t.id" :class="{ 'type-card--selected': questionType === t.id }" @tap="selectType(t.id)">
+          <view class="type-icon-wrap">
+            <text class="type-icon">{{ t.icon }}</text>
+          </view>
+          <view class="type-info">
+            <text class="type-name">{{ t.name }}</text>
+            <text class="type-desc">{{ t.desc }}</text>
+          </view>
+          <view class="type-radio" :class="{ 'type-radio--checked': questionType === t.id }">
+            <view v-if="questionType === t.id" class="type-radio-dot"></view>
+          </view>
+        </view>
+      </view>
+      <view class="action-bar action-bar--double">
+        <view class="btn btn--outline" @tap="goStep1">上一步</view>
+        <view class="btn btn--primary" :class="{ 'btn--disabled': !questionType }" @tap="startQuiz">开始答题</view>
+      </view>
+    </view>
+
+    <view class="step-content" v-if="step === 3 && !showResume">
+      <view class="progress-header">
+        <text class="progress-label">第 {{ currentQ + 1 }}/{{ questions.length }} 题</text>
+        <view class="progress-bar">
+          <view class="progress-fill" :style="'width:' + progressPct + '%'"></view>
+        </view>
+      </view>
+      <view class="question-card" v-if="currentQuestion">
+        <view class="q-tag">{{ tagLabel }}</view>
+        <text class="q-text">{{ currentQuestion.question }}</text>
+        <view class="q-options" v-if="currentQuestion.isTF">
+          <view class="q-option q-option--tf" :class="{ 'q-option--selected': currentAnswer === '正确' }" @tap="currentAnswer = '正确'">
+            <text class="q-option-tf-text">正确</text>
+          </view>
+          <view class="q-option q-option--tf" :class="{ 'q-option--selected': currentAnswer === '错误' }" @tap="currentAnswer = '错误'">
+            <text class="q-option-tf-text">错误</text>
+          </view>
+        </view>
+        <view class="q-options" v-else>
+          <view class="q-option" v-for="(opt, oi) in currentQuestion.options" :key="oi" :class="{ 'q-option--selected': currentAnswer === opt }" @tap="currentAnswer = opt">
+            <view class="q-radio" :class="{ 'q-radio--checked': currentAnswer === opt }">
+              <view v-if="currentAnswer === opt" class="q-radio-dot"></view>
+            </view>
+            <text class="q-option-text">{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="action-bar action-bar--double">
+        <view class="btn btn--outline" :class="{ 'btn--disabled': currentQ === 0 }" @tap="prevQuestion">上一题</view>
+        <view class="btn btn--primary" :class="{ 'btn--disabled': !currentAnswer }" @tap="nextQuestion">{{ currentQ < questions.length - 1 ? '下一题' : '查看结果' }}</view>
+      </view>
+    </view>
+
+    <view class="step-content" v-if="step === 4 && !showResume">
+      <view class="result-header">
+        <view class="score-circle">
+          <text class="score-value">{{ score }}/5</text>
+        </view>
+        <text class="score-label">{{ resultMessage }}</text>
+      </view>
+      <view class="section-title">维度分析</view>
+      <view class="result-dims">
+        <view class="dim-bar-item" v-for="(dim, di) in dimBreakdown" :key="di">
+          <view class="dim-bar-header">
+            <text class="dim-bar-name">{{ dim.name }}</text>
+            <text class="dim-bar-count">{{ dim.correct }}/{{ dim.total }}</text>
+          </view>
+          <view class="dim-bar-track">
+            <view class="dim-bar-fill" :style="'width:' + dim.pct + '%'"></view>
+          </view>
+        </view>
+      </view>
+      <view class="action-bar">
+        <view class="btn btn--primary" @tap="resetQuiz">再测一次</view>
+        <view class="btn btn--outline" @tap="goHome">返回首页</view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+var QUESTION_BANK = [
+  { dimension: 'focus', question: '以下哪个因素最容易影响注意力集中?', options: ['噪音', '温度', '光照', '以上都是'], answer: '以上都是' },
+  { dimension: 'focus', question: '持续专注工作建议多久休息一次?', options: ['15分钟', '30分钟', '45分钟', '60分钟'], answer: '45分钟' },
+  { dimension: 'focus', question: '舒尔特方格的训练目标是提升什么?', options: ['记忆力', '专注力', '逻辑思维', '创造力'], answer: '专注力' },
+  { dimension: 'memory', question: '短期记忆通常能记住几个信息组块?', options: ['3-4个', '5-9个', '10-15个', '20个以上'], answer: '5-9个' },
+  { dimension: 'memory', question: '以下哪个方法有助于增强记忆?', options: ['死记硬背', '联想记忆', '反复抄写', '一次性学习'], answer: '联想记忆' },
+  { dimension: 'memory', question: '睡眠对记忆的影响是什么?', options: ['削弱记忆', '巩固记忆', '无影响', '干扰记忆'], answer: '巩固记忆' },
+  { dimension: 'logic', question: '如果A>B且B>C,那么?', options: ['A=C', 'A>C', 'A<C', '不确定'], answer: 'A>C' },
+  { dimension: 'logic', question: '1, 1, 2, 3, 5, 8, ? 下一个数是多少?', options: ['10', '11', '12', '13'], answer: '13' },
+  { dimension: 'logic', question: '以下哪个不是有效推理方式?', options: ['归纳推理', '演绎推理', '循环论证', '类比推理'], answer: '循环论证' },
+  { dimension: 'perception', question: '人眼能分辨约多少种颜色?', options: ['几百种', '几千种', '几万种', '数百万种'], answer: '数百万种' },
+  { dimension: 'perception', question: '以下哪种错觉与视觉感知有关?', options: ['缪勒-莱尔错觉', '德布罗意错觉', '薛定谔错觉', '迈克尔逊错觉'], answer: '缪勒-莱尔错觉' },
+  { dimension: 'perception', question: '感觉适应是指什么?', options: ['敏感度降低', '敏感度升高', '感知消失', '感知增强'], answer: '敏感度降低' },
+  { dimension: 'spatial', question: '正方体有多少个面?', options: ['4', '6', '8', '12'], answer: '6' },
+  { dimension: 'spatial', question: '以下哪个是立体几何的基本要素?', options: ['点线面', '正反合', '红黄蓝', '上下左右'], answer: '点线面' },
+  { dimension: 'spatial', question: '一个立方体展开图有几个正方形?', options: ['4', '6', '8', '12'], answer: '6' },
+  { dimension: 'speed', question: '反应时实验测量的是什么?', options: ['思考速度', '反应速度', '运动速度', '阅读速度'], answer: '反应速度' },
+  { dimension: 'speed', question: '以下哪个因素会影响加工速度?', options: ['年龄', '情绪', '疲劳', '以上都是'], answer: '以上都是' },
+  { dimension: 'speed', question: '信息加工速度最快的年龄段是?', options: ['儿童期', '青春期', '成年早期', '中老年期'], answer: '成年早期' }
+]
+
+export default {
+  data() {
+    return {
+      step: 1,
+      selectedGoal: null,
+      questionType: null,
+      questions: [],
+      currentQ: 0,
+      answers: [],
+      showResume: false,
+      savedSession: null,
+      goals: [
+        { id: 'focus', title: '专注力', desc: '提升注意力集中能力', icon: '集' },
+        { id: 'memory', title: '记忆力', desc: '增强信息记忆与回忆能力', icon: '忆' },
+        { id: 'logic', title: '逻辑思维', desc: '锻炼逻辑推理与判断能力', icon: '逻' },
+        { id: 'perception', title: '感知觉', desc: '提高感官信息处理能力', icon: '感' },
+        { id: 'spatial', title: '空间思维', desc: '培养空间想象与构建能力', icon: '间' },
+        { id: 'speed', title: '加工速度', desc: '加快信息处理与反应速度', icon: '速' }
+      ],
+      questionTypes: [
+        { id: 'choice', name: '选择题', desc: '四选一标准选择题', icon: '选' },
+        { id: 'truefalse', name: '判断题', desc: '判断陈述正误', icon: '判' },
+        { id: 'mix', name: '混合题型', desc: '选择题与判断题混合', icon: '混' }
+      ],
+      stepLabels: ['目标', '题型', '答题', '结果']
+    }
+  },
+  computed: {
+    currentQuestion: function() {
+      if (this.questions.length === 0) {
+        return null
+      }
+      return this.questions[this.currentQ]
+    },
+    currentAnswer: {
+      get: function() {
+        return this.answers[this.currentQ] || null
+      },
+      set: function(val) {
+        this.$set(this.answers, this.currentQ, val)
+      }
+    },
+    progressPct: function() {
+      if (this.questions.length === 0) {
+        return 0
+      }
+      return ((this.currentQ + 1) / this.questions.length) * 100
+    },
+    score: function() {
+      var correct = 0
+      for (var i = 0; i < this.questions.length; i++) {
+        var q = this.questions[i]
+        var a = this.answers[i]
+        if (q && a && q.answer === a) {
+          correct++
+        }
+      }
+      return correct
+    },
+    resultMessage: function() {
+      var s = this.score
+      if (s >= 5) return '太棒了!满分通过!'
+      if (s >= 4) return '非常优秀!继续保持!'
+      if (s >= 3) return '表现不错,还有提升空间!'
+      return '继续加油,多练习会更好!'
+    },
+    tagLabel: function() {
+      if (!this.currentQuestion) return ''
+      var dimMap = {
+        focus: '专注力',
+        memory: '记忆力',
+        logic: '逻辑思维',
+        perception: '感知觉',
+        spatial: '空间思维',
+        speed: '加工速度'
+      }
+      return dimMap[this.currentQuestion.dimension] || ''
+    },
+    dimBreakdown: function() {
+      var dimNames = {
+        focus: '专注力',
+        memory: '记忆力',
+        logic: '逻辑思维',
+        perception: '感知觉',
+        spatial: '空间思维',
+        speed: '加工速度'
+      }
+      var dims = {}
+      for (var i = 0; i < this.questions.length; i++) {
+        var q = this.questions[i]
+        if (!q) continue
+        if (!dims[q.dimension]) {
+          dims[q.dimension] = { total: 0, correct: 0 }
+        }
+        dims[q.dimension].total++
+        if (this.answers[i] && q.answer === this.answers[i]) {
+          dims[q.dimension].correct++
+        }
+      }
+      var result = []
+      var keys = Object.keys(dims)
+      for (var k = 0; k < keys.length; k++) {
+        var key = keys[k]
+        var item = dims[key]
+        result.push({
+          name: dimNames[key] || key,
+          total: item.total,
+          correct: item.correct,
+          pct: item.total > 0 ? Math.round((item.correct / item.total) * 100) : 0
+        })
+      }
+      return result
+    }
+  },
+  onLoad: function() {
+    this.checkSession()
+  },
+  onUnload: function() {
+    this.saveSession()
+  },
+  methods: {
+    checkSession: function() {
+      var saved = uni.getStorageSync('selfQuizSession')
+      if (saved) {
+        try {
+          var session = JSON.parse(saved)
+          if (session && session.step && session.step >= 2 && session.step <= 3) {
+            this.showResume = true
+            this.savedSession = session
+          } else {
+            uni.removeStorageSync('selfQuizSession')
+          }
+        } catch (e) {
+          uni.removeStorageSync('selfQuizSession')
+        }
+      }
+    },
+    saveSession: function() {
+      var session = {
+        step: this.step,
+        selectedGoal: this.selectedGoal,
+        questionType: this.questionType,
+        questions: this.questions,
+        currentQ: this.currentQ,
+        answers: this.answers
+      }
+      uni.setStorageSync('selfQuizSession', JSON.stringify(session))
+    },
+    resumeSession: function() {
+      if (!this.savedSession) return
+      var s = this.savedSession
+      this.step = s.step
+      this.selectedGoal = s.selectedGoal
+      this.questionType = s.questionType
+      this.questions = s.questions || []
+      this.currentQ = s.currentQ || 0
+      this.answers = s.answers || []
+      this.showResume = false
+      this.savedSession = null
+    },
+    clearSession: function() {
+      uni.removeStorageSync('selfQuizSession')
+      this.showResume = false
+      this.savedSession = null
+    },
+    selectGoal: function(g) {
+      this.selectedGoal = g
+    },
+    goStep2: function() {
+      if (this.selectedGoal) {
+        this.step = 2
+        this.saveSession()
+      }
+    },
+    goStep1: function() {
+      this.step = 1
+      this.saveSession()
+    },
+    selectType: function(id) {
+      this.questionType = id
+    },
+    startQuiz: function() {
+      if (!this.questionType) return
+      this.generateQuestions()
+      this.currentQ = 0
+      this.answers = []
+      this.step = 3
+      this.saveSession()
+    },
+    shuffle: function(arr) {
+      var a = arr.slice()
+      for (var i = a.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = a[i]
+        a[i] = a[j]
+        a[j] = tmp
+      }
+      return a
+    },
+    convertToTF: function(q) {
+      var opts = q.options
+      var idx = opts.indexOf(q.answer)
+      var wrongOpts = []
+      for (var w = 0; w < opts.length; w++) {
+        if (opts[w] !== q.answer) {
+          wrongOpts.push(opts[w])
+        }
+      }
+      var isTrue = Math.random() > 0.4
+      var claimIdx
+      if (isTrue) {
+        claimIdx = idx
+      } else {
+        var randomWrong = wrongOpts[Math.floor(Math.random() * wrongOpts.length)]
+        claimIdx = opts.indexOf(randomWrong)
+      }
+      return {
+        dimension: q.dimension,
+        question: q.question + ' 正确答案是"' + opts[claimIdx] + '"',
+        options: ['正确', '错误'],
+        answer: isTrue ? '正确' : '错误',
+        isTF: true
+      }
+    },
+    generateQuestions: function() {
+      var goalId = this.selectedGoal && this.selectedGoal.id
+      if (!goalId) return
+      var pool = []
+      for (var p = 0; p < QUESTION_BANK.length; p++) {
+        if (QUESTION_BANK[p].dimension === goalId) {
+          pool.push(QUESTION_BANK[p])
+        }
+      }
+      var shuffled = this.shuffle(pool)
+      var type = this.questionType
+      if (type === 'truefalse') {
+        var tfQuestions = []
+        var count = Math.min(5, shuffled.length)
+        for (var ti = 0; ti < count; ti++) {
+          tfQuestions.push(this.convertToTF(shuffled[ti]))
+        }
+        this.questions = tfQuestions
+      } else if (type === 'mix') {
+        var allDims = ['focus', 'memory', 'logic', 'perception', 'spatial', 'speed']
+        var otherDims = []
+        for (var od = 0; od < allDims.length; od++) {
+          if (allDims[od] !== goalId) {
+            otherDims.push(allDims[od])
+          }
+        }
+        otherDims = this.shuffle(otherDims)
+        var selectedDims = [goalId].concat(otherDims.slice(0, 4))
+        var mixedQuestions = []
+        for (var di = 0; di < selectedDims.length; di++) {
+          var dimPool = []
+          for (var dq = 0; dq < QUESTION_BANK.length; dq++) {
+            if (QUESTION_BANK[dq].dimension === selectedDims[di]) {
+              dimPool.push(QUESTION_BANK[dq])
+            }
+          }
+          if (dimPool.length > 0) {
+            var pick = this.shuffle(dimPool)
+            mixedQuestions.push(pick[0])
+          }
+        }
+        this.questions = this.shuffle(mixedQuestions).slice(0, 5)
+      } else {
+        this.questions = shuffled.slice(0, 5)
+      }
+    },
+    nextQuestion: function() {
+      if (!this.currentAnswer) return
+      if (this.currentQ < this.questions.length - 1) {
+        this.currentQ++
+        this.saveSession()
+      } else {
+        this.step = 4
+        this.saveSession()
+      }
+    },
+    prevQuestion: function() {
+      if (this.currentQ > 0) {
+        this.currentQ--
+      }
+    },
+    resetQuiz: function() {
+      uni.removeStorageSync('selfQuizSession')
+      this.step = 1
+      this.selectedGoal = null
+      this.questionType = null
+      this.questions = []
+      this.currentQ = 0
+      this.answers = []
+    },
+    goHome: function() {
+      uni.switchTab({ url: '/pages/wisdom/index' })
+    },
+    goStep: function(n) {
+      if (n === this.step) return
+      if (n < this.step) {
+        this.step = n
+        this.saveSession()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 30rpx;
+  padding-bottom: 120rpx;
+  box-sizing: border-box;
+}
+
+.resume-prompt {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 50rpx 30rpx;
+  text-align: center;
+  margin-top: 160rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+}
+.resume-text {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 30rpx;
+}
+.resume-btns {
+  display: flex;
+  justify-content: center;
+  gap: 20rpx;
+}
+
+.step-wrap {
+  margin-bottom: 10rpx;
+}
+.step-row {
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
+  padding: 20rpx 0;
+}
+.step-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 0 24rpx;
+}
+.step-dot {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 3rpx solid #ddd;
+  background: #fff;
+  transition: all 0.3s;
+}
+.step-dot--active {
+  background: #FFD700;
+  border-color: #FFD700;
+}
+.step-dot--done {
+  border-color: #FFD700;
+  background: #FFD700;
+}
+.step-dot--pending {
+  border-color: #ddd;
+  background: #fff;
+}
+.step-check {
+  font-size: 22rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.step-num {
+  font-size: 22rpx;
+  color: #999;
+}
+.step-dot--active .step-num {
+  color: #fff;
+}
+.step-label {
+  font-size: 20rpx;
+  margin-top: 8rpx;
+  color: #999;
+}
+.step-label--active {
+  color: #FFD700;
+  font-weight: bold;
+}
+.step-label--muted {
+  color: #ccc;
+}
+
+.step-content {
+  animation: fadeIn 0.3s ease;
+}
+@keyframes fadeIn {
+  from { opacity: 0; transform: translateY(20rpx); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.content-header {
+  text-align: center;
+  margin-bottom: 30rpx;
+}
+.content-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.content-sub {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 10rpx;
+  display: block;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+}
+
+.goal-grid {
+  display: flex;
+  flex-wrap: wrap;
+}
+.goal-card {
+  width: calc(50% - 12rpx);
+  margin: 6rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx 16rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  border: 3rpx solid transparent;
+  transition: all 0.3s;
+  box-sizing: border-box;
+}
+.goal-card--selected {
+  border-color: #FFD700;
+  background: #FFF8E1;
+}
+.goal-icon-wrap {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 16rpx;
+}
+.goal-icon {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.goal-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 6rpx;
+}
+.goal-desc {
+  font-size: 22rpx;
+  color: #999;
+  text-align: center;
+  line-height: 1.4;
+}
+
+.type-list {
+  margin-top: 10rpx;
+}
+.type-card {
+  display: flex;
+  align-items: center;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+  border: 3rpx solid transparent;
+  transition: all 0.3s;
+}
+.type-card--selected {
+  border-color: #FFD700;
+  background: #FFF8E1;
+}
+.type-icon-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  flex-shrink: 0;
+}
+.type-icon {
+  font-size: 28rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.type-info {
+  flex: 1;
+}
+.type-name {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.type-desc {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.type-radio {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  border: 3rpx solid #ddd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+.type-radio--checked {
+  border-color: #FFD700;
+}
+.type-radio-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  background: #FFD700;
+}
+
+.progress-header {
+  margin-bottom: 30rpx;
+}
+.progress-label {
+  font-size: 26rpx;
+  color: #666;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.progress-bar {
+  height: 8rpx;
+  background: #eee;
+  border-radius: 4rpx;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  border-radius: 4rpx;
+  transition: width 0.3s ease;
+}
+
+.question-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 36rpx 28rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+  margin-bottom: 30rpx;
+}
+.q-tag {
+  display: inline-block;
+  padding: 6rpx 20rpx;
+  background: #FFF8E1;
+  color: #B8860B;
+  font-size: 20rpx;
+  border-radius: 20rpx;
+  margin-bottom: 20rpx;
+}
+.q-text {
+  font-size: 30rpx;
+  color: #333;
+  line-height: 1.6;
+  display: block;
+  margin-bottom: 30rpx;
+}
+.q-options {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.q-option {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 20rpx;
+  border-radius: 16rpx;
+  border: 2rpx solid #eee;
+  transition: all 0.3s;
+}
+.q-option--selected {
+  border-color: #FFD700;
+  background: #FFF8E1;
+}
+.q-option--tf {
+  justify-content: center;
+  padding: 28rpx;
+}
+.q-option-tf-text {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #666;
+}
+.q-option--selected .q-option-tf-text {
+  color: #B8860B;
+}
+.q-radio {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 50%;
+  border: 3rpx solid #ddd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-right: 16rpx;
+}
+.q-radio--checked {
+  border-color: #FFD700;
+}
+.q-radio-dot {
+  width: 18rpx;
+  height: 18rpx;
+  border-radius: 50%;
+  background: #FFD700;
+}
+.q-option-text {
+  font-size: 26rpx;
+  color: #333;
+  flex: 1;
+}
+
+.action-bar {
+  margin-top: 40rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.action-bar--double {
+  flex-direction: row;
+  gap: 20rpx;
+}
+.action-bar--double .btn {
+  flex: 1;
+}
+
+.btn {
+  padding: 24rpx 0;
+  border-radius: 16rpx;
+  text-align: center;
+  font-size: 28rpx;
+  font-weight: bold;
+  transition: all 0.3s;
+  box-sizing: border-box;
+}
+.btn--primary {
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  color: #fff;
+}
+.btn--primary:active {
+  opacity: 0.9;
+  transform: scale(0.98);
+}
+.btn--disabled {
+  opacity: 0.4;
+  pointer-events: none;
+}
+.btn--outline {
+  border: 3rpx solid #FFD700;
+  color: #B8860B;
+  background: #fff;
+}
+.btn--outline:active {
+  background: #FFF8E1;
+}
+.btn--ghost {
+  color: #999;
+  background: transparent;
+}
+
+.result-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 40rpx 0;
+}
+.score-circle {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 30rpx rgba(255,215,0,0.3);
+  margin-bottom: 20rpx;
+}
+.score-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #fff;
+}
+.score-label {
+  font-size: 28rpx;
+  color: #666;
+  display: block;
+}
+
+.result-dims {
+  margin: 20rpx 0 30rpx;
+}
+.dim-bar-item {
+  margin-bottom: 24rpx;
+}
+.dim-bar-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.dim-bar-name {
+  font-size: 24rpx;
+  color: #333;
+  font-weight: bold;
+}
+.dim-bar-count {
+  font-size: 22rpx;
+  color: #999;
+}
+.dim-bar-track {
+  height: 8rpx;
+  background: #eee;
+  border-radius: 4rpx;
+  overflow: hidden;
+}
+.dim-bar-fill {
+  height: 100%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  border-radius: 4rpx;
+  transition: width 0.6s ease;
+}
+
+.bottom-spacer {
+  height: 120rpx;
+}
+</style>

+ 359 - 0
cfc-frontend/pages/wisdom/wisdom_temp/training-hub.vue

@@ -0,0 +1,359 @@
+<template>
+  <view class="container">
+    <PageBanner theme="wisdom"
+      tagline="智慧训练中心"
+      :quote="bannerQuote" />
+
+    <!-- 训练说明 -->
+    <view class="intro-card">
+      <text class="intro-icon">&#x2696;&#xFE0F;</text>
+      <text class="intro-title">智·训练中心</text>
+      <text class="intro-desc">通过科学设计的认知训练游戏,全面提升专注力、逻辑思维与反应速度。每日坚持训练,让智慧之金更加闪耀。</text>
+    </view>
+
+    <!-- 推荐训练 -->
+    <view class="section">
+      <view class="section-header">
+        <text class="section-title">推荐训练</text>
+        <text class="section-subtitle">选择一项开始挑战</text>
+      </view>
+      <view class="game-list">
+        <view class="game-card" v-for="game in games" :key="game.code" @click="goGame(game)">
+          <view class="game-icon-wrap">
+            <text class="game-icon">{{ game.icon }}</text>
+          </view>
+          <view class="game-info">
+            <text class="game-title">{{ game.name }}</text>
+            <text class="game-desc">{{ game.desc }}</text>
+            <view class="game-meta">
+              <view class="difficulty-row">
+                <text class="difficulty-label">难度:</text>
+                <view class="stars-row">
+                  <text class="star filled" v-for="i in game.difficulty" :key="'on' + i">&#x2605;</text>
+                  <text class="star empty" v-for="i in (5 - game.difficulty)" :key="'off' + i">&#x2605;</text>
+                </view>
+              </view>
+              <text class="game-badge" v-if="game.badge">{{ game.badge }}</text>
+            </view>
+          </view>
+          <button class="start-btn" hover-class="start-btn-hover">开始挑战</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 训练小贴士 -->
+    <view class="tip-section">
+      <view class="tip-card">
+        <view class="tip-left-bar"></view>
+        <view class="tip-body">
+          <text class="tip-title">&#x1F4A1; 训练建议</text>
+          <text class="tip-content">每次训练15-30分钟效果最佳;保持专注,减少干扰;循序渐进,从简单模式开始;定期复盘进步,保持训练动力。</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部占位 -->
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import PageBanner from '../../components/PageBanner.vue'
+
+var GAME_LIST = [
+  {
+    code: 'schulte',
+    name: '舒尔特方格',
+    icon: '\u{1F3AF}',
+    desc: '提升专注力与反应速度',
+    difficulty: 3,
+    badge: '入门推荐'
+  },
+  {
+    code: '1a2b',
+    name: '猜数字',
+    icon: '\u{1F522}',
+    desc: '锻炼逻辑思维与推理能力',
+    difficulty: 4,
+    badge: '进阶挑战'
+  }
+]
+
+var GAME_PAGES = {
+  schulte: '/pages/games/schulte',
+  '1a2b': '/pages/games/1a2b'
+}
+
+export default {
+  components: { PageBanner },
+  data: function() {
+    return {
+      games: GAME_LIST,
+      childId: null,
+      bannerQuote: '学而不思则罔,思而不学则殆'
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.childId) {
+      this.childId = options.childId
+    } else {
+      var stored = uni.getStorageSync('currentChildId')
+      if (stored) {
+        this.childId = stored
+      }
+    }
+    uni.setNavigationBarTitle({
+      title: '智慧训练中心'
+    })
+  },
+  methods: {
+    goGame: function(game) {
+      var url = GAME_PAGES[game.code]
+      if (!url) {
+        uni.showToast({ title: '游戏暂未开放', icon: 'none' })
+        return
+      }
+      var childId = this.childId || ''
+      var queryParams = '?childId=' + childId + '&dimension=wisdom'
+      uni.navigateTo({ url: url + queryParams })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #FFFBEB;
+  padding-bottom: 120rpx;
+}
+
+/* ===== 训练说明卡 ===== */
+.intro-card {
+  margin: 30rpx;
+  background: linear-gradient(135deg, #FFFBEB 0%, #FFF8E1 100%);
+  border-radius: 24rpx;
+  padding: 40rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  border: 1rpx solid rgba(255, 215, 0, 0.2);
+  box-shadow: 0 4rpx 20rpx rgba(255, 215, 0, 0.1);
+}
+
+.intro-icon {
+  font-size: 56rpx;
+  margin-bottom: 16rpx;
+}
+
+.intro-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #B8860B;
+  margin-bottom: 16rpx;
+  letter-spacing: 2rpx;
+}
+
+.intro-desc {
+  font-size: 26rpx;
+  color: #8B7355;
+  text-align: center;
+  line-height: 1.7;
+}
+
+/* ===== 通用区段 ===== */
+.section {
+  margin: 20rpx 30rpx;
+}
+
+.section-header {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 24rpx;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #5D4037;
+}
+
+.section-subtitle {
+  font-size: 24rpx;
+  color: #A0896E;
+  margin-top: 6rpx;
+}
+
+/* ===== 游戏卡片 ===== */
+.game-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.game-card {
+  display: flex;
+  flex-direction: column;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  border: 1rpx solid rgba(255, 215, 0, 0.15);
+  box-shadow: 0 4rpx 16rpx rgba(255, 215, 0, 0.08);
+}
+
+.game-card:active {
+  opacity: 0.85;
+  transform: scale(0.98);
+}
+
+.game-icon-wrap {
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50rpx;
+  background: linear-gradient(135deg, #FFD700 0%, #FFA000 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 12rpx rgba(255, 215, 0, 0.3);
+}
+
+.game-icon {
+  font-size: 48rpx;
+}
+
+.game-info {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 24rpx;
+}
+
+.game-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #5D4037;
+  margin-bottom: 8rpx;
+}
+
+.game-desc {
+  font-size: 26rpx;
+  color: #8B7355;
+  margin-bottom: 16rpx;
+  line-height: 1.5;
+}
+
+.game-meta {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.difficulty-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.difficulty-label {
+  font-size: 22rpx;
+  color: #A0896E;
+  margin-right: 8rpx;
+}
+
+.stars-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.star {
+  font-size: 24rpx;
+  margin-right: 4rpx;
+}
+
+.star.filled {
+  color: #FFD700;
+}
+
+.star.empty {
+  color: #E0D5C0;
+}
+
+.game-badge {
+  font-size: 20rpx;
+  color: #B8860B;
+  background: #FFFBEB;
+  border-radius: 20rpx;
+  padding: 4rpx 16rpx;
+  border: 1rpx solid rgba(255, 215, 0, 0.3);
+  line-height: 1.6;
+}
+
+.start-btn {
+  width: 100%;
+  height: 80rpx;
+  line-height: 80rpx;
+  text-align: center;
+  background: linear-gradient(135deg, #FFD700 0%, #FFA000 100%);
+  color: #5D4037;
+  font-size: 30rpx;
+  font-weight: bold;
+  border-radius: 40rpx;
+  border: none;
+  padding: 0;
+  letter-spacing: 4rpx;
+  box-shadow: 0 4rpx 12rpx rgba(255, 215, 0, 0.3);
+}
+
+.start-btn-hover {
+  opacity: 0.8;
+  transform: scale(0.96);
+}
+
+/* ===== 训练小贴士 ===== */
+.tip-section {
+  margin: 20rpx 30rpx 0;
+}
+
+.tip-card {
+  display: flex;
+  flex-direction: row;
+  background: #FFFFFF;
+  border-radius: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+  overflow: hidden;
+  border: 1rpx solid rgba(255, 215, 0, 0.1);
+}
+
+.tip-left-bar {
+  width: 6rpx;
+  flex-shrink: 0;
+  background: linear-gradient(180deg, #FFD700 0%, #FFA000 100%);
+}
+
+.tip-body {
+  flex: 1;
+  padding: 28rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+}
+
+.tip-title {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #B8860B;
+  margin-bottom: 12rpx;
+}
+
+.tip-content {
+  font-size: 24rpx;
+  color: #8B7355;
+  line-height: 1.7;
+}
+
+/* ===== 底部 ===== */
+.bottom-spacer {
+  height: 120rpx;
+}
+</style>

+ 241 - 0
docs/relationship-management-analysis.md

@@ -0,0 +1,241 @@
+# 关系管理功能需求分析
+
+## 一、当前实现范围
+
+关系管理是浠艾福平台的核心基础能力——以家庭为单位的服务平台,关系管理贯穿家庭构成、服务商绑定、社会联系人等场景。当前已实现以下能力:
+
+### 1.1 家庭内部关系
+
+| 能力 | 数据结构 | 前端 | 后端 API | 状态 |
+|------|---------|------|----------|:----:|
+| 家庭创建 | `family` 表 | — | `FamilyController` | ✅ |
+| 家庭成员添加 | `family_members` 表 | `add-member.vue` | `FamilyMembersController.add` | ✅ |
+| 家庭成员列表 | `family_members` 表 | `family-members.vue` | `FamilyMembersController.list` | ✅ |
+| 成员关系类型编辑 | `family_members.relationshipType` | `RelationshipPicker.vue` 组件 | `FamilyMembersController.update` | ✅ |
+| 切换成员视角 | `family_members` 表 | `family-members.vue` | `FamilyMembersController.switch` | ✅ |
+| 踢出成员 | `family_members` 表 | `family-members.vue` | `FamilyMembersController.kick` | ✅ |
+| 孩子管理 | 关联 `family_members`+`user` | `children.vue` / `create-child.vue` / `edit-child.vue` | 家长端接口 | ✅ |
+| 关系类型字典 CRUD | `relationship_types` 表 | `RelationshipTypes.vue`(管理端) | `RelationshipTypeAdminController` | ✅ |
+| 家庭邀请码 | `family.inviteCode` | 小程序码 | `FamilyController` | ✅ |
+| 家庭邀请处理 | `family_invitation` 表 | 落地页 | `FamilyInviteController` | ✅ |
+| 家庭地址编辑 | `family` 表地址字段 | `address-edit.vue` | — | ✅ |
+| 家庭属性配置 | `family_member_attributes` 表 | — | `FamilyAttributeController` | ✅ |
+| 家庭关系图 | Canvas 绘制 | `FamilyRelationGraph.vue` 组件 | 前端绘制 | ✅ |
+
+### 1.2 社会联系人(非家庭成员)
+
+| 能力 | 数据结构 | 前端 | 后端 API | 状态 |
+|------|---------|------|----------|:----:|
+| 联系人列表 | `contacts` 表 | `ContactCard.vue` 组件 | `ContactController.list` | ✅ |
+| 创建联系人 | `contacts` 表 | 表单 | `ContactController.create` | ✅ |
+| 编辑联系人 | `contacts` 表 | 表单 | `ContactController.update` | ✅ |
+| 删除联系人 | `contacts` 表 | 删除操作 | `ContactController.delete` | ✅ |
+| 联系人详情 | `contacts` 表 | `ContactCard.vue` | `ContactController.detail` | ✅ |
+| 手机通讯录导入 | `contacts` 表 | `ContactImport.vue` 组件 | `ContactController.importPhone` | ✅ |
+| 互动记录 | `contacts` 表 + contactCount | — | `ContactController.interaction` | ✅ |
+
+### 1.3 服务商-家庭绑定
+
+| 能力 | 数据结构 | 前端 | 后端 API | 状态 |
+|------|---------|------|----------|:----:|
+| 家长-规划师绑定 | `guide_family_relations` + `family.teacherId` | 绑定页面 | `BindController` | ✅ |
+| 规划师邀请绑定 | `guide_family_relations` | 邀请链接 | `BindInviteController` | ✅ |
+| 规划师家庭列表 | `guide_family_relations` | `FamilyList.vue`(管理端) | `GuideFamilyService` | ✅ |
+| 规划师-家庭关系 | `guide_family_relations`(relationType, status) | — | `GuideFamilyRelationService` | ✅ |
+
+### 1.4 其他关系
+
+| 能力 | 数据结构 | 状态 |
+|------|---------|:----:|
+| 分销关系 | `distribution_relation` 表 | ✅ 存在但未见前端对接 |
+| 家庭会员关系 | `family_memberships` 表 | ✅ 基础实现 |
+| 成员属性扩展 | `family_member_attributes` 表 | ✅ |
+
+---
+
+## 二、现有功能覆盖评估
+
+### 已覆盖的维度(5分/5分)
+
+- **成员 CRUD**:完整的家庭成员增删改查
+- **关系类型字典**:管理端可配置
+- **关系图可视化**:Canvas 绘制家庭关系图
+- **联系人管理**:社会联系人 CRUD + 手机导入
+- **规划师-家庭绑定**:两端双向绑定流程
+- **邀请机制**:通过邀请码加入家庭
+
+### 部分覆盖的维度(3分/5分)
+
+- **关系类型体系**:只有 typeKey/typeName/capabilityRole 三要素,缺少关系属性(如"亲密度"、"关系年限"、"共同居住"等)
+- **家庭关系图交互**:仅查看,不支持在图上直接编辑
+- **联系人与家庭成员的打通**:联系人和家庭成员互不关联,无法将联系人直接邀请为家庭成员
+- **关系历史/时间线**:成员关系变更没有记录
+
+### 未覆盖的维度(0分/5分)
+
+- **关系统计分析**:无任何关系数据统计功能
+- **关系事件提醒**:无生日提醒、纪念日提醒等
+- **家庭成员角色/标签**:无自定义标签(如"主要照顾者"、"紧急联系人"等)
+- **关系健康度评估**:无关系质量打分、互动频率分析
+- **多家庭归属**:一个人是否可属于多个家庭
+- **家庭树谱系图**:跨代家庭关系展示
+
+---
+
+## 三、功能需求建议
+
+### P0 - 核心补齐(建议立即纳入)
+
+#### 3.1 关系类型体系扩展
+- **问题**:当前关系类型只有 typeKey/typeName/capabilityRole,无法表达关系的多维属性
+- **需求**:
+  - 关系属性扩展:亲属关系可配置"父系/母系"、"血缘/姻亲"等维度
+  - 关系有效期:临时关系(如"保姆")可设置有效期
+  - 关系等级:成员在家庭中的权限等级(管理员/普通成员/受限成员)
+- **涉及范围**:`RelationshipType` 实体扩展、`family_members` 加字段
+
+#### 3.2 联系人→家庭成员升级路径
+- **问题**:联系人与家庭成员是两套独立的数据,无法互通
+- **需求**:
+  - 联系人的"邀请加入家庭"操作
+  - 联系人详情页显示"已在家庭中"的状态
+  - 导入手机通讯录后,自动匹配已注册用户,显示"可邀请加入家庭"提示
+- **涉及范围**:`ContactController` 新增邀请接口、`ContactCard.vue` 扩展
+
+#### 3.3 家庭关系时间线
+- **问题**:家庭成员的关系变更、加入、离开无历史记录
+- **需求**:
+  - 记录家庭成员每次关系类型变更
+  - 记录新成员加入时间、退出时间
+  - 可在家庭关系图中按时间节点回溯关系状态
+- **涉及范围**:新增 `family_member_logs` 表、更新时间线组件
+
+### P1 - 体验增强(建议下个迭代)
+
+#### 3.4 家庭关系图增强
+- 单击节点显示成员详情(弹窗)
+- 双击节点切换为该成员视角(复用现有 switch 能力)
+- 关系线 hover 显示关系类型名称
+- 支持按关系类型筛选显示/隐藏
+- 支持缩放和平移(大数据量家庭)
+
+#### 3.5 家庭成员标签系统
+- 可为成员添加自定义标签(如"接送人"、"紧急联系人"、"过敏体质"等)
+- 标签在家庭关系图中以图标展示
+- 标签由家长管理
+
+#### 3.6 关系事件提醒
+- 成员生日提醒
+- 关系纪念日提醒(如 "爸爸加入家庭一周年")
+- 互动频率低提醒(如 "和孩子已 7 天未互动")
+
+### P2 - 高阶能力(建议规划)
+
+#### 3.7 关系健康度评估
+- 基于互动频率、任务完成情况、能量流动方向等,计算成员之间的关系健康度
+- 关系健康度在家庭关系图中以颜色/动画表现
+- 低健康度时 AI 提醒并提供改善建议
+
+#### 3.8 多家庭/跨家庭场景
+- 一个人可同时属于多个家庭(如祖父母属于多个子女家庭)
+- 跨家庭能量互动、任务协作
+- 家庭联姻关系管理(两个家庭因婚姻关系关联)
+
+#### 3.9 家族谱系(Tree)
+- 支持跨代关系展示(祖父母→父母→子女)
+- 支持"家庭成员"和"已故长辈"的标注
+- 族谱导出(图片/PDF)
+
+---
+
+## 四、现有数据模型改进建议
+
+### 4.1 `relationship_types` 表扩展
+
+```sql
+-- 当前字段:typeKey, typeName, capabilityRole, sortOrder, enabled
+-- 建议新增:
+ALTER TABLE relationship_types ADD COLUMN category VARCHAR(32) DEFAULT 'family' COMMENT '分类: family/contact/workplace';
+ALTER TABLE relationship_types ADD COLUMN is_blood_relation TINYINT(1) DEFAULT 0 COMMENT '是否血缘关系';
+ALTER TABLE relationship_types ADD COLUMN is_immediate_family TINYINT(1) DEFAULT 0 COMMENT '是否直系亲属';
+```
+
+### 4.2 `family_members` 表扩展
+
+```sql
+-- 当前字段:userId, familyId, relationshipType, nickname, avatar, etc.
+-- 建议新增:
+ALTER TABLE family_members ADD COLUMN is_primary TINYINT(1) DEFAULT 0 COMMENT '是否主要家庭成员';
+ALTER TABLE family_members ADD COLUMN permission_level VARCHAR(16) DEFAULT 'member' COMMENT '权限等级: admin/member/restricted';
+ALTER TABLE family_members ADD COLUMN joined_at DATETIME COMMENT '加入时间';
+```
+
+### 4.3 新增表:`family_member_tags`
+
+```sql
+CREATE TABLE family_member_tags (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL,
+    member_id BIGINT NOT NULL,
+    tag_name VARCHAR(32) NOT NULL COMMENT '标签名',
+    tag_type VARCHAR(16) DEFAULT 'custom' COMMENT '标签类型: system/custom',
+    created_at DATETIME
+);
+```
+
+### 4.4 新增表:`family_member_logs`
+
+```sql
+CREATE TABLE family_member_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL,
+    member_id BIGINT NOT NULL,
+    action VARCHAR(32) NOT NULL COMMENT '操作: join/leave/change_role/change_relation',
+    old_value VARCHAR(64) COMMENT '旧值',
+    new_value VARCHAR(64) COMMENT '新值',
+    operator_id BIGINT COMMENT '操作人',
+    created_at DATETIME
+);
+```
+
+### 4.5 `contacts` 表扩展
+
+```sql
+-- 建议新增字段:
+ALTER TABLE contacts ADD COLUMN family_member_id BIGINT DEFAULT NULL COMMENT '关联的家庭成员ID';
+ALTER TABLE contacts ADD COLUMN invited_status VARCHAR(16) DEFAULT 'none' COMMENT '邀请加入家庭状态: none/invited/accepted/declined';
+```
+
+---
+
+## 五、优先级建议
+
+| 优先级 | 需求 | 工作估 | 依赖 |
+|:------:|------|:------:|:----:|
+| **P0** | 关系类型体系扩展(属性/有效期/权限) | 2-3 天 | 后端实体改、管理端 UI |
+| **P0** | 联系人→家庭成员升级路径 | 3-4 天 | 后端 API + 前端组件 |
+| **P0** | 家庭关系时间线 | 3-5 天 | 新建表 + 后端 + 前端 |
+| **P1** | 关系图增强交互 | 2-3 天 | 前端组件改 |
+| **P1** | 成员标签系统 | 2-3 天 | 新建表 + 管理端 |
+| **P1** | 关系事件提醒 | 2-3 天 | 定时任务 + 消息推送 |
+| **P2** | 关系健康度评估 | 5-7 天 | AI 分析 + 可视化 |
+| **P2** | 多家庭场景 | 5-8 天 | 架构级改动 |
+| **P2** | 家族谱系 | 4-6 天 | 前端组件 + 后端 |
+
+---
+
+## 六、总结
+
+当前关系管理功能覆盖了基本 CRUD 和核心流程(家庭构成、联系人管理、服务商绑定),已经是一个可用的基础版本。主要短板在于:
+
+1. **数据隔离**:联系人和家庭成员是两套独立数据,缺少互通路径
+2. **历史缺失**:关系变更无记录,无法回溯
+3. **交互单薄**:关系图展示能力强但交互弱(仅查看、无编辑)
+4. **智能不足**:无关系分析、健康度评估等 AI 能力
+
+建议 P0 需求在 1-2 个迭代内完成,补齐基础能力;P1 优化体验;P2 作为 AI 差异化能力规划。
+
+---
+
+*文档版本: v0.1*
+*日期: 2026-06-26*