2026-06-05-five-dimension-energy-system.md 55 KB

五维能量系统实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现身/心/智/行/富五维能量系统,包括4张新表、后端Entity/Mapper/Service/Controller、数据库迁移、前端wuxing-sandbox组件重写、能量详情页、API集成

Architecture: 方案B — 维度定义表+流水表+余额表+比例配置表。标准ledger模式(流水+余额双写),awardEnergy自动查比例分配各维度,deductEnergy检查余额后扣减。与现有积分系统完全解耦并行。

Tech Stack: Java 1.8 + Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0(后端)| uni-app Vue 2(前端小程序)

Design Spec: docs/superpowers/specs/2026-06-05-five-dimension-energy-design.md


File Structure

后端新建文件

文件 职责
zxyj-backend/src/main/java/com/zxyj/entity/EnergyDimension.java 维度定义实体
zxyj-backend/src/main/java/com/zxyj/entity/EnergySourceConfig.java 服务-维度比例配置实体
zxyj-backend/src/main/java/com/zxyj/entity/EnergyLog.java 能量流水实体
zxyj-backend/src/main/java/com/zxyj/entity/EnergyBalance.java 维度余额实体
zxyj-backend/src/main/java/com/zxyj/mapper/EnergyDimensionMapper.java 维度CRUD
zxyj-backend/src/main/java/com/zxyj/mapper/EnergySourceConfigMapper.java 比例配置CRUD
zxyj-backend/src/main/java/com/zxyj/mapper/EnergyLogMapper.java 流水查询
zxyj-backend/src/main/java/com/zxyj/mapper/EnergyBalanceMapper.java 余额查询/更新
zxyj-backend/src/main/java/com/zxyj/service/EnergyService.java 核心业务:发放/扣除/查概览/查流水/配比例
zxyj-backend/src/main/java/com/zxyj/controller/energy/EnergyController.java API入口

后端修改文件

文件 修改内容
zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java 添加4张表DDL + 种子数据 + 比例配置迁移
zxyj-backend/src/main/java/com/zxyj/service/TaskService.java completeTask()末尾添加energyService.awardEnergy()调用

前端修改文件

文件 修改内容
zxyj-frontend/components/wuxing-sandbox.vue 完全重写:接收dimensions/totalEnergy/totalHealthIndex props,显示真实数据,三层视觉结构
zxyj-frontend/utils/api.js 添加getEnergyOverview/getEnergyLogs
zxyj-frontend/pages/index/parent-index.vue 传入API获取的dimensions/totalEnergy/totalHealthIndex
zxyj-frontend/pages/index/child-index.vue 同上
zxyj-frontend/pages.json 注册energy/detail页面

前端新建文件

文件 职责
zxyj-frontend/pages/energy/detail.vue 维度详情页:能量值、流水记录

Task 1: 数据库迁移 — 4张新表 + 种子数据

Files:

  • Modify: zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java

initSchema() 方法末尾(log.info("数据库迁移完成") 之前)添加能量系统4张表DDL和种子数据。

  • Step 1: 在DatabaseInitializer.initSchema()末尾添加4张表DDL

log.info("数据库迁移完成") 之前添加:

// ==================== 五维能量系统 ====================
// 维度定义表
try {
    jdbcTemplate.execute(
        "CREATE TABLE IF NOT EXISTS energy_dimension (" +
        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
        "code VARCHAR(16) NOT NULL UNIQUE COMMENT '维度代码: body/mind/wisdom/action/wealth', " +
        "name VARCHAR(20) NOT NULL COMMENT '维度名称: 身/心/智/行/富', " +
        "icon VARCHAR(16) COMMENT '图标emoji', " +
        "element VARCHAR(10) COMMENT '五行元素: 土/火/金/木/水', " +
        "sort_order INT DEFAULT 0 COMMENT '显示顺序', " +
        "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用', " +
        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量维度定义表'"
    );
    log.info("已创建energy_dimension表");
} catch (Exception e) {
    log.warn("创建energy_dimension表失败: {}", e.getMessage());
}

// 服务-维度比例配置表
try {
    jdbcTemplate.execute(
        "CREATE TABLE IF NOT EXISTS energy_source_config (" +
        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
        "source_type VARCHAR(32) NOT NULL COMMENT '来源类型: task/activity/course/product/consultation', " +
        "source_id BIGINT NOT NULL COMMENT '来源业务表ID', " +
        "source_name VARCHAR(200) COMMENT '冗余名称', " +
        "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
        "ratio DECIMAL(5,4) NOT NULL COMMENT '占比如0.6000=60%', " +
        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
        "INDEX idx_source (source_type, source_id)" +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务-维度比例配置表'"
    );
    log.info("已创建energy_source_config表");
} catch (Exception e) {
    log.warn("创建energy_source_config表失败: {}", e.getMessage());
}

// 能量流水表
try {
    jdbcTemplate.execute(
        "CREATE TABLE IF NOT EXISTS energy_log (" +
        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
        "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
        "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
        "amount INT NOT NULL COMMENT '变动量(正=获得,负=消耗)', " +
        "balance_after INT NOT NULL COMMENT '变动后余额', " +
        "source_type VARCHAR(32) COMMENT '来源类型', " +
        "source_id BIGINT COMMENT '来源ID', " +
        "description VARCHAR(500) COMMENT '描述', " +
        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
        "INDEX idx_child_dim (child_id, dimension_id), " +
        "INDEX idx_created (created_at)" +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量流水表'"
    );
    log.info("已创建energy_log表");
} catch (Exception e) {
    log.warn("创建energy_log表失败: {}", e.getMessage());
}

// 维度余额表
try {
    jdbcTemplate.execute(
        "CREATE TABLE IF NOT EXISTS energy_balance (" +
        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
        "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
        "dimension_id BIGINT NOT NULL COMMENT '维度ID', " +
        "balance INT DEFAULT 0 COMMENT '当前能量值', " +
        "total_earned INT DEFAULT 0 COMMENT '累计获得', " +
        "total_spent INT DEFAULT 0 COMMENT '累计消耗', " +
        "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
        "UNIQUE KEY uk_child_dim (child_id, dimension_id)" +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维度余额表'"
    );
    log.info("已创建energy_balance表");
} catch (Exception e) {
    log.warn("创建energy_balance表失败: {}", e.getMessage());
}
  • [ ] Step 2: 在表DDL之后添加维度种子数据

    // 维度种子数据
    try {
    Integer dimCount = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM energy_dimension", Integer.class);
    if (dimCount == null || dimCount == 0) {
        jdbcTemplate.execute("INSERT INTO energy_dimension (code, name, icon, element, sort_order, status) VALUES " +
            "('mind', '心', '🔥', '火', 1, 1), " +
            "('action', '行', '🌿', '木', 2, 1), " +
            "('wealth', '富', '💧', '水', 3, 1), " +
            "('wisdom', '智', '⚔️', '金', 4, 1), " +
            "('body', '身', '🌏', '土', 5, 1)");
        log.info("能量维度种子数据已初始化");
    }
    } catch (Exception e) {
    log.warn("初始化能量维度种子数据失败: {}", e.getMessage());
    }
    
  • [ ] Step 3: 为已有Product种子数据插入默认比例配置

