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

feat: 关系三图——章鱼图(谁帮过我)/珍珠图(我有什么)/能力图(谁会什么) 全链路上线

Sisyphus Agent пре 1 недеља
родитељ
комит
2b1fcdc962
22 измењених фајлова са 4180 додато и 27 уклоњено
  1. 30 25
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/AbilityController.java
  3. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/OctopusController.java
  4. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java
  5. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ContactHelpLogDTO.java
  6. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ContactHelpLog.java
  7. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ResourceItem.java
  8. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ContactHelpLogMapper.java
  9. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ResourceItemMapper.java
  10. 249 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ContactHelpLogService.java
  11. 198 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java
  12. 26 0
      cfc-backend/src/main/resources/schema.sql
  13. 701 0
      cfc-frontend/components/AbilityDiagram.vue
  14. 919 0
      cfc-frontend/components/OctopusDiagram.vue
  15. 873 0
      cfc-frontend/components/PearlDiagram.vue
  16. 18 0
      cfc-frontend/pages.json
  17. 130 2
      cfc-frontend/pages/action-detail/index.vue
  18. 249 0
      cfc-frontend/pages/action-detail/octopus-add-help.vue
  19. 222 0
      cfc-frontend/pages/action-detail/octopus-records.vue
  20. 274 0
      cfc-frontend/pages/action-detail/pearl-add-resource.vue
  21. 11 0
      cfc-frontend/utils/api.js
  22. 39 0
      docs/superpowers/api/API_REFERENCE.md

+ 30 - 25
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10505,35 +10505,40 @@ public class DatabaseInitializer implements CommandLineRunner {
             log.warn("迁移301: platform_balance_log 唯一索引处理失败: {}", e.getMessage());
         }
 
-        // 迁移303: interaction_logs表添加附件字段(照片、视频、音频URL)
-        ensureColumn("interaction_logs", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
-        ensureColumn("interaction_logs", "video_url", "VARCHAR(500) COMMENT '视频URL'");
-        ensureColumn("interaction_logs", "audio_url", "VARCHAR(500) COMMENT '音频URL'");
-
-        // 迁移304: report_type_registry表添加script_status字段(自学习生成采集脚本的状态追踪)
-        ensureColumn("report_type_registry", "script_status",
-                "VARCHAR(20) DEFAULT NULL COMMENT '采集脚本生成状态: generating/ready/failed'");
-
-        // 迁移305: 创建 family_member_dimension_base_scores 表(五维基础能量分,支持重算)
+        // 迁移302: 创建 contact_help_logs 表(章鱼图帮助记录)
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (" +
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS contact_help_logs (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
-                    "member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent', " +
-                    "dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth', " +
-                    "base_score INT NOT NULL COMMENT '基础能量分(0-100)', " +
-                    "last_calculated_at DATETIME NOT NULL COMMENT '最后计算时间', " +
-                    "calculated_by VARCHAR(50) DEFAULT 'system' COMMENT '计算来源(system/manual)', " +
-                    "UNIQUE KEY uk_member_dimension (member_id, dimension_code), " +
-                    "INDEX idx_member (member_id)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维基础能量分(落库,支持重算)'");
-            log.info("已创建 family_member_dimension_base_scores 表");
+                    "user_id BIGINT NOT NULL COMMENT '被帮助者(当前用户)', " +
+                    "contact_id BIGINT NOT NULL COMMENT '帮助者(联系人)', " +
+                    "help_type VARCHAR(32) COMMENT '帮助类型: MONEY/ITEM/EMOTION/RESOURCE/SKILL/TIME', " +
+                    "amount DECIMAL(10,2) DEFAULT 0 COMMENT '帮助金额(非金钱类为0)', " +
+                    "description VARCHAR(255) COMMENT '帮助内容简述', " +
+                    "happened_at DATETIME COMMENT '帮助发生时间', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_chl_user (user_id), " +
+                    "INDEX idx_chl_contact (contact_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人帮助记录(章鱼图数据源)'");
+            log.info("已创建contact_help_logs表");
         } catch (Exception e) {
-            // 表已存在,忽略错误
+            log.warn("创建contact_help_logs表失败: {}", e.getMessage());
         }
 
-        // 迁移306: health_reports和health_report_drafts添加file_hash字段(报告上传去重)
-        ensureColumn("health_reports", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
-        ensureColumn("health_report_drafts", "file_hash", "VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测'");
+        // 迁移303: 创建 resource_items 表(珍珠图资源登记:信息/场所/技能类资源)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS resource_items (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL COMMENT '资源拥有者(当前用户)', " +
+                    "type VARCHAR(16) NOT NULL COMMENT '资源类型: INFO(信息)/PLACE(场所)/SKILL(技能)', " +
+                    "name VARCHAR(100) NOT NULL COMMENT '资源名称', " +
+                    "description VARCHAR(255) COMMENT '资源描述(可调用方式/说明)', " +
+                    "contact_id BIGINT DEFAULT NULL COMMENT '关联联系人ID(可选)', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_ri_user (user_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图资源登记表(信息/场所/技能类资源)'");
+            log.info("已创建resource_items表");
+        } catch (Exception e) {
+            log.warn("创建resource_items表失败: {}", e.getMessage());
+        }
     }
 }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/AbilityController.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ResourceService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/ability")
+public class AbilityController {
+
+    @Resource
+    private ResourceService resourceService;
+
+    /** 能力图数据:SKILL 类帮助记录按联系人聚合为能力节点 */
+    @PostMapping("/map")
+    public Result<List<Map<String, Object>>> map(@RequestAttribute("userId") Long userId) {
+        return resourceService.getAbilityMap(userId);
+    }
+}

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/OctopusController.java

@@ -0,0 +1,62 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ContactHelpLogService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/octopus")
+public class OctopusController {
+
+    @Resource
+    private ContactHelpLogService helpLogService;
+
+    /** 章鱼图数据:取前8条触手 */
+    @PostMapping("/tentacles")
+    public Result<List<Map<String, Object>>> tentacles(@RequestAttribute("userId") Long userId) {
+        return helpLogService.getOctopusTentacles(userId);
+    }
+
+    /** 新增帮助记录(被帮时调用) */
+    @PostMapping("/add")
+    public Result<Object> addHelpLog(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("userId") Long userId) {
+        Long contactId = params.get("contactId") != null ?
+                Long.valueOf(params.get("contactId").toString()) : null;
+        String helpType = (String) params.get("helpType");
+        String amountStr = (String) params.get("amount");
+        BigDecimal amount = amountStr != null && !amountStr.isEmpty()
+                ? new BigDecimal(amountStr) : BigDecimal.ZERO;
+        String description = (String) params.get("description");
+        Long happenedAt = params.get("happenedAt") != null ?
+                Long.valueOf(params.get("happenedAt").toString()) : null;
+        Date happenedAtDate = happenedAt != null ? new Date(happenedAt) : null;
+
+        return helpLogService.addHelpLog(userId, contactId, helpType, amount, description, happenedAtDate);
+    }
+
+    /** 删除帮助记录 */
+    @PostMapping("/delete")
+    public Result<String> deleteHelpLog(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("userId") Long userId) {
+        Long logId = params.get("logId") != null ?
+                Long.valueOf(params.get("logId").toString()) : null;
+        return helpLogService.deleteHelpLog(userId, logId);
+    }
+
+    /** 获取某联系人的帮助记录明细 */
+    @PostMapping("/records")
+    public Result<Map<String, Object>> records(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long userId) {
+        Long contactId = params.get("contactId") != null ?
+                Long.valueOf(params.get("contactId").toString()) : null;
+        return helpLogService.getHelpRecordsByContact(userId, contactId);
+    }
+}

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

@@ -0,0 +1,45 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ResourceItem;
+import com.etotem.cfc.service.ResourceService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/pearl")
+public class PearlController {
+
+    @Resource
+    private ResourceService resourceService;
+
+    /** 珍珠图聚合数据:人脉 + 技能 + 信息 + 场所 四组资源 */
+    @PostMapping("/resources")
+    public Result<Map<String, Object>> resources(@RequestAttribute("userId") Long userId) {
+        return resourceService.getPearlResources(userId);
+    }
+
+    /** 登记珍珠图资源(信息/场所/技能手工登记) */
+    @PostMapping("/item/add")
+    public Result<ResourceItem> addItem(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("userId") Long userId) {
+        String type = (String) params.get("type");
+        String name = (String) params.get("name");
+        String description = (String) params.get("description");
+        Long contactId = params.get("contactId") != null
+                ? Long.valueOf(params.get("contactId").toString()) : null;
+        return resourceService.addResourceItem(userId, type, name, description, contactId);
+    }
+
+    /** 删除登记的珍珠图资源 */
+    @PostMapping("/item/delete")
+    public Result<String> deleteItem(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("userId") Long userId) {
+        Long itemId = params.get("itemId") != null
+                ? Long.valueOf(params.get("itemId").toString()) : null;
+        return resourceService.deleteResourceItem(userId, itemId);
+    }
+}

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

@@ -0,0 +1,21 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class ContactHelpLogDTO {
+    private Long id;
+    private Long userId;
+    private Long contactId;
+    private String contactName;
+    private String contactAvatar;
+    private String helpType;
+    private String helpTypeName;
+    private BigDecimal amount;
+    private String description;
+    private Date happenedAt;
+    private Date createdAt;
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ContactHelpLog.java

@@ -0,0 +1,38 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("contact_help_logs")
+public class ContactHelpLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 被帮助者(当前用户) */
+    private Long userId;
+
+    /** 帮助者(联系人) */
+    private Long contactId;
+
+    /** 帮助类型: MONEY/ITEM/EMOTION/RESOURCE/SKILL/TIME */
+    private String helpType;
+
+    /** 帮助金额(非金钱类为0) */
+    private BigDecimal amount;
+
+    /** 帮助内容 */
+    private String description;
+
+    /** 帮助发生时间 */
+    private Date happenedAt;
+
+    private Date createdAt;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ResourceItem.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("resource_items")
+public class ResourceItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 资源拥有者(当前用户) */
+    private Long userId;
+
+    /** 资源类型: INFO(信息)/PLACE(场所)/SKILL(技能) */
+    private String type;
+
+    /** 资源名称 */
+    private String name;
+
+    /** 资源描述(可调用方式/说明) */
+    private String description;
+
+    /** 关联联系人ID(可选) */
+    private Long contactId;
+
+    private Date createdAt;
+}

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

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

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

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

+ 249 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContactHelpLogService.java

@@ -0,0 +1,249 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ContactHelpLogDTO;
+import com.etotem.cfc.entity.Contact;
+import com.etotem.cfc.entity.ContactHelpLog;
+import com.etotem.cfc.mapper.ContactHelpLogMapper;
+import com.etotem.cfc.mapper.ContactMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class ContactHelpLogService {
+
+    @Resource
+    private ContactHelpLogMapper helpLogMapper;
+
+    @Resource
+    private ContactMapper contactMapper;
+
+    /** 新增帮助记录 */
+    public Result<ContactHelpLogDTO> addHelpLog(Long userId, Long contactId, String helpType,
+                                                BigDecimal amount, String description, Date happenedAt) {
+        // 验证联系人归属
+        Contact contact = contactMapper.selectById(contactId);
+        if (contact == null || !contact.getUserId().equals(userId)) {
+            return Result.error("联系人不存在");
+        }
+
+        ContactHelpLog log = new ContactHelpLog();
+        log.setUserId(userId);
+        log.setContactId(contactId);
+        log.setHelpType(helpType);
+        log.setAmount(amount != null ? amount : BigDecimal.ZERO);
+        log.setDescription(description);
+        log.setHappenedAt(happenedAt != null ? happenedAt : new Date());
+        log.setCreatedAt(new Date());
+        helpLogMapper.insert(log);
+
+        return Result.success(toDTO(log, contact));
+    }
+
+    /** 获取联系人的帮助记录列表 */
+    public Result<List<ContactHelpLogDTO>> listByContact(Long userId, Long contactId) {
+        LambdaQueryWrapper<ContactHelpLog> query = new LambdaQueryWrapper<ContactHelpLog>()
+                .eq(ContactHelpLog::getUserId, userId)
+                .eq(ContactHelpLog::getContactId, contactId)
+                .orderByDesc(ContactHelpLog::getHappenedAt);
+        List<ContactHelpLog> logs = helpLogMapper.selectList(query);
+        Contact contact = contactMapper.selectById(contactId);
+        List<ContactHelpLogDTO> result = logs.stream()
+                .map(l -> toDTO(l, contact))
+                .collect(Collectors.toList());
+        return Result.success(result);
+    }
+
+    /** 删除帮助记录 */
+    public Result<String> deleteHelpLog(Long userId, Long logId) {
+        ContactHelpLog log = helpLogMapper.selectById(logId);
+        if (log == null || !log.getUserId().equals(userId)) {
+            return Result.error("记录不存在");
+        }
+        helpLogMapper.deleteById(logId);
+        return Result.success(null);
+    }
+
+    /** 章鱼图聚合:取帮助次数榜前8 + 金额榜前8,合并去重后取前8 */
+    public Result<List<Map<String, Object>>> getOctopusTentacles(Long userId) {
+        // 1. 聚合所有联系人的帮助统计
+        List<Contact> contacts = contactMapper.selectList(
+                new LambdaQueryWrapper<Contact>().eq(Contact::getUserId, userId)
+        );
+        if (contacts.isEmpty()) {
+            return Result.success(new ArrayList<>());
+        }
+
+        List<Long> contactIds = contacts.stream().map(Contact::getId).collect(Collectors.toList());
+
+        // 查询所有帮助记录
+        LambdaQueryWrapper<ContactHelpLog> query = new LambdaQueryWrapper<ContactHelpLog>()
+                .eq(ContactHelpLog::getUserId, userId)
+                .in(ContactHelpLog::getContactId, contactIds);
+        List<ContactHelpLog> allLogs = helpLogMapper.selectList(query);
+
+        // 聚合:每个联系人的 次数、总金额、上次帮助时间、类型分布
+        Map<Long, ContactAgg> aggMap = new HashMap<>();
+        for (ContactHelpLog log : allLogs) {
+            ContactAgg agg = aggMap.computeIfAbsent(log.getContactId(), k -> new ContactAgg());
+            agg.contactId = log.getContactId();
+            agg.helpCount++;
+            agg.totalAmount = agg.totalAmount.add(log.getAmount() != null ? log.getAmount() : BigDecimal.ZERO);
+            if (agg.lastHelpedAt == null || log.getHappenedAt().after(agg.lastHelpedAt)) {
+                agg.lastHelpedAt = log.getHappenedAt();
+            }
+            // 统计类型分布
+            String type = log.getHelpType() != null ? log.getHelpType() : "OTHER";
+            agg.typeCount.merge(type, 1, Integer::sum);
+        }
+
+        // 转为列表,关联联系人基本信息
+        List<ContactAgg> aggList = new ArrayList<>(aggMap.values());
+        Map<Long, Contact> contactMap = contacts.stream()
+                .collect(Collectors.toMap(Contact::getId, c -> c));
+        for (ContactAgg agg : aggList) {
+            Contact c = contactMap.get(agg.contactId);
+            if (c != null) {
+                agg.contactName = c.getName();
+                agg.contactAvatar = c.getAvatar();
+                agg.relationshipType = c.getRelationshipType();
+            }
+        }
+
+        // 2. 双榜取前8合并去重
+        // 次数榜
+        List<ContactAgg> byCount = new ArrayList<>(aggList);
+        byCount.sort(Comparator.comparingInt((ContactAgg a) -> a.helpCount).reversed()
+                .thenComparing(a -> a.totalAmount, Comparator.reverseOrder()));
+        List<ContactAgg> top8ByCount = byCount.stream().limit(8).collect(Collectors.toList());
+
+        // 金额榜
+        List<ContactAgg> byAmount = new ArrayList<>(aggList);
+        byAmount.sort(Comparator.comparing(a -> a.totalAmount, Comparator.reverseOrder())
+                .thenComparingInt(a -> a.helpCount).reversed());
+        List<ContactAgg> top8ByAmount = byAmount.stream().limit(8).collect(Collectors.toList());
+
+        // 合并去重,保持次数榜优先顺序
+        List<ContactAgg> merged = new ArrayList<>();
+        Set<Long> seen = new java.util.HashSet<>();
+        for (ContactAgg a : top8ByCount) {
+            if (seen.add(a.contactId)) merged.add(a);
+        }
+        for (ContactAgg a : top8ByAmount) {
+            if (seen.add(a.contactId)) merged.add(a);
+        }
+
+        // 最终取前8
+        List<ContactAgg> final8 = merged.stream().limit(8).collect(Collectors.toList());
+
+        // 转为前端需要的格式
+        List<Map<String, Object>> result = final8.stream().map(this::toTentacleVO).collect(Collectors.toList());
+        return Result.success(result);
+    }
+
+    /** 按联系人分组的帮助记录(用于点击触手后显示明细) */
+    public Result<Map<String, Object>> getHelpRecordsByContact(Long userId, Long contactId) {
+        Contact contact = contactMapper.selectById(contactId);
+        if (contact == null || !contact.getUserId().equals(userId)) {
+            return Result.error("联系人不存在");
+        }
+
+        List<ContactHelpLogDTO> logs = listByContact(userId, contactId).getData();
+
+        // 聚合统计
+        int totalCount = logs.size();
+        BigDecimal totalAmount = logs.stream()
+                .map(ContactHelpLogDTO::getAmount)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("contact", Map.of(
+                "id", contact.getId(),
+                "name", contact.getName(),
+                "avatar", contact.getAvatar(),
+                "relationshipType", contact.getRelationshipType()
+        ));
+        result.put("summary", Map.of(
+                "helpCount", totalCount,
+                "totalAmount", totalAmount,
+                "lastHelpedAt", logs.isEmpty() ? null : logs.get(0).getHappenedAt()
+        ));
+        result.put("records", logs);
+        return Result.success(result);
+    }
+
+    private ContactHelpLogDTO toDTO(ContactHelpLog log, Contact contact) {
+        ContactHelpLogDTO dto = new ContactHelpLogDTO();
+        dto.setId(log.getId());
+        dto.setUserId(log.getUserId());
+        dto.setContactId(log.getContactId());
+        dto.setContactName(contact != null ? contact.getName() : "");
+        dto.setContactAvatar(contact != null ? contact.getAvatar() : "");
+        dto.setHelpType(log.getHelpType());
+        dto.setHelpTypeName(helpTypeName(log.getHelpType()));
+        dto.setAmount(log.getAmount());
+        dto.setDescription(log.getDescription());
+        dto.setHappenedAt(log.getHappenedAt());
+        dto.setCreatedAt(log.getCreatedAt());
+        return dto;
+    }
+
+    private Map<String, Object> toTentacleVO(ContactAgg a) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("contactId", a.contactId);
+        m.put("name", a.contactName);
+        m.put("avatar", a.contactAvatar);
+        m.put("relationshipType", a.relationshipType);
+        m.put("helpCount", a.helpCount);
+        m.put("totalAmount", a.totalAmount);
+        m.put("lastHelpedAt", a.lastHelpedAt);
+        // 主导类型
+        String mainType = a.typeCount.entrySet().stream()
+                .max(Map.Entry.comparingByValue())
+                .map(Map.Entry::getKey)
+                .orElse("OTHER");
+        m.put("mainHelpType", mainType);
+        m.put("mainHelpTypeName", helpTypeName(mainType));
+        // 节点大小映射:次数 1-10 -> radius 24-40
+        int radius = 24 + Math.min(16, Math.max(0, a.helpCount - 1) * 2);
+        m.put("radius", radius);
+        return m;
+    }
+
+    private String helpTypeName(String type) {
+        switch (type) {
+            case "MONEY": return "金钱";
+            case "ITEM": return "物质";
+            case "EMOTION": return "情感";
+            case "RESOURCE": return "资源";
+            case "SKILL": return "技能";
+            case "TIME": return "时间";
+            default: return "其他";
+        }
+    }
+
+    /** 聚合中间类 */
+    private static class ContactAgg {
+        Long contactId;
+        String contactName;
+        String contactAvatar;
+        String relationshipType;
+        int helpCount = 0;
+        BigDecimal totalAmount = BigDecimal.ZERO;
+        Date lastHelpedAt;
+        Map<String, Integer> typeCount = new HashMap<>();
+    }
+}

+ 198 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java

@@ -0,0 +1,198 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Contact;
+import com.etotem.cfc.entity.ContactHelpLog;
+import com.etotem.cfc.entity.ResourceItem;
+import com.etotem.cfc.mapper.ContactHelpLogMapper;
+import com.etotem.cfc.mapper.ContactMapper;
+import com.etotem.cfc.mapper.ResourceItemMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class ResourceService {
+
+    @Resource
+    private ResourceItemMapper resourceItemMapper;
+
+    @Resource
+    private ContactMapper contactMapper;
+
+    @Resource
+    private ContactHelpLogMapper helpLogMapper;
+
+    /** 珍珠图聚合:人(contacts) + 技能(help_logs SKILL) + 信息/场所(resource_items) */
+    public Result<Map<String, Object>> getPearlResources(Long userId) {
+        // 1. 人脉:全部联系人
+        List<Contact> contacts = contactMapper.selectList(
+                new LambdaQueryWrapper<Contact>().eq(Contact::getUserId, userId)
+        );
+        List<Map<String, Object>> personItems = contacts.stream().map(c -> {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", c.getId());
+            m.put("name", c.getName());
+            m.put("avatar", c.getAvatar());
+            m.put("relationshipType", c.getRelationshipType());
+            m.put("description", c.getRelationshipType());
+            return m;
+        }).collect(Collectors.toList());
+
+        // 2. 技能:SKILL 类帮助记录,按联系人聚合
+        List<ContactHelpLog> skillLogs = helpLogMapper.selectList(
+                new LambdaQueryWrapper<ContactHelpLog>()
+                        .eq(ContactHelpLog::getUserId, userId)
+                        .eq(ContactHelpLog::getHelpType, "SKILL")
+                        .orderByDesc(ContactHelpLog::getHappenedAt)
+        );
+        List<Map<String, Object>> skillItems = buildSkillItems(skillLogs);
+
+        // 3+4. 信息/场所:resource_items 登记
+        List<ResourceItem> resourceItems = resourceItemMapper.selectList(
+                new LambdaQueryWrapper<ResourceItem>()
+                        .eq(ResourceItem::getUserId, userId)
+                        .in(ResourceItem::getType, Arrays.asList("INFO", "PLACE", "SKILL"))
+                        .orderByDesc(ResourceItem::getCreatedAt)
+        );
+        List<Map<String, Object>> infoItems = new ArrayList<>();
+        List<Map<String, Object>> placeItems = new ArrayList<>();
+        for (ResourceItem item : resourceItems) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", item.getId());
+            m.put("name", item.getName());
+            m.put("description", item.getDescription());
+            m.put("contactId", item.getContactId());
+            m.put("createdAt", item.getCreatedAt());
+            if ("PLACE".equals(item.getType())) {
+                placeItems.add(m);
+            } else {
+                infoItems.add(m);
+            }
+        }
+
+        List<Map<String, Object>> groups = new ArrayList<>();
+        groups.add(group("PERSON", "人脉", personItems));
+        groups.add(group("SKILL", "技能", skillItems));
+        groups.add(group("INFO", "信息", infoItems));
+        groups.add(group("PLACE", "场所", placeItems));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("groups", groups);
+        return Result.success(result);
+    }
+
+    /** 登记资源(珍珠图:信息/场所/技能类手工登记) */
+    public Result<ResourceItem> addResourceItem(Long userId, String type, String name,
+                                                String description, Long contactId) {
+        if (type == null || !Arrays.asList("INFO", "PLACE", "SKILL").contains(type)) {
+            return Result.error("资源类型不合法");
+        }
+        if (name == null || name.trim().isEmpty()) {
+            return Result.error("资源名称不能为空");
+        }
+        if (contactId != null) {
+            Contact contact = contactMapper.selectById(contactId);
+            if (contact == null || !contact.getUserId().equals(userId)) {
+                return Result.error("关联联系人不存在");
+            }
+        }
+
+        ResourceItem item = new ResourceItem();
+        item.setUserId(userId);
+        item.setType(type);
+        item.setName(name.trim());
+        item.setDescription(description != null ? description.trim() : null);
+        item.setContactId(contactId);
+        item.setCreatedAt(new Date());
+        resourceItemMapper.insert(item);
+        return Result.success(item);
+    }
+
+    /** 删除登记的珍珠图资源 */
+    public Result<String> deleteResourceItem(Long userId, Long itemId) {
+        ResourceItem item = resourceItemMapper.selectById(itemId);
+        if (item == null || !item.getUserId().equals(userId)) {
+            return Result.error("资源不存在");
+        }
+        resourceItemMapper.deleteById(itemId);
+        return Result.success(null);
+    }
+
+    /** 能力图聚合:SKILL 类帮助记录按联系人聚合为能力节点 */
+    public Result<List<Map<String, Object>>> getAbilityMap(Long userId) {
+        List<ContactHelpLog> skillLogs = helpLogMapper.selectList(
+                new LambdaQueryWrapper<ContactHelpLog>()
+                        .eq(ContactHelpLog::getUserId, userId)
+                        .eq(ContactHelpLog::getHelpType, "SKILL")
+                        .orderByDesc(ContactHelpLog::getHappenedAt)
+        );
+        List<Map<String, Object>> items = buildSkillItems(skillLogs);
+        return Result.success(items);
+    }
+
+    /** 按联系人聚合 SKILL 帮助记录为能力节点 */
+    private List<Map<String, Object>> buildSkillItems(List<ContactHelpLog> skillLogs) {
+        Map<Long, SkillAgg> aggMap = new HashMap<>();
+        for (ContactHelpLog log : skillLogs) {
+            SkillAgg agg = aggMap.computeIfAbsent(log.getContactId(), k -> new SkillAgg());
+            agg.contactId = log.getContactId();
+            agg.helpCount++;
+            if (log.getDescription() != null && !log.getDescription().trim().isEmpty()) {
+                agg.skillNames.add(log.getDescription().trim());
+            }
+            if (agg.lastHelpedAt == null || (log.getHappenedAt() != null && log.getHappenedAt().after(agg.lastHelpedAt))) {
+                agg.lastHelpedAt = log.getHappenedAt();
+            }
+        }
+
+        Map<Long, Contact> contactMap = new HashMap<>();
+        List<Long> ids = new ArrayList<>(aggMap.keySet());
+        if (!ids.isEmpty()) {
+            contactMapper.selectBatchIds(ids).forEach(c -> contactMap.put(c.getId(), c));
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SkillAgg agg : aggMap.values()) {
+            Contact c = contactMap.get(agg.contactId);
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("contactId", agg.contactId);
+            m.put("name", c != null ? c.getName() : "");
+            m.put("avatar", c != null ? c.getAvatar() : "");
+            m.put("relationshipType", c != null ? c.getRelationshipType() : "");
+            m.put("skillCount", agg.helpCount);
+            m.put("skills", new ArrayList<>(agg.skillNames));
+            m.put("lastHelpedAt", agg.lastHelpedAt);
+            result.add(m);
+        }
+        result.sort((a, b) -> Integer.compare((Integer) b.get("skillCount"), (Integer) a.get("skillCount")));
+        return result;
+    }
+
+    private Map<String, Object> group(String type, String typeName, List<Map<String, Object>> items) {
+        Map<String, Object> g = new LinkedHashMap<>();
+        g.put("type", type);
+        g.put("typeName", typeName);
+        g.put("items", items);
+        return g;
+    }
+
+    /** 技能聚合中间类 */
+    private static class SkillAgg {
+        Long contactId;
+        int helpCount = 0;
+        Set<String> skillNames = new LinkedHashSet<>();
+        Date lastHelpedAt;
+    }
+}

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

@@ -5234,6 +5234,32 @@ CREATE TABLE IF NOT EXISTS contacts (
     INDEX idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';
 
+-- 联系人帮助记录表(章鱼图数据源)
+CREATE TABLE IF NOT EXISTS contact_help_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '被帮助者(当前用户)',
+    contact_id BIGINT NOT NULL COMMENT '帮助者(联系人)',
+    help_type VARCHAR(32) COMMENT '帮助类型: MONEY/ITEM/EMOTION/RESOURCE/SKILL/TIME',
+    amount DECIMAL(10,2) DEFAULT 0 COMMENT '帮助金额(非金钱类为0)',
+    description VARCHAR(255) COMMENT '帮助内容简述',
+    happened_at DATETIME COMMENT '帮助发生时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_chl_user (user_id),
+    INDEX idx_chl_contact (contact_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人帮助记录(章鱼图数据源)';
+
+-- 珍珠图资源登记表(信息/场所/技能类资源)
+CREATE TABLE IF NOT EXISTS resource_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '资源拥有者(当前用户)',
+    type VARCHAR(16) NOT NULL COMMENT '资源类型: INFO(信息)/PLACE(场所)/SKILL(技能)',
+    name VARCHAR(100) NOT NULL COMMENT '资源名称',
+    description VARCHAR(255) COMMENT '资源描述(可调用方式/说明)',
+    contact_id BIGINT DEFAULT NULL COMMENT '关联联系人ID(可选)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_ri_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图资源登记表(信息/场所/技能类资源)';
+
 -- 维度配置表(cfclub 五维能量体系)
 CREATE TABLE IF NOT EXISTS product_dimension_config (
     id BIGINT PRIMARY KEY AUTO_INCREMENT,

+ 701 - 0
cfc-frontend/components/AbilityDiagram.vue

@@ -0,0 +1,701 @@
+<template>
+  <!-- 能力图 - 径向布局 Canvas 组件 -->
+  <view class="ability-graph-wrapper" v-if="abilities && abilities.length > 0" :style="{ height: canvasHeight + 'px' }">
+    <canvas
+      class="ability-graph-canvas"
+      canvas-id="abilityGraphCanvas"
+      id="abilityGraphCanvas"
+      type="2d"
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      @tap="onCanvasTap" />
+    <!-- 点击节点时显示详情弹窗 -->
+    <view class="ability-detail-popup" v-if="selectedNode" :style="popupPosition">
+      <view class="popup-content">
+        <view class="popup-header">
+          <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
+          <view class="popup-info">
+            <text class="popup-name">{{ selectedNode.name }}</text>
+            <text class="popup-relation" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
+          </view>
+        </view>
+        <view class="popup-skills-section">
+          <text class="popup-skills-title">技能 x{{ selectedNode.skillCount || 0 }}</text>
+          <view class="popup-skills-list">
+            <text class="popup-skill-item" v-for="(sk, idx) in displaySkills" :key="idx">{{ sk }}</text>
+            <text class="popup-skill-more" v-if="selectedNode.skills && selectedNode.skills.length > 8">+{{ selectedNode.skills.length - 8 }} 项</text>
+          </view>
+        </view>
+        <view class="popup-time" v-if="selectedNode.lastHelpedAt">
+          <text class="popup-time-label">最近帮助:</text>
+          <text class="popup-time-value">{{ formatDate(selectedNode.lastHelpedAt) }}</text>
+        </view>
+        <view class="popup-arrow" :style="arrowPosition"></view>
+      </view>
+    </view>
+  </view>
+  <!-- 空状态 -->
+  <view class="ability-graph-empty" v-else-if="abilities && abilities.length === 0">
+    <text class="empty-icon">🛠️</text>
+    <text class="empty-text">暂无能力图数据</text>
+    <text class="empty-hint">记录技能类帮助后,这里会画出大家的能力</text>
+  </view>
+  <!-- 加载状态 -->
+  <view class="ability-loading" v-else>
+    <text class="loading-text">加载能力图...</text>
+  </view>
+</template>
+
+<script>
+/**
+ * AbilityDiagram - 能力图 Canvas 组件
+ *
+ * 径向布局,展示"谁有哪项能力、曾帮助过谁":
+ * - 中心节点 = 当前用户"我"(受助人,静态)
+ * - 外围节点 = 提供过 SKILL 类帮助的人
+ * - 节点大小 = 技能数量(skillCount)
+ * - 边标注 = 技能名称
+ * - 点击节点 → 显示详情弹窗(技能列表、最近帮助时间)
+ *
+ * 数据源:父组件通过 abilities prop 传入
+ */
+export default {
+  name: 'AbilityDiagram',
+  props: {
+    /** 当前用户ID */
+    selfId: {
+      type: [Number, String],
+      default: null
+    },
+    /** 能力数据数组或 null(null=加载中) */
+    abilities: {
+      type: Array,
+      default: null
+    },
+    /** 画布宽度 px */
+    canvasWidth: {
+      type: Number,
+      default: 340
+    },
+    /** 画布高度 px */
+    canvasHeight: {
+      type: Number,
+      default: 280
+    },
+    /** 是否允许交互(点击节点查看明细) */
+    interactive: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data: function() {
+    return {
+      dpr: 1,
+      ctx: null,
+      canvasReady: false,
+      nodeData: [],
+      edges: [],
+      selectedNode: null,
+      selectedNodePos: null,
+      defaultAvatar: '/static/default-avatar.png',
+      _destroyed: false,
+      _canvasRect: null,
+      // 节点颜色:技能统一金色
+      skillColor: '#F59E0B',
+      // 边颜色
+      edgeColor: '#CBD5E1'
+    }
+  },
+  computed: {
+    popupPosition: function() {
+      if (!this.selectedNodePos) return { display: 'none' }
+      return {
+        left: this.selectedNodePos.x + 'px',
+        top: this.selectedNodePos.y + 'px'
+      }
+    },
+    arrowPosition: function() {
+      return {}
+    },
+    displaySkills: function() {
+      if (!this.selectedNode || !this.selectedNode.skills) return []
+      return this.selectedNode.skills.slice(0, 8)
+    }
+  },
+  watch: {
+    abilities: {
+      handler: function() {
+        this.buildNodesAndEdges()
+        this.draw()
+      },
+      deep: true
+    },
+    canvasWidth: function() {
+      this.buildNodesAndEdges()
+      this.draw()
+    },
+    canvasHeight: function() {
+      this.buildNodesAndEdges()
+      this.draw()
+    }
+  },
+  mounted: function() {
+    this._destroyed = false
+    var self = this
+    try {
+      this.dpr = uni.getWindowInfo().pixelRatio || 1
+    } catch (e) {
+      this.dpr = 1
+    }
+    // 延迟初始化 canvas(等待 DOM 布局完成)
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[AbilityDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
+        }
+      })
+    }
+    this.$nextTick(function() {
+      if (self._destroyed) return
+      self.$nextTick(function() {
+        if (!self._destroyed) {
+          tryInit()
+        }
+      })
+    })
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+  },
+  methods: {
+    /* ===========================
+     * 数据构建
+     * =========================== */
+
+    buildNodesAndEdges: function() {
+      if (!this.abilities || this.abilities.length === 0) {
+        this.nodeData = []
+        this.edges = []
+        return
+      }
+
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      var n = this.abilities.length
+      var self = this
+
+      // 径向分布半径(确保不超出画布,留边距)
+      var maxRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
+      // 如果节点多,缩小半径避免重叠(最小 0.2)
+      var layoutRadius = maxRadius
+      if (n > 6) {
+        layoutRadius = maxRadius * Math.max(0.22, 1 - (n - 6) * 0.06)
+      }
+
+      // 1. 中心节点(自己)
+      var centerNode = {
+        id: 'center',
+        isCenter: true,
+        displayName: '我',
+        radius: 30,
+        x: centerX,
+        y: centerY
+      }
+
+      // 2. 能力节点(提供技能帮助的人)
+      var abilityNodes = this.abilities.map(function(a, idx) {
+        var angle = (2 * Math.PI * idx) / n
+        var nodeRadius = 24 + Math.min(12, (a.skillCount - 1) * 2)
+        return {
+          id: 'ability_' + a.contactId,
+          contactId: a.contactId,
+          name: a.name,
+          displayName: a.name ? a.name.charAt(0) : '?',
+          avatar: a.avatar,
+          relationshipType: a.relationshipType,
+          skillCount: a.skillCount || 0,
+          skills: a.skills || [],
+          lastHelpedAt: a.lastHelpedAt,
+          radius: nodeRadius,
+          color: self.skillColor,
+          x: centerX + layoutRadius * Math.cos(angle),
+          y: centerY + layoutRadius * Math.sin(angle)
+        }
+      })
+
+      this.nodeData = [centerNode].concat(abilityNodes)
+
+      // 3. 边:中心连接到每个能力节点
+      this.edges = abilityNodes.map(function(n) {
+        return {
+          source: centerNode,
+          target: n,
+          skills: n.skills
+        }
+      })
+    },
+
+    /* ===========================
+     * 渲染
+     * =========================== */
+
+    draw: function() {
+      if (!this.ctx) return
+      var ctx = this.ctx
+      var w = this.canvasWidth
+      var h = this.canvasHeight
+
+      ctx.clearRect(0, 0, w, h)
+
+      // 1. 绘制边(连接线 + 箭头 + 技能标注)
+      this._drawEdges(ctx)
+
+      // 2. 绘制节点
+      for (var i = 0; i < this.nodeData.length; i++) {
+        this._drawNode(ctx, this.nodeData[i])
+      }
+    },
+
+    _drawEdges: function(ctx) {
+      var self = this
+      var centerR = 30 // 中心节点半径
+
+      this.edges.forEach(function(e) {
+        var source = e.source
+        var target = e.target
+
+        // 计算方向
+        var dx = target.x - source.x
+        var dy = target.y - source.y
+        var dist = Math.sqrt(dx * dx + dy * dy) || 1
+        var nx = dx / dist
+        var ny = dy / dist
+
+        // 线段起点(从中心节点边缘)
+        var sx = source.x + nx * centerR
+        var sy = source.y + ny * centerR
+        // 线段终点(到能力节点边缘)
+        var tx = target.x - nx * target.radius
+        var ty = target.y - ny * target.radius
+
+        // 绘制线段
+        ctx.beginPath()
+        ctx.moveTo(sx, sy)
+        ctx.lineTo(tx, ty)
+        ctx.strokeStyle = self.edgeColor
+        ctx.lineWidth = 1.5
+        ctx.stroke()
+
+        // 箭头(靠近中心侧)
+        var arrowLen = 8
+        var arrowWidth = 4
+        // 箭头位置:在终点偏回一点
+        var ax = tx
+        var ay = ty
+        ctx.beginPath()
+        ctx.moveTo(ax, ay)
+        ctx.lineTo(ax - nx * arrowLen + ny * arrowWidth, ay - ny * arrowLen - nx * arrowWidth)
+        ctx.lineTo(ax - nx * arrowLen - ny * arrowWidth, ay - ny * arrowLen + nx * arrowWidth)
+        ctx.closePath()
+        ctx.fillStyle = self.edgeColor
+        ctx.fill()
+
+        // 技能标注(在能力节点附近)
+        var skills = e.skills || []
+        if (skills.length > 0) {
+          var label = self._buildSkillLabel(skills)
+          var labelX = target.x - nx * (target.radius + 10)
+          var labelY = target.y - ny * (target.radius + 10)
+          // 微调偏移让文字不压在连线上
+          var perpX = -ny
+          var perpY = nx
+          labelX += perpX * 12
+          labelY += perpY * 12
+          ctx.font = '11px PingFang SC, sans-serif'
+          ctx.fillStyle = '#475569'
+          ctx.textAlign = 'center'
+          ctx.textBaseline = 'middle'
+          ctx.fillText(label, labelX, labelY)
+        }
+      })
+    },
+
+    _buildSkillLabel: function(skills) {
+      if (!skills || skills.length === 0) return ''
+      if (skills.length === 1) return skills[0]
+      // 最多显示 2 个技能名
+      var first = skills[0]
+      var second = skills[1]
+      var combined = first + '、' + second
+      if (combined.length <= 12) {
+        if (skills.length > 2) {
+          return combined + ' +' + (skills.length - 2)
+        }
+        return combined
+      }
+      // 名字太长,只显示第一个 + 数量
+      return first + ' +' + (skills.length - 1)
+    },
+
+    _drawNode: function(ctx, node) {
+      var r = node.radius
+      var x = node.x
+      var y = node.y
+
+      // 裁剪到画布范围
+      if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
+        return
+      }
+
+      if (node.isCenter) {
+        // 中心节点:实心圆 + "我" 字
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, Math.PI * 2)
+        ctx.fillStyle = '#10B981'
+        ctx.fill()
+        ctx.strokeStyle = '#FFFFFF'
+        ctx.lineWidth = 2
+        ctx.stroke()
+
+        ctx.font = 'bold 18px PingFang SC, sans-serif'
+        ctx.fillStyle = '#FFFFFF'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText('我', x, y + 1)
+      } else {
+        // 能力节点:实心圆 + 头像(异步加载,兜底首字)
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, Math.PI * 2)
+        ctx.fillStyle = node.color
+        ctx.fill()
+
+        // 白边
+        ctx.strokeStyle = '#FFFFFF'
+        ctx.lineWidth = 2
+        ctx.stroke()
+
+        // 首字(先画兜底,头像加载成功后覆盖)
+        ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
+        ctx.fillStyle = '#FFFFFF'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(node.displayName, x, y + 1)
+
+        // 头像:圆形裁剪绘制
+        if (node.avatar && this._canvasNode && this._canvasNode.createImage) {
+          var img = this._canvasNode.createImage()
+          var self = this
+          img.onload = function() {
+            if (self._destroyed || !self.ctx) return
+            var ctx2 = self.ctx
+            ctx2.save()
+            ctx2.beginPath()
+            ctx2.arc(x, y, r, 0, Math.PI * 2)
+            ctx2.clip()
+            ctx2.drawImage(img, x - r, y - r, r * 2, r * 2)
+            ctx2.restore()
+            // 重绘白边(覆盖裁剪边缘)
+            ctx2.beginPath()
+            ctx2.arc(x, y, r, 0, Math.PI * 2)
+            ctx2.strokeStyle = '#FFFFFF'
+            ctx2.lineWidth = 2
+            ctx2.stroke()
+          }
+          img.onerror = function() {
+            // 头像加载失败,保留首字兜底
+          }
+          img.src = node.avatar
+        }
+      }
+    },
+
+    /* ===========================
+     * 交互
+     * =========================== */
+
+    onCanvasTap: function(e) {
+      if (!this.interactive) return
+      var touches = e.touches || e.changedTouches
+      if (!touches || touches.length === 0) return
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) return
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+
+      var hitNode = this._hitTest(x, y)
+      if (hitNode && !hitNode.isCenter) {
+        this.selectedNode = hitNode
+        this.selectedNodePos = { x: hitNode.x, y: hitNode.y }
+      } else {
+        this.selectedNode = null
+        this.selectedNodePos = null
+      }
+    },
+
+    _hitTest: function(x, y) {
+      for (var i = this.nodeData.length - 1; i >= 0; i--) {
+        var node = this.nodeData[i]
+        var dx = x - node.x
+        var dy = y - node.y
+        if (dx * dx + dy * dy <= node.radius * node.radius) {
+          return node
+        }
+      }
+      return null
+    },
+
+    /* ===========================
+     * 工具方法
+     * =========================== */
+
+    _getCanvasRect: function() {
+      if (this._canvasRect) {
+        return {
+          left: this._canvasRect.left,
+          top: this._canvasRect.top,
+          width: this.canvasWidth,
+          height: this.canvasHeight
+        }
+      }
+      return {
+        left: 0,
+        top: 0,
+        width: this.canvasWidth,
+        height: this.canvasHeight
+      }
+    },
+
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      // 直接截取前 10 位 yyyy-MM-dd,无需 Date 解析
+      return String(dateStr).substring(0, 10)
+    },
+
+    /* ===========================
+     * Canvas 初始化
+     * =========================== */
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + 'abilityGraphCanvas')
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0]) {
+              if (cb) cb(false)
+              return
+            }
+            var info = res[0]
+            if (!info.node) {
+              if (cb) cb(false)
+              return
+            }
+
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try {
+                var winInfo = uni.getWindowInfo()
+                winWidth = winInfo.windowWidth || 375
+              } catch (e2) {}
+              width = Math.round((self.canvasWidth || 340) * winWidth / 750)
+              height = Math.round((self.canvasHeight || 280) * winWidth / 750)
+            }
+
+            var pixelRatio = 2
+            try {
+              var system = uni.getWindowInfo()
+              pixelRatio = system.pixelRatio || 2
+            } catch (e) {}
+
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+
+            var ctx = canvas.getContext('2d')
+            if (!ctx) {
+              if (cb) cb(false)
+              return
+            }
+            ctx.scale(pixelRatio, pixelRatio)
+
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self.canvasReady = true
+            // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
+            if (info.rect) {
+              self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
+            }
+
+            self.buildNodesAndEdges()
+            self.draw()
+            if (cb) cb(true)
+          } catch (e) {
+            if (cb) cb(false)
+          }
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ability-graph-wrapper {
+  position: relative;
+  width: 100%;
+  min-height: 200px;
+  height: auto;
+  margin: 10rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  overflow: hidden;
+}
+.ability-graph-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 1;
+}
+
+/* 详情弹窗 */
+.ability-detail-popup {
+  position: absolute;
+  z-index: 10;
+  pointer-events: none;
+  transform: translate(-50%, -100%);
+  margin-top: -16rpx;
+}
+.popup-content {
+  background: #FFFFFF;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
+  min-width: 200rpx;
+  max-width: 320rpx;
+  pointer-events: auto;
+}
+.popup-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.popup-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  margin-right: 12rpx;
+}
+.popup-info {
+  display: flex;
+  flex-direction: column;
+}
+.popup-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.popup-relation {
+  font-size: 22rpx;
+  color: #64748B;
+  margin-top: 2rpx;
+}
+.popup-skills-section {
+  padding: 12rpx 0;
+  border-top: 1rpx solid #F1F5F9;
+  border-bottom: 1rpx solid #F1F5F9;
+  margin-bottom: 12rpx;
+}
+.popup-skills-title {
+  font-size: 24rpx;
+  font-weight: 700;
+  color: #F59E0B;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.popup-skills-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+.popup-skill-item {
+  font-size: 22rpx;
+  color: #475569;
+  background: #FEF3C7;
+  border-radius: 8rpx;
+  padding: 4rpx 10rpx;
+}
+.popup-skill-more {
+  font-size: 22rpx;
+  color: #94A3B8;
+  padding: 4rpx 6rpx;
+}
+.popup-time {
+  display: flex;
+  align-items: center;
+  margin-top: 4rpx;
+}
+.popup-time-label {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.popup-time-value {
+  font-size: 22rpx;
+  color: #475569;
+}
+.popup-arrow {
+  position: absolute;
+  bottom: -8rpx;
+  left: 50%;
+  margin-left: -8rpx;
+  width: 0;
+  height: 0;
+  border-left: 8rpx solid transparent;
+  border-right: 8rpx solid transparent;
+  border-top: 8rpx solid #FFFFFF;
+}
+
+/* 空状态 */
+.ability-graph-empty,
+.ability-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60rpx 20rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  margin: 10rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 8rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-bottom: 24rpx;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #64748B;
+}
+</style>

+ 919 - 0
cfc-frontend/components/OctopusDiagram.vue

@@ -0,0 +1,919 @@
+<template>
+  <!-- 章鱼图 - 基于 Canvas force-directed 物理引擎 -->
+  <view class="octopus-graph-wrapper" v-if="tentacles && tentacles.length > 0" :style="{ minHeight: canvasHeight + 'px' }">
+    <canvas
+      class="octopus-graph-canvas"
+      canvas-id="octopusGraphCanvas"
+      id="octopusGraphCanvas"
+      type="2d"
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      @touchstart="onTouchStart"
+      @touchmove="onTouchMove"
+      @touchend="onTouchEnd" />
+    <!-- 点击节点时显示详情弹窗 -->
+    <view class="octopus-detail-popup" v-if="selectedTentacle" :style="popupPosition">
+      <view class="popup-content">
+        <view class="popup-header">
+          <image class="popup-avatar" :src="selectedTentacle.avatar || defaultAvatar" mode="aspectFill" />
+          <view class="popup-info">
+            <text class="popup-name">{{ selectedTentacle.name }}</text>
+            <text class="popup-relation" v-if="selectedTentacle.relationshipType">{{ selectedTentacle.relationshipType }}</text>
+          </view>
+        </view>
+        <view class="popup-stats">
+          <view class="stat-item">
+            <text class="stat-value">{{ selectedTentacle.helpCount }}</text>
+            <text class="stat-label">次数</text>
+          </view>
+          <view class="stat-divider"></view>
+          <view class="stat-item">
+            <text class="stat-value">¥{{ formatAmount(selectedTentacle.totalAmount) }}</text>
+            <text class="stat-label">金额</text>
+          </view>
+          <view class="stat-divider"></view>
+          <view class="stat-item">
+            <text class="stat-value">{{ selectedTentacle.mainHelpTypeName }}</text>
+            <text class="stat-label">主导类型</text>
+          </view>
+        </view>
+        <view class="popup-actions">
+          <text class="action-btn" @tap="viewRecords(selectedTentacle.contactId)">查看明细</text>
+        </view>
+        <view class="popup-arrow" :style="arrowPosition"></view>
+      </view>
+    </view>
+  </view>
+  <!-- 空状态 -->
+  <view class="octopus-graph-empty" v-else-if="tentacles && tentacles.length === 0">
+    <text class="empty-icon">🐙</text>
+    <text class="empty-text">暂无章鱼图数据</text>
+    <text class="empty-hint">记录谁帮助过你,画出你的触手图</text>
+    <text class="action-btn" @tap="goAddHelp">+ 记录一次帮助</text>
+  </view>
+  <!-- 加载状态 -->
+  <view class="octopus-loading" v-else>
+    <text class="loading-text">加载章鱼图...</text>
+  </view>
+</template>
+
+<script>
+/**
+ * OctopusDiagram - 章鱼图 Canvas 组件
+ *
+ * 复用 FamilyRelationGraph 的 force-directed 物理引擎:
+ * - 中心节点 = 当前用户(固定不动)
+ * - 触手节点 = 帮助过我的人(分布在四周)
+ * - 节点大小 = 帮助次数
+ * - 节点颜色 = 主导帮助类型
+ * - 触手末端标注 = 帮助金额
+ * - 点击节点 → 显示帮助明细
+ *
+ * 数据源:GET /api/octopus/tentacles
+ * 返回字段:contactId, name, avatar, relationshipType, helpCount, totalAmount,
+ *          lastHelpedAt, mainHelpType, mainHelpTypeName, radius
+ */
+export default {
+  name: 'OctopusDiagram',
+  props: {
+    /** 当前用户ID(可从 storage 获取,也可由父组件传入) */
+    selfId: {
+      type: [Number, String],
+      default: null
+    },
+    /** 画布宽度 rpx */
+    canvasWidth: {
+      type: Number,
+      default: 340
+    },
+    /** 画布高度 rpx */
+    canvasHeight: {
+      type: Number,
+      default: 280
+    },
+    /** 是否允许交互(点击节点查看明细) */
+    interactive: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data: function() {
+    return {
+      dpr: 1,
+      // 物理参数
+      physics: {
+        repulsion: 600,       // 节点间斥力强度
+        springLength: 140,    // 弹簧自然长度(中心到触手距离)
+        springStrength: 0.05, // 弹簧劲度系数
+        centering: 0.02,      // 中心引力
+        damping: 0.90,        // 速度阻尼
+        maxSpeed: 10,         // 最大速度
+        minDistance: 40       // 最小节点距离
+      },
+      // 动画状态
+      dragging: null,         // { nodeIndex, offsetX, offsetY }
+      dragStartPos: null,
+      animFrameId: null,
+      isSimulating: false,
+      stillFrames: 0,
+      energyThreshold: 1.5,
+      stillFrameLimit: 30,
+      // 节点数据
+      nodeData: [],
+      // 边数据(中心到每个触手)
+      edges: [],
+      // 触手数据(从 API 获取)
+      tentacles: [],
+      // 选中的触手(用于显示弹窗)
+      selectedTentacle: null,
+      // 默认头像
+      defaultAvatar: '/static/default-avatar.png',
+      // 是否销毁
+      _destroyed: false,
+      // 类型颜色映射
+      typeColors: {
+        MONEY: '#FF8C42',    // 橙 - 金钱
+        ITEM: '#10B981',     // 绿 - 物质
+        EMOTION: '#FF6B9D',  // 粉 - 情感
+        RESOURCE: '#6366F1', // 蓝 - 资源
+        SKILL: '#F59E0B',    // 金 - 技能
+        TIME: '#8B5CF6',     // 紫 - 时间
+        OTHER: '#94A3B8'     // 灰 - 其他
+      }
+    }
+  },
+  computed: {
+    // 中心节点(自己)
+    centerNode: function() {
+      return this.nodeData.find(function(n) { return n.isCenter })
+    }
+  },
+  watch: {
+    tentacles: {
+      handler: function() {
+        this.buildNodesAndEdges()
+        this.resetSimulation()
+      },
+      immediate: true,
+      deep: true
+    },
+    canvasWidth: function() {
+      this.resetSimulation()
+    },
+    canvasHeight: function() {
+      this.resetSimulation()
+    }
+  },
+  mounted: function() {
+    this._destroyed = false
+    var self = this
+    try {
+      this.dpr = uni.getWindowInfo().pixelRatio || 1
+    } catch (e) {
+      this.dpr = 1
+    }
+    // 延迟初始化 canvas(等待 DOM 布局完成)
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[OctopusDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
+        }
+      })
+    }
+    this.$nextTick(function() {
+      if (self._destroyed) return
+      self.updateCanvasSize()
+      self.$nextTick(function() {
+        if (!self._destroyed) {
+          tryInit()
+        }
+      })
+    })
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+    this.stopSimulation()
+  },
+  methods: {
+    /* ===========================
+     * 数据构建
+     * =========================== */
+
+    buildNodesAndEdges: function() {
+      if (!this.tentacles || this.tentacles.length === 0) {
+        this.nodeData = []
+        this.edges = []
+        return
+      }
+
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      var self = this
+
+      // 1. 中心节点(自己)
+      var centerNode = {
+        id: 'center',
+        isCenter: true,
+        nickname: '我',
+        displayName: '我',
+        radius: 32,
+        x: centerX,
+        y: centerY,
+        vx: 0,
+        vy: 0,
+        fx: 0,
+        fy: 0,
+        fixed: true
+      }
+
+      // 2. 触手节点(帮助过我的人)
+      var tentacleNodes = this.tentacles.map(function(t, idx) {
+        // 初始位置:圆形分布
+        var angle = (2 * Math.PI * idx) / self.tentacles.length
+        var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.38
+        var initX = centerX + r * Math.cos(angle)
+        var initY = centerY + r * Math.sin(angle)
+
+        return {
+          id: 'tentacle_' + t.contactId,
+          isCenter: false,
+          contactId: t.contactId,
+          nickname: t.name,
+          displayName: t.name ? t.name.charAt(0) : '?',
+          avatar: t.avatar,
+          relationshipType: t.relationshipType,
+          helpCount: t.helpCount,
+          totalAmount: t.totalAmount,
+          mainHelpType: t.mainHelpType,
+          mainHelpTypeName: t.mainHelpTypeName,
+          radius: t.radius || 28,
+          color: self.typeColors[t.mainHelpType] || self.typeColors.OTHER,
+          x: initX,
+          y: initY,
+          vx: 0,
+          vy: 0,
+          fx: 0,
+          fy: 0,
+          fixed: false
+        }
+      })
+
+      this.nodeData = [centerNode].concat(tentacleNodes)
+
+      // 3. 边:中心连接到每个触手
+      this.edges = tentacleNodes.map(function(n) {
+        return {
+          source: centerNode,
+          target: n,
+          helpCount: n.helpCount,
+          totalAmount: n.totalAmount
+        }
+      })
+    },
+
+    /* ===========================
+     * 物理引擎
+     * =========================== */
+
+    resetSimulation: function() {
+      this.stopSimulation()
+      // 重新初始化位置
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      var self = this
+      this.nodeData.forEach(function(n, idx) {
+        if (!n.isCenter) {
+          var angle = (2 * Math.PI * (idx - 1)) / Math.max(1, self.nodeData.length - 1)
+          var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.38
+          n.x = centerX + r * Math.cos(angle)
+          n.y = centerY + r * Math.sin(angle)
+        } else {
+          n.x = centerX
+          n.y = centerY
+        }
+        n.vx = 0
+        n.vy = 0
+        n.fx = 0
+        n.fy = 0
+      })
+      this.startSimulation()
+    },
+
+    initSimulation: function() {
+      if (!this.nodeData || this.nodeData.length === 0) return
+      this.startSimulation()
+    },
+
+    startSimulation: function() {
+      if (this.isSimulating) return
+      this.isSimulating = true
+      this.stillFrames = 0
+      this.simulationTick()
+    },
+
+    stopSimulation: function() {
+      this.isSimulating = false
+      if (this.animFrameId) {
+        clearTimeout(this.animFrameId)
+        this.animFrameId = null
+      }
+    },
+
+    simulationTick: function() {
+      if (!this.isSimulating) return
+      if (this._destroyed) {
+        this.isSimulating = false
+        return
+      }
+
+      var nodes = this.nodeData
+      var edges = this.edges
+      var p = this.physics
+
+      // 1. 清零力
+      for (var i = 0; i < nodes.length; i++) {
+        if (!nodes[i].fixed) {
+          nodes[i].fx = 0
+          nodes[i].fy = 0
+        }
+      }
+
+      // 2. 节点间斥力(触手之间互斥)
+      for (var i = 1; i < nodes.length; i++) { // 跳过中心节点(索引0)
+        for (var j = i + 1; j < nodes.length; j++) {
+          var dx = nodes[j].x - nodes[i].x
+          var dy = nodes[j].y - nodes[i].y
+          var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
+          if (dist < p.minDistance) {
+            var force = p.repulsion / (dist * dist)
+            var fx = force * dx / dist
+            var fy = force * dy / dist
+            nodes[i].fx -= fx
+            nodes[i].fy -= fy
+            nodes[j].fx += fx
+            nodes[j].fy += fy
+          }
+        }
+      }
+
+      // 3. 边弹簧引力(中心 -> 触手)
+      for (var k = 0; k < edges.length; k++) {
+        var e = edges[k]
+        var dx = e.target.x - e.source.x
+        var dy = e.target.y - e.source.y
+        var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
+        var force = p.springStrength * (dist - p.springLength)
+        var fx = force * dx / dist
+        var fy = force * dy / dist
+        if (!e.target.fixed) {
+          e.target.fx += fx
+          e.target.fy += fy
+        }
+      }
+
+      // 4. 中心引力(将触手轻轻拉向画布中心)
+      for (var m = 1; m < nodes.length; m++) {
+        var dx = (this.canvasWidth / 2) - nodes[m].x
+        var dy = (this.canvasHeight / 2) - nodes[m].y
+        var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
+        var force = p.centering * dist
+        if (!nodes[m].fixed) {
+          nodes[m].fx += force * dx / dist
+          nodes[m].fy += force * dy / dist
+        }
+      }
+
+      // 5. 更新速度和位置
+      var totalEnergy = 0
+      for (var n = 1; n < nodes.length; n++) { // 跳过中心节点
+        if (nodes[n].fixed) continue
+        nodes[n].vx = (nodes[n].vx + nodes[n].fx) * p.damping
+        nodes[n].vy = (nodes[n].vy + nodes[n].fy) * p.damping
+        var speed = Math.sqrt(nodes[n].vx * nodes[n].vx + nodes[n].vy * nodes[n].vy)
+        if (speed > p.maxSpeed) {
+          nodes[n].vx = nodes[n].vx / speed * p.maxSpeed
+          nodes[n].vy = nodes[n].vy / speed * p.maxSpeed
+        }
+        nodes[n].x += nodes[n].vx
+        nodes[n].y += nodes[n].vy
+        totalEnergy += speed * speed
+      }
+
+      // 6. 渲染
+      this._doRender()
+
+      // 7. 收敛检测
+      if (totalEnergy < p.energyThreshold) {
+        this.stillFrames++
+      } else {
+        this.stillFrames = 0
+      }
+      if (this.stillFrames >= p.stillFrameLimit) {
+        this.stopSimulation()
+        return
+      }
+
+      // 8. 下一帧
+      var self = this
+      this.animFrameId = setTimeout(function() {
+        self.simulationTick()
+      }, 16)
+    },
+
+    /* ===========================
+     * 渲染
+     * =========================== */
+
+    _doRender: function() {
+      if (!this.ctx) { return }
+      var ctx = this.ctx
+      var w = this.canvasWidth
+      var h = this.canvasHeight
+
+      ctx.clearRect(0, 0, w, h)
+
+      // 1. 绘制边(触手线条)
+      this._drawEdges(ctx)
+
+      // 2. 绘制节点
+      for (var i = 0; i < this.nodeData.length; i++) {
+        this._drawNode(ctx, this.nodeData[i])
+      }
+    },
+
+    _drawEdges: function(ctx) {
+      var self = this
+      this.edges.forEach(function(e) {
+        var source = e.source
+        var target = e.target
+
+        // 贝塞尔曲线模拟触手弯曲
+        var cx = (source.x + target.x) / 2
+        var cy = (source.y + target.y) / 2
+        // 偏移控制点,制造弯曲感
+        var offsetX = (target.y - source.y) * 0.15
+        var offsetY = -(target.x - source.x) * 0.15
+
+        ctx.beginPath()
+        ctx.moveTo(source.x, source.y)
+        ctx.quadraticCurveTo(cx + offsetX, cy + offsetY, target.x, target.y)
+
+        // 线条宽度随帮助次数变化
+        var lineWidth = 2 + Math.min(4, e.helpCount * 0.5)
+        ctx.lineWidth = lineWidth
+        // 线条颜色用目标节点颜色,透明度递减
+        ctx.strokeStyle = self._hexToRgba(target.color, 0.4)
+        ctx.stroke()
+
+        // 金额标注(在触手末端附近)
+        if (e.totalAmount && e.totalAmount > 0) {
+          var labelX = (source.x + target.x) / 2
+          var labelY = (source.y + target.y) / 2 - 20
+          ctx.font = '12px PingFang SC, sans-serif'
+          ctx.fillStyle = '#FF8C42'
+          ctx.textAlign = 'center'
+          ctx.fillText('¥' + self._formatAmountShort(e.totalAmount), labelX, labelY)
+        }
+      })
+    },
+
+    _drawNode: function(ctx, node) {
+      var r = node.radius
+      var x = node.x
+      var y = node.y
+
+      // 裁剪到画布范围
+      if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
+        return
+      }
+
+      if (node.isCenter) {
+        // 中心节点:实心圆 + "我" 字
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, Math.PI * 2)
+        ctx.fillStyle = '#10B981'
+        ctx.fill()
+        ctx.strokeStyle = '#FFFFFF'
+        ctx.lineWidth = 2
+        ctx.stroke()
+
+        ctx.font = 'bold 18px PingFang SC, sans-serif'
+        ctx.fillStyle = '#FFFFFF'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText('我', x, y + 1)
+      } else {
+        // 触手节点:实心圆 + 首字
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, Math.PI * 2)
+        ctx.fillStyle = node.color
+        ctx.fill()
+
+        // 白边
+        ctx.strokeStyle = '#FFFFFF'
+        ctx.lineWidth = 2
+        ctx.stroke()
+
+        // 首字
+        ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
+        ctx.fillStyle = '#FFFFFF'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(node.displayName, x, y + 1)
+      }
+    },
+
+    /* ===========================
+     * 交互
+     * =========================== */
+
+    _hitTest: function(x, y) {
+      // 从后往前测试,优先触手节点
+      for (var i = this.nodeData.length - 1; i >= 0; i--) {
+        var node = this.nodeData[i]
+        var dx = x - node.x
+        var dy = y - node.y
+        if (dx * dx + dy * dy <= node.radius * node.radius) {
+          return i
+        }
+      }
+      return -1
+    },
+
+    onTouchStart: function(e) {
+      if (!this.interactive) return
+      var touches = e.touches || e.changedTouches
+      if (!touches || touches.length === 0) return
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) return
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+
+      var hitIndex = this._hitTest(x, y)
+      if (hitIndex >= 0 && !this.nodeData[hitIndex].isCenter) {
+        this.dragging = {
+          nodeIndex: hitIndex,
+          offsetX: x - this.nodeData[hitIndex].x,
+          offsetY: y - this.nodeData[hitIndex].y
+        }
+        this.dragStartPos = { x: x, y: y }
+        this.nodeData[hitIndex].fixed = true
+      } else if (hitIndex >= 0 && this.nodeData[hitIndex].isCenter) {
+        // 点击中心节点不拖拽
+      } else {
+        // 点击空白处关闭弹窗
+        this.selectedTentacle = null
+      }
+    },
+
+    onTouchMove: function(e) {
+      if (!this.dragging) return
+      var touches = e.touches || e.changedTouches
+      if (!touches || touches.length === 0) return
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) return
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+
+      var node = this.nodeData[this.dragging.nodeIndex]
+      node.x = x - this.dragging.offsetX
+      node.y = y - this.dragging.offsetY
+      node.vx = 0
+      node.vy = 0
+
+      if (!this.isSimulating) {
+        this._doRender()
+      }
+    },
+
+    onTouchEnd: function(e) {
+      if (this.dragging) {
+        var node = this.nodeData[this.dragging.nodeIndex]
+        node.fixed = false
+        this.dragging = null
+
+        // 判断是否点击(移动距离很小)
+        if (this.dragStartPos) {
+          var touches = e.changedTouches
+          if (touches && touches.length > 0) {
+            var canvasRect = this._getCanvasRect()
+            if (canvasRect) {
+              var x = touches[0].clientX - canvasRect.left
+              var y = touches[0].clientY - canvasRect.top
+              var dx = x - this.dragStartPos.x
+              var dy = y - this.dragStartPos.y
+              if (dx * dx + dy * dy < 100) { // 10px 以内算点击
+                this._onNodeClick(this.dragging.nodeIndex)
+              }
+            }
+          }
+        }
+
+        if (!this.isSimulating) {
+          this.startSimulation()
+        }
+      }
+    },
+
+    _onNodeClick: function(nodeIndex) {
+      if (nodeIndex <= 0) return // 中心节点不响应
+      var node = this.nodeData[nodeIndex]
+      if (!node.isCenter) {
+        // 找到对应的触手数据
+        var tentacle = this.tentacles.find(function(t) { return t.contactId == node.contactId })
+        if (tentacle) {
+          this.selectedTentacle = Object.assign({}, tentacle, {
+            contactId: node.contactId
+          })
+        }
+      }
+    },
+
+    viewRecords: function(contactId) {
+      this.$emit('view-records', contactId)
+    },
+
+    goAddHelp: function() {
+      this.$emit('add-help')
+    },
+
+    /* ===========================
+     * 工具方法
+     * =========================== */
+
+    _getCanvasRect: function() {
+      if (this._canvasRect) {
+        return {
+          left: this._canvasRect.left,
+          top: this._canvasRect.top,
+          width: this.canvasWidth,
+          height: this.canvasHeight
+        }
+      }
+      return {
+        left: 0,
+        top: 0,
+        width: this.canvasWidth,
+        height: this.canvasHeight
+      }
+    },
+
+    _hexToRgba: function(hex, alpha) {
+      var r = parseInt(hex.slice(1, 3), 16)
+      var g = parseInt(hex.slice(3, 5), 16)
+      var b = parseInt(hex.slice(5, 7), 16)
+      return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
+    },
+
+    _formatAmountShort: function(amount) {
+      var num = typeof amount === 'string' ? parseFloat(amount) : amount
+      if (num >= 10000) {
+        return (num / 10000).toFixed(1) + 'w'
+      }
+      return String(num)
+    },
+
+    formatAmount: function(amount) {
+      var num = typeof amount === 'string' ? parseFloat(amount) : amount
+      return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
+    },
+
+    /* ===========================
+     * Canvas 初始化
+     * =========================== */
+
+    updateCanvasSize: function() {
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('.octopus-graph-wrapper').boundingClientRect(function(rect) {
+        if (rect) {
+          self.canvasWidth = rect.width || 340
+        }
+      }).exec()
+    },
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + 'octopusGraphCanvas')
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0]) {
+              if (cb) cb(false)
+              return
+            }
+            var info = res[0]
+            if (!info.node) {
+              if (cb) cb(false)
+              return
+            }
+
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try {
+                var winInfo = uni.getWindowInfo()
+                winWidth = winInfo.windowWidth || 375
+              } catch (e2) {}
+              width = Math.round((self.canvasWidth || 340) * winWidth / 750)
+              height = Math.round((self.canvasHeight || 280) * winWidth / 750)
+            }
+
+            var pixelRatio = 2
+            try {
+              var system = uni.getWindowInfo()
+              pixelRatio = system.pixelRatio || 2
+            } catch (e) {}
+
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+
+            var ctx = canvas.getContext('2d')
+            if (!ctx) {
+              if (cb) cb(false)
+              return
+            }
+            ctx.scale(pixelRatio, pixelRatio)
+
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self.canvasReady = true
+            // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
+            if (info.rect) {
+              self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
+            }
+
+            if (Array.isArray(self.tentacles) && self.tentacles.length > 0) {
+              self.buildNodesAndEdges()
+              self.startSimulation()
+            } else {
+              self._doRender()
+            }
+            if (cb) cb(true)
+          } catch (e) {
+            if (cb) cb(false)
+          }
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.octopus-graph-wrapper {
+  position: relative;
+  width: 100%;
+  min-height: 200px;
+  height: auto;
+  margin: 10rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  overflow: hidden;
+}
+.octopus-graph-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 1;
+}
+
+/* 详情弹窗 */
+.octopus-detail-popup {
+  position: absolute;
+  z-index: 10;
+  pointer-events: none;
+  transform: translate(-50%, -100%);
+  margin-top: -16rpx;
+}
+.popup-content {
+  background: #FFFFFF;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
+  min-width: 200rpx;
+  max-width: 280rpx;
+  pointer-events: auto;
+}
+.popup-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.popup-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  margin-right: 12rpx;
+}
+.popup-info {
+  display: flex;
+  flex-direction: column;
+}
+.popup-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.popup-relation {
+  font-size: 22rpx;
+  color: #64748B;
+  margin-top: 2rpx;
+}
+.popup-stats {
+  display: flex;
+  justify-content: space-around;
+  padding: 12rpx 0;
+  border-top: 1rpx solid #F1F5F9;
+  border-bottom: 1rpx solid #F1F5F9;
+  margin-bottom: 16rpx;
+}
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.stat-value {
+  font-size: 24rpx;
+  font-weight: 700;
+  color: #10B981;
+}
+.stat-label {
+  font-size: 20rpx;
+  color: #94A3B8;
+  margin-top: 4rpx;
+}
+.stat-divider {
+  width: 1rpx;
+  height: 32rpx;
+  background: linear-gradient(180deg, transparent, #E2E8F0, transparent);
+}
+.popup-actions {
+  text-align: center;
+}
+.action-btn {
+  display: inline-block;
+  padding: 10rpx 24rpx;
+  background: #10B981;
+  color: #FFFFFF;
+  border-radius: 20rpx;
+  font-size: 24rpx;
+}
+.popup-arrow {
+  position: absolute;
+  bottom: -8rpx;
+  left: 50%;
+  margin-left: -8rpx;
+  width: 0;
+  height: 0;
+  border-left: 8rpx solid transparent;
+  border-right: 8rpx solid transparent;
+  border-top: 8rpx solid #FFFFFF;
+}
+
+/* 空状态 */
+.octopus-graph-empty,
+.octopus-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60rpx 20rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  margin: 10rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 8rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-bottom: 24rpx;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #64748B;
+}
+</style>

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

@@ -0,0 +1,873 @@
+<template>
+  <!-- 珍珠图 - 珍珠项链资源清单 -->
+  <view class="pearl-graph-wrapper" v-if="hasData" :style="{ minHeight: canvasHeight + 'px' }">
+    <canvas
+      class="pearl-graph-canvas"
+      canvas-id="pearlGraphCanvas"
+      id="pearlGraphCanvas"
+      type="2d"
+      :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
+      @touchstart="onTouchStart"
+      @touchmove="onTouchMove"
+      @touchend="onTouchEnd" />
+    <!-- 点击珍珠时显示详情弹窗 -->
+    <view class="pearl-detail-popup" :class="{ 'popup-below': showPopupBelow }" v-if="selectedNode" :style="popupPosition">
+      <view class="popup-content">
+        <view class="popup-header">
+          <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
+          <view class="popup-info">
+            <text class="popup-name">{{ selectedNode.name }}</text>
+            <view class="popup-tags">
+              <text class="popup-tag" :style="{ background: selectedNode.color, color: '#FFFFFF' }">{{ selectedNode.groupTypeName }}</text>
+              <text class="popup-tag popup-tag-rel" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
+            </view>
+          </view>
+        </view>
+        <!-- 技能列表(仅 SKILL 组) -->
+        <view class="popup-skills" v-if="selectedNode.groupType === 'SKILL' && selectedNode.skills && selectedNode.skills.length > 0">
+          <text class="popup-skill-count">技能数 ×{{ selectedNode.skillCount || 0 }}</text>
+          <view class="popup-skill-list">
+            <text class="popup-skill-item" v-for="(s, idx) in displaySkills" :key="idx">{{ s }}</text>
+          </view>
+        </view>
+        <!-- 描述 -->
+        <view class="popup-desc" v-if="selectedNode.description">
+          <text class="popup-desc-text">{{ selectedNode.description }}</text>
+        </view>
+        <!-- 操作按钮 -->
+        <view class="popup-actions">
+          <text class="action-btn action-btn-delete" v-if="canDelete" @tap="onDelete">删除</text>
+        </view>
+        <view class="popup-arrow" :class="{ 'popup-arrow-down': showPopupBelow }"></view>
+      </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>
+  </view>
+  <!-- 加载状态 -->
+  <view class="pearl-loading" v-else>
+    <text class="loading-text">加载珍珠图...</text>
+  </view>
+</template>
+
+<script>
+/**
+ * PearlDiagram - 珍珠图 Canvas 组件
+ *
+ * 珍珠项链式资源清单可视化:
+ * - 中心节点 = 当前用户"我"(固定不动)
+ * - 珍珠节点 = 资源,按 4 组分布在中心周围:
+ *   人脉(PERSON) / 技能(SKILL) / 信息(INFO) / 场所(PLACE)
+ * - 每组以弧形分布在中心周围,颜色编码
+ * - 点击珍珠 → 显示详情弹窗
+ *
+ * 数据源:父组件通过 resources prop 传入
+ * 布局方式:确定性弧形布局(非力导向)
+ */
+export default {
+  name: 'PearlDiagram',
+  props: {
+    /** 资源数据 { groups: [{ type, typeName, items }] } */
+    resources: {
+      type: Object,
+      default: null
+    },
+    /** 当前用户ID */
+    selfId: {
+      type: [Number, String],
+      default: null
+    },
+    /** 画布宽度 px */
+    canvasWidth: {
+      type: Number,
+      default: 340
+    },
+    /** 画布高度 px */
+    canvasHeight: {
+      type: Number,
+      default: 400
+    },
+    /** 是否允许交互(点击珍珠查看详情) */
+    interactive: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data: function() {
+    return {
+      dpr: 1,
+      ctx: null,
+      _canvasNode: null,
+      _canvasRect: null,
+      canvasReady: false,
+      _destroyed: false,
+      // 珍珠节点数据
+      pearlNodes: [],
+      // 选中的节点(弹窗用)
+      selectedNode: null,
+      // 触摸状态
+      _touchStartPos: null,
+      // 默认头像
+      defaultAvatar: '/static/default-avatar.png',
+      // 分组配置:类型、颜色、半径比例、弧度范围(弧度)
+      // 0=右(3点钟), PI/2=下(6点), PI=左(9点), -PI/2=上(12点)
+      groupConfigs: [
+        { type: 'PERSON', typeName: '人脉', color: '#F97316', radiusPct: 0.28, startAngle: -Math.PI / 2, endAngle: 0 },
+        { type: 'SKILL', typeName: '技能', color: '#F59E0B', radiusPct: 0.38, startAngle: Math.PI, endAngle: Math.PI * 1.5 },
+        { type: 'INFO', typeName: '信息', color: '#10B981', radiusPct: 0.38, startAngle: 0, endAngle: Math.PI / 2 },
+        { type: 'PLACE', typeName: '场所', color: '#6366F1', radiusPct: 0.28, startAngle: Math.PI / 2, endAngle: Math.PI }
+      ],
+      // 弧度边距(每侧留白角度)
+      arcMargin: Math.PI / 20
+    }
+  },
+  computed: {
+    /** 是否有至少一组包含资源 */
+    hasData: function() {
+      if (!this.resources) return false
+      var groups = this.resources.groups
+      if (!groups || !Array.isArray(groups)) return false
+      for (var i = 0; i < groups.length; i++) {
+        if (groups[i].items && groups[i].items.length > 0) return true
+      }
+      return false
+    },
+    /** 资源已加载但全部为空 */
+    isEmpty: function() {
+      if (!this.resources) return false
+      var groups = this.resources.groups
+      if (!groups || !Array.isArray(groups)) return true
+      for (var i = 0; i < groups.length; i++) {
+        if (groups[i].items && groups[i].items.length > 0) return false
+      }
+      return true
+    },
+    /** 选中节点是否可删除(仅 INFO/PLACE 为手工登记资源;PERSON 来自联系人、SKILL 来自帮助记录聚合,均不可删) */
+    canDelete: function() {
+      if (!this.selectedNode) return false
+      return this.selectedNode.groupType === 'INFO' || this.selectedNode.groupType === 'PLACE'
+    },
+    /** 弹窗中展示的技能(前3个) */
+    displaySkills: function() {
+      if (!this.selectedNode || !this.selectedNode.skills) return []
+      return this.selectedNode.skills.slice(0, 3)
+    },
+    /** 弹窗是否显示在节点下方(节点太靠近顶部时) */
+    showPopupBelow: function() {
+      if (!this.selectedNode) return false
+      return this.selectedNode.y < 140
+    },
+    /** 弹窗定位样式 */
+    popupPosition: function() {
+      if (!this.selectedNode) return { display: 'none' }
+      var px = this.selectedNode.x
+      var py = this.selectedNode.y
+      // 限制弹窗不超出画布水平范围
+      var halfPopup = 120
+      var left = Math.max(halfPopup, Math.min(this.canvasWidth - halfPopup, px))
+      return {
+        left: left + 'px',
+        top: py + 'px'
+      }
+    }
+  },
+  watch: {
+    resources: {
+      handler: function() {
+        this.selectedNode = null
+        this.buildLayout()
+        if (this.canvasReady) {
+          this.drawAll()
+        }
+      },
+      deep: true
+    },
+    canvasWidth: function() {
+      this.buildLayout()
+      if (this.canvasReady) {
+        this.drawAll()
+      }
+    },
+    canvasHeight: function() {
+      this.buildLayout()
+      if (this.canvasReady) {
+        this.drawAll()
+      }
+    }
+  },
+  mounted: function() {
+    this._destroyed = false
+    var self = this
+    try {
+      this.dpr = uni.getWindowInfo().pixelRatio || 1
+    } catch (e) {
+      this.dpr = 1
+    }
+    // 延迟初始化 canvas(等待 DOM 布局完成)
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[PearlDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
+        }
+      })
+    }
+    this.$nextTick(function() {
+      if (self._destroyed) return
+      self.updateCanvasSize()
+      self.$nextTick(function() {
+        if (!self._destroyed) {
+          tryInit()
+        }
+      })
+    })
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+  },
+  methods: {
+    /* ===========================
+     * 布局构建(确定性弧形)
+     * =========================== */
+
+    /**
+     * 根据 resources 构建珍珠节点的 x, y 坐标
+     * 布局逻辑:
+     * - 中心 = (canvasWidth/2, canvasHeight/2) → "我"
+     * - 每组分配一个象限 + 特定半径
+     * - 组内珍珠沿弧线等间距分布
+     */
+    buildLayout: function() {
+      this.pearlNodes = []
+
+      if (!this.resources || !this.resources.groups || !Array.isArray(this.resources.groups)) {
+        return
+      }
+
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
+      var configs = this.groupConfigs
+
+      for (var c = 0; c < configs.length; c++) {
+        var config = configs[c]
+        // 在 resources.groups 中查找该类型
+        var group = null
+        for (var g = 0; g < this.resources.groups.length; g++) {
+          if (this.resources.groups[g].type === config.type) {
+            group = this.resources.groups[g]
+            break
+          }
+        }
+
+        if (!group || !group.items || group.items.length === 0) continue
+
+        var R = minDim * config.radiusPct
+        var effStart = config.startAngle + this.arcMargin
+        var effEnd = config.endAngle - this.arcMargin
+        var arcSpan = effEnd - effStart
+        var items = group.items
+
+        for (var i = 0; i < items.length; i++) {
+          var item = items[i]
+          var t = items.length === 1 ? 0.5 : i / (items.length - 1)
+          var angle = effStart + t * arcSpan
+
+          this.pearlNodes.push({
+            id: item.id || item.contactId || null,
+            contactId: item.contactId || null,
+            groupType: config.type,
+            groupTypeName: config.typeName,
+            name: item.name || '未知',
+            displayName: item.name ? item.name.charAt(0) : '?',
+            avatar: item.avatar || null,
+            description: item.description || '',
+            skillCount: item.skillCount || 0,
+            skills: item.skills || [],
+            relationshipType: item.relationshipType || '',
+            radius: (config.type === 'PERSON' || config.type === 'SKILL') ? 24 : 20,
+            color: config.color,
+            x: cx + R * Math.cos(angle),
+            y: cy + R * Math.sin(angle)
+          })
+        }
+      }
+    },
+
+    /* ===========================
+     * Canvas 渲染
+     * =========================== */
+
+    drawAll: function() {
+      if (!this.ctx) return
+      var ctx = this.ctx
+      var w = this.canvasWidth
+      var h = this.canvasHeight
+
+      ctx.clearRect(0, 0, w, h)
+
+      // 1. 绘制分组弧线背景(半透明色带)
+      this._drawGroupArcs(ctx)
+
+      // 2. 绘制中心节点"我"
+      this._drawCenterNode(ctx)
+
+      // 3. 绘制珍珠节点
+      for (var i = 0; i < this.pearlNodes.length; i++) {
+        this._drawPearlNode(ctx, this.pearlNodes[i])
+      }
+
+      // 4. 绘制分组标签
+      this._drawGroupLabels(ctx)
+    },
+
+    /** 绘制每组的弧形色带(背景)和连接线 */
+    _drawGroupArcs: function(ctx) {
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
+      var configs = this.groupConfigs
+      var self = this
+
+      for (var c = 0; c < configs.length; c++) {
+        var config = configs[c]
+        // 检查该组是否有节点
+        var hasItems = false
+        for (var p = 0; p < this.pearlNodes.length; p++) {
+          if (this.pearlNodes[p].groupType === config.type) {
+            hasItems = true
+            break
+          }
+        }
+        if (!hasItems) continue
+
+        var R = minDim * config.radiusPct
+        var effStart = config.startAngle + this.arcMargin
+        var effEnd = config.endAngle - this.arcMargin
+
+        // 色带背景(粗线,低透明度)
+        ctx.beginPath()
+        ctx.arc(cx, cy, R, effStart, effEnd)
+        ctx.lineWidth = 14
+        ctx.strokeStyle = self._hexToRgba(config.color, 0.12)
+        ctx.lineCap = 'round'
+        ctx.stroke()
+
+        // 连接线(细线,沿弧串联珍珠)
+        ctx.beginPath()
+        ctx.arc(cx, cy, R, effStart, effEnd)
+        ctx.lineWidth = 1.5
+        ctx.strokeStyle = self._hexToRgba(config.color, 0.3)
+        ctx.lineCap = 'butt'
+        ctx.stroke()
+
+        // 中心到组内每个珍珠的连线
+        for (var n = 0; n < this.pearlNodes.length; n++) {
+          var node = this.pearlNodes[n]
+          if (node.groupType !== config.type) continue
+          ctx.beginPath()
+          ctx.moveTo(cx, cy)
+          ctx.lineTo(node.x, node.y)
+          ctx.lineWidth = 1
+          ctx.strokeStyle = self._hexToRgba(config.color, 0.15)
+          ctx.stroke()
+        }
+      }
+    },
+
+    /** 绘制中心节点"我" */
+    _drawCenterNode: function(ctx) {
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var r = 30
+
+      // 外圈光晕
+      ctx.beginPath()
+      ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
+      ctx.fillStyle = this._hexToRgba('#10B981', 0.15)
+      ctx.fill()
+
+      // 实心圆
+      ctx.beginPath()
+      ctx.arc(cx, cy, r, 0, Math.PI * 2)
+      ctx.fillStyle = '#10B981'
+      ctx.fill()
+      ctx.strokeStyle = '#FFFFFF'
+      ctx.lineWidth = 3
+      ctx.stroke()
+
+      // "我" 字
+      ctx.font = 'bold 18px PingFang SC, sans-serif'
+      ctx.fillStyle = '#FFFFFF'
+      ctx.textAlign = 'center'
+      ctx.textBaseline = 'middle'
+      ctx.fillText('我', cx, cy + 1)
+    },
+
+    /** 绘制单个珍珠节点 */
+    _drawPearlNode: function(ctx, node) {
+      var x = node.x
+      var y = node.y
+      var r = node.radius
+
+      // 裁剪到画布范围
+      if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
+        return
+      }
+
+      // 珍珠光晕
+      ctx.beginPath()
+      ctx.arc(x, y, r + 3, 0, Math.PI * 2)
+      ctx.fillStyle = this._hexToRgba(node.color, 0.12)
+      ctx.fill()
+
+      // 实心圆(珍珠主体)
+      ctx.beginPath()
+      ctx.arc(x, y, r, 0, Math.PI * 2)
+      ctx.fillStyle = node.color
+      ctx.fill()
+
+      // 白边
+      ctx.strokeStyle = '#FFFFFF'
+      ctx.lineWidth = 2
+      ctx.stroke()
+
+      // 首字
+      ctx.font = 'bold ' + Math.max(12, r * 0.65) + 'px PingFang SC, sans-serif'
+      ctx.fillStyle = '#FFFFFF'
+      ctx.textAlign = 'center'
+      ctx.textBaseline = 'middle'
+      ctx.fillText(node.displayName, x, y + 1)
+    },
+
+    /** 绘制分组标签(弧外侧文字) */
+    _drawGroupLabels: function(ctx) {
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
+      var configs = this.groupConfigs
+      var self = this
+
+      for (var c = 0; c < configs.length; c++) {
+        var config = configs[c]
+        // 检查该组是否有节点
+        var hasItems = false
+        for (var p = 0; p < this.pearlNodes.length; p++) {
+          if (this.pearlNodes[p].groupType === config.type) {
+            hasItems = true
+            break
+          }
+        }
+        if (!hasItems) continue
+
+        var R = minDim * config.radiusPct
+        var midAngle = (config.startAngle + config.endAngle) / 2
+        // 标签在弧线外侧
+        var labelR = R + 18
+        var lx = cx + labelR * Math.cos(midAngle)
+        var ly = cy + labelR * Math.sin(midAngle)
+
+        ctx.font = '12px PingFang SC, sans-serif'
+        ctx.fillStyle = self._hexToRgba(config.color, 0.8)
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(config.typeName, lx, ly)
+      }
+    },
+
+    /* ===========================
+     * 交互:触摸处理
+     * =========================== */
+
+    /** 命中测试:返回被点击的珍珠索引,-1 表示未命中 */
+    hitTest: function(x, y) {
+      for (var i = this.pearlNodes.length - 1; i >= 0; i--) {
+        var node = this.pearlNodes[i]
+        var dx = x - node.x
+        var dy = y - node.y
+        if (dx * dx + dy * dy <= node.radius * node.radius) {
+          return i
+        }
+      }
+      return -1
+    },
+
+    onTouchStart: function(e) {
+      if (!this.interactive) return
+      var touches = e.touches || e.changedTouches
+      if (!touches || touches.length === 0) return
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) return
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+
+      this._touchStartPos = { x: x, y: y }
+
+      // 未命中任何珍珠 → 关闭弹窗
+      var hitIndex = this.hitTest(x, y)
+      if (hitIndex < 0) {
+        this.selectedNode = null
+      }
+    },
+
+    onTouchMove: function(e) {
+      if (!this._touchStartPos) return
+      var touches = e.touches || e.changedTouches
+      if (!touches || touches.length === 0) return
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) return
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+      var dx = x - this._touchStartPos.x
+      var dy = y - this._touchStartPos.y
+      // 移动超过 10px 取消点击判定
+      if (dx * dx + dy * dy > 100) {
+        this._touchStartPos = null
+      }
+    },
+
+    onTouchEnd: function(e) {
+      if (!this._touchStartPos) return
+      var touches = e.changedTouches
+      if (!touches || touches.length === 0) {
+        this._touchStartPos = null
+        return
+      }
+      var touch = touches[0]
+      var canvasRect = this._getCanvasRect()
+      if (!canvasRect) {
+        this._touchStartPos = null
+        return
+      }
+      var x = touch.clientX - canvasRect.left
+      var y = touch.clientY - canvasRect.top
+      var dx = x - this._touchStartPos.x
+      var dy = y - this._touchStartPos.y
+      this._touchStartPos = null
+
+      // 移动超过 10px 不算点击
+      if (dx * dx + dy * dy > 100) return
+
+      var hitIndex = this.hitTest(x, y)
+      if (hitIndex >= 0) {
+        this.selectedNode = this.pearlNodes[hitIndex]
+      }
+    },
+
+    /* ===========================
+     * 事件发射
+     * =========================== */
+
+    onAdd: function() {
+      this.$emit('add')
+    },
+
+    onDelete: function() {
+      if (!this.selectedNode) return
+      // INFO/PLACE 为手工登记资源,删除键是 resource_items 的真实 id;
+      // 节点登记时可能关联了联系人(contactId),但不能作为删除标识
+      var id = this.selectedNode.id || this.selectedNode.contactId
+      this.$emit('delete', { type: this.selectedNode.groupType, id: id })
+      this.selectedNode = null
+    },
+
+    /* ===========================
+     * Canvas 初始化
+     * =========================== */
+
+    updateCanvasSize: function() {
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('.pearl-graph-wrapper').boundingClientRect(function(rect) {
+        if (rect) {
+          self.canvasWidth = rect.width || 340
+        }
+      }).exec()
+    },
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + 'pearlGraphCanvas')
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0]) {
+              if (cb) cb(false)
+              return
+            }
+            var info = res[0]
+            if (!info.node) {
+              if (cb) cb(false)
+              return
+            }
+
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try {
+                var winInfo = uni.getWindowInfo()
+                winWidth = winInfo.windowWidth || 375
+              } catch (e2) {}
+              width = Math.round((self.canvasWidth || 340) * winWidth / 750)
+              height = Math.round((self.canvasHeight || 400) * winWidth / 750)
+            }
+
+            var pixelRatio = 2
+            try {
+              var system = uni.getWindowInfo()
+              pixelRatio = system.pixelRatio || 2
+            } catch (e) {}
+
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+
+            var ctx = canvas.getContext('2d')
+            if (!ctx) {
+              if (cb) cb(false)
+              return
+            }
+            ctx.scale(pixelRatio, pixelRatio)
+
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self.canvasReady = true
+            // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
+            if (info.rect) {
+              self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
+            }
+
+            self.buildLayout()
+            self.drawAll()
+
+            if (cb) cb(true)
+          } catch (e) {
+            if (cb) cb(false)
+          }
+        })
+    },
+
+    /* ===========================
+     * 工具方法
+     * =========================== */
+
+    _getCanvasRect: function() {
+      if (this._canvasRect) {
+        return {
+          left: this._canvasRect.left,
+          top: this._canvasRect.top,
+          width: this.canvasWidth,
+          height: this.canvasHeight
+        }
+      }
+      return {
+        left: 0,
+        top: 0,
+        width: this.canvasWidth,
+        height: this.canvasHeight
+      }
+    },
+
+    _hexToRgba: function(hex, alpha) {
+      var r = parseInt(hex.slice(1, 3), 16)
+      var g = parseInt(hex.slice(3, 5), 16)
+      var b = parseInt(hex.slice(5, 7), 16)
+      return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pearl-graph-wrapper {
+  position: relative;
+  width: 100%;
+  min-height: 200px;
+  height: auto;
+  margin: 10rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  overflow: visible;
+}
+.pearl-graph-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 1;
+}
+
+/* 详情弹窗 */
+.pearl-detail-popup {
+  position: absolute;
+  z-index: 10;
+  pointer-events: none;
+  transform: translate(-50%, -100%);
+  margin-top: -16rpx;
+}
+.pearl-detail-popup.popup-below {
+  transform: translate(-50%, 0);
+  margin-top: 16rpx;
+}
+.popup-content {
+  background: #FFFFFF;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
+  min-width: 220rpx;
+  max-width: 300rpx;
+  pointer-events: auto;
+}
+.popup-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.popup-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  margin-right: 12rpx;
+}
+.popup-info {
+  display: flex;
+  flex-direction: column;
+}
+.popup-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.popup-tags {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 6rpx;
+}
+.popup-tag {
+  font-size: 18rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 6rpx;
+  margin-right: 8rpx;
+}
+.popup-tag-rel {
+  background: #F1F5F9;
+  color: #64748B;
+}
+.popup-skills {
+  padding: 10rpx 0;
+  border-top: 1rpx solid #F1F5F9;
+}
+.popup-skill-count {
+  font-size: 22rpx;
+  font-weight: 600;
+  color: #F59E0B;
+  display: block;
+  margin-bottom: 6rpx;
+}
+.popup-skill-list {
+  display: flex;
+  flex-wrap: wrap;
+}
+.popup-skill-item {
+  font-size: 20rpx;
+  color: #64748B;
+  margin-right: 8rpx;
+  margin-bottom: 4rpx;
+}
+.popup-desc {
+  padding: 10rpx 0;
+  border-top: 1rpx solid #F1F5F9;
+}
+.popup-desc-text {
+  font-size: 22rpx;
+  color: #475569;
+  display: block;
+}
+.popup-actions {
+  display: flex;
+  justify-content: center;
+  margin-top: 12rpx;
+}
+.action-btn {
+  display: inline-block;
+  padding: 10rpx 24rpx;
+  border-radius: 20rpx;
+  font-size: 24rpx;
+}
+.action-btn-primary {
+  background: #F97316;
+  color: #FFFFFF;
+}
+.action-btn-delete {
+  background: #FEE2E2;
+  color: #EF4444;
+  margin-left: 16rpx;
+}
+.popup-arrow {
+  position: absolute;
+  bottom: -8rpx;
+  left: 50%;
+  margin-left: -8rpx;
+  width: 0;
+  height: 0;
+  border-left: 8rpx solid transparent;
+  border-right: 8rpx solid transparent;
+  border-top: 8rpx solid #FFFFFF;
+}
+.popup-arrow-down {
+  bottom: auto;
+  top: -8rpx;
+  border-top: none;
+  border-bottom: 8rpx solid #FFFFFF;
+}
+
+/* 空状态 */
+.pearl-graph-empty,
+.pearl-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60rpx 20rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  margin: 10rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 8rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-bottom: 24rpx;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #64748B;
+}
+</style>

