Преглед на файлове

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot преди 1 месец
родител
ревизия
ac9e718a70
променени са 60 файла, в които са добавени 8304 реда и са изтрити 142 реда
  1. 30 2
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 43 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java
  3. 52 8
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  5. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/CognitiveController.java
  6. 77 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java
  7. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java
  8. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java
  9. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java
  10. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java
  11. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java
  12. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberDimensionBaseScoreMapper.java
  13. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  14. 69 3
      cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveService.java
  15. 124 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
  16. 140 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberDimensionBaseScoreService.java
  17. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberNutritionProfileService.java
  18. 76 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  19. 13 2
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  20. 26 7
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  21. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  22. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java
  23. 438 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportScriptGeneratorService.java
  24. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java
  25. 16 0
      cfc-backend/src/main/resources/schema.sql
  26. 130 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyMemberDimensionBaseScoreServiceTest.java
  27. 42 1
      cfc-frontend/pages.json
  28. 12 2
      cfc-frontend/pages/article-center/index.vue
  29. 513 0
      cfc-frontend/pages/cognitive-test/logic-test.vue
  30. 675 0
      cfc-frontend/pages/cognitive-test/memory-test.vue
  31. 457 0
      cfc-frontend/pages/cognitive-test/perception-test.vue
  32. 517 0
      cfc-frontend/pages/cognitive-test/spatial-test.vue
  33. 577 0
      cfc-frontend/pages/cognitive-test/speed-test.vue
  34. 11 3
      cfc-frontend/pages/discover/index.vue
  35. 5 2
      cfc-frontend/pages/growth-main/index.vue
  36. 149 20
      cfc-frontend/pages/growth/caution-detail.vue
  37. 73 0
      cfc-frontend/pages/growth/mini-trend.vue
  38. 2 2
      cfc-frontend/pages/health-main/index.vue
  39. 40 22
      cfc-frontend/pages/health/gut-flora-species-detail.vue
  40. 12 1
      cfc-frontend/pages/health/report-list.vue
  41. 1 43
      cfc-frontend/pages/health/report-upload.vue
  42. 3 5
      cfc-frontend/pages/index-home/index.vue
  43. 0 1
      cfc-frontend/pages/profile-extra/family-members.vue
  44. 97 3
      cfc-frontend/pages/tianpan/index.vue
  45. 438 0
      cfc-frontend/pages/tianpan/traditional.vue
  46. 62 1
      cfc-frontend/pages/wisdom-detail/training-hub.vue
  47. 17 1
      cfc-frontend/utils/api.js
  48. 1 1
      cfc-web/.last_build_commit
  49. 2 2
      cfc-web/package-lock.json
  50. 1 1
      cfc-web/package.json
  51. 112 0
      cfc-web/public/CHANGELOG-v1.0.md
  52. 113 1
      cfc-web/public/CHANGELOG.md
  53. 1 1
      cfc-web/src/views/admin/ReportAutoLearn.vue
  54. 8 0
      cfc-web/src/views/admin/ReportTypeManagement.vue
  55. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  56. 39 0
      docs/superpowers/api/API_REFERENCE.md
  57. 1055 0
      docs/superpowers/plans/2026-09-09-team-hierarchy.md
  58. 1190 0
      docs/superpowers/plans/2026-09-09-tianpan-tradition-impl.md
  59. 330 0
      docs/superpowers/specs/2026-09-09-team-hierarchy-design.md
  60. 351 0
      docs/superpowers/specs/2026-09-09-tianpan-tradition-design.md

+ 30 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1,8 +1,9 @@
 package com.etotem.cfc.config;
 
 import com.etotem.cfc.mapper.*;
-import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import lombok.var;
 import org.springframework.beans.factory.annotation.Value;
@@ -23,10 +24,11 @@ import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-@Slf4j
 @Component
 public class DatabaseInitializer implements CommandLineRunner {
 
+    private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class);
+
     @Resource
     private JdbcTemplate jdbcTemplate;
 