在种子数据插入之后,添加Product→energy_source_config的1:1单维度映射:

// 为已有商品插入默认比例配置(与Product.domain一致)
try {
    Integer configCount = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM energy_source_config WHERE source_type = 'product'", Integer.class);
    if (configCount == null || configCount == 0) {
        // domain→dimension_code映射
        String[][] domainMapping = {
            {"action", "action"}, {"mind", "mind"}, {"body", "body"},
            {"wisdom", "wisdom"}, {"wealth", "wealth"}
        };
        for (String[] mapping : domainMapping) {
            jdbcTemplate.execute(
                "INSERT INTO energy_source_config (source_type, source_id, source_name, dimension_id, ratio) " +
                "SELECT 'product', p.id, p.name, d.id, 1.0000 " +
                "FROM products p " +
                "JOIN energy_dimension d ON d.code = '" + mapping[1] + "' " +
                "WHERE p.domain = '" + mapping[0] + "' " +
                "AND NOT EXISTS (" +
                "  SELECT 1 FROM energy_source_config esc " +
                "  WHERE esc.source_type = 'product' AND esc.source_id = p.id" +
                ")"
            );
        }
        log.info("已有商品能量比例配置已初始化");
    }
} catch (Exception e) {
    log.warn("初始化商品能量比例配置失败: {}", e.getMessage());
}
  • Step 4: 验证编译通过

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 5: Commit

    git add zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java
    git commit -m "feat(energy): add 4 energy tables + seed data to DatabaseInitializer"
    

Task 2: 后端实体类 — 4个Entity

Files:

  • Create: zxyj-backend/src/main/java/com/zxyj/entity/EnergyDimension.java
  • Create: zxyj-backend/src/main/java/com/zxyj/entity/EnergySourceConfig.java
  • Create: zxyj-backend/src/main/java/com/zxyj/entity/EnergyLog.java
  • Create: zxyj-backend/src/main/java/com/zxyj/entity/EnergyBalance.java

遵循现有entity模式:@Data @TableName @TableId(type=IdType.AUTO) implements Serializable,字段名用驼峰。

  • [ ] Step 1: 创建 EnergyDimension.java

    package com.zxyj.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("energy_dimension")
    public class EnergyDimension implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String code;
    private String name;
    private String icon;
    private String element;
    private Integer sortOrder;
    private Integer status;
    private Date createdAt;
    }
    
  • [ ] Step 2: 创建 EnergySourceConfig.java

    package com.zxyj.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    import java.io.Serializable;
    import java.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("energy_source_config")
    public class EnergySourceConfig implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String sourceType;
    private Long sourceId;
    private String sourceName;
    private Long dimensionId;
    private BigDecimal ratio;
    private Date createdAt;
    }
    
  • [ ] Step 3: 创建 EnergyLog.java

    package com.zxyj.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("energy_log")
    public class EnergyLog implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long childId;
    private Long dimensionId;
    private Integer amount;
    private Integer balanceAfter;
    private String sourceType;
    private Long sourceId;
    private String description;
    private Date createdAt;
    }
    
  • [ ] Step 4: 创建 EnergyBalance.java

    package com.zxyj.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("energy_balance")
    public class EnergyBalance implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long childId;
    private Long dimensionId;
    private Integer balance;
    private Integer totalEarned;
    private Integer totalSpent;
    private Date updatedAt;
    }
    
  • [ ] Step 5: 验证编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 6: Commit

    git add zxyj-backend/src/main/java/com/zxyj/entity/Energy*.java
    git commit -m "feat(energy): add 4 energy entity classes"
    

Task 3: 后端Mapper接口 — 4个Mapper

Files:

  • Create: zxyj-backend/src/main/java/com/zxyj/mapper/EnergyDimensionMapper.java
  • Create: zxyj-backend/src/main/java/com/zxyj/mapper/EnergySourceConfigMapper.java
  • Create: zxyj-backend/src/main/java/com/zxyj/mapper/EnergyLogMapper.java
  • Create: zxyj-backend/src/main/java/com/zxyj/mapper/EnergyBalanceMapper.java

遵循现有mapper模式:@Mapper interface XxxMapper extends BaseMapper<Xxx>,无自定义方法。

  • [ ] Step 1: 创建4个Mapper

    // EnergyDimensionMapper.java
    package com.zxyj.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.zxyj.entity.EnergyDimension;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface EnergyDimensionMapper extends BaseMapper<EnergyDimension> {
    }
    
    // EnergySourceConfigMapper.java
    package com.zxyj.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.zxyj.entity.EnergySourceConfig;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface EnergySourceConfigMapper extends BaseMapper<EnergySourceConfig> {
    }
    
    // EnergyLogMapper.java
    package com.zxyj.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.zxyj.entity.EnergyLog;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface EnergyLogMapper extends BaseMapper<EnergyLog> {
    }
    
    // EnergyBalanceMapper.java
    package com.zxyj.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.zxyj.entity.EnergyBalance;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface EnergyBalanceMapper extends BaseMapper<EnergyBalance> {
    }
    
  • [ ] Step 2: 验证编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 3: Commit

    git add zxyj-backend/src/main/java/com/zxyj/mapper/Energy*Mapper.java
    git commit -m "feat(energy): add 4 energy mapper interfaces"
    

Task 4: 后端核心服务 — EnergyService

Files:

  • Create: zxyj-backend/src/main/java/com/zxyj/service/EnergyService.java

这是能量系统最核心的文件。实现5个核心方法:awardEnergy、deductEnergy、getOverview、getLogs、configureSourceRatios。

  • [ ] Step 1: 创建EnergyService.java

    package com.zxyj.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.zxyj.entity.*;
    import com.zxyj.mapper.*;
    import javax.annotation.Resource;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import java.math.BigDecimal;
    import java.util.*;
    import java.util.stream.Collectors;
    
    @Service
    public class EnergyService {
    
    @Resource
    private EnergyDimensionMapper dimensionMapper;
    @Resource
    private EnergySourceConfigMapper sourceConfigMapper;
    @Resource
    private EnergyLogMapper logMapper;
    @Resource
    private EnergyBalanceMapper balanceMapper;
    @Resource
    private ProductMapper productMapper;
    
    // 维度缓存(启动后首次查询缓存,维度数据极少变更)
    private List<EnergyDimension> dimensionCache;
    private Map<String, EnergyDimension> codeToDimension;
    
    /**
     * 发放能量 — 查比例配置,按比例分配到各维度
     * @param childId 孩子ID
     * @param sourceType 来源类型(task/activity/course/product/consultation)
     * @param sourceId 来源ID
     * @param totalAmount 总能量值
     * @param description 描述
     * @return 各维度实际发放量 Map<dimensionCode, amount>
     */
    @Transactional
    public Map<String, Integer> awardEnergy(Long childId, String sourceType, Long sourceId,
                                             Integer totalAmount, String description) {
        if (totalAmount <= 0) {
            return new HashMap<>();
        }
    
        // 1. 查比例配置
        List<EnergySourceConfig> configs = sourceConfigMapper.selectList(
            new LambdaQueryWrapper<EnergySourceConfig>()
                .eq(EnergySourceConfig::getSourceType, sourceType)
                .eq(EnergySourceConfig::getSourceId, sourceId)
        );
    
        // 2. Fallback链
        Map<Long, BigDecimal> dimensionRatios = new LinkedHashMap<>();
        if (!configs.isEmpty()) {
            // 有配置,使用配置比例
            for (EnergySourceConfig config : configs) {
                dimensionRatios.put(config.getDimensionId(), config.getRatio());
            }
        } else if ("product".equals(sourceType)) {
            // Fallback: 查Product.domain
            dimensionRatios = resolveFromProductDomain(sourceId);
        }
    
        if (dimensionRatios.isEmpty()) {
            // 最终fallback: 默认分配到"行"维度100%
            EnergyDimension actionDim = getDimensionByCode("action");
            if (actionDim != null) {
                dimensionRatios.put(actionDim.getId(), BigDecimal.ONE);
            }
        }
    
        if (dimensionRatios.isEmpty()) {
            return new HashMap<>();
        }
    
        // 3. 按比例计算各维度发放量(整数,余数加到最大比例维度)
        Map<Long, Integer> allocations = calculateAllocations(totalAmount, dimensionRatios);
    
        // 4. 对每个维度:更新余额 + 写流水
        Map<String, Integer> result = new LinkedHashMap<>();
        for (Map.Entry<Long, Integer> entry : allocations.entrySet()) {
            Long dimensionId = entry.getKey();
            Integer amount = entry.getValue();
            if (amount <= 0) continue;
    
            // 更新余额
            EnergyBalance balance = getOrCreateBalance(childId, dimensionId);
            balance.setBalance(balance.getBalance() + amount);
            balance.setTotalEarned(balance.getTotalEarned() + amount);
            balance.setUpdatedAt(new Date());
            balanceMapper.updateById(balance);
    
            // 写流水
            EnergyLog log = new EnergyLog();
            log.setChildId(childId);
            log.setDimensionId(dimensionId);
            log.setAmount(amount);
            log.setBalanceAfter(balance.getBalance());
            log.setSourceType(sourceType);
            log.setSourceId(sourceId);
            log.setDescription(description);
            log.setCreatedAt(new Date());
            logMapper.insert(log);
    
            // 记录返回结果
            EnergyDimension dim = getDimensionById(dimensionId);
            if (dim != null) {
                result.put(dim.getCode(), amount);
            }
        }
    
        return result;
    }
    
    /**
     * 扣除能量 — 指定维度,检查余额
     * @return 扣除后余额,-1表示余额不足
     */
    @Transactional
    public int deductEnergy(Long childId, Long dimensionId, Integer amount, String reason) {
        if (amount <= 0) {
            throw new IllegalArgumentException("扣除数量必须为正数");
        }
    
        EnergyBalance balance = getOrCreateBalance(childId, dimensionId);
        if (balance.getBalance() < amount) {
            return -1;
        }
    
        balance.setBalance(balance.getBalance() - amount);
        balance.setTotalSpent(balance.getTotalSpent() + amount);
        balance.setUpdatedAt(new Date());
        balanceMapper.updateById(balance);
    
        EnergyLog log = new EnergyLog();
        log.setChildId(childId);
        log.setDimensionId(dimensionId);
        log.setAmount(-amount);
        log.setBalanceAfter(balance.getBalance());
        log.setDescription(reason);
        log.setCreatedAt(new Date());
        logMapper.insert(log);
    
        return balance.getBalance();
    }
    
    /**
     * 查询概览 — 5维度能量值+健康指数+总能量+总指数
     */
    public Map<String, Object> getOverview(Long childId) {
        List<EnergyDimension> allDimensions = getAllDimensions();
    
        List<Map<String, Object>> dimensionList = new ArrayList<>();
        int totalEnergy = 0;
    
        for (EnergyDimension dim : allDimensions) {
            if (dim.getStatus() != 1) continue;
    
            EnergyBalance balance = balanceMapper.selectOne(
                new LambdaQueryWrapper<EnergyBalance>()
                    .eq(EnergyBalance::getChildId, childId)
                    .eq(EnergyBalance::getDimensionId, dim.getId())
            );
    
            int energy = (balance != null) ? balance.getBalance() : 0;
            totalEnergy += energy;
    
            Map<String, Object> dimData = new LinkedHashMap<>();
            dimData.put("code", dim.getCode());
            dimData.put("name", dim.getName());
            dimData.put("icon", dim.getIcon());
            dimData.put("element", dim.getElement());
            dimData.put("energy", energy);
            dimData.put("healthIndex", 0); // 算法待定,先返回0
            dimensionList.add(dimData);
        }
    
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("dimensions", dimensionList);
        result.put("totalEnergy", totalEnergy);
        result.put("totalHealthIndex", 0); // 算法待定,先返回0
        return result;
    }
    
    /**
     * 查询流水 — 按维度筛选,分页
     */
    public Page<EnergyLog> getLogs(Long childId, String dimensionCode, Integer page, Integer size) {
        Page<EnergyLog> pageParam = new Page<>(page, size);
        LambdaQueryWrapper<EnergyLog> wrapper = new LambdaQueryWrapper<EnergyLog>()
            .eq(EnergyLog::getChildId, childId);
    
        if (dimensionCode != null && !dimensionCode.isEmpty()) {
            EnergyDimension dim = getDimensionByCode(dimensionCode);
            if (dim != null) {
                wrapper.eq(EnergyLog::getDimensionId, dim.getId());
            }
        }
    
        wrapper.orderByDesc(EnergyLog::getCreatedAt);
        return logMapper.selectPage(pageParam, wrapper);
    }
    
    /**
     * 配置比例 — 为某服务实例设置维度比例
     */
    @Transactional
    public void configureSourceRatios(String sourceType, Long sourceId, String sourceName,
                                       List<Map<String, Object>> ratios) {
        // 删除旧配置
        sourceConfigMapper.delete(
            new LambdaQueryWrapper<EnergySourceConfig>()
                .eq(EnergySourceConfig::getSourceType, sourceType)
                .eq(EnergySourceConfig::getSourceId, sourceId)
        );
    
        // 插入新配置
        for (Map<String, Object> ratio : ratios) {
            String dimensionCode = (String) ratio.get("dimensionCode");
            BigDecimal ratioValue = new BigDecimal(ratio.get("ratio").toString());
    
            EnergyDimension dim = getDimensionByCode(dimensionCode);
            if (dim == null) continue;
    
            EnergySourceConfig config = new EnergySourceConfig();
            config.setSourceType(sourceType);
            config.setSourceId(sourceId);
            config.setSourceName(sourceName);
            config.setDimensionId(dim.getId());
            config.setRatio(ratioValue);
            config.setCreatedAt(new Date());
            sourceConfigMapper.insert(config);
        }
    }
    
    // ==================== 内部辅助方法 ====================
    
    private EnergyBalance getOrCreateBalance(Long childId, Long dimensionId) {
        EnergyBalance balance = balanceMapper.selectOne(
            new LambdaQueryWrapper<EnergyBalance>()
                .eq(EnergyBalance::getChildId, childId)
                .eq(EnergyBalance::getDimensionId, dimensionId)
        );
        if (balance == null) {
            balance = new EnergyBalance();
            balance.setChildId(childId);
            balance.setDimensionId(dimensionId);
            balance.setBalance(0);
            balance.setTotalEarned(0);
            balance.setTotalSpent(0);
            balance.setUpdatedAt(new Date());
            balanceMapper.insert(balance);
        }
        return balance;
    }
    
    private Map<Long, BigDecimal> resolveFromProductDomain(Long productId) {
        Map<Long, BigDecimal> result = new LinkedHashMap<>();
        try {
            Product product = productMapper.selectById(productId);
            if (product != null && product.getDomain() != null && !product.getDomain().isEmpty()) {
                EnergyDimension dim = getDimensionByCode(product.getDomain());
                if (dim != null) {
                    result.put(dim.getId(), BigDecimal.ONE);
                }
            }
        } catch (Exception e) {
            // Product可能不存在,忽略
        }
        return result;
    }
    
    /**
     * 按比例计算各维度分配量(整数分配,余数加到最大比例维度)
     */
    private Map<Long, Integer> calculateAllocations(Integer totalAmount, Map<Long, BigDecimal> dimensionRatios) {
        Map<Long, Integer> allocations = new LinkedHashMap<>();
        int allocated = 0;
        Long maxRatioDimId = null;
        BigDecimal maxRatio = BigDecimal.ZERO;
    
        for (Map.Entry<Long, BigDecimal> entry : dimensionRatios.entrySet()) {
            int amount = entry.getValue().multiply(new BigDecimal(totalAmount))
                .setScale(0, BigDecimal.ROUND_DOWN).intValue();
            allocations.put(entry.getKey(), amount);
            allocated += amount;
    
            if (entry.getValue().compareTo(maxRatio) > 0) {
                maxRatio = entry.getValue();
                maxRatioDimId = entry.getKey();
            }
        }
    
        // 余数加到最大比例维度
        int remainder = totalAmount - allocated;
        if (remainder > 0 && maxRatioDimId != null) {
            allocations.put(maxRatioDimId, allocations.get(maxRatioDimId) + remainder);
        }
    
        return allocations;
    }
    
    private List<EnergyDimension> getAllDimensions() {
        if (dimensionCache == null) {
            dimensionCache = dimensionMapper.selectList(
                new LambdaQueryWrapper<EnergyDimension>().orderByAsc(EnergyDimension::getSortOrder)
            );
            codeToDimension = new HashMap<>();
            for (EnergyDimension dim : dimensionCache) {
                codeToDimension.put(dim.getCode(), dim);
            }
        }
        return dimensionCache;
    }
    
    private EnergyDimension getDimensionByCode(String code) {
        getAllDimensions(); // 确保缓存加载
        return codeToDimension.get(code);
    }
    
    private EnergyDimension getDimensionById(Long id) {
        getAllDimensions();
        for (EnergyDimension dim : dimensionCache) {
            if (dim.getId().equals(id)) {
                return dim;
            }
        }
        return null;
    }
    }
    
  • [ ] Step 2: 验证编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 3: Commit

    git add zxyj-backend/src/main/java/com/zxyj/service/EnergyService.java
    git commit -m "feat(energy): add EnergyService with award/deduct/overview/logs/configure"
    

Task 5: 后端API入口 — EnergyController

Files:

  • Create: zxyj-backend/src/main/java/com/zxyj/controller/energy/EnergyController.java

遵循现有controller模式:@RestController @RequestMapping,统一@PostMapping@Resource注入Service,Result<T>响应。

  • [ ] Step 1: 创建EnergyController.java

    package com.zxyj.controller.energy;
    
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.zxyj.common.Result;
    import com.zxyj.entity.EnergyLog;
    import com.zxyj.service.EnergyService;
    import javax.annotation.Resource;
    import org.springframework.web.bind.annotation.*;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/energy")
    public class EnergyController {
    
    @Resource
    private EnergyService energyService;
    
    @PostMapping("/overview")
    public Result<Map<String, Object>> getOverview(@RequestBody Map<String, Object> params) {
        Long childId = params.get("childId") != null
            ? Long.valueOf(params.get("childId").toString()) : null;
        if (childId == null) {
            return Result.error("childId不能为空");
        }
        Map<String, Object> overview = energyService.getOverview(childId);
        return Result.success(overview);
    }
    
    @PostMapping("/logs")
    public Result<Page<EnergyLog>> getLogs(@RequestBody Map<String, Object> params) {
        Long childId = params.get("childId") != null
            ? Long.valueOf(params.get("childId").toString()) : null;
        String dimensionCode = params.get("dimensionCode") != null
            ? params.get("dimensionCode").toString() : null;
        Integer page = params.get("page") != null
            ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer size = params.get("size") != null
            ? Integer.valueOf(params.get("size").toString()) : 10;
    
        if (childId == null) {
            return Result.error("childId不能为空");
        }
        Page<EnergyLog> logs = energyService.getLogs(childId, dimensionCode, page, size);
        return Result.success(logs);
    }
    
    @PostMapping("/configure")
    public Result<String> configureSourceRatios(@RequestBody Map<String, Object> params,
                                                  @RequestAttribute("userId") Long userId) {
        String sourceType = (String) params.get("sourceType");
        Long sourceId = Long.valueOf(params.get("sourceId").toString());
        String sourceName = (String) params.get("sourceName");
        java.util.List<Map<String, Object>> ratios =
            (java.util.List<Map<String, Object>>) params.get("ratios");
    
        if (sourceType == null || sourceId == null || ratios == null || ratios.isEmpty()) {
            return Result.error("参数不完整");
        }
    
        energyService.configureSourceRatios(sourceType, sourceId, sourceName, ratios);
        return Result.success("配置成功");
    }
    }
    
  • [ ] Step 2: 验证编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 3: Commit

    git add zxyj-backend/src/main/java/com/zxyj/controller/energy/EnergyController.java
    git commit -m "feat(energy): add EnergyController with overview/logs/configure endpoints"
    