+ 18 - 0
cfc-frontend/pages.json

@@ -1229,6 +1229,24 @@
             "navigationBarTitleText": "联系人详情",
             "navigationStyle": "custom"
           }
+        },
+        {
+          "path": "octopus-add-help",
+          "style": {
+            "navigationBarTitleText": "记录帮助"
+          }
+        },
+        {
+          "path": "octopus-records",
+          "style": {
+            "navigationBarTitleText": "帮助记录"
+          }
+        },
+        {
+          "path": "pearl-add-resource",
+          "style": {
+            "navigationBarTitleText": "登记资源"
+          }
         }
       ]
     },

+ 130 - 2
cfc-frontend/pages/action-detail/index.vue

@@ -66,6 +66,56 @@
         :interactive="true" />
     </view>
 
+    <!-- 登录后:章鱼图(谁帮过我) -->
+    <view class="section" v-if="isLoggedIn">
+      <view class="section-header">
+        <view>
+          <text class="section-title">🐙 章鱼图</text>
+          <text class="section-sub">盘点帮助过你的人——你不是一个人在硬撑</text>
+        </view>
+        <text class="section-link" @tap="goOctopusHelp">+ 记录</text>
+      </view>
+      <OctopusDiagram
+        ref="octopusDiagram"
+        :selfId="selfId"
+        :interactive="true"
+        @view-records="onViewOctopusRecords"
+        @add-help="goOctopusHelp" />
+    </view>
+
+    <!-- 登录后:珍珠图(我有什么) -->
+    <view class="section" v-if="isLoggedIn">
+      <view class="section-header">
+        <view>
+          <text class="section-title">🫧 珍珠图</text>
+          <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
+        </view>
+        <text class="section-link" @tap="goPearlAddResource">+ 登记</text>
+      </view>
+      <PearlDiagram
+        ref="pearlDiagram"
+        :selfId="selfId"
+        :resources="pearlData"
+        :interactive="true"
+        @add="goPearlAddResource"
+        @delete="onDeletePearlResource" />
+    </view>
+
+    <!-- 登录后:能力图(谁会什么) -->
+    <view class="section" v-if="isLoggedIn">
+      <view class="section-header">
+        <view>
+          <text class="section-title">💡 能力图</text>
+          <text class="section-sub">身边藏着什么技能——谁有能力帮到你</text>
+        </view>
+      </view>
+      <AbilityDiagram
+        ref="abilityDiagram"
+        :selfId="selfId"
+        :abilities="abilityData"
+        :interactive="true" />
+    </view>
+
     <!-- 行6维关系域雷达图(登录后) -->
     <view class="section" v-if="isLoggedIn && currentMemberEnergies">
       <view class="section-header">
@@ -128,6 +178,9 @@ import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+import OctopusDiagram from '../../components/OctopusDiagram.vue'
+import PearlDiagram from '../../components/PearlDiagram.vue'
+import AbilityDiagram from '../../components/AbilityDiagram.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
@@ -136,10 +189,10 @@ import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, OctopusDiagram, PearlDiagram, AbilityDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
   data() {
     return {
       isLoggedIn: false,
@@ -159,6 +212,8 @@ export default {
       dualDimension: null,
       selfId: null,
       visibleSections: [],
+      pearlData: null,
+      abilityData: null,
       funcList: [
         { icon: '\u{1F4AC}', label: '记录互动', needLogin: true, page: '/pages/action-detail/interaction-log' },
         { icon: '\u{1F4DD}', label: '关系问卷', needLogin: true, page: '/pages/action-detail/relationship-questionnaire' },
@@ -286,6 +341,9 @@ export default {
     if (this.isLoggedIn) {
       this.loadChildren()
       this.loadFamilyMembersVisible()
+      this.loadOctopusTentacles()
+      this.loadPearlResources()
+      this.loadAbilityMap()
     }
     this._watchPageReady()
   },
@@ -416,6 +474,76 @@ export default {
       }
       if (item.page) uni.navigateTo({ url: item.page })
     },
+    /** 加载章鱼图数据 */
+    loadOctopusTentacles: function() {
+      var self = this
+      getOctopusTentacles().then(function(res) {
+        var tentacles = (res.code === 200 && Array.isArray(res.data)) ? res.data : []
+        if (self.$refs.octopusDiagram) {
+          self.$refs.octopusDiagram.tentacles = tentacles
+        }
+      }).catch(function(e) {
+        console.log('获取章鱼图数据失败', e)
+      })
+    },
+    /** 查看某人的帮助明细 */
+    onViewOctopusRecords: function(contactId) {
+      if (!contactId) return
+      uni.navigateTo({
+        url: '/pages/action-detail/octopus-records?contactId=' + contactId
+      })
+    },
+    /** 记录帮助入口 */
+    goOctopusHelp: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-add-help' })
+    },
+    /** 加载珍珠图数据 */
+    loadPearlResources: function() {
+      var self = this
+      getPearlResources().then(function(res) {
+        self.pearlData = (res.code === 200 && res.data) ? res.data : null
+      }).catch(function(e) {
+        console.log('获取珍珠图数据失败', e)
+        self.pearlData = null
+      })
+    },
+    /** 登记珍珠图资源入口 */
+    goPearlAddResource: function() {
+      uni.navigateTo({ url: '/pages/action-detail/pearl-add-resource' })
+    },
+    /** 删除珍珠图资源 */
+    onDeletePearlResource: function(item) {
+      if (!item || !item.id) return
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定删除该资源吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          deletePearlResource({ itemId: item.id }).then(function(r) {
+            if (r.code === 200) {
+              uni.showToast({ title: '已删除', icon: 'success' })
+              self.loadPearlResources()
+            } else {
+              uni.showToast({ title: r.message || '删除失败', icon: 'none' })
+            }
+          }).catch(function(e) {
+            console.log('删除珍珠图资源失败', e)
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    /** 加载能力图数据 */
+    loadAbilityMap: function() {
+      var self = this
+      getAbilityMap().then(function(res) {
+        self.abilityData = (res.code === 200 && Array.isArray(res.data)) ? res.data : []
+      }).catch(function(e) {
+        console.log('获取能力图数据失败', e)
+        self.abilityData = []
+      })
+    },
     onTaskClick: function(task) {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },

+ 249 - 0
cfc-frontend/pages/action-detail/octopus-add-help.vue

@@ -0,0 +1,249 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">记录帮助</text>
+    </view>
+
+    <!-- 选择帮助对象 -->
+    <view class="form-section">
+      <text class="section-label">帮助对象</text>
+      <view class="member-select">
+        <view class="member-item" v-for="c in contacts" :key="c.id"
+              :class="{selected: selectedContactId === c.id}"
+              @click="selectContact(c)">
+          <text class="member-name">{{ c.name }}</text>
+          <text class="member-rel">{{ c.relationshipType }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 选择帮助类型 -->
+    <view class="form-section">
+      <text class="section-label">帮助类型</text>
+      <view class="type-grid">
+        <view class="type-item" v-for="(name, key) in helpTypes" :key="key"
+              :class="{selected: selectedType === key}"
+              @click="selectedType = key">
+          <text class="type-icon">{{ helpTypeIcons[key] }}</text>
+          <text class="type-name">{{ name }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 金额(仅经济支持) -->
+    <view class="form-section" v-if="selectedType === 'MONEY'">
+      <text class="section-label">金额(选填)</text>
+      <input class="amount-input" type="digit" v-model="amount"
+             placeholder="请输入金额,单位:元" />
+    </view>
+
+    <!-- 描述 -->
+    <view class="form-section">
+      <text class="section-label">描述(选填)</text>
+      <textarea class="desc-input" v-model="description"
+                placeholder="简单描述一下这次帮助..." maxlength="256"></textarea>
+    </view>
+
+    <!-- 提交按钮 -->
+    <view class="submit-bar">
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存记录</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getContactList, addOctopusHelpLog } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      contacts: [],
+      selectedContactId: null,
+      selectedType: '',
+      amount: '',
+      description: '',
+      helpTypes: {
+        MONEY: '经济支持',
+        ITEM: '物质帮助',
+        EMOTION: '情感支持',
+        RESOURCE: '资源帮助',
+        SKILL: '技能帮助',
+        TIME: '时间帮助',
+        OTHER: '其他'
+      },
+      helpTypeIcons: {
+        MONEY: '💰',
+        ITEM: '📦',
+        EMOTION: '💕',
+        RESOURCE: '📚',
+        SKILL: '🛠️',
+        TIME: '⏰',
+        OTHER: '🤝'
+      }
+    }
+  },
+  computed: {
+    canSubmit() {
+      return this.selectedContactId && this.selectedType
+    }
+  },
+  onLoad(options) {
+    if (options.contactId) {
+      this.selectedContactId = Number(options.contactId)
+    }
+    this.loadContacts()
+  },
+  methods: {
+    async loadContacts() {
+      try {
+        var res = await getContactList({ page: 1, size: 100 })
+        if (res.code === 200 && res.data) {
+          this.contacts = res.data.list || (Array.isArray(res.data) ? res.data : [])
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载联系人失败', icon: 'none' })
+      }
+    },
+    selectContact(contact) {
+      this.selectedContactId = contact.id
+    },
+    async onSubmit() {
+      if (!this.canSubmit) {
+        uni.showToast({ title: '请选择对象和类型', icon: 'none' })
+        return
+      }
+      try {
+        var logData = {
+          contactId: this.selectedContactId,
+          helpType: this.selectedType,
+          amount: this.amount,
+          description: this.description
+        }
+        var res = await addOctopusHelpLog(logData)
+        if (res.code === 200) {
+          uni.showToast({ title: '记录成功', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 1500)
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.section-label {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.member-select {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.member-item {
+  padding: 16rpx 24rpx;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.member-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.member-name {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+}
+.member-rel {
+  font-size: 22rpx;
+  color: #999;
+}
+.type-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.type-item {
+  width: calc(33.33% - 11rpx);
+  padding: 20rpx 0;
+  text-align: center;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.type-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.type-icon {
+  font-size: 40rpx;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.type-name {
+  font-size: 22rpx;
+  color: #666;
+}
+.amount-input {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.desc-input {
+  width: 100%;
+  height: 160rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.submit-bar {
+  margin-top: 40rpx;
+}
+.btn-submit {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  font-size: 32rpx;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+</style>

+ 222 - 0
cfc-frontend/pages/action-detail/octopus-records.vue

@@ -0,0 +1,222 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">{{ pageTitle }}</text>
+    </view>
+
+    <!-- 记录列表 -->
+    <view v-if="records.length > 0" class="records-list">
+      <view class="record-card" v-for="item in records" :key="item.id">
+        <view class="record-main">
+          <view class="record-type">
+            <text class="type-icon">{{ helpTypeMap[item.helpType] && helpTypeMap[item.helpType].icon || '🤝' }}</text>
+            <text class="type-label">{{ helpTypeMap[item.helpType] && helpTypeMap[item.helpType].label || '其他' }}</text>
+          </view>
+          <view class="record-info">
+            <text v-if="item.amount" class="record-amount">¥{{ item.amount }}</text>
+            <text v-if="item.description" class="record-desc">{{ item.description }}</text>
+            <text class="record-time">{{ formatTime(item.happenedAt) }}</text>
+          </view>
+        </view>
+        <view class="record-actions">
+          <view class="btn-delete" @click="onDelete(item)">
+            <text class="delete-text">删除</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-state">
+      <text class="empty-icon">📋</text>
+      <text class="empty-text">暂无帮助记录</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getOctopusRecords, deleteOctopusHelpLog } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      contactId: '',
+      contactName: '',
+      records: [],
+      loading: false,
+      helpTypeMap: {
+        MONEY: { icon: '💰', label: '经济支持' },
+        ITEM: { icon: '📦', label: '物质帮助' },
+        EMOTION: { icon: '💕', label: '情感支持' },
+        RESOURCE: { icon: '📚', label: '资源帮助' },
+        SKILL: { icon: '🛠️', label: '技能帮助' },
+        TIME: { icon: '⏰', label: '时间帮助' },
+        OTHER: { icon: '🤝', label: '其他' }
+      }
+    }
+  },
+  computed: {
+    pageTitle() {
+      return this.contactName ? (this.contactName + ' 的帮助记录') : '帮助记录'
+    }
+  },
+  onLoad(options) {
+    if (options && options.contactId) {
+      this.contactId = options.contactId
+    }
+    if (options && options.contactName) {
+      this.contactName = options.contactName
+    }
+    this.loadRecords()
+  },
+  methods: {
+    formatTime(str) {
+      if (!str) return ''
+      if (str.indexOf('T') !== -1) {
+        return str.replace('T', ' ').slice(0, 19)
+      }
+      return str.slice(0, 10)
+    },
+    async loadRecords() {
+      if (this.loading) return
+      this.loading = true
+      try {
+        var res = await getOctopusRecords({ contactId: this.contactId })
+        // 后端返回 { contact, summary, records: [...] }
+        var list = (res.code === 200 && res.data && res.data.records) ? res.data.records : []
+        if (!this.contactName && res.data && res.data.contact) {
+          this.contactName = res.data.contact.name || ''
+        }
+        this.records = list.slice().sort(function(a, b) {
+          return (b.happenedAt || '').localeCompare(a.happenedAt || '')
+        })
+      } catch (e) {
+        uni.showToast({ title: '加载记录失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    onDelete(record) {
+      var that = this
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要删除这条帮助记录吗?',
+        success: function(res) {
+          if (res.confirm) {
+            that.doDelete(record)
+          }
+        }
+      })
+    },
+    async doDelete(record) {
+      try {
+        var res = await deleteOctopusHelpLog({ logId: record.id })
+        if (res.code === 200) {
+          uni.showToast({ title: '删除成功', icon: 'success' })
+          this.loadRecords()
+        } else {
+          uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '删除失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.records-list {
+  padding-bottom: 40rpx;
+}
+.record-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+}
+.record-main {
+  display: flex;
+  align-items: flex-start;
+}
+.record-type {
+  display: flex;
+  align-items: center;
+  margin-right: 20rpx;
+  flex-shrink: 0;
+}
+.type-icon {
+  font-size: 36rpx;
+  margin-right: 8rpx;
+}
+.type-label {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.record-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.record-amount {
+  font-size: 32rpx;
+  color: #F97316;
+  font-weight: bold;
+  margin-bottom: 8rpx;
+}
+.record-desc {
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 8rpx;
+  line-height: 1.5;
+}
+.record-time {
+  font-size: 22rpx;
+  color: #999;
+}
+.record-actions {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16rpx;
+  padding-top: 16rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+.btn-delete {
+  padding: 8rpx 24rpx;
+  border: 1rpx solid #ff4d4f;
+  border-radius: 8rpx;
+}
+.delete-text {
+  font-size: 22rpx;
+  color: #ff4d4f;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding-top: 200rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 24rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

+ 274 - 0
cfc-frontend/pages/action-detail/pearl-add-resource.vue

@@ -0,0 +1,274 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">登记资源</text>
+    </view>
+
+    <!-- 选择资源类型 -->
+    <view class="form-section">
+      <text class="section-label">资源类型</text>
+      <view class="type-grid">
+        <view class="type-item" v-for="(name, key) in resourceTypes" :key="key"
+              :class="{selected: selectedType === key}"
+              @click="selectedType = key">
+          <text class="type-icon">{{ typeIcons[key] }}</text>
+          <text class="type-name">{{ name }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 资源名称 -->
+    <view class="form-section">
+      <text class="section-label">资源名称 <text class="required-mark">*</text></text>
+      <input class="name-input" v-model="name" :placeholder="namePlaceholder" maxlength="50" />
+    </view>
+
+    <!-- 资源描述 -->
+    <view class="form-section">
+      <text class="section-label">描述(选填)</text>
+      <textarea class="desc-input" v-model="description"
+                :placeholder="descPlaceholder" maxlength="200"></textarea>
+    </view>
+
+    <!-- 关联联系人(选填) -->
+    <view class="form-section">
+      <text class="section-label">关联联系人(选填)</text>
+      <view class="member-select" v-if="contacts.length > 0">
+        <view class="member-item" v-for="c in contacts" :key="c.id"
+              :class="{selected: selectedContactId === c.id}"
+              @click="selectContact(c)">
+          <text class="member-name">{{ c.name }}</text>
+          <text class="member-rel">{{ c.relationshipType }}</text>
+        </view>
+      </view>
+      <text class="empty-hint" v-else>暂无联系人,可暂不关联</text>
+    </view>
+
+    <!-- 提交按钮 -->
+    <view class="submit-bar">
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存资源</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getContactList, addPearlResource } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      contacts: [],
+      selectedContactId: null,
+      selectedType: '',
+      name: '',
+      description: '',
+      resourceTypes: {
+        INFO: '信息',
+        PLACE: '场所',
+        SKILL: '技能'
+      },
+      typeIcons: {
+        INFO: '📚',
+        PLACE: '🏠',
+        SKILL: '🛠️'
+      }
+    }
+  },
+  computed: {
+    canSubmit() {
+      return this.selectedType && this.name.trim()
+    },
+    namePlaceholder() {
+      if (this.selectedType === 'INFO') {
+        return '如:升学政策渠道、名师课程资源'
+      }
+      if (this.selectedType === 'PLACE') {
+        return '如:社区图书馆、运动场馆'
+      }
+      if (this.selectedType === 'SKILL') {
+        return '如:编程辅导、英语陪练'
+      }
+      return '请输入资源名称'
+    },
+    descPlaceholder() {
+      if (this.selectedType === 'INFO') {
+        return '如:在哪获取、谁提供、怎么用'
+      }
+      if (this.selectedType === 'PLACE') {
+        return '如:地址、开放时间、适合场景'
+      }
+      if (this.selectedType === 'SKILL') {
+        return '如:擅长方向、可帮助的内容'
+      }
+      return '简单描述一下这个资源'
+    }
+  },
+  onLoad(options) {
+    if (options.type) {
+      this.selectedType = options.type
+    }
+    this.loadContacts()
+  },
+  methods: {
+    async loadContacts() {
+      try {
+        var res = await getContactList({ page: 1, size: 100 })
+        if (res.code === 200 && res.data) {
+          this.contacts = res.data.list || (Array.isArray(res.data) ? res.data : [])
+        }
+      } catch (e) {
+        // 静默:不阻塞登记
+      }
+    },
+    selectContact(contact) {
+      this.selectedContactId = this.selectedContactId === contact.id ? null : contact.id
+    },
+    async onSubmit() {
+      if (!this.canSubmit) {
+        uni.showToast({ title: '请选择类型并填写名称', icon: 'none' })
+        return
+      }
+      try {
+        var data = {
+          type: this.selectedType,
+          name: this.name.trim(),
+          description: this.description.trim()
+        }
+        if (this.selectedContactId) {
+          data.contactId = this.selectedContactId
+        }
+        var res = await addPearlResource(data)
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 1500)
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.section-label {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.required-mark {
+  color: #F97316;
+}
+.type-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.type-item {
+  width: calc(33.33% - 11rpx);
+  padding: 20rpx 0;
+  text-align: center;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.type-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.type-icon {
+  font-size: 40rpx;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.type-name {
+  font-size: 22rpx;
+  color: #666;
+}
+.name-input {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.desc-input {
+  width: 100%;
+  height: 160rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.member-select {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.member-item {
+  padding: 16rpx 24rpx;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.member-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.member-name {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+}
+.member-rel {
+  font-size: 22rpx;
+  color: #999;
+}
+.empty-hint {
+  font-size: 26rpx;
+  color: #999;
+}
+.submit-bar {
+  margin-top: 40rpx;
+}
+.btn-submit {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  font-size: 32rpx;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+</style>

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

@@ -2268,6 +2268,17 @@ export const deleteContact = (id) => request('/api/contact/delete', 'POST', { id
 export const importPhoneContact = (data) => request('/api/contact/import-phone', 'POST', data)
 export const recordContactInteraction = (data) => request('/api/contact/interaction', 'POST', data)
 
+// Octopus 章鱼图
+export const getOctopusTentacles = () => request('/api/octopus/tentacles', 'POST')
+export const addOctopusHelpLog = (data) => request('/api/octopus/add', 'POST', data)
+export const deleteOctopusHelpLog = (data) => request('/api/octopus/delete', 'POST', data)
+export const getOctopusRecords = (data) => request('/api/octopus/records', 'POST', data)
+
+export const getPearlResources = () => request('/api/pearl/resources', 'POST')
+export const addPearlResource = (data) => request('/api/pearl/item/add', 'POST', data)
+export const deletePearlResource = (data) => request('/api/pearl/item/delete', 'POST', data)
+export const getAbilityMap = () => request('/api/ability/map', 'POST')
+
 // ===== 椋熻氨鎺ㄨ崘 =====
 export const getMealRecommend = (params) => request('/api/meal/recommend', 'POST', params)
 export const replaceFood = (data) => request('/api/meal/replace', 'POST', data)

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

@@ -72,6 +72,11 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `CfCommissionController` | `/api/commission/cf` | CF 分佣(团队规模/比例/钱包/流水) | — |
 | `CfTransferController` | `/api/cf/transfer` | CF 成员间转让 | — |
 | `AdminCfRateTierController` | `/api/admin/cf-rate-tier` | CF 返佣阶梯配置(admin) | — |
+| `ContactController` | `/api/contact` | 联系人 CRUD/互动/邀请入家庭 | — |
+| `ContactMatchController` | `/api/contact/match` | 联系人对接(send/accept/reject/列表) | — |
+| `OctopusController` | `/api/octopus` | 章鱼图(帮助记录/触手聚合) | — |
+| `PearlController` | `/api/pearl` | 珍珠图(四类资源聚合 + 登记 CRUD) | — |
+| `AbilityController` | `/api/ability` | 能力图(SKILL 帮助记录聚合为能力节点) | — |
 
 ---
 
@@ -1085,6 +1090,40 @@ score 范围:0-100(整数)
 
 **已记录认知自测 API 端点 — 禁止重复注册。**
 
+### 4.39 珍珠图/能力图(`/api/pearl/*`, `/api/ability/*`)
+
+关系三图体系:章鱼图(谁帮过我)→ 珍珠图(我有什么)→ 能力图(谁会什么)。珍珠图聚合四类资源,能力图聚合 SKILL 类帮助记录。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/octopus/tentacles` | 章鱼图:帮助次数榜+金额榜前 8 触手(已存在,见章鱼图功能) |
+| `POST /api/octopus/add` | 新增帮助记录 |
+| `POST /api/octopus/delete` | 删除帮助记录 |
+| `POST /api/octopus/records` | 按联系人查询帮助记录明细 |
+| `POST /api/pearl/resources` | 珍珠图聚合:人脉(contacts) + 技能(help_logs SKILL) + 信息/场所(resource_items) |
+| `POST /api/pearl/item/add` | 登记珍珠图资源(type=INFO/PLACE/SKILL,name 必填,contactId 可选) |
+| `POST /api/pearl/item/delete` | 删除登记的珍珠图资源(itemId) |
+| `POST /api/ability/map` | 能力图:SKILL 帮助记录按联系人聚合为能力节点 |
+
+**`/api/pearl/resources` 响应结构:**
+```json
+{
+  "code": 200,
+  "data": {
+    "groups": [
+      { "type": "PERSON", "typeName": "人脉", "items": [{ "id": 1, "name": "张三", "avatar": "", "relationshipType": "好友" }] },
+      { "type": "SKILL", "typeName": "技能", "items": [{ "contactId": 2, "name": "李四", "skillCount": 3, "skills": ["画画", "辅导"], "lastHelpedAt": "2026-08-01 10:00:00" }] },
+      { "type": "INFO", "typeName": "信息", "items": [{ "id": 3, "name": "升学政策", "description": "..." }] },
+      { "type": "PLACE", "typeName": "场所", "items": [{ "id": 4, "name": "社区图书馆", "description": "..." }] }
+    ]
+  }
+}
+```
+
+**`/api/ability/map` 响应结构:** `data` 为数组,元素结构同 SKILL 组 items(contactId/name/avatar/relationshipType/skillCount/skills/lastHelpedAt)。
+
+**「已记录关系三图 API 端点 — 禁止重复注册。」**
+
 ## 六、待清理的废弃接口
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |