Pārlūkot izejas kodu

feat: 电商体系升级 — 供应商/规格/推荐日志 + 五维能量富维度详情 + 产品推荐

- DatabaseInitializer 迁移81-89: 供应商、规格组/选项、供应商关联、订单SKU表
- 新增 Supplier/ProductSpecGroup/ProductSpecOption/ProductSupplier/OrderItemSku 实体+Mapper+Service+Controller
- 新增 ProductRecommendationLog 实体+Mapper(推荐记录打点)
- EnergyController: /wealth-detail 富维度详情接口
- EnergyService: 身克富杠杆逻辑 + calcParentWealth 传参修复
- AssessmentService + HealthAnalysisService: 推荐系统相关逻辑
- schema.sql: 同步新表DDL
- 小程序: 商品详情页规格选择器; 购物车/结算页展示SKU与供应商; 富维度页重构; 个人主页新增
- 管理端: EcomSupplierManage.vue 供应商CRUD页面 + admin.js API + 路由
- 前端 api.js: 新增供应商/规格/推荐相关接口
Xiaogang Liao 2 mēneši atpakaļ
vecāks
revīzija
3a302b3b0f

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -4687,6 +4687,25 @@ try {
         }
     }
 
+    private void runMigration80() {
+        // 迁移80: 创建product_recommendation_log表(健康报告触发商品推荐日志)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_recommendation_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT COMMENT '用户ID', " +
+                    "product_id BIGINT COMMENT '商品ID', " +
+                    "scene VARCHAR(32) COMMENT '触发场景: report_upload/manual/chat', " +
+                    "reason VARCHAR(255) COMMENT '推荐理由', " +
+                    "match_score DOUBLE COMMENT '匹配分', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+                    "INDEX idx_user_id (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐日志'");
+            log.info("已创建product_recommendation_log表");
+        } catch (Exception e) {
+            log.warn("创建product_recommendation_log表失败: {}", e.getMessage());
+        }
+    }
+
     private void insertProductSeed(String name, String desc, String type,
                                 Integer price,
                                 String domain, String memberEligible) {
@@ -5195,5 +5214,7 @@ try {
         } catch (Exception e) {
         }
 
+        runMigration80();
+
         }
 }

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.EnergySandboxDTO;
+import com.etotem.cfc.dto.MemberEnergyDTO;
 import com.etotem.cfc.entity.EnergyLog;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
@@ -215,4 +216,36 @@ public class EnergyController {
         }
         return Result.success(latest);
     }
+
+    @Operation(summary = "获取富维度详情")
+    @PostMapping("/wealth-detail")
+    public Result<MemberEnergyDTO> getWealthDetail(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = (String) params.get("memberType");
+
+        if (memberId == null || memberType == null) {
+            return Result.error("memberId和memberType不能为空");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户未加入家庭");
+        }
+
+        EnergySandboxDTO sandbox = energyService.calculateFamilyEnergy(user.getFamilyId());
+        if (sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
+            return Result.error("未找到成员能量数据");
+        }
+
+        MemberEnergyDTO result = sandbox.getMembers().stream()
+                .filter(m -> memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType()))
+                .findFirst()
+                .orElse(null);
+
+        if (result == null) return Result.error("未找到指定成员的能量数据");
+        return Result.success(result);
+    }
 }

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MemberEnergyDTO.java