Task 6: 集成到TaskService — 完成任务时发放能量

Files:

  • Modify: zxyj-backend/src/main/java/com/zxyj/service/TaskService.java

completeTask()方法的pointsLogMapper.insert(pointsLog)之后、Map<String, Object> result之前,添加一行energyService调用。

  • Step 1: 在TaskService中注入EnergyService

在现有@Resource注入区域添加:

@Resource
private EnergyService energyService;
  • Step 2: 在completeTask()末尾添加energy发放调用

pointsLogMapper.insert(pointsLog); 之后,Map<String, Object> result = new HashMap<>(); 之前,添加:

// 五维能量发放(与积分并行,独立系统)
try {
    int energyAmount = Math.abs(pointsEarned); // 能量值为正数
    if (energyAmount > 0) {
        energyService.awardEnergy(childId, "task", taskId, energyAmount,
            "完成任务: " + task.getTitle());
    }
} catch (Exception e) {
    log.warn("能量发放失败(不影响积分): childId={}, taskId={}, error={}",
        childId, taskId, e.getMessage());
}

注意:用try-catch包裹,能量发放失败不影响积分逻辑(解耦原则)。

  • Step 3: 验证编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • [ ] Step 4: Commit

    git add zxyj-backend/src/main/java/com/zxyj/service/TaskService.java
    git commit -m "feat(energy): integrate energy award into TaskService.completeTask()"
    

Task 7: 前端API封装 — api.js添加能量接口

Files:

  • Modify: zxyj-frontend/utils/api.js

在文件末尾添加能量系统两个API调用。

  • [ ] Step 1: 在api.js末尾添加能量API

    // 能量模块
    export const getEnergyOverview = (childId) => {
    return request('/api/energy/overview', 'POST', { childId })
    }
    
    export const getEnergyLogs = (childId, dimensionCode, page = 1, size = 10) => {
    return request('/api/energy/logs', 'POST', { childId, dimensionCode, page, size })
    }
    
  • [ ] Step 2: Commit

    git add zxyj-frontend/utils/api.js
    git commit -m "feat(energy): add energy API calls to frontend api.js"
    

Task 8: 前端wuxing-sandbox组件重写

Files:

  • Modify: zxyj-frontend/components/wuxing-sandbox.vue

