|
|
@@ -0,0 +1,1703 @@
|
|
|
+# 五维能量系统实施计划
|
|
|
+
|
|
|
+> **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("数据库迁移完成")` 之前添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// ==================== 五维能量系统 ====================
|
|
|
+// 维度定义表
|
|
|
+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之后添加维度种子数据**
|
|
|
+
|
|
|
+```java
|
|
|
+// 维度种子数据
|
|
|
+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单维度映射:
|
|
|
+
|
|
|
+```java
|
|
|
+// 为已有商品插入默认比例配置(与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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`注入区域添加:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private EnergyService energyService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在completeTask()末尾添加energy发放调用**
|
|
|
+
|
|
|
+在 `pointsLogMapper.insert(pointsLog);` 之后,`Map<String, Object> result = new HashMap<>();` 之前,添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 五维能量发放(与积分并行,独立系统)
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```js
|
|
|
+// 能量模块
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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顺序排列
|
|
|
+- 三层视觉结构:外层角(能量值)、中层扇区(健康指数)、内层圆(总能量+总指数)
|
|
|
+- 向后兼容:保留`badgeText`和`mode` props,新增`dimensions`/`totalEnergy`/`totalHealthIndex`
|
|
|
+- 无数据时用DEFAULT_DIMS占位
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`
|
|
|
+
|
|
|
+在两个首页的`onShow`或`onLoad`生命周期中调用`getEnergyOverview`API,将结果传给wuxing-sandbox组件。
|
|
|
+
|
|
|
+- [ ] **Step 1: 修改parent-index.vue**
|
|
|
+
|
|
|
+**import区域**:添加能量API导入
|
|
|
+
|
|
|
+在现有 `import WuxingSandbox from ...` 之后添加:
|
|
|
+```js
|
|
|
+import { getEnergyOverview } from '../../utils/api.js'
|
|
|
+```
|
|
|
+
|
|
|
+**data区域**:添加能量数据字段
|
|
|
+
|
|
|
+```js
|
|
|
+energyDimensions: [],
|
|
|
+totalEnergy: 0,
|
|
|
+totalHealthIndex: 0,
|
|
|
+```
|
|
|
+
|
|
|
+**methods区域**:添加加载能量数据方法
|
|
|
+
|
|
|
+```js
|
|
|
+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调用
|
|
|
+
|
|
|
+将:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="parent" badgeText="综合成长力 0%" @point-click="goToDomain" />
|
|
|
+```
|
|
|
+改为:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="parent"
|
|
|
+ :badgeText="'综合成长力 ' + totalHealthIndex + '%'"
|
|
|
+ :dimensions="energyDimensions"
|
|
|
+ :totalEnergy="totalEnergy"
|
|
|
+ :totalHealthIndex="totalHealthIndex"
|
|
|
+ @point-click="goToDomain" />
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改child-index.vue**
|
|
|
+
|
|
|
+同样模式:
|
|
|
+
|
|
|
+**import区域**添加:
|
|
|
+```js
|
|
|
+import { getEnergyOverview } from '../../utils/api.js'
|
|
|
+```
|
|
|
+
|
|
|
+**data区域**添加:
|
|
|
+```js
|
|
|
+energyDimensions: [],
|
|
|
+totalEnergy: 0,
|
|
|
+totalHealthIndex: 0,
|
|
|
+```
|
|
|
+
|
|
|
+**methods区域**添加:
|
|
|
+```js
|
|
|
+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区域**将:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="child" badgeText="综合 0%" @point-click="goToDomain" />
|
|
|
+```
|
|
|
+改为:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="child"
|
|
|
+ :badgeText="'综合 ' + totalHealthIndex + '%'"
|
|
|
+ :dimensions="energyDimensions"
|
|
|
+ :totalEnergy="totalEnergy"
|
|
|
+ :totalHealthIndex="totalHealthIndex"
|
|
|
+ @point-click="goToDomain" />
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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` 条目之后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/energy/detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "维度详情"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在parent-index.vue和child-index.vue的goToDomain方法中添加导航**
|
|
|
+
|
|
|
+在两个页面的 `goToDomain` 方法中,添加导航到能量详情页:
|
|
|
+
|
|
|
+```js
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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调用**
|
|
|
+
|
|
|
+将:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />
|
|
|
+```
|
|
|
+改为:
|
|
|
+```html
|
|
|
+<wuxing-sandbox mode="preview"
|
|
|
+ badgeText="登录查看完整报告"
|
|
|
+ :dimensions="[]"
|
|
|
+ :totalEnergy="0"
|
|
|
+ :totalHealthIndex="0"
|
|
|
+ @point-click="handleLogin" />
|
|
|
+```
|
|
|
+
|
|
|
+空数组会触发组件内部的DEFAULT_DIMS fallback,显示图标和名称但能量值不显示。
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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中冗余。此为合理设计,不需要改动。
|