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

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot пре 1 недеља
родитељ
комит
d0789e712e
29 измењених фајлова са 1509 додато и 46 уклоњено
  1. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java
  3. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java
  4. 190 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/FamilyMetricAggConfigController.java
  5. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMetricAggConfig.java
  6. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMetricAggConfigMapper.java
  7. 110 6
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  8. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  9. 78 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java
  10. 19 0
      cfc-backend/src/main/resources/schema.sql
  11. 162 0
      cfc-frontend/components/ArticleFeed.vue
  12. 84 10
      cfc-frontend/pages/action-detail/components/OctopusDiagram.vue
  13. 88 6
      cfc-frontend/pages/action-detail/components/PearlDiagram.vue
  14. 135 10
      cfc-frontend/pages/action-detail/index.vue
  15. 4 4
      cfc-frontend/pages/growth-main/index.vue
  16. 3 4
      cfc-frontend/pages/index-home/index.vue
  17. 3 0
      cfc-frontend/uni.scss
  18. 6 0
      cfc-frontend/utils/api.js
  19. 1 1
      cfc-web/.last_build_commit
  20. 2 2
      cfc-web/package-lock.json
  21. 1 1
      cfc-web/package.json
  22. 41 0
      cfc-web/public/CHANGELOG-v1.0.md
  23. 42 1
      cfc-web/public/CHANGELOG.md
  24. 32 0
      cfc-web/src/api/admin.js
  25. 6 0
      cfc-web/src/router/index.js
  26. 278 0
      cfc-web/src/views/FamilyMetricAggConfig.vue
  27. 1 0
      cfc-web/src/views/Layout.vue
  28. 0 0
      docs/superpowers/PROJECT-OVERVIEW.md
  29. 50 0
      docs/superpowers/api/API_REFERENCE.md

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

@@ -3389,10 +3389,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateSalesAwardTierThresholds();
         // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
         migrateFamilyNameBackfill();
+        // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
+        migrateFamilyNameBackfill();
 
         // 迁移432: report_type_registry 添加 linked_product_id 列(报告类型→商城商品链接配置,规格 2026-10-30-report-type-product-link-design)
         ensureColumn("report_type_registry", "linked_product_id",
                 "BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接'");
