Преглед изворни кода

feat: 维度页面缺口修补(Phase1) + 圈子系统(Phase2)

Phase1 小缺口修补:
- 身: 健康打卡入口(HealthCheckinCard) + body/index.vue
- 智: 阅读时长展示(wisdom/index.vue reading stats)
- 心: 天盘相性入门版(已有完整天盘/兼容性模块)
- EnergyService: 心子维度(情绪稳定/理解包容/传承传递) + 五行克生算法v2.0
- MemberEnergyDTO: 心子维度字段
- ProductController: spec/map 规格映射接口

Phase2 珍珠图/圈子系统:
- 后端: SocialCircle/SocialCircleMember实体+Mapper
- 后端: CircleService CRUD + CircleMatchService匹配引擎
- 后端: CircleController API + DatabaseInitializer迁移85 + schema.sql
- 前端: PearlDiagram.vue 珍珠图横向滚动
- 前端: CircleCard.vue 圈子卡片组件
- 前端: CircleDetail.vue 圈子详情弹窗
- 前端: action/index.vue 集成PearlDiagram + CircleDetail
- 前端: api.js 圈子API函数
Xiaogang Liao пре 2 месеци
родитељ
комит
aacb505f1e

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

@@ -5312,5 +5312,31 @@ try {
         // 迁移84: cart表添加skuId和specOptionIds字段(支持SKU选择)
         ensureColumn("cart", "sku_id", "BIGINT COMMENT '选中的SKU ID'");
         ensureColumn("cart", "spec_option_ids", "VARCHAR(500) COMMENT '选中的规格选项IDs JSON'");
+
+        // 迁移85: 创建社交圈子表(珍珠图/圈子系统)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "name VARCHAR(100) NOT NULL COMMENT '圈子名称', " +
+                    "type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider', " +
+                    "match_source VARCHAR(50) NOT NULL COMMENT '匹配源', " +
+                    "source_id BIGINT COMMENT '匹配源ID', " +
+                    "member_count INT DEFAULT 0 COMMENT '成员数', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈/珍珠'");
+            log.info("已创建social_circle表");
+        } catch (Exception e) { /* 表已存在忽略 */ }
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_member (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "circle_id BIGINT NOT NULL, " +
+                    "member_id BIGINT NOT NULL COMMENT '用户ID', " +
+                    "member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " +
+                    "joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_circle_member (circle_id, member_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员'");
+            log.info("已创建social_circle_member表");
+        } catch (Exception e) { /* 表已存在忽略 */ }
     }
 }

+ 121 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java