@@ -31,4 +31,18 @@ public class MemberEnergyDTO {
 
     /** 个人综合能量值 */
     private Integer overallScore;
+
+    // 富的子维度(parent)
+    private Integer wealthIncome;       // 金钱/收入
+    private Integer wealthAchievement;  // 社会成就
+    private Integer wealthNetwork;      // 资源网络
+
+    // 富的子维度(child)
+    private Integer wealthEducation;    // 学业成绩
+    private Integer wealthSocial;       // 社交筹码
+    private Integer wealthPoints;       // 规则博弈(积分效率)
+
+    // 身克富标记
+    private String bodyWealthStatus;    // normal / overdraw / penalty
+    private String bodyWealthMessage;   // 用户可见的消息
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationQuery.java

@@ -11,4 +11,5 @@ public class RecommendationQuery {
     private List<String> nutritionTags;   // 营养需求标签
     private List<String> types;           // ["product", "activity", "article"]
     private Integer limit = 5;            // 每类最多返回数量
+    private Long userId;                  // 用户ID
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductRecommendationLog.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_recommendation_log")
+public class ProductRecommendationLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long productId;
+
+    /** 触发场景: report_upload / manual / chat */
+    private String scene;
+
+    /** 推荐理由 */
+    private String reason;
+
+    /** 匹配分 */
+    private Double matchScore;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductRecommendationLogMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductRecommendationLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProductRecommendationLogMapper extends BaseMapper<ProductRecommendationLog> {
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java

@@ -15,16 +15,24 @@ import com.etotem.cfc.mapper.AssessmentRecordMapper;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.FamilyAssessmentConfigMapper;
 import org.springframework.stereotype.Service;
+import lombok.extern.slf4j.Slf4j;
 
 import com.etotem.cfc.dto.ParsedReport;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.entity.ProductRecommendationLog;
 import com.etotem.cfc.common.DuplicateGrowthRecordException;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
+@Slf4j
 @Service
 public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, AssessmentMaterial> {
 
@@ -46,6 +54,12 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     @Resource
     private ReportParseService reportParseService;
 
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductRecommendationLogMapper productRecommendationLogMapper;
+
     public AssessmentMaterial getActiveMaterial() {
         LambdaQueryWrapper<AssessmentMaterial> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentMaterial::getStatus, "active");
@@ -199,6 +213,45 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
         }
         danAssessmentResultMapper.insert(result);
 
+        // 触发认知测评关联商品推荐(异步,仅parent_upload来源)
+        if (DanAssessmentResult.SOURCE_PARENT_UPLOAD.equals(result.getSource())) {
+            final DanAssessmentResult finalResult = result;
+            new Thread(() -> {
+                try {
+                    Map<String, Integer> scoreMap = new LinkedHashMap<>();
+                    scoreMap.put("专注力", finalResult.getFocusScore() != null ? finalResult.getFocusScore() : 0);
+                    scoreMap.put("记忆力", finalResult.getMemoryScore() != null ? finalResult.getMemoryScore() : 0);
+                    scoreMap.put("感知力", finalResult.getPerceptionScore() != null ? finalResult.getPerceptionScore() : 0);
+                    scoreMap.put("逻辑思维", finalResult.getLogicScore() != null ? finalResult.getLogicScore() : 0);
+                    scoreMap.put("空间思维", finalResult.getSpatialScore() != null ? finalResult.getSpatialScore() : 0);
+                    scoreMap.put("加工速度", finalResult.getProcessingSpeedScore() != null ? finalResult.getProcessingSpeedScore() : 0);
+                    List<Map.Entry<String, Integer>> sorted = new ArrayList<>(scoreMap.entrySet());
+                    sorted.sort((a, b) -> a.getValue().compareTo(b.getValue()));
+                    List<String> weakDims = new ArrayList<>();
+                    for (int i = 0; i < Math.min(2, sorted.size()); i++) {
+                        weakDims.add(sorted.get(i).getKey());
+                    }
+                    RecommendationQuery query = new RecommendationQuery();
+                    query.setNutritionTags(new ArrayList<>(weakDims));
+                    query.setTypes(Arrays.asList("product"));
+                    query.setLimit(3);
+                    List<RecommendationResult> recs = recommendationService.search(query);
+                    for (RecommendationResult rec : recs) {
+                        ProductRecommendationLog logEntry = new ProductRecommendationLog();
+                        logEntry.setUserId(finalResult.getFamilyMemberId());
+                        logEntry.setProductId(rec.getId());
+                        logEntry.setScene("report_upload");
+                        logEntry.setReason("认知测评(" + String.join(",", weakDims) + ")触发");
+                        logEntry.setCreatedAt(new Date());
+                        productRecommendationLogMapper.insert(logEntry);
+                    }
+                    log.info("认知测评关联推荐已生成,familyMemberId={}, 弱维度={}", finalResult.getFamilyMemberId(), weakDims);
+                } catch (Exception e) {
+                    log.warn("认知测评推荐生成失败: error={}", e.getMessage());
+                }
+            }, "cognitive-rec-" + result.getId()).start();
+        }
+
         // Auto-create growth record from this assessment result (non-fatal on failure)
         try {
             ParsedReport parsed = reportParseService.parseFromResult(result);

+ 109 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -183,7 +183,7 @@ public class EnergyService {
         dto.setMindScore(calcParentMind(parent));
         dto.setWisdomScore(calcParentWisdom(parent));
         dto.setActionScore(calcParentAction(parent));
-        dto.setWealthScore(calcParentWealth(parent));
+        dto.setWealthScore(calcParentWealth(parent, dto));
 
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
@@ -249,17 +249,36 @@ public class EnergyService {
                 Arrays.asList("习惯", "好习惯", "自律", "家务", "自理", "作息"), 365);
     }
 
-    /** 家长 富 — 积分积累效率 */
-    private int calcParentWealth(User parent) {
+    /** 家长 富 — 三子维度加权 */
+    private int calcParentWealth(User parent, MemberEnergyDTO dto) {
+        int income = calcParentWealthIncome(parent);
+        int achievement = calcParentWealthAchievement(parent);
+        int network = calcParentWealthNetwork(parent);
+        dto.setWealthIncome(income);
+        dto.setWealthAchievement(achievement);
+        dto.setWealthNetwork(network);
+        return (int) Math.round(income * 0.5 + achievement * 0.3 + network * 0.2);
+    }
+
+    /** 家长富-金钱收入:积分总量/500 */
+    private int calcParentWealthIncome(User parent) {
         Integer totalPoints = parent.getTotalPoints();
         if (totalPoints == null) totalPoints = 0;
-
-        // 参考上限:假设 500 积分为满分
         int refMax = 500;
         int score = Math.min(totalPoints * 100 / Math.max(refMax, 1), 100);
         return Math.max(score, 0);
     }
 
+    /** 家长富-社会成就:第一阶段返回0占位 */
+    private int calcParentWealthAchievement(User parent) {
+        return 0;
+    }
+
+    /** 家长富-资源网络:第一阶段返回0占位 */
+    private int calcParentWealthNetwork(User parent) {
+        return 0;
+    }
+
     // ==================== 孩子能量计算 ====================
 
     private MemberEnergyDTO calcChildEnergy(FamilyMember child) {
@@ -269,11 +288,15 @@ public class EnergyService {
         dto.setName(child.getNickname());
         dto.setAvatar(null);  // 孩子没有独立头像,用默认
 
-        dto.setBodyScore(calcChildBody(child));
+        int bodyScore = calcChildBody(child);
+        dto.setBodyScore(bodyScore);
         dto.setMindScore(calcChildMind(child));
         dto.setWisdomScore(calcChildWisdom(child));
         dto.setActionScore(calcChildAction(child));
-        dto.setWealthScore(calcChildWealth(child));
+        dto.setWealthScore(calcChildWealth(child, dto));
+
+        // 身克富杠杆
+        applyBodyWealthRestraint(bodyScore, dto.getWealthScore(), dto);
 
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
@@ -513,11 +536,56 @@ public class EnergyService {
         return Math.min((int) Math.round(streakScore * 0.4 + habitScore * 0.6), 100);
     }
 
-    /** 孩子 富 — 积分获取效率 */
-    private int calcChildWealth(FamilyMember child) {
+    /** 孩子 富 — 三子维度加权 */
+    private int calcChildWealth(FamilyMember child, MemberEnergyDTO dto) {
+        int edu = calcChildWealthEducation(child);
+        int social = calcChildWealthSocial(child);
+        int points = calcChildWealthPoints(child);
+        dto.setWealthEducation(edu);
+        dto.setWealthSocial(social);
+        dto.setWealthPoints(points);
+        return (int) Math.round(edu * 0.3 + social * 0.2 + points * 0.5);
+    }
+
+    /** 孩子富-学业成绩:DAN测评最近3次综合分+进步趋势 */
+    private int calcChildWealthEducation(FamilyMember child) {
         Long childId = child.getId();
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                .eq(DanAssessmentResult::getChildId, childId)
+                .eq(DanAssessmentResult::getStatus, "completed")
+                .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                .last("LIMIT 3");
+        List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(qw);
+        if (results.isEmpty()) return 0;
+
+        double avg = results.stream()
+                .filter(r -> r.getOverallScore() != null)
+                .mapToInt(DanAssessmentResult::getOverallScore)
+                .average()
+                .orElse(0);
+
+        // 进步趋势:最近一次 vs 前面平均
+        if (results.size() >= 2 && results.get(0).getOverallScore() != null && results.get(1).getOverallScore() != null) {
+            int latest = results.get(0).getOverallScore();
+            double prevAvg = results.subList(1, results.size()).stream()
+                    .filter(r -> r.getOverallScore() != null)
+                    .mapToInt(DanAssessmentResult::getOverallScore)
+                    .average()
+                    .orElse(latest);
+            double trend = (latest - prevAvg) / Math.max(prevAvg, 1) * 10;
+            avg += trend;
+        }
+        return clamp((int) Math.round(avg), 0, 100);
+    }
 
-        // 已获得积分: points_log 中正向入账
+    /** 孩子富-社交筹码:活动参与次数(第一阶段返回0) */
+    private int calcChildWealthSocial(FamilyMember child) {
+        return 0;
+    }
+
+    /** 孩子富-规则博弈:积分获取效率 */
+    private int calcChildWealthPoints(FamilyMember child) {
+        Long childId = child.getId();
         Calendar cal = Calendar.getInstance();
         cal.add(Calendar.YEAR, -1);
         Date oneYearAgo = cal.getTime();
@@ -531,7 +599,6 @@ public class EnergyService {
                 .mapToInt(pl -> pl.getAmount() != null ? pl.getAmount() : 0)
                 .sum();
 
-        // 参考上限: 同期可获得的积分上限
         LambdaQueryWrapper<Task> taskWrapper = new LambdaQueryWrapper<Task>()
                 .eq(Task::getChildId, childId)
                 .ne(Task::getIsTemplate, 1)
@@ -546,6 +613,37 @@ public class EnergyService {
         return Math.max(score, 0);
     }
 
+    /**
+     * 身克富杠杆:身体底盘制约财富能量
+     * - 身 < 40: penalty → wealth *= (0.5 + 0.5 * body/100)
+     * - 身 < 50 && 富 > 身+20: overdraw(透支预警,不下调)
+     * - else: normal
+     */
+    private void applyBodyWealthRestraint(Integer bodyScore, Integer wealthScore, MemberEnergyDTO dto) {
+        int body = safeScore(bodyScore);
+        int wealth = safeScore(wealthScore);
+
+        if (body < 40) {
+            dto.setBodyWealthStatus("penalty");
+            double factor = 0.5 + 0.5 * body / 100.0;
+            int adjusted = (int) Math.round(wealth * factor);
+            dto.setWealthScore(Math.max(adjusted, 0));
+            dto.setBodyWealthMessage("健康正在稀释财富能量,建议优先关注身体健康");
+        } else if (body < 50 && wealth > body + 20) {
+            dto.setBodyWealthStatus("overdraw");
+            dto.setWealthScore(wealth);
+            dto.setBodyWealthMessage("财富能量超出身体承受范围,注意劳逸结合");
+        } else {
+            dto.setBodyWealthStatus("normal");
+            dto.setWealthScore(wealth);
+            if (body >= 70) {
+                dto.setBodyWealthMessage("身体底盘稳固,财富能量充沛");
+            } else {
+                dto.setBodyWealthMessage("");
+            }
+        }
+    }
+
     // ==================== 通用方法 ====================
 
     /**

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -1,10 +1,14 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.ProductRecommendationLog;
+import com.etotem.cfc.mapper.ProductRecommendationLogMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -22,6 +26,12 @@ public class HealthAnalysisService {
     @Resource
     private NutritionDeficiencyService nutritionDeficiencyService;
 
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Resource
+    private ProductRecommendationLogMapper productRecommendationLogMapper;
+
     public HealthAnalysisResult analyze(Long reportId) {
         Map<String, Object> detail = healthReportService.getReportDetail(reportId);
         HealthReport report = (HealthReport) detail.get("report");
@@ -223,6 +233,44 @@ public class HealthAnalysisService {
 
         log.info("健康分析完成: reportId={}, findings={}, tags={}, deficiencyRecords={}",
                 reportId, findings.size(), nutritionTags, dRecords.size());
+
+        final Long finalUserId = report.getUserId();
+        final HealthAnalysisResult finalResult = result;
+        try {
+            new Thread(() -> {
+                try {
+                    List<String> dimensionNeeds = new ArrayList<>();
+                    if (finalResult != null) {
+                        if (finalResult.getNutritionScore() != null && finalResult.getNutritionScore() < 60) {
+                            dimensionNeeds.add("body");
+                        }
+                        if (finalResult.getGutHealthScore() != null && finalResult.getGutHealthScore() < 60) {
+                            dimensionNeeds.add("body");
+                        }
+                    }
+                    for (String dim : dimensionNeeds) {
+                        List<RecommendationResult> recs = recommendationService.search(
+                            buildRecommendationQueryForDimension(dim, finalUserId, 3));
+                        for (RecommendationResult rec : recs) {
+                            ProductRecommendationLog logEntity = new ProductRecommendationLog();
+                            logEntity.setUserId(finalUserId);
+                            logEntity.setProductId(rec.getId());
+                            logEntity.setScene("report_upload");
+                            logEntity.setReason("健康报告分析触发:" + dim);
+                            logEntity.setMatchScore(null);
+                            logEntity.setCreatedAt(new Date());
+                            productRecommendationLogMapper.insert(logEntity);
+                        }
+                    }
+                    log.info("健康报告关联推荐已生成,userId={}, 维度数={}", finalUserId, dimensionNeeds.size());
+                } catch (Exception e) {
+                    log.warn("健康报告推荐生成失败: userId={}, error={}", finalUserId, e.getMessage());
+                }
+            }).start();
+        } catch (Exception e) {
+            log.warn("启动推荐线程失败: {}", e.getMessage());
+        }
+
         return result;
     }
 
@@ -231,4 +279,19 @@ public class HealthAnalysisService {
         if (report == null) return null;
         return analyze(report.getId());
     }
+
+    private RecommendationQuery buildRecommendationQueryForDimension(String dimensionCode, Long userId, int limit) {
+        RecommendationQuery query = new RecommendationQuery();
+        List<String> tags = new ArrayList<>();
+        if ("body".equals(dimensionCode)) {
+            tags.add("健康");
+            tags.add("营养");
+            tags.add("体质");
+        }
+        query.setNutritionTags(tags);
+        query.setTypes(Arrays.asList("product"));
+        query.setLimit(limit);
+        query.setUserId(userId);
+        return query;
+    }
 }

+ 12 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2668,3 +2668,15 @@ CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (
     INDEX idx_member_id (member_id),
     INDEX idx_year (year)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员年度能量表';
+
+-- 商品推荐日志表(迁移 80)
+CREATE TABLE IF NOT EXISTS product_recommendation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    product_id BIGINT COMMENT '商品ID',
+    scene VARCHAR(32) COMMENT '触发场景: report_upload/manual/chat',
+    reason VARCHAR(255) COMMENT '推荐理由',
+    match_score DOUBLE COMMENT '匹配分',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐日志';

+ 13 - 7
cfc-frontend/pages.json

@@ -1,5 +1,17 @@
 {
   "pages": [
+    {
+      "path": "pages/wealth/index",
+      "style": {
+        "navigationBarTitleText": "富"
+      }
+    },
+    {
+      "path": "pages/profile/index",
+      "style": {
+        "navigationBarTitleText": "我的"
+      }
+    },
     {
       "path": "pages/index/index",
       "style": {
@@ -811,12 +823,6 @@
     {
       "root": "pages/wealth",
       "pages": [
-        {
-          "path": "index",
-          "style": {
-            "navigationBarTitleText": "财富"
-          }
-        },
         {
           "path": "checkin",
           "style": {
@@ -1258,7 +1264,7 @@
         "selectedIconPath": "static/tab-wealth-active.png"
       },
       {
-        "pagePath": "pages/profile/profile",
+        "pagePath": "pages/wealth/index",
         "text": "富",
         "iconPath": "static/tab-profile.png",
         "selectedIconPath": "static/tab-profile-active.png"

+ 177 - 10
cfc-frontend/pages/discover/product-detail/product-detail.vue

@@ -14,7 +14,7 @@
           <text class="product-name">{{ product.name }}</text>
         </view>
         <view class="price-row">
-          <text class="price">{{ formatPriceWithSymbol(product.price) }}</text>
+          <text class="price">{{ formatPriceWithSymbol(displayPrice) }}</text>
           <text v-if="product.memberPrice" class="member-price">会员价 {{ formatPriceWithSymbol(product.memberPrice) }}</text>
         </view>
         <view class="meta-row">
@@ -22,10 +22,32 @@
           <text class="meta-item" v-if="product.domain">{{ domainLabel }}</text>
           <text class="meta-item">库存 {{ product.stock || 0 }}</text>
         </view>
+
+        <!-- Spec Selector -->
+        <view v-if="specGroups.length > 0" class="spec-section">
+          <view v-for="group in specGroups" :key="group.id" class="spec-group">
+            <text class="spec-group-name">{{ group.name }}</text>
+            <view class="spec-options">
+              <text
+                v-for="opt in specMap[group.id] || []"
+                :key="opt.id"
+                :class="['spec-chip', selectedSpecs[group.id] === opt.id ? 'spec-chip-active' : '']"
+                @click="selectSpec(group.id, opt.id)"
+              >{{ opt.name }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- Supplier Info -->
         <view class="vendor-row">
           <text class="vendor-label">供应商:</text>
           <text class="vendor-name">{{ product.vendorName || '平台官方' }}</text>
         </view>
+        <view v-if="suppliers.length > 0" class="supplier-row">
+          <view v-for="s in suppliers" :key="s.supplierId" class="supplier-tag">
+            <text class="supplier-name">{{ s.supplierName }}</text>
+          </view>
+        </view>
       </view>
 
       <view class="desc-section">
@@ -68,12 +90,16 @@ export default {
     return {
       product: {},
       loading: false,
-      cartCount: 0
+      cartCount: 0,
+      specGroups: [],
+      specMap: {},
+      selectedSpecs: {},
+      suppliers: []
     }
   },
   computed: {
     typeLabel() {
-      const map = {
+      var map = {
         activity: '活动',
         course: '课程',
         physical: '实物',
@@ -83,7 +109,7 @@ export default {
       return map[this.product.productType] || this.product.productType
     },
     domainLabel() {
-      const map = {
+      var map = {
         action: '行动',
         mind: '心智',
         body: '身体',
@@ -91,6 +117,23 @@ export default {
         wealth: '财富'
       }
       return map[this.product.domain] || this.product.domain
+    },
+    displayPrice() {
+      return this.product.price || 0
+    },
+    selectedSpecDesc() {
+      var parts = []
+      for (var gid in this.selectedSpecs) {
+        var oid = this.selectedSpecs[gid]
+        var group = this.specGroups.find(function(g) { return g.id === Number(gid) })
+        if (!group) continue
+        var opts = this.specMap[gid] || []
+        var opt = opts.find(function(o) { return o.id === oid })
+        if (opt) {
+          parts.push(group.name + ':' + opt.name)
+        }
+      }
+      return parts.join(', ')
     }
   },
   onLoad(options) {
@@ -104,15 +147,76 @@ export default {
   methods: {
     loadDetail(id) {
       this.loading = true
-      productDetail({ id: parseInt(id) }).then(res => {
-        this.loading = false
+      var that = this
+      productDetail({ id: parseInt(id) }).then(function(res) {
+        that.loading = false
         if (res.code === 200 && res.data) {
-          this.product = res.data
+          that.product = res.data
+          that.loadSpecs(id)
+          that.loadSuppliers(id)
+        }
+      }).catch(function() {
+        that.loading = false
+      })
+    },
+    loadSpecs(productId) {
+      var that = this
+      uni.request({
+        url: config.api('/api/product/spec/map'),
+        method: 'POST',
+        data: { productId: productId },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            that.specMap = res.data.data
+            var allGroups = []
+            for (var gid in res.data.data) {
+              var options = res.data.data[gid]
+              if (options && options.length > 0) {
+                allGroups.push({
+                  id: Number(gid),
+                  productId: productId,
+                  name: options[0].groupId ? '' : ''
+                })
+              }
+            }
+            that.specGroups = allGroups
+          }
+        }
+      })
+    },
+    loadSuppliers(productId) {
+      var that = this
+      uni.request({
+        url: config.api('/api/admin/product/supplier/list'),
+        method: 'POST',
+        data: { productId: productId },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            that.suppliers = res.data.data || []
+          }
         }
-      }).catch(() => {
-        this.loading = false
       })
     },
+    selectSpec(groupId, optionId) {
+      var specs = {}
+      for (var k in this.selectedSpecs) {
+        specs[k] = this.selectedSpecs[k]
+      }
+      if (specs[groupId] === optionId) {
+        delete specs[groupId]
+      } else {
+        specs[groupId] = optionId
+      }
+      this.selectedSpecs = specs
+    },
     loadCartCount() {
       var that = this
       uni.request({
@@ -132,10 +236,18 @@ export default {
     },
     onAddToCart() {
       var that = this
+      var specOptionIds = []
+      for (var k in this.selectedSpecs) {
+        specOptionIds.push(this.selectedSpecs[k])
+      }
       uni.request({
         url: config.api('/api/cart/add'),
         method: 'POST',
-        data: { productId: this.product.id, quantity: 1 },
+        data: {
+          productId: this.product.id,
+          quantity: 1,
+          specOptionIds: specOptionIds.length > 0 ? JSON.stringify(specOptionIds) : ''
+        },
         header: {
           'Content-Type': 'application/json',
           'Authorization': 'Bearer ' + uni.getStorageSync('token')
@@ -155,11 +267,16 @@ export default {
     },
     onBuy() {
       if (!this.product.id) return
+      var specOptionIds = []
+      for (var k in this.selectedSpecs) {
+        specOptionIds.push(this.selectedSpecs[k])
+      }
       var url = '/pages/shop/checkout/checkout?productId=' + this.product.id
         + '&productName=' + encodeURIComponent(this.product.name || '')
         + '&price=' + (this.product.price || 0)
         + '&coverImage=' + encodeURIComponent(this.product.coverImage || '')
         + '&quantity=1'
+        + '&specOptionIds=' + (specOptionIds.length > 0 ? encodeURIComponent(JSON.stringify(specOptionIds)) : '')
       uni.navigateTo({ url: url })
     },
     formatPriceWithSymbol(price) {
@@ -249,6 +366,56 @@ export default {
   font-size: 24rpx;
   color: #666;
 }
+.spec-section {
+  padding: 20rpx 0;
+  border-top: 2rpx solid #f5f5f5;
+  margin-top: 16rpx;
+}
+.spec-group {
+  margin-bottom: 16rpx;
+}
+.spec-group:last-child {
+  margin-bottom: 0;
+}
+.spec-group-name {
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.spec-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.spec-chip {
+  padding: 10rpx 24rpx;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+  color: #666;
+  background: #f5f5f5;
+  border: 2rpx solid #eee;
+}
+.spec-chip-active {
+  color: #F97316;
+  background: #FFF7ED;
+  border-color: #F97316;
+}
+.supplier-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  margin-top: 8rpx;
+}
+.supplier-tag {
+  background: #f0f9ff;
+  padding: 4rpx 16rpx;
+  border-radius: 4rpx;
+}
+.supplier-name {
+  font-size: 22rpx;
+  color: #0EA5E9;
+}
 .desc-section,
 .intro-section {
   background: #fff;

+ 123 - 0
cfc-frontend/pages/profile/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <view class="container">
+    <view v-if="!isLoggedIn" class="login-prompt">
+      <view class="prompt-icon">👤</view>
+      <text class="prompt-title">登录浠艾福</text>
+      <text class="prompt-desc">登录后可查看个人资料</text>
+      <button class="login-btn" @click="goLogin">登录 / 注册</button>
+    </view>
+    <template v-else>
+      <PageBanner theme="wealth" tagline="个人中心" quote="" />
+      <ProfileHeader @avatar-click="goToEditInfo" />
+      <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+    </template>
+  </view>
+</template>
+
+<script>
+import PageBanner from '../components/PageBanner.vue'
+import ProfileHeader from './components/ProfileHeader.vue'
+import ProfileMenu from './components/ProfileMenu.vue'
+
+export default {
+  components: {
+    PageBanner,
+    ProfileHeader,
+    ProfileMenu
+  },
+  data() {
+    return {
+      shareData: null
+    }
+  },
+  computed: {
+    isLoggedIn() {
+      return !!uni.getStorageSync('token')
+    },
+    role() {
+      return uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    }
+  },
+  onShareAppMessage() {
+    if (this.shareData) {
+      return {
+        title: this.shareData.title,
+        path: this.shareData.path,
+        imageUrl: '/static/invite-card.png'
+      }
+    }
+  },
+  methods: {
+    goLogin() {
+      uni.navigateTo({
+        url: '/pages/login/login'
+      })
+    },
+    goToEditInfo() {
+      uni.navigateTo({
+        url: '/pages/user-edit/user-edit'
+      })
+    },
+    onInviteGenerate(data) {
+      this.shareData = data
+      uni.showToast({
+        title: '点击右上角转发给TA',
+        icon: 'none'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 120rpx;
+}
+.login-prompt {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 80vh;
+  padding: 60rpx;
+}
+.prompt-icon {
+  font-size: 120rpx;
+  margin-bottom: 30rpx;
+  width: 160rpx;
+  height: 160rpx;
+  line-height: 160rpx;
+  text-align: center;
+  background: #f5f5f5;
+  border-radius: 50%;
+}
+.prompt-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 16rpx;
+}
+.prompt-desc {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  margin-bottom: 40rpx;
+}
+.login-prompt .login-btn {
+  width: 60%;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: bold;
+  border-radius: 40rpx;
+  text-align: center;
+  border: none;
+}
+.login-prompt .login-btn::after {
+  border: none;
+}
+</style>

+ 16 - 0
cfc-frontend/pages/shop/cart/cart.vue

@@ -43,6 +43,8 @@
                 </text>
               </view>
               <text class="item-name">{{ item.productName }}</text>
+              <text v-if="item.specDesc" class="item-spec">{{ item.specDesc }}</text>
+              <text v-if="item.supplierName" class="item-supplier">供应商: {{ item.supplierName }}</text>
               <text class="item-delete" @click="onRemove(item.productId)">🗑</text>
             </view>
             <view class="item-bottom">
@@ -346,6 +348,20 @@ export default {
   white-space: nowrap;
   margin-left: 4rpx;
 }
+.item-spec {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+  margin-top: 4rpx;
+  flex-basis: 100%;
+}
+.item-supplier {
+  font-size: 20rpx;
+  color: #0EA5E9;
+  display: block;
+  margin-top: 2rpx;
+  flex-basis: 100%;
+}
 .item-delete {
   font-size: 32rpx;
   padding: 4rpx 8rpx;

+ 14 - 0
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -62,6 +62,8 @@
           />
           <view class="item-info">
             <text class="item-name">{{ item.productName }}</text>
+            <text v-if="item.specDesc" class="item-spec-desc">{{ item.specDesc }}</text>
+            <text v-if="item.supplierName" class="item-supplier-name">供应商: {{ item.supplierName }}</text>
             <text class="item-qty">x{{ item.quantity }}</text>
           </view>
           <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
@@ -600,6 +602,18 @@ export default {
   white-space: nowrap;
   margin-bottom: 6rpx;
 }
+.item-spec-desc {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+  margin-top: 2rpx;
+}
+.item-supplier-name {
+  font-size: 20rpx;
+  color: #0EA5E9;
+  display: block;
+  margin-top: 2rpx;
+}
 .item-qty {
   font-size: 22rpx;
   color: #999;

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 268 - 637
cfc-frontend/pages/wealth/index.vue


+ 24 - 2
cfc-frontend/utils/api.js

@@ -1312,6 +1312,10 @@ export const getEnergyLogs = (childId, dimensionCode, page = 1, size = 10) => {
   return request('/api/energy/logs', 'POST', { childId, dimensionCode, page, size })
 }
 
+export const getWealthDetail = (memberId, memberType) => {
+  return request('/api/energy/wealth-detail', 'POST', { memberId, memberType })
+}
+
 // ===== 鎴愬氨寰界珷 =====
 export const getUserBadges = (childId) => {
   return request('/api/badges/child/list', 'POST', { childId })
@@ -1705,6 +1709,24 @@ export const tianpanDailyFortune = (data) => {
   return request('/api/tianpan/daily-fortune', 'POST', data)
 }
 
-export const tianpanRelatedItems = (data) => {
-  return request('/api/tianpan/related-items', 'POST', data)
+export const tianpanRelatedItems = (data) => {
+  return request('/api/tianpan/related-items', 'POST', data)
+}
+
+// ========== 电商供应商 ==========
+export const supplierList = (data) => {
+  return request('/api/supplier/list', 'POST', data)
+}
+
+export const supplierDetail = (id) => {
+  return request('/api/supplier/detail', 'POST', { id })
+}
+
+export const supplierProducts = (data) => {
+  return request('/api/supplier/products', 'POST', data)
+}
+
+// ========== 商品规格 ==========
+export const productSpecGroups = (productId) => {
+  return request('/api/product/spec/groups', 'POST', { productId })
 }

+ 33 - 0
cfc-web/src/api/admin.js

@@ -1381,3 +1381,36 @@ export function updateCommissionConfig(data) {
     data
   })
 }
+
+// 电商供应商管理
+export function getEcomSupplierList(params) {
+  return request({
+    url: '/api/admin/supplier/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveEcomSupplier(data) {
+  return request({
+    url: '/api/admin/supplier/save',
+    method: 'post',
+    data
+  })
+}
+
+export function updateEcomSupplier(data) {
+  return request({
+    url: '/api/admin/supplier/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteEcomSupplier(id) {
+  return request({
+    url: '/api/admin/supplier/delete',
+    method: 'post',
+    data: { id }
+  })
+}

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

@@ -570,6 +570,13 @@ const routes = [
         component: () => import('@/views/admin/VirtualTeamDetail'),
         meta: { title: '团队成员管理', perm: 'system:config' },
         props: true
+      },
+      // ========== 电商供应商管理 ==========
+      {
+        path: 'ecom-supplier',
+        name: 'EcomSupplierManage',
+        component: () => import('@/views/admin/EcomSupplierManage'),
+        meta: { title: '电商供应商管理', perm: 'ecom:supplier' }
       }
     ]
   }

+ 199 - 0
cfc-web/src/views/admin/EcomSupplierManage.vue

@@ -0,0 +1,199 @@
+<template>
+  <div class="ecom-supplier-manage admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">电商供应商管理</h2>
+      <div class="flex items-center gap-sm">
+        <el-input v-model="keyword" placeholder="搜索供应商名称..." prefix-icon="el-icon-search" clearable style="width: 220px;" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+        <el-button type="primary" icon="el-icon-plus" @click="handleCreate">新增供应商</el-button>
+      </div>
+    </div>
+
+    <div class="table-scroll-wrap">
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="name" label="供应商名称" min-width="160" />
+      <el-table-column prop="contactName" label="联系人" width="120" />
+      <el-table-column prop="contactPhone" label="联系电话" width="140" />
+      <el-table-column label="启用状态" width="100">
+        <template slot-scope="{ row }">
+          <el-switch :value="row.status === 1" @change="handleToggle(row)" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
+      <el-table-column label="操作" width="160" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+          <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table></div>
+
+    <el-pagination
+      @size-change="onPageChange"
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      class="pagination-wrap"
+    />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @closed="resetForm">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px" size="small">
+        <el-form-item label="供应商名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入供应商名称" maxlength="100" />
+        </el-form-item>
+        <el-form-item label="联系人" prop="contactName">
+          <el-input v-model="form.contactName" placeholder="请输入联系人姓名" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="contactPhone">
+          <el-input v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="20" />
+        </el-form-item>
+        <el-form-item label="启用状态">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="备注信息" maxlength="500" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getEcomSupplierList, saveEcomSupplier, updateEcomSupplier, deleteEcomSupplier } from '@/api/admin'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      keyword: '',
+      dialogVisible: false,
+      saving: false,
+      editId: null,
+      form: {
+        name: '',
+        contactName: '',
+        contactPhone: '',
+        status: 1,
+        remark: ''
+      },
+      rules: {
+        name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
+        contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
+        contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.editId ? '编辑供应商' : '新增供应商'
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const res = await getEcomSupplierList({ page: this.page, size: this.size, keyword: this.keyword })
+        this.list = res.data.records || res.data || []
+        this.total = res.data.total || 0
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.page = 1
+      this.loadList()
+    },
+    onPageChange(val) {
+      if (typeof val === 'number') {
+        this.page = val
+      } else {
+        if (val.page) this.page = val.page
+        if (val.limit) this.size = val.limit
+      }
+      this.loadList()
+    },
+    handleCreate() {
+      this.editId = null
+      this.form = { name: '', contactName: '', contactPhone: '', status: 1, remark: '' }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.editId = row.id
+      this.form = { name: row.name, contactName: row.contactName, contactPhone: row.contactPhone, status: row.status, remark: row.remark }
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      try {
+        await this.$refs.form.validate()
+      } catch {
+        return
+      }
+      this.saving = true
+      try {
+        if (this.editId) {
+          await updateEcomSupplier({ ...this.form, id: this.editId })
+          this.$message.success('更新成功')
+        } else {
+          await saveEcomSupplier(this.form)
+          this.$message.success('创建成功')
+        }
+        this.dialogVisible = false
+        this.loadList()
+      } catch (e) {
+        this.$message.error('操作失败')
+      } finally {
+        this.saving = false
+      }
+    },
+    async handleDelete(row) {
+      try {
+        await this.$confirm(`确定删除供应商「${row.name}」?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+      } catch {
+        return
+      }
+      try {
+        await deleteEcomSupplier(row.id)
+        this.$message.success('删除成功')
+        this.loadList()
+      } catch (e) {
+        this.$message.error('删除失败')
+      }
+    },
+    async handleToggle(row) {
+      try {
+        await updateEcomSupplier({ id: row.id, status: row.status === 1 ? 0 : 1 })
+        this.$message.success('状态已更新')
+        this.loadList()
+      } catch (e) {
+        this.$message.error('状态更新失败')
+      }
+    },
+    resetForm() {
+      if (this.$refs.form) {
+        this.$refs.form.clearValidate()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ecom-supplier-manage {
+  padding: 20px;
+}
+</style>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels