فهرست منبع

feat(admin): 五行能量配置管理

- AdminEnergyConfigController: 来源配置CRUD+每日限制
- WuxingConfig.vue: 能量来源比例配置+每日限制概览
- 路由注册 /admin/energy-config
Xiaogang Liao 2 ماه پیش
والد
کامیت
c4dc8414c3

+ 236 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminEnergyConfigController.java

@@ -0,0 +1,236 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.EnergySourceConfig;
+import com.etotem.cfc.mapper.EnergySourceConfigMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Tag(name = "管理端 - 五行能量配置")
+@RestController
+@RequestMapping("/api/admin/energy-config")
+public class AdminEnergyConfigController {
+
+    @Resource
+    private EnergySourceConfigMapper energySourceConfigMapper;
+
+    /**
+     * 能量来源配置列表
+     */
+    @Operation(summary = "能量来源配置列表")
+    @PostMapping("/source-list")
+    public Result<List<Map<String, Object>>> sourceList(@RequestBody Map<String, Object> params) {
+        List<EnergySourceConfig> configs = energySourceConfigMapper.selectList(
+            new LambdaQueryWrapper<EnergySourceConfig>()
+                .orderByAsc(EnergySourceConfig::getSourceType)
+                .orderByAsc(EnergySourceConfig::getSourceId)
+        );
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (EnergySourceConfig config : configs) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", config.getId());
+            item.put("sourceType", config.getSourceType());
+            item.put("sourceId", config.getSourceId());
+            item.put("sourceName", config.getSourceName());
+            item.put("dimensionId", config.getDimensionId());
+            item.put("ratio", config.getRatio());
+            item.put("createdAt", config.getCreatedAt());
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 保存能量来源配置(新增/更新)
+     * 注意:EnergySourceConfig 表中每条记录对应一个来源与一个维度的映射关系
+     * 前端传入的 ratios 数组会被拆分为多条记录
+     */
+    @Operation(summary = "保存能量来源配置")
+    @PostMapping("/source-save")
+    public Result<String> sourceSave(@RequestBody EnergySourceConfigSaveRequest request) {
+        // 删除该来源的所有旧配置
+        if (request.getSourceId() != null && request.getSourceId() > 0) {
+            energySourceConfigMapper.delete(
+                new LambdaQueryWrapper<EnergySourceConfig>()
+                    .eq(EnergySourceConfig::getSourceType, request.getSourceType())
+                    .eq(EnergySourceConfig::getSourceId, request.getSourceId())
+            );
+        }
+
+        // 插入新的维度比例配置
+        Date now = new Date();
+        for (DimensionRatio ratio : request.getRatios()) {
+            EnergySourceConfig config = new EnergySourceConfig();
+            config.setSourceType(request.getSourceType());
+            config.setSourceId(request.getSourceId());
+            config.setSourceName(request.getSourceName());
+            config.setDimensionId(ratio.getDimensionId());
+            config.setRatio(ratio.getRatio());
+            config.setCreatedAt(now);
+            energySourceConfigMapper.insert(config);
+        }
+
+        return Result.success("保存成功");
+    }
+
+    /**
+     * 删除能量来源配置
+     */
+    @Operation(summary = "删除能量来源配置")
+    @PostMapping("/source-delete")
+    public Result<String> sourceDelete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        energySourceConfigMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    /**
+     * 获取每日能量限制(V1 返回硬编码值)
+     */
+    @Operation(summary = "获取每日能量限制")
+    @PostMapping("/daily-limits")
+    public Result<List<DimensionDailyLimit>> dailyLimits(@RequestBody Map<String, Object> params) {
+        List<DimensionDailyLimit> limits = new ArrayList<>();
+        limits.add(new DimensionDailyLimit("body", 50));
+        limits.add(new DimensionDailyLimit("mind", 50));
+        limits.add(new DimensionDailyLimit("wisdom", 80));
+        limits.add(new DimensionDailyLimit("action", 60));
+        limits.add(new DimensionDailyLimit("wealth", 50));
+        return Result.success(limits);
+    }
+
+    /**
+     * 保存每日能量限制(V1 仅返回成功,实际存储待后续迁移)
+     */
+    @Operation(summary = "保存每日能量限制")
+    @PostMapping("/daily-limit-save")
+    public Result<String> dailyLimitSave(@RequestBody DailyLimitSaveRequest request) {
+        // V1: 仅返回成功,实际存储待后续迁移到数据库
+        return Result.success("保存成功");
+    }
+
+    /**
+     * 能量配置总览:所有来源类型及其维度比例
+     */
+    @Operation(summary = "能量配置总览")
+    @PostMapping("/overview")
+    public Result<Map<String, Object>> overview(@RequestBody Map<String, Object> params) {
+        // 获取所有来源类型分组
+        List<EnergySourceConfig> allConfigs = energySourceConfigMapper.selectList(
+            new LambdaQueryWrapper<EnergySourceConfig>()
+                .orderByAsc(EnergySourceConfig::getSourceType)
+                .orderByAsc(EnergySourceConfig::getSourceId)
+        );
+
+        // 按 sourceType + sourceId 分组
+        Map<String, List<EnergySourceConfig>> grouped = new LinkedHashMap<>();
+        for (EnergySourceConfig config : allConfigs) {
+            String key = config.getSourceType() + ":" + config.getSourceId();
+            grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(config);
+        }
+
+        List<Map<String, Object>> sources = new ArrayList<>();
+        for (Map.Entry<String, List<EnergySourceConfig>> entry : grouped.entrySet()) {
+            List<EnergySourceConfig> list = entry.getValue();
+            Map<String, Object> source = new HashMap<>();
+            source.put("sourceType", list.get(0).getSourceType());
+            source.put("sourceId", list.get(0).getSourceId());
+            source.put("sourceName", list.get(0).getSourceName());
+
+            // 维度比例数组
+            List<Map<String, Object>> ratios = new ArrayList<>();
+            for (EnergySourceConfig config : list) {
+                Map<String, Object> ratio = new HashMap<>();
+                ratio.put("dimensionId", config.getDimensionId());
+                ratio.put("ratio", config.getRatio());
+                ratios.add(ratio);
+            }
+            source.put("ratios", ratios);
+            sources.add(source);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("sources", sources);
+
+        // 每日限制
+        List<Map<String, Object>> dailyLimits = new ArrayList<>();
+        dailyLimits.add(limitMap("body", 50));
+        dailyLimits.add(limitMap("mind", 50));
+        dailyLimits.add(limitMap("wisdom", 80));
+        dailyLimits.add(limitMap("action", 60));
+        dailyLimits.add(limitMap("wealth", 50));
+        data.put("dailyLimits", dailyLimits);
+
+        return Result.success(data);
+    }
+
+    private Map<String, Object> limitMap(String code, int value) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("dimensionCode", code);
+        m.put("dailyLimit", value);
+        return m;
+    }
+
+    // ==================== DTOs ====================
+
+    public static class EnergySourceConfigSaveRequest {
+        private Long id;
+        private String sourceType;
+        private Long sourceId;
+        private String sourceName;
+        private List<DimensionRatio> ratios;
+
+        public Long getId() { return id; }
+        public void setId(Long id) { this.id = id; }
+        public String getSourceType() { return sourceType; }
+        public void setSourceType(String sourceType) { this.sourceType = sourceType; }
+        public Long getSourceId() { return sourceId; }
+        public void setSourceId(Long sourceId) { this.sourceId = sourceId; }
+        public String getSourceName() { return sourceName; }
+        public void setSourceName(String sourceName) { this.sourceName = sourceName; }
+        public List<DimensionRatio> getRatios() { return ratios; }
+        public void setRatios(List<DimensionRatio> ratios) { this.ratios = ratios; }
+    }
+
+    public static class DimensionRatio {
+        private Long dimensionId;
+        private Integer ratio;
+
+        public Long getDimensionId() { return dimensionId; }
+        public void setDimensionId(Long dimensionId) { this.dimensionId = dimensionId; }
+        public Integer getRatio() { return ratio; }
+        public void setRatio(Integer ratio) { this.ratio = ratio; }
+    }
+
+    public static class DimensionDailyLimit {
+        private String dimensionCode;
+        private Integer dailyLimit;
+
+        public DimensionDailyLimit() {}
+        public DimensionDailyLimit(String dimensionCode, Integer dailyLimit) {
+            this.dimensionCode = dimensionCode;
+            this.dailyLimit = dailyLimit;
+        }
+
+        public String getDimensionCode() { return dimensionCode; }
+        public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
+        public Integer getDailyLimit() { return dailyLimit; }
+        public void setDailyLimit(Integer dailyLimit) { this.dailyLimit = dailyLimit; }
+    }
+
+    public static class DailyLimitSaveRequest {
+        private List<DimensionDailyLimit> limits;
+
+        public List<DimensionDailyLimit> getLimits() { return limits; }
+        public void setLimits(List<DimensionDailyLimit> limits) { this.limits = limits; }
+    }
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -557,6 +557,12 @@ const routes = [
         component: () => import('@/views/admin/HealthEnergyConfig'),
         meta: { title: '七维能量配置', perm: 'system:config' }
       },
+      {
+        path: 'energy-config',
+        name: 'EnergyConfig',
+        component: () => import('@/views/admin/energy-config/WuxingConfig'),
+        meta: { title: '五行能量配置', perm: 'system:config' }
+      },
       {
         path: 'indicators',
         name: 'IndicatorManage',

+ 291 - 0
cfc-web/src/views/admin/energy-config/WuxingConfig.vue

@@ -0,0 +1,291 @@
+<template>
+  <div class="wuxing-energy-config admin-page">
+    <el-card>
+      <div slot="header">
+        <span>五行能量配置</span>
+      </div>
+
+      <!-- Section 1: Energy Source Config -->
+      <div style="margin-bottom: 30px;">
+        <h3 style="margin-bottom: 15px; color: #606266;">能量来源配置</h3>
+        <el-input v-model="sourceKeyword" placeholder="搜索来源类型/名称..." prefix-icon="el-icon-search" style="width: 250px; float: right; margin-bottom: 15px;" clearable @keyup.enter.native="loadSourceConfigs" @clear="loadSourceConfigs" />
+        <div style="clear: both;"></div>
+
+        <el-table :data="filteredSourceConfigs" stripe v-loading="sourceLoading" style="margin-top: 10px;">
+          <el-table-column prop="sourceType" label="来源类型" width="120" show-overflow-tooltip></el-table-column>
+          <el-table-column prop="sourceName" label="来源名称" width="150" show-overflow-tooltip></el-table-column>
+          <el-table-column label="维度比例" min-width="300">
+            <template slot-scope="scope">
+              <div v-for="ratio in getDimensionRatios(scope.row)" :key="ratio.dimensionId" style="display: inline-block; margin-right: 10px; margin-bottom: 5px;">
+                <el-tag size="mini" effect="plain">{{ getDimensionLabel(ratio.dimensionId) }}: {{ ratio.ratio / 10000 }}%</el-tag>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="150" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEditSource(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDeleteSource(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div style="margin-top: 15px;">
+          <el-button type="primary" @click="handleCreateSource">+ 新增来源配置</el-button>
+        </div>
+      </div>
+
+      <!-- Section 2: Daily Limits Overview (read-only for V1) -->
+      <div>
+        <h3 style="margin-bottom: 15px; color: #606266;">每日能量限制(只读)</h3>
+        <el-table :data="dailyLimits" stripe border>
+          <el-table-column prop="dimensionCode" label="维度" width="120">
+            <template slot-scope="scope">
+              <el-tag :type="getDimensionType(scope.row.dimensionCode)">{{ getDimensionLabel(scope.row.dimensionCode) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="dimensionName" label="维度名称" width="150"></el-table-column>
+          <el-table-column prop="dailyLimit" label="每日限制" width="120">
+            <template slot-scope="scope">
+              <el-progress :percentage="scope.row.dailyLimit" :format="formatLimit"></el-progress>
+            </template>
+          </el-table-column>
+          <el-table-column label="说明" min-width="200">
+            <template slot-scope="scope">
+              {{ getLimitDescription(scope.row.dimensionCode) }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- Source Config Dialog -->
+    <el-dialog :visible.sync="sourceDialogVisible" :title="sourceIsEdit ? '编辑来源配置' : '新增来源配置'" width="700px">
+      <el-form :model="sourceForm" label-width="120px">
+        <el-form-item label="来源类型" required>
+          <el-select v-model="sourceForm.sourceType" placeholder="选择来源类型" style="width: 100%">
+            <el-option label="任务 (task)" value="task"></el-option>
+            <el-option label="活动 (activity)" value="activity"></el-option>
+            <el-option label="打卡 (streak)" value="streak"></el-option>
+            <el-option label="商品 (product)" value="product"></el-option>
+            <el-option label="新手引导 (onboarding)" value="onboarding"></el-option>
+            <el-option label="邀请里程碑 (invite_milestone)" value="invite_milestone"></el-option>
+            <el-option label="健康打卡 (health_checkin)" value="health_checkin"></el-option>
+            <el-option label="成长日 (growth_day)" value="growth_day"></el-option>
+            <el-option label="游戏 (game)" value="game"></el-option>
+            <el-option label="签到 (checkin)" value="checkin"></el-option>
+            <el-option label="情绪打卡 (emotion_checkin)" value="emotion_checkin"></el-option>
+            <el-option label="维度任务 (dimension)" value="dimension"></el-option>
+            <el-option label="预约 (appointment)" value="appointment"></el-option>
+            <el-option label="文章 (article)" value="article"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="来源名称" required>
+          <el-input v-model="sourceForm.sourceName" placeholder="如:任务通用、周末活动" style="width: 100%"></el-input>
+        </el-form-item>
+        <el-form-item label="维度比例">
+          <div v-for="(ratio, index) in sourceForm.ratios" :key="index" style="margin-bottom: 10px;">
+            <el-row :gutter="10">
+              <el-col :span="10">
+                <el-select v-model="ratio.dimensionId" placeholder="选择维度" style="width: 100%">
+                  <el-option label="身 (body)" :value="1"></el-option>
+                  <el-option label="心 (mind)" :value="2"></el-option>
+                  <el-option label="智 (wisdom)" :value="3"></el-option>
+                  <el-option label="行 (action)" :value="4"></el-option>
+                  <el-option label="富 (wealth)" :value="5"></el-option>
+                </el-select>
+              </el-col>
+              <el-col :span="10">
+                <el-input-number v-model="ratio.ratio" :min="0" :max="10000" :step="100" style="width: 100%" />
+                <span style="margin-left: 10px;">{{ ratio.ratio / 100 }}%</span>
+              </el-col>
+              <el-col :span="4">
+                <el-button size="mini" type="danger" @click="removeRatio(index)" v-if="sourceForm.ratios.length > 1">删除</el-button>
+              </el-col>
+            </el-row>
+          </div>
+          <el-button size="mini" type="primary" @click="addRatio">+ 添加维度</el-button>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="sourceDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitSourceForm" :loading="sourceSubmitting">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'WuxingEnergyConfig',
+  data() {
+    return {
+      sourceLoading: false,
+      sourceSubmitting: false,
+      sourceDialogVisible: false,
+      sourceIsEdit: false,
+      sourceKeyword: '',
+      sourceConfigs: [],
+      sourceForm: this.emptySourceForm(),
+      dailyLimits: [],
+      dailyLimitsLoading: false
+    }
+  },
+  computed: {
+    filteredSourceConfigs() {
+      if (!this.sourceKeyword) return this.sourceConfigs
+      var kw = this.sourceKeyword.toLowerCase()
+      var self = this
+      return self.sourceConfigs.filter(function(item) {
+        return (item.sourceType && item.sourceType.toLowerCase().includes(kw)) ||
+               (item.sourceName && item.sourceName.toLowerCase().includes(kw))
+      })
+    }
+  },
+  mounted() {
+    this.loadSourceConfigs()
+    this.loadDailyLimits()
+  },
+  methods: {
+    emptySourceForm() {
+      return {
+        id: null,
+        sourceType: 'task',
+        sourceId: 0,
+        sourceName: '',
+        ratios: [
+          { dimensionId: 1, ratio: 5000 },
+          { dimensionId: 2, ratio: 5000 }
+        ]
+      }
+    },
+    getDimensionLabel(dimensionId) {
+      var map = { 1: '身', 2: '心', 3: '智', 4: '行', 5: '富' }
+      return map[dimensionId] || dimensionId
+    },
+    getDimensionType(code) {
+      var map = { body: 'warning', mind: 'success', wisdom: 'info', action: 'primary', wealth: 'warning' }
+      return map[code] || ''
+    },
+    getDimensionRatios(row) {
+      if (!row.ratios) return []
+      return row.ratios
+    },
+    getLimitDescription(code) {
+      var map = {
+        body: '身体健康维度每日能量上限',
+        mind: '心理情绪维度每日能量上限',
+        wisdom: '认知智慧维度每日能量上限',
+        action: '行动自律维度每日能量上限',
+        wealth: '财富资源维度每日能量上限'
+      }
+      return map[code] || ''
+    },
+    formatLimit(val) {
+      return val + '%'
+    },
+    loadSourceConfigs() {
+      this.sourceLoading = true
+      this.$axios.post('/api/admin/energy-config/source-list').then(res => {
+        if (res.data.code === 200) {
+          this.sourceConfigs = res.data.data || []
+        }
+      }).catch(function() {}).finally(function() { this.sourceLoading = false }.bind(this))
+    },
+    loadDailyLimits() {
+      this.dailyLimitsLoading = true
+      this.$axios.post('/api/admin/energy-config/daily-limits').then(res => {
+        if (res.data.code === 200) {
+          var limits = res.data.data || []
+          var self = this
+          this.dailyLimits = limits.map(function(item) {
+            var nameMap = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
+            return {
+              dimensionCode: item.dimensionCode,
+              dimensionName: nameMap[item.dimensionCode] || item.dimensionCode,
+              dailyLimit: item.dailyLimit
+            }
+          })
+        }
+      }).catch(function() {}).finally(function() { this.dailyLimitsLoading = false }.bind(this))
+    },
+    handleCreateSource() {
+      this.sourceForm = this.emptySourceForm()
+      this.sourceIsEdit = false
+      this.sourceDialogVisible = true
+    },
+    handleEditSource(row) {
+      this.sourceForm = {
+        id: row.id,
+        sourceType: row.sourceType,
+        sourceId: row.sourceId,
+        sourceName: row.sourceName,
+        ratios: row.ratios ? JSON.parse(JSON.stringify(row.ratios)) : []
+      }
+      if (!this.sourceForm.ratios || this.sourceForm.ratios.length === 0) {
+        this.sourceForm.ratios = [{ dimensionId: 1, ratio: 5000 }]
+      }
+      this.sourceIsEdit = true
+      this.sourceDialogVisible = true
+    },
+    addRatio() {
+      this.sourceForm.ratios.push({ dimensionId: 1, ratio: 5000 })
+    },
+    removeRatio(index) {
+      if (this.sourceForm.ratios.length > 1) {
+        this.sourceForm.ratios.splice(index, 1)
+      }
+    },
+    submitSourceForm() {
+      if (!this.sourceForm.sourceType || !this.sourceForm.sourceName) {
+        this.$message.error('请填写完整信息')
+        return
+      }
+
+      // 验证维度比例总和
+      var totalRatio = 0
+      for (var i = 0; i < this.sourceForm.ratios.length; i++) {
+        totalRatio += this.sourceForm.ratios[i].ratio
+      }
+      if (totalRatio !== 10000) {
+        this.$message.error('维度比例总和必须为 100%(10000)')
+        return
+      }
+
+      this.sourceSubmitting = true
+      this.$axios.post('/api/admin/energy-config/source-save', this.sourceForm).then(res => {
+        if (res.data.code === 200) {
+          this.sourceDialogVisible = false
+          this.loadSourceConfigs()
+          this.$message.success('保存成功')
+        } else {
+          this.$message.error(res.data.message || '保存失败')
+        }
+      }).catch(function() { this.$message.error('保存失败') }.bind(this)).finally(function() { this.sourceSubmitting = false }.bind(this))
+    },
+    handleDeleteSource(row) {
+      var self = this
+      this.$confirm('确认删除此配置?', '提示', { type: 'warning' }).then(function() {
+        self.$axios.post('/api/admin/energy-config/source-delete', { id: row.id }).then(res => {
+          if (res.data.code === 200) {
+            self.loadSourceConfigs()
+            self.$message.success('删除成功')
+          } else {
+            self.$message.error(res.data.message || '删除失败')
+          }
+        })
+      }).catch(function() {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page {
+  padding: 20px;
+}
+.admin-page h3 {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+</style>