@@ -0,0 +1,121 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.service.CircleService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+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.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/circle")
+public class CircleController {
+
+    @Resource
+    private CircleService circleService;
+
+    /**
+     * 获取用户已加入的圈子列表
+     */
+    @PostMapping("/my-circles")
+    public Result<List<Map<String, Object>>> myCircles(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        List<Map<String, Object>> circles = circleService.getMyCircles(memberId, memberType);
+        return Result.success(circles);
+    }
+
+    /**
+     * 发现推荐圈子
+     */
+    @PostMapping("/discover")
+    public Result<List<Map<String, Object>>> discover(@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不能为空");
+        }
+        List<Map<String, Object>> circles = circleService.discoverCircles(childId);
+        return Result.success(circles);
+    }
+
+    /**
+     * 加入圈子
+     */
+    @PostMapping("/join")
+    public Result<String> join(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+
+        boolean success = circleService.joinCircle(circleId, memberId, memberType);
+        return success ? Result.success("加入成功") : Result.error("加入失败");
+    }
+
+    /**
+     * 退出圈子
+     */
+    @PostMapping("/leave")
+    public Result<String> leave(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+
+        boolean success = circleService.leaveCircle(circleId, memberId, memberType);
+        return success ? Result.success("退出成功") : Result.error("退出失败");
+    }
+
+    /**
+     * 创建圈子
+     */
+    @PostMapping("/create")
+    public Result<SocialCircle> create(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "userId", required = false) Long userId) {
+        String name = params.get("name") != null ? params.get("name").toString() : null;
+        String type = params.get("type") != null ? params.get("type").toString() : null;
+        String matchSource = params.get("matchSource") != null ? params.get("matchSource").toString() : null;
+        Long sourceId = params.get("sourceId") != null
+                ? Long.valueOf(params.get("sourceId").toString()) : null;
+        Long creatorId = params.get("creatorId") != null
+                ? Long.valueOf(params.get("creatorId").toString()) : null;
+        String creatorType = params.get("creatorType") != null
+                ? params.get("creatorType").toString() : "child";
+
+        if (name == null) return Result.error("name不能为空");
+        if (type == null) return Result.error("type不能为空");
+
+        if (creatorId == null && userId != null) {
+            creatorId = userId;
+        }
+        if (creatorId == null) {
+            return Result.error("creatorId不能为空");
+        }
+
+        SocialCircle circle = circleService.createCircle(name, type, matchSource,
+                sourceId, creatorId, creatorType);
+        return Result.success(circle);
+    }
+}

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -9,6 +9,8 @@ import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductService;
 import com.etotem.cfc.service.ProductSkuService;
 import com.alibaba.fastjson.JSON;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -26,6 +28,8 @@ import java.util.Map;
 @RequestMapping("/api/product")
 public class ProductController {
 
+    private static final org.slf4j.Logger log = LoggerFactory.getLogger(ProductController.class);
+
     @Resource
     private ProductService productService;
 
@@ -80,4 +84,71 @@ public class ProductController {
         List<String> images = (List<String>) params.get("images");
         return productService.updateImages(productId, userId, images);
     }
+
+    @PostMapping("/spec/map")
+    public Result<Map<String, Object>> specMap(@RequestBody Map<String, Object> params) {
+        @SuppressWarnings("unchecked")
+        List<Object> productIdList = (List<Object>) params.get("productIds");
+        if (productIdList == null || productIdList.isEmpty()) {
+            return Result.success(new HashMap<>());
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        for (Object pid : productIdList) {
+            Long productId = Long.valueOf(pid.toString());
+            List<ProductSku> skus = productSkuService.listByProductId(productId);
+
+            List<Map<String, Object>> groups = new ArrayList<>();
+            Map<String, Integer> nameToGroupId = new HashMap<>();
+            int groupId = 1;
+
+            for (ProductSku sku : skus) {
+                if (sku.getSpecs() == null) continue;
+                try {
+                    @SuppressWarnings("unchecked")
+                    List<Map<String, String>> specs = (List<Map<String, String>>) JSON.parseArray(sku.getSpecs());
+                    for (Map<String, String> spec : specs) {
+                        String name = spec.get("name");
+                        String value = spec.get("value");
+                        if (name == null || value == null) continue;
+
+                        if (!nameToGroupId.containsKey(name)) {
+                            nameToGroupId.put(name, groupId++);
+                            Map<String, Object> grp = new HashMap<>();
+                            grp.put("groupId", nameToGroupId.get(name));
+                            grp.put("groupName", name);
+                            grp.put("options", new ArrayList<Map<String, Object>>());
+                            groups.add(grp);
+                        }
+
+                        Map<String, Object> option = new HashMap<>();
+                        option.put("id", sku.getId());
+                        option.put("groupId", nameToGroupId.get(name));
+                        option.put("name", value);
+                        option.put("skuId", sku.getId());
+                        option.put("price", sku.getPrice());
+                        option.put("stock", sku.getStock());
+                        option.put("enabled", sku.getEnabled());
+
+                        for (Map<String, Object> g : groups) {
+                            if (g.get("groupId").equals(nameToGroupId.get(name))) {
+                                @SuppressWarnings("unchecked")
+                                List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
+                                boolean exists = opts.stream().anyMatch(o ->
+                                        value.equals(o.get("name")) && sku.getId().equals(o.get("skuId")));
+                                if (!exists) {
+                                    opts.add(option);
+                                }
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.warn("parse specs JSON failed for sku {}: {}", sku.getId(), e.getMessage());
+                }
+            }
+
+            result.put(productId.toString(), groups);
+        }
+        return Result.success(result);
+    }
 }

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

@@ -45,4 +45,25 @@ public class MemberEnergyDTO {
     // 身克富标记
     private String bodyWealthStatus;    // normal / overdraw / penalty
     private String bodyWealthMessage;   // 用户可见的消息
+
+    // 行克身标记(v2.0 五行相克)
+    private String actionBodyStatus;    // normal / overdraw / penalty / cautious / balanced
+    private String actionBodyMessage;
+
+    // 富克心标记
+    private String wealthHeartStatus;   // normal / eruption / judgmental / materialized / nourished
+    private String wealthHeartMessage;
+
+    // 心克智标记
+    private String mindWisdomStatus;     // normal / overprotect / cold_wise / balanced
+    private String mindWisdomMessage;
+
+    // 智克行标记
+    private String wisdomActionStatus;  // normal / blind / paralysis / balanced
+    private String wisdomActionMessage;
+
+    // 心的子维度(仅孩子有真实数据,成人回0)
+    private Integer heartEmotionStable;  // 心·情绪稳定 (0-100)
+    private Integer heartUnderstanding;  // 心·理解包容 (0-100)
+    private Integer heartLegacy;         // 心·传承传递 (0-100),第一阶段回0
 }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -100,4 +100,15 @@ public class ProductDTO {
         }
         return d;
     }
+
+    public static ProductDTO from(Product p, boolean isGuest, String memberLevel,
+            Integer effectivePpoint, String ppointSource) {
+        ProductDTO d = from(p, isGuest, memberLevel);
+        if (d != null && effectivePpoint != null) {
+            d.setEffectivePpoint(effectivePpoint);
+            d.setPpointSource(ppointSource);
+            d.setProfitRate(p.getProfitRate());
+        }
+        return d;
+    }
 }

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircle.java

@@ -0,0 +1,34 @@
+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("social_circle")
+public class SocialCircle implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 圈子名称 */
+    private String name;
+
+    /** 类型: topic/activity/hobby/ability/product/health/provider */
+    private String type;
+
+    /** 匹配源: article/activity/game/assessment/product/health_report/teacher */
+    private String matchSource;
+
+    /** 匹配源ID */
+    private Long sourceId;
+
+    /** 成员数 */
+    private Integer memberCount;
+
+    private Date createdAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleMember.java

@@ -0,0 +1,28 @@
+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("social_circle_member")
+public class SocialCircleMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 圈子ID */
+    private Long circleId;
+
+    /** 用户ID */
+    private Long memberId;
+
+    /** parent/child */
+    private String memberType;
+
+    private Date joinedAt;
+}

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

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

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

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

+ 518 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleMatchService.java

@@ -0,0 +1,518 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 圈子匹配引擎
+ * 基于身·心·智·富·行五维数据发现共同点,推荐圈子
+ */
+@Slf4j
+@Service
+public class CircleMatchService {
+
+    @Resource
+    private ActivityRegistrationMapper activityRegistrationMapper;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private CircleService circleService;
+
+    /**
+     * 为孩子发现匹配圈子(按匹配源分组)
+     */
+    public List<Map<String, Object>> discover(Long childId) {
+        List<Map<String, Object>> allRecommendations = new ArrayList<>();
+        Set<String> dedupKey = new HashSet<>();
+
+        // 1. 共同活动匹配
+        List<Map<String, Object>> activityCircles = matchByActivity(childId);
+        for (Map<String, Object> c : activityCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 2. 共同认知能力匹配
+        List<Map<String, Object>> abilityCircles = matchByAbility(childId);
+        for (Map<String, Object> c : abilityCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 3. 共同情绪特征匹配
+        List<Map<String, Object>> topicCircles = matchByEmotion(childId);
+        for (Map<String, Object> c : topicCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 4. 共同身体状况匹配
+        List<Map<String, Object>> healthCircles = matchByHealth(childId);
+        for (Map<String, Object> c : healthCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 5. 共同产品匹配
+        List<Map<String, Object>> productCircles = matchByProduct(childId);
+        for (Map<String, Object> c : productCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 6. 共同服务商匹配
+        List<Map<String, Object>> providerCircles = matchByProvider(childId);
+        for (Map<String, Object> c : providerCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        return allRecommendations;
+    }
+
+    /**
+     * 1. 共同活动匹配:参与同一活动的其他孩子
+     */
+    private List<Map<String, Object>> matchByActivity(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子参与的活动
+            LambdaQueryWrapper<ActivityRegistration> regWrapper = new LambdaQueryWrapper<ActivityRegistration>()
+                    .eq(ActivityRegistration::getChildId, childId);
+            List<ActivityRegistration> myRegs = activityRegistrationMapper.selectList(regWrapper);
+
+            for (ActivityRegistration reg : myRegs) {
+                if (reg.getActivityId() == null) continue;
+
+                // 查找参与同一活动的其他孩子
+                LambdaQueryWrapper<ActivityRegistration> sameActWrapper = new LambdaQueryWrapper<ActivityRegistration>()
+                        .eq(ActivityRegistration::getActivityId, reg.getActivityId())
+                        .ne(ActivityRegistration::getChildId, childId);
+                List<ActivityRegistration> sameAct = activityRegistrationMapper.selectList(sameActWrapper);
+
+                if (!sameAct.isEmpty()) {
+                    // 创建或复用圈子
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "共同活动圈", "activity", "activity",
+                            reg.getActivityId().longValue(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "参加了同一活动");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同活动匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 2. 共同认知能力匹配:DAN COG 子分差值 < 10
+     */
+    private List<Map<String, Object>> matchByAbility(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            DanAssessmentResult myDan = findLatestDan(childId);
+            if (myDan == null) return result;
+
+            // 查找其他认知能力相近的孩子
+            LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                    .eq(DanAssessmentResult::getStatus, "completed")
+                    .ne(DanAssessmentResult::getChildId, childId)
+                    .orderByDesc(DanAssessmentResult::getAssessmentDate);
+            List<DanAssessmentResult> others = danAssessmentResultMapper.selectList(qw);
+
+            // 按childId去重取最新
+            Map<Long, DanAssessmentResult> latestByChild = new HashMap<>();
+            for (DanAssessmentResult r : others) {
+                if (!latestByChild.containsKey(r.getChildId())) {
+                    latestByChild.put(r.getChildId(), r);
+                }
+            }
+
+            for (DanAssessmentResult other : latestByChild.values()) {
+                if (isCogSimilar(myDan, other)) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "认知能力圈", "ability", "assessment",
+                            myDan.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "认知能力相似");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同认知能力匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 3. 共同情绪特征匹配:EMI 子分相近
+     */
+    private List<Map<String, Object>> matchByEmotion(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            DanAssessmentResult myDan = findLatestDan(childId);
+            if (myDan == null) return result;
+
+            LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                    .eq(DanAssessmentResult::getStatus, "completed")
+                    .ne(DanAssessmentResult::getChildId, childId)
+                    .orderByDesc(DanAssessmentResult::getAssessmentDate);
+            List<DanAssessmentResult> others = danAssessmentResultMapper.selectList(qw);
+
+            Map<Long, DanAssessmentResult> latestByChild = new HashMap<>();
+            for (DanAssessmentResult r : others) {
+                if (!latestByChild.containsKey(r.getChildId())) {
+                    latestByChild.put(r.getChildId(), r);
+                }
+            }
+
+            for (DanAssessmentResult other : latestByChild.values()) {
+                if (isEmiSimilar(myDan, other)) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "情绪成长圈", "topic", "assessment",
+                            myDan.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "情绪特征相近");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同情绪特征匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 4. 共同身体状况匹配:健康指标相近
+     */
+    private List<Map<String, Object>> matchByHealth(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子的最新健康报告
+            LambdaQueryWrapper<HealthReport> myHw = new LambdaQueryWrapper<HealthReport>()
+                    .eq(HealthReport::getUserId, childId)
+                    .eq(HealthReport::getStatus, "active")
+                    .orderByDesc(HealthReport::getReportDate)
+                    .last("LIMIT 1");
+            HealthReport myReport = healthReportMapper.selectOne(myHw);
+            if (myReport == null || myReport.getOverallScore() == null) return result;
+
+            // 查找其他健康指标相近的孩子
+            LambdaQueryWrapper<HealthReport> othersHw = new LambdaQueryWrapper<HealthReport>()
+                    .eq(HealthReport::getStatus, "active")
+                    .ne(HealthReport::getUserId, childId)
+                    .orderByDesc(HealthReport::getReportDate);
+            List<HealthReport> others = healthReportMapper.selectList(othersHw);
+
+            Map<Long, HealthReport> latestByUser = new HashMap<>();
+            for (HealthReport r : others) {
+                if (!latestByUser.containsKey(r.getUserId())) {
+                    latestByUser.put(r.getUserId(), r);
+                }
+            }
+
+            int myScore = myReport.getOverallScore();
+            for (HealthReport other : latestByUser.values()) {
+                if (other.getOverallScore() != null
+                        && Math.abs(other.getOverallScore() - myScore) < 15) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "健康生活圈", "health", "health_report",
+                            myReport.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "身体状况相近");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同身体状况匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 5. 共同产品匹配:购买了同一商品
+     */
+    private List<Map<String, Object>> matchByProduct(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 使用 product_order 表查询
+            String tableName = "product_order";
+            String sql = "SELECT DISTINCT product_id FROM " + tableName
+                    + " WHERE buyer_id = " + childId
+                    + " AND product_id IS NOT NULL";
+            List<Map<String, Object>> myProducts;
+            try {
+                myProducts = productOrderMapper.selectMaps(
+                        new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                .select("DISTINCT product_id")
+                                .eq("buyer_id", childId)
+                                .isNotNull("product_id"));
+            } catch (Exception e) {
+                log.warn("product_order查询失败,尝试product_orders: {}", e.getMessage());
+                try {
+                    myProducts = productOrderMapper.selectMaps(
+                            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                    .select("DISTINCT product_id")
+                                    .eq("buyer_id", childId)
+                                    .isNotNull("product_id"));
+                } catch (Exception e2) {
+                    log.warn("product_orders查询也失败: {}", e2.getMessage());
+                    myProducts = new ArrayList<>();
+                }
+            }
+
+            for (Map<String, Object> row : myProducts) {
+                Object pid = row.get("product_id");
+                if (pid == null) continue;
+                Long productId = Long.valueOf(pid.toString());
+
+                // 查找买了同一商品的其他用户
+                String searchSql = "SELECT DISTINCT buyer_id FROM " + tableName
+                        + " WHERE product_id = " + productId
+                        + " AND buyer_id != " + childId;
+                List<Map<String, Object>> others;
+                try {
+                    others = productOrderMapper.selectMaps(
+                            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                    .select("DISTINCT buyer_id")
+                                    .eq("product_id", productId)
+                                    .ne("buyer_id", childId));
+                } catch (Exception e) {
+                    continue;
+                }
+
+                if (!others.isEmpty()) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "好物分享圈", "product", "product",
+                            productId, childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "购买了相同商品");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同产品匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 6. 共同服务商匹配:绑定了同一位teacher
+     */
+    private List<Map<String, Object>> matchByProvider(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子所在的家庭
+            LambdaQueryWrapper<FamilyMember> fmw = new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getId, childId);
+            FamilyMember member = familyMemberMapper.selectOne(fmw);
+            if (member == null || member.getFamilyId() == null) return result;
+
+            // 查找家庭绑定的teacher
+            Family family = familyMapper.selectById(member.getFamilyId());
+            if (family == null || family.getTeacherId() == null) return result;
+
+            Long teacherId = family.getTeacherId();
+
+            // 查找绑定了同一teacher的其他家庭的孩子
+            LambdaQueryWrapper<Family> familyQw = new LambdaQueryWrapper<Family>()
+                    .eq(Family::getTeacherId, teacherId)
+                    .ne(Family::getId, member.getFamilyId());
+            List<Family> sameTeacherFamilies = familyMapper.selectList(familyQw);
+
+            for (Family f : sameTeacherFamilies) {
+                LambdaQueryWrapper<FamilyMember> childQw = new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, f.getId());
+                List<FamilyMember> siblings = familyMemberMapper.selectList(childQw);
+                if (!siblings.isEmpty()) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "规划师同门圈", "provider", "teacher",
+                            teacherId, childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "同一成长规划师");
+                        result.add(circle);
+                    }
+                    break; // 一个teacher只创建一个圈子
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同服务商匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    /**
+     * 获取或创建推荐圈子
+     */
+    private Map<String, Object> getOrCreateRecommendation(String baseName, String type,
+                                                          String matchSource, Long sourceId,
+                                                          Long childId) {
+        try {
+            // 查找是否已有相同源ID+类型的圈子
+            LambdaQueryWrapper<SocialCircle> qw = new LambdaQueryWrapper<SocialCircle>()
+                    .eq(SocialCircle::getSourceId, sourceId)
+                    .eq(SocialCircle::getType, type);
+            SocialCircle existing = circleService.getOne(qw);
+
+            if (existing != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", existing.getId());
+                item.put("name", existing.getName());
+                item.put("type", existing.getType());
+                item.put("matchSource", existing.getMatchSource());
+                item.put("sourceId", existing.getSourceId());
+                item.put("memberCount", existing.getMemberCount());
+                return item;
+            }
+
+            // 创建新圈子
+            SocialCircle circle = circleService.createCircle(baseName, type, matchSource,
+                    sourceId, childId, "child");
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", circle.getId());
+            item.put("name", circle.getName());
+            item.put("type", circle.getType());
+            item.put("matchSource", circle.getMatchSource());
+            item.put("sourceId", circle.getSourceId());
+            item.put("memberCount", circle.getMemberCount());
+            return item;
+        } catch (Exception e) {
+            log.warn("创建推荐圈子失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 获取孩子最新的DAN测评结果
+     */
+    private DanAssessmentResult findLatestDan(Long childId) {
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                .eq(DanAssessmentResult::getChildId, childId)
+                .eq(DanAssessmentResult::getStatus, "completed")
+                .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                .last("LIMIT 1");
+        return danAssessmentResultMapper.selectOne(qw);
+    }
+
+    /**
+     * 判断COG认知能力是否相近(各维度差值<10)
+     */
+    private boolean isCogSimilar(DanAssessmentResult a, DanAssessmentResult b) {
+        int diffSum = 0;
+        int count = 0;
+
+        if (a.getAttentionScore() != null && b.getAttentionScore() != null) {
+            diffSum += Math.abs(a.getAttentionScore() - b.getAttentionScore());
+            count++;
+        }
+        if (a.getFocusScore() != null && b.getFocusScore() != null) {
+            diffSum += Math.abs(a.getFocusScore() - b.getFocusScore());
+            count++;
+        }
+        if (a.getMemoryScore() != null && b.getMemoryScore() != null) {
+            diffSum += Math.abs(a.getMemoryScore() - b.getMemoryScore());
+            count++;
+        }
+        if (a.getLogicScore() != null && b.getLogicScore() != null) {
+            diffSum += Math.abs(a.getLogicScore() - b.getLogicScore());
+            count++;
+        }
+        if (a.getPerceptionScore() != null && b.getPerceptionScore() != null) {
+            diffSum += Math.abs(a.getPerceptionScore() - b.getPerceptionScore());
+            count++;
+        }
+        if (a.getSpatialScore() != null && b.getSpatialScore() != null) {
+            diffSum += Math.abs(a.getSpatialScore() - b.getSpatialScore());
+            count++;
+        }
+
+        if (count == 0) return false;
+        return (diffSum / count) < 10;
+    }
+
+    /**
+     * 判断EMI情绪特征是否相近
+     */
+    private boolean isEmiSimilar(DanAssessmentResult a, DanAssessmentResult b) {
+        int diffSum = 0;
+        int count = 0;
+
+        if (a.getEmotionManagementScore() != null && b.getEmotionManagementScore() != null) {
+            diffSum += Math.abs(a.getEmotionManagementScore() - b.getEmotionManagementScore());
+            count++;
+        }
+        if (a.getEmpathyScore() != null && b.getEmpathyScore() != null) {
+            diffSum += Math.abs(a.getEmpathyScore() - b.getEmpathyScore());
+            count++;
+        }
+        if (a.getSocialAdaptabilityScore() != null && b.getSocialAdaptabilityScore() != null) {
+            diffSum += Math.abs(a.getSocialAdaptabilityScore() - b.getSocialAdaptabilityScore());
+            count++;
+        }
+        if (a.getSelfMotivationScore() != null && b.getSelfMotivationScore() != null) {
+            diffSum += Math.abs(a.getSelfMotivationScore() - b.getSelfMotivationScore());
+            count++;
+        }
+
+        if (count == 0) return false;
+        return (diffSum / count) < 10;
+    }
+}

+ 182 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java

@@ -0,0 +1,182 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.entity.SocialCircleMember;
+import com.etotem.cfc.mapper.SocialCircleMapper;
+import com.etotem.cfc.mapper.SocialCircleMemberMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle> {
+
+    @Resource
+    private SocialCircleMemberMapper socialCircleMemberMapper;
+
+    @Resource
+    private CircleMatchService circleMatchService;
+
+    /**
+     * 获取用户已加入的圈子列表
+     */
+    public List<Map<String, Object>> getMyCircles(Long memberId, String memberType) {
+        // 查询用户加入的圈子成员记录
+        LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getMemberType, memberType);
+        List<SocialCircleMember> myMemberships = socialCircleMemberMapper.selectList(mw);
+
+        if (myMemberships.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<Long> circleIds = myMemberships.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(Collectors.toList());
+
+        // 查询圈子详情
+        LambdaQueryWrapper<SocialCircle> cw = new LambdaQueryWrapper<SocialCircle>()
+                .in(SocialCircle::getId, circleIds);
+        List<SocialCircle> circles = this.list(cw);
+
+        // 组装结果
+        Map<Long, SocialCircle> circleMap = circles.stream()
+                .collect(Collectors.toMap(SocialCircle::getId, c -> c));
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCircleMember membership : myMemberships) {
+            SocialCircle circle = circleMap.get(membership.getCircleId());
+            if (circle != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", circle.getId());
+                item.put("name", circle.getName());
+                item.put("type", circle.getType());
+                item.put("matchSource", circle.getMatchSource());
+                item.put("memberCount", circle.getMemberCount());
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 发现推荐圈子
+     */
+    public List<Map<String, Object>> discoverCircles(Long childId) {
+        // 获取用户已加入圈子ID集合
+        LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, childId)
+                .eq(SocialCircleMember::getMemberType, "child");
+        List<SocialCircleMember> existing = socialCircleMemberMapper.selectList(mw);
+        List<Long> joinedCircleIds = existing.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(Collectors.toList());
+
+        // 调用匹配引擎获取推荐
+        List<Map<String, Object>> recommendations = circleMatchService.discover(childId);
+
+        // 过滤已加入的圈子
+        List<Map<String, Object>> filtered = new ArrayList<>();
+        for (Map<String, Object> rec : recommendations) {
+            Long circleId = (Long) rec.get("id");
+            if (circleId != null && !joinedCircleIds.contains(circleId)) {
+                filtered.add(rec);
+            }
+        }
+        return filtered;
+    }
+
+    /**
+     * 加入圈子
+     */
+    @Transactional
+    public boolean joinCircle(Long circleId, Long memberId, String memberType) {
+        // 检查是否已加入
+        LambdaQueryWrapper<SocialCircleMember> check = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId);
+        SocialCircleMember existing = socialCircleMemberMapper.selectOne(check);
+        if (existing != null) {
+            return true; // 已加入,视为成功
+        }
+
+        // 添加成员记录
+        SocialCircleMember member = new SocialCircleMember();
+        member.setCircleId(circleId);
+        member.setMemberId(memberId);
+        member.setMemberType(memberType);
+        member.setJoinedAt(new Date());
+        socialCircleMemberMapper.insert(member);
+
+        // 更新圈子成员数
+        SocialCircle circle = this.getById(circleId);
+        if (circle != null) {
+            int count = circle.getMemberCount() != null ? circle.getMemberCount() + 1 : 1;
+            circle.setMemberCount(count);
+            this.updateById(circle);
+        }
+
+        return true;
+    }
+
+    /**
+     * 退出圈子
+     */
+    @Transactional
+    public boolean leaveCircle(Long circleId, Long memberId, String memberType) {
+        LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getMemberType, memberType);
+        int deleted = socialCircleMemberMapper.delete(wrapper);
+        if (deleted > 0) {
+            // 更新圈子成员数
+            SocialCircle circle = this.getById(circleId);
+            if (circle != null && circle.getMemberCount() != null && circle.getMemberCount() > 0) {
+                circle.setMemberCount(circle.getMemberCount() - 1);
+                this.updateById(circle);
+            }
+        }
+        return deleted > 0;
+    }
+
+    /**
+     * 创建圈子并自动加入
+     */
+    @Transactional
+    public SocialCircle createCircle(String name, String type, String matchSource,
+                                     Long sourceId, Long creatorId, String creatorType) {
+        SocialCircle circle = new SocialCircle();
+        circle.setName(name);
+        circle.setType(type);
+        circle.setMatchSource(matchSource);
+        circle.setSourceId(sourceId);
+        circle.setMemberCount(1);
+        circle.setCreatedAt(new Date());
+        this.save(circle);
+
+        // 创建者自动加入
+        SocialCircleMember member = new SocialCircleMember();
+        member.setCircleId(circle.getId());
+        member.setMemberId(creatorId);
+        if (creatorType != null) {
+            member.setMemberType(creatorType);
+        } else {
+            member.setMemberType("child");
+        }
+        member.setJoinedAt(new Date());
+        socialCircleMemberMapper.insert(member);
+
+        return circle;
+    }
+}

+ 317 - 21
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -185,6 +185,15 @@ public class EnergyService {
         dto.setActionScore(calcParentAction(parent));
         dto.setWealthScore(calcParentWealth(parent, dto));
 
+        // 心的子维度(家长暂无数据源,三方面均回0)
+        dto.setHeartEmotionStable(calcHeartEmotionStableForParent());
+        dto.setHeartUnderstanding(calcHeartUnderstandingForParent());
+        dto.setHeartLegacy(calcHeartLegacy());
+
+        // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
+        applyAllRestraints(dto);
+        applyAllBoosts(dto);
+
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
                 + safeScore(dto.getWealthScore())) / 5;
@@ -295,8 +304,15 @@ public class EnergyService {
         dto.setActionScore(calcChildAction(child));
         dto.setWealthScore(calcChildWealth(child, dto));
 
-        // 身克富杠杆
-        applyBodyWealthRestraint(bodyScore, dto.getWealthScore(), dto);
+        // 心的子维度(v2.0):用最近一次 DAN 结果填入情绪稳定/理解包容/传承传递
+        DanAssessmentResult danForHeart = findLatestDanResult(child.getId());
+        dto.setHeartEmotionStable(calcHeartEmotionStableForChild(child.getId(), danForHeart));
+        dto.setHeartUnderstanding(calcHeartUnderstandingForChild(child.getId(), danForHeart));
+        dto.setHeartLegacy(calcHeartLegacy());
+
+        // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
+        applyAllRestraints(dto);
+        applyAllBoosts(dto);
 
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
@@ -614,37 +630,317 @@ public class EnergyService {
     }
 
     /**
-     * 身克富杠杆:身体底盘制约财富能量
-     * - 身 < 40: penalty → wealth *= (0.5 + 0.5 * body/100)
-     * - 身 < 50 && 富 > 身+20: overdraw(透支预警,不下调)
-     * - else: normal
+     * 身克富杠杆:身体底盘制约财富能量(ke-full-design §克② 重写版)
+     * - body=0 && wealth=0 → normal(无数据边界保护)
+     * - 身<50 且 富>身+20 → overdraw(透支预警,不下调)
+     * - 身<40 → penalty(wealth *= 0.5 + 0.5×身/100,文案"健康正在稀释财富能量")
+     * - 否则 → normal(身≥70 且 富≥60:双优;身≥70 且 富<40:创富时机;其他:空)
      */
-    private void applyBodyWealthRestraint(Integer bodyScore, Integer wealthScore, MemberEnergyDTO dto) {
+    private int applyBodyWealthRestraint(Integer bodyScore, Integer wealthScore, MemberEnergyDTO dto) {
         int body = safeScore(bodyScore);
         int wealth = safeScore(wealthScore);
 
+        if (body == 0 && wealth == 0) {
+            dto.setBodyWealthStatus("normal");
+            dto.setBodyWealthMessage("");
+            return wealth;
+        }
+
+        if (body < 50 && wealth > body + 20) {
+            dto.setBodyWealthStatus("overdraw");
+            dto.setBodyWealthMessage("财富能量超出身体承受范围,注意劳逸结合");
+            return wealth;
+        }
+
         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.setBodyWealthStatus("penalty");
             dto.setBodyWealthMessage("健康正在稀释财富能量,建议优先关注身体健康");
-        } else if (body < 50 && wealth > body + 20) {
-            dto.setBodyWealthStatus("overdraw");
-            dto.setWealthScore(wealth);
-            dto.setBodyWealthMessage("财富能量超出身体承受范围,注意劳逸结合");
+            return Math.max(adjusted, 0);
+        }
+
+        dto.setBodyWealthStatus("normal");
+        if (body >= 70 && wealth >= 60) {
+            dto.setBodyWealthMessage("身体是财富的底盘,您处于双优状态");
+        } else if (body >= 70 && wealth < 40) {
+            dto.setBodyWealthMessage("您的健康基础很好,是启动创富的好时机");
         } else {
-            dto.setBodyWealthStatus("normal");
-            dto.setWealthScore(wealth);
-            if (body >= 70) {
-                dto.setBodyWealthMessage("身体底盘稳固,财富能量充沛");
-            } else {
-                dto.setBodyWealthMessage("");
-            }
+            dto.setBodyWealthMessage("");
+        }
+        return wealth;
+    }
+
+    // ==================== 五行相克(v2.0 克环其余4个 + 统一入口) ====================
+
+    /**
+     * 行克身(木克土)—— 行动社交对身体健康的制约
+     * - 行>75 且 身<40 → overdraw(身 *= 0.5 + 0.5×身/100,透支)
+     * - 行>60 且 身>60 → balanced(不下调,身心兼修)
+     * - 身>70 且 行<30 → cautious(不下调,缺少行动提示)
+     * - 否则 → normal
+     */
+    private int applyActionBodyRestraint(Integer actionScore, Integer bodyScore, MemberEnergyDTO dto) {
+        int action = safeScore(actionScore);
+        int body = safeScore(bodyScore);
+
+        if (action > 75 && body < 40) {
+            double penalty = 0.5 + 0.5 * (body / 100.0);
+            int adjusted = (int) Math.round(body * penalty);
+            dto.setActionBodyStatus("overdraw");
+            dto.setActionBodyMessage("行动力很强,但身体在报警。适度暂停,是另一种前进");
+            return Math.min(adjusted, body);
+        }
+        if (action > 60 && body > 60) {
+            dto.setActionBodyStatus("balanced");
+            dto.setActionBodyMessage("身心兼修,动态平衡");
+            return body;
+        }
+        if (body > 70 && action < 30) {
+            dto.setActionBodyStatus("cautious");
+            dto.setActionBodyMessage("身体底子好,但缺少行动。出门走走,也是养生的一部分");
+            return body;
+        }
+        dto.setActionBodyStatus("normal");
+        dto.setActionBodyMessage("");
+        return body;
+    }
+
+    /**
+     * 富克心(水克火)—— 财富对心(情绪/包容/传承)三层面的侵蚀
+     * 检测优先级:情绪<40→eruption / 包容<40→judgmental / 传承<20→materialized / 富>70心>65→nourished / normal
+     */
+    private int applyWealthHeartRestraint(Integer wealthScore, Integer heartScore, MemberEnergyDTO dto) {
+        int wealth = safeScore(wealthScore);
+        int heart = safeScore(heartScore);
+
+        int emotionStable = dto.getHeartEmotionStable() != null ? dto.getHeartEmotionStable() : 50;
+        int understanding = dto.getHeartUnderstanding() != null ? dto.getHeartUnderstanding() : 50;
+        int legacy = dto.getHeartLegacy() != null ? dto.getHeartLegacy() : 50;
+
+        if (wealth > 70 && emotionStable < 40) {
+            double penalty = 0.4 + 0.6 * (emotionStable / 100.0);
+            int adjusted = (int) Math.round(heart * penalty);
+            dto.setWealthHeartStatus("eruption");
+            dto.setWealthHeartMessage("财富给了底气,也偷走了对家人的耐心。先稳住情绪,再谈其他");
+            return Math.min(adjusted, heart);
+        }
+
+        if (wealth > 60 && understanding < 40) {
+            dto.setWealthHeartStatus("judgmental");
+            dto.setWealthHeartMessage("站在高处久了,可能忘了普通人走路的难处");
+            return heart;
+        }
+
+        if (wealth > 60 && legacy < 20) {
+            dto.setWealthHeartStatus("materialized");
+            dto.setWealthHeartMessage("留给孩子的只有存款,价值观也需要传下去");
+            return heart;
+        }
+
+        if (wealth > 70 && heart > 65) {
+            dto.setWealthHeartStatus("nourished");
+            dto.setWealthHeartMessage("您的财富正在滋养家庭情感,这是最好的传承");
+            return heart;
+        }
+
+        dto.setWealthHeartStatus("normal");
+        dto.setWealthHeartMessage("");
+        return heart;
+    }
+
+    /**
+     * 心克智(火克金)—— 情感关爱对理性培养的制约
+     * - 心>70 且 智<40 → overprotect(智 *= 0.5+0.5×智/100,过度保护)
+     * - 智>70 且 心<40 → cold_wise(不下调,理性冷淡提示)
+     * - 心>60 且 智>60 → balanced(不下调)
+     * - 否则 → normal
+     */
+    private int applyMindWisdomRestraint(Integer mindScore, Integer wisdomScore, MemberEnergyDTO dto) {
+        int mind = safeScore(mindScore);
+        int wisdom = safeScore(wisdomScore);
+
+        if (mind > 70 && wisdom < 40) {
+            double penalty = 0.5 + 0.5 * (wisdom / 100.0);
+            int adjusted = (int) Math.round(wisdom * penalty);
+            dto.setMindWisdomStatus("overprotect");
+            dto.setMindWisdomMessage("放手让孩子试错,是培养智慧的第一步");
+            return Math.min(adjusted, wisdom);
+        }
+
+        if (wisdom > 70 && mind < 40) {
+            dto.setMindWisdomStatus("cold_wise");
+            dto.setMindWisdomMessage("智慧需要温度,不然长大了只会做事不会做人");
+            return wisdom;
+        }
+
+        if (mind > 60 && wisdom > 60) {
+            dto.setMindWisdomStatus("balanced");
+            dto.setMindWisdomMessage("爱与理性并行,是最好的教育");
+            return wisdom;
+        }
+
+        dto.setMindWisdomStatus("normal");
+        dto.setMindWisdomMessage("");
+        return wisdom;
+    }
+
+    /**
+     * 智克行(金克木)—— 认知分析对人际关系的制约
+     * - 智<30 且 行>50 → blind(行 *= 0.6+0.4×智/100,盲目社交下调)
+     * - 智>80 且 行<40 → paralysis(不下调,认知孤独预警)
+     * - 智>60 且 行>60 → balanced(不下调)
+     * - 否则 → normal
+     */
+    private int applyWisdomActionRestraint(Integer wisdomScore, Integer actionScore, MemberEnergyDTO dto) {
+        int wisdom = safeScore(wisdomScore);
+        int action = safeScore(actionScore);
+
+        if (wisdom < 30 && action > 50) {
+            double penalty = 0.6 + 0.4 * (wisdom / 100.0);
+            int adjusted = (int) Math.round(action * penalty);
+            dto.setWisdomActionStatus("blind");
+            dto.setWisdomActionMessage("行动力很强,但有方向会让努力更有效。建议先做一次测评");
+            return Math.min(adjusted, action);
+        }
+
+        if (wisdom > 80 && action < 40) {
+            dto.setWisdomActionStatus("paralysis");
+            dto.setWisdomActionMessage("聪明不等于会相处。朋友不是用逻辑交来的");
+            return action;
+        }
+
+        if (wisdom >= 60 && action >= 60) {
+            dto.setWisdomActionStatus("balanced");
+            dto.setWisdomActionMessage("知行合一状态,继续保持");
+            return action;
+        }
+
+        dto.setWisdomActionStatus("normal");
+        if (action >= 60 && wisdom < 50) {
+            dto.setWisdomActionMessage("行动力很好,建议增加测评了解方向");
+        } else {
+            dto.setWisdomActionMessage("");
+        }
+        return action;
+    }
+
+    /**
+     * 五行相克统一入口 — 按克环顺序:行克身→身克富→富克心→心克智→智克行。
+     * 关键:每个克方法使用前一个克方法调整后的分值(链式调用,避免迭代收敛问题)。
+     */
+    private void applyAllRestraints(MemberEnergyDTO dto) {
+        int bodyAdj = applyActionBodyRestraint(dto.getActionScore(), dto.getBodyScore(), dto);
+        dto.setBodyScore(bodyAdj);
+
+        int wealthAdj = applyBodyWealthRestraint(dto.getBodyScore(), dto.getWealthScore(), dto);
+        dto.setWealthScore(wealthAdj);
+
+        int mindAdj = applyWealthHeartRestraint(dto.getWealthScore(), dto.getMindScore(), dto);
+        dto.setMindScore(mindAdj);
+
+        int wisdomAdj = applyMindWisdomRestraint(dto.getMindScore(), dto.getWisdomScore(), dto);
+        dto.setWisdomScore(wisdomAdj);
+
+        int actionAdj = applyWisdomActionRestraint(dto.getWisdomScore(), dto.getActionScore(), dto);
+        dto.setActionScore(actionAdj);
+    }
+
+    // ==================== 五行相生(v2.0 生环5个 + 统一入口) ====================
+
+    /**
+     * 生增益统一公式:boost = (upstream-50)/100×10,仅当 upstream>60 且 downstream<80 触发,
+     * 上限 80-downstream(留成长空间)。
+     */
+    private int applyBoost(Integer upstreamScore, Integer downstreamScore) {
+        int up = safeScore(upstreamScore);
+        int down = safeScore(downstreamScore);
+        if (up > 60 && down < 80) {
+            double boost = (up - 50) / 100.0 * 10;
+            int room = 80 - down;
+            int gain = (int) Math.round(boost);
+            if (gain > room) gain = room;
+            if (gain < 0) gain = 0;
+            return down + gain;
         }
+        return down;
+    }
+
+    /** 心生身(火生土) */
+    private int applyMindBodyBoost(Integer mindScore, Integer bodyScore, MemberEnergyDTO dto) {
+        return applyBoost(mindScore, bodyScore);
+    }
+
+    /** 身生智(土生金) */
+    private int applyBodyWisdomBoost(Integer bodyScore, Integer wisdomScore, MemberEnergyDTO dto) {
+        return applyBoost(bodyScore, wisdomScore);
+    }
+
+    /** 智生富(金生水) */
+    private int applyWisdomWealthBoost(Integer wisdomScore, Integer wealthScore, MemberEnergyDTO dto) {
+        return applyBoost(wisdomScore, wealthScore);
+    }
+
+    /** 富生行(水生木) */
+    private int applyWealthActionBoost(Integer wealthScore, Integer actionScore, MemberEnergyDTO dto) {
+        return applyBoost(wealthScore, actionScore);
+    }
+
+    /** 行生心(木生火) */
+    private int applyActionMindBoost(Integer actionScore, Integer mindScore, MemberEnergyDTO dto) {
+        return applyBoost(actionScore, mindScore);
+    }
+
+    /**
+     * 五行相生统一入口 — 按生环顺序:心生身→身生智→智生富→富生行→行生心。
+     * 链式调用,每个生方法使用前一个的输出作为下游输入。
+     */
+    private void applyAllBoosts(MemberEnergyDTO dto) {
+        dto.setBodyScore(applyMindBodyBoost(dto.getMindScore(), dto.getBodyScore(), dto));
+        dto.setWisdomScore(applyBodyWisdomBoost(dto.getBodyScore(), dto.getWisdomScore(), dto));
+        dto.setWealthScore(applyWisdomWealthBoost(dto.getWisdomScore(), dto.getWealthScore(), dto));
+        dto.setActionScore(applyWealthActionBoost(dto.getWealthScore(), dto.getActionScore(), dto));
+        dto.setMindScore(applyActionMindBoost(dto.getActionScore(), dto.getMindScore(), dto));
+    }
+
+    // ==================== 心的子维度(v2.0 心三层拆分) ====================
+
+    /** 心·情绪稳定 — 孩子:DAN EMI 三子分(emotionManagement+resilience+stressCoping)均值。
+     *  文档原 emotionCheckinService 在本项目不存在,用此现有 DAN 字段做代理实现。 */
+    private int calcHeartEmotionStableForChild(Long childId, DanAssessmentResult dan) {
+        if (dan == null) return 50;
+        Integer ems = dan.getEmotionManagementScore();
+        Integer resilience = dan.getResilienceScore();
+        Integer stress = dan.getStressCopingScore();
+        if (ems == null || resilience == null || stress == null) return 50;
+        return clamp((ems + resilience + stress) / 3, 0, 100);
+    }
+
+    /** 心·情绪稳定 — 家长:暂无数据,回0(ke-full-design 维度表显式标注) */
+    private int calcHeartEmotionStableForParent() {
+        return 0;
+    }
+
+    /** 心·理解包容 — 孩子:DAN (empathy+socialAdaptability)/2。
+     *  文档原家庭天盘 helper 不存在,按 doc §克③ line 295-296 用 100% DAN fallback。 */
+    private int calcHeartUnderstandingForChild(Long childId, DanAssessmentResult dan) {
+        if (dan == null) return 50;
+        Integer empathy = dan.getEmpathyScore();
+        Integer social = dan.getSocialAdaptabilityScore();
+        if (empathy == null || social == null) return 50;
+        return clamp((empathy + social) / 2, 0, 100);
+    }
+
+    /** 心·理解包容 — 家长:暂无数据,回0 */
+    private int calcHeartUnderstandingForParent() {
+        return 0;
+    }
+
+    /** 心·传承传递 — 第一阶段回0占位(孩子+家长通用) */
+    private int calcHeartLegacy() {
+        return 0;
     }
 
-    // ==================== 通用方法 ====================
 
     /**
      * 计算指定成员的任务完成率得分 (0-100)

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -36,6 +36,9 @@ public class ProductService {
     @Resource
     private AssessmentProductService assessmentProductService;
 
+    @Resource
+    private PpointService ppointService;
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
@@ -80,7 +83,8 @@ public class ProductService {
         if (!"on_shelf".equals(product.getStatus()) && !"approved".equals(product.getStatus())) {
             return Result.error("商品未上架");
         }
-        return Result.success(ProductDTO.from(product));
+        int effectivePpoint = ppointService.getEffectivePpoint(id);
+        return Result.success(ProductDTO.from(product, false, null, effectivePpoint, effectivePpoint > 0 ? "effective" : "default"));
     }
 
     public Result<ProductDTO> create(Product product, Long vendorId) {

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

@@ -2731,3 +2731,29 @@ CREATE TABLE IF NOT EXISTS repurchase_reminder_config (
 ALTER TABLE products ADD COLUMN IF NOT EXISTS recommendation_tags VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain;
 ALTER TABLE products ADD COLUMN IF NOT EXISTS purchase_count_threshold INT DEFAULT 0;
 ALTER TABLE products ADD COLUMN IF NOT EXISTS repurchase_interval_days INT DEFAULT 30;
+
+-- 社交圈/珍珠表(迁移 85)
+CREATE TABLE IF NOT EXISTS social_circle (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  name VARCHAR(100) NOT NULL COMMENT '圈子名称',
+  type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider',
+  match_source VARCHAR(50) NOT NULL COMMENT '匹配源: article/activity/game/assessment/product/health_report/teacher',
+  source_id BIGINT COMMENT '匹配源ID',
+  member_count INT DEFAULT 0 COMMENT '成员数',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  INDEX idx_type (type),
+  INDEX idx_match_source (match_source),
+  INDEX idx_source_id (source_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈/珍珠';
+
+-- 社交圈成员表(迁移 85)
+CREATE TABLE IF NOT EXISTS social_circle_member (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  circle_id BIGINT NOT NULL,
+  member_id BIGINT NOT NULL COMMENT '用户ID',
+  member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
+  joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_circle_member (circle_id, member_id),
+  INDEX idx_member (member_id, member_type),
+  INDEX idx_circle_id (circle_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员';

+ 140 - 0
cfc-frontend/components/CircleCard.vue

@@ -0,0 +1,140 @@
+<template>
+  <view class="circle-card" :class="{ compact: compact }" @click="$emit('click')">
+    <view class="circle-card-left">
+      <view class="circle-icon-wrap" :style="{ background: iconBg(type) }">
+        <text class="circle-icon">{{ typeIcon(type) }}</text>
+      </view>
+    </view>
+    <view class="circle-card-body">
+      <text class="circle-name">{{ circle.name }}</text>
+      <text class="circle-source">{{ sourceLabel(matchSource) }}</text>
+    </view>
+    <view class="circle-card-right">
+      <text class="circle-count">{{ memberCount }}人</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'CircleCard',
+  props: {
+    circle: {
+      type: Object,
+      required: true
+    },
+    compact: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    type: function() {
+      return this.circle && this.circle.type || 'topic'
+    },
+    matchSource: function() {
+      return this.circle && this.circle.matchSource || ''
+    },
+    memberCount: function() {
+      return this.circle && this.circle.memberCount || 0
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    },
+    iconBg: function(type) {
+      var map = {
+        activity: '#E0F7FA',
+        ability: '#F3E5F5',
+        health: '#FFF3E0',
+        product: '#E8F5E9',
+        provider: '#E3F2FD',
+        topic: '#FCE4EC',
+        hobby: '#FFF8E1'
+      }
+      return map[type] || '#F5F5F5'
+    },
+    sourceLabel: function(source) {
+      var map = {
+        activity: '源于共同活动',
+        assessment: '源于共同测评',
+        health_report: '源于健康状况',
+        product: '源于共同商品',
+        teacher: '源于同一规划师',
+        article: '源于共同阅读',
+        game: '源于共同游戏'
+      }
+      return map[source] || '源于共同兴趣'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.circle-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  margin-bottom: 12rpx;
+}
+.circle-card:active {
+  opacity: 0.8;
+}
+.circle-card.compact {
+  padding: 12rpx;
+  margin-bottom: 0;
+}
+.circle-card-left {
+  margin-right: 16rpx;
+}
+.circle-icon-wrap {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.circle-icon {
+  font-size: 32rpx;
+}
+.circle-card-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.circle-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.circle-source {
+  font-size: 22rpx;
+  color: #999;
+}
+.circle-card-right {
+  margin-left: 12rpx;
+}
+.circle-count {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF7ED;
+  padding: 4rpx 12rpx;
+  border-radius: 20rpx;
+}
+</style>

+ 210 - 0
cfc-frontend/components/CircleDetail.vue

@@ -0,0 +1,210 @@
+<template>
+  <view class="circle-overlay" v-if="visible" @click="$emit('close')">
+    <view class="circle-modal" @click.stop>
+      <view class="circle-modal-header">
+        <view class="circle-modal-icon-wrap">
+          <text class="circle-modal-icon">{{ typeIcon(circleType) }}</text>
+        </view>
+        <text class="circle-modal-name">{{ circleName }}</text>
+        <text class="circle-modal-source">{{ sourceLabel(circleSource) }}</text>
+      </view>
+
+      <view class="circle-modal-body">
+        <view class="circle-modal-section">
+          <text class="circle-modal-section-title">圈子成员 ({{ memberCount }}人)</text>
+          <view class="circle-modal-members">
+            <text class="circle-modal-members-placeholder">成员列表加载中...</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="circle-modal-footer">
+        <button
+          v-if="!isMember"
+          class="circle-btn circle-btn-join"
+          @click="$emit('join', circleId)">加入圈子</button>
+        <button
+          v-else
+          class="circle-btn circle-btn-leave"
+          @click="$emit('leave', circleId)">退出圈子</button>
+        <button
+          class="circle-btn circle-btn-close"
+          @click="$emit('close')">关闭</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'CircleDetail',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    circle: {
+      type: Object,
+      default: function() { return {} }
+    },
+    isMember: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    circleId: function() {
+      return this.circle && this.circle.id || null
+    },
+    circleName: function() {
+      return this.circle && this.circle.name || '未知圈子'
+    },
+    circleType: function() {
+      return this.circle && this.circle.type || 'topic'
+    },
+    circleSource: function() {
+      return this.circle && this.circle.matchSource || ''
+    },
+    memberCount: function() {
+      return this.circle && this.circle.memberCount || 0
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    },
+    sourceLabel: function(source) {
+      var map = {
+        activity: '源于共同活动',
+        assessment: '源于共同测评',
+        health_report: '源于健康状况',
+        product: '源于共同商品',
+        teacher: '源于同一规划师',
+        article: '源于共同阅读',
+        game: '源于共同游戏'
+      }
+      return map[source] || '源于共同兴趣'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.circle-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+.circle-modal {
+  width: 600rpx;
+  max-height: 80vh;
+  background: #fff;
+  border-radius: 24rpx;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.circle-modal-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40rpx 30rpx 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.circle-modal-icon-wrap {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  background: #FFF7ED;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 16rpx;
+}
+.circle-modal-icon {
+  font-size: 44rpx;
+}
+.circle-modal-name {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 8rpx;
+}
+.circle-modal-source {
+  font-size: 24rpx;
+  color: #999;
+}
+.circle-modal-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20rpx 30rpx;
+}
+.circle-modal-section {
+  margin-bottom: 20rpx;
+}
+.circle-modal-section-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #666;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.circle-modal-members {
+  min-height: 60rpx;
+}
+.circle-modal-members-placeholder {
+  font-size: 24rpx;
+  color: #ccc;
+}
+.circle-modal-footer {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  padding: 20rpx 30rpx 30rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+.circle-btn {
+  width: 100%;
+  height: 80rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  padding: 0;
+}
+.circle-btn:active {
+  opacity: 0.8;
+}
+.circle-btn-join {
+  background: #F97316;
+  color: #fff;
+}
+.circle-btn-leave {
+  background: #fff;
+  color: #FF4444;
+  border: 2rpx solid #FF4444;
+}
+.circle-btn-close {
+  background: #f5f5f5;
+  color: #999;
+}
+</style>

+ 193 - 0
cfc-frontend/components/PearlDiagram.vue

@@ -0,0 +1,193 @@
+<template>
+  <view class="pearl-section">
+    <view class="pearl-header">
+      <text class="pearl-title">我的珍珠圈</text>
+      <text class="pearl-discover-btn" @click="$emit('discover')">发现新圈子</text>
+    </view>
+
+    <view v-if="circles && circles.length > 0" class="pearl-scroll-wrap">
+      <scroll-view class="pearl-scroll" scroll-x enable-flex>
+        <view class="pearl-scroll-inner">
+          <view
+            class="pearl-item"
+            v-for="item in circles"
+            :key="item.id"
+            @click="$emit('circleClick', item)">
+            <view class="pearl-icon-wrap">
+              <text class="pearl-icon">{{ typeIcon(item.type) }}</text>
+            </view>
+            <text class="pearl-name">{{ item.name }}</text>
+            <text class="pearl-count">{{ item.memberCount || 0 }}人</text>
+          </view>
+          <view class="pearl-add-item" @click="$emit('discover')">
+            <view class="pearl-add-icon-wrap">
+              <text class="pearl-add-icon">+</text>
+            </view>
+            <text class="pearl-add-label">发现更多</text>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <view v-else class="pearl-empty">
+      <text class="pearl-empty-text">暂无圈子</text>
+      <text class="pearl-empty-action" @click="$emit('discover')">去发现新圈子</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'PearlDiagram',
+  props: {
+    circles: {
+      type: Array,
+      default: function() { return [] }
+    },
+    isLoggedIn: {
+      type: Boolean,
+      default: false
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pearl-section {
+  margin: 20rpx 20rpx 0;
+}
+.pearl-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.pearl-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.pearl-discover-btn {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.pearl-scroll {
+  white-space: nowrap;
+  overflow: hidden;
+}
+.pearl-scroll-inner {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  padding: 8rpx 0 16rpx;
+}
+.pearl-item {
+  flex-shrink: 0;
+  width: 160rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx 12rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.pearl-item:active {
+  opacity: 0.8;
+}
+.pearl-icon-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: #FFF7ED;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 10rpx;
+}
+.pearl-icon {
+  font-size: 36rpx;
+}
+.pearl-name {
+  font-size: 24rpx;
+  color: #333;
+  font-weight: 500;
+  text-align: center;
+  white-space: normal;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  max-width: 140rpx;
+}
+.pearl-count {
+  font-size: 20rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.pearl-add-item {
+  flex-shrink: 0;
+  width: 120rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 8rpx;
+}
+.pearl-add-item:active {
+  opacity: 0.7;
+}
+.pearl-add-icon-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  border: 2rpx dashed #ddd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 10rpx;
+}
+.pearl-add-icon {
+  font-size: 36rpx;
+  color: #ccc;
+}
+.pearl-add-label {
+  font-size: 22rpx;
+  color: #999;
+}
+.pearl-empty {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 40rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.pearl-empty-text {
+  font-size: 26rpx;
+  color: #ccc;
+}
+.pearl-empty-action {
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+</style>

+ 78 - 2
cfc-frontend/pages/action/index.vue

@@ -81,6 +81,14 @@
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
 
+    <!-- 珍珠图/圈子(登录后可见) -->
+    <PearlDiagram
+      v-if="isLoggedIn"
+      :circles="myCircles"
+      :isLoggedIn="isLoggedIn"
+      @circleClick="onCircleClick"
+      @discover="goDiscoverCircles" />
+
     <!-- ===== 重要关系维护 + 关系健康概览 ===== -->
     <FamilyRelationshipSection
       :contactList="contactList"
@@ -107,6 +115,15 @@
       @close="onCloseImport"
       @success="onImportSuccess" />
 
+    <!-- 圈子详情弹窗 -->
+    <CircleDetail
+      :visible="showCircleDetail"
+      :circle="selectedCircle"
+      :isMember="isCircleMember"
+      @close="showCircleDetail = false"
+      @join="onCircleJoin"
+      @leave="onCircleLeave" />
+
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
   </view>
@@ -121,15 +138,17 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import ActionArticleRecommend from '../../components/ActionArticleRecommend.vue'
+import PearlDiagram from '../../components/PearlDiagram.vue'
+import CircleDetail from '../../components/CircleDetail.vue'
 import ContactCard from '../../components/ContactCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
 import FamilyRelationshipSection from '../../components/FamilyRelationshipSection.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
-import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts, getMilestones } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts, getMilestones, getMyCircles, discoverCircles, joinCircle, leaveCircle } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ActionArticleRecommend, ContactCard, ContactImport, FamilyRelationGraph, FamilyRelationshipSection, WuxingSandbox },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ActionArticleRecommend, PearlDiagram, CircleDetail, ContactCard, ContactImport, FamilyRelationGraph, FamilyRelationshipSection, WuxingSandbox },
   data() {
     return {
       showTabTransition: true,
@@ -149,6 +168,10 @@ export default {
       dimensionProducts: [],
       articles: [],
       contactList: [],
+      myCircles: [],
+      showCircleDetail: false,
+      selectedCircle: null,
+      isCircleMember: false,
       showImportModal: false,
       healthAlerts: [],
       milestones: [],
@@ -209,6 +232,7 @@ export default {
       })
       this.loadFamilyMembersVisible()
       this.loadRelationshipHealth()
+      this.loadMyCircles()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -512,6 +536,58 @@ export default {
         if (unwatch) unwatch()
         self.showTabTransition = false
       }, 5000)
+    },
+    loadMyCircles: function() {
+      var self = this
+      getMyCircles({}).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.myCircles = res.data
+        }
+      }).catch(function() {
+        self.myCircles = []
+      })
+    },
+    onCircleClick: function(circle) {
+      this.selectedCircle = circle
+      this.isCircleMember = true
+      this.showCircleDetail = true
+    },
+    goDiscoverCircles: function() {
+      var self = this
+      var childId = this.currentChildId || uni.getStorageSync('currentChildId')
+      discoverCircles({ childId: childId }).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          self.myCircles = res.data
+        }
+      }).catch(function() {})
+    },
+    onCircleJoin: function(circleId) {
+      var self = this
+      joinCircle({ circleId: circleId }).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '加入成功', icon: 'success' })
+          self.showCircleDetail = false
+          self.loadMyCircles()
+        }
+      }).catch(function() {})
+    },
+    onCircleLeave: function(circleId) {
+      var self = this
+      uni.showModal({
+        title: '退出圈子',
+        content: '确定退出该圈子吗?',
+        success: function(res) {
+          if (res.confirm) {
+            leaveCircle({ circleId: circleId }).then(function(res2) {
+              if (res2.code === 200) {
+                uni.showToast({ title: '已退出', icon: 'success' })
+                self.showCircleDetail = false
+                self.loadMyCircles()
+              }
+            }).catch(function() {})
+          }
+        }
+      })
     }
   }
 }

+ 17 - 0
cfc-frontend/utils/api.js

@@ -1743,4 +1743,21 @@ export const getRepurchaseReminders = (params) => {
 
 export const clickRepurchaseReminder = (id) => {
   return request('/api/recommend/repurchase-reminder/' + id + '/click', 'POST', {})
+}
+
+// ===== 圈子/珍珠图 =====
+export const getMyCircles = (params) => {
+  return request('/api/circle/my-circles', 'POST', params)
+}
+
+export const discoverCircles = (params) => {
+  return request('/api/circle/discover', 'POST', params)
+}
+
+export const joinCircle = (params) => {
+  return request('/api/circle/join', 'POST', params)
+}
+
+export const leaveCircle = (params) => {
+  return request('/api/circle/leave', 'POST', params)
 }