@@ -10507,5 +10509,31 @@ public class DatabaseInitializer implements CommandLineRunner {
         ensureColumn("interaction_logs", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
         ensureColumn("interaction_logs", "video_url", "VARCHAR(500) COMMENT '视频URL'");
         ensureColumn("interaction_logs", "audio_url", "VARCHAR(500) COMMENT '音频URL'");
+
+        // 迁移304: report_type_registry表添加script_status字段(自学习生成采集脚本的状态追踪)
+        ensureColumn("report_type_registry", "script_status",
+                "VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed'");
+
+        // 迁移305: 创建 family_member_dimension_base_scores 表(五维基础能量分,支持重算)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+                    "member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent', " +
+                    "dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth', " +
+                    "base_score INT NOT NULL COMMENT '基础能量分(0-100)', " +
+                    "last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间', " +
+                    "calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源(system/manual)', " +
+                    "UNIQUE KEY uk_member_dimension (member_id, dimension_code), " +
+                    "INDEX idx_member (member_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分(落库,支持重算)'");
+            log.info("已创建 family_member_dimension_base_scores 表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
+        // 迁移306: health_reports和health_report_drafts添加file_hash字段(报告上传去重)
+        ensureColumn("health_reports", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
+        ensureColumn("health_report_drafts", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
     }
 }

+ 43 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/FoodRecommendController.java

@@ -1,14 +1,17 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
+import com.etotem.cfc.service.FamilyMemberService;
 import com.etotem.cfc.service.FoodRecommendService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
@@ -21,6 +24,9 @@ public class FoodRecommendController {
     @Resource
     private FoodRecommendService foodRecommendService;
 
+    @Resource
+    private FamilyMemberService familyMemberService;
+
     @Operation(summary = "获取用户食材推荐指数")
     @PostMapping("/index")
     public Result<List<FoodRecommendIndex>> getIndex(@RequestBody Map<String, Object> params) {
@@ -57,9 +63,44 @@ public class FoodRecommendController {
     @Operation(summary = "获取戒备食材列表(推荐指数 < 0)")
     @PostMapping("/caution")
     public Result<List<FoodRecommendIndex>> getCautionFoods(
-            @RequestAttribute("userId") Long userId) {
+            @RequestAttribute("userId") Long userId,
+            @RequestBody(required = false) Map<String, Object> params) {
         if (userId == null) return Result.error("请先登录");
-        List<FoodRecommendIndex> foods = foodRecommendService.getCautionFoods(userId);
+        Long memberId = null;
+        if (params != null && params.get("memberId") instanceof Number) {
+            memberId = ((Number) params.get("memberId")).longValue();
+        }
+        List<FoodRecommendIndex> foods = foodRecommendService.getCautionFoods(memberId != null ? memberId : userId);
         return Result.success(foods);
     }
+
+    @Operation(summary = "获取家庭成员列表(用于戒备页面切换)")
+    @PostMapping("/members")
+    public Result<List<FamilyMemberVO>> getFamilyMembers(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+        return Result.success(members);
+    }
+
+    @Operation(summary = "获取戒备食材趋势数据(历史变化)")
+    @PostMapping("/trend")
+    public Result<List<Map<String, Object>>> getTrend(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) return Result.error("请先登录");
+        Long memberId = null;
+        if (params != null && params.get("memberId") instanceof Number) {
+            memberId = ((Number) params.get("memberId")).longValue();
+        }
+        List<Long> foodIds = new ArrayList<>();
+        if (params != null && params.get("foodIds") instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Object> ids = (List<Object>) params.get("foodIds");
+            for (Object id : ids) {
+                if (id instanceof Number) foodIds.add(((Number) id).longValue());
+            }
+        }
+        List<Map<String, Object>> trend = foodRecommendService.getTrend(memberId != null ? memberId : userId, foodIds);
+        return Result.success(trend);
+    }
 }

+ 52 - 8
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -643,15 +643,14 @@ public class HealthReportController {
         String originalFilename = file.getOriginalFilename();
 
         try {
+            // 先读取文件字节:图片用于安全检测,PDF用于MD5去重(storeFile 后 temp 文件会被转移,无法再读)
+            byte[] fileBytes = file.getBytes();
+            String fileHash = computeMd5(fileBytes);
+
             // 图片类型:安全检测 + 保存
             if ("image".equals(type) || isImageContent(contentType)) {
-                try {
-                    byte[] imageBytes = file.getBytes();
-                    if (!wechatService.checkImage(imageBytes)) {
-                        return Result.error("图片内容包含敏感信息,上传失败");
-                    }
-                } catch (IOException e) {
-                    return Result.error("图片读取失败: " + e.getMessage());
+                if (!wechatService.checkImage(fileBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
                 }
             } else {
                 // PDF 类型校验
@@ -662,6 +661,25 @@ public class HealthReportController {
                 }
             }
 
+            // 重复上传检测:同一 MD5 已入库或正在采集中则直接返回,不再重复解析
+            Long existingReportId = healthReportService.findReportIdByFileHash(fileHash, reportTypeOfUpload(type, familyId));
+            if (existingReportId != null) {
+                Map<String, Object> dup = new LinkedHashMap<>();
+                dup.put("duplicate", true);
+                dup.put("reportId", existingReportId);
+                dup.put("message", "该报告已上传过,无需重复解析");
+                return Result.success(dup);
+            }
+            Long collectingDraftId = healthReportService.findCollectingDraftIdByFileHash(fileHash);
+            if (collectingDraftId != null) {
+                Map<String, Object> dup = new LinkedHashMap<>();
+                dup.put("duplicate", true);
+                dup.put("draftId", collectingDraftId);
+                dup.put("collecting", true);
+                dup.put("message", "该报告正在解析中,请稍候");
+                return Result.success(dup);
+            }
+
             String fileUrl = storageService.storeFile(file, "health-reports");
             String reportType = familyId != null && !("pdf".equals(type) || "auto".equals(type))
                     ? "physical_exam" : "gut_flora";
@@ -671,7 +689,7 @@ public class HealthReportController {
             String payloadJson = objectMapper.writeValueAsString(emptyPayload);
 
             HealthReportDraft draft = healthReportDraftService.createDraft(
-                    userId, familyId, reportType, fileUrl, originalFilename, payloadJson);
+                    userId, familyId, reportType, fileUrl, originalFilename, payloadJson, fileHash);
 
             // 提交异步采集:指纹判定类型后走专用脚本/opencode,完成自动入库,前端轮询 parse_status
             reportCollectService.submitAsyncCollect(draft.getId());
@@ -690,6 +708,32 @@ public class HealthReportController {
         }
     }
 
+    /**
+     * 计算上传文件的MD5哈希(用于重复上传去重)
+     */
+    private String computeMd5(byte[] data) {
+        try {
+            java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(data);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            log.warn("计算文件MD5失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 根据上传类型推导报告类型(与下方 reportType 计算保持一致,供去重查询使用)
+     */
+    private String reportTypeOfUpload(String type, Long familyId) {
+        return familyId != null && !("pdf".equals(type) || "auto".equals(type))
+                ? "physical_exam" : "gut_flora";
+    }
+
     /**
      * 查询异步采集任务状态(前端轮询):返回 parse_status/parse_method/parse_error/reportId
      */

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.ReportImportService;
+import com.etotem.cfc.service.ReportScriptGeneratorService;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
@@ -43,6 +44,9 @@ public class ReportParserAdminController {
     @Resource
     private ReportImportService reportImportService;
 
+    @Resource
+    private ReportScriptGeneratorService reportScriptGeneratorService;
+
     @Resource
     private ReportTemplateMapper reportTemplateMapper;
 
@@ -246,6 +250,9 @@ public class ReportParserAdminController {
             type.setCreatedAt(new Date());
             type.setUpdatedAt(new Date());
             typeRegistryMapper.insert(type);
+            type.setScriptStatus(ReportTypeRegistry.SCRIPT_STATUS_GENERATING);
+            type.setUpdatedAt(new Date());
+            typeRegistryMapper.updateById(type);
 
             // 从公共签名生成指纹规则
             String signatures = cluster.getCommonSignatures();
@@ -296,6 +303,9 @@ public class ReportParserAdminController {
             result.put("displayName", type.getDisplayName());
             result.put("fingerprintCount", type.getMinConfidence());
             result.put("reviewStatus", "pending");
+            result.put("scriptStatus", type.getScriptStatus());
+            // 异步触发 opencode 写采集脚本
+            reportScriptGeneratorService.triggerAsyncGenerate(cluster.getId(), typeId);
             return Result.success(result);
         } catch (Exception e) {
             log.error("自学习生成类型失败 clusterId={}", clusterId, e);

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/CognitiveController.java

@@ -213,4 +213,37 @@ public class CognitiveController {
 
         return Result.success(trend);
     }
+
+    @Operation(summary = "提交认知自测结果(写入六维认知档案,自动创建档案)")
+    @PostMapping("/self-test")
+    public Result<String> submitSelfTest(
+            @RequestBody Map<String, Object> body,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
+        String dimension = body.get("dimension") != null
+                ? body.get("dimension").toString()
+                : null;
+        Integer score = body.get("score") != null
+                ? Integer.valueOf(body.get("score").toString())
+                : null;
+
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        if (dimension == null || dimension.isEmpty()) {
+            return Result.error("dimension不能为空");
+        }
+        if (score == null) {
+            return Result.error("score不能为空");
+        }
+        if (score < 0 || score > 100) {
+            return Result.error("score必须在0-100之间");
+        }
+
+        boolean ok = cognitiveService.submitSelfTestScore(memberId, dimension, score);
+        if (!ok) {
+            return Result.error("提交失败,请重试");
+        }
+        return Result.success("提交成功");
+    }
 }

+ 77 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java

@@ -2,11 +2,14 @@ package com.etotem.cfc.controller.tianpan;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.*;
-import com.etotem.cfc.service.TianpanService;
+import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.service.*;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import java.util.Calendar;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
@@ -21,6 +24,15 @@ public class TianpanController {
     @Resource
     private TianpanService tianpanService;
 
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    @Resource
+    private ZodiacAnnualEnergyService zodiacAnnualEnergyService;
+
+    @Resource
+    private FamilyMemberDimensionBaseScoreService familyMemberDimensionBaseScoreService;
+
     /**
      * 获取家庭天盘仪表盘
      * 
@@ -207,6 +219,70 @@ public class TianpanController {
         return Result.success(data);
     }
 
+    /**
+     * 重算单个成员的五维基础分
+     */
+    @PostMapping("/recalibrate")
+    public Result<Map<String, Integer>> recalibrate(
+            HttpServletRequest request,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = Long.valueOf(params.get("memberId").toString());
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+        String operator = params.get("operator") != null ? params.get("operator").toString() : "system";
+
+        String role = (String) request.getAttribute("role");
+        Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
+        Long familyId = getMemberFamilyId(memberId);
+        if (familyId == null) {
+            return Result.error(404, "成员不存在");
+        }
+        if (!checkAccess(role, familyId, currentUserId, request)) {
+            return Result.error(403, "无权访问该成员数据");
+        }
+
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+        if (attrs == null) {
+            return Result.error("成员属性不存在");
+        }
+
+        Map<String, Integer> wuxingElements = parseWuxingElements(attrs.getWuxingElements());
+        String westernSign = null;
+        if (attrs.getBirthDatetime() != null) {
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(attrs.getBirthDatetime());
+            westernSign = zodiacAnnualEnergyService.getWesternSign(cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+        }
+        Integer lifeNumber = extractLifeNumber(attrs.getBehaviorModifier());
+
+        familyMemberDimensionBaseScoreService.calcAndSave(memberId, memberType, wuxingElements, westernSign, lifeNumber, operator);
+        Map<String, Integer> scores = familyMemberDimensionBaseScoreService.getScores(memberId);
+        return Result.success(scores);
+    }
+
+    private Map<String, Integer> parseWuxingElements(String json) {
+        Map<String, Integer> result = new LinkedHashMap<>();
+        if (json == null || json.isEmpty()) return result;
+        try {
+            Map<String, Object> raw = familyMemberAttributeService.fromJson(json);
+            for (Map.Entry<String, Object> e : raw.entrySet()) {
+                if (e.getValue() instanceof Number) {
+                    result.put(e.getKey(), ((Number) e.getValue()).intValue());
+                }
+            }
+        } catch (Exception ignored) {}
+        return result;
+    }
+
+    private Integer extractLifeNumber(String behaviorModifier) {
+        if (behaviorModifier == null || behaviorModifier.isEmpty()) return null;
+        try {
+            Map<String, Object> map = familyMemberAttributeService.fromJson(behaviorModifier);
+            Object ln = map.get("lifeNumber");
+            if (ln instanceof Number) return ((Number) ln).intValue();
+        } catch (Exception ignored) {}
+        return null;
+    }
+
     // ========== 权限检查 ==========
 
     private boolean checkAccess(String role, Long familyId, Long currentUserId, HttpServletRequest request) {

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java

@@ -30,4 +30,5 @@ public class TianpanMemberVO {
     private Map<String, Integer> wuxingElements;  // 五行元素分布
     private String wuxingReading;                 // 五行解读文本
     private Map<String, Object> energySnapshot;  // 能量快照:body/mind/wisdom/action/wealth
+    private Map<String, Integer> dimensionBaseScores;  // 五维基础分(先天)
 }

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 五维基础能量分(落库,支持重算)
+ */
+@Data
+@TableName("family_member_dimension_base_scores")
+public class FamilyMemberDimensionBaseScore implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭成员ID */
+    private Long memberId;
+
+    /** 成员类型: child/parent */
+    private String memberType;
+
+    /** 维度: body/mind/wisdom/action/wealth */
+    private String dimensionCode;
+
+    /** 基础能量分(0-100) */
+    private Integer baseScore;
+
+    /** 最后计算时间 */
+    private Date lastCalculatedAt;
+
+    /** 计算来源(system/manual/admin) */
+    private String calculatedBy;
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -83,6 +83,9 @@ public class HealthReport implements Serializable {
     /** 报告文件URL */
     private String fileUrl;
 
+    /** 文件MD5哈希(用于重复上传去重) */
+    private String fileHash;
+
     /** 状态: active/archived */
     private String status;
 

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java

@@ -30,6 +30,9 @@ public class HealthReportDraft implements Serializable {
     /** 上传的文件URL(PDF或图片) */
     private String fileUrl;
 
+    /** 文件MD5哈希(用于重复上传去重) */
+    private String fileHash;
+
     /** 原始文件名 */
     private String originalFilename;
 

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTypeRegistry.java

@@ -26,6 +26,7 @@ public class ReportTypeRegistry implements Serializable {
     private String reviewStatus;
     private String version;
     private Long createdBy;
+    private String scriptStatus;
     private Date createdAt;
     private Date updatedAt;
 
@@ -36,4 +37,9 @@ public class ReportTypeRegistry implements Serializable {
     public static final String REVIEW_APPROVED = "approved";
     public static final String REVIEW_PENDING = "pending";
     public static final String REVIEW_REJECTED = "rejected";
+
+    /** 采集脚本生成状态: generating=生成中 / ready=测试通过 / failed=生成或测试失败 */
+    public static final String SCRIPT_STATUS_GENERATING = "generating";
+    public static final String SCRIPT_STATUS_READY = "ready";
+    public static final String SCRIPT_STATUS_FAILED = "failed";
 }

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

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

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -114,6 +114,8 @@ public class ArticleService {
         List<Article> visible = articlePermissionService.filterVisible(
                 result.getRecords(), userId, familyId, vendorType);
 
+        enrichTotalDurationSeconds(visible);
+
         Page<Article> filtered = new Page<>(page, size);
         filtered.setTotal(visible.size());
         filtered.setRecords(visible);
@@ -144,6 +146,8 @@ public class ArticleService {
         List<Article> all = articleMapper.selectList(wrapper);
         List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
 
+        enrichTotalDurationSeconds(visible);
+
         return visible.size() > size ? visible.subList(0, size) : visible;
     }
 
@@ -382,6 +386,27 @@ public class ArticleService {
         return articleMapper.selectById(id);
     }
 
+    private void enrichTotalDurationSeconds(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) return;
+        String ids = articles.stream()
+                .map(a -> String.valueOf(a.getId()))
+                .collect(Collectors.joining(","));
+        List<Map<String, Object>> durationRows = jdbcTemplate.queryForList(
+                "SELECT article_id, SUM(duration_seconds) AS total_duration " +
+                "FROM article_reading_records WHERE article_id IN (" + ids + ") GROUP BY article_id");
+        Map<Long, Integer> durationMap = new HashMap<>();
+        for (Map<String, Object> row : durationRows) {
+            Number aid = (Number) row.get("article_id");
+            Number total = (Number) row.get("total_duration");
+            if (aid != null && total != null) {
+                durationMap.put(aid.longValue(), total.intValue());
+            }
+        }
+        for (Article article : articles) {
+            article.setTotalDurationSeconds(durationMap.getOrDefault(article.getId(), 0));
+        }
+    }
+
     public void enrichWithCategoryName(List<Article> articles) {
         if (articles == null || articles.isEmpty()) return;
     }

+ 69 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveService.java

@@ -67,19 +67,16 @@ public class CognitiveService {
      * @return Map<childId, DanAssessmentResult> 孩子ID与最新测评结果的映射
      */
     public Map<Long, DanAssessmentResult> getFamilyLatestResults(Long userId) {
-        // 1. 查找用户所属家庭
         User user = userMapper.selectById(userId);
         if (user == null || user.getFamilyId() == null) {
             return Collections.emptyMap();
         }
 
-        // 2. 查找该家庭下所有孩子
         List<FamilyMember> children = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>()
                         .eq(FamilyMember::getFamilyId, user.getFamilyId())
         );
 
-        // 3. 获取每个孩子的最新测评结果
         Map<Long, DanAssessmentResult> resultMap = new LinkedHashMap<>();
         for (FamilyMember child : children) {
             DanAssessmentResult latest = getFamilyMemberLatestResult(child.getId());
@@ -90,6 +87,75 @@ public class CognitiveService {
         return resultMap;
     }
 
+    /**
+     * 提交认知自测分数(自动创建测评结果行,适用于孩子尚无DAN测评档案的情况)
+     * 自动计算综合分(7个认知维度非空值的平均值)
+     */
+    public boolean submitSelfTestScore(Long memberId, String dimension, Integer score) {
+        DanAssessmentResult latest = getFamilyMemberLatestResult(memberId);
+        if (latest == null) {
+            FamilyMember member = familyMemberMapper.selectById(memberId);
+            if (member == null) {
+                log.warn("未找到家庭成员 {}, 无法创建自测结果", memberId);
+                return false;
+            }
+            latest = new DanAssessmentResult();
+            latest.setChildId(memberId);
+            latest.setFamilyMemberId(memberId);
+            latest.setFamilyMemberName(member.getNickname());
+            latest.setFamilyMemberBirthday(member.getBirthday());
+            latest.setFamilyMemberGender(member.getGender());
+            latest.setFamilyMemberAge(member.getAge());
+            latest.setTeacherId(0L);
+            latest.setStatus("completed");
+            latest.setAssessmentDate(new Date());
+            latest.setSource("self_test");
+            latest.setCreatedAt(new Date());
+            latest.setUpdatedAt(new Date());
+        }
+
+        switch (dimension) {
+            case "memoryScore":
+                latest.setMemoryScore(score);
+                break;
+            case "logicScore":
+                latest.setLogicScore(score);
+                break;
+            case "perceptionScore":
+                latest.setPerceptionScore(score);
+                break;
+            case "spatialScore":
+                latest.setSpatialScore(score);
+                break;
+            case "processingSpeedScore":
+                latest.setProcessingSpeedScore(score);
+                break;
+            default:
+                log.warn("未知的认知自测维度: {}", dimension);
+                return false;
+        }
+
+        int validCount = 0, total = 0;
+        for (Integer s : Arrays.asList(
+                latest.getAttentionScore(), latest.getFocusScore(),
+                latest.getMemoryScore(), latest.getLogicScore(),
+                latest.getPerceptionScore(), latest.getSpatialScore(),
+                latest.getProcessingSpeedScore())) {
+            if (s != null && s > 0) { total += s; validCount++; }
+        }
+        if (validCount > 0) {
+            latest.setOverallScore(Math.round(total / (float) validCount));
+        }
+
+        if (latest.getId() == null) {
+            danAssessmentResultMapper.insert(latest);
+        } else {
+            danAssessmentResultMapper.updateById(latest);
+        }
+        log.info("已更新孩子 {} 的自测维度 {} 得分为 {} (id={})", memberId, dimension, score, latest.getId());
+        return true;
+    }
+
     /**
      * 更新指定孩子的认知维度得分
      *

+ 124 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -6,13 +6,17 @@ import com.etotem.cfc.dto.CommissionSummaryDTO;
 import com.etotem.cfc.dto.ReferralSummaryDTO;
 import com.etotem.cfc.entity.CommissionRecord;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTierConfig;
+import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.WithdrawalRequest;
 import com.etotem.cfc.mapper.CommissionRecordMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.PromotionTierConfigMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.WithdrawalRequestMapper;
 import org.slf4j.Logger;
@@ -72,6 +76,12 @@ public class CommissionService {
     @Resource
     private PromotionTierConfigMapper configMapper;
 
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+
     @Resource
     private CfReferralService cfReferralService;
 
@@ -493,6 +503,29 @@ public class CommissionService {
             }
         }
 
+        // 批量预取成员汇总(避免 N+1):已完成履约订单总额 + 非cancelled佣金总额,循环内直接取用
+        List<Long> buyerIds = new ArrayList<>(memberMap.keySet());
+        Map<Long, Long> consumptionMap = new HashMap<>();
+        Map<Long, Integer> cfEarnedMap = new HashMap<>();
+        if (!buyerIds.isEmpty()) {
+            LambdaQueryWrapper<ProductOrder> consumptionWrapper = new LambdaQueryWrapper<ProductOrder>()
+                    .in(ProductOrder::getBuyerId, buyerIds)
+                    .eq(ProductOrder::getFulfillStatus, "completed");
+            for (ProductOrder order : productOrderMapper.selectList(consumptionWrapper)) {
+                if (order.getBuyerId() == null || order.getTotalAmount() == null) continue;
+                consumptionMap.merge(order.getBuyerId(), order.getTotalAmount().longValue(), Long::sum);
+            }
+
+            LambdaQueryWrapper<CommissionRecord> cfWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                    .eq(CommissionRecord::getReferrerId, userId)
+                    .in(CommissionRecord::getBuyerId, buyerIds)
+                    .ne(CommissionRecord::getStatus, "cancelled");
+            for (CommissionRecord record : commissionRecordMapper.selectList(cfWrapper)) {
+                if (record.getBuyerId() == null || record.getCommissionAmount() == null) continue;
+                cfEarnedMap.merge(record.getBuyerId(), record.getCommissionAmount(), Integer::sum);
+            }
+        }
+
         for (Map.Entry<Long, Map<String, Object>> entry : memberMap.entrySet()) {
             Long buyerId = entry.getKey();
             Map<String, Object> memberInfo = entry.getValue();
@@ -521,6 +554,9 @@ public class CommissionService {
                     .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
                     .sum();
             memberInfo.put("commissionEarned", commissionEarned);
+
+            memberInfo.put("memberConsumption", consumptionMap.getOrDefault(buyerId, 0L));
+            memberInfo.put("myCfEarnedFromMember", cfEarnedMap.getOrDefault(buyerId, 0));
         }
 
         List<Map<String, Object>> allMembers = new ArrayList<>(memberMap.values());
@@ -598,6 +634,94 @@ public class CommissionService {
         return stats;
     }
 
+    /**
+     * 团队总消费额分级汇总(按 referral_tree level 聚合)
+     * maxLevel 默认 3,可后台配置
+     */
+    public Map<String, Object> getTeamConsumption(Long userId, Integer maxLevel) {
+        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
+        if (maxLevel > 10) maxLevel = 10; // 安全上限
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> levelBreakdown = new ArrayList<>();
+
+        // 1. 查 referral_tree: parentId = userId 且 level <= maxLevel
+        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, userId)
+                        .le(ReferralTree::getLevel, maxLevel)
+                        .orderByAsc(ReferralTree::getLevel)
+        );
+
+        if (treeNodes.isEmpty()) {
+            result.put("totalConsumption", 0);
+            result.put("levelBreakdown", levelBreakdown);
+            result.put("totalMembers", 0);
+            result.put("maxLevel", maxLevel);
+            return result;
+        }
+
+        // 2. 按 level 分组收集 childId
+        Map<Integer, List<Long>> levelToChildIds = new LinkedHashMap<>();
+        for (ReferralTree node : treeNodes) {
+            levelToChildIds.computeIfAbsent(node.getLevel(), k -> new ArrayList<>()).add(node.getChildId());
+        }
+
+        // 3. 查 product_orders: buyerId IN childIds 且 fulfillStatus = 'completed'
+        int totalConsumption = 0;
+        for (Map.Entry<Integer, List<Long>> entry : levelToChildIds.entrySet()) {
+            Integer level = entry.getKey();
+            List<Long> childIds = entry.getValue();
+            if (childIds.isEmpty()) continue;
+
+            LambdaQueryWrapper<ProductOrder> orderWrapper = new LambdaQueryWrapper<ProductOrder>()
+                    .in(ProductOrder::getBuyerId, childIds)
+                    .eq(ProductOrder::getFulfillStatus, "completed");
+            Long sum = productOrderMapper.selectList(orderWrapper).stream()
+                    .mapToLong(o -> o.getTotalAmount() != null ? o.getTotalAmount() : 0)
+                    .sum();
+
+            int memberCount = childIds.size();
+            totalConsumption += sum;
+
+            Map<String, Object> levelInfo = new LinkedHashMap<>();
+            levelInfo.put("level", level);
+            levelInfo.put("memberCount", memberCount);
+            levelInfo.put("consumption", sum.intValue());
+            levelBreakdown.add(levelInfo);
+        }
+
+        result.put("totalConsumption", totalConsumption);
+        result.put("levelBreakdown", levelBreakdown);
+        result.put("totalMembers", treeNodes.size());
+        result.put("maxLevel", maxLevel);
+        return result;
+    }
+
+    /**
+     * 获取我的推荐人信息(直推人)
+     */
+    public Map<String, Object> getMyReferrer(Long userId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getReferrerId() == null) {
+            result.put("hasReferrer", false);
+            return result;
+        }
+        User referrer = userMapper.selectById(user.getReferrerId());
+        if (referrer == null) {
+            result.put("hasReferrer", false);
+            return result;
+        }
+        result.put("hasReferrer", true);
+        result.put("referrerId", referrer.getId());
+        result.put("nickname", referrer.getNickname());
+        result.put("avatar", referrer.getAvatar());
+        result.put("referralCode", referrer.getReferralCode());
+        result.put("bindTime", user.getCreatedAt()); // 以用户创建时间作为绑定时间近似
+        return result;
+    }
+
     /**
      * 获取佣金统计(首页面板数据)
      */

+ 140 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberDimensionBaseScoreService.java

@@ -0,0 +1,140 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMemberDimensionBaseScore;
+import com.etotem.cfc.mapper.FamilyMemberDimensionBaseScoreMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class FamilyMemberDimensionBaseScoreService {
+
+    @Resource
+    private FamilyMemberDimensionBaseScoreMapper dimensionBaseScoreMapper;
+
+    private static final String[] DIMENSIONS = {"body", "mind", "wisdom", "action", "wealth"};
+
+    private static final Map<String, String> ZODIAC_TO_DIM = new LinkedHashMap<>();
+    private static final Map<Integer, String> LIFE_NUMBER_TO_DIM = new LinkedHashMap<>();
+
+    static {
+        ZODIAC_TO_DIM.put("白羊座", "mind");
+        ZODIAC_TO_DIM.put("金牛座", "body");
+        ZODIAC_TO_DIM.put("双子座", "action");
+        ZODIAC_TO_DIM.put("巨蟹座", "wealth");
+        ZODIAC_TO_DIM.put("狮子座", "mind");
+        ZODIAC_TO_DIM.put("处女座", "body");
+        ZODIAC_TO_DIM.put("天秤座", "action");
+        ZODIAC_TO_DIM.put("天蝎座", "wealth");
+        ZODIAC_TO_DIM.put("射手座", "mind");
+        ZODIAC_TO_DIM.put("摩羯座", "body");
+        ZODIAC_TO_DIM.put("水瓶座", "action");
+        ZODIAC_TO_DIM.put("双鱼座", "wealth");
+
+        LIFE_NUMBER_TO_DIM.put(1, "action");
+        LIFE_NUMBER_TO_DIM.put(2, "mind");
+        LIFE_NUMBER_TO_DIM.put(3, "wisdom");
+        LIFE_NUMBER_TO_DIM.put(4, "body");
+        LIFE_NUMBER_TO_DIM.put(5, "action");
+        LIFE_NUMBER_TO_DIM.put(6, "mind");
+        LIFE_NUMBER_TO_DIM.put(7, "wisdom");
+        LIFE_NUMBER_TO_DIM.put(8, "wealth");
+        LIFE_NUMBER_TO_DIM.put(9, "mind");
+    }
+
+    public Map<String, Integer> calcBaseScores(Map<String, Integer> wuxingElements,
+                                                String westernSign,
+                                                Integer lifeNumber) {
+        Map<String, Integer> scores = new HashMap<>();
+        for (String dim : DIMENSIONS) {
+            scores.put(dim, 50);
+        }
+
+        if (wuxingElements != null) {
+            scores.put("action", Math.round((float) wuxingElements.getOrDefault("wood", 50) * 0.6f));
+            scores.put("mind",   Math.round((float) wuxingElements.getOrDefault("fire", 50) * 0.6f));
+            scores.put("body",   Math.round((float) wuxingElements.getOrDefault("earth", 50) * 0.6f));
+            scores.put("wisdom", Math.round((float) wuxingElements.getOrDefault("metal", 50) * 0.6f));
+            scores.put("wealth", Math.round((float) wuxingElements.getOrDefault("water", 50) * 0.6f));
+        }
+
+        if (westernSign != null && ZODIAC_TO_DIM.containsKey(westernSign)) {
+            String dim = ZODIAC_TO_DIM.get(westernSign);
+            scores.put(dim, scores.get(dim) + 20);
+        }
+
+        if (lifeNumber != null) {
+            int reduced = reduceMaster(lifeNumber);
+            if (LIFE_NUMBER_TO_DIM.containsKey(reduced)) {
+                String dim = LIFE_NUMBER_TO_DIM.get(reduced);
+                scores.put(dim, scores.get(dim) + 20);
+            }
+        }
+
+        return scores;
+    }
+
+    public void calcAndSave(Long memberId, String memberType,
+                            Map<String, Integer> wuxingElements,
+                            String westernSign, Integer lifeNumber,
+                            String operator) {
+        Map<String, Integer> scores = calcBaseScores(wuxingElements, westernSign, lifeNumber);
+        saveScores(memberId, memberType, scores, operator);
+    }
+
+    private void saveScores(Long memberId, String memberType, Map<String, Integer> scores, String operator) {
+        Date now = new Date();
+        for (Map.Entry<String, Integer> entry : scores.entrySet()) {
+            LambdaQueryWrapper<FamilyMemberDimensionBaseScore> qw = new LambdaQueryWrapper<>();
+            qw.eq(FamilyMemberDimensionBaseScore::getMemberId, memberId)
+              .eq(FamilyMemberDimensionBaseScore::getDimensionCode, entry.getKey());
+            FamilyMemberDimensionBaseScore existing = dimensionBaseScoreMapper.selectOne(qw);
+            if (existing != null) {
+                existing.setBaseScore(entry.getValue());
+                existing.setLastCalculatedAt(now);
+                existing.setCalculatedBy(operator);
+                dimensionBaseScoreMapper.updateById(existing);
+            } else {
+                FamilyMemberDimensionBaseScore entity = new FamilyMemberDimensionBaseScore();
+                entity.setMemberId(memberId);
+                entity.setMemberType(memberType);
+                entity.setDimensionCode(entry.getKey());
+                entity.setBaseScore(entry.getValue());
+                entity.setLastCalculatedAt(now);
+                entity.setCalculatedBy(operator);
+                dimensionBaseScoreMapper.insert(entity);
+            }
+        }
+    }
+
+    public Map<String, Integer> getScores(Long memberId) {
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        for (String dim : DIMENSIONS) {
+            scores.put(dim, 50);
+        }
+        LambdaQueryWrapper<FamilyMemberDimensionBaseScore> qw = new LambdaQueryWrapper<>();
+        qw.eq(FamilyMemberDimensionBaseScore::getMemberId, memberId);
+        List<FamilyMemberDimensionBaseScore> list = dimensionBaseScoreMapper.selectList(qw);
+        for (FamilyMemberDimensionBaseScore item : list) {
+            scores.put(item.getDimensionCode(), item.getBaseScore());
+        }
+        return scores;
+    }
+
+    private int reduceMaster(int n) {
+        if (n == 11) return 2;
+        if (n == 22) return 4;
+        if (n == 33) return 6;
+        while (n >= 10) {
+            int sum = 0;
+            while (n > 0) {
+                sum += n % 10;
+                n /= 10;
+            }
+            n = sum;
+        }
+        return n;
+    }
+}

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberNutritionProfileService.java

@@ -58,7 +58,7 @@ public class FamilyMemberNutritionProfileService {
             profile.put("deficiencyRecords", Collections.emptyList());
         }
 
-        List<HealthReport> reports = healthReportService.getUserReports(memberId);
+        List<HealthReport> reports = healthReportService.getMemberReports(memberId);
         profile.put("reportCount", reports != null ? reports.size() : 0);
         profile.put("reportTimeline", reports);
 
@@ -130,7 +130,7 @@ public class FamilyMemberNutritionProfileService {
     public Map<String, Object> getReportTrendData(Long memberId) {
         Map<String, Object> trend = new HashMap<>();
 
-        List<HealthReport> reports = healthReportService.getUserReports(memberId);
+        List<HealthReport> reports = healthReportService.getMemberReports(memberId);
         if (reports == null || reports.isEmpty()) {
             trend.put("dates", Collections.emptyList());
             trend.put("scores", Collections.emptyList());

+ 76 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -253,13 +253,87 @@ public class FoodRecommendService {
     }
     /**
      * 获取戒备食材列表(推荐指数 < 0),按指数升序排列(最需戒备的排最前)
+     * @param queryUserId 查询目标,传memberId则按成员查,否则退回userId
      */
-    public List<FoodRecommendIndex> getCautionFoods(Long userId) {
+    public List<FoodRecommendIndex> getCautionFoods(Long queryUserId) {
         return foodRecommendIndexMapper.selectList(
                 new LambdaQueryWrapper<FoodRecommendIndex>()
-                        .eq(FoodRecommendIndex::getUserId, userId)
+                        .eq(FoodRecommendIndex::getUserId, queryUserId)
                         .lt(FoodRecommendIndex::getIndexScore, 0)
                         .orderByAsc(FoodRecommendIndex::getIndexScore)
         );
     }
+
+    /**
+     * 获取戒备食材趋势数据:返回每个食材的 indexScore 历史序列(最近N条变更记录)
+     * 每个元素: { foodId, foodName, currentScore, trend: [reportDate, score], changeDir: "up"|"down"|"flat" }
+     */
+    public List<Map<String, Object>> getTrend(Long userId, List<Long> foodIds) {
+        List<FoodRecommendIdxLog> logs;
+        if (foodIds != null && !foodIds.isEmpty()) {
+            logs = foodRecommendIdxLogMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIdxLog>()
+                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .in(FoodRecommendIdxLog::getFoodId, foodIds)
+                            .orderByDesc(FoodRecommendIdxLog::getChangedAt)
+                            .last("LIMIT 60")
+            );
+        } else {
+            logs = foodRecommendIdxLogMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIdxLog>()
+                            .eq(FoodRecommendIdxLog::getUserId, userId)
+                            .orderByDesc(FoodRecommendIdxLog::getChangedAt)
+                            .last("LIMIT 120")
+            );
+        }
+        if (logs.isEmpty()) return new ArrayList<>();
+
+        // 按 foodId 分组
+        Map<Long, List<FoodRecommendIdxLog>> byFood = new LinkedHashMap<>();
+        for (FoodRecommendIdxLog log : logs) {
+            byFood.computeIfAbsent(log.getFoodId(), k -> new ArrayList<>()).add(log);
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map.Entry<Long, List<FoodRecommendIdxLog>> entry : byFood.entrySet()) {
+            Long foodId = entry.getKey();
+            List<FoodRecommendIdxLog> foodLogs = entry.getValue();
+            // 最新一条的 newIndex 是当前值,旧的那条是 previousIndex
+            // logs 已按 changedAt 降序,所以第一条是最新的
+            FoodRecommendIdxLog latest = foodLogs.get(0);
+            Integer currentScore = latest.getNewIndex() != null ? latest.getNewIndex() : latest.getPreviousIndex();
+
+            // 取最近4条变更构建趋势(如果不足4条则全部)
+            int count = Math.min(4, foodLogs.size());
+            List<Map<String, Object>> points = new ArrayList<>();
+            for (int i = 0; i < count; i++) {
+                FoodRecommendIdxLog l = foodLogs.get(i);
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportDate", l.getChangedAt());
+                p.put("score", l.getNewIndex() != null ? l.getNewIndex() : l.getPreviousIndex());
+                p.put("reportId", l.getHealthReportId());
+                points.add(p);
+            }
+
+            // 计算变化方向:最新 score vs 上条 previousIndex
+            String changeDir = "flat";
+            if (foodLogs.size() >= 2) {
+                Integer prev = foodLogs.get(1).getPreviousIndex();
+                Integer cur = latest.getNewIndex();
+                if (prev != null && cur != null) {
+                    if (cur > prev) changeDir = "up";
+                    else if (cur < prev) changeDir = "down";
+                }
+            }
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("foodId", foodId);
+            item.put("foodName", latest.getFoodName());
+            item.put("currentScore", currentScore);
+            item.put("trend", points);
+            item.put("changeDir", changeDir);
+            result.add(item);
+        }
+        return result;
+    }
 }

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -28,7 +28,16 @@ public class HealthReportDraftService {
      * 创建草稿。返回带 id 的实体。
      */
     public HealthReportDraft createDraft(Long userId, Long familyId, String reportType,
-                                         String fileUrl, String originalFilename, String payloadJson) {
+                                          String fileUrl, String originalFilename, String payloadJson) {
+        return createDraft(userId, familyId, reportType, fileUrl, originalFilename, payloadJson, null);
+    }
+
+    /**
+     * 创建草稿(含文件哈希,用于重复上传检测)。
+     */
+    public HealthReportDraft createDraft(Long userId, Long familyId, String reportType,
+                                          String fileUrl, String originalFilename,
+                                          String payloadJson, String fileHash) {
         HealthReportDraft draft = new HealthReportDraft();
         draft.setUserId(userId);
         draft.setFamilyId(familyId);
@@ -36,13 +45,15 @@ public class HealthReportDraftService {
         draft.setFileUrl(fileUrl);
         draft.setOriginalFilename(originalFilename);
         draft.setPayloadJson(payloadJson);
+        draft.setFileHash(fileHash);
         draft.setStatus("pending");
         Date now = new Date();
         draft.setCreatedAt(now);
         draft.setUpdatedAt(now);
         draft.setExpireAt(new Date(now.getTime() + DRAFT_TTL_MS));
         draftMapper.insert(draft);
-        log.info("草稿已创建: draftId={}, userId={}, reportType={}", draft.getId(), userId, reportType);
+        log.info("草稿已创建: draftId={}, userId={}, reportType={}, fileHash={}",
+                draft.getId(), userId, reportType, fileHash);
         return draft;
     }
 

+ 26 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -190,17 +190,36 @@ public class HealthReportService {
     }
 
     /**
-     * 获取用户的所有健康报告(通过 subjectId 或 userId 关联)
+     * 按文件MD5查找已入库的报告ID(同一报告类型下),用于重复上传检测
      */
-    public List<HealthReport> getUserReports(Long userId) {
-        return healthReportMapper.selectList(
+    public Long findReportIdByFileHash(String fileHash, String reportType) {
+        if (fileHash == null || reportType == null) {
+            return null;
+        }
+        HealthReport report = healthReportMapper.selectOne(
                 new LambdaQueryWrapper<HealthReport>()
-                        .and(w -> w.eq(HealthReport::getSubjectId, userId)
-                                .or(w2 -> w2.isNull(HealthReport::getSubjectId)
-                                        .eq(HealthReport::getUserId, userId)))
+                        .eq(HealthReport::getFileHash, fileHash)
+                        .eq(HealthReport::getReportType, reportType)
                         .eq(HealthReport::getStatus, "active")
-                        .orderByDesc(HealthReport::getReportDate)
+                        .last("LIMIT 1")
+        );
+        return report != null ? report.getId() : null;
+    }
+
+    /**
+     * 按文件MD5查找正在采集中的草稿ID,用于避免重复提交采集
+     */
+    public Long findCollectingDraftIdByFileHash(String fileHash) {
+        if (fileHash == null) {
+            return null;
+        }
+        HealthReportDraft draft = draftMapper.selectOne(
+                new LambdaQueryWrapper<HealthReportDraft>()
+                        .eq(HealthReportDraft::getFileHash, fileHash)
+                        .eq(HealthReportDraft::getParseStatus, "collecting")
+                        .last("LIMIT 1")
         );
+        return draft != null ? draft.getId() : null;
     }
 
     /**

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -457,6 +457,7 @@ public class ReportCollectService {
         report.setSubjectId(subjectId);
         report.setReportType(draft.getReportType());
         report.setFileUrl(draft.getFileUrl());
+        report.setFileHash(draft.getFileHash());
 
         ParsedReportPayload.Summary s = payload.getSummary();
         if (s != null) {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java

@@ -129,8 +129,10 @@ public class ReportFingerprintService {
      */
     private String getScriptPath(String scriptName) {
         String[] searchPaths = {
+            "docs/scripts/parsers/" + scriptName,
             "docs/scripts/" + scriptName,
             "../docs/scripts/" + scriptName,
+            "/app/cfc/docs/scripts/parsers/" + scriptName,
             "/app/cfc/docs/scripts/" + scriptName,
             scriptName,
         };

+ 438 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportScriptGeneratorService.java

@@ -0,0 +1,438 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.entity.ReportUnknownCluster;
+import com.etotem.cfc.entity.ReportUnknownUpload;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class ReportScriptGeneratorService {
+
+    @Resource(name = "taskExecutor")
+    private Executor taskExecutor;
+
+    @Resource
+    private ReportUnknownUploadMapper unknownUploadMapper;
+
+    @Resource
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @Resource
+    private ReportUnknownClusterMapper unknownClusterMapper;
+
+    @Resource
+    private StorageService storageService;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
+    private String opencodeBase;
+
+    @Value("${report.collect.opencode-user:opencode}")
+    private String opencodeUser;
+
+    @Value("${report.collect.opencode-password:IwinTrue@123}")
+    private String opencodePassword;
+
+    @Value("${report.collect.opencode-workdir:/app/cfc}")
+    private String opencodeWorkdir;
+
+    @Value("${report.collect.opencode-model-id:agnes-2.5-flash}")
+    private String opencodeModelId;
+
+    @Value("${report.collect.opencode-model-provider:agnes-ai}")
+    private String opencodeModelProvider;
+
+    @Value("${python.executable:python3}")
+    private String pythonExecutable;
+
+    @Value("${report.parser.scripts.path:docs/scripts/parsers}")
+    private String scriptsPath;
+
+    private static final long SCRIPT_GEN_TIMEOUT_MS = 300_000L;
+    private static final long SCRIPT_TEST_TIMEOUT_MS = 60_000L;
+
+    /**
+     * 异步触发脚本生成。立即返回,结果通过更新 report_type_registry.script_status 通知前端。
+     * 防重入:若已在生成中则跳过。
+     */
+    public void triggerAsyncGenerate(Long clusterId, String typeId) {
+        ReportTypeRegistry type = typeRegistryMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportTypeRegistry>()
+                        .eq(ReportTypeRegistry::getTypeId, typeId));
+        if (type == null) {
+            log.warn("脚本生成跳过:类型不存在 typeId={}", typeId);
+            return;
+        }
+        if (ReportTypeRegistry.SCRIPT_STATUS_GENERATING.equals(type.getScriptStatus())) {
+            log.info("脚本生成已在进行中,跳过 typeId={}", typeId);
+            return;
+        }
+        taskExecutor.execute(() -> {
+            try {
+                generateAndTest(clusterId, typeId, type.getDisplayName());
+            } catch (Exception e) {
+                log.error("脚本生成异常 typeId={}", typeId, e);
+                setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            }
+        });
+    }
+
+    private void generateAndTest(Long clusterId, String typeId, String displayName) {
+        ReportUnknownCluster cluster = unknownClusterMapper.selectById(clusterId);
+        if (cluster == null) {
+            log.warn("脚本生成跳过:聚类不存在 clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        List<ReportUnknownUpload> uploads = unknownUploadMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ReportUnknownUpload>()
+                        .eq(ReportUnknownUpload::getClusterId, clusterId)
+                        .orderByDesc(ReportUnknownUpload::getUploadedAt)
+                        .last("LIMIT 1"));
+        if (uploads.isEmpty() || uploads.get(0).getFileUrl() == null) {
+            log.warn("脚本生成跳过:无样例 PDF clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        String samplePdfPath = resolveLocalPath(uploads.get(0).getFileUrl());
+        if (samplePdfPath == null) {
+            log.warn("脚本生成跳过:样例 PDF 本地路径不可达 clusterId={}", clusterId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_GENERATING);
+
+        String scriptContent;
+        try {
+            scriptContent = invokeOpencodeGenerateScript(typeId, displayName,
+                    cluster.getCommonSignatures(), samplePdfPath);
+        } catch (Exception e) {
+            log.error("opencode 写脚本失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        File dir = new File(scriptsPath);
+        if (!dir.exists() && !dir.mkdirs()) {
+            log.warn("无法创建脚本目录: {}", scriptsPath);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+        File scriptFile = new File(dir, typeId + ".py");
+        try {
+            java.nio.file.Files.write(scriptFile.toPath(),
+                    scriptContent.getBytes(StandardCharsets.UTF_8));
+            log.info("脚本已写入: {}", scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.error("写脚本文件失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+            return;
+        }
+
+        try {
+            String stdout = runScriptAndWait(samplePdfPath, scriptFile.getAbsolutePath());
+            validateJsonOutput(stdout, typeId);
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_READY);
+            log.info("脚本生成+测试通过 typeId={} scriptPath={}", typeId, scriptFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.warn("脚本独立测试失败 typeId={}: {}", typeId, e.getMessage());
+            setScriptStatus(typeId, ReportTypeRegistry.SCRIPT_STATUS_FAILED);
+        }
+    }
+
+    private String invokeOpencodeGenerateScript(String typeId, String displayName,
+                                                 String commonSignatures, String samplePdfPath)
+            throws Exception {
+        String sessionId = opencodeCreateSession("script-gen-" + typeId);
+        if (sessionId == null) {
+            throw new IllegalStateException("opencode 会话创建失败");
+        }
+
+        String prompt = buildGenerateScriptPrompt(typeId, displayName, commonSignatures, samplePdfPath);
+        sendPrompt(sessionId, prompt);
+
+        long deadline = System.currentTimeMillis() + SCRIPT_GEN_TIMEOUT_MS;
+        int before = countMessages(sessionId);
+        while (System.currentTimeMillis() < deadline) {
+            try {
+                Thread.sleep(5000);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+            List<Map<String, Object>> msgs = listMessages(sessionId);
+            if (msgs == null || msgs.size() <= before) {
+                continue;
+            }
+            String reply = findLastAssistantText(msgs);
+            if (reply != null && !reply.trim().isEmpty()) {
+                log.info("opencode 回复(前200字符): {}", reply.substring(0, Math.min(200, reply.length())));
+                String code = extractPythonCode(reply);
+                if (code == null || code.trim().isEmpty()) {
+                    throw new IllegalStateException("opencode 回复中未找到 Python 代码块");
+                }
+                return code;
+            }
+        }
+        throw new IllegalStateException("opencode 写脚本超时(" + SCRIPT_GEN_TIMEOUT_MS / 1000 + "s)");
+    }
+
+    private String buildGenerateScriptPrompt(String typeId, String displayName,
+                                              String commonSignatures, String samplePdfPath) {
+        String desc = (commonSignatures != null && !commonSignatures.isEmpty())
+                ? "公共文本特征:\n" + commonSignatures : "无额外描述";
+        return "你是报告解析脚本编写专家。\n\n"
+                + "任务:为报告类型 '" + typeId + "'(" + (displayName != null ? displayName : "未命名") + ")编写 Python 采集脚本。\n\n"
+                + desc + "\n\n"
+                + "样例PDF路径(本地):" + samplePdfPath + "\n"
+                + "目标脚本路径:/app/cfc/" + scriptsPath + "/" + typeId + ".py\n\n"
+                + "要求:\n"
+                + "1. 先用 pdftotext 或 pdfplumber 读取样例PDF,分析报告结构和字段位置。\n"
+                + "2. 编写 Python 脚本(纯算法,不用 LLM/外部 API),用正则 + pdfplumber/pdfminer 提取结构化数据。\n"
+                + "3. 脚本调用约定:python3 " + typeId + ".py <pdf_path>\n"
+                + "4. stdout 必须输出严格 JSON,结构如下:\n"
+                + "{\n"
+                + "  \"summary\": {\"overallScore\": 0, \"gutHealthScore\": 0, \"reportNumber\": \"\", \"reportDate\": \"\", \"personName\": \"\", \"gender\": \"\", \"age\": 0},\n"
+                + "  \"indicators\": [{\"category\": \"\", \"indicatorName\": \"\", \"indicatorValue\": \"\", \"unit\": \"\", \"refRange\": \"\", \"status\": \"\"}],\n"
+                + "  \"gutFlora\": [{\"bacteriaName\": \"\", \"bacteriaValue\": \"\", \"normalRange\": \"\", \"populationLevel\": \"\", \"detectionRate\": \"\", \"description\": \"\", \"category\": \"\", \"level\": \"\"}],\n"
+                + "  \"foods\": [{\"name\": \"\", \"category\": \"\", \"score\": 0}],\n"
+                + "  \"diseaseRisks\": [{\"diseaseName\": \"\", \"riskValue\": \"\", \"riskLevel\": \"\"}]\n"
+                + "}\n"
+                + "5. 脚本末尾用 try/except 包裹,确保异常时退出码非零。\n"
+                + "6. 写完脚本后,用样例PDF跑一遍(python3 " + typeId + ".py " + samplePdfPath + "),打印 stdout,确认是合法 JSON。\n"
+                + "7. 最后报告脚本路径和测试输出(前500字符)。\n\n"
+                + "注意:只做以上任务。完成后输出完整的 Python 脚本代码(用 ```python 包裹),以及测试结果。";
+    }
+
+    private String runScriptAndWait(String pdfPath, String scriptPath) throws Exception {
+        ProcessBuilder pb = new ProcessBuilder(pythonExecutable, scriptPath, pdfPath);
+        pb.redirectErrorStream(true);
+        Process proc = pb.start();
+        StringBuilder sb = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append('\n');
+            }
+        }
+        boolean finished = proc.waitFor(SCRIPT_TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+        if (!finished) {
+            proc.destroyForcibly();
+            throw new IllegalStateException("脚本测试超时(" + SCRIPT_TEST_TIMEOUT_MS / 1000 + "s)");
+        }
+        if (proc.exitValue() != 0) {
+            throw new IllegalStateException("脚本测试退出码=" + proc.exitValue()
+                    + "\nstdout: " + sb.toString().trim());
+        }
+        String stdout = sb.toString().trim();
+        if (stdout.isEmpty()) {
+            throw new IllegalStateException("脚本测试 stdout 为空");
+        }
+        return stdout;
+    }
+
+    private void validateJsonOutput(String stdout, String typeId) throws Exception {
+        String json = extractJson(stdout);
+        if (json == null) {
+            throw new IllegalStateException("脚本 stdout 中未找到 JSON\n"
+                    + stdout.substring(0, Math.min(500, stdout.length())));
+        }
+        objectMapper.readTree(json);
+        log.info("脚本测试 JSON 校验通过 typeId={} jsonLen={}", typeId, json.length());
+    }
+
+    private String extractJson(String text) {
+        if (text == null) {
+            return null;
+        }
+        int start = text.indexOf('{');
+        int end = text.lastIndexOf('}');
+        return (start >= 0 && end > start) ? text.substring(start, end + 1) : null;
+    }
+
+    private String opencodeCreateSession(String title) {
+        try {
+            String url = opencodeBase + "/session?directory="
+                    + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("title", title);
+            body.put("agent", "build");
+            Map<String, Object> model = new LinkedHashMap<>();
+            model.put("id", opencodeModelId);
+            model.put("providerID", opencodeModelProvider);
+            body.put("model", model);
+            Map<String, Object> tools = new LinkedHashMap<>();
+            tools.put("bash", Boolean.TRUE);
+            tools.put("write", Boolean.TRUE);
+            tools.put("edit", Boolean.TRUE);
+            for (String t : new String[]{"apply_patch", "task", "dispatch",
+                    "webfetch_post", "chrome_launch"}) {
+                tools.put(t, Boolean.FALSE);
+            }
+            body.put("tools", tools);
+            ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST,
+                    new HttpEntity<>(body, opencodeHeaders()), Map.class);
+            Map<?, ?> data = resp.getBody();
+            if (data == null || data.get("id") == null) {
+                log.warn("opencode 创建会话未返回 session_id");
+                return null;
+            }
+            return data.get("id").toString();
+        } catch (Exception e) {
+            log.error("opencode 创建会话失败 title={}: {}", title, e.getMessage());
+            return null;
+        }
+    }
+
+    private void sendPrompt(String sessionId, String prompt) {
+        List<Map<String, Object>> parts = new ArrayList<>();
+        Map<String, Object> part = new LinkedHashMap<>();
+        part.put("type", "text");
+        part.put("text", prompt);
+        parts.add(part);
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("parts", parts);
+        restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
+                HttpMethod.POST, new HttpEntity<>(body, opencodeHeaders()), Void.class);
+        log.info("opencode prompt 已发送 session={}", sessionId);
+    }
+
+    private List<Map<String, Object>> listMessages(String sessionId) {
+        try {
+            ResponseEntity<List> resp = restTemplate.exchange(
+                    opencodeBase + "/session/" + sessionId + "/message",
+                    HttpMethod.GET, new HttpEntity<>(opencodeHeaders()), List.class);
+            return resp.getBody();
+        } catch (Exception e) {
+            log.warn("opencode 查询消息失败 session={}: {}", sessionId, e.getMessage());
+            return null;
+        }
+    }
+
+    private int countMessages(String sessionId) {
+        List<Map<String, Object>> msgs = listMessages(sessionId);
+        return msgs == null ? 0 : msgs.size();
+    }
+
+    private HttpHeaders opencodeHeaders() {
+        String token = Base64.getEncoder().encodeToString(
+                (opencodeUser + ":" + opencodePassword).getBytes(StandardCharsets.UTF_8));
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Basic " + token);
+        return headers;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String findLastAssistantText(List<Map<String, Object>> msgs) {
+        if (msgs == null || msgs.isEmpty()) {
+            return null;
+        }
+        for (int i = msgs.size() - 1; i >= 0; i--) {
+            Map<String, Object> msg = msgs.get(i);
+            Object infoObj = msg.get("info");
+            if (!(infoObj instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> info = (Map<String, Object>) infoObj;
+            if (!"assistant".equals(info.get("role"))) {
+                continue;
+            }
+            Object partsObj = msg.get("parts");
+            if (!(partsObj instanceof List)) {
+                continue;
+            }
+            for (Object pObj : (List<Object>) partsObj) {
+                if (!(pObj instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> p = (Map<String, Object>) pObj;
+                if ("text".equals(p.get("type")) && p.get("text") != null) {
+                    String text = p.get("text").toString().trim();
+                    if (!text.isEmpty()) {
+                        return text;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private String extractPythonCode(String reply) {
+        if (reply == null) {
+            return null;
+        }
+        int pyStart = reply.indexOf("```python");
+        if (pyStart >= 0) {
+            int codeStart = reply.indexOf('\n', pyStart);
+            int end = reply.lastIndexOf("```");
+            if (end > codeStart) {
+                return reply.substring(codeStart + 1, end).trim();
+            }
+        }
+        int start = reply.indexOf("```");
+        if (start >= 0) {
+            int codeStart = reply.indexOf('\n', start);
+            int end = reply.lastIndexOf("```");
+            if (end > codeStart) {
+                return reply.substring(codeStart + 1, end).trim();
+            }
+        }
+        return reply.trim();
+    }
+
+    private String resolveLocalPath(String fileUrl) {
+        if (fileUrl == null) {
+            return null;
+        }
+        try {
+            String p = storageService.getFilePath(fileUrl);
+            File f = new File(p);
+            return f.exists() ? f.getAbsolutePath() : null;
+        } catch (Exception e) {
+            log.warn("解析文件路径失败 fileUrl={}: {}", fileUrl, e.getMessage());
+            return null;
+        }
+    }
+
+    private void setScriptStatus(String typeId, String scriptStatus) {
+        try {
+            com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ReportTypeRegistry> uw =
+                    new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<>();
+            uw.eq(ReportTypeRegistry::getTypeId, typeId)
+                    .set(ReportTypeRegistry::getScriptStatus, scriptStatus)
+                    .set(ReportTypeRegistry::getUpdatedAt, new Date());
+            typeRegistryMapper.update(null, uw);
+            log.info("脚本状态已更新 typeId={} status={}", typeId, scriptStatus);
+        } catch (Exception e) {
+            log.error("更新脚本状态失败 typeId={}: {}", typeId, e.getMessage());
+        }
+    }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java

@@ -54,6 +54,9 @@ public class TianpanService {
     @Resource
     private FamilyMemberService familyMemberService;
 
+    @Resource
+    private FamilyMemberDimensionBaseScoreService familyMemberDimensionBaseScoreService;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -223,6 +226,23 @@ public class TianpanService {
             }
             vo.setEightCharacters(eightCharacters);
 
+            // 生肖中文名
+            vo.setZodiacName(findZodiacName(m.getZodiac()));
+
+            // 五维基础分:优先读库,无则计算并落库
+            Map<String, Integer> cachedScores = familyMemberDimensionBaseScoreService.getScores(m.getMemberId());
+            boolean hasScores = cachedScores.values().stream().anyMatch(s -> s != 50);
+            if (!hasScores) {
+                familyMemberDimensionBaseScoreService.calcAndSave(
+                        m.getMemberId(), m.getMemberType(),
+                        m.getWuxingElements(),
+                        vo.getWesternSign(),
+                        m.getLifeNumber(),
+                        "system");
+                cachedScores = familyMemberDimensionBaseScoreService.getScores(m.getMemberId());
+            }
+            vo.setDimensionBaseScores(cachedScores);
+
             result.add(vo);
         }
 
@@ -786,4 +806,14 @@ public class TianpanService {
     private String getDayElementDetail(Date date) {
         return "甲木参天";
     }
+
+    private String findZodiacName(String zodiacCode) {
+        if (zodiacCode == null) return null;
+        Map<String, String> nameMap = new LinkedHashMap<>();
+        nameMap.put("rat", "鼠"); nameMap.put("ox", "牛"); nameMap.put("tiger", "虎");
+        nameMap.put("rabbit", "兔"); nameMap.put("dragon", "龙"); nameMap.put("snake", "蛇");
+        nameMap.put("horse", "马"); nameMap.put("sheep", "羊"); nameMap.put("monkey", "猴");
+        nameMap.put("rooster", "鸡"); nameMap.put("dog", "狗"); nameMap.put("pig", "猪");
+        return nameMap.get(zodiacCode);
+    }
 }

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

@@ -1774,6 +1774,7 @@ CREATE TABLE IF NOT EXISTS health_reports (
     gut_age VARCHAR(32) COMMENT '肠道预测年龄',
     gut_type VARCHAR(50) COMMENT '肠型',
     file_url VARCHAR(500) COMMENT '报告文件URL',
+    file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测',
     report_format VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image',
     status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived',
     remark TEXT COMMENT '备注',
@@ -1826,6 +1827,7 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     family_id BIGINT COMMENT '家庭ID',
     report_type VARCHAR(32) NOT NULL COMMENT '报告类型: physical_exam/gut_flora',
     file_url VARCHAR(500) COMMENT '上传的文件URL',
+    file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测',
     original_filename VARCHAR(255) COMMENT '原始文件名',
     payload_json MEDIUMTEXT NOT NULL COMMENT '草稿载荷JSON(含解析/手动录入的所有指标)',
     status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/confirmed/discarded',
@@ -4675,6 +4677,7 @@ CREATE TABLE IF NOT EXISTS report_type_registry (
     review_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected',
     version VARCHAR(20) DEFAULT '1.0' COMMENT '版本',
     created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
+    script_status VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_type_id (type_id)
@@ -5447,3 +5450,16 @@ CREATE TABLE IF NOT EXISTS cf_transfer_record (
     INDEX idx_family (family_id),
     UNIQUE KEY uk_ref_type_id (ref_type, ref_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录';
+
+-- 五维基础能量分表(迁移305)
+CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent',
+    dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth',
+    base_score INT NOT NULL COMMENT '基础能量分(0-100)',
+    last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间',
+    calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源(system/manual)',
+    UNIQUE KEY uk_member_dimension (member_id, dimension_code),
+    INDEX idx_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分(落库,支持重算)';

+ 130 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyMemberDimensionBaseScoreServiceTest.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.service.FamilyMemberDimensionBaseScoreService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@ExtendWith(MockitoExtension.class)
+class FamilyMemberDimensionBaseScoreServiceTest {
+
+    @InjectMocks
+    private FamilyMemberDimensionBaseScoreService service;
+
+    private Map<String, Integer> wuxing(int wood, int fire, int earth, int metal, int water) {
+        Map<String, Integer> m = new HashMap<>();
+        m.put("wood", wood);
+        m.put("fire", fire);
+        m.put("earth", earth);
+        m.put("metal", metal);
+        m.put("water", water);
+        return m;
+    }
+
+    @Test
+    void calcBaseScores_fullData_returnsCorrectScores() {
+        // 八字五行 {wood:40, fire:60, earth:50, metal:30, water:20}
+        // 星座狮子座(火象→心)
+        // 灵数7(→智)
+        Map<String, Integer> result = service.calcBaseScores(wuxing(40, 60, 50, 30, 20), "狮子座", 7);
+
+        assertEquals(30, result.get("body"));      // 50×0.6=30
+        assertEquals(56, result.get("mind"));      // 60×0.6+20=56
+        assertEquals(38, result.get("wisdom"));    // 30×0.6+20=38
+        assertEquals(24, result.get("action"));    // 40×0.6=24
+        assertEquals(12, result.get("wealth"));    // 20×0.6=12
+    }
+
+    @Test
+    void calcBaseScores_nullWuxing_returnsNeutral50() {
+        Map<String, Integer> result = service.calcBaseScores(null, null, null);
+
+        assertEquals(50, result.get("body"));
+        assertEquals(50, result.get("mind"));
+        assertEquals(50, result.get("wisdom"));
+        assertEquals(50, result.get("action"));
+        assertEquals(50, result.get("wealth"));
+    }
+
+    @Test
+    void calcBaseScores_masterNumber_11MapsTo2() {
+        // 灵数11→化简为2→映射到心(mind),星座null
+        Map<String, Integer> result = service.calcBaseScores(wuxing(50, 50, 50, 50, 50), null, 11);
+
+        // fire=50, 50×0.6=30, 灵数11→2→心(mind) → 30+20=50
+        assertEquals(50, result.get("mind"));
+    }
+
+    @Test
+    void calcBaseScores_masterNumber_22MapsTo4() {
+        Map<String, Integer> result = service.calcBaseScores(wuxing(50, 50, 50, 50, 50), null, 22);
+        // 22→4→身(body=earth)
+        // earth=50, 50×0.6=30, 30+20=50
+        assertEquals(50, result.get("body"));
+    }
+
+    @Test
+    void calcBaseScores_allZodiacSigns_coverage() {
+        // 使用非均匀五行值,使命中维度 > 中性线
+        Map<String, Integer> w = wuxing(60, 80, 60, 60, 60);
+        // 各维度基础分: action=36, mind=48, body=36, wisdom=36, wealth=36
+
+        // 火象 → 心(mind): 48+20=68 > 50 ✓
+        Map<String, Integer> fireResult = service.calcBaseScores(w, "白羊座", null);
+        assertTrue(fireResult.get("mind") > 50);
+
+        // 土象 → 身(body): 36+20=56 > 50 ✓
+        Map<String, Integer> earthResult = service.calcBaseScores(w, "金牛座", null);
+        assertTrue(earthResult.get("body") > 50);
+
+        // 风象 → 行(action): 36+20=56 > 50 ✓
+        Map<String, Integer> airResult = service.calcBaseScores(w, "双子座", null);
+        assertTrue(airResult.get("action") > 50);
+
+        // 水象 → 富(wealth): 36+20=56 > 50 ✓
+        Map<String, Integer> waterResult = service.calcBaseScores(w, "巨蟹座", null);
+        assertTrue(waterResult.get("wealth") > 50);
+    }
+
+    @Test
+    void calcBaseScores_all12ZodiacSigns() {
+        Map<String, Integer> w = wuxing(60, 80, 60, 60, 60);
+
+        // 火象 3个
+        service.calcBaseScores(w, "狮子座", null);
+        service.calcBaseScores(w, "射手座", null);
+
+        // 土象 3个
+        service.calcBaseScores(w, "处女座", null);
+        service.calcBaseScores(w, "摩羯座", null);
+
+        // 风象 3个
+        service.calcBaseScores(w, "天秤座", null);
+        service.calcBaseScores(w, "水瓶座", null);
+
+        // 水象 3个
+        service.calcBaseScores(w, "天蝎座", null);
+        service.calcBaseScores(w, "双鱼座", null);
+
+        // 所有12个星座不应抛异常;白羊座(火→心): 48+20=68
+        Map<String, Integer> result = service.calcBaseScores(w, "白羊座", null);
+        assertEquals(68, result.get("mind"));
+    }
+
+    @Test
+    void calcBaseScores_unknownZodiacNoBonus() {
+        // wuxing全null时,各维度默认50;未知星座不应加分
+        Map<String, Integer> result = service.calcBaseScores(null, "未知星座", null);
+        assertEquals(50, result.get("body"));
+        assertEquals(50, result.get("mind"));
+        assertEquals(50, result.get("wisdom"));
+        assertEquals(50, result.get("action"));
+        assertEquals(50, result.get("wealth"));
+    }
+}

+ 42 - 1
cfc-frontend/pages.json

@@ -337,6 +337,41 @@
         }
       ]
     },
+    {
+      "root": "pages/cognitive-test",
+      "pages": [
+        {
+          "path": "logic-test",
+          "style": {
+            "navigationBarTitleText": "逻辑推理测试"
+          }
+        },
+        {
+          "path": "perception-test",
+          "style": {
+            "navigationBarTitleText": "感知觉测试"
+          }
+        },
+        {
+          "path": "memory-test",
+          "style": {
+            "navigationBarTitleText": "记忆力测试"
+          }
+        },
+        {
+          "path": "spatial-test",
+          "style": {
+            "navigationBarTitleText": "空间思维测试"
+          }
+        },
+        {
+          "path": "speed-test",
+          "style": {
+            "navigationBarTitleText": "加工速度测试"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/assessment",
       "pages": [
@@ -1296,7 +1331,7 @@
         {
           "path": "index",
           "style": {
-            "navigationBarTitleText": "知识中心",
+            "navigationBarTitleText": "知识分享",
             "navigationStyle": "custom"
           }
         },
@@ -1365,6 +1400,12 @@
             "navigationBarTitleText": "相关事项",
             "navigationStyle": "custom"
           }
+        },
+        {
+          "path": "traditional",
+          "style": {
+            "navigationBarTitleText": "家庭传统命理"
+          }
         }
       ]
     },

+ 12 - 2
cfc-frontend/pages/article-center/index.vue

@@ -5,7 +5,7 @@
       <view class="ac-nav-left" @click="onBack">
         <text class="ac-nav-back">&#x2190;</text>
       </view>
-      <text class="ac-nav-title">知识中心</text>
+      <text class="ac-nav-title">知识分享</text>
       <view class="ac-nav-right">
         <text class="ac-nav-search-icon" @click="focusSearch">&#x1F50D;</text>
         <text class="ac-nav-add" @click="goEdit">+</text>
@@ -109,6 +109,8 @@
               <text class="ac-card-fav">&#x2B50; {{ item.favCount || 0 }}</text>
               <text class="ac-card-sep">|</text>
               <text class="ac-card-readcount">&#x1F441; {{ item.viewCount || 0 }}</text>
+              <text class="ac-card-sep">|</text>
+              <text class="ac-card-duration">&#x23F1; {{ formatDuration(item.totalDurationSeconds) }}</text>
             </view>
           </view>
         </view>
@@ -267,6 +269,14 @@ export default {
       if (!dateStr) return ''
       return dateStr.slice(0, 10)
     },
+    formatDuration(seconds) {
+      if (!seconds || seconds <= 0) return '0分钟'
+      var mins = Math.round(seconds / 60)
+      if (mins < 60) return mins + '分钟'
+      var hours = Math.floor(mins / 60)
+      var restMins = mins % 60
+      return restMins > 0 ? hours + '小时' + restMins + '分钟' : hours + '小时'
+    },
     parseDimensions(str) {
       if (!str) return []
       var dimMap = {
@@ -305,7 +315,7 @@ export default {
 
 <style scoped>
 /* ====================================
-   知识中心 — 重设计
+   知识分享 — 重设计
    白色导航 + 药丸Tab + 精装卡片
    ==================================== */
 

+ 513 - 0
cfc-frontend/pages/cognitive-test/logic-test.vue

@@ -0,0 +1,513 @@
+<template>
+  <view class="container">
+    <!-- 引导页 -->
+    <view class="step-intro" v-if="step === 'intro'">
+      <view class="intro-icon">
+        <text class="intro-icon-text">🧠</text>
+      </view>
+      <text class="intro-title">逻辑推理测试</text>
+      <text class="intro-desc">观察数列规律,从选项中选出缺失的数字</text>
+      <view class="intro-info">
+        <view class="info-row">
+          <text class="info-label">题目数量</text>
+          <text class="info-value">8 题</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">每题限时</text>
+          <text class="info-value">15 秒</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">测评维度</text>
+          <text class="info-value">逻辑思维</text>
+        </view>
+      </view>
+      <view class="btn btn--primary" @tap="startTest">开始测试</view>
+    </view>
+
+    <!-- 答题页 -->
+    <view class="step-playing" v-if="step === 'playing'">
+      <view class="quiz-header">
+        <text class="quiz-progress">第 {{ currentQ + 1 }}/{{ questions.length }} 题</text>
+        <text class="quiz-timer">{{ timerSeconds }}s</text>
+      </view>
+      <view class="timer-track">
+        <view class="timer-fill" :style="'width:' + timerPct + '%'"></view>
+      </view>
+      <view class="question-card" v-if="currentQuestion">
+        <text class="q-text">{{ currentQuestion.seq }}</text>
+        <view class="q-options">
+          <view
+            class="q-option"
+            v-for="(opt, oi) in currentQuestion.options"
+            :key="oi"
+            :class="{ 'q-option--selected': selectedIndex === oi }"
+            @tap="selectOption(oi)"
+          >
+            <text class="q-option-text">{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 结果页 -->
+    <view class="step-result" v-if="step === 'result'">
+      <view class="score-circle">
+        <text class="score-value">{{ score }}</text>
+      </view>
+      <text class="score-label">{{ scoreText }}</text>
+      <view class="result-info">
+        <view class="result-row">
+          <text class="result-label">答对题数</text>
+          <text class="result-value">{{ correctCount }}/{{ questions.length }}</text>
+        </view>
+        <view class="result-row">
+          <text class="result-label">测评维度</text>
+          <view class="dim-tag">
+            <text class="dim-tag-text">逻辑思维</text>
+          </view>
+        </view>
+        <view class="result-row">
+          <text class="result-label">得分</text>
+          <text class="result-value">{{ score }} 分</text>
+        </view>
+      </view>
+      <view class="btn btn--primary" @tap="submitScore">提交成绩</view>
+      <view class="btn btn--outline" @tap="restartTest">再测一次</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+var TIMER_SECONDS = 15
+var ROUND_COUNT = 8
+
+var QUESTION_BANK = [
+  { seq: '2, 4, 6, 8, ?', options: ['9', '10', '11', '12'], answer: '10' },
+  { seq: '21, 18, 15, 12, ?', options: ['10', '9', '8', '7'], answer: '9' },
+  { seq: '1, 2, 4, 8, ?', options: ['12', '14', '16', '20'], answer: '16' },
+  { seq: '1, 3, 9, 27, ?', options: ['54', '72', '81', '90'], answer: '81' },
+  { seq: '1, 1, 2, 3, 5, 8, ?', options: ['10', '12', '13', '15'], answer: '13' },
+  { seq: '2, 3, 5, 8, 13, ?', options: ['18', '20', '21', '24'], answer: '21' },
+  { seq: '1, 4, 9, 16, 25, ?', options: ['30', '32', '36', '49'], answer: '36' },
+  { seq: '1, 8, 27, 64, ?', options: ['100', '121', '125', '144'], answer: '125' },
+  { seq: '5, 7, 6, 8, 7, ?', options: ['8', '9', '10', '11'], answer: '9' },
+  { seq: '2, 5, 6, 9, 10, ?', options: ['11', '12', '13', '14'], answer: '13' },
+  { seq: '1, 3, 5, 7, 9, ?', options: ['10', '11', '12', '13'], answer: '11' },
+  { seq: '2, 5, 11, 23, ?', options: ['35', '41', '46', '47'], answer: '47' },
+  { seq: '1, 3, 7, 13, 21, ?', options: ['27', '29', '31', '33'], answer: '31' },
+  { seq: '3, 8, 6, 11, 9, ?', options: ['12', '13', '14', '15'], answer: '14' },
+  { seq: '2, 3, 5, 7, 11, ?', options: ['12', '13', '15', '17'], answer: '13' },
+  { seq: '64, 32, 16, 8, ?', options: ['2', '4', '6', '8'], answer: '4' },
+  { seq: '4, 7, 10, 13, 16, ?', options: ['17', '18', '19', '21'], answer: '19' },
+  { seq: '50, 45, 40, 35, ?', options: ['25', '28', '30', '32'], answer: '30' },
+  { seq: '1, 5, 9, 13, 17, ?', options: ['19', '20', '21', '23'], answer: '21' },
+  { seq: '3, 5, 9, 17, 33, ?', options: ['49', '57', '63', '65'], answer: '65' },
+  { seq: '4, 8, 7, 14, 13, ?', options: ['20', '24', '25', '26'], answer: '26' },
+  { seq: '1, 3, 6, 10, 15, ?', options: ['18', '20', '21', '24'], answer: '21' },
+  { seq: '30, 28, 24, 18, 10, ?', options: ['0', '2', '4', '6'], answer: '0' },
+  { seq: '7, 14, 28, 56, ?', options: ['84', '96', '108', '112'], answer: '112' }
+]
+
+export default {
+  data() {
+    return {
+      step: 'intro',
+      questions: [],
+      currentQ: 0,
+      selectedIndex: -1,
+      correctCount: 0,
+      timeLeft: TIMER_SECONDS,
+      timer: null,
+      answered: false,
+      score: 0,
+      memberId: ''
+    }
+  },
+  computed: {
+    currentQuestion: function() {
+      if (this.questions.length === 0) {
+        return null
+      }
+      return this.questions[this.currentQ]
+    },
+    timerSeconds: function() {
+      return Math.ceil(this.timeLeft)
+    },
+    timerPct: function() {
+      var pct = (this.timeLeft / TIMER_SECONDS) * 100
+      if (pct < 0) {
+        pct = 0
+      }
+      return pct
+    },
+    scoreText: function() {
+      var s = this.score
+      if (s >= 90) return '太棒了,逻辑满分!'
+      if (s >= 70) return '逻辑思维很出色!'
+      if (s >= 50) return '表现不错,继续加油!'
+      return '多练习数列规律会更好!'
+    }
+  },
+  onLoad(options) {
+    if (options && options.memberId) {
+      this.memberId = options.memberId
+    }
+    this.memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+  },
+  onUnload() {
+    this.stopTimer()
+  },
+  methods: {
+    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
+    },
+    startTest: function() {
+      var shuffled = this.shuffle(QUESTION_BANK)
+      this.questions = shuffled.slice(0, ROUND_COUNT)
+      this.currentQ = 0
+      this.correctCount = 0
+      this.score = 0
+      this.answered = false
+      this.selectedIndex = -1
+      this.step = 'playing'
+      this.startTimer()
+    },
+    startTimer: function() {
+      this.stopTimer()
+      this.timeLeft = TIMER_SECONDS
+      var self = this
+      this.timer = setInterval(function() {
+        self.timeLeft -= 0.1
+        if (self.timeLeft <= 0.01) {
+          self.timeLeft = 0
+          self.onTimeout()
+        }
+      }, 100)
+    },
+    stopTimer: function() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    selectOption: function(idx) {
+      if (this.answered) return
+      this.answered = true
+      this.selectedIndex = idx
+      this.stopTimer()
+      var q = this.currentQuestion
+      if (q && q.options[idx] === q.answer) {
+        this.correctCount++
+      }
+      var self = this
+      setTimeout(function() {
+        self.advance()
+      }, 400)
+    },
+    onTimeout: function() {
+      if (this.answered) return
+      this.answered = true
+      this.stopTimer()
+      this.selectedIndex = -1
+      var self = this
+      setTimeout(function() {
+        self.advance()
+      }, 400)
+    },
+    advance: function() {
+      if (this.currentQ < this.questions.length - 1) {
+        this.currentQ++
+        this.answered = false
+        this.selectedIndex = -1
+        this.startTimer()
+      } else {
+        this.finishTest()
+      }
+    },
+    finishTest: function() {
+      this.stopTimer()
+      this.score = Math.round((this.correctCount / ROUND_COUNT) * 100)
+      this.step = 'result'
+    },
+    restartTest: function() {
+      this.stopTimer()
+      this.startTest()
+    },
+    submitScore: function() {
+      if (!this.memberId) {
+        uni.showToast({ title: '未获取到成员信息', icon: 'none' })
+        return
+      }
+      submitCognitiveSelfTest(this.memberId, 'logicScore', this.score)
+        .then((res) => {
+          if (res && res.code === 200) {
+            uni.showToast({ title: '提交成功', icon: 'success' })
+            var self = this
+            setTimeout(function() {
+              uni.navigateBack()
+            }, 800)
+          } else {
+            uni.showToast({ title: '提交失败', icon: 'none' })
+          }
+        })
+        .catch(function() {
+          uni.showToast({ title: '提交失败', icon: 'none' })
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+  box-sizing: border-box;
+}
+
+/* ===== 引导页 ===== */
+.step-intro {
+  padding-top: 80rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.intro-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(255, 215, 0, 0.15);
+  border: 3rpx solid #FFD700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+.intro-icon-text {
+  font-size: 72rpx;
+}
+.intro-title {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #FFD700;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.intro-desc {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+  margin-bottom: 50rpx;
+  display: block;
+}
+.intro-info {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 20rpx 30rpx;
+  margin-bottom: 60rpx;
+  box-sizing: border-box;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.info-row + .info-row {
+  border-top: 1rpx solid rgba(255, 255, 255, 0.08);
+}
+.info-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+}
+.info-value {
+  font-size: 26rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+
+/* ===== 答题页 ===== */
+.quiz-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.quiz-progress {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.quiz-timer {
+  font-size: 30rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+.timer-track {
+  height: 12rpx;
+  background: rgba(255, 255, 255, 0.12);
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 50rpx;
+}
+.timer-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #FFD700, #FFA500);
+  border-radius: 6rpx;
+  transition: width 0.1s linear;
+}
+.question-card {
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 40rpx 30rpx;
+  box-sizing: border-box;
+}
+.q-text {
+  font-size: 44rpx;
+  color: #fff;
+  font-weight: bold;
+  text-align: center;
+  display: block;
+  margin-bottom: 50rpx;
+  letter-spacing: 4rpx;
+}
+.q-options {
+  display: flex;
+  flex-direction: column;
+}
+.q-option {
+  padding: 26rpx 0;
+  border-radius: 16rpx;
+  border: 3rpx solid rgba(255, 255, 255, 0.2);
+  background: #24243e;
+  text-align: center;
+  margin-bottom: 20rpx;
+  transition: all 0.2s;
+}
+.q-option:last-child {
+  margin-bottom: 0;
+}
+.q-option--selected {
+  border-color: #FFD700;
+  background: rgba(255, 215, 0, 0.12);
+}
+.q-option:active {
+  transform: scale(0.98);
+}
+.q-option-text {
+  font-size: 34rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.q-option--selected .q-option-text {
+  color: #FFD700;
+}
+
+/* ===== 结果页 ===== */
+.step-result {
+  padding-top: 100rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-circle {
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 40rpx rgba(255, 215, 0, 0.35);
+  margin-bottom: 30rpx;
+}
+.score-value {
+  font-size: 80rpx;
+  font-weight: bold;
+  color: #1a1a2e;
+}
+.score-label {
+  font-size: 30rpx;
+  color: #FFD700;
+  margin-bottom: 50rpx;
+  display: block;
+}
+.result-info {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 20rpx 30rpx;
+  margin-bottom: 60rpx;
+  box-sizing: border-box;
+}
+.result-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.result-row + .result-row {
+  border-top: 1rpx solid rgba(255, 255, 255, 0.08);
+}
+.result-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+}
+.result-value {
+  font-size: 28rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.dim-tag {
+  padding: 6rpx 24rpx;
+  border-radius: 24rpx;
+  border: 3rpx solid #FFD700;
+  background: rgba(255, 215, 0, 0.12);
+}
+.dim-tag-text {
+  font-size: 24rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+
+/* ===== 按钮 ===== */
+.btn {
+  width: 100%;
+  padding: 26rpx 0;
+  border-radius: 50rpx;
+  text-align: center;
+  font-size: 30rpx;
+  font-weight: bold;
+  box-sizing: border-box;
+  margin-bottom: 24rpx;
+}
+.btn:last-child {
+  margin-bottom: 0;
+}
+.btn--primary {
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  color: #1a1a2e;
+}
+.btn--primary:active {
+  opacity: 0.9;
+  transform: scale(0.98);
+}
+.btn--outline {
+  border: 3rpx solid #FFD700;
+  color: #FFD700;
+  background: transparent;
+}
+.btn--outline:active {
+  background: rgba(255, 215, 0, 0.12);
+}
+</style>

+ 675 - 0
cfc-frontend/pages/cognitive-test/memory-test.vue

@@ -0,0 +1,675 @@
+<template>
+  <view class="page">
+
+    <!-- ============ 1. 说明页 ============ -->
+    <view class="intro" v-if="step === 'intro'">
+      <view class="intro-icon">
+        <text class="intro-icon-text">🧠</text>
+      </view>
+      <text class="intro-title">记忆力测试</text>
+      <text class="intro-desc">屏幕上会快速闪现一串数字,数字消失后,用下方数字键盘把它重新输入出来。每答对一关,数字就会增加一位,看看你能记住多少位!</text>
+      <view class="intro-rule">
+        <text class="rule-item">起始位数:3 位</text>
+        <text class="rule-item">最高位数:8 位</text>
+        <text class="rule-item">共 6 关</text>
+      </view>
+      <button class="primary-btn" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- ============ 2. 测试中 ============ -->
+    <view class="playing" v-if="step === 'playing'">
+      <view class="progress-header">
+        <text class="progress-level">第 {{ levelIndex }} 关</text>
+        <text class="progress-digit">{{ digitCount }} 位数</text>
+      </view>
+      <view class="progress-track">
+        <view class="progress-fill" :style="{ width: progressPct + '%' }"></view>
+      </view>
+
+      <!-- 展示阶段:数字闪现 + 计时条 -->
+      <view class="show-area" v-if="phase === 'show'">
+        <text class="show-hint">记住这串数字</text>
+        <view class="digit-row">
+          <view class="show-digit" v-for="(d, i) in showDigits" :key="getKey(d, i)">
+            <text>{{ d }}</text>
+          </view>
+        </view>
+        <view class="timer-bar-wrap">
+          <view class="timer-bar" :style="{ width: timerProgress * 100 + '%' }"></view>
+        </view>
+      </view>
+
+      <!-- 输入阶段:数字键盘 -->
+      <view class="input-area" v-if="phase === 'input'">
+        <text class="input-hint">请输入刚才的数字</text>
+        <view class="input-display">
+          <view
+            class="input-cell"
+            v-for="(c, i) in inputCells"
+            :key="getCellKey(i)"
+            :class="{ filled: i < userInput.length }"
+          ></view>
+        </view>
+        <view class="pad">
+          <view class="pad-row" v-for="(row, ri) in padRows" :key="getRowKey(ri)">
+            <view
+              class="pad-key"
+              :class="{ 'pad-key-ctrl': key.ctrl }"
+              v-for="(key, ci) in row"
+              :key="getPadKey(key, ri, ci)"
+              @click="onPadPress(key)"
+            >
+              <text>{{ key.v }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 反馈动画 -->
+      <view class="feedback feedback-correct" v-if="feedback === 'correct'">
+        <text class="feedback-text">回答正确</text>
+      </view>
+      <view class="feedback feedback-wrong" v-if="feedback === 'wrong'">
+        <text class="feedback-text">回答错误</text>
+      </view>
+    </view>
+
+    <!-- ============ 3. 结果页 ============ -->
+    <view class="result" v-if="step === 'result'">
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+        <text class="score-unit">分</text>
+      </view>
+      <view class="max-level">
+        <text class="max-level-label">最高记忆</text>
+        <text class="max-level-value">{{ maxLevel === 0 ? 3 : maxLevel }}位数</text>
+      </view>
+      <view class="dimension-tag">
+        <text>记忆力</text>
+      </view>
+      <button class="primary-btn submit-btn" :disabled="submitting" @click="submitScore">
+        {{ submitting ? '提交中...' : '提交成绩' }}
+      </button>
+      <button class="ghost-btn" @click="startTest">再测一次</button>
+    </view>
+
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      step: 'intro',          // intro | playing | result
+      phase: 'show',          // show | input
+      digitCount: 3,          // 当前关卡位数 3-8
+      currentNumber: '',      // 需要记忆的数字串
+      userInput: [],          // 用户已输入的数字
+      inputLocked: false,     // 反馈期间锁定键盘
+      feedback: '',           // '' | correct | wrong
+      showStart: 0,           // 数字展示开始时间戳
+      showDuration: 0,        // 数字展示时长(ms)
+      timerProgress: 1,       // 计时条剩余比例 0-1
+      showTimer: null,
+      correctTimer: null,
+      wrongTimer: null,
+      submitTimer: null,
+      maxLevel: 0,            // 成功记住的最高位数
+      score: 0,
+      submitting: false,
+      memberId: '',
+      padRows: [
+        [{ v: 1 }, { v: 2 }, { v: 3 }],
+        [{ v: 4 }, { v: 5 }, { v: 6 }],
+        [{ v: 7 }, { v: 8 }, { v: 9 }],
+        [{ v: '清除', ctrl: true }, { v: 0 }, { v: '确认', ctrl: true }]
+      ]
+    }
+  },
+  computed: {
+    levelIndex() {
+      return this.digitCount - 2
+    },
+    progressPct() {
+      return (this.levelIndex / 6) * 100
+    },
+    showDigits() {
+      return (this.currentNumber || '').split('')
+    },
+    inputCells() {
+      var arr = []
+      for (var i = 0; i < this.digitCount; i++) {
+        arr.push(i)
+      }
+      return arr
+    }
+  },
+  onLoad(options) {
+    var id = (options && options.memberId) || uni.getStorageSync('currentChildId') || ''
+    this.memberId = id
+  },
+  onUnload() {
+    this.clearAllTimers()
+  },
+  methods: {
+    // key 一律用方法调用,禁止模板内表达式
+    getKey(d, i) {
+      return 'digit-' + i
+    },
+    getCellKey(i) {
+      return 'cell-' + i
+    },
+    getRowKey(ri) {
+      return 'row-' + ri
+    },
+    getPadKey(key, ri, ci) {
+      return 'pad-' + ri + '-' + ci
+    },
+    clearAllTimers() {
+      if (this.showTimer) {
+        clearInterval(this.showTimer)
+        this.showTimer = null
+      }
+      if (this.correctTimer) {
+        clearTimeout(this.correctTimer)
+        this.correctTimer = null
+      }
+      if (this.wrongTimer) {
+        clearTimeout(this.wrongTimer)
+        this.wrongTimer = null
+      }
+      if (this.submitTimer) {
+        clearTimeout(this.submitTimer)
+        this.submitTimer = null
+      }
+    },
+    startTest() {
+      this.clearAllTimers()
+      this.digitCount = 3
+      this.maxLevel = 0
+      this.score = 0
+      this.userInput = []
+      this.inputLocked = false
+      this.feedback = ''
+      this.step = 'playing'
+      this.startRound()
+    },
+    startRound() {
+      this.phase = 'show'
+      this.userInput = []
+      this.inputLocked = false
+      this.feedback = ''
+      // 生成随机数字串
+      var num = ''
+      for (var i = 0; i < this.digitCount; i++) {
+        num += Math.floor(Math.random() * 10)
+      }
+      this.currentNumber = num
+      this.showStart = Date.now()
+      this.showDuration = Math.max(1.5, this.digitCount * 0.5) * 1000
+      this.timerProgress = 1
+      if (this.showTimer) {
+        clearInterval(this.showTimer)
+        this.showTimer = null
+      }
+      this.showTimer = setInterval(() => {
+        var remaining = this.showDuration - (Date.now() - this.showStart)
+        if (remaining <= 0) {
+          if (this.showTimer) {
+            clearInterval(this.showTimer)
+            this.showTimer = null
+          }
+          this.timerProgress = 0
+          this.phase = 'input'
+          return
+        }
+        this.timerProgress = remaining / this.showDuration
+      }, 50)
+    },
+    onPadPress(key) {
+      if (this.feedback || this.inputLocked) return
+      var v = key.v
+      if (typeof v === 'number') {
+        if (this.userInput.length < this.digitCount) {
+          this.userInput.push(v)
+        }
+      } else if (v === '清除') {
+        this.userInput.pop()
+      } else if (v === '确认') {
+        this.confirmInput()
+      }
+    },
+    confirmInput() {
+      if (this.userInput.length < this.digitCount) return
+      this.inputLocked = true
+      var entered = this.userInput.join('')
+      if (entered === this.currentNumber) {
+        this.feedback = 'correct'
+        this.correctTimer = setTimeout(() => {
+          this.correctTimer = null
+          this.feedback = ''
+          this.maxLevel = this.digitCount
+          if (this.digitCount >= 8) {
+            // 最高关卡已通过,测试结束
+            this.endGame()
+          } else {
+            this.digitCount++
+            this.startRound()
+          }
+        }, 700)
+      } else {
+        this.feedback = 'wrong'
+        this.wrongTimer = setTimeout(() => {
+          this.wrongTimer = null
+          this.feedback = ''
+          this.endGame()
+        }, 900)
+      }
+    },
+    endGame() {
+      this.clearAllTimers()
+      // 按最高成功记住的位数映射得分
+      var score = 100
+      if (this.maxLevel === 0) {
+        score = 40
+      } else if (this.maxLevel === 3) {
+        score = 55
+      } else if (this.maxLevel === 4) {
+        score = 70
+      } else if (this.maxLevel === 5) {
+        score = 82
+      } else if (this.maxLevel === 6) {
+        score = 92
+      }
+      this.score = score
+      this.step = 'result'
+    },
+    submitScore() {
+      if (this.submitting) return
+      var memberId = this.memberId
+      if (!memberId) {
+        uni.showToast({ title: '未找到测评成员', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      submitCognitiveSelfTest(memberId, 'memoryScore', this.score)
+        .then(() => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          this.submitTimer = setTimeout(() => {
+            var pages = getCurrentPages()
+            if (pages.length > 1) {
+              uni.navigateBack()
+            } else {
+              uni.reLaunch({ url: '/pages/index/index' })
+            }
+          }, 800)
+        })
+        .catch(() => {
+          // request() 已自动弹出失败提示,这里只需恢复按钮状态
+          this.submitting = false
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60rpx 40rpx 80rpx;
+  box-sizing: border-box;
+}
+
+/* ---------- 通用按钮 ---------- */
+.primary-btn {
+  margin-top: 60rpx;
+  width: 480rpx;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+  color: #ffffff;
+  font-size: 34rpx;
+  font-weight: 600;
+  border: none;
+  box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
+}
+.primary-btn::after {
+  border: none;
+}
+.primary-btn[disabled] {
+  opacity: 0.6;
+  color: #ffffff;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+}
+
+.ghost-btn {
+  margin-top: 30rpx;
+  width: 480rpx;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  background: transparent;
+  color: #a5b4fc;
+  font-size: 32rpx;
+  border: 2rpx solid rgba(99, 102, 241, 0.5);
+}
+.ghost-btn::after {
+  border: none;
+}
+
+/* ---------- 说明页 ---------- */
+.intro {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 80rpx;
+}
+.intro-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(99, 102, 241, 0.15);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+.intro-icon-text {
+  font-size: 80rpx;
+}
+.intro-title {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 30rpx;
+}
+.intro-desc {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.75);
+  line-height: 1.7;
+  text-align: center;
+  padding: 0 20rpx;
+}
+.intro-rule {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: center;
+  margin-top: 40rpx;
+}
+.rule-item {
+  font-size: 24rpx;
+  color: #a5b4fc;
+  background: rgba(99, 102, 241, 0.12);
+  border-radius: 24rpx;
+  padding: 10rpx 24rpx;
+  margin: 8rpx 12rpx;
+}
+
+/* ---------- 测试中 ---------- */
+.playing {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.progress-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 20rpx;
+}
+.progress-level {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.progress-digit {
+  font-size: 26rpx;
+  color: #a5b4fc;
+  margin-top: 8rpx;
+}
+.progress-track {
+  width: 480rpx;
+  height: 8rpx;
+  border-radius: 4rpx;
+  background: rgba(255, 255, 255, 0.1);
+  margin-top: 24rpx;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  border-radius: 4rpx;
+  background: linear-gradient(90deg, #6366f1, #818cf8);
+  transition: width 0.3s ease;
+}
+
+/* 展示阶段:数字闪现 */
+.show-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 120rpx;
+}
+.show-hint {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 60rpx;
+}
+.digit-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+}
+.show-digit {
+  width: 76rpx;
+  height: 140rpx;
+  margin: 0 4rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 16rpx;
+}
+.show-digit text {
+  font-size: 120rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.timer-bar-wrap {
+  width: 480rpx;
+  height: 12rpx;
+  border-radius: 6rpx;
+  background: rgba(255, 255, 255, 0.1);
+  overflow: hidden;
+  margin-top: 60rpx;
+}
+.timer-bar {
+  height: 100%;
+  border-radius: 6rpx;
+  background: linear-gradient(90deg, #6366f1, #818cf8);
+  transition: width 0.05s linear;
+}
+
+/* 输入阶段:数字键盘 */
+.input-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 60rpx;
+}
+.input-hint {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 50rpx;
+}
+.input-display {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  min-height: 100rpx;
+  margin-bottom: 40rpx;
+}
+.input-cell {
+  width: 76rpx;
+  height: 100rpx;
+  margin: 0 4rpx;
+  border-radius: 16rpx;
+  background: rgba(255, 255, 255, 0.04);
+  border: 2rpx solid rgba(255, 255, 255, 0.15);
+  box-sizing: border-box;
+}
+.input-cell.filled {
+  background: rgba(99, 102, 241, 0.5);
+  border-color: #6366f1;
+}
+.pad {
+  width: 100%;
+  max-width: 620rpx;
+}
+.pad-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.pad-key {
+  width: 180rpx;
+  height: 108rpx;
+  border-radius: 20rpx;
+  background: rgba(255, 255, 255, 0.06);
+  border: 2rpx solid rgba(255, 255, 255, 0.08);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+.pad-key text {
+  font-size: 44rpx;
+  color: #ffffff;
+  font-weight: 500;
+}
+.pad-key:active {
+  border-color: #6366f1;
+  background: rgba(99, 102, 241, 0.2);
+}
+.pad-key-ctrl {
+  background: rgba(255, 255, 255, 0.03);
+}
+.pad-key-ctrl text {
+  font-size: 32rpx;
+  color: #a5b4fc;
+}
+
+/* 反馈动画 */
+.feedback {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99;
+  animation: feedbackFade 0.7s ease both;
+}
+.feedback-correct {
+  background: rgba(16, 185, 129, 0.3);
+}
+.feedback-wrong {
+  background: rgba(239, 68, 68, 0.3);
+}
+.feedback-text {
+  font-size: 56rpx;
+  font-weight: bold;
+  color: #ffffff;
+  padding: 24rpx 60rpx;
+  border-radius: 24rpx;
+  background: rgba(0, 0, 0, 0.35);
+}
+@keyframes feedbackFade {
+  0% {
+    opacity: 0;
+  }
+  20% {
+    opacity: 1;
+  }
+  80% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+/* ---------- 结果页 ---------- */
+.result {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 100rpx;
+}
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 12rpx 40rpx rgba(99, 102, 241, 0.45);
+}
+.score-num {
+  font-size: 96rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1;
+}
+.score-unit {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 8rpx;
+}
+.max-level {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 60rpx;
+}
+.max-level-label {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.6);
+  margin-right: 16rpx;
+}
+.max-level-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.dimension-tag {
+  margin-top: 24rpx;
+  font-size: 26rpx;
+  color: #a5b4fc;
+  background: rgba(99, 102, 241, 0.15);
+  border: 2rpx solid rgba(99, 102, 241, 0.4);
+  border-radius: 24rpx;
+  padding: 8rpx 28rpx;
+}
+.submit-btn {
+  margin-top: 80rpx;
+}
+</style>

+ 457 - 0
cfc-frontend/pages/cognitive-test/perception-test.vue

@@ -0,0 +1,457 @@
+<template>
+  <view class="test-container">
+
+    <!-- 步骤一:测试说明 -->
+    <view class="step-panel" v-if="step === 'instruction'">
+      <view class="title-icon">👁️</view>
+      <text class="test-title">感知觉测试</text>
+      <text class="test-desc">屏幕上会出现 6 个相似的图案,其中有一个与众不同。</text>
+      <text class="test-desc">请快速找出"不一样"的那个,点击它即可!</text>
+      <text class="test-meta">共 8 题 · 每题限时 8 秒 · 超时自动进入下一题</text>
+      <button class="btn-primary" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- 步骤二:答题中 -->
+    <view class="step-panel" v-else-if="step === 'playing'">
+      <view class="progress-row">
+        <text class="progress-text">第 {{ currentIndex + 1 }}/8 题</text>
+        <text class="progress-text">答对 {{ correctCount }} 题</text>
+      </view>
+
+      <view class="timer-bar">
+        <view class="timer-fill" :style="timerBarStyle"></view>
+      </view>
+      <text class="timer-text">{{ timerLeft }}秒</text>
+
+      <text class="question-hint" v-if="currentQuestion">{{ currentQuestion.hint }}</text>
+
+      <view class="grid-wrap">
+        <view
+          class="grid-cell"
+          v-for="(item, index) in currentQuestion.items"
+          :key="getItemKey(item, index)"
+          :class="{
+            'cell-correct': lockInput && index === currentQuestion.oddIndex,
+            'cell-wrong': tappedIndex === index && index !== currentQuestion.oddIndex
+          }"
+          @click="handleTap(index)"
+        >
+          <text class="cell-emoji">{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 步骤三:结果 -->
+    <view class="step-panel" v-else>
+      <text class="result-title">测试完成</text>
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+      </view>
+      <view class="score-row">
+        <text class="score-label">答对 {{ correctCount }}/8 题</text>
+        <text class="dimension-tag">感知觉</text>
+      </view>
+      <view class="result-tip" v-if="score >= 75">
+        <text class="result-tip-text">感官敏锐,找不同又快又准!</text>
+      </view>
+      <view class="result-tip" v-else-if="score >= 50">
+        <text class="result-tip-text">表现不错,再细心一点会更好!</text>
+      </view>
+      <view class="result-tip" v-else>
+        <text class="result-tip-text">别灰心,多练几次会越来越快!</text>
+      </view>
+      <button class="btn-primary" @click="submitResult">提交成绩</button>
+      <button class="btn-secondary" @click="retryTest">再测一次</button>
+    </view>
+
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+// 题库:6 个相似图案,其中一个是"不同"的(颜色/形状变体)
+var QUESTION_BANK = [
+  { items: ['🟦','🟦','🟦','🟦','🟦','🟥'], oddIndex: 5, hint: '找出不同的颜色' },
+  { items: ['🟨','🟨','🟨','🟨','🟨','🟩'], oddIndex: 3, hint: '找出不同的颜色' },
+  { items: ['🟪','🟪','🟪','🟪','🟪','🟦'], oddIndex: 4, hint: '找出不同的颜色' },
+  { items: ['🟩','🟩','🟩','🟩','🟩','🟨'], oddIndex: 1, hint: '找出不同的颜色' },
+  { items: ['🟥','🟥','🟥','🟥','🟥','🟪'], oddIndex: 2, hint: '找出不同的颜色' },
+  { items: ['🟡','🟡','🟡','🟡','🟡','🟢'], oddIndex: 0, hint: '找出不同的颜色' },
+  { items: ['⬛','⬛','⬛','⬛','⬛','⬜'], oddIndex: 5, hint: '找出不同的颜色' },
+  { items: ['🔴','🔴','🔴','🔴','🔴','🟡'], oddIndex: 2, hint: '找出不同的颜色' },
+  { items: ['🟨','🟨','🟨','🟨','🟨','🟥'], oddIndex: 0, hint: '找出不同的颜色' },
+  { items: ['🟩','🟩','🟩','🟩','🟩','🟦'], oddIndex: 4, hint: '找出不同的颜色' },
+  { items: ['🟪','🟪','🟪','🟪','🟪','⬛'], oddIndex: 1, hint: '找出不同的图案' },
+  { items: ['⬜','⬜','⬜','⬜','⬜','⬛'], oddIndex: 3, hint: '找出不同的图案' },
+  { items: ['🔴','🔴','🔴','🔴','🔴','🟥'], oddIndex: 5, hint: '找出不同的图案' },
+  { items: ['🟥','🟥','🟥','🟥','🟥','🔴'], oddIndex: 2, hint: '找出不同的图案' },
+  { items: ['🟡','🟡','🟡','🟡','🟡','🟢'], oddIndex: 1, hint: '找出不同的颜色' },
+  { items: ['🟦','🟦','🟦','🟦','🟦','⬜'], oddIndex: 4, hint: '找出不同的图案' },
+  { items: ['⬡','⬡','⬡','⬡','⬡','⬢'], oddIndex: 5, hint: '找出不同的形状' },
+  { items: ['◈','◈','◈','◈','◈','◆'], oddIndex: 1, hint: '找出不同的形状' },
+  { items: ['○','○','○','○','○','◇'], oddIndex: 3, hint: '找出不同的形状' },
+  { items: ['◆','◆','◆','◆','◆','◈'], oddIndex: 0, hint: '找出不同的形状' },
+  { items: ['⬢','⬢','⬢','⬢','⬢','⬡'], oddIndex: 2, hint: '找出不同的形状' },
+  { items: ['◇','◇','◇','◇','◇','○'], oddIndex: 5, hint: '找出不同的形状' }
+]
+
+export default {
+  data() {
+    return {
+      step: 'instruction',
+      memberId: '',
+      roundQuestions: [],
+      currentIndex: 0,
+      currentQuestion: null,
+      correctCount: 0,
+      timerLeft: 8,
+      timer: null,
+      nextTimer: null,
+      lockInput: false,
+      tappedIndex: -1,
+      score: 0
+    }
+  },
+  computed: {
+    timerPercent: function() {
+      return Math.round(this.timerLeft / 8 * 100)
+    },
+    timerBarStyle: function() {
+      return 'width:' + this.timerPercent + '%'
+    }
+  },
+  onLoad(options) {
+    if (options && options.memberId) {
+      this.memberId = options.memberId
+    }
+  },
+  onUnload() {
+    this.stopTimer()
+    if (this.nextTimer) {
+      clearTimeout(this.nextTimer)
+      this.nextTimer = null
+    }
+  },
+  methods: {
+    getItemKey: function(item, index) {
+      return item + '-' + index
+    },
+    startTest: function() {
+      this.correctCount = 0
+      this.currentIndex = 0
+      this.score = 0
+      this.lockInput = false
+      this.tappedIndex = -1
+      // 洗牌后取前 8 题
+      var bank = QUESTION_BANK.slice()
+      for (var i = bank.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = bank[i]
+        bank[i] = bank[j]
+        bank[j] = tmp
+      }
+      this.roundQuestions = bank.slice(0, 8)
+      this.step = 'playing'
+      this.loadQuestion(0)
+    },
+    loadQuestion: function(index) {
+      this.currentIndex = index
+      this.currentQuestion = this.roundQuestions[index]
+      this.tappedIndex = -1
+      this.lockInput = false
+      this.timerLeft = 8
+      this.startTimer()
+    },
+    startTimer: function() {
+      this.stopTimer()
+      this.timer = setInterval(() => {
+        this.timerLeft--
+        if (this.timerLeft <= 0) {
+          this.timerLeft = 0
+          this.onTimeout()
+        }
+      }, 1000)
+    },
+    stopTimer: function() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    handleTap: function(index) {
+      if (this.lockInput || !this.currentQuestion) return
+      this.lockInput = true
+      this.stopTimer()
+      this.tappedIndex = index
+      if (index === this.currentQuestion.oddIndex) {
+        this.correctCount++
+      }
+      this.scheduleNext()
+    },
+    onTimeout: function() {
+      if (this.lockInput) return
+      this.lockInput = true
+      this.stopTimer()
+      this.tappedIndex = -1
+      // 超时未作答,短暂高亮正确答案后进入下一题
+      this.scheduleNext()
+    },
+    scheduleNext: function() {
+      if (this.nextTimer) clearTimeout(this.nextTimer)
+      this.nextTimer = setTimeout(() => {
+        this.nextTimer = null
+        this.advance()
+      }, 700)
+    },
+    advance: function() {
+      if (this.currentIndex + 1 >= this.roundQuestions.length) {
+        this.finishTest()
+      } else {
+        this.loadQuestion(this.currentIndex + 1)
+      }
+    },
+    finishTest: function() {
+      this.stopTimer()
+      this.score = Math.round(this.correctCount / this.roundQuestions.length * 100)
+      this.step = 'result'
+    },
+    submitResult: function() {
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      submitCognitiveSelfTest(memberId, 'perceptionScore', this.score)
+        .then((res) => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 1500)
+        })
+        .catch((err) => {
+          console.error('提交感知觉测试成绩失败', err)
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    },
+    retryTest: function() {
+      this.startTest()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.test-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+}
+
+.step-panel {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* ===== 说明页 ===== */
+.title-icon {
+  font-size: 88rpx;
+  margin-bottom: 24rpx;
+}
+
+.test-title {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #fff;
+  margin-bottom: 32rpx;
+}
+
+.test-desc {
+  font-size: 30rpx;
+  color: rgba(255,255,255,0.7);
+  line-height: 1.7;
+  text-align: center;
+}
+
+.test-meta {
+  font-size: 24rpx;
+  color: rgba(255,255,255,0.45);
+  margin-top: 24rpx;
+  margin-bottom: 80rpx;
+  text-align: center;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #4ECDC4, #45B7AA);
+  color: #fff;
+  font-size: 34rpx;
+  font-weight: bold;
+  width: 480rpx;
+  height: 92rpx;
+  line-height: 92rpx;
+  border-radius: 46rpx;
+  border: none;
+  margin-top: 60rpx;
+  padding: 0;
+}
+
+.btn-primary::after {
+  border: none;
+}
+
+.btn-secondary {
+  background: rgba(255,255,255,0.1);
+  color: rgba(255,255,255,0.85);
+  font-size: 32rpx;
+  width: 480rpx;
+  height: 92rpx;
+  line-height: 92rpx;
+  border-radius: 46rpx;
+  border: 2rpx solid rgba(255,255,255,0.25);
+  margin-top: 32rpx;
+  padding: 0;
+}
+
+.btn-secondary::after {
+  border: none;
+}
+
+/* ===== 答题页 ===== */
+.progress-row {
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  margin-bottom: 24rpx;
+}
+
+.progress-text {
+  font-size: 28rpx;
+  color: rgba(255,255,255,0.7);
+}
+
+.timer-bar {
+  width: 100%;
+  height: 16rpx;
+  background: rgba(255,255,255,0.12);
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+
+.timer-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #4ECDC4, #45B7AA);
+  border-radius: 8rpx;
+  transition: width 0.9s linear;
+}
+
+.timer-text {
+  display: block;
+  font-size: 26rpx;
+  color: #4ECDC4;
+  margin-top: 12rpx;
+  text-align: center;
+}
+
+.question-hint {
+  display: block;
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #fff;
+  margin: 40rpx 0 32rpx;
+  text-align: center;
+}
+
+.grid-wrap {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.grid-cell {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 30%;
+  height: 190rpx;
+  background: #2a2a4a;
+  border-radius: 24rpx;
+  border: 4rpx solid transparent;
+  margin-bottom: 24rpx;
+  box-sizing: border-box;
+}
+
+.grid-cell:active {
+  background: #35406b;
+}
+
+.cell-emoji {
+  font-size: 50rpx;
+}
+
+.cell-correct {
+  background: #2f4a4a;
+  border-color: #4ECDC4;
+}
+
+.cell-wrong {
+  background: #4a2f35;
+  border-color: #ff6b6b;
+}
+
+/* ===== 结果页 ===== */
+.result-title {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #fff;
+  margin-bottom: 48rpx;
+}
+
+.score-circle {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 260rpx;
+  height: 260rpx;
+  border-radius: 50%;
+  border: 10rpx solid #4ECDC4;
+  background: rgba(78,205,196,0.1);
+  margin-bottom: 36rpx;
+}
+
+.score-num {
+  font-size: 88rpx;
+  font-weight: bold;
+  color: #4ECDC4;
+}
+
+.score-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.score-label {
+  font-size: 30rpx;
+  color: rgba(255,255,255,0.7);
+  margin-right: 20rpx;
+}
+
+.dimension-tag {
+  font-size: 24rpx;
+  color: #4ECDC4;
+  border: 2rpx solid #4ECDC4;
+  border-radius: 24rpx;
+  padding: 6rpx 24rpx;
+}
+
+.result-tip {
+  margin-bottom: 20rpx;
+}
+
+.result-tip-text {
+  font-size: 28rpx;
+  color: rgba(255,255,255,0.6);
+}
+</style>

+ 517 - 0
cfc-frontend/pages/cognitive-test/spatial-test.vue

@@ -0,0 +1,517 @@
+<template>
+  <view class="page">
+    <!-- 开始引导 -->
+    <view v-if="step === 'instruction'" class="container">
+      <view class="hero-icon">
+        <text class="hero-symbol">↻</text>
+      </view>
+      <text class="hero-title">空间思维测试</text>
+      <text class="hero-desc">观察箭头的初始方向,想象它按提示旋转后的样子,从四个选项中选出正确的方向。</text>
+      <view class="rule-list">
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">共 8 题,每题限时 10 秒</text>
+        </view>
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">超时未作答按答错处理</text>
+        </view>
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">答题结束后自动计算得分</text>
+        </view>
+      </view>
+      <button class="btn btn-primary" @tap="startTest">开始测试</button>
+    </view>
+
+    <!-- 答题中 -->
+    <view v-else-if="step === 'playing'" class="container">
+      <view class="progress-row">
+        <text class="progress-text">第 {{ currentIndex + 1 }}/{{ questions.length }} 题</text>
+        <text class="timer-text">{{ timeLeft }}s</text>
+      </view>
+      <view class="timer-bar">
+        <view class="timer-bar-fill" :style="timerBarStyle"></view>
+      </view>
+
+      <view class="question-box">
+        <text class="direction-label">初始方向</text>
+        <text class="direction-arrow">{{ currentQuestion.direction }}</text>
+        <text class="rotation-tip">顺时针旋转 {{ currentQuestion.rotation }}°</text>
+      </view>
+
+      <view class="options-wrap">
+        <view
+          v-for="opt in currentQuestion.options"
+          :key="getOptKey(opt)"
+          class="option-btn"
+          :class="selected === opt ? 'option-selected' : ''"
+          @tap="handleOptionTap(opt)"
+        >
+          <text class="option-arrow">{{ opt }}</text>
+        </view>
+      </view>
+
+      <view v-if="feedback" class="feedback-row">
+        <text class="feedback-text" :class="'feedback-' + feedback">{{ feedbackText }}</text>
+      </view>
+    </view>
+
+    <!-- 结果 -->
+    <view v-else class="container">
+      <text class="result-title">测试完成</text>
+      <view class="score-circle">
+        <text class="score-number">{{ score }}</text>
+        <text class="score-unit">分</text>
+      </view>
+      <text class="score-detail">答对 {{ correctCount }}/{{ questions.length }} 题</text>
+      <view class="dimension-tag">
+        <text class="dimension-name">空间思维</text>
+      </view>
+
+      <button class="btn btn-primary" :disabled="submitting" @tap="submitScore">提交成绩</button>
+      <button class="btn btn-secondary" @tap="restartTest">再测一次</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+const TOTAL_TIME = 10
+const TOTAL_QUESTIONS = 8
+const DIRECTIONS = ['↑', '→', '↓', '←']
+// 顺时针旋转 90° 的方向映射
+const ROTATE_CW = { '↑': '→', '→': '↓', '↓': '←', '←': '↑' }
+
+function shuffleArray(arr) {
+  var a = arr.slice()
+  var i = a.length - 1
+  while (i > 0) {
+    var j = Math.floor(Math.random() * (i + 1))
+    var tmp = a[i]
+    a[i] = a[j]
+    a[j] = tmp
+    i -= 1
+  }
+  return a
+}
+
+function rotateDirection(direction, degrees) {
+  var result = direction
+  var times = degrees / 90
+  for (var i = 0; i < times; i++) {
+    result = ROTATE_CW[result]
+  }
+  return result
+}
+
+function buildQuestionBank() {
+  var bank = []
+  var rotations = [90, 180, 270]
+  for (var d = 0; d < DIRECTIONS.length; d++) {
+    var dir = DIRECTIONS[d]
+    for (var r = 0; r < rotations.length; r++) {
+      var rot = rotations[r]
+      var answer = rotateDirection(dir, rot)
+      // 每个 方向×角度 组合生成 2 题,共 24 题
+      bank.push({ direction: dir, rotation: rot, answer: answer, options: shuffleArray(DIRECTIONS) })
+      bank.push({ direction: dir, rotation: rot, answer: answer, options: shuffleArray(DIRECTIONS) })
+    }
+  }
+  return bank
+}
+
+const QUESTION_BANK = buildQuestionBank()
+
+export default {
+  data() {
+    return {
+      memberId: '',
+      step: 'instruction', // instruction | playing | result
+      questions: [],
+      currentIndex: 0,
+      selected: '',
+      answered: false,
+      feedback: '', // '' | correct | wrong | timeout
+      correctCount: 0,
+      timeLeft: TOTAL_TIME,
+      score: 0,
+      submitting: false,
+      timer: null,
+      timeoutJump: null
+    }
+  },
+  computed: {
+    currentQuestion() {
+      return this.questions[this.currentIndex] || {}
+    },
+    timerBarStyle() {
+      return 'width:' + (this.timeLeft / TOTAL_TIME * 100) + '%'
+    },
+    feedbackText() {
+      if (this.feedback === 'correct') return '回答正确'
+      if (this.feedback === 'wrong') return '回答错误'
+      if (this.feedback === 'timeout') return '时间到,未作答'
+      return ''
+    }
+  },
+  onLoad() {
+    this.memberId = uni.getStorageSync('currentChildId') || ''
+  },
+  onUnload() {
+    this.stopTimer()
+    if (this.timeoutJump) {
+      clearTimeout(this.timeoutJump)
+      this.timeoutJump = null
+    }
+  },
+  methods: {
+    getOptKey(opt) {
+      return this.currentIndex + '_' + opt
+    },
+    generateQuestions() {
+      var shuffled = shuffleArray(QUESTION_BANK)
+      return shuffled.slice(0, TOTAL_QUESTIONS)
+    },
+    startTest() {
+      this.questions = this.generateQuestions()
+      this.currentIndex = 0
+      this.selected = ''
+      this.answered = false
+      this.feedback = ''
+      this.correctCount = 0
+      this.score = 0
+      this.submitting = false
+      this.step = 'playing'
+      this.startTimer()
+    },
+    startTimer() {
+      this.stopTimer()
+      this.timeLeft = TOTAL_TIME
+      this.timer = setInterval(() => {
+        this.timeLeft -= 1
+        if (this.timeLeft <= 0) {
+          this.handleTimeout()
+        }
+      }, 1000)
+    },
+    stopTimer() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    handleOptionTap(opt) {
+      if (this.answered) {
+        return
+      }
+      this.answered = true
+      this.selected = opt
+      this.stopTimer()
+      if (opt === this.currentQuestion.answer) {
+        this.correctCount += 1
+        this.feedback = 'correct'
+      } else {
+        this.feedback = 'wrong'
+      }
+      this.scheduleNext()
+    },
+    handleTimeout() {
+      this.stopTimer()
+      this.answered = true
+      this.feedback = 'timeout'
+      this.scheduleNext()
+    },
+    scheduleNext() {
+      var self = this
+      this.timeoutJump = setTimeout(function () {
+        self.nextQuestion()
+      }, 800)
+    },
+    nextQuestion() {
+      if (this.timeoutJump) {
+        clearTimeout(this.timeoutJump)
+        this.timeoutJump = null
+      }
+      if (this.currentIndex >= this.questions.length - 1) {
+        this.finishTest()
+        return
+      }
+      this.currentIndex += 1
+      this.selected = ''
+      this.answered = false
+      this.feedback = ''
+      this.startTimer()
+    },
+    finishTest() {
+      this.stopTimer()
+      this.score = Math.round(this.correctCount / this.questions.length * 100)
+      this.step = 'result'
+    },
+    submitScore() {
+      if (this.submitting) {
+        return
+      }
+      this.submitting = true
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      submitCognitiveSelfTest(memberId, 'spatialScore', this.score)
+        .then(() => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 1500)
+        })
+        .catch(() => {
+          this.submitting = false
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    },
+    restartTest() {
+      this.startTest()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+  box-sizing: border-box;
+}
+
+.container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* --- 引导页 --- */
+.hero-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(59, 130, 246, 0.15);
+  border: 2rpx solid rgba(59, 130, 246, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 100rpx;
+  margin-bottom: 40rpx;
+}
+.hero-symbol {
+  font-size: 80rpx;
+  color: #3B82F6;
+  line-height: 1;
+}
+.hero-title {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 24rpx;
+}
+.hero-desc {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.7);
+  line-height: 1.7;
+  text-align: center;
+  margin-bottom: 48rpx;
+}
+.rule-list {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 80rpx;
+  box-sizing: border-box;
+}
+.rule-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.rule-item:last-child {
+  margin-bottom: 0;
+}
+.rule-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  background: #3B82F6;
+  margin-right: 16rpx;
+}
+.rule-text {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+/* --- 答题页 --- */
+.progress-row {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.progress-text {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.85);
+}
+.timer-text {
+  font-size: 28rpx;
+  color: #60A5FA;
+  font-weight: bold;
+}
+.timer-bar {
+  width: 100%;
+  height: 12rpx;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 80rpx;
+}
+.timer-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #3B82F6, #60A5FA);
+  border-radius: 6rpx;
+  transition: width 1s linear;
+}
+.question-box {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 50rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 60rpx;
+}
+.direction-label {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 20rpx;
+}
+.direction-arrow {
+  font-size: 100rpx;
+  color: #ffffff;
+  line-height: 1.2;
+  margin-bottom: 20rpx;
+}
+.rotation-tip {
+  font-size: 30rpx;
+  color: #93C5FD;
+}
+.options-wrap {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.option-btn {
+  width: 300rpx;
+  height: 220rpx;
+  background: rgba(255, 255, 255, 0.08);
+  border: 2rpx solid rgba(255, 255, 255, 0.15);
+  border-radius: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 30rpx;
+  box-sizing: border-box;
+}
+.option-selected {
+  border-color: #3B82F6;
+  background: rgba(59, 130, 246, 0.2);
+}
+.option-arrow {
+  font-size: 72rpx;
+  color: #ffffff;
+}
+.feedback-row {
+  margin-top: 10rpx;
+}
+.feedback-text {
+  font-size: 30rpx;
+}
+.feedback-correct {
+  color: #34D399;
+}
+.feedback-wrong {
+  color: #F87171;
+}
+.feedback-timeout {
+  color: rgba(255, 255, 255, 0.5);
+}
+
+/* --- 结果页 --- */
+.result-title {
+  font-size: 36rpx;
+  color: #ffffff;
+  margin-top: 60rpx;
+  margin-bottom: 20rpx;
+}
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  border: 10rpx solid #3B82F6;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin: 40rpx 0;
+}
+.score-number {
+  font-size: 80rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1.1;
+}
+.score-unit {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.6);
+}
+.score-detail {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-bottom: 30rpx;
+}
+.dimension-tag {
+  padding: 10rpx 32rpx;
+  background: rgba(59, 130, 246, 0.15);
+  border: 2rpx solid rgba(59, 130, 246, 0.5);
+  border-radius: 40rpx;
+  margin-bottom: 80rpx;
+}
+.dimension-name {
+  font-size: 26rpx;
+  color: #93C5FD;
+}
+
+/* --- 按钮 --- */
+.btn {
+  width: 100%;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  font-size: 32rpx;
+  text-align: center;
+  margin-bottom: 30rpx;
+  padding: 0;
+}
+.btn::after {
+  border: none;
+}
+.btn-primary {
+  background: #3B82F6;
+  color: #ffffff;
+}
+.btn-secondary {
+  background: transparent;
+  border: 2rpx solid #3B82F6;
+  color: #3B82F6;
+  box-sizing: border-box;
+}
+</style>

+ 577 - 0
cfc-frontend/pages/cognitive-test/speed-test.vue

@@ -0,0 +1,577 @@
+<template>
+  <view class="page-container">
+    <!-- 说明页 -->
+    <view v-if="step === 'instruction'" class="step-wrap">
+      <text class="title">加工速度测试</text>
+      <text class="subtitle">符号匹配 · 反应速度</text>
+
+      <view class="desc-card">
+        <text class="desc-title">测试说明</text>
+        <text class="desc-line">1. 先记住出现的符号</text>
+        <text class="desc-line">2. 从 4 个候选中点出相同的符号</text>
+        <text class="desc-line">3. 越快越准,得分越高</text>
+        <text class="desc-line">共 10 轮,每轮限时 5 秒</text>
+      </view>
+
+      <button class="btn-primary" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- 测试中 -->
+    <view v-if="step === 'playing'" class="step-wrap">
+      <view class="round-bar">
+        <text class="round-text">第 {{ roundIndex }}/10</text>
+        <view class="timer-track">
+          <view class="timer-fill" :style="'width:' + timerPercent + '%'"></view>
+        </view>
+      </view>
+
+      <!-- 记忆阶段:展示目标符号 -->
+      <view v-if="phase === 'show'" class="show-area">
+        <text class="phase-hint">记住这个符号</text>
+        <view class="symbol-box" :style="'width:' + symbolBoxSize + 'px;height:' + symbolBoxSize + 'px'">
+          <text class="symbol-big" :style="'font-size:' + symbolFontSize + 'px'">{{ currentTarget }}</text>
+        </view>
+      </view>
+
+      <!-- 选择阶段:4 选 1 -->
+      <view v-else class="choice-area">
+        <text class="phase-hint">找出刚才的符号</text>
+        <view class="choice-grid">
+          <view
+            v-for="(sym, idx) in currentOptions"
+            :key="getSymKey(sym)"
+            :class="'choice-cell' + (selIndex === idx ? (roundCorrect ? ' cell-hit' : ' cell-miss') : '')"
+            :style="'width:' + cellSize + 'px;height:' + cellSize + 'px'"
+            @click="handleChoice(sym)"
+          >
+            <text class="symbol-opt" :style="'font-size:' + symbolFontSize + 'px'">{{ sym }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 结果页 -->
+    <view v-if="step === 'result'" class="step-wrap">
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+        <text class="score-label">分</text>
+      </view>
+
+      <view class="result-stats">
+        <view class="stat-item">
+          <text class="stat-value">{{ correctCount }}/10</text>
+          <text class="stat-label">答对</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ avgTimeText }}</text>
+          <text class="stat-label">平均反应</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">+{{ speedBonus }}</text>
+          <text class="stat-label">速度加分</text>
+        </view>
+      </view>
+
+      <view class="dimension-tag">加工速度</view>
+
+      <button class="btn-primary" :disabled="submitting" @click="submitScore">
+        {{ submitting ? '提交中...' : '提交成绩' }}
+      </button>
+      <button class="btn-secondary" @click="startTest">再测一次</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      SYMBOLS: ['★', '●', '■', '▲', '◆', '◇', '○', '△'],
+      SYMBOL_PAIRS: [['★', '◆'], ['●', '○'], ['■', '◇'], ['▲', '△']],
+      step: 'instruction',
+      phase: 'show',
+      roundIndex: 1,
+      currentTarget: '',
+      currentOptions: [],
+      roundStartMs: 0,
+      roundElapsed: 0,
+      timerPercent: 0,
+      locked: false,
+      selIndex: -1,
+      roundCorrect: false,
+      results: [],
+      correctCount: 0,
+      avgTimeText: '0.00s',
+      speedBonus: 0,
+      score: 0,
+      submitting: false,
+      memberId: '',
+      showTimer: null,
+      roundTimer: null,
+      windowWidth: 375,
+      symbolFontSize: 75,
+      symbolBoxSize: 150,
+      cellSize: 112
+    }
+  },
+
+  onLoad(options) {
+    var win = uni.getWindowInfo()
+    this.windowWidth = (win && win.windowWidth) || 375
+    this.symbolFontSize = Math.round(this.windowWidth * 0.2)
+    this.symbolBoxSize = Math.round(this.windowWidth * 0.4)
+    this.cellSize = Math.round(this.windowWidth * 0.3)
+    this.memberId = (options && options.memberId) || uni.getStorageSync('currentChildId') || ''
+  },
+
+  onUnload() {
+    this.clearTimers()
+  },
+
+  methods: {
+    getSymKey(sym) {
+      return sym
+    },
+
+    clearTimers() {
+      if (this.showTimer) {
+        clearTimeout(this.showTimer)
+        this.showTimer = null
+      }
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+    },
+
+    startTest() {
+      this.clearTimers()
+      this.results = []
+      this.roundIndex = 1
+      this.submitting = false
+      this.selIndex = -1
+      this.timerPercent = 0
+      this.step = 'playing'
+      this.startRound()
+    },
+
+    // 生成一轮:从符号对中随机取目标,3 个干扰项 + 配对符号
+    generateRound() {
+      var pair = this.SYMBOL_PAIRS[Math.floor(Math.random() * this.SYMBOL_PAIRS.length)]
+      var target = pair[Math.floor(Math.random() * 2)]
+      var pairMate = target === pair[0] ? pair[1] : pair[0]
+      var others = []
+      for (var i = 0; i < this.SYMBOLS.length; i++) {
+        if (this.SYMBOLS[i] !== target && this.SYMBOLS[i] !== pairMate) {
+          others.push(this.SYMBOLS[i])
+        }
+      }
+      this.shuffle(others)
+      var options = [target, pairMate, others[0], others[1]]
+      this.shuffle(options)
+      return { target: target, options: options }
+    },
+
+    shuffle(arr) {
+      for (var i = arr.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = arr[i]
+        arr[i] = arr[j]
+        arr[j] = tmp
+      }
+    },
+
+    startRound() {
+      this.clearTimers()
+      this.phase = 'show'
+      this.locked = false
+      this.selIndex = -1
+      this.roundElapsed = 0
+      this.timerPercent = 0
+
+      var round = this.generateRound()
+      this.currentTarget = round.target
+      this.currentOptions = round.options
+
+      // 记忆阶段:展示 1 秒后进入选择
+      var that = this
+      this.showTimer = setTimeout(function () {
+        that.phase = 'choice'
+        that.roundStartMs = Date.now()
+        that.roundTimer = setInterval(function () {
+          that.tickRound()
+        }, 100)
+      }, 1000)
+    },
+
+    tickRound() {
+      this.roundElapsed += 100
+      this.timerPercent = Math.min(this.roundElapsed / 5000 * 100, 100)
+      if (this.roundElapsed >= 5000) {
+        // 超时未作答,记错
+        this.finishRound()
+      }
+    },
+
+    handleChoice(sym) {
+      if (this.phase !== 'choice' || this.locked) return
+      this.locked = true
+      var isCorrect = sym === this.currentTarget
+      this.selIndex = this.currentOptions.indexOf(sym)
+      this.roundCorrect = isCorrect
+      this.results.push({
+        correct: isCorrect,
+        timeMs: Date.now() - this.roundStartMs
+      })
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+      var that = this
+      setTimeout(function () {
+        that.nextRound()
+      }, 450)
+    },
+
+    // 超时:timeMs 记 0(不计入平均反应时间)
+    finishRound() {
+      if (this.locked) return
+      this.locked = true
+      this.results.push({ correct: false, timeMs: 0 })
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+      var that = this
+      setTimeout(function () {
+        that.nextRound()
+      }, 300)
+    },
+
+    nextRound() {
+      if (this.roundIndex >= 10) {
+        this.finishTest()
+      } else {
+        this.roundIndex++
+        this.startRound()
+      }
+    },
+
+    finishTest() {
+      this.clearTimers()
+      this.step = 'result'
+      this.computeResult()
+    },
+
+    computeResult() {
+      var correct = 0
+      var totalMs = 0
+      var answered = 0
+      for (var i = 0; i < this.results.length; i++) {
+        var r = this.results[i]
+        if (r.correct) correct++
+        if (r.timeMs > 0) {
+          totalMs += r.timeMs
+          answered++
+        }
+      }
+      this.correctCount = correct
+      var avgMs = answered > 0 ? totalMs / answered : 5000
+      this.avgTimeText = (avgMs / 1000).toFixed(2) + 's'
+
+      var avgSec = avgMs / 1000
+      var bonus = 5
+      if (avgSec < 1) {
+        bonus = 20
+      } else if (avgSec < 2) {
+        bonus = 17
+      } else if (avgSec < 3) {
+        bonus = 14
+      } else if (avgSec < 4) {
+        bonus = 10
+      }
+      this.speedBonus = bonus
+
+      var total = Math.round(correct / 10 * 80 + bonus)
+      this.score = Math.min(Math.max(total, 0), 100)
+    },
+
+    submitScore() {
+      if (this.submitting) return
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      var that = this
+      submitCognitiveSelfTest(memberId, 'processingSpeedScore', this.score)
+        .then(function () {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(function () {
+            uni.navigateBack()
+          }, 800)
+        })
+        .catch(function () {
+          that.submitting = false
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 40rpx 50rpx;
+}
+
+.step-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.title {
+  font-size: 52rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-top: 40rpx;
+}
+
+.subtitle {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-top: 16rpx;
+}
+
+.desc-card {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 40rpx;
+  margin: 60rpx 0;
+}
+
+.desc-title {
+  display: block;
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+  margin-bottom: 24rpx;
+}
+
+.desc-line {
+  display: block;
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.85);
+  line-height: 1.9;
+}
+
+/* 按钮 */
+.btn-primary {
+  width: 70%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #ffffff;
+  font-size: 34rpx;
+  border-radius: 50rpx;
+  border: none;
+  margin-top: 20rpx;
+}
+
+.btn-primary::after {
+  border: none;
+}
+
+.btn-primary[disabled] {
+  opacity: 0.6;
+}
+
+.btn-secondary {
+  width: 70%;
+  background: transparent;
+  color: #F97316;
+  font-size: 32rpx;
+  border-radius: 50rpx;
+  border: 2rpx solid #F97316;
+  margin-top: 30rpx;
+}
+
+.btn-secondary::after {
+  border: none;
+}
+
+/* 测试中 */
+.round-bar {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 30rpx;
+}
+
+.round-text {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20rpx;
+}
+
+.timer-track {
+  width: 100%;
+  height: 10rpx;
+  background: rgba(255, 255, 255, 0.12);
+  border-radius: 10rpx;
+  overflow: hidden;
+}
+
+.timer-fill {
+  height: 100%;
+  border-radius: 10rpx;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  transition: width 0.1s linear;
+}
+
+.phase-hint {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.6);
+  margin: 70rpx 0 40rpx;
+}
+
+/* 记忆阶段 */
+.show-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+
+.symbol-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.08);
+  border: 2rpx solid rgba(249, 115, 22, 0.4);
+  border-radius: 24rpx;
+  box-shadow: 0 0 40rpx rgba(249, 115, 22, 0.15);
+}
+
+.symbol-big {
+  color: #ffffff;
+  font-weight: bold;
+  line-height: 1;
+}
+
+/* 选择阶段 */
+.choice-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+
+.choice-grid {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.choice-cell {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #2a2a4a;
+  border: 2rpx solid transparent;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+}
+
+.choice-cell:active {
+  background: #36365c;
+}
+
+.choice-cell.cell-hit {
+  background: #1e3a2f;
+  border-color: #22c55e;
+  box-shadow: 0 0 30rpx rgba(34, 197, 94, 0.3);
+}
+
+.choice-cell.cell-miss {
+  background: #3a1f22;
+  border-color: #ef4444;
+  box-shadow: 0 0 30rpx rgba(239, 68, 68, 0.3);
+}
+
+.symbol-opt {
+  color: #ffffff;
+  font-weight: bold;
+  line-height: 1;
+}
+
+/* 结果页 */
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin: 60rpx 0 50rpx;
+  box-shadow: 0 0 60rpx rgba(249, 115, 22, 0.35);
+}
+
+.score-num {
+  font-size: 88rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1;
+}
+
+.score-label {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.9);
+  margin-top: 8rpx;
+}
+
+.result-stats {
+  width: 100%;
+  display: flex;
+  justify-content: space-around;
+  margin-bottom: 40rpx;
+}
+
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.stat-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+
+.stat-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-top: 10rpx;
+}
+
+.dimension-tag {
+  font-size: 28rpx;
+  color: #F97316;
+  background: rgba(249, 115, 22, 0.12);
+  border: 2rpx solid rgba(249, 115, 22, 0.4);
+  border-radius: 30rpx;
+  padding: 12rpx 40rpx;
+  margin-bottom: 50rpx;
+}
+</style>

+ 11 - 3
cfc-frontend/pages/discover/index.vue

@@ -58,10 +58,10 @@
       </view>
     </view>
 
-    <!-- 知识中心 -->
+    <!-- 知识分享 -->
     <view class="article-section">
       <view class="section-header">
-        <text class="section-title">📖 知识中心</text>
+        <text class="section-title">📖 知识分享</text>
         <text class="section-more" @click="goKnowledgeCenter">查看全部 ›</text>
       </view>
       <view class="article-list">
@@ -72,7 +72,7 @@
           <view class="article-info">
             <text class="article-title">{{ item.title }}</text>
             <text class="article-desc">{{ item.summary || item.title }}</text>
-            <text class="article-meta">{{ item.publishedAt ? item.publishedAt.slice(0, 10) : '' }} · {{ item.readTime || 3 }}分钟阅读</text>
+            <text class="article-meta">{{ item.publishedAt ? item.publishedAt.slice(0, 10) : '' }} · {{ item.readTime || 3 }}分钟阅读 · &#x1F441; {{ item.viewCount || 0 }}次 · &#x23F1; {{ formatDuration(item.totalDurationSeconds) }}</text>
           </view>
         </view>
       </view>
@@ -160,6 +160,14 @@ export default {
     this.loadFeaturedArticles()
   },
   methods: {
+    formatDuration(seconds) {
+      if (!seconds || seconds <= 0) return '0分钟'
+      var mins = Math.round(seconds / 60)
+      if (mins < 60) return mins + '分钟'
+      var hours = Math.floor(mins / 60)
+      var restMins = mins % 60
+      return restMins > 0 ? hours + '小时' + restMins + '分钟' : hours + '小时'
+    },
     checkMemberStatus() {
       var that = this
       getMyMembership().then(function(res) {

+ 5 - 2
cfc-frontend/pages/growth-main/index.vue

@@ -308,7 +308,10 @@ export default {
     goMoreActivities: function() { uni.navigateTo({ url: '/pages/activity/index' }) },
     goToTasks: function() { uni.navigateTo({ url: '/pages/child/tasks' }) },
     handleGuestFeatureTap: function() { uni.showToast({ title: '登录后即可体验该功能', icon: 'none' }) },
-    goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
+    goToCaution: function() {
+      var mid = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+      uni.navigateTo({ url: '/pages/growth/caution-detail' + (mid ? '?memberId=' + mid : '') })
+    },
     goToFoods: function() {
       var mid = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
       uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail' + (mid ? '?memberId=' + mid : '') })
@@ -426,7 +429,7 @@ export default {
     async loadCautionData() {
       var self = this
       try {
-        var res = await getFoodCautionList()
+        var res = await getFoodCautionList(self.memberId)
         if (res && res.code === 200) self.cautionCount = (res.data || []).length
       } catch(e) {}
     },

+ 149 - 20
cfc-frontend/pages/growth/caution-detail.vue

@@ -6,11 +6,26 @@
       <text class="banner-text">以下食材推荐指数低于 0,建议谨慎食用或避免</text>
     </view>
 
+    <view class="member-bar" v-if="memberList.length > 1">
+      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
+        v-for="item in memberList" :key="item.id || item.userId"
+        @tap="switchMember(item)">
+        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
+      </view>
+    </view>
+
     <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
-      <view class="food-card" v-for="(item, idx) in foods" :key="idx">
+      <view class="food-card" v-for="(item, idx) in foods" :key="getItemKey(item)">
         <view class="food-header">
           <text class="food-name">{{ item.foodName }}</text>
-          <view class="caution-badge">戒备</view>
+          <view class="score-badge-group">
+            <text class="score-value danger">{{ item.indexScore }}</text>
+            <view class="caution-badge">戒备</view>
+          </view>
+        </view>
+        <view class="change-row" v-if="item.changeDir">
+          <text class="change-label">较上次</text>
+          <text :class="item.changeClass">{{ item.changeText }}</text>
         </view>
         <view class="food-meta">
           <text class="meta-item" v-if="item.category">{{ item.category }}</text>
@@ -20,10 +35,10 @@
             <view class="score-bar-bg">
               <view class="score-bar-fill danger" :style="'width:' + Math.min(Math.abs(item.indexScore) / 50 * 100, 100) + '%'"></view>
             </view>
-            <text class="score-value danger">{{ item.indexScore }}</text>
+            <text class="score-label">推荐指数</text>
           </view>
-          <text class="score-label">推荐指数</text>
         </view>
+        <mini-trend :data="item.trendPoints" v-if="item.trendPoints && item.trendPoints.length >= 3"></mini-trend>
         <view class="food-nutrition" v-if="item.nutrition">
           <text class="nut-item">蛋白 {{ item.nutrition.protein }}g</text>
           <text class="nut-item">脂肪 {{ item.nutrition.fat }}g</text>
@@ -47,32 +62,68 @@
 </template>
 
 <script>
-import { getFoodCautionList } from '../../utils/api.js'
+import { getFoodCautionList, getFoodCautionTrend, getCautionFamilyMembers } from '../../utils/api.js'
+import MiniTrend from './mini-trend.vue'
 
 export default {
+  components: { MiniTrend },
   data() {
     return {
       foods: [],
       loading: true,
       refreshing: false,
-      nutritionCache: {}
+      nutritionCache: {},
+      currentMemberId: null,
+      memberList: [],
+      trendMap: {}
     }
   },
-  onLoad() {
+  onLoad: function(options) {
+    this.currentMemberId = options.memberId ? parseInt(options.memberId) : null
+    this.loadMemberList()
     this.loadFoods()
   },
   methods: {
-    goBack() {
+    getItemKey: function(item) { return item.foodId || item.id || Math.random() },
+    goBack: function() {
       uni.navigateBack()
     },
+    switchMember: function(item) {
+      this.currentMemberId = item.id || item.userId
+      this.loadFoods()
+    },
+    async loadMemberList() {
+      try {
+        var res = await getCautionFamilyMembers()
+        if (res && res.code === 200 && res.data && res.data.length > 0) {
+          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
+          this.memberList = [allItem].concat(res.data)
+          if (!this.currentMemberId) {
+            this.currentMemberId = -1
+          }
+        }
+      } catch(e) {}
+    },
     async loadFoods() {
       this.loading = true
       try {
-        var res = await getFoodCautionList()
+        var res = await getFoodCautionList(this.currentMemberId > 0 ? this.currentMemberId : null)
         if (res && res.code === 200 && res.data) {
-          this.foods = res.data
-          // 批量加载营养数据
+          this.foods = (res.data || []).map(function(f) {
+            return {
+              foodId: f.foodId || f.id,
+              foodName: f.foodName,
+              indexScore: f.indexScore,
+              category: f.category,
+              nutrition: null,
+              changeDir: null,
+              changeText: '',
+              changeClass: '',
+              trendPoints: null
+            }
+          })
           this.loadNutrition()
+          this.loadTrends()
         } else {
           this.foods = []
         }
@@ -116,7 +167,47 @@ export default {
         console.warn('加载营养数据失败', e)
       }
     },
-    getKnowledgeSection(key) {
+    loadTrends: function() {
+      if (!this.foods.length) return
+      var self = this
+      var foodIds = this.foods.map(function(f) { return f.foodId }).filter(function(id) { return id != null })
+      if (!foodIds.length) return
+      getFoodCautionTrend(this.currentMemberId > 0 ? this.currentMemberId : null, foodIds).then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          var trendMap = {}
+          var dataArray = res.data || []
+          for (var i = 0; i < dataArray.length; i++) {
+            var t = dataArray[i]
+            trendMap[t.foodId] = t
+          }
+          self.trendMap = trendMap
+          self.foods = self.foods.map(function(item) {
+            var t = trendMap[item.foodId]
+            if (!t) return item
+            var trendPoints = []
+            var pts = t.trend || []
+            for (var j = 0; j < pts.length; j++) {
+              trendPoints.push(pts[j].score)
+            }
+            var changeDir = t.changeDir || 'flat'
+            var changeText = ''
+            var changeClass = ''
+            if (changeDir === 'up') { changeText = '↑ 上升'
+              changeClass = 'change-up' }
+            else if (changeDir === 'down') { changeText = '↓ 下降'
+              changeClass = 'change-down' }
+            return Object.assign({}, item, {
+              trendPoints: trendPoints.length >= 3 ? trendPoints : null,
+              changeDir: changeDir,
+              changeText: changeText,
+              changeClass: changeClass
+            })
+          })
+          self.$forceUpdate()
+        }
+      }).catch(function() {})
+    },
+    getKnowledgeSection: function(key) {
       return new Promise(function(resolve) {
         uni.request({
           url: getApp().globalData.baseApi + '/api/kb/section',
@@ -131,7 +222,7 @@ export default {
         })
       })
     },
-    onRefresh() {
+    onRefresh: function() {
       this.refreshing = true
       this.loadFoods()
     }
@@ -157,6 +248,29 @@ export default {
 .banner-icon { font-size: 32rpx; }
 .banner-text { font-size: 24rpx; color: #856404; flex: 1; }
 
+.member-bar {
+  display: flex;
+  overflow-x: auto;
+  padding: 16rpx 30rpx 8rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #f0f0f0;
+  gap: 16rpx;
+  -webkit-overflow-scrolling: touch;
+}
+.member-tab {
+  flex-shrink: 0;
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+  background: #f5f5f5;
+  color: #666;
+  font-size: 26rpx;
+  font-weight: 500;
+}
+.member-tab.active {
+  background: linear-gradient(135deg, #EF4444, #DC2626);
+  color: #fff;
+}
+
 .content {
   padding: 20rpx 30rpx;
   height: calc(100vh - 160rpx);
@@ -181,6 +295,18 @@ export default {
   font-weight: 600;
   color: #1a1a1a;
 }
+.score-badge-group {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+.score-value {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #EF4444;
+  min-width: 60rpx;
+  text-align: right;
+}
 .caution-badge {
   padding: 4rpx 16rpx;
   background: #EF4444;
@@ -189,6 +315,16 @@ export default {
   font-size: 22rpx;
   font-weight: 500;
 }
+.change-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-bottom: 12rpx;
+  font-size: 24rpx;
+}
+.change-label { color: #999; }
+.change-up { color: #10B981; font-weight: 600; }
+.change-down { color: #EF4444; font-weight: 600; }
 .food-meta {
   margin-bottom: 16rpx;
 }
@@ -226,13 +362,6 @@ export default {
 .score-bar-fill.danger {
   background: linear-gradient(90deg, #EF4444, #DC2626);
 }
-.score-value {
-  font-size: 28rpx;
-  font-weight: 700;
-  color: #EF4444;
-  min-width: 60rpx;
-  text-align: right;
-}
 .score-label {
   font-size: 24rpx;
   color: #999;

+ 73 - 0
cfc-frontend/pages/growth/mini-trend.vue

@@ -0,0 +1,73 @@
+<template>
+  <view class="mini-trend">
+    <canvas :canvas-id="canvasId" class="trend-canvas"></canvas>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    data: {
+      type: Array,
+      default: function() { return [] }
+    }
+  },
+  data: function() {
+    return {
+      canvasId: 'trend-' + Math.random().toString(36).substr(2, 9),
+      drawn: false
+    }
+  },
+  mounted: function() {
+    this.$nextTick(this.drawChart)
+  },
+  watch: {
+    data: function() {
+      this.$nextTick(this.drawChart)
+    }
+  },
+  methods: {
+    drawChart: function() {
+      var ctx = uni.createCanvasContext(this.canvasId, this)
+      var width = 600 // rpx
+      var height = 60
+      var data = this.data
+      if (!data.length) return
+      var min = Math.min.apply(null, data)
+      var max = Math.max.apply(null, data)
+      var range = max - min || 1
+      var stepX = width / (data.length - 1 || 1)
+      ctx.setStrokeStyle('#EF4444')
+      ctx.setLineWidth(3)
+      ctx.beginPath()
+      for (var i = 0; i < data.length; i++) {
+        var x = i * stepX
+        var y = height - (data[i] - min) / range * (height - 10) - 5
+        if (i === 0) ctx.moveTo(x, y)
+        else ctx.lineTo(x, y)
+      }
+      ctx.stroke()
+      // 末端圆点
+      var lastX = (data.length - 1) * stepX
+      var lastY = height - (data[data.length - 1] - min) / range * (height - 10) - 5
+      ctx.setFillStyle('#EF4444')
+      ctx.beginPath()
+      ctx.arc(lastX, lastY, 4, 0, 2 * Math.PI)
+      ctx.fill()
+      ctx.draw()
+      this.drawn = true
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mini-trend {
+  margin: 12rpx 0;
+  width: 100%;
+}
+.trend-canvas {
+  width: 100%;
+  height: 60rpx;
+}
+</style>

+ 2 - 2
cfc-frontend/pages/health-main/index.vue

@@ -250,9 +250,9 @@ export default {
       tabs: [
         { code: 'body', label: '健康', icon: '🟠', color: '#FF8C42' },
         { code: 'cognitive', label: '认知', icon: '🧠', color: '#6366F1' },
-        { code: 'mental', label: '心理', icon: '💖', color: '#FF6B9D' },
+        { code: 'wealth', label: '财富', icon: '💧', color: '#F59E0B' },
         { code: 'relationship', label: '社会性', icon: '🤝', color: '#10B981' },
-        { code: 'wealth', label: '财富', icon: '💧', color: '#F59E0B' }
+        { code: 'mental', label: '心理', icon: '💖', color: '#FF6B9D' }
       ],
       quizzes: [
         { code: 'cognitive', label: '认知能力测评', icon: '🧠', score: null },

+ 40 - 22
cfc-frontend/pages/health/gut-flora-species-detail.vue

@@ -6,7 +6,7 @@
       <view class="tab-item" v-for="(tab, idx) in tabs" :key="idx"
             :class="'tab-' + (selectedTab === tab.key ? 'active' : '')"
             @tap="selectedTab = tab.key">
-        <text>{{ tab.label }}</text>
+        <text>{{ tab.label }} <text class="tab-count">{{ tabCounts[tab.key] }}</text></text>
       </view>
     </view>
 
@@ -22,8 +22,10 @@
           <text class="flora-level-tag" v-if="item.level && item.level !== 'GENUS'">{{ item.level }}</text>
         </view>
         <view class="flora-details" v-if="!isEditing">
-          <text class="detail-text">丰度 {{ item.bacteriaValue || '--' }} | 正常 {{ item.normalRange || '--' }}</text>
-          <text class="detail-text">人群 {{ item.populationLevel || '--' }} | 检出 {{ item.detectionRate || '--' }}</text>
+          <text class="detail-item">人群 {{ item.populationLevel || '--' }}</text>
+          <text class="detail-item">检出 {{ item.detectionRate || '--' }}</text>
+          <text class="detail-item">丰度 {{ item.bacteriaValue || '--' }}</text>
+          <text class="detail-item">正常 {{ item.normalRange || '--' }}</text>
         </view>
         <view class="flora-edit-row" v-if="isEditing">
           <text class="edit-label">丰度:</text>
@@ -83,9 +85,35 @@ export default {
     }
   },
   computed: {
+    /** 每个查询标签对应的菌群数量 */
+    tabCounts: function() {
+      var self = this
+      var counts = {}
+      for (var i = 0; i < this.tabs.length; i++) {
+        var key = this.tabs[i].key
+        counts[key] = self._getTabList(key).length
+      }
+      return counts
+    },
     filteredFlora: function() {
+      return this._getTabList(this.selectedTab)
+    }
+  },
+  onLoad: function(options) {
+    this.reportId = options.reportId ? parseInt(options.reportId) : null
+    if (options.tab) {
+      this.selectedTab = options.tab
+    }
+    if (this.reportId) {
+      this.loadData()
+    }
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    /** 按 tabKey 过滤菌群列表(filteredFlora 与 tabCounts 共用) */
+    _getTabList: function(tabKey) {
       var list = this.isEditing ? this.editFlora : this.gutFlora
-      if (this.selectedTab === 'all') return list
+      if (tabKey === 'all') return list
       // 中文 category 映射到英文 tab key(兼容旧数据)
       var catToTab = {
         'core': 'core', '核心菌': 'core', '核心菌属': 'core',
@@ -95,13 +123,13 @@ export default {
         'pathogen_genus': 'pathogen_genus', '病原菌属': 'pathogen_genus',
         'pathogen_detection': 'pathogen_detection', '病原菌': 'pathogen_detection', '致病菌': 'pathogen_detection'
       }
-      if (this.selectedTab === 'probiotic') {
+      if (tabKey === 'probiotic') {
         return this.isEditing
           ? this.editFlora.filter(function(i) { return (catToTab[i.category] || i.category) === 'probiotic' })
           : this.probioticSpecies
       }
       // Taxonomy tabs: filter by category='taxonomy' + level
-      if (this.selectedTab.indexOf('taxonomy_') === 0) {
+      if (tabKey.indexOf('taxonomy_') === 0) {
         var levelMap = {
           'taxonomy_phylum': 'PHYLUM',
           'taxonomy_class': 'CLASS',
@@ -110,32 +138,20 @@ export default {
           'taxonomy_genus': 'GENUS',
           'taxonomy_species': 'SPECIES'
         }
-        var targetLevel = levelMap[this.selectedTab]
+        var targetLevel = levelMap[tabKey]
         return list.filter(function(item) {
           return item.category === 'taxonomy' && item.level === targetLevel
         })
       }
       // Other category tabs
-      var targetTab = catToTab[this.selectedTab]
+      var targetTab = catToTab[tabKey]
       if (targetTab) {
         return list.filter(function(item) {
           return (catToTab[item.category] || item.category) === targetTab
         })
       }
       return []
-    }
-  },
-  onLoad: function(options) {
-    this.reportId = options.reportId ? parseInt(options.reportId) : null
-    if (options.tab) {
-      this.selectedTab = options.tab
-    }
-    if (this.reportId) {
-      this.loadData()
-    }
-  },
-  methods: {
-    goBack: function() { uni.navigateBack() },
+    },
     /** 中文状态 → CSS安全英文 */
     _cssForStatus: function(s) {
       var map = { '偏高': 'high', '过多': 'excess', '偏低': 'low', '缺乏': 'deficient', '不足': 'insufficient' }
@@ -237,6 +253,7 @@ export default {
 .tabs { background: #fff; padding: 10rpx 20rpx; display: flex; flex-wrap: wrap; border-bottom: 1rpx solid #eee; }
 .tab-item { display: inline-block; padding: 12rpx 24rpx; margin: 6rpx; border-radius: 30rpx; font-size: 24rpx; background: #f0f0f0; color: #666; }
 .tab-active { background: #4A9BD7; color: #fff; }
+.tab-count { font-size: 20rpx; opacity: 0.7; margin-left: 6rpx; }
 .content { padding: 20rpx 30rpx; height: calc(100vh - 200rpx); }
 .flora-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
 .flora-header { display: flex; align-items: center; margin-bottom: 10rpx; }
@@ -249,7 +266,8 @@ export default {
 .arrow-up { color: #C62828; font-size: 22rpx; }
 .arrow-down { color: #E65100; font-size: 22rpx; }
 .flora-level-tag { padding: 2rpx 12rpx; background: #E3F2FD; border-radius: 16rpx; font-size: 20rpx; color: #4A9BD7; }
-.detail-text { font-size: 24rpx; color: #999; display: block; line-height: 1.8; }
+.flora-details { display: flex; align-items: center; }
+.detail-item { flex: 1; font-size: 22rpx; color: #999; text-align: center; white-space: nowrap; }
 .flora-edit-row { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
 .edit-label { font-size: 26rpx; color: #666; }
 .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 140rpx; }

+ 12 - 1
cfc-frontend/pages/health/report-list.vue

@@ -61,7 +61,7 @@
               </view>
               <text class="report-person" v-if="item.personName">{{ item.personName }}</text>
               <text class="report-date">报告日期:{{ formatDate(item.reportDate) }}</text>
-              <text class="report-upload-date">上传日期:{{ formatDate(item.createdAt) }}</text>
+              <text class="report-upload-date">上传日期:{{ formatDateTime(item.createdAt) }}</text>
             </view>
             <view class="report-card-right">
               <text class="report-score">{{ item.overallScore || '--' }}<text class="score-unit">分</text></text>
@@ -299,6 +299,17 @@ export default {
       var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
       var day = ('' + d.getDate()).padStart(2, '0')
       return y + '-' + m + '-' + day
+    },
+    formatDateTime(dateStr) {
+      if (!dateStr) return '--'
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
+      var y = d.getFullYear()
+      var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
+      var day = ('' + d.getDate()).padStart(2, '0')
+      var h = ('' + d.getHours()).padStart(2, '0')
+      var min = ('' + d.getMinutes()).padStart(2, '0')
+      return y + '-' + m + '-' + day + ' ' + h + ':' + min
     }
   }
 }

+ 1 - 43
cfc-frontend/pages/health/report-upload.vue

@@ -19,11 +19,6 @@
           <text class="upload-card-title">拍照上传</text>
           <text class="upload-card-desc">拍摄纸质报告或舌象</text>
         </view>
-        <view class="upload-card card-folder" @click="pickFromFolder">
-          <view class="upload-card-icon">📁</view>
-          <text class="upload-card-title">从文件夹选取</text>
-          <text class="upload-card-desc">手机本地文件</text>
-        </view>
       </view>
 
       <!-- 采集中面板 -->
@@ -172,8 +167,7 @@ export default {
       this.ensureAgreed(function() {
         uni.chooseMessageFile({
           count: 1,
-          type: 'file',
-          extension: ['pdf'],
+          type: 'all',
           success: function(res) {
             var files = res.tempFiles
             if (files && files.length > 0) {
@@ -200,33 +194,6 @@ export default {
         })
       })
     },
-    pickFromFolder: function() {
-      var self = this
-      this.ensureAgreed(function() {
-        uni.chooseMessageFile({
-          count: 1,
-          type: 'file',
-          success: function(res) {
-            var files = res.tempFiles
-            if (files && files.length > 0) {
-              self.uploadReport(files[0].path, files[0].name || '本地文件')
-            }
-          },
-          fail: function() {
-            // 降级到相册
-            uni.chooseImage({
-              count: 1,
-              sourceType: ['album'],
-              success: function(res) {
-                if (res.tempFilePaths && res.tempFilePaths.length > 0) {
-                  self.uploadReport(res.tempFilePaths[0], '相册文件')
-                }
-              }
-            })
-          }
-        })
-      })
-    },
     uploadReport: function(filePath, fileName) {
       if (!filePath) return
       var self = this
@@ -429,15 +396,6 @@ export default {
   color: #fff;
 }
 
-.card-folder {
-  background: #F0FDF4;
-  border: 2rpx dashed #86EFAC;
-}
-.card-folder .upload-card-icon {
-  background: linear-gradient(135deg, #22C55E, #16A34A);
-  color: #fff;
-}
-
 /* 采集中面板 */
 .collecting-panel {
   margin-bottom: 20rpx;

+ 3 - 5
cfc-frontend/pages/index-home/index.vue

@@ -1389,7 +1389,7 @@ membershipDays: 0,
     getImageUrl: function(path) {
       return config.API_BASE_URL + path
     },
-    /** 转发给朋友/群 */
+    /** 转发给朋友/群(不指定 imageUrl,微信自动截取当前页面截图作缩略图;static/share-cover.png 不存在会导致卡片空白) */
     onShareAppMessage: function() {
       var path = '/pages/index-home/index'
       if (this.referralCode) {
@@ -1397,8 +1397,7 @@ membershipDays: 0,
       }
       return {
         title: '浠艾福 · 家庭五维幸福成长',
-        path: path,
-        imageUrl: '/static/share-cover.png'
+        path: path
       }
     },
     /** 分享到朋友圈 */
@@ -1409,8 +1408,7 @@ membershipDays: 0,
       }
       return {
         title: '浠艾福 · 家庭五维幸福成长',
-        query: query,
-        imageUrl: '/static/share-cover.png'
+        query: query
       }
     }
   }

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

@@ -167,7 +167,6 @@ export default {
         }
         return !isSelf
       })
-      })
     },
     detailMemberScores() {
       if (!this.detailMember) return {}

+ 97 - 3
cfc-frontend/pages/tianpan/index.vue

@@ -87,6 +87,17 @@
       <view class="fortune-arrow">查看详情 ›</view>
     </view>
 
+    <!-- 传统命理入口卡片 -->
+    <view class="fortune-card tradition-card" @click="goToTraditional">
+      <view class="fortune-header">
+        <text class="fortune-title">家庭传统命理</text>
+      </view>
+      <view class="fortune-content">
+        <text class="fortune-text">查看家庭成员八字、星座、灵数与五维基础能量分</text>
+      </view>
+      <view class="fortune-arrow">进入命理详情 ›</view>
+    </view>
+
     <!-- 成员详情弹窗 -->
     <view class="member-popup" v-if="showMemberPopup" @touchmove.stop>
       <view class="popup-overlay" @click="closeMemberPopup"></view>
@@ -98,7 +109,7 @@
         <view class="popup-body">
           <view class="member-info-row">
             <text class="info-label">生肖:</text>
-            <text class="info-value">{{ selectedMember.zodiac || '-' }}</text>
+            <text class="info-value">{{ selectedMember.zodiacName || selectedMember.zodiac || '-' }}</text>
           </view>
           <view class="member-info-row">
             <text class="info-label">星座:</text>
@@ -108,6 +119,14 @@
             <text class="info-label">代际:</text>
             <text class="info-value">{{ selectedMember.generation || '-' }}</text>
           </view>
+          <view class="member-info-row" v-if="selectedMember.lifeNumber">
+            <text class="info-label">灵数:</text>
+            <text class="info-value">{{ selectedMember.lifeNumber }}</text>
+          </view>
+          <view class="member-info-row" v-if="selectedMember.eightCharacters">
+            <text class="info-label">八字:</text>
+            <text class="info-value">{{ getEightCharsText(selectedMember.eightCharacters) }}</text>
+          </view>
           <view class="energy-bars">
             <view class="energy-bar-item" v-for="energy in selectedMember.energy" :key="energy.type">
               <text class="energy-label">{{ energy.label }}</text>
@@ -117,6 +136,16 @@
               <text class="energy-value">{{ energy.value }}</text>
             </view>
           </view>
+          <view class="energy-bars" v-if="selectedMember.dimensionBaseScores">
+            <text class="energy-section-title">五维基础分</text>
+            <view class="energy-bar-item" v-for="(val, key) in selectedMember.dimensionBaseScores" :key="getDimKey(key)">
+              <text class="energy-label">{{ getDimLabel(key) }}</text>
+              <view class="energy-bar">
+                <view class="energy-fill" :style="{ width: val + '%', background: getDimColor(key) }"></view>
+              </view>
+              <text class="energy-value">{{ val }}</text>
+            </view>
+          </view>
         </view>
         <view class="popup-footer">
           <button class="detail-btn" @click="goToMemberDetail">查看详情</button>
@@ -397,6 +426,20 @@ export default {
         ctx.textBaseline = 'middle'
         ctx.fillText(member.nickname || member.name, x, y)
         
+        // 绘制生肖+灵数标注
+        var showLabel = member.zodiacName || member.lifeNumber
+        if (showLabel) {
+          var labelParts = []
+          if (member.zodiacName) labelParts.push(member.zodiacName)
+          if (member.lifeNumber) labelParts.push('灵数' + member.lifeNumber)
+          var label = labelParts.join('·')
+          ctx.font = '9px sans-serif'
+          ctx.fillStyle = this.getMemberColor(member.effectiveRole)
+          ctx.textAlign = 'center'
+          ctx.textBaseline = 'top'
+          ctx.fillText(label, x, y + 18)
+        }
+        
         // 存储点击区域
         if (!this.memberHitAreas) {
           this.memberHitAreas = []
@@ -507,6 +550,28 @@ export default {
       return colorMap[role] || '#6B7280'
     },
     
+    getEightCharsText: function(eightChars) {
+      if (!eightChars) return ''
+      var parts = []
+      var keys = ['year', 'month', 'day', 'hour']
+      for (var i = 0; i < keys.length; i++) {
+        if (eightChars[keys[i]]) parts.push(eightChars[keys[i]])
+      }
+      return parts.join('  ')
+    },
+    
+    getDimKey: function(key) { return 'dim-' + key },
+    
+    getDimLabel: function(key) {
+      var labels = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
+      return labels[key] || key
+    },
+    
+    getDimColor: function(key) {
+      var colors = { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' }
+      return colors[key] || '#6B7280'
+    },
+    
     onCanvasTouchEnd: function(e) {
       if (!e || !e.changedTouches || !e.changedTouches[0]) {
         return
@@ -588,6 +653,12 @@ export default {
       })
     },
     
+    goToTraditional: function() {
+      uni.navigateTo({
+        url: '/pages/tianpan/traditional'
+      })
+    },
+    
     loadDailyFortune: function() {
       var today = new Date()
       var dateStr = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate()
@@ -872,6 +943,14 @@ export default {
   margin-top: 20px;
 }
 
+.energy-section-title {
+  font-size: 14px;
+  font-weight: bold;
+  color: #1F2937;
+  margin-bottom: 12px;
+  display: block;
+}
+
 .energy-bar-item {
   display: flex;
   align-items: center;
@@ -920,8 +999,23 @@ export default {
   font-size: 16px;
 }
 
-/* 加载状态 */
-.loading-overlay {
+/* 传统命理入口卡片 */
+.tradition-card {
+  background: linear-gradient(135deg, #F5F0FF 0%, #E0D4FF 100%);
+  box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2);
+}
+
+.tradition-card .fortune-title {
+  color: #6D28D9;
+}
+
+.tradition-card .fortune-text {
+  color: #7C3AED;
+}
+
+.tradition-card .fortune-arrow {
+  color: #8B5CF6;
+}
   position: fixed;
   top: 0;
   left: 0;

+ 438 - 0
cfc-frontend/pages/tianpan/traditional.vue

@@ -0,0 +1,438 @@
+<template>
+  <view class="traditional-container">
+    <!-- 自定义导航栏 -->
+    <view class="custom-nav">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">家庭传统命理</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+
+    <!-- 成员列表 -->
+    <view class="member-list" v-if="!loading && members && members.length > 0">
+      <view class="member-card" v-for="member in members" :key="member.memberId">
+        <!-- 头部 -->
+        <view class="card-header">
+          <view class="avatar" :style="{ backgroundColor: member.colorHex || '#6B7280' }">
+            <text class="avatar-text">{{ member.nickname ? member.nickname.charAt(0) : 'M' }}</text>
+          </view>
+          <view class="member-info">
+            <text class="member-name">{{ member.nickname || member.name }}</text>
+            <text class="member-role">{{ getRoleLabel(member.memberType) }}</text>
+          </view>
+        </view>
+
+        <!-- 八字四柱 -->
+        <view class="section" v-if="member.eightCharacters">
+          <text class="section-title">八字四柱</text>
+          <view class="bazi-grid">
+            <view class="bazi-col" v-for="(val, key) in member.eightCharacters" :key="key">
+              <text class="bazi-label">{{ getPillarLabel(key) }}</text>
+              <text class="bazi-value">{{ val }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 五行能量 -->
+        <view class="section" v-if="member.wuxingElements">
+          <text class="section-title">五行能量</text>
+          <view class="wuxing-list">
+            <view class="wuxing-item" v-for="wx in getWuxingData(member.wuxingElements)" :key="wx.key">
+              <text class="wuxing-label">{{ wx.label }}</text>
+              <view class="wuxing-bar-track">
+                <view class="wuxing-bar-fill" :style="{ width: wx.value + '%', background: wx.color }"></view>
+              </view>
+              <text class="wuxing-value">{{ wx.value }}%</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 生肖星座 -->
+        <view class="section" v-if="member.zodiacName || member.westernSign">
+          <text class="section-title">生肖星座</text>
+          <view class="tag-row">
+            <view class="tag" v-if="member.zodiacName">
+              <text>{{ member.zodiacName }}</text>
+            </view>
+            <view class="tag" v-if="member.westernSign">
+              <text>{{ member.westernSign }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 生命灵数 -->
+        <view class="section" v-if="member.lifeNumber">
+          <text class="section-title">生命灵数</text>
+          <view class="numsoul-display">
+            <view class="numsoul-number" :style="{ background: getLifeNumberColor(member.lifeNumber) }">
+              <text>{{ member.lifeNumber }}</text>
+            </view>
+            <text class="numsoul-title">{{ getLifeNumberTitle(member.lifeNumber) }}</text>
+          </view>
+        </view>
+
+        <!-- 五维基础分 -->
+        <view class="section" v-if="member.dimensionBaseScores">
+          <text class="section-title">五维基础分</text>
+          <view class="dimension-bars">
+            <view class="dim-item" v-for="(val, key) in member.dimensionBaseScores" :key="key">
+              <text class="dim-label">{{ getDimLabel(key) }}</text>
+              <view class="dim-bar-track">
+                <view class="dim-bar-fill" :style="{ width: val + '%', background: getDimColor(key) }"></view>
+              </view>
+              <text class="dim-value">{{ val }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-if="!loading && (!members || members.length === 0)">
+      <text>暂无家庭成员数据</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex'
+
+export default {
+  data: function() {
+    return {
+      loading: false,
+      members: []
+    }
+  },
+  computed: {
+    ...mapState('tianpan', ['loading']),
+    ...mapGetters('tianpan', ['members'])
+  },
+  onLoad: function() {
+    this.loadMembers()
+  },
+  methods: {
+    ...mapActions('tianpan', ['loadDashboard']),
+
+    loadMembers: function() {
+      this.loading = true
+      if (!this.members || this.members.length === 0) {
+        this.loadDashboard({ year: new Date().getFullYear(), daysAhead: 7 })
+          .then(this.fetchMembers)
+          .finally(function() {
+            this.loading = false
+          }.bind(this))
+      } else {
+        this.fetchMembers()
+      }
+    },
+
+    fetchMembers: function() {
+      this.members = this.members || []
+    },
+
+    goBack: function() {
+      uni.navigateBack()
+    },
+
+    getPillarLabel: function(key) {
+      var labels = { year: '年柱', month: '月柱', day: '日柱', hour: '时柱' }
+      return labels[key] || key
+    },
+
+    getWuxingData: function(wuxingElements) {
+      if (!wuxingElements) return []
+      var colorMap = { wood: '#10B981', fire: '#FF6B9D', earth: '#FF8C42', metal: '#6366F1', water: '#3B82F6' }
+      var labelMap = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' }
+      var result = []
+      for (var key in wuxingElements) {
+        if (wuxingElements.hasOwnProperty(key)) {
+          result.push({ key: key, label: labelMap[key] || key, value: wuxingElements[key], color: colorMap[key] || '#94A3B8' })
+        }
+      }
+      return result.sort(function(a, b) { return b.value - a.value })
+    },
+
+    getRoleLabel: function(type) {
+      var labels = { parent: '家长', child: '孩子', teacher: '规划师' }
+      return labels[type] || type
+    },
+
+    getLifeNumberColor: function(num) {
+      var colors = { 1: '#F97316', 2: '#EC4899', 3: '#8B5CF6', 4: '#F59E0B', 5: '#3B82F6', 6: '#10B981', 7: '#6366F1', 8: '#EF4444', 9: '#14B8A6' }
+      return colors[num] || '#6B7280'
+    },
+
+    getLifeNumberTitle: function(num) {
+      var titles = { 1: '开创', 2: '协调', 3: '表达', 4: '稳定', 5: '自由', 6: '责任', 7: '求知', 8: '权力', 9: '博爱' }
+      return titles[num] || ''
+    },
+
+    getDimLabel: function(key) {
+      var labels = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
+      return labels[key] || key
+    },
+
+    getDimColor: function(key) {
+      var colors = { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' }
+      return colors[key] || '#6B7280'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.traditional-container {
+  min-height: 100vh;
+  background: #FFF8F0;
+  padding-bottom: 40px;
+}
+
+.custom-nav {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  padding: 44px 16px 16px;
+  display: flex;
+  align-items: center;
+}
+
+.nav-back {
+  font-size: 16px;
+  color: #FFFFFF;
+  margin-right: 16px;
+}
+
+.nav-title {
+  font-size: 18px;
+  color: #FFFFFF;
+  font-weight: bold;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 120px 0;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 4px solid #E5E7EB;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 16px;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.member-list {
+  padding: 16px;
+}
+
+.member-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.avatar {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 16px;
+}
+
+.avatar-text {
+  color: #FFFFFF;
+  font-size: 28px;
+  font-weight: bold;
+}
+
+.member-info {
+  flex: 1;
+}
+
+.member-name {
+  font-size: 18px;
+  font-weight: bold;
+  color: #1F2937;
+  display: block;
+}
+
+.member-role {
+  font-size: 14px;
+  color: #6B7280;
+}
+
+.section {
+  margin-bottom: 20px;
+}
+
+.section-title {
+  font-size: 14px;
+  color: #9CA3AF;
+  margin-bottom: 12px;
+  font-weight: 500;
+}
+
+.bazi-grid {
+  display: flex;
+  justify-content: space-around;
+}
+
+.bazi-col {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.bazi-label {
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-bottom: 4px;
+}
+
+.bazi-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: #8B5CF6;
+}
+
+.wuxing-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.wuxing-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.wuxing-label {
+  font-size: 14px;
+  width: 24px;
+  text-align: center;
+}
+
+.wuxing-bar-track {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.wuxing-bar-fill {
+  height: 100%;
+  border-radius: 4px;
+}
+
+.wuxing-value {
+  font-size: 12px;
+  color: #6B7280;
+  width: 36px;
+  text-align: right;
+}
+
+.tag-row {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.tag {
+  background: #F3F4F6;
+  padding: 6px 16px;
+  border-radius: 20px;
+  font-size: 14px;
+  color: #374151;
+}
+
+.numsoul-display {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.numsoul-number {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 28px;
+  font-weight: bold;
+  color: #FFFFFF;
+}
+
+.numsoul-title {
+  font-size: 16px;
+  color: #374151;
+  font-weight: 500;
+}
+
+.dimension-bars {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.dim-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.dim-label {
+  font-size: 14px;
+  width: 24px;
+}
+
+.dim-bar-track {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.dim-bar-fill {
+  height: 100%;
+  border-radius: 4px;
+}
+
+.dim-value {
+  font-size: 12px;
+  color: #6B7280;
+  width: 36px;
+  text-align: right;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 120px 0;
+  color: #9CA3AF;
+}
+</style>

+ 62 - 1
cfc-frontend/pages/wisdom-detail/training-hub.vue

@@ -10,6 +10,61 @@
       <text class="desc-text">选择训练游戏,提升你的认知能力</text>
     </view>
 
+    <view class="game-card" @click="goGame('logic-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧩</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">逻辑推理测试</text>
+        <text class="game-desc">数列推理 · 锻炼逻辑思维</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('perception-test')">
+      <view class="game-card-left">
+        <text class="game-icon">👁️</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">感知觉测试</text>
+        <text class="game-desc">找不同 · 提高感官信息处理能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('memory-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧠</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">记忆力测试</text>
+        <text class="game-desc">数字广度 · 增强信息记忆与回忆能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('spatial-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧭</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">空间思维测试</text>
+        <text class="game-desc">方向旋转 · 培养空间想象与构建能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('speed-test')">
+      <view class="game-card-left">
+        <text class="game-icon">⚡</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">加工速度测试</text>
+        <text class="game-desc">符号匹配 · 加快信息处理与反应速度</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
     <view class="game-card" @click="goGame('schulte')">
       <view class="game-card-left">
         <text class="game-icon">🎯</text>
@@ -33,7 +88,7 @@
     </view>
 
     <view class="note-section">
-      <text class="note-text">更多训练游戏正在开发中,敬请期待</text>
+      <text class="note-text">完成测试后,认知雷达图会更新对应维度得分</text>
     </view>
   </view>
 </template>
@@ -60,8 +115,14 @@ export default {
         url = '/pages/games/schulte'
       } else if (game === '1a2b') {
         url = '/pages/games/1a2b'
+      } else {
+        url = '/pages/cognitive-test/' + game
       }
       if (url) {
+        var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+        if (memberId) {
+          url += '?memberId=' + memberId
+        }
         uni.navigateTo({ url: url })
       }
     }

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

@@ -1808,6 +1808,7 @@ export const getEmiReport = (memberId) => request('/api/emireport/latest', 'POST
 export const getEmiFamilyReports = (childIds) => request('/api/emireport/family', 'POST', { childIds })
 export const getCognitiveChildScores = (memberId) => request('/api/cognitive/child', 'POST', { memberId })
 export const getCognitiveFamilyScores = () => request('/api/cognitive/family', 'POST', {})
+export const submitCognitiveSelfTest = (memberId, dimension, score) => request('/api/cognitive/self-test', 'POST', { memberId, dimension, score })
 
 
 // ===== 认知测评结果 =====
@@ -2749,7 +2750,22 @@ export const getGrowthRecommendations = (category = 'health', limit = 3) =>
   request('/api/growth/recommendations', 'POST', { category, limit })
 
 // ===== 食材戒备匹配 =====
-export const getFoodCautionList = () => request('/api/food/recommendation/caution', 'POST', {})
+export const getFoodCautionList = (memberId) => {
+  var data = {}
+  if (memberId) data.memberId = memberId
+  return request('/api/food/recommendation/caution', 'POST', data)
+}
+
+// ===== 戒备页面家庭成员列表 =====
+export const getCautionFamilyMembers = () => request('/api/food/recommendation/members', 'POST', {})
+
+// ===== 戒备食材趋势数据 =====
+export const getFoodCautionTrend = (memberId, foodIds) => {
+  var data = {}
+  if (memberId) data.memberId = memberId
+  if (foodIds && foodIds.length) data.foodIds = foodIds
+  return request('/api/food/recommendation/trend', 'POST', data)
+}
 
 // ===== 健康报告食材推荐(共用 /api/health/foods)=====
 export const getHealthFoods = (reportId, memberId) => {

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d485c9b9b64d5ef5b15d0f51d87f26cc2bb41f29
+9062f32a3b4790b960d34af09ba1a0d0416c6954

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,118 @@
 
 ---
 
+## v1.0.1345 (2026-09-09)
+
+### 新功能
+- 知识中心改名知识分享 + 卡片显示阅读次数总时长
+- 实现 getMyReferrer 获取我的直推人信息
+- 实现 getTeamConsumption 团队消费统计(按 referral_tree level 聚合,fulfillStatus=completed)
+- 注入 ProductOrderMapper、ReferralTreeMapper 到 CommissionService
+
+### 其他
+- - article-center/index.vue: 导航栏标题/注册标题改为知识分享,卡片 footer 新增 ⏱ 总时长
+- - discover/index.vue: 区块标题改为知识分享,推荐卡片 meta 新增 👁 阅读次数 + ⏱ 总时长
+- - 新增 formatDuration() 前端格式化:<60分钟显示分钟,≥1小时显示小时+分钟
+- 
+
+### Bug 修复
+- 修复预存编译错误(HealthReportDraft 重复字段、DatabaseInitializer 缺 logger)
+
+
+## v1.0.1344 (2026-09-09)
+
+### 文档
+- 团队层级树实现计划(13 Tasks,后4+前7)
+
+
+## v1.0.1343 (2026-09-09)
+
+### 新功能
+- 菌群详情页标签增加分类数量统计,列表人群/检出/丰度/正常单行展示
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1342 (2026-09-09)
+
+### 文档
+- 修正团队层级设计 - 订单状态字段/树响应字段/UI实现
+- 团队层级树与推荐关系可视化设计文档
+
+### 其他
+- - 树响应 nodes[] 新增 memberConsumption、myCfEarnedFromMember 字段
+- - 根节点('我')不包含在 nodes 数组中
+- - 更新 UI 实现代码片段(无根节点、flex column-reverse、L1为起点)
+- - audit 通过(0 error)
+- 
+- - 更新 PROJECT-OVERVIEW.md 记录
+- - 涵盖:富页面去推荐码、我的页面推荐人卡片+团队入口、树形页面从下往上生长、团队消费额分级统计(默认3级可配)、直推成员消费/CF明细、4个后端接口新增/扩展
+- - audit 通过(0 error)
+- 
+
+
+## v1.0.1341 (2026-09-09)
+
+### Bug 修复
+- 首页分享卡片去掉不存在的 imageUrl 避免空白
+
+### 其他
+- 改为不指定 imageUrl,微信自动截取当前页面截图作缩略图。
+- 
+
+
+## v1.0.1340 (2026-09-09)
+
+### 文档
+- 同步天盘接口文档
+
+### Bug 修复
+- recalibrate接口补充权限检查
+- 上传报告支持聊天记录选取图片,移除从文件夹选取入口
+- family-members 移除多余括号修复编译错误
+
+### 新功能
+- 新增家庭传统命理页面+入口卡片
+- 天盘首页canvas节点标注生肖灵数+成员弹窗增强
+- 扩展天盘服务支持五维基础分展示和重算接口
+- 新增五维基础分计算服务FamilyMemberDimensionBaseScoreService
+- 新增五维基础分表及迁移305
+
+
+## v1.0.1339 (2026-09-09)
+
+### 新功能
+- 新增5个认知测试页面 + 自测提交API
+
+### 其他
+- - CognitiveService.submitSelfTestScore() 支持无档案时自动建表
+- - 前端新增 5 个认知测试页:逻辑推理/感知觉/记忆力/空间思维/加工速度
+- - 训练中心入口新增 5 个测试卡片
+- - 健康页面 tab 顺序调整为:健康/认知/财富/社会性/心理
+- 
+
+
+## v1.0.1338 (2026-09-09)
+
+### 文档
+- 更新天盘设计——五维基础分改为落库+重算接口
+- 家庭天盘传统文化+五维基础分设计文档
+
+
+## v1.0.1337 (2026-09-09)
+
+### 新功能
+- 情绪识别接入 DeepFace (LangGraph + 后端接口)
+
+### 其他
+- - cfc-backend: 新增 EmotionRecognitionController(文件上传/URL 两种方式)
+- - cfc-backend: AiGateway 新增 analyzeEmotion / analyzeEmotionByUrl 方法
+- - docs: 新增舌诊情绪识别技术选型报告,更新 API_REFERENCE.md
+- 
+
+
 ## v1.0.1336 (2026-09-08)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1336
+> 当前版本: v1.0.1345
 
 ## 历史版本
 
@@ -8,6 +8,118 @@
 
 ---
 
+## v1.0.1345 (2026-09-09)
+
+### 新功能
+- 知识中心改名知识分享 + 卡片显示阅读次数总时长
+- 实现 getMyReferrer 获取我的直推人信息
+- 实现 getTeamConsumption 团队消费统计(按 referral_tree level 聚合,fulfillStatus=completed)
+- 注入 ProductOrderMapper、ReferralTreeMapper 到 CommissionService
+
+### 其他
+- - article-center/index.vue: 导航栏标题/注册标题改为知识分享,卡片 footer 新增 ⏱ 总时长
+- - discover/index.vue: 区块标题改为知识分享,推荐卡片 meta 新增 👁 阅读次数 + ⏱ 总时长
+- - 新增 formatDuration() 前端格式化:<60分钟显示分钟,≥1小时显示小时+分钟
+- 
+
+### Bug 修复
+- 修复预存编译错误(HealthReportDraft 重复字段、DatabaseInitializer 缺 logger)
+
+
+## v1.0.1344 (2026-09-09)
+
+### 文档
+- 团队层级树实现计划(13 Tasks,后4+前7)
+
+
+## v1.0.1343 (2026-09-09)
+
+### 新功能
+- 菌群详情页标签增加分类数量统计,列表人群/检出/丰度/正常单行展示
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.1342 (2026-09-09)
+
+### 文档
+- 修正团队层级设计 - 订单状态字段/树响应字段/UI实现
+- 团队层级树与推荐关系可视化设计文档
+
+### 其他
+- - 树响应 nodes[] 新增 memberConsumption、myCfEarnedFromMember 字段
+- - 根节点('我')不包含在 nodes 数组中
+- - 更新 UI 实现代码片段(无根节点、flex column-reverse、L1为起点)
+- - audit 通过(0 error)
+- 
+- - 更新 PROJECT-OVERVIEW.md 记录
+- - 涵盖:富页面去推荐码、我的页面推荐人卡片+团队入口、树形页面从下往上生长、团队消费额分级统计(默认3级可配)、直推成员消费/CF明细、4个后端接口新增/扩展
+- - audit 通过(0 error)
+- 
+
+
+## v1.0.1341 (2026-09-09)
+
+### Bug 修复
+- 首页分享卡片去掉不存在的 imageUrl 避免空白
+
+### 其他
+- 改为不指定 imageUrl,微信自动截取当前页面截图作缩略图。
+- 
+
+
+## v1.0.1340 (2026-09-09)
+
+### 文档
+- 同步天盘接口文档
+
+### Bug 修复
+- recalibrate接口补充权限检查
+- 上传报告支持聊天记录选取图片,移除从文件夹选取入口
+- family-members 移除多余括号修复编译错误
+
+### 新功能
+- 新增家庭传统命理页面+入口卡片
+- 天盘首页canvas节点标注生肖灵数+成员弹窗增强
+- 扩展天盘服务支持五维基础分展示和重算接口
+- 新增五维基础分计算服务FamilyMemberDimensionBaseScoreService
+- 新增五维基础分表及迁移305
+
+
+## v1.0.1339 (2026-09-09)
+
+### 新功能
+- 新增5个认知测试页面 + 自测提交API
+
+### 其他
+- - CognitiveService.submitSelfTestScore() 支持无档案时自动建表
+- - 前端新增 5 个认知测试页:逻辑推理/感知觉/记忆力/空间思维/加工速度
+- - 训练中心入口新增 5 个测试卡片
+- - 健康页面 tab 顺序调整为:健康/认知/财富/社会性/心理
+- 
+
+
+## v1.0.1338 (2026-09-09)
+
+### 文档
+- 更新天盘设计——五维基础分改为落库+重算接口
+- 家庭天盘传统文化+五维基础分设计文档
+
+
+## v1.0.1337 (2026-09-09)
+
+### 新功能
+- 情绪识别接入 DeepFace (LangGraph + 后端接口)
+
+### 其他
+- - cfc-backend: 新增 EmotionRecognitionController(文件上传/URL 两种方式)
+- - cfc-backend: AiGateway 新增 analyzeEmotion / analyzeEmotionByUrl 方法
+- - docs: 新增舌诊情绪识别技术选型报告,更新 API_REFERENCE.md
+- 
+
+
 ## v1.0.1336 (2026-09-08)
 
 ### 新功能

+ 1 - 1
cfc-web/src/views/admin/ReportAutoLearn.vue

@@ -99,7 +99,7 @@ export default {
       this.detailVisible = true
     },
     handleGenerate(row) {
-      this.$confirm('确认为此聚类生成报告类型和指纹规则?生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
+      this.$confirm('确认为此聚类生成报告类型和指纹规则?系统将自动通过 opencode 生成采集脚本并测试,生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
         .then(async () => {
           try {
             const res = await generateTypeFromCluster({ clusterId: row.id })

+ 8 - 0
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -31,6 +31,14 @@
             </el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="脚本状态" width="110">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.scriptStatus === 'generating'" type="warning" size="small">生成中</el-tag>
+            <el-tag v-else-if="row.scriptStatus === 'ready'" type="success" size="small">测试通过</el-tag>
+            <el-tag v-else-if="row.scriptStatus === 'failed'" type="danger" size="small">生成失败</el-tag>
+            <span v-else style="color:#bbb;">—</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="source" label="来源" width="100" />
         <el-table-column label="操作" width="220" fixed="right">
           <template slot-scope="{ row }">

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -369,6 +369,7 @@
 | `2026-08-31-coupon-family-based-design.md` | 🟡 设计稿 | 优惠券全链路改为家庭维度(新建 family_coupon / family_coupon_grant_log 表,6 条发放路径全部改家庭,消费/核销同步;API 路径与响应字段保持不变) |
 | `2026-08-31-self-check-reminder-design.md` | 🟡 设计稿(15 天复检周期 + 首页入口显示自检分数 + 题目轮换 + 后端忽略记录,纯设计方案) | 五维家庭自检 15 天复检周期与题目轮换设计 |
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
+| `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`) | 后台接口参考文档 |
 
 ### 实施计划(plans/)

+ 39 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -780,6 +780,12 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/recommend/repurchase-reminders` | 复购提醒 |
 | `POST /api/task-reminders/upcoming/{memberId}` | 即将到期提醒 |
 | `POST /api/tianpan/dashboard` | 天盘概览 |
+| `POST /api/tianpan/member/{memberId}` | 成员详情 |
+| `POST /api/tianpan/compatibility` | 兼容性分析 |
+| `POST /api/tianpan/annual-energy` | 年度能量 |
+| `POST /api/tianpan/daily-fortune` | 每日运势 |
+| `POST /api/tianpan/related-items` | 相关事项 |
+| `POST /api/tianpan/recalibrate` | 重算五维基础分(管理员) |
 | `POST /api/wisdom/gut-cognition` | 肠道认知 |
 | `POST /api/wealth/checkin/*` | 财富打卡 |
 | `POST /api/insurance/*` | 保险规划 |
@@ -1043,10 +1049,43 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
+### 4.38 认知维度(`/api/cognitive/*`)
+
+六维认知体系:感知(perception) / 专注(focus) / 记忆(memory) / 逻辑(logic) / 空间(spatial) / 加工速度(processingSpeed)。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/cognitive/child` | 获取孩子最新六维认知得分 |
+| `POST /api/cognitive/family` | 获取家庭所有孩子最新六维认知得分 |
+| `POST /api/cognitive/update` | 更新认知维度得分(由规划师录入,需已有 completed 行) |
+| `POST /api/cognitive/self-test` | 提交认知自测结果(自动创建档案,写入六维认知档案,综合分自动计算) |
+| `POST /api/cognitive/norms` | 获取认知维度百分位常模 |
+| `POST /api/cognitive/trend` | 获取认知维度历史趋势 |
+| `POST /api/cognitive/recommendations` | 获取认知训练建议(按薄弱维度生成) |
+| `POST /api/cognitive/recommendations/create-task` | 从建议创建每日任务 |
+
+**`/api/cognitive/self-test` 请求体:**
+```json
+{
+  "memberId": 12345,
+  "dimension": "memoryScore",
+  "score": 85
+}
+```
+dimension 取值:`memoryScore` / `logicScore` / `perceptionScore` / `spatialScore` / `processingSpeedScore`
+score 范围:0-100(整数)
+
+**响应:** `Result<String>`,成功返回 "提交成功",失败返回错误原因。
+
+**行为:** 若该孩子尚无任何 `status=completed` 的 DanAssessmentResult 行,自动创建(source='self_test'),写入对应维度分,重新计算综合分(7 维度非空均值)。
+
+**已记录认知自测 API 端点 — 禁止重复注册。**
+
 ## 六、待清理的废弃接口
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
 |------------|----------|----------|----------|----------|
+|------------|----------|----------|----------|----------|
 | `FamilyController` | `/invite-code`, `/invite-code/generate` | `/api/family/invite/generate` | @Deprecated + 410 ✓ | 确认前端无调用 |
 | `FamilyUserController` | `/switch-mode`, `/switch-to-child` | `/api/family/member/switch` | @Deprecated + 410 ✓ | 确认前端无调用 |
 | `FamilyUserController` | `/switch-back-to-parent`, `/switch-back-verify` | `/api/family/member/switch` | 已修复:@Deprecated + 410 | 确认前端无调用 |

+ 1055 - 0
docs/superpowers/plans/2026-09-09-team-hierarchy.md

@@ -0,0 +1,1055 @@
+# 团队层级树与推荐关系可视化 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 实现「我的」页面推荐人卡片、团队层级入口、独立树形页面(从下往上生长),去掉富页面推荐码,新增 3 个后端接口并扩展 1 个现有接口。
+
+**架构:**
+- 后端:在 `CommissionService` 新增 `getTeamConsumption`、`getMyReferrer`、`getTeamTree`,扩展 `getTeamList`;`CommissionController` 暴露 4 个 POST 接口
+- 前端:删除 `wealth/index.vue` 推荐码横条;`profile/profile.vue` 新增推荐人卡片 + 入口按钮;新建 `profile/team-tree.vue` 树形页面(flex column-reverse + 递归组件);`utils/api.js` 新增 3 个方法;`pages.json` 注册新页面
+
+**技术栈:** Spring Boot 2.7 + MyBatis-Plus / uni-app Vue 2 (Options API)
+
+---
+
+### 任务 1:后端 - 新增 Mapper 依赖注入
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java:39-60`
+
+- [ ] **步骤 1:编写测试用例(手工验证)**
+
+```bash
+# 编译验证
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 2:添加注入字段**
+
+在 `CommissionService` 类中已有的注入字段后追加:
+
+```java
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+
+    @Resource
+    private CfReferralService cfReferralService;
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "feat: 注入 ProductOrderMapper、ReferralTreeMapper、CfReferralService 到 CommissionService"
+```
+
+---
+
+### 任务 2:后端 - 实现 `getTeamConsumption`(团队消费统计)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`(在 `getTeamStats` 方法后,约第 599 行后追加)
+
+- [ ] **步骤 1:编写 curl 测试**
+
+```bash
+curl -X POST http://localhost:9082/api/commission/team/consumption \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"maxLevel": 3}'
+# 预期:{"code":200,"data":{"totalConsumption":...,"levelBreakdown":[...],"totalMembers":...,"maxLevel":3}}
+```
+
+- [ ] **步骤 2:实现方法**
+
+```java
+    /**
+     * 团队总消费额分级汇总(按 referral_tree level 聚合)
+     * maxLevel 默认 3,可后台配置
+     */
+    public Map<String, Object> getTeamConsumption(Long userId, Integer maxLevel) {
+        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
+        if (maxLevel > 10) maxLevel = 10; // 安全上限
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> levelBreakdown = new ArrayList<>();
+
+        // 1. 查 referral_tree: parentId = userId 且 level <= maxLevel
+        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, userId)
+                        .le(ReferralTree::getLevel, maxLevel)
+                        .orderByAsc(ReferralTree::getLevel)
+        );
+
+        if (treeNodes.isEmpty()) {
+            result.put("totalConsumption", 0);
+            result.put("levelBreakdown", levelBreakdown);
+            result.put("totalMembers", 0);
+            result.put("maxLevel", maxLevel);
+            return result;
+        }
+
+        // 2. 按 level 分组收集 childId
+        Map<Integer, List<Long>> levelToChildIds = new LinkedHashMap<>();
+        for (ReferralTree node : treeNodes) {
+            levelToChildIds.computeIfAbsent(node.getLevel(), k -> new ArrayList<>()).add(node.getChildId());
+        }
+
+        // 3. 查 product_orders: buyerId IN childIds 且 fulfillStatus = 'completed'
+        int totalConsumption = 0;
+        for (Map.Entry<Integer, List<Long>> entry : levelToChildIds.entrySet()) {
+            Integer level = entry.getKey();
+            List<Long> childIds = entry.getValue();
+            if (childIds.isEmpty()) continue;
+
+            LambdaQueryWrapper<ProductOrder> orderWrapper = new LambdaQueryWrapper<ProductOrder>()
+                    .in(ProductOrder::getBuyerId, childIds)
+                    .eq(ProductOrder::getFulfillStatus, "completed");
+            Long sum = productOrderMapper.selectList(orderWrapper).stream()
+                    .mapToLong(o -> o.getTotalAmount() != null ? o.getTotalAmount() : 0)
+                    .sum();
+
+            int memberCount = childIds.size();
+            totalConsumption += sum;
+
+            Map<String, Object> levelInfo = new LinkedHashMap<>();
+            levelInfo.put("level", level);
+            levelInfo.put("memberCount", memberCount);
+            levelInfo.put("consumption", (int) sum);
+            levelBreakdown.add(levelInfo);
+        }
+
+        result.put("totalConsumption", totalConsumption);
+        result.put("levelBreakdown", levelBreakdown);
+        result.put("totalMembers", treeNodes.size());
+        result.put("maxLevel", maxLevel);
+        return result;
+    }
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "feat: 实现 getTeamConsumption 团队消费统计(按 referral_tree level 聚合,fulfillStatus=completed)"
+```
+
+---
+
+### 任务 3:后端 - 实现 `getMyReferrer`(我的推荐人)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`(在 getTeamConsumption 后新增)
+
+- [ ] **步骤 1:实现方法**
+
+```java
+    /**
+     * 获取我的推荐人信息(直推人)
+     */
+    public Map<String, Object> getMyReferrer(Long userId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getReferrerId() == null) {
+            result.put("hasReferrer", false);
+            return result;
+        }
+        User referrer = userMapper.selectById(user.getReferrerId());
+        if (referrer == null) {
+            result.put("hasReferrer", false);
+            return result;
+        }
+        result.put("hasReferrer", true);
+        result.put("referrerId", referrer.getId());
+        result.put("nickname", referrer.getNickname());
+        result.put("avatar", referrer.getAvatar());
+        result.put("referralCode", referrer.getReferralCode());
+        result.put("bindTime", user.getCreatedAt()); // 以用户创建时间作为绑定时间近似
+        return result;
+    }
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "feat: 实现 getMyReferrer 获取我的直推人信息"
+```
+
+---
+
+### 任务 4:后端 - 实现 `getTeamTree`(树形扁平数据)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`(在 getMyReferrer 后新增)
+
+- [ ] **步骤 1:实现方法**
+
+```java
+    /**
+     * 获取团队树形扁平数据(不含根节点,仅 level>=1 成员)
+     * 每个节点包含 memberConsumption、myCfEarnedFromMember
+     */
+    public Map<String, Object> getTeamTree(Long userId, Integer maxLevel) {
+        if (maxLevel == null || maxLevel <= 0) maxLevel = 3;
+        if (maxLevel > 10) maxLevel = 10;
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> nodes = new ArrayList<>();
+
+        // 1. 查 referral_tree
+        List<ReferralTree> treeNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, userId)
+                        .le(ReferralTree::getLevel, maxLevel)
+                        .orderByAsc(ReferralTree::getLevel)
+        );
+
+        if (treeNodes.isEmpty()) {
+            result.put("nodes", nodes);
+            result.put("totalMembers", 0);
+            result.put("maxLevel", maxLevel);
+            return result;
+        }
+
+        // 2. 批量查用户基本信息
+        List<Long> childIds = treeNodes.stream().map(ReferralTree::getChildId).distinct().collect(Collectors.toList());
+        Map<Long, User> userMap = userMapper.selectBatchIds(childIds).stream()
+                .collect(Collectors.toMap(User::getId, u -> u));
+
+        // 3. 按成员聚合 memberConsumption(fulfillStatus=completed 订单总额)
+        Map<Long, Integer> memberConsumptionMap = new HashMap<>();
+        if (!childIds.isEmpty()) {
+            List<ProductOrder> orders = productOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .in(ProductOrder::getBuyerId, childIds)
+                            .eq(ProductOrder::getFulfillStatus, "completed")
+            );
+            for (ProductOrder o : orders) {
+                if (o.getBuyerId() != null && o.getTotalAmount() != null) {
+                    memberConsumptionMap.merge(o.getBuyerId(), o.getTotalAmount(), Integer::sum);
+                }
+            }
+        }
+
+        // 4. 按成员聚合 myCfEarnedFromMember(commission_records 已结算+待结算,排除 cancelled)
+        Map<Long, Integer> myCfEarnedMap = new HashMap<>();
+        if (!childIds.isEmpty()) {
+            List<CommissionRecord> records = commissionRecordMapper.selectList(
+                    new LambdaQueryWrapper<CommissionRecord>()
+                            .eq(CommissionRecord::getReferrerId, userId)
+                            .in(CommissionRecord::getBuyerId, childIds)
+                            .ne(CommissionRecord::getStatus, "cancelled")
+            );
+            for (CommissionRecord r : records) {
+                if (r.getBuyerId() != null && r.getCommissionAmount() != null) {
+                    myCfEarnedMap.merge(r.getBuyerId(), r.getCommissionAmount(), Integer::sum);
+                }
+            }
+        }
+
+        // 5. 组装 nodes(不含根节点,仅 level>=1 的成员)
+        for (ReferralTree node : treeNodes) {
+            User u = userMap.get(node.getChildId());
+            if (u == null) continue;
+
+            Map<String, Object> n = new LinkedHashMap<>();
+            n.put("userId", node.getChildId());
+            n.put("nickname", u.getNickname());
+            n.put("avatar", u.getAvatar());
+            n.put("level", node.getLevel());
+            n.put("parentId", node.getParentId());
+            n.put("referrerId", u.getReferrerId());
+            n.put("memberConsumption", memberConsumptionMap.getOrDefault(node.getChildId(), 0));
+            n.put("myCfEarnedFromMember", myCfEarnedMap.getOrDefault(node.getChildId(), 0));
+            nodes.add(n);
+        }
+
+        result.put("nodes", nodes);
+        result.put("totalMembers", nodes.size());
+        result.put("maxLevel", maxLevel);
+        return result;
+    }
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "feat: 实现 getTeamTree 树形扁平数据(含 memberConsumption、myCfEarnedFromMember)"
+```
+
+---
+
+### 任务 5:后端 - 扩展 `getTeamList`(新增消费/CF 字段)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java`(`getTeamList` 方法,约第 460-543 行)
+
+- [ ] **步骤 1:在 getTeamList 中,循环查询 memberConsumption 和 myCfEarnedFromMember 之前,改为预查所有成员的汇总**
+
+在现有 `getTeamList` 方法中,找到 `for (Map.Entry<Long, Map<String, Object>> entry : memberMap.entrySet())` 循环**之前**,插入预查逻辑(替换现有循环内的逐成员查询):
+
+```java
+        // 预查所有成员的 memberConsumption 和 myCfEarnedFromMember
+        Set<Long> allBuyerIds = memberMap.keySet();
+        Map<Long, Integer> memberConsumptionMap = new HashMap<>();
+        Map<Long, Integer> myCfEarnedMap = new HashMap<>();
+
+        if (!allBuyerIds.isEmpty()) {
+            // memberConsumption:该成员已完成履约订单总额
+            LambdaQueryWrapper<ProductOrder> consumptionWrapper = new LambdaQueryWrapper<ProductOrder>()
+                    .in(ProductOrder::getBuyerId, allBuyerIds)
+                    .eq(ProductOrder::getFulfillStatus, "completed");
+            for (ProductOrder o : productOrderMapper.selectList(consumptionWrapper)) {
+                if (o.getBuyerId() != null && o.getTotalAmount() != null) {
+                    memberConsumptionMap.merge(o.getBuyerId(), o.getTotalAmount(), Integer::sum);
+                }
+            }
+            // myCfEarnedFromMember:我从该成员消费获得的 CF 总额
+            LambdaQueryWrapper<CommissionRecord> cfWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                    .eq(CommissionRecord::getReferrerId, userId)
+                    .in(CommissionRecord::getBuyerId, allBuyerIds)
+                    .ne(CommissionRecord::getStatus, "cancelled");
+            if ("L1".equals(level)) cfWrapper.eq(CommissionRecord::getLevel, 1);
+            else if ("L2".equals(level)) cfWrapper.eq(CommissionRecord::getLevel, 2);
+            SortUtil.applySort(cfWrapper);
+            for (CommissionRecord r : commissionRecordMapper.selectList(cfWrapper)) {
+                if (r.getBuyerId() != null && r.getCommissionAmount() != null) {
+                    myCfEarnedMap.merge(r.getBuyerId(), r.getCommissionAmount(), Integer::sum);
+                }
+            }
+        }
+```
+
+- [ ] **步骤 2:在现有循环内部,用预查结果替换原来的逐成员查询,加入新字段**
+
+找到现有循环内:
+```java
+            int commissionEarned = commissionRecordMapper.selectList(commissionWrapper).stream()
+                    .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
+                    .sum();
+            memberInfo.put("commissionEarned", commissionEarned);
+```
+替换为:
+```java
+            memberInfo.put("memberConsumption", memberConsumptionMap.getOrDefault(buyerId, 0));
+            memberInfo.put("myCfEarnedFromMember", myCfEarnedMap.getOrDefault(buyerId, 0));
+            // 保留原有 commissionEarned(用于展示佣金)
+            int commissionEarned = commissionRecordMapper.selectList(commissionWrapper).stream()
+                    .mapToInt(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : 0)
+                    .sum();
+            memberInfo.put("commissionEarned", commissionEarned);
+```
+
+- [ ] **步骤 3:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
+git commit -m "feat: 扩展 getTeamList 新增 memberConsumption、myCfEarnedFromMember 字段"
+```
+
+---
+
+### 任务 6:后端 - 新增 Controller 端点
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java`(在类末尾前追加 3 个方法)
+
+- [ ] **步骤 1:追加 3 个新端点**
+
+```java
+    /**
+     * 团队消费额分级汇总
+     */
+    @PostMapping("/team/consumption")
+    public Result<Map<String, Object>> teamConsumption(@RequestAttribute("userId") Long userId,
+                                                        @RequestBody Map<String, Object> params) {
+        Integer maxLevel = params.get("maxLevel") != null
+                ? ((Number) params.get("maxLevel")).intValue()
+                : 3;
+        return Result.success(commissionService.getTeamConsumption(userId, maxLevel));
+    }
+
+    /**
+     * 获取我的推荐人
+     */
+    @PostMapping("/my-referrer")
+    public Result<Map<String, Object>> myReferrer(@RequestAttribute("userId") Long userId) {
+        return Result.success(commissionService.getMyReferrer(userId));
+    }
+
+    /**
+     * 团队树形扁平数据
+     */
+    @PostMapping("/team/tree")
+    public Result<Map<String, Object>> teamTree(@RequestAttribute("userId") Long userId,
+                                                 @RequestBody Map<String, Object> params) {
+        Integer maxLevel = params.get("maxLevel") != null
+                ? ((Number) params.get("maxLevel")).intValue()
+                : 3;
+        return Result.success(commissionService.getTeamTree(userId, maxLevel));
+    }
+```
+
+- [ ] **步骤 2:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java
+git commit -m "feat: 新增 /api/commission/team/consumption、/my-referrer、/team/tree 接口"
+```
+
+---
+
+### 任务 7:后端 - 更新 API_REFERENCE.md
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`(在 4.21 佣金管理章节后追加)
+
+- [ ] **步骤 1:追加新接口文档**
+
+在 `### 4.21 佣金管理(/api/commission/*)` 之后新增:
+
+```markdown
+### 4.22 团队消费与推荐关系(`/api/commission/*` 扩展)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/commission/team/list` | 扩展:返回中每个成员新增 `memberConsumption`(该成员自身完成履约订单总额,分)、`myCfEarnedFromMember`(我从该成员消费获得的 CF 总额,分) |
+| `POST /api/commission/team/consumption` | 团队总消费额分级汇总,请求 `{maxLevel}`(默认 3),返回 `{totalConsumption: 分, levelBreakdown[{level, memberCount, consumption}], totalMembers, maxLevel}` |
+| `POST /api/commission/my-referrer` | 获取我的直推人信息,返回 `{hasReferrer: bool, referrerId, nickname, avatar, referralCode, bindTime}`,无推荐人时 hasReferrer=false |
+| `POST /api/commission/team/tree` | 团队树形扁平数据(不含根节点),请求 `{maxLevel}`(默认 3),返回 `{nodes[{userId, nickname, avatar, level, parentId, referrerId, memberConsumption, myCfEarnedFromMember}], totalMembers, maxLevel}` |
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git commit -m "docs: 更新 API_REFERENCE.md 新增团队消费与推荐关系接口"
+```
+
+---
+
+### 任务 8:前端 - 删除富页面推荐码横条
+
+**文件:**
+- 修改:`cfc-frontend/pages/wealth/index.vue:161-165`
+
+- [ ] **步骤 1:删除模板中的 `referral-code-bar` 区块**
+
+```diff
+-       <!-- 邀请码复制条 -->
+-       <view class="referral-code-bar" v-if="wealthData.referralCode" @click="copyReferralCode">
+-         <text class="referral-code-label">我的邀请码</text>
+-         <text class="referral-code-value">{{ wealthData.referralCode }}</text>
+-         <text class="referral-code-copy">复制</text>
+-       </view>
+```
+
+- [ ] **步骤 2:删除 CSS 中 `.referral-code-bar` 相关样式**
+
+```diff
+- /* 邀请码复制条 */
+- .referral-code-bar {
+-   display: flex;
+-   align-items: center;
+-   background: #fff;
+-   margin-top: 16rpx;
+-   border-radius: 16rpx;
+-   padding: 24rpx 30rpx;
+-   box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+- }
+- .referral-code-label { font-size: 26rpx; color: #666; margin-right: 16rpx; }
+- .referral-code-value { flex: 1; font-size: 30rpx; font-weight: bold; color: #F59E0B; letter-spacing: 2rpx; }
+- .referral-code-copy { font-size: 24rpx; color: #fff; background: linear-gradient(135deg, #F59E0B, #FBBF24); padding: 8rpx 24rpx; border-radius: 20rpx; }
+```
+
+- [ ] **步骤 3:删除 methods 中的 `copyReferralCode` 方法(如不再使用)**
+
+检查 `copyReferralCode` 是否在其他地方使用,若未使用则删除。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/wealth/index.vue
+git commit -m "feat(wealth): 删除富页面推荐码横条"
+```
+
+---
+
+### 任务 9:前端 - 我的页面新增推荐人卡片 + 团队入口
+
+**文件:**
+- 修改:`cfc-frontend/pages/profile-main/profile.vue`
+
+- [ ] **步骤 1:import 部分追加**
+
+```js
+// 在现有 import 中追加
+import { getMyReferrer } from '../../utils/api.js'
+```
+
+- [ ] **步骤 2:data() 追加字段**
+
+```js
+data() {
+  return {
+    // ...existing fields...
+    myReferrer: null
+  }
+}
+```
+
+- [ ] **步骤 3:methods 新增加载与工具方法**
+
+```js
+methods: {
+  // ...existing methods...
+  async loadMyReferrer() {
+    var self = this
+    try {
+      var res = await getMyReferrer()
+      if (res && res.code === 200 && res.data && res.data.hasReferrer) {
+        self.myReferrer = res.data
+      }
+    } catch (e) {
+      console.error('获取推荐人失败', e)
+    }
+  },
+  goTeamTree() {
+    uni.navigateTo({ url: '/pages/profile/team-tree' })
+  },
+  copyReferrerCode() {
+    if (!this.myReferrer || !this.myReferrer.referralCode) return
+    uni.setClipboardData({
+      data: this.myReferrer.referralCode,
+      success: function() {
+        uni.showToast({ title: '邀请码已复制', icon: 'success' })
+      }
+    })
+  }
+}
+```
+
+- [ ] **步骤 4:onShow 中调用 loadMyReferrer**
+
+```js
+onShow() {
+  if (!this.isLoggedIn) return
+  // ...existing...
+  this.loadMyReferrer()
+}
+```
+
+- [ ] **步骤 5:模板中在「个人成就统计」section 前插入以下 HTML**
+
+```html
+<!-- 我的推荐人卡片 -->
+<view class="referrer-card" v-if="myReferrer && myReferrer.hasReferrer">
+  <view class="referrer-header">
+    <text class="referrer-title">我的推荐人</text>
+  </view>
+  <view class="referrer-content">
+    <view class="referrer-avatar-wrap">
+      <image v-if="myReferrer.avatar" class="referrer-avatar" :src="myReferrer.avatar" mode="aspectFill"></image>
+      <text v-else class="referrer-avatar-text">{{ myReferrer.nickname ? myReferrer.nickname.charAt(0) : '?' }}</text>
+    </view>
+    <view class="referrer-info">
+      <text class="referrer-name">{{ myReferrer.nickname }}</text>
+      <text class="referrer-code">邀请码:{{ myReferrer.referralCode }}</text>
+      <text class="referrer-time" v-if="myReferrer.bindTime">{{ formatReferralTime(myReferrer.bindTime) }}</text>
+    </view>
+    <view class="referrer-copy-btn" @click="copyReferrerCode">
+      <text>复制</text>
+    </view>
+  </view>
+</view>
+
+<!-- 团队层级入口 -->
+<view class="team-tree-entry" @click="goTeamTree">
+  <view class="entry-icon">🌳</view>
+  <view class="entry-text">
+    <text class="entry-title">团队层级</text>
+    <text class="entry-desc">查看团队结构与消费明细</text>
+  </view>
+  <text class="entry-arrow">›</text>
+</view>
+```
+
+- [ ] **步骤 6:添加时间格式化方法**
+
+```js
+methods: {
+  // ...existing...
+  formatReferralTime(ts) {
+    if (!ts) return ''
+    var d = ts
+    if (typeof d === 'string' && d.indexOf('T') > -1) {
+      d = d.substring(0, 16).replace('T', ' ')
+    }
+    return '绑定于 ' + d
+  }
+}
+```
+
+- [ ] **步骤 7:在 style 末尾追加样式**
+
+```css
+/* 我的推荐人卡片 */
+.referrer-card {
+  margin: 0 30rpx 20rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.referrer-header { margin-bottom: 16rpx; }
+.referrer-title { font-size: 28rpx; font-weight: 600; color: #1E293B; }
+.referrer-content { display: flex; align-items: center; gap: 20rpx; }
+.referrer-avatar-wrap {
+  width: 72rpx; height: 72rpx; border-radius: 50%; background: #FEF3C7;
+  display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;
+}
+.referrer-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; }
+.referrer-avatar-text { font-size: 28rpx; color: #F97316; }
+.referrer-info { flex: 1; min-width: 0; }
+.referrer-name { font-size: 28rpx; font-weight: 500; color: #1E293B; display: block; }
+.referrer-code { font-size: 24rpx; color: #F97316; margin-top: 4rpx; display: block; }
+.referrer-time { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
+.referrer-copy-btn {
+  font-size: 26rpx; color: #F97316; padding: 8rpx 16rpx; border: 1rpx solid #FED7AA;
+  border-radius: 20rpx; flex-shrink: 0;
+}
+.team-tree-entry {
+  margin: 0 30rpx 20rpx;
+  background: linear-gradient(135deg, #FFF7ED, rgba(249,115,22,0.06));
+  border-radius: 20rpx; padding: 24rpx;
+  display: flex; align-items: center; gap: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+  border-left: 6rpx solid #F97316;
+}
+.entry-icon { font-size: 40rpx; }
+.entry-text { flex: 1; min-width: 0; }
+.entry-title { font-size: 28rpx; font-weight: 600; color: #1E293B; display: block; }
+.entry-desc { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
+.entry-arrow { font-size: 28rpx; color: #F97316; }
+```
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-frontend/pages/profile-main/profile.vue
+git commit -m "feat(profile): 新增我的推荐人卡片与团队层级入口"
+```
+
+---
+
+### 任务 10:前端 - 新增 api.js 方法
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`(在现有 commission 相关 export 后追加)
+
+- [ ] **步骤 1:追加 3 个 export 函数**
+
+```javascript
+export const getTeamConsumption = (maxLevel = 3) => request('/api/commission/team/consumption', 'POST', { maxLevel })
+
+export const getMyReferrer = () => request('/api/commission/my-referrer', 'POST')
+
+export const getTeamTree = (maxLevel = 3) => request('/api/commission/team/tree', 'POST', { maxLevel })
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat(api): 新增 getTeamConsumption、getMyReferrer、getTeamTree"
+```
+
+---
+
+### 任务 11:前端 - 新建 `pages/profile/team-tree.vue`
+
+**文件:**
+- 创建:`cfc-frontend/pages/profile/team-tree.vue`
+
+- [ ] **步骤 1:创建完整页面文件**
+
+```vue
+<template>
+  <view class="container">
+    <!-- 顶部统计卡 -->
+    <view class="stats-card">
+      <view class="stat-item">
+        <text class="stat-value orange">{{ fenToYuan(consumption.totalConsumption) }}</text>
+        <text class="stat-label">团队总消费</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value blue">{{ consumption.totalMembers }}</text>
+        <text class="stat-label">团队人数</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-value green">{{ currentMaxLevel }}</text>
+        <text class="stat-label">统计层级</text>
+      </view>
+    </view>
+
+    <!-- 层级切换 tabs -->
+    <view class="level-tabs">
+      <view class="level-tab" :class="{ active: currentMaxLevel === 1 }" @click="switchLevel(1)">1级</view>
+      <view class="level-tab" :class="{ active: currentMaxLevel === 2 }" @click="switchLevel(2)">2级</view>
+      <view class="level-tab" :class="{ active: currentMaxLevel === 3 }" @click="switchLevel(3)">3级</view>
+      <view class="level-tab" :class="{ active: currentMaxLevel === 4 }" @click="switchLevel(4)">4级</view>
+      <view class="level-tab" :class="{ active: currentMaxLevel === 5 }" @click="switchLevel(5)">5级</view>
+    </view>
+
+    <!-- 树形区域(从下往上生长:column-reverse,L1 在底部) -->
+    <view class="tree-wrapper">
+      <view class="tree-container">
+        <TreeNode
+          v-for="root in level1Roots"
+          :key="root.userId"
+          :node="root"
+          :level="1"
+          :maxLevel="currentMaxLevel"
+        />
+      </view>
+      <view class="empty-state" v-if="!loading && level1Roots.length === 0">
+        <text>暂无团队成员</text>
+      </view>
+    </view>
+
+    <view class="loading-overlay" v-if="loading">
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getTeamTree, getTeamConsumption } from '@/utils/api.js'
+
+export default {
+  components: { TreeNode: {
+    name: 'TreeNode',
+    props: { node: Object, level: Number, maxLevel: Number },
+    data() { return { expanded: false } },
+    methods: {
+      toggle() { this.expanded = !this.expanded }
+    },
+    template: `
+      <view class="tree-node" :style="{ paddingLeft: (level > 1 ? (level - 1) * 24 : 0) + 'rpx' }">
+        <view class="node-main" @click="toggle">
+          <image v-if="node.avatar" class="node-avatar" :src="node.avatar" mode="aspectFill" />
+          <text v-else class="node-avatar-text">{{ node.nickname ? node.nickname.charAt(0) : '?' }}</text>
+          <text class="node-name">{{ node.nickname }}</text>
+          <text class="node-tag" v-if="level > 1">L{{ level }}</text>
+          <view class="node-stats">
+            <text class="node-stat consumption">消费 ¥{{ (node.memberConsumption / 100).toFixed(2) }}</text>
+            <text class="node-stat my-cf">得CF ¥{{ (node.myCfEarnedFromMember / 100).toFixed(2) }}</text>
+          </view>
+          <text class="node-arrow">{{ expanded ? '▲' : '▼' }}</text>
+        </view>
+        <view v-if="expanded && node.children && node.children.length > 0" class="children">
+          <TreeNode
+            v-for="child in node.children"
+            :key="child.userId"
+            :node="child"
+            :level="level + 1"
+            :maxLevel="maxLevel"
+          />
+        </view>
+      </view>
+    `
+  }},
+  data() {
+    return {
+      nodes: [],
+      level1Roots: [],
+      consumption: { totalConsumption: 0, totalMembers: 0, maxLevel: 3 },
+      currentMaxLevel: 3,
+      loading: false
+    }
+  },
+  onLoad() {
+    this.loadData()
+  },
+  onShow() {
+    if (!this._loadFired) {
+      this._loadFired = true
+    } else {
+      this.loadData()
+    }
+  },
+  methods: {
+    async loadData() {
+      this.loading = true
+      try {
+        var self = this
+        var treeRes = await getTeamTree(self.currentMaxLevel)
+        var consumptionRes = await getTeamConsumption(self.currentMaxLevel)
+        if (treeRes && treeRes.data && treeRes.data.nodes) {
+          self.nodes = treeRes.data.nodes
+          self.buildLevel1Roots()
+        }
+        if (consumptionRes && consumptionRes.data) {
+          self.consumption = consumptionRes.data
+          self.consumption.maxLevel = self.consumption.maxLevel || self.currentMaxLevel
+        }
+      } catch (e) {
+        console.error('加载团队树失败', e)
+      }
+      this.loading = false
+    },
+    switchLevel(level) {
+      if (this.currentMaxLevel === level) return
+      this.currentMaxLevel = level
+      this.loadData()
+    },
+    buildLevel1Roots() {
+      var self = this
+      // 将扁平 nodes 构建成以 L1 为根的子树
+      var nodeMap = {}
+      var l1Ids = []
+      self.nodes.forEach(function(n) {
+        nodeMap[n.userId] = { userId: n.userId, children: [] }
+        if (n.level === 1) l1Ids.push(n.userId)
+      })
+      self.nodes.forEach(function(n) {
+        if (!nodeMap[n.userId]) return
+        nodeMap[n.userId] = Object.assign({}, nodeMap[n.userId], n)
+        if (n.parentId && nodeMap[n.parentId]) {
+          nodeMap[n.parentId].children.push(nodeMap[n.userId])
+        }
+      })
+      // 按 userId 排序
+      var sortNodes = function(arr) {
+        arr.sort(function(a, b) { return (a.userId || 0) - (b.userId || 0) })
+        arr.forEach(function(c) { sortNodes(c.children) })
+      }
+      sortNodes(l1Ids.map(function(id) { return nodeMap[id] }))
+      self.level1Roots = l1Ids.map(function(id) { return nodeMap[id] })
+    },
+    fenToYuan(fen) {
+      if (fen === null || fen === undefined) return '0.00'
+      var num = Number(fen)
+      if (isNaN(num)) return '0.00'
+      return (num / 100).toFixed(2)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { min-height: 100vh; background: #FFF7ED; padding-bottom: 40rpx; }
+.stats-card {
+  background: #fff; border-radius: 20rpx; padding: 30rpx; margin: 20rpx 30rpx 16rpx;
+  display: flex; align-items: center; box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+}
+.stat-item { flex: 1; text-align: center; }
+.stat-value { font-size: 36rpx; font-weight: bold; display: block; }
+.stat-value.orange { color: #F97316; }
+.stat-value.blue { color: #0EA5E9; }
+.stat-value.green { color: #10B981; }
+.stat-label { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
+.stat-divider { width: 1rpx; height: 48rpx; background: #FED7AA; }
+.level-tabs {
+  display: flex; padding: 0 30rpx; margin-bottom: 16rpx; overflow-x: auto; gap: 12rpx;
+  white-space: nowrap;
+}
+.level-tab {
+  flex-shrink: 0; padding: 12rpx 24rpx; border-radius: 20rpx;
+  font-size: 24rpx; color: #64748B; background: #F1F5F9;
+}
+.level-tab.active { background: #F97316; color: #fff; font-weight: 600; }
+.tree-wrapper { padding: 0 30rpx; }
+.tree-container { min-height: 300rpx; }
+.tree-node { margin-bottom: 16rpx; }
+.node-main {
+  display: flex; align-items: center; gap: 16rpx;
+  background: #fff; border-radius: 16rpx; padding: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.node-avatar { width: 60rpx; height: 60rpx; border-radius: 50%; background: #FEF3C7; flex-shrink: 0; }
+.node-avatar-text {
+  width: 60rpx; height: 60rpx; border-radius: 50%; background: #FEF3C7;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 24rpx; color: #F97316; flex-shrink: 0;
+}
+.node-name { flex: 1; min-width: 0; font-size: 26rpx; color: #1E293B; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.node-tag { font-size: 20rpx; background: #FFF7ED; color: #F97316; padding: 2rpx 8rpx; border-radius: 8rpx; flex-shrink: 0; }
+.node-stats { flex-shrink: 0; text-align: right; margin-right: 8rpx; }
+.node-stat { font-size: 20rpx; display: block; line-height: 1.5; }
+.node-stat.consumption { color: #F97316; }
+.node-stat.my-cf { color: #0EA5E9; }
+.node-arrow { font-size: 20rpx; color: #94A3B8; flex-shrink: 0; }
+.children { margin-top: 12rpx; padding-left: 32rpx; border-left: 1rpx dashed #FED7AA; }
+.empty-state { text-align: center; padding: 80rpx; color: #94A3B8; font-size: 28rpx; }
+.loading-overlay {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(255,255,255,0.85); display: flex; align-items: center; justify-content: center;
+  z-index: 999; font-size: 28rpx; color: #94A3B8;
+}
+</style>
+```
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd cfc-frontend && node --check pages/profile/team-tree.vue 2>&1 || true
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages/profile/team-tree.vue
+git commit -m "feat(profile): 新建 team-tree 页面(树形从下往上生长、层级切换、消费/CF明细)"
+```
+
+---
+
+### 任务 12:前端 - 注册 `pages.json` 新页面
+
+**文件:**
+- 修改:`cfc-frontend/pages.json`(在 `pages/profile` root 下的 `pages` 数组追加)
+
+- [ ] **步骤 1:找到 `pages/profile` root 并追加**
+
+在 `pages.json` 中找到 `"root": "pages/profile"` 的 section,在数组末尾追加:
+
+```json
+{
+  "path": "pages/profile/team-tree",
+  "style": {
+    "navigationBarTitleText": "团队层级",
+    "enablePullDownRefresh": false
+  }
+}
+```
+
+- [ ] **步骤 2:语法验证**
+
+```bash
+cd cfc-frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8'))" && echo "JSON valid"
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/pages.json
+git commit -m "feat(pages.json): 注册 team-tree 页面"
+```
+
+---
+
+### 任务 13:联调验证 + API 审计
+
+**文件:**
+- 无新增文件
+
+- [ ] **步骤 1:编译后端**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+- [ ] **步骤 2:启动后端服务(HBuilderX 或命令行)并手动验证接口**
+
+```bash
+# 后端编译通过后启动,在微信开发者工具中打开小程序测试
+# 关键接口验证点:
+# 1. GET /api/commission/team/consumption?maxLevel=3 → 200
+# 2. POST /api/commission/my-referrer → 200
+# 3. POST /api/commission/team/tree?maxLevel=3 → 200
+# 4. POST /api/commission/team/list?level=L1 → 200, records[].memberConsumption exists
+```
+
+- [ ] **步骤 3:HBuilderX 打包小程序**
+
+在 HBuilderX 中打开 `cfc-frontend/`,运行到微信开发者工具,依次验证:
+- 富页面:无推荐码横条,有「邀请人数」
+- 我的页面:有推荐人卡片(显示昵称/头像/邀请码/复制按钮)、有「团队层级」入口
+- 点击「团队层级」→ 进入 team-tree,显示统计卡 + 层级 tabs + 树形
+- 切换层级 tabs → 树形和统计更新
+- 点击节点 → 展开/折叠
+- 无推荐人用户 → 推荐人卡片不显示
+- 空团队 → 显示「暂无团队成员」
+
+- [ ] **步骤 4:API 重复调用审计**
+
+```bash
+cd cfc-frontend && node scripts/audit-duplicate-api-calls.js
+# 预期:0 error
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add -A
+git commit -m "chore: 联调验证通过 + API审计0 error"
+```
+
+---
+
+## 自检清单(实施后逐条确认)
+
+- [ ] 富页面无推荐码横条,保留「邀请人数」数字
+- [ ] 我的页面有推荐人卡片(有推荐人时显示;无推荐人时不显示)
+- [ ] 我的页面有「团队层级」入口,点击跳转 `/pages/profile/team-tree`
+- [ ] team-tree 页面:
+  - 顶部统计卡:总消费额(分转元)、人数、层级数
+  - 1-5 级切换 tabs,切换后统计和树形同步更新
+  - 树形从下往上生长(L1 在底部,L2/L3 向上延伸)
+  - 每节点显示:头像、昵称、L标签、消费额、我得CF、展开图标
+  - 点击节点展开/折叠,子节点在上方渲染,虚线连接正确
+  - 空团队显示「暂无团队成员」
+- [ ] 复制推荐码按钮正常工作
+- [ ] 后端 4 个接口响应正确,字段名与设计文档一致
+- [ ] `mvn clean compile` 通过
+- [ ] `audit-duplicate-api-calls.js` 0 error
+- [ ] API_REFERENCE.md 已同步更新
+
+---
+
+**文档结束**

+ 1190 - 0
docs/superpowers/plans/2026-09-09-tianpan-tradition-impl.md

@@ -0,0 +1,1190 @@
+# 家庭天盘·传统文化 + 五维基础能量分 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 完善家庭天盘传统文化展示设计,基于生辰八字/星座/灵数计算五维基础能量分并落库,提供重算接口,前端三处增强(canvas 节点标注、成员弹窗、传统命理页)。
+
+**架构:** 后端新增 `FiveDimensionScoreService` 计算服务,新建 `family_member_dimension_base_scores` 表存储五维基础分,支持手动/批量重算;前端改造现有天盘页面,在 canvas 节点、成员弹窗、新增传统命理页展示传统文化信息和五维基础分。
+
+**技术栈:** Spring Boot 2.7 + MyBatis-Plus(后端);uni-app Vue 2 Options API(小程序前端);MySQL 8.0
+
+---
+
+## 文件清单
+
+| 操作 | 文件路径 | 职责 |
+|------|----------|------|
+| 创建 | `cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java` | 五维基础分计算/落库/重算服务 |
+| 创建 | `cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java` | 五维基础分实体 |
+| 创建 | `cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberDimensionBaseScoreMapper.java` | MyBatis-Plus Mapper 接口 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java` | 新增 dimensionBaseScores 字段 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java` | 扩展 enrichMembers 填充 zodiacName 和 dimensionBaseScores |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java` | 新增重算接口 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 新增迁移 SQL |
+| 修改 | `cfc-backend/src/main/resources/schema.sql` | 同步新表定义 |
+| 创建 | `cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java` | 单元测试 |
+| 修改 | `cfc-frontend/pages/tianpan/index.vue` | canvas 节点标注 + 成员弹窗增强 |
+| 创建 | `cfc-frontend/pages/tianpan/traditional.vue` | 新增传统命理页 |
+| 修改 | `cfc-frontend/pages.json` | 注册新页面 |
+
+---
+
+## 任务列表
+
+### 任务 1:后端 — 数据库迁移和新表实体
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberDimensionBaseScoreMapper.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+
+- [ ] **步骤 1:确认最新迁移编号**
+
+在 `DatabaseInitializer.java` 末尾搜索 `// 迁移` 找到最新编号 N。
+
+运行:
+```bash
+grep -n "// 迁移" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java | tail -5
+```
+
+- [ ] **步骤 2:创建实体类**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_member_dimension_base_scores")
+public class FamilyMemberDimensionBaseScore implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String memberType;
+    private String dimensionCode;  // body/mind/wisdom/action/wealth
+    private Integer baseScore;
+    private Date lastCalculatedAt;
+    private String calculatedBy;  // system/manual/admin
+}
+```
+
+- [ ] **步骤 3:创建 Mapper 接口**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberDimensionBaseScoreMapper.java`:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.FamilyMemberDimensionBaseScore;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FamilyMemberDimensionBaseScoreMapper extends BaseMapper<FamilyMemberDimensionBaseScore> {
+}
+```
+
+- [ ] **步骤 4:在 schema.sql 添加建表语句**
+
+在 `cfc-backend/src/main/resources/schema.sql` 末尾追加:
+
+```sql
+CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent',
+    dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth',
+    base_score INT NOT NULL COMMENT '基础能量分(0-100)',
+    last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间',
+    calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源(system/manual)',
+    UNIQUE KEY uk_member_dimension (member_id, dimension_code),
+    INDEX idx_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分(落库,支持重算)';
+```
+
+- [ ] **步骤 5:在 DatabaseInitializer 添加迁移**
+
+在 `DatabaseInitializer.java` 的 `runMigrations()` 末尾(迁移N之后)追加:
+
+```java
+// 迁移N+1: 创建 family_member_dimension_base_scores 表
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (" +
+        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+        "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+        "member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent', " +
+        "dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth', " +
+        "base_score INT NOT NULL COMMENT '基础能量分(0-100)', " +
+        "last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间', " +
+        "calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源', " +
+        "UNIQUE KEY uk_member_dimension (member_id, dimension_code), " +
+        "INDEX idx_member (member_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分'");
+    log.info("已创建family_member_dimension_base_scores表");
+} catch (Exception e) {
+    // 表已存在,忽略错误
+}
+```
+
+- [ ] **步骤 6:编译验证**
+
+运行:
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMemberDimensionBaseScore.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMemberDimensionBaseScoreMapper.java \
+       cfc-backend/src/main/resources/schema.sql
+git commit -m "feat: 新增五维基础分表及迁移"
+```
+
+---
+
+### 任务 2:后端 — FiveDimensionScoreService 核心服务
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java`
+- 创建:`cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java`
+
+- [ ] **步骤 1:编写测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@ExtendWith(MockitoExtension.class)
+class FiveDimensionScoreServiceTest {
+
+    @InjectMocks
+    private FiveDimensionScoreService service;
+
+    @Test
+    void calcBaseScores_fullData_returnsCorrectScores() {
+        // 八字五行 {wood:40, fire:60, earth:50, metal:30, water:20}
+        // 星座狮子座(火象→心)
+        // 灵数7(→智)
+        Map<String, Integer> wuxing = Map.of("wood", 40, "fire", 60, "earth", 50, "metal", 30, "water", 20);
+        Map<String, Integer> result = service.calcBaseScores(wuxing, "狮子座", 7);
+        
+        assertEquals(30, result.get("body"));     // 50×0.6=30
+        assertEquals(56, result.get("mind"));     // 60×0.6+20=56
+        assertEquals(38, result.get("wisdom"));   // 30×0.6+20=38
+        assertEquals(24, result.get("action"));   // 40×0.6=24
+        assertEquals(12, result.get("wealth"));   // 20×0.6=12
+    }
+
+    @Test
+    void calcBaseScores_nullWuxing_returnsNeutral50() {
+        Map<String, Integer> result = service.calcBaseScores(null, null, null);
+        
+        assertEquals(50, result.get("body"));
+        assertEquals(50, result.get("mind"));
+        assertEquals(50, result.get("wisdom"));
+        assertEquals(50, result.get("action"));
+        assertEquals(50, result.get("wealth"));
+    }
+
+    @Test
+    void calcBaseScores_masterNumber_11MapsTo2() {
+        Map<String, Integer> wuxing = Map.of("wood", 50, "fire", 50, "earth", 50, "metal", 50, "water", 50);
+        Map<String, Integer> result = service.calcBaseScores(wuxing, null, 11); // 11→2→心
+        
+        assertEquals(60 + 20, result.get("mind")); // 土×0.6=50, 灵数11→2→心+20
+    }
+
+    @Test
+    void calcBaseScores_allZodiacSigns_coverage() {
+        Map<String, Integer> wuxing = Map.of("wood", 50, "fire", 50, "earth", 50, "metal", 50, "water", 50);
+        
+        // 火象
+        Map<String, Integer> fireResult = service.calcBaseScores(wuxing, "白羊座", null);
+        assertTrue(fireResult.get("mind") > 50);
+        
+        // 土象
+        Map<String, Integer> earthResult = service.calcBaseScores(wuxing, "金牛座", null);
+        assertTrue(earthResult.get("body") > 50);
+        
+        // 风象
+        Map<String, Integer> airResult = service.calcBaseScores(wuxing, "双子座", null);
+        assertTrue(airResult.get("action") > 50);
+        
+        // 水象
+        Map<String, Integer> waterResult = service.calcBaseScores(wuxing, "巨蟹座", null);
+        assertTrue(waterResult.get("wealth") > 50);
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+```bash
+cd cfc-backend && mvn test -pl . -Dtest=FiveDimensionScoreServiceTest
+```
+预期:FAIL(方法不存在)
+
+- [ ] **步骤 3:实现服务**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMemberDimensionBaseScore;
+import com.etotem.cfc.mapper.FamilyMemberDimensionBaseScoreMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class FiveDimensionScoreService {
+
+    @Resource
+    private FamilyMemberDimensionBaseScoreMapper dimensionBaseScoreMapper;
+
+    private static final Map<String, String> ZODIAC_ELEMENT_TO_DIM = new LinkedHashMap<>();
+    private static final Map<Integer, String> LIFE_NUMBER_DIM = new LinkedHashMap<>();
+    private static final String[] DIMENSIONS = {"body", "mind", "wisdom", "action", "wealth"};
+
+    static {
+        // 星座 → 维度(火象→心, 土象→身, 风象→行, 水象→富)
+        ZODIAC_ELEMENT_TO_DIM.put("白羊座", "mind");
+        ZODIAC_ELEMENT_TO_DIM.put("狮子座", "mind");
+        ZODIAC_ELEMENT_TO_DIM.put("射手座", "mind");
+        ZODIAC_ELEMENT_TO_DIM.put("金牛座", "body");
+        ZODIAC_ELEMENT_TO_DIM.put("处女座", "body");
+        ZODIAC_ELEMENT_TO_DIM.put("摩羯座", "body");
+        ZODIAC_ELEMENT_TO_DIM.put("双子座", "action");
+        ZODIAC_ELEMENT_TO_DIM.put("天秤座", "action");
+        ZODIAC_ELEMENT_TO_DIM.put("水瓶座", "action");
+        ZODIAC_ELEMENT_TO_DIM.put("巨蟹座", "wealth");
+        ZODIAC_ELEMENT_TO_DIM.put("天蝎座", "wealth");
+        ZODIAC_ELEMENT_TO_DIM.put("双鱼座", "wealth");
+
+        // 灵数 → 主维度
+        LIFE_NUMBER_DIM.put(1, "action");
+        LIFE_NUMBER_DIM.put(2, "mind");
+        LIFE_NUMBER_DIM.put(3, "wisdom");
+        LIFE_NUMBER_DIM.put(4, "body");
+        LIFE_NUMBER_DIM.put(5, "action");
+        LIFE_NUMBER_DIM.put(6, "mind");
+        LIFE_NUMBER_DIM.put(7, "wisdom");
+        LIFE_NUMBER_DIM.put(8, "wealth");
+        LIFE_NUMBER_DIM.put(9, "mind");
+    }
+
+    /**
+     * 计算五维基础能量分(纯算法,不落库)
+     */
+    public Map<String, Integer> calcBaseScores(Map<String, Integer> wuxingElements,
+                                                String westernSign,
+                                                Integer lifeNumber) {
+        Map<String, Integer> scores = new HashMap<>();
+        for (String dim : DIMENSIONS) {
+            scores.put(dim, 50); // 默认中性值
+        }
+
+        // 1. 八字五行层(权重 60%)
+        if (wuxingElements != null) {
+            scores.put("action", Math.round((float) wuxingElements.getOrDefault("wood", 50) * 0.6));
+            scores.put("mind", Math.round((float) wuxingElements.getOrDefault("fire", 50) * 0.6));
+            scores.put("body", Math.round((float) wuxingElements.getOrDefault("earth", 50) * 0.6));
+            scores.put("wisdom", Math.round((float) wuxingElements.getOrDefault("metal", 50) * 0.6));
+            scores.put("wealth", Math.round((float) wuxingElements.getOrDefault("water", 50) * 0.6));
+        }
+
+        // 2. 星座层(权重 20%)
+        if (westernSign != null && ZODIAC_ELEMENT_TO_DIM.containsKey(westernSign)) {
+            String dim = ZODIAC_ELEMENT_TO_DIM.get(westernSign);
+            scores.put(dim, scores.get(dim) + 20);
+        }
+
+        // 3. 灵数层(权重 20%)
+        if (lifeNumber != null) {
+            int reduced = reduceMaster(lifeNumber);
+            if (LIFE_NUMBER_DIM.containsKey(reduced)) {
+                String dim = LIFE_NUMBER_DIM.get(reduced);
+                scores.put(dim, scores.get(dim) + 20);
+            }
+        }
+
+        return scores;
+    }
+
+    /**
+     * 计算并落库(INSERT ON DUPLICATE KEY UPDATE)
+     */
+    public void calcAndSave(Long memberId, String memberType,
+                            Map<String, Integer> wuxingElements,
+                            String westernSign, Integer lifeNumber,
+                            String operator) {
+        Map<String, Integer> scores = calcBaseScores(wuxingElements, westernSign, lifeNumber);
+        saveScores(memberId, memberType, scores, operator);
+    }
+
+    private void saveScores(Long memberId, String memberType, Map<String, Integer> scores, String operator) {
+        Date now = new Date();
+        for (Map.Entry<String, Integer> entry : scores.entrySet()) {
+            FamilyMemberDimensionBaseScore entity = new FamilyMemberDimensionBaseScore();
+            entity.setMemberId(memberId);
+            entity.setMemberType(memberType);
+            entity.setDimensionCode(entry.getKey());
+            entity.setBaseScore(entry.getValue());
+            entity.setLastCalculatedAt(now);
+            entity.setCalculatedBy(operator);
+
+            LambdaQueryWrapper<FamilyMemberDimensionBaseScore> qw = new LambdaQueryWrapper<>();
+            qw.eq(FamilyMemberDimensionBaseScore::getMemberId, memberId)
+              .eq(FamilyMemberDimensionBaseScore::getDimensionCode, entry.getKey());
+            
+            FamilyMemberDimensionBaseScore existing = dimensionBaseScoreMapper.selectOne(qw);
+            if (existing != null) {
+                existing.setBaseScore(entry.getValue());
+                existing.setLastCalculatedAt(now);
+                existing.setCalculatedBy(operator);
+                dimensionBaseScoreMapper.updateById(existing);
+            } else {
+                entity.setId(null);
+                dimensionBaseScoreMapper.insert(entity);
+            }
+        }
+    }
+
+    /**
+     * 重算单个成员
+     */
+    public void recalibrate(Long memberId, String memberType, String operator) {
+        // 从 family_member_attributes 读取源数据
+        // 简化:直接调用 calcAndSave,传入正确的源数据
+        // 实际调用方应负责准备 wuxingElements/westernSign/lifeNumber
+    }
+
+    /**
+     * 查询五维基础分(供 dashboard 接口读取)
+     */
+    public Map<String, Integer> getScores(Long memberId) {
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        for (String dim : DIMENSIONS) {
+            scores.put(dim, 50); // 默认中性值
+        }
+        LambdaQueryWrapper<FamilyMemberDimensionBaseScore> qw = new LambdaQueryWrapper<>();
+        qw.eq(FamilyMemberDimensionBaseScore::getMemberId, memberId);
+        List<FamilyMemberDimensionBaseScore> list = dimensionBaseScoreMapper.selectList(qw);
+        for (FamilyMemberDimensionBaseScore item : list) {
+            scores.put(item.getDimensionCode(), item.getBaseScore());
+        }
+        return scores;
+    }
+
+    /**
+     * 主数化简:11→2, 22→4, 33→6, 其他>9按数位和递归
+     */
+    private int reduceMaster(int n) {
+        if (n == 11) return 2;
+        if (n == 22) return 4;
+        if (n == 33) return 6;
+        while (n >= 10) {
+            int sum = 0;
+            while (n > 0) {
+                sum += n % 10;
+                n /= 10;
+            }
+            n = sum;
+        }
+        return n;
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试确认通过**
+
+```bash
+cd cfc-backend && mvn test -pl . -Dtest=FiveDimensionScoreServiceTest
+```
+预期:PASS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java \
+       cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java
+git commit -m "feat: 新增五维基础分计算服务"
+```
+
+---
+
+### 任务 3:后端 — 扩展 TianpanService 和 DTO
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java`
+
+- [ ] **步骤 1:扩展 TianpanMemberVO**
+
+在 `TianpanMemberVO.java` 中追加字段:
+
+```java
+private Map<String, Integer> dimensionBaseScores;  // 五维基础分
+private String zodiacName;                         // 生肖中文名(rat→鼠等)
+```
+
+- [ ] **步骤 2:修改 TianpanService.enrichMembers()**
+
+在 `TianpanService.java` 的 `enrichMembers()` 方法(第 216 行附近)追加:
+
+```java
+// 注入新服务
+@Resource
+private FiveDimensionScoreService fiveDimensionScoreService;
+```
+
+在 `enrichMembers()` 循环末尾(`vo.setEightCharacters(...)` 之后)追加:
+
+```java
+// 生肖中文名
+String zodiacName = findZodiacName(m.getZodiac());
+vo.setZodiacName(zodiacName);
+
+// 五维基础分:优先读库,无则计算并落库
+Map<String, Integer> cachedScores = fiveDimensionScoreService.getScores(m.getMemberId());
+boolean hasScores = cachedScores.values().stream().anyMatch(s -> s != 50);
+if (!hasScores) {
+    fiveDimensionScoreService.calcAndSave(
+        m.getMemberId(), m.getMemberType(),
+        m.getWuxingElements(),
+        vo.getWesternSign(), 
+        m.getLifeNumber(),
+        "system");
+    cachedScores = fiveDimensionScoreService.getScores(m.getMemberId());
+}
+vo.setDimensionBaseScores(cachedScores);
+```
+
+添加辅助方法:
+
+```java
+private String findZodiacName(String zodiacCode) {
+    if (zodiacCode == null) return null;
+    Map<String, String> nameMap = new LinkedHashMap<>();
+    nameMap.put("rat", "鼠"); nameMap.put("ox", "牛"); nameMap.put("tiger", "虎");
+    nameMap.put("rabbit", "兔"); nameMap.put("dragon", "龙"); nameMap.put("snake", "蛇");
+    nameMap.put("horse", "马"); nameMap.put("sheep", "羊"); nameMap.put("monkey", "猴");
+    nameMap.put("rooster", "鸡"); nameMap.put("dog", "狗"); nameMap.put("pig", "猪");
+    return nameMap.get(zodiacCode);
+}
+```
+
+- [ ] **步骤 3:新增重算接口**
+
+在 `TianpanController.java` 追加:
+
+```java
+@PostMapping("/recalibrate")
+public Result<Map<String, Integer>> recalibrate(@RequestBody Map<String, Object> params) {
+    Long memberId = Long.valueOf(params.get("memberId").toString());
+    String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+    String operator = params.get("operator") != null ? params.get("operator").toString() : "admin";
+    
+    // 从 family_member_attributes 读取源数据并重算
+    FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+    if (attrs == null) {
+        return Result.error("成员属性不存在");
+    }
+    
+    Map<String, Integer> wuxingElements = parseJsonInt(attrs.getWuxingElements());
+    String westernSign = null;
+    if (attrs.getBirthDatetime() != null) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(attrs.getBirthDatetime());
+        westernSign = zodiacAnnualEnergyService.getWesternSign(cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+    }
+    
+    String behaviorModifier = attrs.getBehaviorModifier();
+    Integer lifeNumber = null;
+    if (behaviorModifier != null) {
+        try {
+            Map<String, Object> bm = objectMapper.readValue(behaviorModifier, Map.class);
+            Object ln = bm.get("lifeNumber");
+            if (ln instanceof Number) lifeNumber = ((Number) ln).intValue();
+        } catch (Exception e) { /* ignore */ }
+    }
+    
+    fiveDimensionScoreService.calcAndSave(memberId, memberType, wuxingElements, westernSign, lifeNumber, operator);
+    
+    Map<String, Integer> scores = fiveDimensionScoreService.getScores(memberId);
+    return Result.success(scores);
+}
+```
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/TianpanMemberVO.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/TianpanService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java
+git commit -m "feat: 扩展天盘服务支持五维基础分展示和重算"
+```
+
+---
+
+### 任务 4:小程序 — 天盘首页增强
+
+**文件:**
+- 修改:`cfc-frontend/pages/tianpan/index.vue`
+
+- [ ] **步骤 1:增强 drawMemberNodes**
+
+在 `drawMemberNodes` 方法(第 375 行附近)的成员节点绘制后追加:
+
+```javascript
+// 在 ctx.fillText(member.nickname || member.name, x, y) 之后追加
+// 绘制生肖+灵数标注
+var showLabel = member.zodiacName || member.lifeNumber
+if (showLabel) {
+  var labelParts = []
+  if (member.zodiacName) labelParts.push(member.zodiacName)
+  if (member.lifeNumber) labelParts.push('灵数' + member.lifeNumber)
+  var label = labelParts.join('·')
+  ctx.font = '9px sans-serif'
+  ctx.fillStyle = this.getMemberColor(member.effectiveRole)
+  ctx.textAlign = 'center'
+  ctx.textBaseline = 'top'
+  ctx.fillText(label, x, y + 18)
+}
+```
+
+- [ ] **步骤 2:增强成员弹窗**
+
+在模板中(第 98-119 行),基本信息区追加八字和灵数,能量区追加五维基础分:
+
+```html
+<!-- 新增:生命灵数 -->
+<view class="member-info-row">
+  <text class="info-label">灵数:</text>
+  <text class="info-value">{{ selectedMember.lifeNumber || '-' }}</text>
+</view>
+<!-- 新增:八字四柱 -->
+<view class="member-info-row" v-if="selectedMember.eightCharacters">
+  <text class="info-label">八字:</text>
+  <text class="info-value">{{ getEightCharsText(selectedMember.eightCharacters) }}</text>
+</view>
+```
+
+在 methods 中追加:
+
+```javascript
+getEightCharsText: function(eightChars) {
+  if (!eightChars) return ''
+  var parts = []
+  var keys = ['year', 'month', 'day', 'hour']
+  for (var i = 0; i < keys.length; i++) {
+    if (eightChars[keys[i]]) parts.push(eightChars[keys[i]])
+  }
+  return parts.join('  ')
+},
+```
+
+在 `.member-info-row` 之后追加五维基础分展示:
+
+```html
+<!-- 新增:五维基础分 -->
+<view class="energy-bars" v-if="selectedMember.dimensionBaseScores">
+  <view class="energy-bar-item" v-for="(val, key) in selectedMember.dimensionBaseScores" :key="getKey(key)">
+    <text class="energy-label">{{ getDimLabel(key) }}</text>
+    <view class="energy-bar">
+      <view class="energy-fill" :style="{ width: val + '%', background: getDimColor(key) }"></view>
+    </view>
+    <text class="energy-value">{{ val }}</text>
+  </view>
+</view>
+```
+
+在 methods 中追加:
+
+```javascript
+getKey: function(key) { return 'dim-' + key },
+getDimLabel: function(key) {
+  var labels = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
+  return labels[key] || key
+},
+getDimColor: function(key) {
+  var colors = { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' }
+  return colors[key] || '#6B7280'
+}
+```
+
+- [ ] **步骤 3:语法检查**
+
+运行:
+```bash
+node -c cfc-frontend/pages/tianpan/index.vue 2>&1 || echo "需要检查script块语法"
+```
+
+由于这是 .vue 文件,手动检查 script 块语法:
+
+```bash
+sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/tianpan/index.vue | sed '1d;$d' | node --check -
+```
+
+预期:无报错
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/tianpan/index.vue
+git commit -m "feat(frontend): 天盘首页canvas节点标注生肖灵数+成员弹窗增强"
+```
+
+---
+
+### 任务 5:小程序 — 新增传统命理页
+
+**文件:**
+- 创建:`cfc-frontend/pages/tianpan/traditional.vue`
+- 修改:`cfc-frontend/pages.json`
+
+- [ ] **步骤 1:注册页面**
+
+在 `cfc-frontend/pages.json` 的 `pages/tianpan` 分包中添加:
+
+```json
+{
+  "path": "pages/tianpan/traditional",
+  "style": {
+    "navigationBarTitleText": "家庭传统命理"
+  }
+}
+```
+
+- [ ] **步骤 2:创建页面组件**
+
+创建 `cfc-frontend/pages/tianpan/traditional.vue`:
+
+```html
+<template>
+  <view class="traditional-container">
+    <!-- 自定义导航栏 -->
+    <view class="custom-nav">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">家庭传统命理</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+
+    <!-- 成员列表 -->
+    <view class="member-list" v-if="!loading && members && members.length > 0">
+      <view class="member-card" v-for="member in members" :key="member.memberId">
+        <!-- 头部 -->
+        <view class="card-header">
+          <view class="avatar" :style="{ backgroundColor: member.colorHex || '#6B7280' }">
+            <text class="avatar-text">{{ member.nickname ? member.nickname.charAt(0) : 'M' }}</text>
+          </view>
+          <view class="member-info">
+            <text class="member-name">{{ member.nickname || member.name }}</text>
+            <text class="member-role">{{ getRoleLabel(member.memberType) }}</text>
+          </view>
+        </view>
+
+        <!-- 八字四柱 -->
+        <view class="section" v-if="member.eightCharacters">
+          <text class="section-title">八字四柱</text>
+          <view class="bazi-grid">
+            <view class="bazi-col" v-for="(val, key) in member.eightCharacters" :key="key">
+              <text class="bazi-label">{{ getPillarLabel(key) }}</text>
+              <text class="bazi-value">{{ val }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 五行能量 -->
+        <view class="section" v-if="member.wuxingElements">
+          <text class="section-title">五行能量</text>
+          <view class="wuxing-list">
+            <view class="wuxing-item" v-for="wx in getWuxingData(member.wuxingElements)" :key="wx.key">
+              <text class="wuxing-label">{{ wx.label }}</text>
+              <view class="wuxing-bar-track">
+                <view class="wuxing-bar-fill" :style="{ width: wx.value + '%', background: wx.color }"></view>
+              </view>
+              <text class="wuxing-value">{{ wx.value }}%</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 生肖星座 -->
+        <view class="section" v-if="member.zodiacName || member.westernSign">
+          <text class="section-title">生肖星座</text>
+          <view class="tag-row">
+            <view class="tag" v-if="member.zodiacName">
+              <text>{{ member.zodiacName }}</text>
+            </view>
+            <view class="tag" v-if="member.westernSign">
+              <text>{{ member.westernSign }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 生命灵数 -->
+        <view class="section" v-if="member.lifeNumber">
+          <text class="section-title">生命灵数</text>
+          <view class="numsoul-display">
+            <view class="numsoul-number" :style="{ background: getLifeNumberColor(member.lifeNumber) }">
+              <text>{{ member.lifeNumber }}</text>
+            </view>
+            <text class="numsoul-title">{{ getLifeNumberTitle(member.lifeNumber) }}</text>
+          </view>
+        </view>
+
+        <!-- 五维基础分 -->
+        <view class="section" v-if="member.dimensionBaseScores">
+          <text class="section-title">五维基础分</text>
+          <view class="dimension-bars">
+            <view class="dim-item" v-for="(val, key) in member.dimensionBaseScores" :key="key">
+              <text class="dim-label">{{ getDimLabel(key) }}</text>
+              <view class="dim-bar-track">
+                <view class="dim-bar-fill" :style="{ width: val + '%', background: getDimColor(key) }"></view>
+              </view>
+              <text class="dim-value">{{ val }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-if="!loading && (!members || members.length === 0)">
+      <text>暂无家庭成员数据</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex'
+
+export default {
+  data: function() {
+    return {
+      loading: false,
+      members: []
+    }
+  },
+  computed: {
+    ...mapState('tianpan', ['loading']),
+    ...mapGetters('tianpan', ['members'])
+  },
+  onLoad: function() {
+    this.loadMembers()
+  },
+  methods: {
+    ...mapActions('tianpan', ['loadDashboard']),
+    
+    loadMembers: function() {
+      this.loading = true
+      // 如果 store 中无数据则重新加载
+      if (!this.members || this.members.length === 0) {
+        this.loadDashboard({ year: new Date().getFullYear(), daysAhead: 7 })
+          .then(this.fetchMembers)
+          .finally(function() {
+            this.loading = false
+          }.bind(this))
+      } else {
+        this.fetchMembers()
+      }
+    },
+    
+    fetchMembers: function() {
+      this.members = this.members || []
+    },
+    
+    goBack: function() {
+      uni.navigateBack()
+    },
+    
+    getPillarLabel: function(key) {
+      var labels = { year: '年柱', month: '月柱', day: '日柱', hour: '时柱' }
+      return labels[key] || key
+    },
+    
+    getWuxingData: function(wuxingElements) {
+      if (!wuxingElements) return []
+      var colorMap = { wood: '#10B981', fire: '#FF6B9D', earth: '#FF8C42', metal: '#6366F1', water: '#3B82F6' }
+      var labelMap = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' }
+      var result = []
+      for (var key in wuxingElements) {
+        if (wuxingElements.hasOwnProperty(key)) {
+          result.push({ key: key, label: labelMap[key] || key, value: wuxingElements[key], color: colorMap[key] || '#94A3B8' })
+        }
+      }
+      return result.sort(function(a, b) { return b.value - a.value })
+    },
+    
+    getRoleLabel: function(type) {
+      var labels = { parent: '家长', child: '孩子', teacher: '规划师' }
+      return labels[type] || type
+    },
+    
+    getLifeNumberColor: function(num) {
+      var colors = { 1: '#F97316', 2: '#EC4899', 3: '#8B5CF6', 4: '#F59E0B', 5: '#3B82F6', 6: '#10B981', 7: '#6366F1', 8: '#EF4444', 9: '#14B8A6' }
+      return colors[num] || '#6B7280'
+    },
+    
+    getLifeNumberTitle: function(num) {
+      var titles = { 1: '开创', 2: '协调', 3: '表达', 4: '稳定', 5: '自由', 6: '责任', 7: '求知', 8: '权力', 9: '博爱' }
+      return titles[num] || ''
+    },
+    
+    getDimLabel: function(key) {
+      var labels = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
+      return labels[key] || key
+    },
+    
+    getDimColor: function(key) {
+      var colors = { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' }
+      return colors[key] || '#6B7280'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.traditional-container {
+  min-height: 100vh;
+  background: #FFF8F0;
+  padding-bottom: 40rpx;
+}
+
+.custom-nav {
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
+  padding: 44px 16px 16px;
+  display: flex;
+  align-items: center;
+}
+
+.nav-back {
+  font-size: 16px;
+  color: #FFFFFF;
+  margin-right: 16px;
+}
+
+.nav-title {
+  font-size: 18px;
+  color: #FFFFFF;
+  font-weight: bold;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 120px 0;
+}
+
+.loading-spinner {
+  width: 40px;
+  height: 40px;
+  border: 4px solid #E5E7EB;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 16px;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.member-list {
+  padding: 16px;
+}
+
+.member-card {
+  background: #FFFFFF;
+  border-radius: 16px;
+  padding: 20px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.avatar {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 16px;
+}
+
+.avatar-text {
+  color: #FFFFFF;
+  font-size: 28px;
+  font-weight: bold;
+}
+
+.member-info {
+  flex: 1;
+}
+
+.member-name {
+  font-size: 18px;
+  font-weight: bold;
+  color: #1F2937;
+  display: block;
+}
+
+.member-role {
+  font-size: 14px;
+  color: #6B7280;
+}
+
+.section {
+  margin-bottom: 20px;
+}
+
+.section-title {
+  font-size: 14px;
+  color: #9CA3AF;
+  margin-bottom: 12px;
+  font-weight: 500;
+}
+
+.bazi-grid {
+  display: flex;
+  justify-content: space-around;
+}
+
+.bazi-col {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.bazi-label {
+  font-size: 12px;
+  color: #9CA3AF;
+  margin-bottom: 4px;
+}
+
+.bazi-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: #8B5CF6;
+}
+
+.wuxing-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.wuxing-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.wuxing-label {
+  font-size: 14px;
+  width: 24px;
+  text-align: center;
+}
+
+.wuxing-bar-track {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.wuxing-bar-fill {
+  height: 100%;
+  border-radius: 4px;
+}
+
+.wuxing-value {
+  font-size: 12px;
+  color: #6B7280;
+  width: 36px;
+  text-align: right;
+}
+
+.tag-row {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.tag {
+  background: #F3F4F6;
+  padding: 6px 16px;
+  border-radius: 20px;
+  font-size: 14px;
+  color: #374151;
+}
+
+.numsoul-display {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.numsoul-number {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 28px;
+  font-weight: bold;
+  color: #FFFFFF;
+}
+
+.numsoul-title {
+  font-size: 16px;
+  color: #374151;
+  font-weight: 500;
+}
+
+.dimension-bars {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.dim-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.dim-label {
+  font-size: 14px;
+  width: 24px;
+}
+
+.dim-bar-track {
+  flex: 1;
+  height: 8px;
+  background: #E5E7EB;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.dim-bar-fill {
+  height: 100%;
+  border-radius: 4px;
+}
+
+.dim-value {
+  font-size: 12px;
+  color: #6B7280;
+  width: 36px;
+  text-align: right;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 120px 0;
+  color: #9CA3AF;
+}
+</style>
+```
+
+- [ ] **步骤 3:语法检查**
+
+```bash
+sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/tianpan/traditional.vue | sed '1d;$d' | node --check -
+```
+预期:无报错
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages.json cfc-frontend/pages/tianpan/traditional.vue
+git commit -m "feat(frontend): 新增传统命理页面"
+```
+
+---
+
+## 验证清单
+
+完成所有任务后,执行以下验证:
+
+1. **后端编译**
+   ```bash
+   cd cfc-backend && mvn clean compile
+   ```
+   预期:BUILD SUCCESS
+
+2. **单测通过**
+   ```bash
+   cd cfc-backend && mvn test -Dtest=FiveDimensionScoreServiceTest
+   ```
+   预期:全部 PASS
+
+3. **前端语法检查**
+   ```bash
+   for f in cfc-frontend/pages/tianpan/index.vue cfc-frontend/pages/tianpan/traditional.vue; do
+     sed -n '/<script>/,/<\/script>/p' $f | sed '1d;$d' | node --check - && echo "$f OK"
+   done
+   ```
+   预期:两个文件均输出 OK
+
+4. **小程序限制自查**
+   - [ ] 无可选链 `?.`
+   - [ ] 无 CSS Grid
+   - [ ] 无 `:key` 表达式
+   - [ ] 无 `new Date(string)`
+
+5. **CI 门禁**
+   ```bash
+   cd cfc-frontend && node scripts/audit-duplicate-api-calls.js
+   ```
+   预期:退出码 0

+ 330 - 0
docs/superpowers/specs/2026-09-09-team-hierarchy-design.md

@@ -0,0 +1,330 @@
+# 团队层级树与推荐关系可视化设计
+
+**版本:** v1.0
+**日期:** 2026-09-09
+**状态:** 待审查
+**关联需求:** 用户需求记录
+
+---
+
+## 一、概述
+
+**用户故事:**
+> 作为家长用户,我想在「我的」页面查看我的推荐人、分享人数、团队层级树(从下往上生长)、团队总消费额、直推成员的消费额和我获得的 CF 值,以便直观了解我的推广成果与团队结构。
+
+**核心目标:**
+1. 去掉富页面的推荐码展示
+2. 我的页面新增「我的推荐人」卡片
+3. 我的页面新增「团队层级」入口,跳转独立树形页面(从下往上生长)
+4. 树形页面展示:团队总消费额(默认 3 级,后台可配)、直推成员列表含消费额+我获得的 CF 值
+5. 后端新增/扩展接口支撑上述功能
+
+---
+
+## 二、验收标准
+
+### 2.1 富页面
+- [ ] 删除「我的邀请码」横条(`referral-code-bar` 区块)
+- [ ] 保留「邀请人数」显示
+
+### 2.2 我的页面
+- [ ] 新增「我的推荐人」卡片(显示推荐人昵称、头像、邀请码、绑定时间)
+- [ ] 新增「团队层级」入口按钮,点击跳转树形页面
+- [ ] 保留现有「推广收益」成就卡片
+
+### 2.3 团队层级树页面(新建 `/pages/profile/team-tree`)
+- [ ] 树形结构从下往上生长(根节点在底部,分支向上延伸)
+- [ ] 顶部展示:团队总消费额(默认统计 3 级)、团队人数
+- [ ] 支持折叠/展开每个节点
+- [ ] 每个节点显示:头像、昵称、层级标识(L1/L2/L3)、消费额、我的 CF 获得
+- [ ] 点击成员可查看详情(预留)
+
+### 2.4 后端接口
+- [ ] `POST /api/commission/team/list` 扩展:返回中每个成员新增 `memberConsumption`、`myCfEarnedFromMember`
+- [ ] `POST /api/commission/team/consumption` 新增:团队总消费额分级汇总
+- [ ] `POST /api/commission/my-referrer` 新增:获取我的推荐人信息
+- [ ] `POST /api/commission/team/tree` 新增:团队树形扁平数据(含层级、父子关系)
+
+### 2.5 配置化
+- [ ] 后台可配置团队消费统计最大层级(默认 3 级)
+
+---
+
+## 三、技术方案
+
+### 3.1 数据模型关系
+
+| 表 | 关键字段 | 用途 |
+|---|---|---|
+| `users` | `id`, `referrerId`, `referralCode`, `nickname`, `avatar`, `created_at` | 用户与推荐关系 |
+| `referral_tree` | `parentId`, `childId`, `level`, `path` | 物化全层级推荐树 |
+| `product_orders` | `buyerId`, `totalAmount`, `status`, `fulfillStatus`, `paidAt` | 消费订单(fulfillStatus=completed 表示已完成履约) |
+| `commission_records` | `referrerId`, `buyerId`, `commissionAmount`, `level`, `status` | 佣金记录(L1/L2;status=settled/cancelled) |
+| `user_platform_balance` | `userId`, `totalEarned`, `available`, `frozen`, `withdrawn` | CF 值钱包(现有,本需求不直接读) |
+| `cf_rate_tier` | `minTeamSize`, `ratePercent`, `enabled` | 返佣阶梯(现有,复用其 maxLevel 配置) |
+
+> 注:`referral_tree.level` = 代际差(1=直推,2=间推...);`commission_records.level` = 1/2(仅 L1/L2 结算佣金);订单完成以 `fulfillStatus = 'completed'` 为准。
+
+### 3.2 后端接口设计
+
+#### 3.2.1 扩展 `/api/commission/team/list`
+
+**请求:** `{ page, size, level }`(level: ALL/L1/L2)
+
+**响应扩展字段:**
+```json
+{
+  "records": [
+    {
+      "userId": 123,
+      "nickname": "用户名",
+      "avatarUrl": "https://...",
+      "createdAt": "2026-01-01",
+      "orderCount": 5,
+      "commissionEarned": 15000,
+      "memberConsumption": 32000,       // 新增:该成员 completed 订单总额
+      "myCfEarnedFromMember": 9600      // 新增:我从该成员消费获得的 CF(佣金)总额
+    }
+  ],
+  "total": 10,
+  "page": 1,
+  "size": 20
+}
+```
+
+**实现逻辑:**
+- `memberConsumption`:查 `product_orders` 表 `buyerId = 该成员` 且 `fulfillStatus = 'completed'` 的 `totalAmount` 总和(仅统计已完成履约的订单)
+- `myCfEarnedFromMember`:查 `commission_records` 表 `referrerId = 我` 且 `buyerId = 该成员` 且 `status != 'cancelled'` 的 `commissionAmount` 总和(含 settled + pending)
+
+#### 3.2.2 新增 `/api/commission/team/consumption`
+
+**请求:** `{ maxLevel: 3 }`(默认 3,可选)
+
+**响应:**
+```json
+{
+  "totalConsumption": 125000,
+  "levelBreakdown": [
+    { "level": 1, "memberCount": 5, "consumption": 80000 },
+    { "level": 2, "memberCount": 12, "consumption": 35000 },
+    { "level": 3, "memberCount": 8, "consumption": 10000 }
+  ],
+  "totalMembers": 25,
+  "maxLevel": 3
+}
+```
+
+**实现逻辑:**
+1. 查 `referral_tree` 表 `parentId = 我` 且 `level <= maxLevel` 的所有 `childId`
+2. 按 `level` 分组统计 `memberCount`
+3. 查这些 `childId` 的 `product_orders`(`fulfillStatus = 'completed'`)`totalAmount` 总和,按 `level` 汇总
+
+**配置化:**
+- 读取 `cf_rate_tier` 表中 `enabled=1` 的记录,或新增 `commission_config` 表存储 `team_consumption_max_level`(默认 3)
+- 后端管理接口:`POST /api/admin/cf-rate-tier/setting`(扩展现有)或新增
+
+#### 3.2.3 新增 `/api/commission/my-referrer`
+
+**请求:** 空
+
+**响应:**
+```json
+{
+  "referrerId": 456,
+  "nickname": "推荐人昵称",
+  "avatar": "https://...",
+  "referralCode": "ABC123XY",
+  "bindTime": "2026-01-15 10:30:00"
+}
+```
+
+**实现逻辑:**
+- 查 `users` 表 `id = 当前用户.referrerId`
+- 若 `referrerId` 为空,返回 `{ hasReferrer: false }`
+
+#### 3.2.4 新增 `/api/commission/team/tree`
+
+**请求:** `{ maxLevel: 3 }`(默认 3)
+
+**响应:**
+```json
+{
+  "nodes": [
+    { "userId": 101, "nickname": "直推A", "avatar": "https://...", "level": 1, "parentId": 100, "referrerId": 100, "memberConsumption": 32000, "myCfEarnedFromMember": 9600 },
+    { "userId": 102, "nickname": "间推B", "avatar": "https://...", "level": 2, "parentId": 101, "referrerId": 101, "memberConsumption": 8000, "myCfEarnedFromMember": 2400 },
+    { "userId": 103, "nickname": "直推C", "avatar": "https://...", "level": 1, "parentId": 100, "referrerId": 100, "memberConsumption": 15000, "myCfEarnedFromMember": 4500 }
+  ],
+  "totalMembers": 3,
+  "maxLevel": 3
+}
+```
+
+> **说明:** `nodes` 数组**不包含根节点("我")**——仅包含 team 成员(level >= 1);根节点仅用于前端统计/上下文。每个节点已包含 `memberConsumption`(该成员自身已完成履约订单总额)和 `myCfEarnedFromMember`(我从该成员消费获得的 CF 总额)。前端按 `parentId` 递归构建树,`level` 用于层级标签与缩进。
+
+---
+
+### 3.3 前端改动清单
+
+| 文件 | 变更类型 | 详情 |
+|---|---|---|
+| `pages/wealth/index.vue` | 删除 | 移除 `referral-code-bar`(第 161-165 行) |
+| `pages/profile-main/profile.vue` | 新增 | 1.「我的推荐人」卡片<br>2.「团队层级」入口按钮 → `navigateTo('/pages/profile/team-tree')` |
+| `pages/profile/team-tree.vue` | **新建** | 树形页面:<br>- 顶部统计卡(总消费、人数)<br>- 树形组件(从下往上生长,flex column-reverse + 递归)<br>- 节点组件:头像、昵称、层级标签、消费额、我的 CF 获得<br>- 折叠/展开交互 |
+| `utils/api.js` | 新增 | `getTeamConsumption(maxLevel)`、`getMyReferrer()`、`getTeamTree(maxLevel)`、`getTeamList` 扩展字段 |
+| `pages.json` | 注册 | 新增 `pages/profile/team-tree` 页面 |
+
+**树形 UI 实现思路(从下往上生长):**
+```vue
+<!-- 树形容器:flex column-reverse,L1节点在底部,分支向上延伸 -->
+<view class="tree-container" style="flex-direction: column-reverse;">
+  <!-- L1节点作为根:扁平 nodes 先按 parentId 组装为树,再渲染 -->
+  <TreeNode v-for="root in level1Roots" :key="root.userId" :node="root" :level="1" :maxLevel="maxLevel" />
+</view>
+
+<!-- TreeNode.vue -->
+<view class="tree-node" :style="{ 'padding-left': (level - 1) * 30 + 'rpx' }">
+  <view class="node-main" @click="toggle">
+    <image :src="node.avatar" />
+    <text>{{ node.nickname }}</text>
+    <tag>L{{ node.level }}</tag>
+    <text class="consumption">消费: ¥{{ (node.memberConsumption/100).toFixed(2) }}</text>
+    <text class="my-cf">我得CF: ¥{{ (node.myCfEarnedFromMember/100).toFixed(2) }}</text>
+    <icon :name="expanded ? 'up' : 'down'" />
+  </view>
+  <!-- 子节点:因为父容器 column-reverse,子节点会在上方渲染 -->
+  <view v-if="expanded && node.children" class="children">
+    <TreeNode v-for="child in node.children" :key="child.userId" :node="child" :level="level+1" :maxLevel="maxLevel" />
+  </view>
+  <!-- 连接线:::before 画竖线,父节点中心向上 -->
+</view>
+```
+
+---
+
+### 3.4 后端实现要点
+
+**新增 Service 方法(`CommissionService`):**
+
+```java
+// 1. 团队消费统计
+public Map<String, Object> getTeamConsumption(Long userId, Integer maxLevel)
+
+// 2. 我的推荐人
+public Map<String, Object> getMyReferrer(Long userId)
+
+// 3. 团队树形数据
+public Map<String, Object> getTeamTree(Long userId, Integer maxLevel)
+
+// 4. 扩展 getTeamList:查询 memberConsumption、myCfEarnedFromMember
+```
+
+**依赖注入需补充:**
+- `ProductOrderMapper`
+- `ReferralTreeMapper`
+- `UserPlatformBalanceMapper`(如需)
+
+**新增 Controller 方法(`CommissionController`):**
+```java
+@PostMapping("/team/consumption")
+public Result<Map<String, Object>> teamConsumption(@RequestAttribute("userId") Long userId,
+                                                    @RequestBody Map<String, Object> params)
+
+@PostMapping("/my-referrer")
+public Result<Map<String, Object>> myReferrer(@RequestAttribute("userId") Long userId)
+
+@PostMapping("/team/tree")
+public Result<Map<String, Object>> teamTree(@RequestAttribute("userId") Long userId,
+                                            @RequestBody Map<String, Object> params)
+```
+
+---
+
+## 四、API 设计汇总
+
+| 接口 | 方法 | 请求体 | 响应关键字段 | 备注 |
+|---|---|---|---|---|
+| `/api/commission/team/list` | POST | `{page,size,level}` | `records[].memberConsumption`, `records[].myCfEarnedFromMember` | 扩展现有 |
+| `/api/commission/team/consumption` | POST | `{maxLevel}` | `totalConsumption`, `levelBreakdown[]`, `totalMembers` | 新增 |
+| `/api/commission/my-referrer` | POST | `{}` | `referrerId`, `nickname`, `avatar`, `referralCode`, `bindTime` | 新增 |
+| `/api/commission/team/tree` | POST | `{maxLevel}` | `nodes[]`, `totalMembers` | 新增 |
+
+---
+
+## 五、配置项
+
+| 配置键 | 默认值 | 说明 | 维护位置 |
+|---|---|---|---|
+| `team_consumption_max_level` | 3 | 团队消费统计最大层级 | 后台管理 - CF返佣阶梯配置扩展 |
+
+> 建议复用 `AdminCfRateTierController` 新增设置接口,或新增 `CommissionConfigController`
+
+---
+
+## 六、边界情况处理
+
+| 场景 | 处理 |
+|---|---|
+| 用户无推荐人(`referrerId` 为空) | 我的推荐人卡片显示"暂无推荐人",不显示邀请码 |
+| 团队成员无消费订单 | 消费额显示 0,CF 获得显示 0 |
+| `maxLevel` 超过实际层级 | 按实际最大层级统计 |
+| `referral_tree` 数据不一致 | 以 `users.referrerId` 为准,`referral_tree` 为辅助加速查询 |
+| 订单状态:仅统计已完成履约 | 高 | 严格筛选 `fulfillStatus = 'completed'`(排除 pending/confirmed_by_vendor/received_by_user) |
+
+---
+
+## 七、测试用例
+
+| 用例 | 输入 | 预期输出 |
+|---|---|---|
+| 无推荐人用户访问我的页面 | 登录用户,referrerId=null | 推荐人卡片显示"暂无推荐人" |
+| 有推荐人用户访问我的页面 | 登录用户,referrerId=456 | 显示推荐人昵称、头像、邀请码、绑定时间 |
+| 空团队访问树形页面 | 无下级 | 仅显示根节点(自己),统计为 0 |
+| 3层团队消费统计 | maxLevel=3 | 正确汇总 L1/L2/L3 消费额 |
+| 直推成员列表 | level=L1 | 每行含 memberConsumption、myCfEarnedFromMember |
+| 折叠/展开交互 | 点击节点图标 | 子节点显示/隐藏,连接线正确 |
+
+---
+
+## 八、关联文档
+
+- `docs/superpowers/api/API_REFERENCE.md` - 接口参考(新增接口需同步记录)
+- `cfc-backend/AGENTS.md` - 后端规范
+- `cfc-frontend/AGENTS.md` - 前端规范
+
+---
+
+## 九、实施顺序建议
+
+1. **后端先行**(1-2 天):
+   - 新增 3 个接口 + 扩展 1 个接口
+   - 单元测试验证查询逻辑
+   - 更新 `API_REFERENCE.md`
+
+2. **前端并行**(1-2 天):
+   - 删除富页面推荐码
+   - 我的页面新增推荐人卡片 + 团队入口
+   - 新建 team-tree 页面(树形组件复用/新建)
+   - 接入 API
+
+3. **配置化**(0.5 天):
+   - 后台 CF 阶梯配置页扩展 `maxLevel` 字段
+   - 接口读取配置
+
+4. **联调验证**(0.5 天):
+   - 真实数据跑通
+   - 边界情况验证
+
+---
+
+## 十、风险与对策
+
+| 风险 | 等级 | 对策 |
+|---|---|---|
+| `referral_tree` 数据量大,树形查询慢 | 中 | 限制 `maxLevel`≤5,加索引 `idx_parent_level(parentId, level)` |
+| 消费额统计包含退款订单 | 高 | 严格筛选 `fulfillStatus = 'completed'`(仅统计已完成履约的订单,排除未结算/退货状态) |
+| 树形 UI 在小程序渲染卡顿 | 低 | 虚拟列表/分页加载,层级>3 懒加载 |
+| 后台配置未生效(缓存) | 低 | 配置读取走 Redis,更新后清理 |
+
+---
+
+**文档结束**

+ 351 - 0
docs/superpowers/specs/2026-09-09-tianpan-tradition-design.md

@@ -0,0 +1,351 @@
+# 家庭天盘 · 传统文化 + 五维基础能量分 设计文档
+
+- **日期**: 2026-09-09
+- **状态**: 设计待审
+- **需求来源**: 用户「完善家庭天盘的展示设计,用姓名、生日通过生辰八字、星座和数字能量进行天盘内容定义。同时也可以对五维有一个基础能量分的计算」
+
+## 1. 背景与现状
+
+### 1.1 现有实现(已具备)
+
+| 能力 | 位置 | 说明 |
+|------|------|------|
+| 八字四柱 | `family_member_attributes.eight_characters` | JSON `{"year":"甲子","month":"丙寅","day":"戊辰","hour":"壬申"}` |
+| 五行元素 | `family_member_attributes.wuxing_elements` | JSON `{"wood":30,"fire":45,"earth":60,"metal":25,"water":40}` |
+| 生肖 | `family_member_attributes.zodiac` | rat/ox/... |
+| 出生时间 | `family_member_attributes.birth_datetime` | 精确到分钟,用于排盘 |
+| 生命灵数 | `NumSoulCalculator` + `InnatePortraitService.calculateNumSoul()` | lifePath/天赋数/生日数/命运数,主数 11/22/33 保留 |
+| 灵数配置 | `numsoul_detail_config` 表 | 1-9 号人 title/keywords/advice/colorHex |
+| 先天分 | `family_member_attributes.mind_base_score` / `wisdom_base_score` | 仅心/智两维已有 |
+| 天盘聚合 | `TianpanService.buildDashboard()` / `buildMemberDetail()` | 已返回八字/星座/灵数/五行/能量快照 |
+
+### 1.2 缺口
+
+1. 五维基础分仅覆盖 mind/wisdom 两维,缺 body/action/wealth
+2. 天盘主页面 canvas 成员节点未展示传统文化信息
+3. 成员弹窗仅展示生肖/星座/代际/动态能量,无八字/灵数/五维基础分
+4. 无「全家成员传统文化对比」专属页面
+
+### 1.3 设计决策(用户已确认)
+
+1. **展示范围**:以上都要 —— canvas 节点标注 + 成员弹窗增强 + 新增专属页面
+2. **五维基础分算法**:八字五行 + 星座 + 灵数 加权(确定性规则,不走 AI)
+3. **数字能量**:9 型人格数字能量学(即生命灵数体系,复用现有 `NumSoulCalculator`)
+
+## 2. 五维基础能量分算法
+
+### 2.1 核心映射(权威,来自 README 五维五行矩阵)
+
+| 维度 | code | 五行 | 颜色 |
+|:---:|:---:|:---:|:---:|
+| 身 | body | 土 earth | `#FF8C42` |
+| 智 | wisdom | 金 metal | `#6366F1` |
+| 富 | wealth | 水 water | `#F59E0B` |
+| 行 | action | 木 wood | `#10B981` |
+| 心 | mind | 火 fire | `#FF6B9D` |
+
+### 2.2 加权公式
+
+```
+五维基础分[dim] = round( 八字五行[dim] × 0.6  +  星座命中[dim] × 20  +  灵数命中[dim] × 20 )
+```
+
+- 分数范围:每维度 0-100
+- 八字贡献 0-60 分(主因子,占 60%)
+- 星座贡献 0-20 分(占 20%)
+- 灵数贡献 0-20 分(占 20%)
+
+### 2.3 八字五行层(权重 60%)
+
+直接取 `wuxingElements` 中对应五行值(0-100 量纲)乘以 0.6:
+
+| 五行 | 映射维度 |
+|:---:|:---:|
+| wood 木 | action 行 |
+| fire 火 | mind 心 |
+| earth 土 | body 身 |
+| metal 金 | wisdom 智 |
+| water 水 | wealth 富 |
+
+**缺数据兜底**:某成员无 `wuxingElements`(或某五行缺失)时,该维度取中性值 50。
+
+### 2.4 星座层(权重 20%)
+
+西方 12 星座四元素 → 五行 → 五维映射,命中维度 +20 分:
+
+| 四元素 | 星座 | 五行 | 维度 |
+|:---:|------|:---:|:---:|
+| 火象 | 白羊/狮子/射手 | 火 | 心 |
+| 土象 | 金牛/处女/摩羯 | 土 | 身 |
+| 风象 | 双子/天秤/水瓶 | 木 | 行 |
+| 水象 | 巨蟹/天蝎/双鱼 | 水 | 富 |
+
+**边界说明**:西方占星无「金」元素,金(智)在星座层恒 0 贡献。智维度由八字金 + 灵数(3/7)覆盖,不影响整体平衡。星座数据来源:`TianpanService.enrichMembers()` 已通过 `zodiacAnnualEnergyService.getWesternSign(month, day)` 计算 `westernSign`。
+
+**无星座数据兜底**:westernSign 为空时星座层全部 0 贡献。
+
+### 2.5 生命灵数层(权重 20%)
+
+灵数(生命数 lifePath,1-9)映射主维度,命中维度 +20 分:
+
+| 灵数 | 人格特质 | 主维度 |
+|:---:|:---:|:---:|
+| 1 | 开创/独立/领导 | 行 |
+| 2 | 合群/同理/协调 | 心 |
+| 3 | 创意/表达 | 智 |
+| 4 | 务实/规律/稳定 | 身 |
+| 5 | 自由/冒险 | 行 |
+| 6 | 关怀/责任/家庭 | 心 |
+| 7 | 分析/策略/求知 | 智 |
+| 8 | 商业/权力/物质 | 富 |
+| 9 | 博爱/理想 | 心 |
+
+灵数来源:现有 `NumSoulCalculator.calcLifePath(year, month, day)`(主数 11/22/33 保留)。`TianpanMemberVO.lifeNumber` 来自 `behaviorModifier` 中存储值,**服务内做防御性化简**:11→2、22→4、33→6(主数化简),其余 >9 值按 digit sum 递归化简至 1-9,null 则不命中。
+
+**无灵数数据兜底**:无 birthDatetime 时灵数层全部 0 贡献。
+
+### 2.6 计算示例
+
+输入:八字五行 `{wood:40, fire:60, earth:50, metal:30, water:20}`、星座狮子座(火象→心)、灵数 7(→智)
+
+| 维度 | 八字×0.6 | 星座 | 灵数 | 基础分 |
+|:---:|:---:|:---:|:---:|:---:|
+| 身 body(土) | 50×0.6=30 | 0 | 0 | 30 |
+| 智 wisdom(金) | 30×0.6=18 | 0 | 20 | 38 |
+| 富 wealth(水) | 20×0.6=12 | 0 | 0 | 12 |
+| 行 action(木) | 40×0.6=24 | 0 | 0 | 24 |
+| 心 mind(火) | 60×0.6=36 | 20 | 0 | 56 |
+
+## 3. 后端设计
+
+### 3.1 新增服务 `FiveDimensionScoreService`
+
+文件:`cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.java`
+
+```java
+@Service
+public class FiveDimensionScoreService {
+
+    /**
+     * 计算并落库(INSERT ON DUPLICATE KEY UPDATE)
+     *
+     * @param memberId 成员 ID
+     * @param memberType 成员类型
+     * @param wuxingElements 五行元素 {"wood":..,"fire":..,"earth":..,"metal":..,"water":..},可为 null
+     * @param westernSign    西方星座中文名(如 "狮子座"),可为 null
+     * @param lifeNumber     生命灵数(主数 11/22/33 已化简为 1-9),可为 null
+     * @param operator       计算来源(system/manual)
+     */
+    public void calcAndSave(Long memberId, String memberType,
+                            Map<String, Integer> wuxingElements,
+                            String westernSign, Integer lifeNumber,
+                            String operator) {
+        Map<String, Integer> scores = calcBaseScores(wuxingElements, westernSign, lifeNumber);
+        saveScores(memberId, memberType, scores, operator);
+    }
+
+    /**
+     * 重算指定成员(覆盖现有记录)
+     */
+    public void recalibrate(Long memberId, String memberType, String operator) {
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+        if (attrs == null) return;
+        String westernSign = calcWesternSignFromAttrs(attrs); // 复用 zodiacAnnualEnergyService 逻辑
+        Integer lifeNumber = attrs.getLifeNumber(); // 若未存则从 birthDatetime 重新算
+        calcAndSave(memberId, memberType, parseWuxingMap(attrs.getWuxingElements()),
+                    westernSign, lifeNumber, operator);
+    }
+
+    /**
+     * 批量重算全家(用于数据修复/策略调整)
+     */
+    public int recalibrateFamily(Long familyId, String operator) { ... }
+
+    /**
+     * 查询五维基础分(供 dashboard/member 接口读取)
+     */
+    public Map<String, Integer> getScores(Long memberId) { ... }
+}
+```
+
+#### 3.1.1 星座 → 四元素映射(内部常量)
+
+```java
+private static final Map<String, String> ZODIAC_ELEMENT = ...;
+// 白羊座/狮子座/射手座 -> FIRE
+// 金牛座/处女座/摩羯座 -> EARTH
+// 双子座/天秤座/水瓶座 -> AIR
+// 巨蟹座/天蝎座/双鱼座 -> WATER
+// 元素 -> 维度: FIRE->mind, EARTH->body, AIR->action, WATER->wealth
+```
+
+#### 3.1.2 灵数 → 主维度映射(内部常量)
+
+```java
+private static final Map<Integer, String> LIFE_NUMBER_DIM = ...;
+// 1->action, 2->mind, 3->wisdom, 4->body, 5->action, 6->mind, 7->wisdom, 8->wealth, 9->mind
+```
+
+#### 3.1.3 计算逻辑
+
+```java
+// 1. 初始化五维 map,八字层: 各维度 = (wuxing 对应五行值 ?? 50) × 0.6
+// 2. 星座命中: elementToDim[westernSign元素] += 20
+// 3. 灵数命中: numberToDim[reduceMaster(lifeNumber)] += 20  // 11->2, 22->4, 33->6, >9 digit sum
+// 4. round 取整返回
+```
+
+**单测**:`cfc-backend/src/test/java/com/etotem/cfc/unit/FiveDimensionScoreServiceTest.java`
+- 全数据用例(对照 2.6 示例断言精确值)
+- 各层缺数据兜底用例(wuxing=null / westernSign=null / lifeNumber=null)
+- 主数化简用例(11→映射2、22→映射4、33→映射6)
+
+### 3.2 扩展 DTO
+
+`TianpanMemberVO` 新增字段:
+
+```java
+private Map<String, Integer> dimensionBaseScores;  // 五维基础分 {body,mind,wisdom,action,wealth}
+```
+
+### 3.3 改动点
+
+**`TianpanService.enrichMembers()`**(第 174 行附近):
+- 原有 `m.getWuxingElements()`、`vo.getWesternSign()`、`m.getLifeNumber()` 保持不变
+- 改为从新表读取已落库的五维基础分:`fiveDimensionScoreService.getScores(m.getMemberId())`
+- 若表无数据(首次部署或重算未完成)则 fallback 到内存计算(`calcBaseScores(...)`)并立即落库
+- 新增 `zodiacName` 填充(见 3.6)
+
+**`TianpanService.buildMemberDetail()`**:经 `enrichMembers` 自动填充,无需额外改动。
+
+**依赖注入**:`TianpanService` 增加 `@Resource private FiveDimensionScoreService fiveDimensionScoreService;`
+
+**新增 Controller 接口**:
+- `POST /api/tianpan/recalibrate` — 手动触发单个成员重算
+  - 请求体:`{memberId, memberType, operator: "admin"}`
+  - 返回:更新后的 `dimensionBaseScores`
+- `POST /api/tianpan/recalibrate-family` — 批量重算全家(管理员用)
+  - 请求体:`{familyId, operator: "admin"}`
+  - 返回:成功重算的成员数
+
+**管理端集成**:在 Web 管理端家庭成员详情页新增「重新计算五维基础分」按钮,调用上述接口。
+
+### 3.4 数据库迁移
+
+**新增表 `family_member_dimension_base_scores`**:
+```sql
+CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent',
+    dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth',
+    base_score INT NOT NULL COMMENT '基础能量分(0-100)',
+    last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间',
+    calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源(system/manual)',
+    UNIQUE KEY uk_member_dimension (member_id, dimension_code),
+    INDEX idx_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分(落库,支持重算)';
+```
+
+**迁移逻辑**:`DatabaseInitializer.runMigrations()` 追加迁移,对已有成员批量回填(若 `family_member_attributes` 有数据则触发一次性重算写入)。
+
+### 3.5 显式不做的事
+
+- ❌ 不引入 AI/LangGraph —— 用户已确认走确定性加权计算
+
+## 4. 前端设计(三处增强)
+
+### 4.1 Canvas 成员节点标注(`pages/tianpan/index.vue`)
+
+`drawMemberNodes()`(第 375 行附近):
+- 成员节点圆形下方增加一行小字:`生肖·灵数`(如 `兔·7`)
+- 数据来源:`member.zodiacName`(生肖名)+ `member.lifeNumber`
+- 无生肖/灵数时不显示该行(避免空标注)
+- 颜色:跟随 `getMemberColor(member.effectiveRole)`
+- 字体:9px,与节点名字体一致
+
+**注意**:`enrichMembers` 目前只设置了 `vo.setZodiac(m.getZodiac())`(生肖代码,如 rat),**未设置 `zodiacName`(生肖中文名)**。前端弹窗/节点展示生肖名必须补齐:在 `enrichMembers` 中通过生肖代码映射中文名(映射表参考 `ZodiacConfig`/`findZodiacName` 逻辑,rat→鼠/ox→牛/...)并 `vo.setZodiacName(...)`。
+
+### 4.2 成员弹窗增强(`pages/tianpan/index.vue` `member-popup`)
+
+弹窗内容重构为两区块:
+
+**① 基本信息区**(现有 + 扩展):
+- 现有:生肖、星座、代际
+- 新增:生命灵数(`member.lifeNumber`,显示 `灵数 N`)、八字四柱(`member.eightCharacters`:年/月/日/时柱横向四格排布,复用 `FamilyTianpanCard` 的 `pillarLabels` 样式)
+- 脱敏:孩子视角查看家长成员时,后端已脱敏为仅年柱(`redactEightCharacters`),前端直接渲染
+
+**② 能量区**(双条对比):
+- 现有:动态能量条(`selectedMember.energy`)
+- 新增:**五维基础分**条(`selectedMember.dimensionBaseScores`,用五维标准色,复用现有 `.energy-bar` 样式)
+
+### 4.3 新增「命理」专属页面 `pages/tianpan/traditional.vue`
+
+**入口**:`pages/tianpan/index.vue` canvas 下方、tab 栏之前新增「家庭成员命理解读」入口卡片(样式参考现有 `fortune-card`)
+
+**页面结构**(全家成员传统文化对比):
+1. **家庭成员列表**:`mapGetters('tianpan', ['members'])` 数据(复用 dashboard 已加载数据,不重复请求)
+2. **每个成员展开卡片**:
+   - 头部:头像 + 昵称 + 角色
+   - 八字四柱:年/月/日/时柱四格
+   - 五行条形图:复用 `FamilyTianpanCard` wuxingData 样式
+   - 星座 + 生肖 tag
+   - 灵数:**定稿为简化版** —— 仅展示生命数(`member.lifeNumber`)+ 灵数称号(`member.lifeNumberTitle`)。完整的天赋数/生日数/命运数(`NumSoulDetailVO`)不随 dashboard 返回,如需完整版走 `/api/tianpan/member/{id}` 详情扩展(本迭代不做,列入 out of scope)
+   - **五维基础分**:条形图(五维标准色)+ 总分
+
+**页面注册**:`pages.json` 的 `pages/tianpan` 分包新增 `traditional`
+
+**API 去重规范**:本页复用 store `tianpan/members`(dashboard 已加载),禁止重复请求 `/api/tianpan/dashboard`;成员展开明细如需更多数据走 `/api/tianpan/member/{id}`(按需点击加载,遵守规则 4 不 N+1)
+
+### 4.4 数据依赖核对
+
+| 前端展示项 | 后端字段 | 现状 | 动作 |
+|------|------|------|------|
+| 生肖名 | `zodiacName` | ❌ `enrichMembers` 未设置(仅 zodiac 代码) | **补齐**:代码→中文名映射 + setZodiacName |
+| 星座 | `westernSign` | ✅ 已设置 | 无 |
+| 灵数 | `lifeNumber` | ✅ 已设置 | 无 |
+| 八字四柱 | `eightCharacters` | ✅ 已设置(含脱敏) | 无 |
+| 五行 | `wuxingElements` | ✅ 已设置 | 无 |
+| 五维基础分 | `dimensionBaseScores` | ❌ 新增 | 3.3 实现 |
+| 灵数详情(天赋/生日/命运数) | numSoul | `TianpanMemberVO` 未含 | 传统页简化版仅展示 lifeNumber |
+
+## 5. 测试策略
+
+### 5.1 后端单测(必须)
+
+`FiveDimensionScoreServiceTest`:
+- 全数据用例(对照 2.6 示例断言)
+- 兜底用例:wuxing=null、westernSign=null、lifeNumber=null、全部为 null(五维全 50×0.6=30)
+- 主数化简用例
+- 星座边界:12 星座全部覆盖 + 未知星座字符串
+
+### 5.2 前端校验
+
+- `node --check` 提取的 script 块语法校验(按 cfc-frontend/AGENTS.md 规范,Agent 不打包)
+- CI 门禁:`node scripts/audit-duplicate-api-calls.js` 通过
+- 小程序限制自查:无可选链 `?.`、无 CSS Grid、无 `:key` 表达式、无 `new Date(string)`
+
+### 5.3 编译验证
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+## 6. 范围边界
+
+**In scope**:
+- 五维基础分算法服务 + 单测
+- `family_member_dimension_base_scores` 表 + 迁移 + 批量回填
+- `TianpanMemberVO` 扩展(dimensionBaseScores)+ `enrichMembers` 填充(含 zodiacName 补齐)
+- 重算接口 `POST /api/tianpan/recalibrate` / `recalibrate-family`
+- 天盘首页 canvas 节点标注 + 成员弹窗增强
+- 新增 `pages/tianpan/traditional.vue` + 入口卡片 + pages.json 注册
+- Web 管理端家庭成员详情「重新计算五维基础分」按钮(可选)
+
+**Out of scope**:
+- 姓名数理(五格剖象法)—— 需汉字笔画库,单独需求
+- 灵数九宫格连线(147/258/369 连线分析)—— 后续迭代
+- 灵数完整详情(天赋数/生日数/命运数)随 member 详情接口返回 —— 后续迭代
+- AI 天盘解读(LangGraph)—— 未确认前不做
+- 天盘周报 PDF 内容变更
+- 数据库迁移(无新表无新列)