Browse Source

chore: auto bump version and changelog [skip ci]

iwt 1 tuần trước cách đây
mục cha
commit
969e79d1ea

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-77dcb8d008e37820ca03b841f1907be8798182cb
+4cb9a1b16892f1ba00b5d151b044d4077b3d6efd

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1336",
+  "version": "1.0.1337",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1336",
+      "version": "1.0.1337",
       "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.1337",
+  "version": "1.0.1338",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,13 @@
 
 ---
 
+## v1.0.1338 (2026-09-09)
+
+### 文档
+- 更新天盘设计——五维基础分改为落库+重算接口
+- 家庭天盘传统文化+五维基础分设计文档
+
+
 ## v1.0.1337 (2026-09-09)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1337
+> 当前版本: v1.0.1338
 
 ## 历史版本
 
@@ -8,6 +8,13 @@
 
 ---
 
+## v1.0.1338 (2026-09-09)
+
+### 文档
+- 更新天盘设计——五维基础分改为落库+重算接口
+- 家庭天盘传统文化+五维基础分设计文档
+
+
 ## v1.0.1337 (2026-09-09)
 
 ### 新功能

+ 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