2026-09-09-tianpan-tradition-impl.md 37 KB

家庭天盘·传统文化 + 五维基础能量分 实现计划

面向 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。

运行:

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:

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:

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 末尾追加:

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之后)追加:

// 迁移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:编译验证

运行:

cd cfc-backend && mvn clean compile

预期:BUILD SUCCESS

  • [ ] 步骤 7:Commit

    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:

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:运行测试确认失败

    cd cfc-backend && mvn test -pl . -Dtest=FiveDimensionScoreServiceTest
    

预期:FAIL(方法不存在)

  • 步骤 3:实现服务

创建 cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionScoreService.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:运行测试确认通过

    cd cfc-backend && mvn test -pl . -Dtest=FiveDimensionScoreServiceTest
    

预期:PASS

  • [ ] 步骤 5:Commit

    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 中追加字段:

private Map<String, Integer> dimensionBaseScores;  // 五维基础分
private String zodiacName;                         // 生肖中文名(rat→鼠等)
  • 步骤 2:修改 TianpanService.enrichMembers()

在 TianpanService.java 的 enrichMembers() 方法(第 216 行附近)追加:

// 注入新服务
@Resource
private FiveDimensionScoreService fiveDimensionScoreService;

在 enrichMembers() 循环末尾(vo.setEightCharacters(...) 之后)追加:

// 生肖中文名
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);

添加辅助方法:

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 追加:

@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:编译验证

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS

  • [ ] 步骤 5:Commit

    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 行附近)的成员节点绘制后追加:

// 在 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 行),基本信息区追加八字和灵数,能量区追加五维基础分:

<!-- 新增:生命灵数 -->
<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 中追加:

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 之后追加五维基础分展示:

<!-- 新增:五维基础分 -->
<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 中追加:

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:语法检查

运行:

node -c cfc-frontend/pages/tianpan/index.vue 2>&1 || echo "需要检查script块语法"

由于这是 .vue 文件,手动检查 script 块语法:

sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/tianpan/index.vue | sed '1d;$d' | node --check -

预期:无报错

  • [ ] 步骤 4:Commit

    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 分包中添加:

{
  "path": "pages/tianpan/traditional",
  "style": {
    "navigationBarTitleText": "家庭传统命理"
  }
}
  • 步骤 2:创建页面组件

创建 cfc-frontend/pages/tianpan/traditional.vue:

<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:语法检查

    sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/tianpan/traditional.vue | sed '1d;$d' | node --check -
    

预期:无报错

  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages.json cfc-frontend/pages/tianpan/traditional.vue
    git commit -m "feat(frontend): 新增传统命理页面"
    

验证清单

完成所有任务后,执行以下验证:

  1. 后端编译

    cd cfc-backend && mvn clean compile
    

    预期:BUILD SUCCESS

  2. 单测通过

    cd cfc-backend && mvn test -Dtest=FiveDimensionScoreServiceTest
    

    预期:全部 PASS

  3. 前端语法检查

    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 门禁

    cd cfc-frontend && node scripts/audit-duplicate-api-calls.js
    

    预期:退出码 0