+        // 迁移433: 家庭指标聚合方式配置表(五维能量汇总/珍珠图/章鱼图汇集配置)
+        createFamilyMetricAggConfigTable();
     }
 
     /**
@@ -14552,7 +14556,66 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // ==================== 迁移433:家庭指标聚合方式配置(五维能量汇总/珍珠图/章鱼图汇集配置) ====================
 
+    /**
+     * 迁移433: 家庭指标聚合方式配置表 family_metric_agg_config。
+     * 平台级全局一份,按数据项一行一条。
+     * 预置 8 行数据:五维各维度 AVG + 综合 AVG + pearl.resources COLLECT + octopus.effects COLLECT
+     */
+    void createFamilyMetricAggConfigTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `family_metric_agg_config` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`metric_key` VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources', "
+                    + "`metric_name` VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」', "
+                    + "`dimension_code` VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common', "
+                    + "`agg_type` VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集', "
+                    + "`value_type` VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)', "
+                    + "`display_mode` VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph', "
+                    + "`sort_order` INT DEFAULT 0 COMMENT '排序', "
+                    + "`status` VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled', "
+                    + "`remark` VARCHAR(255) COMMENT '备注', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY `uk_metric_key` (`metric_key`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置'");
+            log.info("迁移433: 已创建 family_metric_agg_config 表");
+        } catch (Exception e) {
+            log.warn("迁移433: 创建 family_metric_agg_config 表失败(可能已存在): {}", e.getMessage());
+        }
+        // 预置数据(幂等 upsert)
+        upsertFamilyMetricAggConfig("energy.body", "身体维度能量分", "body", "AVG", "NUMBER", "number", 1, "active", "五维身体维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.mind", "心理维度能量分", "mind", "AVG", "NUMBER", "number", 2, "active", "五维心理维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.wisdom", "认知维度能量分", "wisdom", "AVG", "NUMBER", "number", 3, "active", "五维认知维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.action", "社交维度能量分", "action", "AVG", "NUMBER", "number", 4, "active", "五维社交维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.wealth", "成就维度能量分", "wealth", "AVG", "NUMBER", "number", 5, "active", "五维成就维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.overall", "综合能量分", "common", "AVG", "NUMBER", "number", 6, "active", "五维综合能量分家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("pearl.resources", "珍珠图资源(我有什么)", "action", "COLLECT", "COLLECTION", "merge_graph", 7, "active", "珍珠图家庭汇集,合并成一张图按成员着色");
+        upsertFamilyMetricAggConfig("octopus.effects", "章鱼图关键人/成效", "action", "COLLECT", "COLLECTION", "merge_graph", 8, "active", "章鱼图家庭汇集,合并成一张图按成员着色");
+    }
+
+    private void upsertFamilyMetricAggConfig(String metricKey, String metricName, String dimensionCode,
+                                              String aggType, String valueType, String displayMode,
+                                              Integer sortOrder, String status, String remark) {
+        try {
+            // 先尝试更新
+            int updated = jdbcTemplate.update(
+                    "UPDATE `family_metric_agg_config` SET metric_name=?, dimension_code=?, agg_type=?, value_type=?, display_mode=?, sort_order=?, status=?, remark=?, updated_at=CURRENT_TIMESTAMP WHERE metric_key=?",
+                    metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark, metricKey);
+            if (updated == 0) {
+                // 不存在则插入
+                jdbcTemplate.update(
+                        "INSERT INTO `family_metric_agg_config` (metric_key, metric_name, dimension_code, agg_type, value_type, display_mode, sort_order, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+                        metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark);
+                log.info("迁移433: 已插入预置配置 {}", metricKey);
+            } else {
+                log.info("迁移433: 已更新预置配置 {}", metricKey);
+            }
+        } catch (Exception e) {
+            log.warn("迁移433: 预置配置 {} 处理失败(忽略): {}", metricKey, e.getMessage());
+        }
+    }
 
     void createPpointConsumptionLogTable() {
         try {

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

@@ -34,6 +34,12 @@ public class KeyPersonController {
         return keyPersonService.listEffects(userId, null, page, pageSize);
     }
 
+    /** 章鱼图家庭汇集:每个家庭成员一份成效列表(合并成一张图,按成员着色/标注) */
+    @PostMapping("/effect/family/list")
+    public Result<Map<String, Object>> familyListEffects(@RequestAttribute("familyId") Long familyId) {
+        return keyPersonService.getFamilyOctopusEffects(familyId);
+    }
+
     @PostMapping("/effect/add")
     public Result<com.etotem.cfc.entity.OctopusEffectRecord> addEffect(@RequestBody Map<String, Object> params,
                                                                        @RequestAttribute("userId") Long userId) {

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java

@@ -22,6 +22,12 @@ public class PearlController {
         return resourceService.getPearlResources(userId);
     }
 
+    /** 珍珠图家庭汇集:每个家庭成员一份资源 items(合并成一张图,按成员着色/标注) */
+    @PostMapping("/family/resources")
+    public Result<Map<String, Object>> familyResources(@RequestAttribute("familyId") Long familyId) {
+        return resourceService.getFamilyPearlResources(familyId);
+    }
+
     /** 登记珍珠图资源(信息/场所/技能手工登记) */
     @PostMapping("/item/add")
     public Result<ResourceItem> addItem(@RequestBody Map<String, Object> params,

+ 190 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/FamilyMetricAggConfigController.java

@@ -0,0 +1,190 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMetricAggConfig;
+import com.etotem.cfc.mapper.FamilyMetricAggConfigMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.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.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端 - 家庭指标聚合方式配置(平台级全局一份)。
+ * <p>
+ * 配置作用域为平台级(不分家庭),供五维能量汇总 / 珍珠图 / 章鱼图汇集使用。
+ * agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集。
+ */
+@Tag(name = "管理端 - 家庭指标聚合方式配置")
+@RestController
+@RequestMapping("/api/admin/family-metric-agg")
+public class FamilyMetricAggConfigController {
+
+    @Resource
+    private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
+
+    /** 全量配置列表(配置项 < 50,无需分页) */
+    @Operation(summary = "家庭指标聚合方式配置列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        List<FamilyMetricAggConfig> configs = familyMetricAggConfigMapper.selectList(
+                new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                        .orderByAsc(FamilyMetricAggConfig::getSortOrder)
+                        .orderByAsc(FamilyMetricAggConfig::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (FamilyMetricAggConfig c : configs) {
+            result.add(toMap(c));
+        }
+        return Result.success(result);
+    }
+
+    /** 新增/更新配置(body 带 id 则更新,否则新增) */
+    @Operation(summary = "保存家庭指标聚合方式配置")
+    @PostMapping("/save")
+    public Result<Boolean> save(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        String metricKey = str(params.get("metricKey"));
+        String metricName = str(params.get("metricName"));
+        if (metricKey == null || metricKey.trim().isEmpty()) {
+            return Result.error("metricKey不能为空");
+        }
+        if (metricName == null || metricName.trim().isEmpty()) {
+            return Result.error("metricName不能为空");
+        }
+        String aggType = str(params.get("aggType"));
+        String valueType = str(params.get("valueType"));
+        if (aggType == null || aggType.trim().isEmpty()) {
+            return Result.error("aggType不能为空");
+        }
+        if (valueType == null || valueType.trim().isEmpty()) {
+            return Result.error("valueType不能为空");
+        }
+
+        Long id = longVal(params.get("id"));
+        FamilyMetricAggConfig config;
+        if (id != null) {
+            config = familyMetricAggConfigMapper.selectById(id);
+            if (config == null) {
+                return Result.error("配置不存在");
+            }
+        } else {
+            FamilyMetricAggConfig existing = familyMetricAggConfigMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                            .eq(FamilyMetricAggConfig::getMetricKey, metricKey.trim())
+                            .last("LIMIT 1"));
+            if (existing != null) {
+                return Result.error("metricKey已存在,请直接编辑对应记录");
+            }
+            config = new FamilyMetricAggConfig();
+            config.setCreatedAt(new Date());
+        }
+        config.setMetricKey(metricKey.trim());
+        config.setMetricName(metricName.trim());
+        config.setDimensionCode(str(params.get("dimensionCode")));
+        config.setAggType(aggType.trim().toUpperCase());
+        config.setValueType(valueType.trim().toUpperCase());
+        config.setDisplayMode(str(params.get("displayMode")));
+        config.setSortOrder(intVal(params.get("sortOrder"), 0));
+        config.setStatus(str(params.get("status")) != null ? str(params.get("status")).trim() : "active");
+        config.setRemark(str(params.get("remark")));
+        config.setUpdatedAt(new Date());
+
+        if (id != null) {
+            familyMetricAggConfigMapper.updateById(config);
+        } else {
+            familyMetricAggConfigMapper.insert(config);
+        }
+        return Result.success(Boolean.TRUE);
+    }
+
+    /** 删除配置 */
+    @Operation(summary = "删除家庭指标聚合方式配置")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = longVal(params.get("id"));
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        familyMetricAggConfigMapper.deleteById(id);
+        return Result.success(Boolean.TRUE);
+    }
+
+    /** 更新状态(active/disabled) */
+    @Operation(summary = "更新家庭指标聚合方式配置状态")
+    @PostMapping("/update-status")
+    public Result<Boolean> updateStatus(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = longVal(params.get("id"));
+        String status = str(params.get("status"));
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        if (status == null || status.trim().isEmpty()) {
+            return Result.error("status不能为空");
+        }
+        FamilyMetricAggConfig config = familyMetricAggConfigMapper.selectById(id);
+        if (config == null) {
+            return Result.error("配置不存在");
+        }
+        config.setStatus(status.trim());
+        config.setUpdatedAt(new Date());
+        familyMetricAggConfigMapper.updateById(config);
+        return Result.success(Boolean.TRUE);
+    }
+
+    private Map<String, Object> toMap(FamilyMetricAggConfig c) {
+        Map<String, Object> m = new java.util.LinkedHashMap<>();
+        m.put("id", c.getId());
+        m.put("metricKey", c.getMetricKey());
+        m.put("metricName", c.getMetricName());
+        m.put("dimensionCode", c.getDimensionCode());
+        m.put("aggType", c.getAggType());
+        m.put("valueType", c.getValueType());
+        m.put("displayMode", c.getDisplayMode());
+        m.put("sortOrder", c.getSortOrder());
+        m.put("status", c.getStatus());
+        m.put("remark", c.getRemark());
+        return m;
+    }
+
+    private String str(Object o) {
+        return o == null ? null : o.toString();
+    }
+
+    private Long longVal(Object o) {
+        return o == null ? null : Long.valueOf(o.toString());
+    }
+
+    private Integer intVal(Object o, Integer def) {
+        if (o == null) {
+            return def;
+        }
+        try {
+            return Integer.valueOf(o.toString());
+        } catch (NumberFormatException e) {
+            return def;
+        }
+    }
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMetricAggConfig.java

@@ -0,0 +1,55 @@
+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;
+
+/**
+ * 家庭指标聚合方式配置(平台级全局一份,按数据项一行一条)。
+ * <p>
+ * metric_key 唯一标识:energy.body/energy.mind/energy.wisdom/energy.action/energy.wealth/energy.overall/pearl.resources/octopus.effects
+ * agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集
+ * value_type:NUMBER(数值)/ COLLECTION(集合)
+ */
+@Data
+@TableName("family_metric_agg_config")
+public class FamilyMetricAggConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 数据项唯一标识,如 energy.body、pearl.resources */
+    private String metricKey;
+
+    /** 中文名,如「身体维度能量分」 */
+    private String metricName;
+
+    /** 所属维度:body/mind/wisdom/action/wealth/common */
+    private String dimensionCode;
+
+    /** 汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集 */
+    private String aggType;
+
+    /** 值类型:NUMBER(数值)/ COLLECTION(集合) */
+    private String valueType;
+
+    /** 展示形态:number/bar/radar/merge_graph,可空 */
+    private String displayMode;
+
+    /** 排序 */
+    private Integer sortOrder;
+
+    /** 状态:active/disabled */
+    private String status;
+
+    /** 备注 */
+    private String remark;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 110 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -90,6 +90,9 @@ public class EnergyService {
     @Resource
     private GuideMapper guideMapper;
 
+    @Resource
+    private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
+
     /**
      * 计算整个家庭的能量沙盘数据
      *
@@ -154,7 +157,8 @@ public class EnergyService {
 
         dto.setMembers(members);
 
-        // 4. 聚合家庭五维平均值
+        // 4. 聚合家庭五维(按配置 family_metric_agg_config 取 agg_type:AVG=平均 / SUM=合计 / COLLECT=汇集)
+        //    默认 AVG 保证向后兼容;读不到配置时回落 AVG;COLLECT 对数值维度不适用,返回 AVG 并记录日志
         int bodySum = 0, mindSum = 0, wisdomSum = 0, actionSum = 0, wealthSum = 0;
         for (MemberEnergyDTO m : members) {
             bodySum += safeScore(m.getBodyScore());
@@ -164,11 +168,11 @@ public class EnergyService {
             wealthSum += safeScore(m.getWealthScore());
         }
         int count = members.size();
-        dto.setBodyScore(bodySum / count);
-        dto.setMindScore(mindSum / count);
-        dto.setWisdomScore(wisdomSum / count);
-        dto.setActionScore(actionSum / count);
-        dto.setWealthScore(wealthSum / count);
+        dto.setBodyScore(aggregateFamilyMetric("energy.body", bodySum, count));
+        dto.setMindScore(aggregateFamilyMetric("energy.mind", mindSum, count));
+        dto.setWisdomScore(aggregateFamilyMetric("energy.wisdom", wisdomSum, count));
+        dto.setActionScore(aggregateFamilyMetric("energy.action", actionSum, count));
+        dto.setWealthScore(aggregateFamilyMetric("energy.wealth", wealthSum, count));
 
         int overall = (dto.getBodyScore() + dto.getMindScore() + dto.getWisdomScore()
                 + dto.getActionScore() + dto.getWealthScore()) / 5;
@@ -177,6 +181,52 @@ public class EnergyService {
         return dto;
     }
 
+    /**
+     * 按配置聚合家庭数值维度指标。
+     *
+     * <p>依据 {@code family_metric_agg_config} 中 metricKey 对应的 agg_type:
+     * <ul>
+     *   <li>SUM → 合计(返回 sum,不除以 count)</li>
+     *   <li>COLLECT → 数值维度不适用,回落 AVG 并记录日志</li>
+     *   <li>AVG 或读不到配置 → 平均(默认,保持既有行为)</li>
+     * </ul>
+     */
+    private int aggregateFamilyMetric(String metricKey, int sum, int count) {
+        if (count <= 0) {
+            return 0;
+        }
+        String aggType = resolveMetricAggType(metricKey);
+        if ("SUM".equalsIgnoreCase(aggType)) {
+            return sum;
+        }
+        if ("COLLECT".equalsIgnoreCase(aggType)) {
+            log.info("配置项 {} 为 COLLECT 类型,对数值维度不适用,回落 AVG 计算", metricKey);
+        }
+        return sum / count;
+    }
+
+    /**
+     * 读取某数据项的汇总方式(agg_type),读不到配置或配置无效时回落 AVG。
+     */
+    private String resolveMetricAggType(String metricKey) {
+        if (metricKey == null) {
+            return "AVG";
+        }
+        try {
+            FamilyMetricAggConfig config = familyMetricAggConfigMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                            .eq(FamilyMetricAggConfig::getMetricKey, metricKey)
+                            .eq(FamilyMetricAggConfig::getStatus, "active")
+                            .last("LIMIT 1"));
+            if (config != null && config.getAggType() != null && !config.getAggType().trim().isEmpty()) {
+                return config.getAggType().trim().toUpperCase();
+            }
+        } catch (Exception e) {
+            log.warn("读取家庭指标聚合配置失败 metricKey={}: {}", metricKey, e.getMessage());
+        }
+        return "AVG";
+    }
+
     /**
      * 在家庭沙盘中查找指定成员的能量数据。
      * 匹配顺序:memberId+memberType → 仅 memberId → family_members.id 解析到的 userId(家庭主账号)。
@@ -307,6 +357,60 @@ public class EnergyService {
         return result;
     }
 
+    /**
+     * 收集家庭内所有「数据归属主体」列表(供珍珠图/章鱼图家庭汇集接口复用)。
+     * <p>
+     * 与 {@link #doCalculateFamilyEnergy} 的成员收集逻辑一致:
+     * 家庭主账号(userMapper 取 role=parent 的用户)+ 家庭成员(getActualChildren 去重)。
+     * </p>
+     * <p>每项字段:</p>
+     * <ul>
+     *   <li>{@code memberId}:前端归属键(主账号=users.id;成员=family_members.id)</li>
+     *   <li>{@code memberName}:昵称</li>
+     *   <li>{@code memberType}:user=家庭主账号 / member=家庭成员(契约约定)</li>
+     *   <li>{@code colorKey}:稳定色值键(0..n-1 索引,同一成员前后一致)</li>
+     *   <li>{@code dataUserId}:拉取个人数据所用 userId(主账号=userId;成员=family_members.userId,可为 null)</li>
+     * </ul>
+     */
+    public List<Map<String, Object>> collectFamilyMetricMembers(Long familyId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (familyId == null) {
+            return result;
+        }
+        List<User> parents = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+                        .orderByAsc(User::getId));
+        List<FamilyMember> children = getActualChildren(familyId);
+
+        int colorIdx = 0;
+        for (User parent : parents) {
+            if (parent == null || !hasParentRole(parent)) {
+                continue;
+            }
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", parent.getId());
+            m.put("memberName", parent.getNickname() != null ? parent.getNickname() : "成员");
+            m.put("memberType", "user");
+            m.put("colorKey", colorIdx++);
+            m.put("dataUserId", parent.getId());
+            result.add(m);
+        }
+        for (FamilyMember child : children) {
+            if (child == null) {
+                continue;
+            }
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", child.getId());
+            m.put("memberName", child.getNickname() != null ? child.getNickname() : "成员");
+            m.put("memberType", "member");
+            m.put("colorKey", colorIdx++);
+            m.put("dataUserId", child.getUserId());
+            result.add(m);
+        }
+        return result;
+    }
+
     // ==================== 家庭主账号能量计算 ====================
 
     private MemberEnergyDTO calcParentEnergy(User parent) {

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -61,6 +61,40 @@ public class KeyPersonService {
         return Result.success(data);
     }
 
+    /**
+     * 章鱼图家庭汇集:每个家庭成员一份成效列表(合并成一张图,按成员着色/标注)。
+     * 契约:data = { members: [ { memberId, memberName, memberType, colorKey, effects: [原章鱼成效项结构] } ] }
+     * 复用 {@link #listEffects} 的个人成效查询逻辑(含自动计算标签),不重写业务规则。
+     */
+    public Result<Map<String, Object>> getFamilyOctopusEffects(Long familyId) {
+        List<Map<String, Object>> members = new ArrayList<>();
+        List<Map<String, Object>> familyMembers = energyService.collectFamilyMetricMembers(familyId);
+        for (Map<String, Object> member : familyMembers) {
+            Map<String, Object> memberEntry = new LinkedHashMap<>();
+            memberEntry.put("memberId", member.get("memberId"));
+            memberEntry.put("memberName", member.get("memberName"));
+            memberEntry.put("memberType", member.get("memberType"));
+            memberEntry.put("colorKey", member.get("colorKey"));
+            Object dataUserIdObj = member.get("dataUserId");
+            Long dataUserId = dataUserIdObj != null ? Long.valueOf(dataUserIdObj.toString()) : null;
+            memberEntry.put("effects", dataUserId != null ? fetchEffectList(dataUserId) : new ArrayList<>());
+            members.add(memberEntry);
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("members", members);
+        return Result.success(data);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> fetchEffectList(Long userId) {
+        Result<Map<String, Object>> result = listEffects(userId, null, 1, Integer.MAX_VALUE);
+        if (result == null || result.getData() == null) {
+            return new ArrayList<>();
+        }
+        Object listObj = result.getData().get("list");
+        return listObj instanceof List ? (List<Map<String, Object>>) listObj : new ArrayList<>();
+    }
+
     private Map<String, Object> toEffectVO(OctopusEffectRecord r, List<String> computedTypes) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", r.getId());

+ 78 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java

@@ -48,6 +48,9 @@ public class ResourceService {
     @Resource
     private PearlInteractionReminderMapper reminderMapper;
 
+    @Resource
+    private EnergyService energyService;
+
     /** 25 类连接类型 → 前端选择器/渲染配置 */
     public List<Map<String, Object>> getConnectionTypes() {
         List<Map<String, Object>> list = new ArrayList<>();
@@ -64,6 +67,80 @@ public class ResourceService {
 
     /** 珍珠图聚合:25 类社会连接 + 历史资源(legacy PERSON/SKILL/INFO/PLACE) */
     public Result<Map<String, Object>> getPearlResources(Long userId) {
+        return Result.success(buildPearlData(userId));
+    }
+
+    /**
+     * 珍珠图家庭汇集:每个家庭成员一份珍珠资源 items(合并成一张图,按成员着色/标注)。
+     * 契约:data = { members: [ { memberId, memberName, memberType, colorKey, items: [原珍珠资源项结构] } ] }
+     */
+    public Result<Map<String, Object>> getFamilyPearlResources(Long familyId) {
+        List<Map<String, Object>> members = new ArrayList<>();
+        List<Map<String, Object>> familyMembers = energyService.collectFamilyMetricMembers(familyId);
+        for (Map<String, Object> member : familyMembers) {
+            Map<String, Object> memberEntry = new LinkedHashMap<>();
+            memberEntry.put("memberId", member.get("memberId"));
+            memberEntry.put("memberName", member.get("memberName"));
+            memberEntry.put("memberType", member.get("memberType"));
+            memberEntry.put("colorKey", member.get("colorKey"));
+            Object dataUserIdObj = member.get("dataUserId");
+            Long dataUserId = dataUserIdObj != null ? Long.valueOf(dataUserIdObj.toString()) : null;
+            memberEntry.put("items", dataUserId != null ? flattenPearlItems(buildPearlData(dataUserId)) : new ArrayList<>());
+            members.add(memberEntry);
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("members", members);
+        return Result.success(data);
+    }
+
+    /** 将珍珠图 groups + legacyGroups 展平为单层 items(带 groupType/groupTypeName/ring 归属,供合并图) */
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> flattenPearlItems(Map<String, Object> pearlData) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (pearlData == null) {
+            return items;
+        }
+        Object groupsObj = pearlData.get("groups");
+        if (groupsObj instanceof List) {
+            for (Object g : (List<Object>) groupsObj) {
+                if (!(g instanceof Map)) continue;
+                Map<String, Object> group = (Map<String, Object>) g;
+                Object groupItems = group.get("items");
+                if (groupItems instanceof List) {
+                    for (Object it : (List<Object>) groupItems) {
+                        if (!(it instanceof Map)) continue;
+                        Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) it);
+                        item.put("groupType", group.get("type"));
+                        item.put("groupTypeName", group.get("typeName"));
+                        item.put("ring", group.get("ring"));
+                        items.add(item);
+                    }
+                }
+            }
+        }
+        Object legacyObj = pearlData.get("legacyGroups");
+        if (legacyObj instanceof List) {
+            for (Object g : (List<Object>) legacyObj) {
+                if (!(g instanceof Map)) continue;
+                Map<String, Object> group = (Map<String, Object>) g;
+                Object groupItems = group.get("items");
+                if (groupItems instanceof List) {
+                    for (Object it : (List<Object>) groupItems) {
+                        if (!(it instanceof Map)) continue;
+                        Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) it);
+                        item.put("groupType", group.get("type"));
+                        item.put("groupTypeName", group.get("typeName"));
+                        item.put("ring", "inner");
+                        items.add(item);
+                    }
+                }
+            }
+        }
+        return items;
+    }
+
+    /** 构建珍珠图原始数据(groups + legacyGroups) */
+    private Map<String, Object> buildPearlData(Long userId) {
         // 历史资源:legacy 分组的 contacts(人脉)与 skillLogs(技能)照旧
         List<Contact> contacts = contactMapper.selectList(
                 new LambdaQueryWrapper<Contact>().eq(Contact::getUserId, userId)
@@ -151,7 +228,7 @@ public class ResourceService {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("groups", groups);
         result.put("legacyGroups", legacyGroups);
-        return Result.success(result);
+        return result;
     }
 
     /** 登记资源(珍珠图:25 类社会连接,connectionType 必填) */

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

@@ -6973,3 +6973,22 @@ CREATE TABLE IF NOT EXISTS indicator_favorite (
     KEY idx_member_source (member_id, source_type),
     KEY idx_user_created (user_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏';
+
+-- 家庭指标聚合方式配置表(平台级全局一份,按数据项一行一条)
+-- agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集
+-- value_type:NUMBER(数值)/ COLLECTION(集合)
+CREATE TABLE IF NOT EXISTS family_metric_agg_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    metric_key VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources',
+    metric_name VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」',
+    dimension_code VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common',
+    agg_type VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集',
+    value_type VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)',
+    display_mode VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    status VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled',
+    remark VARCHAR(255) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_metric_key (metric_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置';

+ 162 - 0
cfc-frontend/components/ArticleFeed.vue

@@ -0,0 +1,162 @@
+<template>
+  <view class="af-section">
+    <!-- 空态 -->
+    <view v-if="(!articles || articles.length === 0) && !loading" class="af-empty">
+      <text class="af-empty-text">暂无推荐文章</text>
+    </view>
+
+    <!-- 加载态骨架:3 条左图右文卡片 -->
+    <view v-else-if="loading && (!articles || articles.length === 0)" class="af-list">
+      <view v-for="n in 3" :key="n" class="af-item">
+        <view class="af-cover af-skeleton-block"></view>
+        <view class="af-body">
+          <view class="af-skeleton-line af-skeleton-line-title"></view>
+          <view class="af-skeleton-line"></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 左图右文列表(与文章详情「相关文章」视觉一致) -->
+    <view v-else class="af-list">
+      <view
+        v-for="item in articles"
+        :key="item.id"
+        class="af-item"
+        hover-class="af-item-pressed"
+        @click="onArticleClick(item)"
+      >
+        <image class="af-cover" :src="articleCover(item)" mode="aspectFill" lazy-load />
+        <view class="af-body">
+          <text class="af-title">{{ item.title }}</text>
+          <text class="af-summary" v-if="item.summary">{{ item.summary }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+/**
+ * ArticleFeed — 文章「关联文章」同款左图右文列表,用于首页/成长页「推荐阅读」统一视觉。
+ * 纯展示组件:数据由父页面传入,点击通过 articleClick 事件上抛文章对象。
+ */
+export default {
+  name: 'ArticleFeed',
+  props: {
+    articles: { type: Array, default: function() { return [] } },
+    loading: { type: Boolean, default: false }
+  },
+  methods: {
+    onArticleClick: function(item) {
+      this.$emit('articleClick', item)
+    },
+    // 封面图 URL 解析:空 / null / undefined / '/null' → 默认图(与 RelatedContent 同口径,增补 '/null' 兜底)
+    articleCover: function(item) {
+      if (!item || !item.coverImage) return '/static/default-article.png'
+      return this.resolveUrl(item.coverImage) || '/static/default-article.png'
+    },
+    resolveUrl: function(path) {
+      var s = String(path || '').trim()
+      if (!s || s === 'null' || s === 'undefined' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0 || s.indexOf('/static/') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.af-section {
+  background: $app-surface;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+
+/* ---- 空态 ---- */
+.af-empty {
+  display: flex;
+  justify-content: center;
+  padding: 40rpx 0;
+}
+.af-empty-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-gray-400;
+}
+
+/* ---- 列表 ---- */
+.af-list {
+  display: flex;
+  flex-direction: column;
+}
+.af-item {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  padding: 14rpx 0;
+  border-radius: 16rpx;
+}
+.af-item-pressed {
+  background: $facade-fill-gray-100;
+}
+.af-cover {
+  width: 160rpx;
+  height: 120rpx;
+  border-radius: 16rpx;
+  background: $facade-fill-gray-100;
+  flex-shrink: 0;
+}
+.af-body {
+  flex: 1;
+  min-width: 0;
+  margin-left: 16rpx;
+  display: flex;
+  flex-direction: column;
+}
+.af-title {
+  font-size: calc(27rpx * var(--font-scale, 1));
+  font-weight: 500;
+  color: $facade-text-gray-800;
+  line-height: 1.4;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+.af-summary {
+  font-size: calc(23rpx * var(--font-scale, 1));
+  color: $facade-text-gray-400;
+  line-height: 1.4;
+  margin-top: 6rpx;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+
+/* ---- 骨架屏(3 条左图右文占位) ---- */
+.af-skeleton-block {
+  background: linear-gradient(180deg, $facade-fill-4 0%, $facade-fill 50%, $facade-fill-4 100%);
+  background-size: 200% 100%;
+  animation: af-skeleton-wave 1.4s ease-in-out infinite;
+}
+.af-skeleton-line {
+  width: 100%;
+  height: 28rpx;
+  border-radius: 8rpx;
+  margin-top: 10rpx;
+  background: linear-gradient(180deg, $facade-fill-4 0%, $facade-fill 50%, $facade-fill-4 100%);
+  background-size: 200% 100%;
+  animation: af-skeleton-wave 1.4s ease-in-out infinite;
+}
+.af-skeleton-line-title {
+  width: 72%;
+  height: 32rpx;
+  margin-top: 0;
+}
+@keyframes af-skeleton-wave {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+</style>

+ 84 - 10
cfc-frontend/pages/action-detail/components/OctopusDiagram.vue

@@ -1,6 +1,6 @@
 <template>
   <!-- 章鱼图 - 关键人拓展图谱 -->
-  <view class="octopus-wrapper" v-if="effects && effects.list && effects.list.length > 0" :style="{ '--font-scale': fontScale, height: canvasHeight + 'px' }">
+  <view class="octopus-wrapper" v-if="effects && effects.list && effects.list.length > 0" :style="{ '--font-scale': fontScale, minHeight: canvasHeight + 'px' }">
     <canvas
       class="octopus-canvas"
       canvas-id="octopusCanvas"
@@ -15,6 +15,7 @@
       <view class="popup-content">
         <view class="popup-header">
           <text class="popup-name">{{ selectedEffect.name }}</text>
+          <text class="popup-member" v-if="familyMode && selectedEffect.memberName">{{ selectedEffect.memberName }}</text>
           <text class="popup-tags">
             <text class="popup-tag" v-for="t in selectedEffect.effectTypeList" :key="t">{{ t }}</text>
           </text>
@@ -27,24 +28,31 @@
           </view>
         </view>
         <view class="popup-empty" v-else>
-          <text class="empty-text">暂无关键人,点击添加</text>
+          <text class="empty-text">{{ familyMode ? '暂无关键人' : '暂无关键人,点击添加' }}</text>
         </view>
-        <view class="popup-actions">
+        <view class="popup-actions" v-if="!familyMode">
           <text class="action-btn action-btn-primary" @tap="onAddKeyPerson(selectedEffect)">添加关键人</text>
         </view>
       </view>
     </view>
-    <!-- 重新分析按钮 -->
-    <view class="reanalyze-btn" @tap="$emit('reanalyze')">
+    <!-- 重新分析按钮(家庭汇集只读,隐藏个人操作) -->
+    <view class="reanalyze-btn" @tap="$emit('reanalyze')" v-if="!familyMode">
       <text>🔄 重新分析</text>
     </view>
+    <!-- 家庭汇集:成员图例 -->
+    <view class="octopus-member-legend" v-if="familyMode && memberLegend.length > 0" :style="{ marginTop: canvasHeight + 'px' }">
+      <view class="legend-item" v-for="m in memberLegend" :key="m.memberId">
+        <view class="legend-dot" :style="{ background: memberColorFor(m.colorKey) }"></view>
+        <text class="legend-label">{{ m.name }}</text>
+      </view>
+    </view>
   </view>
   <!-- 空状态 -->
   <view class="octopus-empty" v-else-if="effects && effects.list && effects.list.length === 0">
     <text class="empty-icon">🧭</text>
-    <text class="empty-text">暂无感恩记录</text>
-      <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
-    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 感恩记录</text>
+    <text class="empty-text">{{ familyMode ? '暂无家庭感恩记录' : '暂无感恩记录' }}</text>
+    <text class="empty-hint">{{ familyMode ? '家庭成员暂未记录成效' : '记录你的感恩时刻,画出章鱼图' }}</text>
+    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')" v-if="!familyMode">+ 感恩记录</text>
   </view>
   <!-- 加载状态 -->
   <view class="octopus-loading" v-else>
@@ -54,6 +62,17 @@
 
 <script>
 import { BrandColors } from '../../../utils/brand-colors.js'
+// 家庭汇集成员色板(8 色循环,稳定):引用 BrandColors 令牌镜像(SCSS 侧等值令牌见 uni.scss),禁止硬编码 hex
+var FAMILY_MEMBER_COLORS = [
+  BrandColors.success,   // emerald($status-success / $dim-action)
+  BrandColors.info,      // sky($status-info)
+  BrandColors.wisdom,    // indigo($dim-wisdom)
+  BrandColors.wealth,    // amber($dim-wealth)
+  BrandColors.primary,   // orange($brand-primary)
+  BrandColors.mind,      // pink($dim-mind)
+  BrandColors.danger,    // red($status-danger)
+  BrandColors.violet     // violet-500($facade-violet)
+]
 /**
  * OctopusDiagram - 章鱼图(关键人拓展)
  *
@@ -72,7 +91,11 @@ export default {
     expansions: { type: Array, default: function() { return [] } },
     canvasWidth: { type: Number, default: 340 },
     canvasHeight: { type: Number, default: 420 },
-    interactive: { type: Boolean, default: true }
+    interactive: { type: Boolean, default: true },
+    /** 家庭汇集模式:节点按成员 colorKey 着色,隐藏个人操作(只读合并展示) */
+    familyMode: { type: Boolean, default: false },
+    /** 家庭汇集成员图例 [{ name, colorKey }] */
+    memberLegend: { type: Array, default: function() { return [] } }
   },
   data: function() {
     return {
@@ -174,7 +197,10 @@ export default {
           effectType: effects[i].effectType,
           effectTypeList: effects[i].effectTypeList || [],
           effectAmount: effects[i].effectAmount,
-          color: this._getTypeColor(effects[i].effectType)
+          // 家庭汇集:节点按成员着色,并携带成员标注
+          color: this.familyMode ? this._familyMemberColor(effects[i].colorKey || '') : this._getTypeColor(effects[i].effectType),
+          memberId: effects[i].memberId || null,
+          memberName: effects[i].memberName || ''
         })
       }
     },
@@ -533,6 +559,21 @@ export default {
       if (list.length === 0) return BrandColors.slate500
       return this.effectColors[list[0]] || BrandColors.slate500
     },
+
+    /** 家庭汇集成员色:colorKey 稳定哈希取模映射到 8 色板 */
+    memberColorFor: function(colorKey) {
+      if (!colorKey || typeof colorKey !== 'string') return FAMILY_MEMBER_COLORS[0]
+      var hash = 0
+      for (var i = 0; i < colorKey.length; i++) {
+        hash = (hash * 31 + colorKey.charCodeAt(i)) >>> 0
+      }
+      return FAMILY_MEMBER_COLORS[hash % FAMILY_MEMBER_COLORS.length]
+    },
+
+    /** 家庭汇集成员色(canvas 用,同 memberColorFor) */
+    _familyMemberColor: function(colorKey) {
+      return this.memberColorFor(colorKey)
+    },
     formatDate: function(dateStr) {
       if (!dateStr) return ''
       return String(dateStr).substring(0, 10)
@@ -558,6 +599,34 @@ export default {
   left: 0;
   z-index: 1;
 }
+
+/* 家庭汇集:成员图例(canvas 下方,按成员着色) */
+.octopus-member-legend {
+  position: relative;
+  z-index: 2;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8rpx 20rpx;
+  padding: 20rpx 8rpx 8rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8rpx;
+}
+.legend-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.legend-label {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
 .octopus-empty, .octopus-loading {
   display: flex;
   flex-direction: column;
@@ -636,6 +705,11 @@ export default {
   font-weight: 600;
   color: $uni-text-color;
 }
+.popup-member {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-slate;
+  margin-top: 2rpx;
+}
 .popup-tags {
   display: flex;
   flex-wrap: wrap;

+ 88 - 6
cfc-frontend/pages/action-detail/components/PearlDiagram.vue

@@ -17,6 +17,7 @@
           <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
           <view class="popup-info">
             <text class="popup-name">{{ selectedNode.name }}</text>
+            <text class="popup-member" v-if="familyMode && selectedNode.memberName">{{ selectedNode.memberName }}</text>
             <view class="popup-tags">
               <text class="popup-tag" :style="{ background: selectedNode.color, color: BrandColors.white }">{{ selectedNode.groupTypeName }}</text>
               <text class="popup-tag popup-tag-rel" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
@@ -34,8 +35,8 @@
         <view class="popup-desc" v-if="selectedNode.description">
           <text class="popup-desc-text">{{ selectedNode.description }}</text>
         </view>
-        <!-- 操作按钮 -->
-        <view class="popup-actions">
+        <!-- 操作按钮(家庭汇集视图为只读合并展示,隐藏个人操作) -->
+        <view class="popup-actions" v-if="!familyMode">
           <text class="action-btn action-btn-contact" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onContact">联系</text>
           <text class="action-btn action-btn-priority" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onAdjustPriority">优先级</text>
           <text class="action-btn action-btn-delete" v-if="canDelete" @tap="onDelete">删除</text>
@@ -43,13 +44,20 @@
         <view class="popup-arrow" :class="{ 'popup-arrow-down': showPopupBelow }"></view>
       </view>
     </view>
+    <!-- 家庭汇集:成员图例 -->
+    <view class="pearl-member-legend" v-if="familyMode && memberLegend.length > 0" :style="{ marginTop: canvasHeight + 'px' }">
+      <view class="legend-item" v-for="m in memberLegend" :key="m.memberId">
+        <view class="legend-dot" :style="{ background: memberColorFor(m.colorKey) }"></view>
+        <text class="legend-label">{{ m.name }}</text>
+      </view>
+    </view>
   </view>
   <!-- 空状态 -->
   <view class="pearl-graph-empty" v-else-if="isEmpty">
     <text class="empty-icon">📿</text>
-    <text class="empty-text">暂无珍珠图数据</text>
-    <text class="empty-hint">登记你的社会连接,画出珍珠项链图</text>
-    <text class="action-btn action-btn-primary" @tap="onAdd">+ 登记资源</text>
+    <text class="empty-text">{{ familyMode ? '暂无家庭珍珠图数据' : '暂无珍珠图数据' }}</text>
+    <text class="empty-hint">{{ familyMode ? '家庭成员暂未登记社会连接资源' : '登记你的社会连接,画出珍珠项链图' }}</text>
+    <text class="action-btn action-btn-primary" @tap="onAdd" v-if="!familyMode">+ 登记资源</text>
   </view>
   <!-- 加载状态 -->
   <view class="pearl-loading" v-else>
@@ -61,6 +69,18 @@
 import { addPearlInteraction, updatePearlPriority } from '@/utils/api.js'
 import { BrandColors, INDUSTRY_COLORS, danger, primary, success, white } from '@/utils/brand-colors.js'
 
+// 家庭汇集成员色板(8 色循环,稳定):引用 BrandColors 令牌镜像(SCSS 侧等值令牌见 uni.scss),禁止硬编码 hex
+var FAMILY_MEMBER_COLORS = [
+  BrandColors.success,   // emerald($status-success / $dim-action)
+  BrandColors.info,      // sky($status-info)
+  BrandColors.wisdom,    // indigo($dim-wisdom)
+  BrandColors.wealth,    // amber($dim-wealth)
+  BrandColors.primary,   // orange($brand-primary)
+  BrandColors.mind,      // pink($dim-mind)
+  BrandColors.danger,    // red($status-danger)
+  BrandColors.violet     // violet-500($facade-violet)
+]
+
 /**
  * PearlDiagram - 珍珠图 Canvas 组件
  *
@@ -108,6 +128,16 @@ export default {
     overdueMap: {
       type: Object,
       default: null
+    },
+    /** 家庭汇集模式:节点按成员 colorKey 着色,隐藏个人操作(只读合并展示) */
+    familyMode: {
+      type: Boolean,
+      default: false
+    },
+    /** 家庭汇集成员图例 [{ name, colorKey }] */
+    memberLegend: {
+      type: Array,
+      default: function() { return [] }
     }
   },
   data: function() {
@@ -182,6 +212,7 @@ export default {
     },
     /** 选中节点是否可删除(仅 INFO/PLACE 为手工登记资源;PERSON 来自联系人、SKILL 来自帮助记录聚合,均不可删) */
     canDelete: function() {
+      if (this.familyMode) return false
       if (!this.selectedNode) return false
       return this.selectedNode.groupType === 'INFO' || this.selectedNode.groupType === 'PLACE'
     },
@@ -356,7 +387,10 @@ export default {
               valueScore: item.valueScore || 0,
               daysSinceLast: item.daysSinceLast || null,
               radius: itemRadius,
-              color: config.color,
+              // 家庭汇集:节点按成员着色,并携带成员标注
+              color: self.familyMode ? self._familyMemberColor(item.colorKey || '') : config.color,
+              memberId: item.memberId || null,
+              memberName: item.memberName || '',
               ring: config.ring,
               x: cx + R * Math.cos(angle),
               y: cy + R * Math.sin(angle)
@@ -850,6 +884,21 @@ export default {
       var g = parseInt(hex.slice(3, 5), 16)
       var b = parseInt(hex.slice(5, 7), 16)
       return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
+    },
+
+    /** 家庭汇集成员色:colorKey 稳定哈希取模映射到 8 色板 */
+    memberColorFor: function(colorKey) {
+      if (!colorKey || typeof colorKey !== 'string') return FAMILY_MEMBER_COLORS[0]
+      var hash = 0
+      for (var i = 0; i < colorKey.length; i++) {
+        hash = (hash * 31 + colorKey.charCodeAt(i)) >>> 0
+      }
+      return FAMILY_MEMBER_COLORS[hash % FAMILY_MEMBER_COLORS.length]
+    },
+
+    /** 家庭汇集成员色(canvas 用,同 memberColorFor) */
+    _familyMemberColor: function(colorKey) {
+      return this.memberColorFor(colorKey)
     }
   }
 }
@@ -872,6 +921,34 @@ export default {
   z-index: 1;
 }
 
+/* 家庭汇集:成员图例(canvas 下方,按成员着色) */
+.pearl-member-legend {
+  position: relative;
+  z-index: 2;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8rpx 20rpx;
+  padding: 20rpx 8rpx 8rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8rpx;
+}
+.legend-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.legend-label {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
 /* 详情弹窗 */
 .pearl-detail-popup {
   position: absolute;
@@ -913,6 +990,11 @@ export default {
   font-weight: 600;
   color: $uni-text-color;
 }
+.popup-member {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-slate;
+  margin-top: 2rpx;
+}
 .popup-tags {
   display: flex;
   flex-wrap: wrap;

+ 135 - 10
cfc-frontend/pages/action-detail/index.vue

@@ -130,14 +130,16 @@
             <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
           </view>
         </view>
-        <text class="section-link section-link-octopus" @tap="goOctopusEffectsList">+ 感恩记录</text>
+        <text class="section-link section-link-octopus" @tap="goOctopusEffectsList" v-if="currentView === 'self'">+ 感恩记录</text>
       </view>
       <OctopusDiagram
         ref="octopusDiagram"
         :selfId="selfId"
-        :effects="octopusEffects"
-        :expansions="octopusExpansions"
+        :effects="octopusDiagramData"
+        :expansions="currentView === 'family' ? [] : octopusExpansions"
         :interactive="true"
+        :family-mode="currentView === 'family'"
+        :member-legend="familyOctopusLegend"
         @effect-click="onEffectClick"
         @expansion-click="onExpansionClick"
         @add-effect="goOctopusAddEffect"
@@ -154,14 +156,16 @@
             <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
           </view>
         </view>
-        <text class="section-link section-link-pearl" @tap="goPearlAddResource">+ 登记</text>
+        <text class="section-link section-link-pearl" @tap="goPearlAddResource" v-if="currentView === 'self'">+ 登记</text>
       </view>
       <PearlDiagram
         ref="pearlDiagram"
         :selfId="selfId"
-        :resources="pearlData"
-        :overdue-map="overdueMap"
+        :resources="pearlDiagramData"
+        :overdue-map="currentView === 'family' ? null : overdueMap"
         :interactive="true"
+        :family-mode="currentView === 'family'"
+        :member-legend="familyPearlLegend"
         @add="goPearlAddResource"
         @delete="onDeletePearlResource" />
     </view>
@@ -275,7 +279,7 @@ import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from './components/DimensionSubDims.vue'
 import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap, getRadarData, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap, getRadarData, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons, getFamilyPearlResources, getFamilyOctopusEffects } from '../../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -286,7 +290,7 @@ export default {
       gridColor: BrandColors.emerald100,
       labelColor: BrandColors.emerald800,
       successColor: BrandColors.success,
-    pageLoading: false,
+      pageLoading: false,
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
@@ -298,7 +302,7 @@ export default {
       familyMembersVisible: [],
       childrenChecked: false,
       membersChecked: false,
-            actionSubDims: [],
+      actionSubDims: [],
       sandboxData: null,
       actionPopulationScoreMap: {},
       currentView: 'self', // 'self' | 'family'
@@ -311,6 +315,10 @@ export default {
       octopusExpansions: [],
       octopusKeyPersons: {},
       overdueMap: null,
+      // 家庭汇集数据
+      familyPearlData: null,
+      familyOctopusEffects: null,
+      familyLoading: false,
       // 帮助弹窗
       helpPopup: { visible: false, title: '', content: '' },
       funcList: [
@@ -529,6 +537,101 @@ export default {
         result.tip = '建议寻求专业家庭辅导,或从简单的家庭对话开始改善'
       }
       return result
+    },
+
+    /** 当前是否处于「全家」汇集视图 */
+    isFamilyView: function() {
+      return this.currentView === 'family'
+    },
+
+    /** 珍珠图数据源:family 视图取家庭汇集(members 合并展开),否则取个人数据 */
+    pearlDiagramData: function() {
+      if (this.currentView === 'family') {
+        return this.familyPearlDiagram
+      }
+      return this.pearlData
+    },
+
+    /** 章鱼图数据源:family 视图取家庭汇集(members 合并展开),否则取个人数据 */
+    octopusDiagramData: function() {
+      if (this.currentView === 'family') {
+        return this.familyOctopusDiagram
+      }
+      return this.octopusEffects
+    },
+
+    /** 家庭珍珠图:将 members[].items 合并展开为 PearlDiagram 可消费结构,每项带成员标注 */
+    familyPearlDiagram: function() {
+      if (!this.familyPearlData) return null
+      var members = this.familyPearlData.members
+      if (!members || !Array.isArray(members)) return null
+      var groupMap = {}
+      var groupOrder = []
+      for (var i = 0; i < members.length; i++) {
+        var m = members[i]
+        if (!m || !Array.isArray(m.items)) continue
+        for (var j = 0; j < m.items.length; j++) {
+          var item = m.items[j]
+          if (!item) continue
+          // 归属组:优先按连接类型/资源类型字段归组,缺失则归入 INFO(历史资源)
+          var type = item.connectionType || item.type || item.groupType || 'INFO'
+          var stamped = Object.assign({}, item, {
+            memberId: m.memberId,
+            memberName: m.memberName || '',
+            memberType: m.memberType || '',
+            colorKey: m.colorKey || ''
+          })
+          if (!groupMap[type]) {
+            groupMap[type] = { type: type, typeName: item.groupTypeName || item.typeName || type, items: [] }
+            groupOrder.push(type)
+          }
+          groupMap[type].items.push(stamped)
+        }
+      }
+      var groups = []
+      for (var k = 0; k < groupOrder.length; k++) {
+        groups.push(groupMap[groupOrder[k]])
+      }
+      return { groups: groups, legacyGroups: [] }
+    },
+
+    /** 家庭章鱼图:将 members[].effects 合并展开为 OctopusDiagram 可消费结构,每项带成员标注 */
+    familyOctopusDiagram: function() {
+      if (!this.familyOctopusEffects) return null
+      var members = this.familyOctopusEffects.members
+      if (!members || !Array.isArray(members)) return null
+      var list = []
+      for (var i = 0; i < members.length; i++) {
+        var m = members[i]
+        if (!m || !Array.isArray(m.effects)) continue
+        for (var j = 0; j < m.effects.length; j++) {
+          var effect = m.effects[j]
+          if (!effect) continue
+          list.push(Object.assign({}, effect, {
+            memberId: m.memberId,
+            memberName: m.memberName || '',
+            memberType: m.memberType || '',
+            colorKey: m.colorKey || ''
+          }))
+        }
+      }
+      return { list: list }
+    },
+
+    /** 珍珠图成员图例(家庭视图) */
+    familyPearlLegend: function() {
+      if (!this.familyPearlData || !this.familyPearlData.members) return []
+      return this.familyPearlData.members.map(function(m) {
+        return { memberId: m.memberId, name: m.memberName || '成员', colorKey: m.colorKey || '', memberType: m.memberType || '' }
+      })
+    },
+
+    /** 章鱼图成员图例(家庭视图) */
+    familyOctopusLegend: function() {
+      if (!this.familyOctopusEffects || !this.familyOctopusEffects.members) return []
+      return this.familyOctopusEffects.members.map(function(m) {
+        return { memberId: m.memberId, name: m.memberName || '成员', colorKey: m.colorKey || '', memberType: m.memberType || '' }
+      })
     }
   },
   created() {
@@ -650,18 +753,40 @@ export default {
     },
     switchView: function(view) {
       this.currentView = view
+      var self = this
       if (view === 'family') {
         this.selectedMemberId = null
-        var self = this
+        // 全家视图:同时加载家庭汇集珍珠图/章鱼图(只读合并展示)
+        this.loadFamilyCollectData()
         getEnergySandbox('family').then(function(res) {
           if (res && res.data && !res.data.locked) {
             self.sandboxData = res.data.familyData || res.data
           }
         }).catch(function(e) { console.log('获取家庭数据失败', e) })
       } else {
+        // 切回自己视图:恢复个人珍珠图/章鱼图数据
         this.loadSandboxData()
+        this.loadPearlResources()
+        this.loadOctopusData()
       }
     },
+    /** 加载家庭汇集珍珠图/章鱼图数据(Promise.all 并发,任一失败不白屏) */
+    loadFamilyCollectData: function() {
+      var self = this
+      this.familyLoading = true
+      Promise.all([getFamilyPearlResources(), getFamilyOctopusEffects()]).then(function(resArr) {
+        var pearlRes = resArr[0]
+        var octopusRes = resArr[1]
+        self.familyPearlData = (pearlRes && pearlRes.code === 200 && pearlRes.data) ? pearlRes.data : null
+        self.familyOctopusEffects = (octopusRes && octopusRes.code === 200 && octopusRes.data) ? octopusRes.data : null
+        self.familyLoading = false
+      }).catch(function(e) {
+        console.log('获取家庭汇集数据失败', e)
+        self.familyPearlData = null
+        self.familyOctopusEffects = null
+        self.familyLoading = false
+      })
+    },
     loadEnergyData: function() {
       var self = this
       getEnergyOverview(this.activeChildId).then(function(res) {

+ 4 - 4
cfc-frontend/pages/growth-main/index.vue

@@ -251,7 +251,7 @@
           <text class="section-title">📖 推荐阅读</text>
           <text class="section-more" @click="goMoreArticles">更多 ›</text>
         </view>
-        <ArticleBookshelf :articles="recommendArticles" :isLoggedIn="true" @articleClick="goToArticle" />
+        <ArticleFeed :articles="recommendArticles" @articleClick="goToArticle" />
       </view>
 
       <!-- 推荐商品(放最底部) -->
@@ -260,7 +260,7 @@
           <text class="section-title">🛍️ 推荐商品</text>
           <text class="section-more" @click="goMoreProducts">更多 ›</text>
         </view>
-        <DimensionProducts :isLoggedIn="true" :showMore="false" @productClick="goToProduct" />
+        <DimensionProducts :isLoggedIn="true" :showMore="false" layout="mall" @productClick="goToProduct" />
       </view>
 
       <view class="bottom-spacer" style="height: 40rpx;"></view>
@@ -272,14 +272,14 @@
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
-import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
+import ArticleFeed from '../../components/ArticleFeed.vue'
 import { healthCheckinList, getHealthMealList, getGrowthRecommendations, getExerciseRecords, getSleepRecords, getEmotionCheckinList, getCheckinList, getTodayTasks, reportAppEnter } from '../../utils/api.js'
 import { parseDate, recordDateOf } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import shareMixin from '../../components/share-mixin.js'
 export default {
   mixins: [fontScaleMixin, shareMixin],
-  components: { LoginGuideCard, DimensionActivities, DimensionProducts, ArticleBookshelf },
+  components: { LoginGuideCard, DimensionActivities, DimensionProducts, ArticleFeed },
   data() { return { memberId: '', todayDate: '', hasIncomplete: false, incompleteCount: 0, progressItems: [], recentTasks: [], todayTaskList: [], streakDays: 0, activeChallenges: 0, completeness: 0,
       healthArticles: [],
       growthArticles: [],

+ 3 - 4
cfc-frontend/pages/index-home/index.vue

@@ -241,9 +241,8 @@
         <text class="section-title">📖 推荐阅读</text>
         <text class="section-more" @click="goMoreArticles">更多 ›</text>
       </view>
-      <ArticleBookshelf
+      <ArticleFeed
         :articles="actionArticles"
-        :isLoggedIn="true"
         @articleClick="goArticleDetail" />
     </view>
 
@@ -461,7 +460,7 @@ import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import GateLock from '../../components/gate-lock.vue'
 import FamilyFeed from '../../components/FamilyFeed.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
-import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
+import ArticleFeed from '../../components/ArticleFeed.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
@@ -483,7 +482,7 @@ components: {
     GateLock,
     FamilyFeed,
     DimensionProducts,
-    ArticleBookshelf,
+    ArticleFeed,
     FamilyChallengeCard,
     DailyTaskCard,
     ReportUploadCard,

+ 3 - 0
cfc-frontend/uni.scss

@@ -395,6 +395,9 @@ $uni-transition-slow: 0.3s ease;
 $brand-primary: #F97316;
 $brand-primary-dark: #EA580C;
 $brand-primary-soft: rgba(249, 115, 22, 0.12);
+// 次级品牌色:认证中心等页面渐变第二色(W3 提交 3366cff22 遗漏定义,此处补齐,
+// 与 brand-primary-dark 同值,保证深橙渐变背景上白字可读)
+$brand-secondary: #EA580C;
 
 // 五维身份色:页面 PageBanner / 分段左条 / CTA / 价值卡浅底统一取用
 // 身 = 赤陶/土调 #C2703D(2026-10-27 设计稿定稿,替代旧 #FF8C42 亮橙;C2 统一土系)

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

@@ -2532,6 +2532,9 @@ export const getPearlReminders = () => request('/api/pearl/reminder/list', 'POST
 export const updatePearlPriority = (data) => request('/api/pearl/item/update-priority', 'POST', data)
 export const getAbilityMap = () => request('/api/ability/map', 'POST')
 
+// 家庭汇集:珍珠图
+export const getFamilyPearlResources = () => request('/api/pearl/family/resources', 'POST')
+
 // ===== 章鱼图(关键人拓展) =====
 export const getOctopusEffects = (data) => request('/api/octopus/effect/list', 'POST', data)
 export const addOctopusEffect = (data) => request('/api/octopus/effect/add', 'POST', data)
@@ -2549,6 +2552,9 @@ export const getOctopusAnalysisKeywords = (data) => request('/api/octopus/analys
 export const getOctopusAnalysisSummary = (data) => request('/api/octopus/analysis/summary', 'POST', data)
 export const getOctopusExpansionSuggest = (data) => request('/api/octopus/expansion/suggest', 'POST', data)
 
+// 家庭汇集:章鱼图
+export const getFamilyOctopusEffects = () => request('/api/octopus/effect/family/list', 'POST')
+
 // ===== 椋熻氨鎺ㄨ崘 =====
 export const getMealRecommend = (params) => request('/api/meal/recommend', 'POST', params)
 export const replaceFood = (data) => request('/api/meal/replace', 'POST', data)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-dee4c0f0ed9b718a62ba7cbab164a740324399b5
+39f02428049259aab7854e0ee69d024886d557e9

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1877",
+  "version": "1.0.1879",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1877",
+      "version": "1.0.1879",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1878",
+  "version": "1.0.1880",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 41 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,47 @@
 
 ---
 
+## v1.0.1880 (2026-10-31)
+
+### Bug 修复
+- 补齐 $brand-secondary 缺失定义(W3 认证中心渐变第二色,3366cff22 引入遗漏)
+
+### 其他
+- - pages/index-home/index.vue 推荐阅读换用 ArticleFeed 后,ArticleBookshelf 的 import 与组件注册已无使用点,一并移除,避免死代码
+- 验证:verify-vue-compile 3/3 PASS;audit-mp-antipatterns ERROR 0;npm run build:mp-weixin exit 0
+- 
+- - 预置 8 行:五维各维度 AVG + 综合 AVG + pearl.resources/octopus.effects COLLECT
+- - 配置 CRUD:/api/admin/family-metric-agg/{list,save,delete,update-status}(admin 角色)
+- - EnergyService.doCalculateFamilyEnergy 五维聚合改读配置(AVG/SUM/COLLECT,默认 AVG 向后兼容)
+- - 家庭汇集:/api/pearl/family/resources + /api/octopus/effect/family/list(members 数组 + colorKey)
+- - schema.sql 同步 + API_REFERENCE 4.57 + PROJECT-OVERVIEW 更新
+- 
+- - 新增 components/ArticleFeed.vue:复刻 RelatedContent「相关文章」区块视觉
+-   (白卡 16rpx 圆角 / 左图右文 / 图 160x120rpx / 标题 27rpx 两行截断 / 摘要 23rpx 两行截断),
+-   含空态「暂无推荐文章」与 3 条骨架卡片;封面 URL 解析同 RelatedContent.articleCover
+-   (空/null/undefined//null -> /static/default-article.png)
+- - pages/index-home/index.vue:登录态「推荐阅读」ArticleBookshelf -> ArticleFeed
+- - pages/growth-main/index.vue:「推荐阅读」ArticleBookshelf -> ArticleFeed;
+-   「推荐商品」DimensionProducts 追加 layout="mall"(两列大卡片,与首页一致)
+- 未改动:health-main / parent-index / article-center 的 ArticleBookshelf 书封网格,
+- 首页未登录态 article-card 引流区块。
+- 验证:npm run build:mp-weixin exit 0;verify-vue-compile 3/3 PASS;
+- audit-mp-antipatterns ERROR 0;jest 与 origin/cfclub 基线一致(存量 3 套件 4 用例失败)
+- 
+
+### 新功能
+- family collect view for pearl & octopus diagrams
+- add family metric aggregation config + pearl/octopus family collect APIs
+- add family metric aggregation config admin page
+- 首页/成长页「推荐阅读」统一左图右文列表,成长页「推荐商品」对齐首页两列大卡片
+
+
+## v1.0.1879 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 10:48 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1878 (2026-10-31)
 
 ### 文档

+ 42 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1878
+> 当前版本: v1.0.1880
 
 ## 历史版本
 
@@ -8,6 +8,47 @@
 
 ---
 
+## v1.0.1880 (2026-10-31)
+
+### Bug 修复
+- 补齐 $brand-secondary 缺失定义(W3 认证中心渐变第二色,3366cff22 引入遗漏)
+
+### 其他
+- - pages/index-home/index.vue 推荐阅读换用 ArticleFeed 后,ArticleBookshelf 的 import 与组件注册已无使用点,一并移除,避免死代码
+- 验证:verify-vue-compile 3/3 PASS;audit-mp-antipatterns ERROR 0;npm run build:mp-weixin exit 0
+- 
+- - 预置 8 行:五维各维度 AVG + 综合 AVG + pearl.resources/octopus.effects COLLECT
+- - 配置 CRUD:/api/admin/family-metric-agg/{list,save,delete,update-status}(admin 角色)
+- - EnergyService.doCalculateFamilyEnergy 五维聚合改读配置(AVG/SUM/COLLECT,默认 AVG 向后兼容)
+- - 家庭汇集:/api/pearl/family/resources + /api/octopus/effect/family/list(members 数组 + colorKey)
+- - schema.sql 同步 + API_REFERENCE 4.57 + PROJECT-OVERVIEW 更新
+- 
+- - 新增 components/ArticleFeed.vue:复刻 RelatedContent「相关文章」区块视觉
+-   (白卡 16rpx 圆角 / 左图右文 / 图 160x120rpx / 标题 27rpx 两行截断 / 摘要 23rpx 两行截断),
+-   含空态「暂无推荐文章」与 3 条骨架卡片;封面 URL 解析同 RelatedContent.articleCover
+-   (空/null/undefined//null -> /static/default-article.png)
+- - pages/index-home/index.vue:登录态「推荐阅读」ArticleBookshelf -> ArticleFeed
+- - pages/growth-main/index.vue:「推荐阅读」ArticleBookshelf -> ArticleFeed;
+-   「推荐商品」DimensionProducts 追加 layout="mall"(两列大卡片,与首页一致)
+- 未改动:health-main / parent-index / article-center 的 ArticleBookshelf 书封网格,
+- 首页未登录态 article-card 引流区块。
+- 验证:npm run build:mp-weixin exit 0;verify-vue-compile 3/3 PASS;
+- audit-mp-antipatterns ERROR 0;jest 与 origin/cfclub 基线一致(存量 3 套件 4 用例失败)
+- 
+
+### 新功能
+- family collect view for pearl & octopus diagrams
+- add family metric aggregation config + pearl/octopus family collect APIs
+- add family metric aggregation config admin page
+- 首页/成长页「推荐阅读」统一左图右文列表,成长页「推荐商品」对齐首页两列大卡片
+
+
+## v1.0.1879 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 10:48 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1878 (2026-10-31)
 
 ### 文档

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

@@ -136,6 +136,38 @@ export function setMemberAdminRole(familyId, memberId, isAdmin) {
   })
 }
 
+// 家庭数据汇总方式配置(五维家庭汇总:合计 / 平均 / 汇集)
+export function getFamilyMetricAggList() {
+  return request({
+    url: '/api/admin/family-metric-agg/list',
+    method: 'post'
+  })
+}
+
+export function saveFamilyMetricAgg(data) {
+  return request({
+    url: '/api/admin/family-metric-agg/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteFamilyMetricAgg(data) {
+  return request({
+    url: '/api/admin/family-metric-agg/delete',
+    method: 'post',
+    data
+  })
+}
+
+export function updateFamilyMetricAggStatus(data) {
+  return request({
+    url: '/api/admin/family-metric-agg/update-status',
+    method: 'post',
+    data
+  })
+}
+
 // 任务模板管理
 export function getTaskTemplateList(params) {
   return request({

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

@@ -55,6 +55,12 @@ const routes = [
         component: () => import('@/views/Children.vue'),
         meta: { title: '孩子管理', perm: 'family:children' }
       },
+      {
+        path: 'family-metric-agg-config',
+        name: 'FamilyMetricAggConfig',
+        component: () => import('@/views/FamilyMetricAggConfig.vue'),
+        meta: { title: '汇总方式配置', perm: 'family:list' }
+      },
       {
         path: 'points',
         name: 'Points',

+ 278 - 0
cfc-web/src/views/FamilyMetricAggConfig.vue

@@ -0,0 +1,278 @@
+<template>
+  <div class="page-container">
+    <el-card>
+      <PageHeader slot="header" title="家庭数据汇总方式配置" :count="tableData.length">
+        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新增配置</el-button>
+        <el-button size="small" @click="loadList" :loading="loading">刷新</el-button>
+      </PageHeader>
+
+      <div class="adm-table-wrap">
+        <el-table
+          :data="tableData"
+          border
+          v-loading="loading"
+          class="full-width"
+          height="100%"
+        >
+          <el-table-column prop="metricKey" label="数据项标识" min-width="140"></el-table-column>
+          <el-table-column prop="metricName" label="名称" min-width="140"></el-table-column>
+          <el-table-column label="所属维度" width="100">
+            <template slot-scope="scope">
+              {{ dimensionCodeText(scope.row.dimensionCode) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="汇总方式" width="100">
+            <template slot-scope="scope">
+              <StatusTag :type="aggTypeTagType(scope.row.aggType)" :text="aggTypeText(scope.row.aggType)" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="值类型" width="100">
+            <template slot-scope="scope">
+              {{ valueTypeText(scope.row.valueType) }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="sortOrder" label="排序" width="80"></el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="scope">
+              <StatusTag
+                size="small"
+                :type="scope.row.status === 'active' ? 'success' : 'info'"
+                :text="scope.row.status === 'active' ? '启用' : '停用'"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="230" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button
+                size="mini"
+                :type="row.status === 'active' ? 'warning' : 'success'"
+                @click="handleToggleStatus(row)"
+              >{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑配置弹窗 -->
+    <el-dialog
+      :title="form.id ? '编辑配置' : '新增配置'"
+      :visible.sync="dialogVisible"
+      width="560px"
+      :close-on-click-modal="false"
+    >
+      <el-form :model="form" :rules="formRules" ref="form" label-width="100px">
+        <el-form-item label="数据项标识" prop="metricKey">
+          <el-input v-model="form.metricKey" :disabled="!!form.id" placeholder="请输入数据项标识,如 height"></el-input>
+        </el-form-item>
+        <el-form-item label="名称" prop="metricName">
+          <el-input v-model="form.metricName" placeholder="请输入名称,如 身高"></el-input>
+        </el-form-item>
+        <el-form-item label="所属维度" prop="dimensionCode">
+          <el-select v-model="form.dimensionCode" placeholder="请选择维度" class="full-width">
+            <el-option v-for="item in dimensionOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="汇总方式" prop="aggType">
+          <el-select v-model="form.aggType" placeholder="请选择汇总方式" class="full-width">
+            <el-option v-for="item in aggTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="值类型" prop="valueType">
+          <el-select v-model="form.valueType" placeholder="请选择值类型" class="full-width">
+            <el-option v-for="item in valueTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="展示模式" prop="displayMode">
+          <el-input v-model="form.displayMode" placeholder="展示模式标识,如 chart / percent(可选)"></el-input>
+        </el-form-item>
+        <el-form-item label="排序" prop="sortOrder">
+          <el-input-number v-model="form.sortOrder" :min="0" :max="9999" :step="1"></el-input-number>
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select v-model="form.status" class="full-width">
+            <el-option label="启用" value="active"></el-option>
+            <el-option label="停用" value="disabled"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input type="textarea" v-model="form.remark" :rows="2" placeholder="备注说明(可选)"></el-input>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getFamilyMetricAggList, saveFamilyMetricAgg, deleteFamilyMetricAgg, updateFamilyMetricAggStatus } from '@/api/admin'
+
+const DIMENSION_OPTIONS = [
+  { value: 'body', label: '身' },
+  { value: 'mind', label: '心' },
+  { value: 'wisdom', label: '智' },
+  { value: 'action', label: '行' },
+  { value: 'wealth', label: '富' },
+  { value: 'common', label: '通用' }
+]
+const DIMENSION_TEXT = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富', common: '通用' }
+const AGG_TYPE_OPTIONS = [
+  { value: 'SUM', label: '合计' },
+  { value: 'AVG', label: '平均' },
+  { value: 'COLLECT', label: '汇集' }
+]
+const AGG_TYPE_TEXT = { SUM: '合计', AVG: '平均', COLLECT: '汇集' }
+const VALUE_TYPE_OPTIONS = [
+  { value: 'NUMBER', label: '数值' },
+  { value: 'COLLECTION', label: '集合' }
+]
+const VALUE_TYPE_TEXT = { NUMBER: '数值', COLLECTION: '集合' }
+
+export default {
+  name: 'FamilyMetricAggConfig',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      tableData: [],
+      dialogVisible: false,
+      form: this.emptyForm(),
+      formRules: {
+        metricKey: [{ required: true, message: '请输入数据项标识', trigger: 'blur' }],
+        metricName: [{ required: true, message: '请输入名称', trigger: 'blur' }],
+        aggType: [{ required: true, message: '请选择汇总方式', trigger: 'change' }],
+        valueType: [{ required: true, message: '请选择值类型', trigger: 'change' }]
+      }
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    loadList() {
+      this.loading = true
+      return getFamilyMetricAggList()
+        .then(res => {
+          this.tableData = res.data || []
+        })
+        .catch(error => {
+          this.$message.error(error.message || '加载汇总方式配置失败')
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    dimensionCodeText(code) {
+      return DIMENSION_TEXT[code] || code || '-'
+    },
+    aggTypeText(type) {
+      return AGG_TYPE_TEXT[type] || type || '-'
+    },
+    aggTypeTagType(type) {
+      if (type === 'SUM') return 'primary'
+      if (type === 'AVG') return 'warning'
+      return 'info'
+    },
+    valueTypeText(type) {
+      return VALUE_TYPE_TEXT[type] || type || '-'
+    },
+    emptyForm() {
+      return {
+        id: null,
+        metricKey: '',
+        metricName: '',
+        dimensionCode: 'common',
+        aggType: 'SUM',
+        valueType: 'NUMBER',
+        displayMode: '',
+        sortOrder: 0,
+        status: 'active',
+        remark: ''
+      }
+    },
+    handleAdd() {
+      this.form = this.emptyForm()
+      this.dialogVisible = true
+      this.$nextTick(() => {
+        if (this.$refs.form) this.$refs.form.clearValidate()
+      })
+    },
+    handleEdit(row) {
+      this.form = {
+        id: row.id,
+        metricKey: row.metricKey,
+        metricName: row.metricName,
+        dimensionCode: row.dimensionCode,
+        aggType: row.aggType,
+        valueType: row.valueType,
+        displayMode: row.displayMode || '',
+        sortOrder: row.sortOrder,
+        status: row.status || 'active',
+        remark: row.remark || ''
+      }
+      this.dialogVisible = true
+      this.$nextTick(() => {
+        if (this.$refs.form) this.$refs.form.clearValidate()
+      })
+    },
+    submitForm() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        saveFamilyMetricAgg(this.form)
+          .then(() => {
+            this.$message.success('保存成功')
+            this.dialogVisible = false
+            this.loadList()
+          })
+          .catch(error => {
+            this.$message.error(error.message || '保存失败')
+          })
+          .finally(() => {
+            this.submitting = false
+          })
+      })
+    },
+    handleToggleStatus(row) {
+      const newStatus = row.status === 'active' ? 'disabled' : 'active'
+      const action = newStatus === 'active' ? '启用' : '停用'
+      this.$confirm(`确定${action}数据项「${row.metricName}」吗?`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        return updateFamilyMetricAggStatus({ id: row.id, status: newStatus })
+      }).then(() => {
+        this.$message.success(`${action}成功`)
+        this.loadList()
+      }).catch(() => {})
+    },
+    handleDelete(row) {
+      this.$confirm(`确定删除数据项「${row.metricName}」吗?删除后不可恢复。`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        return deleteFamilyMetricAgg({ id: row.id })
+      }).then(() => {
+        this.$message.success('删除成功')
+        this.loadList()
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  padding: var(--spacing-lg);
+}
+.full-width {
+  width: 100%;
+}
+</style>

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -184,6 +184,7 @@ export default {
             { path: '/points-log', label: '积分记录', icon: 'el-icon-document', perm: 'family:points,family:bound' },
             { path: '/wishes', label: '心愿管理', icon: 'el-icon-star-off', perm: 'family:wishes' },
             { path: '/rewards', label: '奖励管理', icon: 'el-icon-s-goods', perm: 'reward:list' },
+            { path: '/family-metric-agg-config', label: '汇总方式配置', icon: 'el-icon-s-operation', perm: 'family:list' },
           ]},
 
         // ===== 3. 方案中心 (plan:*) =====

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 50 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -2403,3 +2403,53 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
   - 落库 `health_plans`,`plan_type=family_attribution_plan`,`status=GENERATED`(⑤)
 
 *文档更新:2026-10-28(家庭归因核心闭环 B1–B4:/api/attribution/analyze、/api/attribution/adjust、/api/attribution/confirm-adjust、/api/attribution/plan、/api/survey/attribution/confirm-questions;/api/survey/submit 扩展 riskHits)*
+
+
+---
+
+## 4.57 家庭指标聚合方式配置 + 珍珠图/章鱼图家庭汇集
+
+> 五维能量页(身/智/富/行/心)三视角:① 个人看自己各维度;② 家庭管理员看家庭成员各维度;③ 看整个家庭各维度汇总。
+> 家庭各维度汇总方式可配置:**SUM=合计 / AVG=平均 / COLLECT=汇集**(如行页面珍珠图、章鱼图)。
+> 配置作用域 = **平台级全局一份**(`family_metric_agg_config` 表,按数据项一行一条,迁移433)。
+> 默认五维 AVG 保持既有页面数值表现不变(向后兼容);SUM/COLLECT 由后台配置页调整即生效。
+
+**数据表 `family_metric_agg_config`**:
+
+| 字段 | 说明 |
+|------|------|
+| `metric_key` (UNIQUE) | 数据项唯一标识:`energy.body`/`energy.mind`/`energy.wisdom`/`energy.action`/`energy.wealth`/`energy.overall`/`pearl.resources`/`octopus.effects` |
+| `metric_name` | 中文名 |
+| `dimension_code` | 所属维度:body/mind/wisdom/action/wealth/common |
+| `agg_type` | SUM=合计 / AVG=平均 / COLLECT=汇集 |
+| `value_type` | NUMBER(数值)/ COLLECTION(集合) |
+| `display_mode` | number/bar/radar/merge_graph |
+| `sort_order` / `status` / `remark` | 排序 / active|disabled / 备注 |
+
+**配置 CRUD**(`/api/admin/family-metric-agg/*`,供 cfc-web 管理端,`admin` 角色守卫):
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/family-metric-agg/list` | 全量配置列表(配置项 < 50,无需分页)。返回 `[ { id, metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark } ]` |
+| `POST /api/admin/family-metric-agg/save` | 新增/更新(body 带 `id` 则更新,否则新增)。Body:`{ id?, metricKey, metricName, dimensionCode, aggType, valueType, displayMode?, sortOrder?, status?, remark? }` → `true` |
+| `POST /api/admin/family-metric-agg/delete` | Body `{ id }` → `true` |
+| `POST /api/admin/family-metric-agg/update-status` | Body `{ id, status }` → `true` |
+
+**家庭汇集数据**(供小程序行页面,全 POST):
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/pearl/family/resources` | 珍珠图家庭汇集。data:`{ members: [ { memberId, memberName, memberType, colorKey, items: [原珍珠资源项结构 + groupType/groupTypeName/ring] } ] }` |
+| `POST /api/octopus/effect/family/list` | 章鱼图家庭汇集。data:`{ members: [ { memberId, memberName, memberType, colorKey, effects: [原章鱼成效项结构] } ] }` |
+
+- `memberType`:`user`(家庭主账号,memberId=users.id)/ `member`(家庭成员,memberId=family_members.id)
+- `colorKey`:稳定色值键(0..n-1 索引,同一成员前后一致),供前端按成员着色
+- 成员收集与 `EnergyService.doCalculateFamilyEnergy` 一致(家庭主账号 role=parent + `getActualChildren` 去重);个人数据查询复用既有逻辑(`ResourceService.getPearlResources` / `KeyPersonService.listEffects`),不重写业务规则
+
+**五维聚合改造(`EnergyService.doCalculateFamilyEnergy` 第 4 步)**:按 `metric_key`(`energy.body`…)查配置取 `agg_type`:
+- AVG → 既有平均逻辑(默认,行为不变)
+- SUM → 合计(sum,不再除以 count)
+- COLLECT → 数值维度不适用,回落 AVG + 日志记录
+- 读不到配置 → 回落 AVG(保证兼容)
+
+*文档更新:2026-10-31(家庭数据汇总方式配置(后端):family_metric_agg_config 表 + 配置 CRUD 4 接口 + 珍珠图/章鱼图家庭汇集 2 接口 + 五维聚合读配置)*

Неке датотеке нису приказане због велике количине промена