完全重写组件,接收真实数据props,实现三层视觉结构(外层维度角、中层健康指数扇区、内层中心圆)。

  • [ ] Step 1: 重写wuxing-sandbox.vue

    <template>
    <view class="wuxing-sandbox">
    <view class="sandbox-header">
      <text class="sandbox-title">五行能量沙盘</text>
      <text class="sandbox-badge">{{ badgeText }}</text>
    </view>
    <view class="sandbox-star">
      <!-- 顶: 心·火 -->
      <view class="star-point star-point-top" @click="onPointClick(0)">
        <text class="point-icon">{{ getDimIcon(0) }}</text>
        <text class="point-label">{{ getDimLabel(0) }}</text>
        <text class="point-energy" v-if="mode !== 'preview'">{{ getDimEnergy(0) }}</text>
        <text class="point-status" v-if="mode === 'preview'">点此登录</text>
      </view>
      <!-- 左上: 行·木 -->
      <view class="star-point star-point-left" @click="onPointClick(1)">
        <text class="point-icon">{{ getDimIcon(1) }}</text>
        <text class="point-label">{{ getDimLabel(1) }}</text>
        <text class="point-energy" v-if="mode !== 'preview'">{{ getDimEnergy(1) }}</text>
        <text class="point-status" v-if="mode === 'preview'">点此登录</text>
      </view>
      <!-- 右上: 富·水 -->
      <view class="star-point star-point-right" @click="onPointClick(2)">
        <text class="point-icon">{{ getDimIcon(2) }}</text>
        <text class="point-label">{{ getDimLabel(2) }}</text>
        <text class="point-energy" v-if="mode !== 'preview'">{{ getDimEnergy(2) }}</text>
        <text class="point-status" v-if="mode === 'preview'">点此登录</text>
      </view>
      <!-- 左下: 智·金 -->
      <view class="star-point star-point-bl" @click="onPointClick(3)">
        <text class="point-icon">{{ getDimIcon(3) }}</text>
        <text class="point-label">{{ getDimLabel(3) }}</text>
        <text class="point-energy" v-if="mode !== 'preview'">{{ getDimEnergy(3) }}</text>
        <text class="point-status" v-if="mode === 'preview'">点此登录</text>
      </view>
      <!-- 右下: 身·土 -->
      <view class="star-point star-point-br" @click="onPointClick(4)">
        <text class="point-icon">{{ getDimIcon(4) }}</text>
        <text class="point-label">{{ getDimLabel(4) }}</text>
        <text class="point-energy" v-if="mode !== 'preview'">{{ getDimEnergy(4) }}</text>
        <text class="point-status" v-if="mode === 'preview'">点此登录</text>
      </view>
    
      <!-- 中层: 健康指数扇区(角底边→中心的三角形区域外围) -->
      <view class="health-sector health-top" v-if="mode !== 'preview'">
        <text class="health-index">{{ getHealthIndex(0) }}</text>
      </view>
      <view class="health-sector health-left" v-if="mode !== 'preview'">
        <text class="health-index">{{ getHealthIndex(1) }}</text>
      </view>
      <view class="health-sector health-right" v-if="mode !== 'preview'">
        <text class="health-index">{{ getHealthIndex(2) }}</text>
      </view>
      <view class="health-sector health-bl" v-if="mode !== 'preview'">
        <text class="health-index">{{ getHealthIndex(3) }}</text>
      </view>
      <view class="health-sector health-br" v-if="mode !== 'preview'">
        <text class="health-index">{{ getHealthIndex(4) }}</text>
      </view>
    
      <!-- 内层: 中心圆 -->
      <view class="star-center">
        <text class="center-energy" v-if="mode !== 'preview'">{{ totalEnergy }}</text>
        <text class="center-text" v-else>五行平衡</text>
        <text class="center-index" v-if="mode !== 'preview'">指数 {{ totalHealthIndex }}</text>
        <text class="center-sub" v-else>共同成长</text>
      </view>
    </view>
    <view class="shenke-hint" v-if="mode === 'preview'">
      <text class="hint-text">🌱 完成测评,点亮「身·土」</text>
    </view>
    <!-- 维度能量条 -->
    <view class="dimension-bar" v-if="mode !== 'preview'">
      <view class="dim-item" v-for="(dim, idx) in dimensions" :key="idx"
            :class="{ inactive: !dim || dim.energy === 0 }">
        {{ dim ? dim.icon : '' }}{{ dim ? dim.name : '' }}
      </view>
    </view>
    </view>
    </template>
    
    <script>
    // 默认维度数据(预览模式/无数据时使用)
    var DEFAULT_DIMS = [
    { code: 'mind', name: '心', icon: '🔥', element: '火', energy: 0, healthIndex: 0 },
    { code: 'action', name: '行', icon: '🌿', element: '木', energy: 0, healthIndex: 0 },
    { code: 'wealth', name: '富', icon: '💧', element: '水', energy: 0, healthIndex: 0 },
    { code: 'wisdom', name: '智', icon: '⚔️', element: '金', energy: 0, healthIndex: 0 },
    { code: 'body', name: '身', icon: '🌏', element: '土', energy: 0, healthIndex: 0 }
    ]
    
    export default {
    props: {
    mode: {
      type: String,
      default: 'preview'
    },
    badgeText: {
      type: String,
      default: '综合成长力 0%'
    },
    dimensions: {
      type: Array,
      default: function () { return [] }
    },
    totalEnergy: {
      type: Number,
      default: 0
    },
    totalHealthIndex: {
      type: Number,
      default: 0
    }
    },
    computed: {
    sortedDimensions: function () {
      if (!this.dimensions || this.dimensions.length === 0) {
        return DEFAULT_DIMS
      }
      // 按spec规定的顺序排列: 心/行/富/智/身
      var order = ['mind', 'action', 'wealth', 'wisdom', 'body']
      var dimMap = {}
      var i
      for (i = 0; i < this.dimensions.length; i++) {
        dimMap[this.dimensions[i].code] = this.dimensions[i]
      }
      var result = []
      for (i = 0; i < order.length; i++) {
        if (dimMap[order[i]]) {
          result.push(dimMap[order[i]])
        } else {
          var def = DEFAULT_DIMS[i]
          result.push(def)
        }
      }
      return result
    }
    },
    methods: {
    getDimIcon: function (idx) {
      var dim = this.sortedDimensions[idx]
      return dim ? dim.icon : ''
    },
    getDimLabel: function (idx) {
      var dim = this.sortedDimensions[idx]
      return dim ? (dim.name + '·' + (dim.element || '')) : ''
    },
    getDimEnergy: function (idx) {
      var dim = this.sortedDimensions[idx]
      return dim ? dim.energy : 0
    },
    getHealthIndex: function (idx) {
      var dim = this.sortedDimensions[idx]
      return dim ? dim.healthIndex : 0
    },
    onPointClick: function (idx) {
      var dim = this.sortedDimensions[idx]
      if (dim) {
        this.$emit('point-click', dim.code)
      }
    }
    }
    }
    </script>
    
    <style scoped>
    .wuxing-sandbox {
    margin: 20rpx 30rpx;
    padding: 30rpx;
    background: linear-gradient(135deg, #1a1a2e, #16213e);
    border-radius: 24rpx;
    position: relative;
    overflow: hidden;
    }
    .sandbox-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30rpx;
    }
    .sandbox-title {
    font-size: 28rpx;
    font-weight: bold;
    color: #FFD700;
    }
    .sandbox-badge {
    font-size: 22rpx;
    color: #aaa;
    background: rgba(255,255,255,0.1);
    padding: 6rpx 16rpx;
    border-radius: 20rpx;
    }
    .sandbox-star {
    position: relative;
    height: 440rpx;
    display: flex;
    justify-content: center;
    align-items: center;
    }
    .star-point {
    position: absolute;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 120rpx;
    z-index: 2;
    }
    .star-point-top { top: 0; left: 50%; transform: translateX(-50%); }
    .star-point-left { top: 120rpx; left: 20rpx; }
    .star-point-right { top: 120rpx; right: 20rpx; }
    .star-point-bl { bottom: 40rpx; left: 60rpx; }
    .star-point-br { bottom: 40rpx; right: 60rpx; }
    
    .point-icon { font-size: 50rpx; margin-bottom: 6rpx; }
    .point-label { font-size: 22rpx; color: #fff; font-weight: bold; }
    .point-energy {
    font-size: 28rpx;
    color: #FFD700;
    font-weight: bold;
    margin-top: 4rpx;
    }
    .point-status { font-size: 18rpx; color: #FF6B6B; margin-top: 4rpx; }
    
    /* 健康指数扇区 */
    .health-sector {
    position: absolute;
    z-index: 1;
    }
    .health-index {
    font-size: 20rpx;
    color: rgba(255,215,0,0.6);
    background: rgba(255,215,0,0.08);
    padding: 4rpx 10rpx;
    border-radius: 10rpx;
    }
    .health-top { top: 70rpx; left: 50%; transform: translateX(-50%); }
    .health-left { top: 180rpx; left: 80rpx; }
    .health-right { top: 180rpx; right: 80rpx; }
    .health-bl { bottom: 80rpx; left: 120rpx; }
    .health-br { bottom: 80rpx; right: 120rpx; }
    
    /* 中心圆 */
    .star-center {
    width: 140rpx;
    height: 140rpx;
    background: radial-gradient(circle, rgba(255,215,0,0.2), rgba(255,215,0,0.05));
    border: 2rpx solid rgba(255,215,0,0.3);
    border-radius: 50%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    z-index: 3;
    }
    .center-energy {
    font-size: 32rpx;
    color: #FFD700;
    font-weight: bold;
    }
    .center-text {
    font-size: 22rpx;
    color: #FFD700;
    font-weight: bold;
    }
    .center-index {
    font-size: 18rpx;
    color: #aaa;
    }
    .center-sub {
    font-size: 18rpx;
    color: #aaa;
    }
    
    .shenke-hint { text-align: center; margin-top: 10rpx; }
    .hint-text { font-size: 22rpx; color: rgba(255,215,0,0.7); }
    
    .dimension-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    margin-top: 20rpx;
    gap: 8rpx;
    flex-wrap: wrap;
    }
    .dim-item {
    font-size: 22rpx;
    color: #FFD700;
    background: rgba(255,215,0,0.15);
    padding: 6rpx 14rpx;
    border-radius: 12rpx;
    }
    .dim-item.inactive {
    color: #666;
    background: rgba(255,255,255,0.05);
    }
    </style>
    

关键设计决策:

  • Vue 2语法(不用可选链?.,用条件表达式代替)
  • sortedDimensions computed按spec顺序排列
  • 三层视觉结构:外层角(能量值)、中层扇区(健康指数)、内层圆(总能量+总指数)
  • 向后兼容:保留badgeTextmode props,新增dimensions/totalEnergy/totalHealthIndex
  • 无数据时用DEFAULT_DIMS占位

  • [ ] Step 2: Commit

    git add zxyj-frontend/components/wuxing-sandbox.vue
    git commit -m "feat(energy): rewrite wuxing-sandbox with real data props and 3-layer visual structure"
    

Task 9: 前端首页集成 — parent-index和child-index传入真实数据

Files:

  • Modify: zxyj-frontend/pages/index/parent-index.vue
  • Modify: zxyj-frontend/pages/index/child-index.vue

在两个首页的onShowonLoad生命周期中调用getEnergyOverviewAPI,将结果传给wuxing-sandbox组件。

  • Step 1: 修改parent-index.vue

import区域:添加能量API导入

在现有 import WuxingSandbox from ... 之后添加:

import { getEnergyOverview } from '../../utils/api.js'

data区域:添加能量数据字段

energyDimensions: [],
totalEnergy: 0,
totalHealthIndex: 0,

methods区域:添加加载能量数据方法

loadEnergyOverview: function () {
  var that = this
  var childId = that.currentChildId // 使用页面现有的childId
  if (!childId) return
  getEnergyOverview(childId).then(function (res) {
    if (res.data) {
      that.energyDimensions = res.data.dimensions || []
      that.totalEnergy = res.data.totalEnergy || 0
      that.totalHealthIndex = res.data.totalHealthIndex || 0
    }
  }).catch(function () {
    // 静默失败,不影响页面其他功能
  })
},

onShow生命周期:调用加载

在现有 onShow() 方法末尾添加 this.loadEnergyOverview()

template区域:修改wuxing-sandbox调用

将:

<wuxing-sandbox mode="parent" badgeText="综合成长力 0%" @point-click="goToDomain" />

改为:

<wuxing-sandbox mode="parent"
  :badgeText="'综合成长力 ' + totalHealthIndex + '%'"
  :dimensions="energyDimensions"
  :totalEnergy="totalEnergy"
  :totalHealthIndex="totalHealthIndex"
  @point-click="goToDomain" />
  • Step 2: 修改child-index.vue

同样模式:

import区域添加:

import { getEnergyOverview } from '../../utils/api.js'

data区域添加:

energyDimensions: [],
totalEnergy: 0,
totalHealthIndex: 0,

methods区域添加:

loadEnergyOverview: function () {
  var that = this
  var childId = that.childId // 使用页面现有的childId
  if (!childId) return
  getEnergyOverview(childId).then(function (res) {
    if (res.data) {
      that.energyDimensions = res.data.dimensions || []
      that.totalEnergy = res.data.totalEnergy || 0
      that.totalHealthIndex = res.data.totalHealthIndex || 0
    }
  }).catch(function () {})
},

onShow生命周期末尾添加 this.loadEnergyOverview()

template区域将:

<wuxing-sandbox mode="child" badgeText="综合 0%" @point-click="goToDomain" />

改为:

<wuxing-sandbox mode="child"
  :badgeText="'综合 ' + totalHealthIndex + '%'"
  :dimensions="energyDimensions"
  :totalEnergy="totalEnergy"
  :totalHealthIndex="totalHealthIndex"
  @point-click="goToDomain" />
  • [ ] Step 3: Commit

    git add zxyj-frontend/pages/index/parent-index.vue zxyj-frontend/pages/index/child-index.vue
    git commit -m "feat(energy): integrate energy overview API into parent and child index pages"
    

Task 10: 前端能量详情页 + pages.json注册

Files:

  • Create: zxyj-frontend/pages/energy/detail.vue
  • Modify: zxyj-frontend/pages.json

创建维度详情页:显示维度名称/图标/能量值/健康指数,下方展示流水记录。从wuxing-sandbox点击维度时navigateTo进入。

  • [ ] Step 1: 创建 pages/energy/detail.vue

    <template>
    <view class="energy-detail">
    <!-- 维度头部 -->
    <view class="dim-header">
      <text class="dim-icon">{{ dimension.icon }}</text>
      <view class="dim-info">
        <text class="dim-name">{{ dimension.name }}·{{ dimension.element }}</text>
        <text class="dim-energy">能量值: {{ dimension.energy }}</text>
        <text class="dim-health">健康指数: {{ dimension.healthIndex }}</text>
      </view>
    </view>
    
    <!-- 流水记录 -->
    <view class="log-section">
      <view class="log-tabs">
        <text class="log-tab" :class="{ active: !filterType }" @click="filterType = ''">全部</text>
        <text class="log-tab" :class="{ active: filterType === 'earn' }" @click="filterType = 'earn'">获得</text>
        <text class="log-tab" :class="{ active: filterType === 'spend' }" @click="filterType = 'spend'">消耗</text>
      </view>
      <view class="log-list">
        <view class="log-item" v-for="(item, idx) in filteredLogs" :key="idx">
          <view class="log-left">
            <text class="log-desc">{{ item.description }}</text>
            <text class="log-time">{{ formatTime(item.createdAt) }}</text>
          </view>
          <text class="log-amount" :class="{ positive: item.amount > 0, negative: item.amount < 0 }">
            {{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
          </text>
        </view>
        <view class="log-empty" v-if="filteredLogs.length === 0">
          <text class="empty-text">暂无能量记录</text>
        </view>
      </view>
      <view class="load-more" v-if="hasMore" @click="loadMore">
        <text class="more-text">加载更多</text>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { getEnergyLogs } from '../../utils/api.js'
    
    export default {
    data: function () {
    return {
      childId: null,
      dimensionCode: '',
      dimension: {
        code: '', name: '', icon: '', element: '',
        energy: 0, healthIndex: 0
      },
      logs: [],
      page: 1,
      size: 20,
      hasMore: true,
      filterType: ''
    }
    },
    computed: {
    filteredLogs: function () {
      if (!this.filterType) return this.logs
      if (this.filterType === 'earn') {
        return this.logs.filter(function (l) { return l.amount > 0 })
      }
      return this.logs.filter(function (l) { return l.amount < 0 })
    }
    },
    onLoad: function (options) {
    this.childId = options.childId ? parseInt(options.childId) : null
    this.dimensionCode = options.code || ''
    if (options.dimData) {
      try {
        this.dimension = JSON.parse(decodeURIComponent(options.dimData))
      } catch (e) {
        // 解析失败用默认值
      }
    }
    this.loadLogs()
    },
    methods: {
    loadLogs: function () {
      var that = this
      if (!that.childId) return
      getEnergyLogs(that.childId, that.dimensionCode, that.page, that.size).then(function (res) {
        if (res.data && res.data.records) {
          if (that.page === 1) {
            that.logs = res.data.records
          } else {
            that.logs = that.logs.concat(res.data.records)
          }
          that.hasMore = res.data.records.length >= that.size
        }
      }).catch(function () {})
    },
    loadMore: function () {
      this.page++
      this.loadLogs()
    },
    formatTime: function (time) {
      if (!time) return ''
      var d = new Date(time)
      var m = d.getMonth() + 1
      var day = d.getDate()
      var h = d.getHours()
      var min = d.getMinutes()
      return m + '/' + day + ' ' + (h < 10 ? '0' + h : h) + ':' + (min < 10 ? '0' + min : min)
    }
    }
    }
    </script>
    
    <style scoped>
    .energy-detail { min-height: 100vh; background: #f5f5f5; }
    .dim-header {
    display: flex;
    align-items: center;
    padding: 40rpx 30rpx;
    background: linear-gradient(135deg, #1a1a2e, #16213e);
    }
    .dim-icon { font-size: 80rpx; margin-right: 30rpx; }
    .dim-info { flex: 1; }
    .dim-name { font-size: 36rpx; color: #FFD700; font-weight: bold; display: block; }
    .dim-energy { font-size: 28rpx; color: #fff; display: block; margin-top: 10rpx; }
    .dim-health { font-size: 24rpx; color: #aaa; display: block; margin-top: 6rpx; }
    
    .log-section { margin: 20rpx; }
    .log-tabs {
    display: flex;
    gap: 20rpx;
    margin-bottom: 20rpx;
    }
    .log-tab {
    font-size: 26rpx;
    color: #666;
    padding: 10rpx 30rpx;
    border-radius: 30rpx;
    background: #fff;
    }
    .log-tab.active { color: #FFD700; background: rgba(255,215,0,0.1); }
    
    .log-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 24rpx 20rpx;
    background: #fff;
    border-radius: 16rpx;
    margin-bottom: 12rpx;
    }
    .log-left { flex: 1; }
    .log-desc { font-size: 28rpx; color: #333; display: block; }
    .log-time { font-size: 22rpx; color: #999; display: block; margin-top: 6rpx; }
    .log-amount { font-size: 32rpx; font-weight: bold; }
    .log-amount.positive { color: #4CAF50; }
    .log-amount.negative { color: #FF6B6B; }
    
    .log-empty { text-align: center; padding: 60rpx 0; }
    .empty-text { font-size: 28rpx; color: #999; }
    
    .load-more { text-align: center; padding: 20rpx; }
    .more-text { font-size: 26rpx; color: #666; }
    </style>
    
  • [ ] Step 2: 在pages.json中注册新页面

在pages数组的 pages/vendor/orders/orders 条目之后添加:

{
  "path": "pages/energy/detail",
  "style": {
    "navigationBarTitleText": "维度详情"
  }
}
  • Step 3: 在parent-index.vue和child-index.vue的goToDomain方法中添加导航

在两个页面的 goToDomain 方法中,添加导航到能量详情页:

goToDomain: function (code) {
  var dimData = {}
  for (var i = 0; i < this.energyDimensions.length; i++) {
    if (this.energyDimensions[i].code === code) {
      dimData = this.energyDimensions[i]
      break
    }
  }
  uni.navigateTo({
    url: '/pages/energy/detail?code=' + code + '&childId=' + (this.currentChildId || this.childId) + '&dimData=' + encodeURIComponent(JSON.stringify(dimData))
  })
}

注意:parent-index使用this.currentChildId,child-index使用this.childId,请根据实际页面变量名调整。

  • [ ] Step 4: Commit

    git add zxyj-frontend/pages/energy/detail.vue zxyj-frontend/pages.json zxyj-frontend/pages/index/parent-index.vue zxyj-frontend/pages/index/child-index.vue
    git commit -m "feat(energy): add energy detail page + register in pages.json + add navigation"
    

Task 11: discover页预览模式更新

Files:

  • Modify: zxyj-frontend/pages/discover/index.vue

discover页保持preview模式,不需要调用API,但需要传入空dimensions数组保证组件正常渲染。

  • Step 1: 更新discover/index.vue中的wuxing-sandbox调用

将:

<wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />

改为:

<wuxing-sandbox mode="preview"
  badgeText="登录查看完整报告"
  :dimensions="[]"
  :totalEnergy="0"
  :totalHealthIndex="0"
  @point-click="handleLogin" />

空数组会触发组件内部的DEFAULT_DIMS fallback,显示图标和名称但能量值不显示。

  • [ ] Step 2: Commit

    git add zxyj-frontend/pages/discover/index.vue
    git commit -m "feat(energy): update discover page with empty energy props for preview mode"
    

Task 12: 后端编译验证 + 前端基础检查

Files: 无修改,纯验证

  • Step 1: 后端完整编译

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS

  • Step 2: 检查LSP诊断

Run: lsp_diagnostics on:

  • zxyj-backend/src/main/java/com/zxyj/service/EnergyService.java
  • zxyj-backend/src/main/java/com/zxyj/controller/energy/EnergyController.java
  • zxyj-backend/src/main/java/com/zxyj/service/TaskService.java

Expected: 无新增error

  • Step 3: 如果有诊断问题,修复后再次验证

Self-Review Checklist

1. Spec Coverage:

  • ✅ 4张表DDL (energy_dimension, energy_source_config, energy_log, energy_balance) → Task 1
  • ✅ 维度种子数据(5条) → Task 1
  • ✅ Product.domain→energy_source_config迁移 → Task 1
  • ✅ 4个Entity → Task 2
  • ✅ 4个Mapper → Task 3
  • ✅ EnergyService 5个方法 → Task 4
  • ✅ awardEnergy Fallback链(sourceConfig→Product.domain→行100%) → Task 4
  • ✅ EnergyController 3个API → Task 5
  • ✅ TaskService.completeTask()集成 → Task 6
  • ✅ 前端api.js → Task 7
  • ✅ wuxing-sandbox重写(三层视觉) → Task 8
  • ✅ parent-index/child-index数据集成 → Task 9
  • ✅ 能量详情页 → Task 10
  • ✅ discover预览模式 → Task 11
  • ✅ 编译验证 → Task 12

2. Placeholder Scan:

  • 无TBD/TODO/占位符
  • 健康指数返回0是spec明确要求,不是占位符

3. Type Consistency:

  • Entity字段名与DDL列名一致(MyBatis-Plus驼峰映射)
  • EnergyService方法签名与Controller调用一致
  • 前端props名称(dimensions/totalEnergy/totalHealthIndex)与组件定义和调用一致
  • API路径 /api/energy/overview/api/energy/logs 前后端一致

4. Gaps Found:

  • EnergyLog实体缺少dimensionCode字段便于前端展示 → 在getLogs API响应中通过dimensionId关联。前端detail页已知dimensionCode(从URL参数传入),无需在log中冗余。此为合理设计,不需要改动。