2026-09-10-key-person-expansion-octopus.md 55 KB

关键人拓展章鱼图替换 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 替换现有"帮助记录型章鱼图"(contact_help_logs + OctopusController + OctopusDiagram.vue),上线"关键人拓展型章鱼图":成效记录(会员客户) → 类型bitmask(金额大/频次高/新成交) → 关键人画像(单位/部门/职务/初识场合/如何认识) → 四步法 + 高频词分析 → ≤8 个拓展方向。

架构:

  • 后端:Spring Boot 2.7.18 + MyBatis-Plus,3 张新表 + KeyPersonController(新建,不复用 OctopusController),Service 层聚合逻辑,统一 POST + Result。
  • 前端:uni-app Vue 2 Options API,Canvas 2d 绘图(type="2d" + uni.createSelectorQuery + dpr 自适应),替换 action-detail 中的 OctopusDiagram.vue。
  • 数据隔离:纯 user_id 租户键,无 child/family 概念。
  • 技术栈: Java 8, Spring Boot 2.7.18, MyBatis-Plus, MySQL 8.0, uni-app (Vue 2 Options API), Canvas 2d, uni.createSelectorQuery。


    任务 1:数据库迁移 + schema.sql 同步

    文件:

    • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java:10549(在迁移304后追加)
    • 修改:cfc-backend/src/main/resources/schema.sql(在 resource_items 表后追加 3 表)

    • [ ] 步骤 1:编写失败的测试(验证迁移不存在)

      # 验证当前无这 3 表
      grep -n "octopus_effect_record\|octopus_key_person\|octopus_effect_key_person" cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
      # 预期:无匹配
      
    • [ ] 步骤 2:在 DatabaseInitializer 追加迁移 305-307

      // 迁移305: 创建 octopus_effect_record 表(关键人拓展-成效记录)
      try {
      jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_record (" +
              "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
              "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " +
              "member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)', " +
              "effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)', " +
              "effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)', " +
              "effect_desc VARCHAR(500) COMMENT '成效描述', " +
              "status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效', " +
              "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
              "INDEX idx_oer_user (user_id), " +
              "INDEX idx_oer_order (member_order_id) " +
              ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录'");
      log.info("已创建octopus_effect_record表");
      } catch (Exception e) {
      log.warn("创建octopus_effect_record表失败: {}", e.getMessage());
      }
      
      // 迁移306: 创建 octopus_key_person 表(关键人画像)
      try {
      jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_key_person (" +
              "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
              "user_id BIGINT NOT NULL COMMENT '当前用户(租户键)', " +
              "name VARCHAR(64) NOT NULL COMMENT '关键人姓名', " +
              "organization VARCHAR(128) COMMENT '单位/公司', " +
              "department VARCHAR(64) COMMENT '部门', " +
              "title VARCHAR(64) COMMENT '职务/职位', " +
              "first_meet_scene VARCHAR(128) COMMENT '初识场合', " +
              "know_way VARCHAR(255) COMMENT '如何认识(认识路径)', " +
              "contact_info VARCHAR(255) COMMENT '联系方式(可选)', " +
              "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
              "INDEX idx_okp_user (user_id), " +
              "INDEX idx_okp_org (organization) " +
              ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像'");
      log.info("已创建octopus_key_person表");
      } catch (Exception e) {
      log.warn("创建octopus_key_person表失败: {}", e.getMessage());
      }
      
      // 迁移307: 创建 octopus_effect_key_person 表(成效-关键人 多对多)
      try {
      jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS octopus_effect_key_person (" +
              "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
              "effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id', " +
              "key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id', " +
              "role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他', " +
              "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
              "INDEX idx_oekp_effect (effect_id), " +
              "INDEX idx_oekp_person (key_person_id), " +
              "UNIQUE KEY uk_oekp (effect_id,key_person_id) " +
              ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联'");
      log.info("已创建octopus_effect_key_person表");
      } catch (Exception e) {
      log.warn("创建octopus_effect_key_person表失败: {}", e.getMessage());
      }
      
    • [ ] 步骤 3:同步 schema.sql(在 resource_items 表后追加)

      -- 关键人拓展-成效记录
      CREATE TABLE IF NOT EXISTS octopus_effect_record (
      id BIGINT AUTO_INCREMENT PRIMARY KEY,
      user_id BIGINT NOT NULL COMMENT '当前用户(租户键)',
      member_order_id BIGINT COMMENT '关联 MemberSubscriptionOrder.id(会员订单)',
      effect_type TINYINT UNSIGNED NOT NULL COMMENT '成效类型 bitmask:1=金额大,2=频次高,4=新成交(支持多选)',
      effect_amount DECIMAL(10,2) DEFAULT 0 COMMENT '成效金额(元)',
      effect_desc VARCHAR(500) COMMENT '成效描述',
      status TINYINT DEFAULT 1 COMMENT '1=有效,0=无效',
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      INDEX idx_oer_user (user_id),
      INDEX idx_oer_order (member_order_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人拓展-成效记录';
      
      -- 关键人画像
      CREATE TABLE IF NOT EXISTS octopus_key_person (
      id BIGINT AUTO_INCREMENT PRIMARY KEY,
      user_id BIGINT NOT NULL COMMENT '当前用户(租户键)',
      name VARCHAR(64) NOT NULL COMMENT '关键人姓名',
      organization VARCHAR(128) COMMENT '单位/公司',
      department VARCHAR(64) COMMENT '部门',
      title VARCHAR(64) COMMENT '职务/职位',
      first_meet_scene VARCHAR(128) COMMENT '初识场合',
      know_way VARCHAR(255) COMMENT '如何认识(认识路径)',
      contact_info VARCHAR(255) COMMENT '联系方式(可选)',
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      INDEX idx_okp_user (user_id),
      INDEX idx_okp_org (organization)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键人画像';
      
      -- 成效-关键人关联
      CREATE TABLE IF NOT EXISTS octopus_effect_key_person (
      id BIGINT AUTO_INCREMENT PRIMARY KEY,
      effect_id BIGINT NOT NULL COMMENT '关联 octopus_effect_record.id',
      key_person_id BIGINT NOT NULL COMMENT '关联 octopus_key_person.id',
      role TINYINT NOT NULL COMMENT '1=引荐,2=决策,3=其他',
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      INDEX idx_oekp_effect (effect_id),
      INDEX idx_oekp_person (key_person_id),
      UNIQUE KEY uk_oekp (effect_id,key_person_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成效-关键人关联';
      
    • [ ] 步骤 4:验证编译

      cd cfc-backend && mvn clean compile -DskipTests
      # 预期:BUILD SUCCESS
      
    • [ ] 步骤 5:Commit

      git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
      git commit -m "db: 迁移305-307 关键人拓展 3 表(octopus_effect_record/octopus_key_person/octopus_effect_key_person)"
      

    任务 2:后端 Entity / Mapper

    文件:

    • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusEffectRecord.java
    • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java
    • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusEffectKeyPerson.java
    • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusEffectRecordMapper.java
    • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusKeyPersonMapper.java
    • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/OctopusEffectKeyPersonMapper.java

    • [ ] 步骤 1:OctopusEffectRecord.java

      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("octopus_effect_record")
      public class OctopusEffectRecord implements Serializable {
      
      @TableId(type = IdType.AUTO)
      private Long id;
      
      private Long userId;
      
      private Long memberOrderId;
      
      private Integer effectType; // bitmask
      
      private BigDecimal effectAmount;
      
      private String effectDesc;
      
      private Integer status;
      
      private Date createdAt;
      
      // 非持久化:解析后的类型标签列表
      private transient java.util.List<String> effectTypeList;
      }
      
    • [ ] 步骤 2:OctopusKeyPerson.java

      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("octopus_key_person")
      public class OctopusKeyPerson implements Serializable {
      
      @TableId(type = IdType.AUTO)
      private Long id;
      
      private Long userId;
      
      private String name;
      
      private String organization;
      
      private String department;
      
      private String title;
      
      private String firstMeetScene;
      
      private String knowWay;
      
      private String contactInfo;
      
      private Date createdAt;
      
      // 非持久化:角色名
      private transient String roleName;
      }
      
    • [ ] 步骤 3:OctopusEffectKeyPerson.java

      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("octopus_effect_key_person")
      public class OctopusEffectKeyPerson implements Serializable {
      
      @TableId(type = IdType.AUTO)
      private Long id;
      
      private Long effectId;
      
      private Long keyPersonId;
      
      private Integer role; // 1=引荐,2=决策,3=其他
      
      private Date createdAt;
      
      // 非持久化
      private transient String roleName;
      }
      
    • [ ] 步骤 4:3 个 Mapper 接口(仅继承 BaseMapper)

      // OctopusEffectRecordMapper.java
      package com.etotem.cfc.mapper;
      
      import com.baomidou.mybatisplus.core.mapper.BaseMapper;
      import com.etotem.cfc.entity.OctopusEffectRecord;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface OctopusEffectRecordMapper extends BaseMapper<OctopusEffectRecord> {
      }
      
      // OctopusKeyPersonMapper.java
      package com.etotem.cfc.mapper;
      
      import com.baomidou.mybatisplus.core.mapper.BaseMapper;
      import com.etotem.cfc.entity.OctopusKeyPerson;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface OctopusKeyPersonMapper extends BaseMapper<OctopusKeyPerson> {
      }
      
      // OctopusEffectKeyPersonMapper.java
      package com.etotem.cfc.mapper;
      
      import com.baomidou.mybatisplus.core.mapper.BaseMapper;
      import com.etotem.cfc.entity.OctopusEffectKeyPerson;
      import org.apache.ibatis.annotations.Mapper;
      
      @Mapper
      public interface OctopusEffectKeyPersonMapper extends BaseMapper<OctopusEffectKeyPerson> {
      }
      
    • [ ] 步骤 5:验证编译 + Commit

      cd cfc-backend && mvn clean compile -DskipTests
      git add cfc-backend/src/main/java/com/etotem/cfc/entity/Octopus*.java cfc-backend/src/main/java/com/etotem/cfc/mapper/Octopus*Mapper.java
      git commit -m "feat: 新增 3 实体 + 3 Mapper(octopus_effect_record/octopus_key_person/octopus_effect_key_person)"
      

    任务 3:KeyPersonService 核心业务逻辑

    文件:

    • 创建:cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

    • [ ] 步骤 1:编写失败的测试(验证类不存在)

      ls cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
      # 预期:No such file
      
    • [ ] 步骤 2:实现 KeyPersonService.java(参考 ResourceService 模式)

      package com.etotem.cfc.service;
      
      import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
      import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
      import com.etotem.cfc.common.Result;
      import com.etotem.cfc.entity.MemberSubscriptionOrder;
      import com.etotem.cfc.entity.OctopusEffectKeyPerson;
      import com.etotem.cfc.entity.OctopusEffectRecord;
      import com.etotem.cfc.entity.OctopusKeyPerson;
      import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
      import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
      import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
      import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
      import org.springframework.stereotype.Service;
      
      import javax.annotation.Resource;
      import java.math.BigDecimal;
      import java.util.*;
      import java.util.stream.Collectors;
      
      @Service
      public class KeyPersonService {
      
      @Resource
      private OctopusEffectRecordMapper effectRecordMapper;
      @Resource
      private OctopusKeyPersonMapper keyPersonMapper;
      @Resource
      private OctopusEffectKeyPersonMapper ekpMapper;
      @Resource
      private MemberSubscriptionOrderMapper memberOrderMapper;
      
      // ========== 1. 盘点项目 ==========
      
      public Result<Map<String, Object>> listEffects(Long userId, Integer effectType, Integer page, Integer pageSize) {
          Page<OctopusEffectRecord> p = new Page<>(page, pageSize);
          LambdaQueryWrapper<OctopusEffectRecord> q = new LambdaQueryWrapper<OctopusEffectRecord>()
                  .eq(OctopusEffectRecord::getUserId, userId);
          if (effectType != null && effectType > 0) {
              q.apply("effect_type & {0}", effectType);
          }
          q.orderByDesc(OctopusEffectRecord::getCreatedAt);
          effectRecordMapper.selectPage(p, q);
      
          List<Map<String, Object>> list = p.getRecords().stream().map(this::toEffectVO).collect(Collectors.toList());
          Map<String, Object> data = new LinkedHashMap<>();
          data.put("list", list);
          data.put("total", p.getTotal());
          data.put("page", page);
          data.put("pageSize", pageSize);
          return Result.success(data);
      }
      
      private Map<String, Object> toEffectVO(OctopusEffectRecord r) {
          Map<String, Object> m = new LinkedHashMap<>();
          m.put("id", r.getId());
          m.put("memberOrderId", r.getMemberOrderId());
          m.put("effectType", r.getEffectType());
          m.put("effectTypeList", parseEffectType(r.getEffectType()));
          m.put("effectAmount", r.getEffectAmount());
          m.put("effectDesc", r.getEffectDesc());
          m.put("status", r.getStatus());
          m.put("createdAt", r.getCreatedAt() != null ? r.getCreatedAt().toString() : "");
          return m;
      }
      
      private List<String> parseEffectType(Integer type) {
          List<String> list = new ArrayList<>();
          if (type == null) return list;
          if ((type & 1) != 0) list.add("金额大");
          if ((type & 2) != 0) list.add("频次高");
          if ((type & 4) != 0) list.add("新成交");
          return list;
      }
      
      public Result<OctopusEffectRecord> addEffect(Long userId, Long memberOrderId, Integer effectType,
                                                   BigDecimal effectAmount, String effectDesc) {
          // 可选:校验 memberOrderId 归属
          if (memberOrderId != null) {
              MemberSubscriptionOrder order = memberOrderMapper.selectById(memberOrderId);
              if (order == null || !order.getUserId().equals(userId)) {
                  return Result.error("会员订单不存在");
              }
          }
          OctopusEffectRecord r = new OctopusEffectRecord();
          r.setUserId(userId);
          r.setMemberOrderId(memberOrderId);
          r.setEffectType(effectType != null ? effectType : 0);
          r.setEffectAmount(effectAmount != null ? effectAmount : BigDecimal.ZERO);
          r.setEffectDesc(effectDesc != null ? effectDesc.trim() : null);
          r.setStatus(1);
          r.setCreatedAt(new Date());
          effectRecordMapper.insert(r);
          return Result.success(r);
      }
      
      public Result<String> deleteEffect(Long userId, Long effectId) {
          OctopusEffectRecord r = effectRecordMapper.selectById(effectId);
          if (r == null || !r.getUserId().equals(userId)) {
              return Result.error("成效记录不存在");
          }
          // 级联删除关联关系
          ekpMapper.delete(new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getEffectId, effectId));
          effectRecordMapper.deleteById(effectId);
          return Result.success(null);
      }
      
      // ========== 2. 找关键人 ==========
      
      public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
                                                   String department, String title,
                                                   String firstMeetScene, String knowWay,
                                                   String contactInfo) {
          if (name == null || name.trim().isEmpty()) {
              return Result.error("关键人姓名不能为空");
          }
          OctopusKeyPerson kp = new OctopusKeyPerson();
          kp.setUserId(userId);
          kp.setName(name.trim());
          kp.setOrganization(organization != null ? organization.trim() : null);
          kp.setDepartment(department != null ? department.trim() : null);
          kp.setTitle(title != null ? title.trim() : null);
          kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
          kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
          kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
          kp.setCreatedAt(new Date());
          keyPersonMapper.insert(kp);
          return Result.success(kp);
      }
      
      public Result<OctopusKeyPerson> updateKeyPerson(Long userId, Long id, Map<String, Object> params) {
          OctopusKeyPerson kp = keyPersonMapper.selectById(id);
          if (kp == null || !kp.getUserId().equals(userId)) {
              return Result.error("关键人不存在");
          }
          if (params.containsKey("name") && params.get("name") != null) kp.setName(params.get("name").toString().trim());
          if (params.containsKey("organization")) kp.setOrganization(params.get("organization") != null ? params.get("organization").toString().trim() : null);
          if (params.containsKey("department")) kp.setDepartment(params.get("department") != null ? params.get("department").toString().trim() : null);
          if (params.containsKey("title")) kp.setTitle(params.get("title") != null ? params.get("title").toString().trim() : null);
          if (params.containsKey("firstMeetScene")) kp.setFirstMeetScene(params.get("firstMeetScene") != null ? params.get("firstMeetScene").toString().trim() : null);
          if (params.containsKey("knowWay")) kp.setKnowWay(params.get("knowWay") != null ? params.get("knowWay").toString().trim() : null);
          if (params.containsKey("contactInfo")) kp.setContactInfo(params.get("contactInfo") != null ? params.get("contactInfo").toString().trim() : null);
          keyPersonMapper.updateById(kp);
          return Result.success(kp);
      }
      
      public Result<String> deleteKeyPerson(Long userId, Long id) {
          OctopusKeyPerson kp = keyPersonMapper.selectById(id);
          if (kp == null || !kp.getUserId().equals(userId)) {
              return Result.error("关键人不存在");
          }
          // 级联删除关联关系
          ekpMapper.delete(new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getKeyPersonId, id));
          keyPersonMapper.deleteById(id);
          return Result.success(null);
      }
      
      public Result<List<Map<String, Object>>> listKeyPersonsByEffect(Long userId, Long effectId) {
          // 校验成效归属
          OctopusEffectRecord eff = effectRecordMapper.selectById(effectId);
          if (eff == null || !eff.getUserId().equals(userId)) {
              return Result.error("成效记录不存在");
          }
          List<OctopusEffectKeyPerson> rels = ekpMapper.selectList(
                  new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getEffectId, effectId)
          );
          if (rels.isEmpty()) return Result.success(new ArrayList<>());
      
          List<Long> kpIds = rels.stream().map(OctopusEffectKeyPerson::getKeyPersonId).collect(Collectors.toList());
          Map<Long, OctopusKeyPerson> kpMap = keyPersonMapper.selectBatchIds(kpIds).stream()
                  .collect(Collectors.toMap(OctopusKeyPerson::getId, k -> k));
          Map<Long, Integer> roleMap = rels.stream().collect(Collectors.toMap(OctopusEffectKeyPerson::getKeyPersonId, OctopusEffectKeyPerson::getRole));
      
          List<Map<String, Object>> list = new ArrayList<>();
          for (Long kpId : kpIds) {
              OctopusKeyPerson kp = kpMap.get(kpId);
              if (kp == null) continue;
              Map<String, Object> m = new LinkedHashMap<>();
              m.put("id", kp.getId());
              m.put("name", kp.getName());
              m.put("organization", kp.getOrganization());
              m.put("department", kp.getDepartment());
              m.put("title", kp.getTitle());
              m.put("firstMeetScene", kp.getFirstMeetScene());
              m.put("knowWay", kp.getKnowWay());
              m.put("contactInfo", kp.getContactInfo());
              m.put("role", roleMap.get(kpId));
              m.put("roleName", roleName(roleMap.get(kpId)));
              m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
              list.add(m);
          }
          return Result.success(list);
      }
      
      // ========== 3. 分析关键人 ==========
      
      public Result<Map<String, Object>> analyzeKeywords(Long userId) {
          List<OctopusKeyPerson> kps = keyPersonMapper.selectList(
                  new LambdaQueryWrapper<OctopusKeyPerson>().eq(OctopusKeyPerson::getUserId, userId)
          );
          // 统计各维度词频
          Map<String, Integer> orgFreq = new HashMap<>();
          Map<String, Integer> deptFreq = new HashMap<>();
          Map<String, Integer> titleFreq = new HashMap<>();
          Map<String, Integer> sceneFreq = new HashMap<>();
          Map<String, Integer> wayFreq = new HashMap<>();
      
          for (OctopusKeyPerson kp : kps) {
              inc(orgFreq, kp.getOrganization());
              inc(deptFreq, kp.getDepartment());
              inc(titleFreq, kp.getTitle());
              inc(sceneFreq, kp.getFirstMeetScene());
              inc(wayFreq, kp.getKnowWay());
          }
      
          Map<String, Object> data = new LinkedHashMap<>();
          data.put("organization", topN(orgFreq, 20));
          data.put("department", topN(deptFreq, 20));
          data.put("title", topN(titleFreq, 20));
          data.put("firstMeetScene", topN(sceneFreq, 20));
          data.put("knowWay", topN(wayFreq, 20));
          return Result.success(data);
      }
      
      private void inc(Map<String, Integer> map, String val) {
          if (val == null || val.trim().isEmpty()) return;
          map.put(val, map.getOrDefault(val, 0) + 1);
      }
      
      private List<Map<String, Object>> topN(Map<String, Integer> map, int n) {
          return map.entrySet().stream()
                  .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
                  .limit(n)
                  .map(e -> {
                      Map<String, Object> m = new LinkedHashMap<>();
                      m.put("keyword", e.getKey());
                      m.put("count", e.getValue());
                      return m;
                  }).collect(Collectors.toList());
      }
      
      public Result<Map<String, Object>> analysisSummary(Long userId) {
          List<OctopusKeyPerson> kps = keyPersonMapper.selectList(
                  new LambdaQueryWrapper<OctopusKeyPerson>().eq(OctopusKeyPerson::getUserId, userId)
          );
          // 聚合:按 organization/title/department/firstMeetScene/knowWay 维度
          List<Map<String, Object>> directions = new ArrayList<>();
      
          addDirections(directions, kps, "organization", "单位", 8);
          addDirections(directions, kps, "title", "职务", 8);
          addDirections(directions, kps, "department", "部门", 8);
          addDirections(directions, kps, "firstMeetScene", "初识场合", 8);
          addDirections(directions, kps, "knowWay", "认识路径", 8);
      
          // 全局排序取 Top 8
          directions.sort((a, b) -> Integer.compare((Integer) b.get("count"), (Integer) a.get("count")));
          if (directions.size() > 8) directions = directions.subList(0, 8);
      
          Map<String, Object> data = new LinkedHashMap<>();
          data.put("directions", directions);
          return Result.success(data);
      }
      
      private void addDirections(List<Map<String, Object>> directions, List<OctopusKeyPerson> kps,
                                 String field, String dimName, int maxPerDim) {
          Map<String, Integer> freq = new HashMap<>();
          for (OctopusKeyPerson kp : kps) {
              String val = getField(kp, field);
              if (val != null && !val.trim().isEmpty()) {
                  freq.put(val, freq.getOrDefault(val, 0) + 1);
              }
          }
          freq.entrySet().stream()
                  .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
                  .limit(maxPerDim)
                  .forEach(e -> {
                      Map<String, Object> m = new LinkedHashMap<>();
                      m.put("dimension", field);
                      m.put("keyword", e.getKey());
                      m.put("count", e.getValue());
                      m.put("reason", genReason(field, e.getKey()));
                      directions.add(m);
                  });
      }
      
      private String getField(OctopusKeyPerson kp, String field) {
          switch (field) {
              case "organization": return kp.getOrganization();
              case "title": return kp.getTitle();
              case "department": return kp.getDepartment();
              case "firstMeetScene": return kp.getFirstMeetScene();
              case "knowWay": return kp.getKnowWay();
              default: return null;
          }
      }
      
      private String genReason(String field, String keyword) {
          switch (field) {
              case "organization": return "该单位关键人高度聚集";
              case "title": return "该职务人脉密集";
              case "department": return "该部门关键人多";
              case "firstMeetScene": return "该场合易识别关键人";
              case "knowWay": return "该认识路径高频";
              default: return "";
          }
      }
      
      // ========== 4. 拓展关键人 ==========
      
      public Result<List<Map<String, Object>>> expansionSuggest(Long userId) {
          // 复用 analysisSummary 的方向,为每个方向生成预填模板
          Result<Map<String, Object>> summaryRes = analysisSummary(userId);
          if (summaryRes.getCode() != 200) return Result.success(new ArrayList<>());
      
          @SuppressWarnings("unchecked")
          List<Map<String, Object>> dirs = (List<Map<String, Object>>) summaryRes.getData().get("directions");
          List<Map<String, Object>> suggests = new ArrayList<>();
          for (Map<String, Object> d : dirs) {
              Map<String, Object> tpl = new LinkedHashMap<>();
              tpl.put("dimension", d.get("dimension"));
              tpl.put("keyword", d.get("keyword"));
              tpl.put("prefill", buildPrefill((String) d.get("dimension"), (String) d.get("keyword")));
              suggests.add(tpl);
          }
          return Result.success(suggests);
      }
      
      private Map<String, Object> buildPrefill(String dim, String keyword) {
          Map<String, Object> p = new LinkedHashMap<>();
          // 只预填对应维度字段,其余留空供用户补充
          switch (dim) {
              case "organization":
                  p.put("organization", keyword);
                  break;
              case "title":
                  p.put("title", keyword);
                  break;
              case "department":
                  p.put("department", keyword);
                  break;
              case "firstMeetScene":
                  p.put("firstMeetScene", keyword);
                  break;
              case "knowWay":
                  p.put("knowWay", keyword);
                  break;
          }
          return p;
      }
      
      private String roleName(Integer role) {
          if (role == null) return "";
          switch (role) {
              case 1: return "引荐";
              case 2: return "决策";
              case 3: return "其他";
              default: return "";
          }
      }
      }
      
    • [ ] 步骤 2:验证编译 + Commit

      cd cfc-backend && mvn clean compile -DskipTests
      git add cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
      git commit -m "feat: KeyPersonService 核心业务逻辑(四步法全接口实现)"
      

    任务 4:KeyPersonController(新建,不复用 OctopusController)

    文件:

    • 创建:cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

    • [ ] 步骤 1:实现 Controller(完全遵循 PearlController 模式)

      package com.etotem.cfc.controller;
      
      import com.etotem.cfc.common.Result;
      import com.etotem.cfc.service.KeyPersonService;
      import org.springframework.web.bind.annotation.*;
      
      import javax.annotation.Resource;
      import java.math.BigDecimal;
      import java.util.Map;
      
      @RestController
      @RequestMapping("/api/octopus")
      public class KeyPersonController {
      
      @Resource
      private KeyPersonService keyPersonService;
      
      // ===== 1. 盘点项目 =====
      @PostMapping("/effect/list")
      public Result<Map<String, Object>> listEffects(@RequestBody(required = false) Map<String, Object> params,
                                                     @RequestAttribute("userId") Long userId) {
          Integer effectType = params != null && params.get("effectType") != null
                  ? Integer.valueOf(params.get("effectType").toString()) : null;
          Integer page = params != null && params.get("page") != null
                  ? Integer.valueOf(params.get("page").toString()) : 1;
          Integer pageSize = params != null && params.get("pageSize") != null
                  ? Integer.valueOf(params.get("pageSize").toString()) : 20;
          return keyPersonService.listEffects(userId, effectType, page, pageSize);
      }
      
      @PostMapping("/effect/add")
      public Result<com.etotem.cfc.entity.OctopusEffectRecord> addEffect(@RequestBody Map<String, Object> params,
                                                                         @RequestAttribute("userId") Long userId) {
          Long memberOrderId = params.get("memberOrderId") != null
                  ? Long.valueOf(params.get("memberOrderId").toString()) : null;
          Integer effectType = params.get("effectType") != null
                  ? Integer.valueOf(params.get("effectType").toString()) : 0;
          BigDecimal effectAmount = params.get("effectAmount") != null
                  ? new BigDecimal(params.get("effectAmount").toString()) : BigDecimal.ZERO;
          String effectDesc = (String) params.get("effectDesc");
          return keyPersonService.addEffect(userId, memberOrderId, effectType, effectAmount, effectDesc);
      }
      
      @PostMapping("/effect/delete")
      public Result<String> deleteEffect(@RequestBody Map<String, Object> params,
                                         @RequestAttribute("userId") Long userId) {
          Long effectId = params.get("effectId") != null
                  ? Long.valueOf(params.get("effectId").toString()) : null;
          return keyPersonService.deleteEffect(userId, effectId);
      }
      
      // ===== 2. 找关键人 =====
      @PostMapping("/key-person/add")
      public Result<com.etotem.cfc.entity.OctopusKeyPerson> addKeyPerson(@RequestBody Map<String, Object> params,
                                                                         @RequestAttribute("userId") Long userId) {
          String name = (String) params.get("name");
          String organization = (String) params.get("organization");
          String department = (String) params.get("department");
          String title = (String) params.get("title");
          String firstMeetScene = (String) params.get("firstMeetScene");
          String knowWay = (String) params.get("knowWay");
          String contactInfo = (String) params.get("contactInfo");
          return keyPersonService.addKeyPerson(userId, name, organization, department, title,
                  firstMeetScene, knowWay, contactInfo);
      }
      
      @PostMapping("/key-person/update")
      public Result<com.etotem.cfc.entity.OctopusKeyPerson> updateKeyPerson(@RequestBody Map<String, Object> params,
                                                                            @RequestAttribute("userId") Long userId) {
          Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
          return keyPersonService.updateKeyPerson(userId, id, params);
      }
      
      @PostMapping("/key-person/delete")
      public Result<String> deleteKeyPerson(@RequestBody Map<String, Object> params,
                                            @RequestAttribute("userId") Long userId) {
          Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
          return keyPersonService.deleteKeyPerson(userId, id);
      }
      
      @PostMapping("/effect/key-persons")
      public Result<List<Map<String, Object>>> effectKeyPersons(@RequestBody Map<String, Object> params,
                                                                @RequestAttribute("userId") Long userId) {
          Long effectId = params.get("effectId") != null ? Long.valueOf(params.get("effectId").toString()) : null;
          return keyPersonService.listKeyPersonsByEffect(userId, effectId);
      }
      
      // ===== 3. 分析关键人 =====
      @PostMapping("/analysis/keywords")
      public Result<Map<String, Object>> analysisKeywords(@RequestAttribute("userId") Long userId) {
          return keyPersonService.analyzeKeywords(userId);
      }
      
      @PostMapping("/analysis/summary")
      public Result<Map<String, Object>> analysisSummary(@RequestAttribute("userId") Long userId) {
          return keyPersonService.analysisSummary(userId);
      }
      
      // ===== 4. 拓展关键人 =====
      @PostMapping("/expansion/suggest")
      public Result<List<Map<String, Object>>> expansionSuggest(@RequestAttribute("userId") Long userId) {
          return keyPersonService.expansionSuggest(userId);
      }
      }
      
    • [ ] 步骤 2:验证编译 + Commit

      cd cfc-backend && mvn clean compile -DskipTests
      git add cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java
      git commit -m "feat: KeyPersonController 10 个接口(/api/octopus/effect/*, /key-person/*, /analysis/*, /expansion/suggest)"
      

    任务 5:废弃旧 OctopusController + 清理

    文件:

    • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/OctopusController.java
    • 删除:cfc-frontend/components/OctopusDiagram.vue
    • 删除:cfc-frontend/pages/action-detail/octopus-add-help.vue
    • 删除:cfc-frontend/pages/action-detail/octopus-records.vue
    • 修改:cfc-frontend/pages.json(移除 octopus-add-help, octopus-records 路由)
    • 修改:cfc-frontend/utils/api.js(移除旧 4 个 octopus 封装)
    • 修改:docs/superpowers/api/API_REFERENCE.md(标记旧接口废弃)

    • [ ] 步骤 1:OctopusController 标记 410

      // 修改每个方法第一行返回 410
      @PostMapping("/tentacles")
      public Result<List<Map<String, Object>>> tentacles(@RequestAttribute("userId") Long userId) {
      return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/list 等新接口");
      }
      @PostMapping("/add")
      public Result<ContactHelpLogDTO> addHelpLog(...) { return Result.error(410, "..."); }
      @PostMapping("/delete")
      public Result<String> deleteHelpLog(...) { return Result.error(410, "..."); }
      @PostMapping("/records")
      public Result<Map<String, Object>> records(...) { return Result.error(410, "..."); }
      
    • [ ] 步骤 2:前端删除旧文件 + 更新 pages.json + api.js

      # 删除文件
      rm cfc-frontend/components/OctopusDiagram.vue
      rm cfc-frontend/pages/action-detail/octopus-add-help.vue
      rm cfc-frontend/pages/action-detail/octopus-records.vue
      
      # pages.json 移除 "octopus-add-help", "octopus-records" 两项
      # api.js 删除 getOctopusTentacles/addOctopusHelpLog/deleteOctopusHelpLog/getOctopusRecords 4 行
      
    • [ ] 步骤 3:API_REFERENCE.md 标记旧接口废弃

    在 "4.37 情绪识别" 后或废弃列表中加:

    ### 4.xx 章鱼图(旧,已废弃 410)
    | 路径 | 说明 | 替代 |
    |------|------|------|
    | POST /api/octopus/tentacles | 触手榜 | /api/octopus/effect/list |
    | POST /api/octopus/add | 新增帮助记录 | /api/octopus/effect/add |
    | POST /api/octopus/delete | 删除帮助记录 | /api/octopus/effect/delete |
    | POST /api/octopus/records | 某联系人帮助明细 | /api/octopus/effect/key-persons |
    
    • [ ] 步骤 4:验证编译 + Commit

      cd cfc-backend && mvn clean compile -DskipTests
      git add -A
      git commit -m "refactor: 废弃旧章鱼图接口/组件/页面/文档(410 Gone),清理 3 旧文件"
      

    任务 6:前端 api.js 新增 10 个封装

    文件:

    • 修改:cfc-frontend/utils/api.js(在 getAbilityMap 后追加)

    • [ ] 步骤 1:追加 10 个函数

      // ===== 关键人拓展章鱼图 =====
      export const getOctopusEffects = (data) => request('/api/octopus/effect/list', 'POST', data)
      export const addOctopusEffect = (data) => request('/api/octopus/effect/add', 'POST', data)
      export const deleteOctopusEffect = (data) => request('/api/octopus/effect/delete', 'POST', data)
      
      export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data)
      export const updateOctopusKeyPerson = (data) => request('/api/octopus/key-person/update', 'POST', data)
      export const deleteOctopusKeyPerson = (data) => request('/api/octopus/key-person/delete', 'POST', data)
      export const getOctopusEffectKeyPersons = (data) => request('/api/octopus/effect/key-persons', 'POST', data)
      
      export const getOctopusAnalysisKeywords = (data) => request('/api/octopus/analysis/keywords', 'POST', data)
      export const getOctopusAnalysisSummary = (data) => request('/api/octopus/analysis/summary', 'POST', data)
      export const getOctopusExpansionSuggest = (data) => request('/api/octopus/expansion/suggest', 'POST', data)
      
    • [ ] 步骤 2:Commit

      git add cfc-frontend/utils/api.js
      git commit -m "feat: api.js 新增 10 个关键人拓展接口封装"
      

    任务 7:前端页面——成效录入页

    文件:

    • 创建:cfc-frontend/pages/action-detail/octopus-add-effect.vue

    • [ ] 步骤 1:参考 pearl-add-resource.vue 风格实现

      <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="order-select" v-if="orders.length > 0">
          <view class="order-item" v-for="o in orders" :key="o.id"
                :class="{selected: selectedOrderId === o.id}"
                @click="selectedOrderId = o.id">
            <text class="order-no">{{ o.orderNo }}</text>
            <text class="order-amount">¥{{ (o.actualPrice/100).toFixed(2) }}</text>
          </view>
        </view>
        <text class="empty-hint" v-else>暂无会员订单,可不关联</text>
      </view>
      
      <!-- 成效类型(多选) -->
      <view class="form-section">
        <text class="section-label">成效类型 <text class="required-mark">*</text></text>
        <view class="type-grid">
          <view class="type-item" v-for="(name, key) in effectTypes" :key="key"
                :class="{selected: effectTypeList.includes(Number(key))}"
                @click="toggleType(Number(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>
        <input class="amount-input" v-model="effectAmount" type="digit" placeholder="如 2500.00" />
      </view>
      
      <!-- 成效描述 -->
      <view class="form-section">
        <text class="section-label">成效描述(选填)</text>
        <textarea class="desc-input" v-model="effectDesc" placeholder="简述成效内容" maxlength="200"></textarea>
      </view>
      
      <view class="submit-bar">
        <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存成效</button>
      </view>
      </view>
      </template>
      
      <script>
      import { getMemberOrders, addOctopusEffect } from '../../utils/api.js'
      
      export default {
      data() {
      return {
        orders: [],
        selectedOrderId: null,
        effectTypeList: [],
        effectTypes: {1: '金额大', 2: '频次高', 4: '新成交'},
        typeIcons: {1: '💰', 2: '🔁', 4: '✨'},
        effectAmount: '',
        effectDesc: ''
      }
      },
      computed: {
      canSubmit() {
        return this.effectTypeList.length > 0 && this.effectAmount.trim()
      }
      },
      onLoad() {
      this.loadOrders()
      },
      methods: {
      async loadOrders() {
        try {
          var res = await getMemberOrders({ page: 1, size: 100 })
          if (res.code === 200 && res.data) {
            this.orders = res.data.list || (Array.isArray(res.data) ? res.data : [])
          }
        } catch (e) { /* 静默 */ }
      },
      toggleType(val) {
        var idx = this.effectTypeList.indexOf(val)
        if (idx > -1) this.effectTypeList.splice(idx, 1)
        else this.effectTypeList.push(val)
      },
      async onSubmit() {
        if (!this.canSubmit) {
          uni.showToast({ title: '请选择类型并填写金额', icon: 'none' })
          return
        }
        var type = 0
        this.effectTypeList.forEach(v => { type |= v })
        var data = { effectType: type, effectAmount: this.effectAmount, effectDesc: this.effectDesc.trim() }
        if (this.selectedOrderId) data.memberOrderId = this.selectedOrderId
        try {
          var res = await addOctopusEffect(data)
          if (res.code === 200) {
            uni.showToast({ title: '保存成功', icon: 'success' })
            setTimeout(() => uni.navigateBack(), 1500)
          } else {
            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
          }
        } catch (e) { uni.showToast({ title: '保存失败', icon: 'none' }) }
      }
      }
      }
      </script>
      <!-- 样式复用 pearl-add-resource.vue 的 .form-section/.type-grid/.submit-bar 等 -->
      
    • [ ] 步骤 2:注册 pages.json

      // pages.json action-detail 分包中,在 pearl-add-resource 后加:
      {
      "path": "octopus-add-effect",
      "style": { "navigationBarTitleText": "记录成效" }
      }
      
    • [ ] 步骤 3:Commit

      git add cfc-frontend/pages/action-detail/octopus-add-effect.vue cfc-frontend/pages.json
      git commit -m "feat: octopus-add-effect 成效录入页 + pages.json 注册"
      

    任务 8:前端页面——关键人录入页

    文件:

    • 创建:cfc-frontend/pages/action-detail/octopus-add-key-person.vue

    • [ ] 步骤 1:实现(支持预填 prefill 参数)

      <template>
      <view class="container">
      <view class="page-header"><text class="page-title">添加关键人</text></view>
      
      <view class="form-section">
        <text class="section-label">姓名 <text class="required-mark">*</text></text>
        <input class="name-input" v-model="name" placeholder="关键人姓名" maxlength="30" />
      </view>
      
      <view class="form-section">
        <text class="section-label">单位/公司</text>
        <input class="name-input" v-model="organization" placeholder="如:某小学家委会、某科技公司" maxlength="60" />
      </view>
      
      <view class="form-section">
        <text class="section-label">部门</text>
        <input class="name-input" v-model="department" placeholder="如:市场部、教务处" maxlength="30" />
      </view>
      
      <view class="form-section">
        <text class="section-label">职务/职位</text>
        <input class="name-input" v-model="title" placeholder="如:家委会主任、市场总监" maxlength="30" />
      </view>
      
      <view class="form-section">
        <text class="section-label">初识场合</text>
        <input class="name-input" v-model="firstMeetScene" placeholder="如:家长会、行业峰会、朋友介绍" maxlength="50" />
      </view>
      
      <view class="form-section">
        <text class="section-label">如何认识</text>
        <textarea class="desc-input" v-model="knowWay" placeholder="如:孩子班主任介绍认识、同事推荐" maxlength="200"></textarea>
      </view>
      
      <view class="form-section">
        <text class="section-label">联系方式(选填)</text>
        <input class="name-input" v-model="contactInfo" placeholder="电话/微信/邮箱" maxlength="50" />
      </view>
      
      <view class="submit-bar">
        <button class="btn-submit" @click="onSubmit" :disabled="!name.trim()">保存关键人</button>
      </view>
      </view>
      </template>
      
      <script>
      import { addOctopusKeyPerson } from '../../utils/api.js'
      
      export default {
      data() {
      return {
        name: '', organization: '', department: '', title: '',
        firstMeetScene: '', knowWay: '', contactInfo: ''
      }
      },
      onLoad(options) {
      if (options.prefill) {
        try {
          var prefill = JSON.parse(decodeURIComponent(options.prefill))
          Object.assign(this.$data, prefill)
        } catch (e) { /* 忽略 */ }
      }
      },
      methods: {
      async onSubmit() {
        if (!this.name.trim()) { uni.showToast({ title: '请填写姓名', icon: 'none' }); return }
        var data = {
          name: this.name.trim(),
          organization: this.organization.trim(),
          department: this.department.trim(),
          title: this.title.trim(),
          firstMeetScene: this.firstMeetScene.trim(),
          knowWay: this.knowWay.trim(),
          contactInfo: this.contactInfo.trim()
        }
        try {
          var res = await addOctopusKeyPerson(data)
          if (res.code === 200) {
            uni.showToast({ title: '保存成功', icon: 'success' })
            setTimeout(() => uni.navigateBack(), 1500)
          } else {
            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
          }
        } catch (e) { uni.showToast({ title: '保存失败', icon: 'none' }) }
      }
      }
      }
      </script>
      <!-- 样式复用 octopus-add-effect.vue -->
      
    • [ ] 步骤 2:注册 pages.json

      {
      "path": "octopus-add-key-person",
      "style": { "navigationBarTitleText": "添加关键人" }
      }
      
    • [ ] 步骤 3:Commit

      git add cfc-frontend/pages/action-detail/octopus-add-key-person.vue cfc-frontend/pages.json
      git commit -m "feat: octopus-add-key-person 关键人录入页(支持 prefill 预填)+ pages.json"
      

    任务 9:前端新组件 OctopusDiagram.vue(全新实现,替换旧组件)

    文件:

    • 创建:cfc-frontend/components/OctopusDiagram.vue

    • [ ] 步骤 1:实现 Canvas 2d 三层画布(参考 PearlDiagram/AbilityDiagram 模式)

    关键要点:

    • props: effects, keyPersons, expansions, selfId, canvasWidth, canvasHeight, interactive
    • data: dpr, ctx, _destroyed, _canvasNode, _canvasRect, canvasReady, centerNode, effectNodes, keyPersonNodes, expansionNodes, selectedNode, ...
    • mounted: $nextTick x2 → initCanvas(retry 5次) → buildLayout() → drawAll()
    • beforeDestroy: _destroyed = true
    • methods: initCanvas(cb), buildLayout(), drawAll(), _drawCenterNode, _drawEffectNodes, _drawKeyPersonNodes, _drawExpansionNodes, _drawEdges, _drawNode, hitTest, onTouchStart/Move/End, onCanvasTap, formatDate, _hexToRgba, _getCanvasRect
    • 三层结构:
      1. 中心 "我"
      2. 第1层:成效节点(圆形卡片,标签:金额大/频次高/新成交,点击 → 弹出关键人列表)
      3. 第2层:关键人节点(头像+姓名+单位/职务,边标记:引荐/决策,点击 → 弹窗完整画像)
      4. 第3层:拓展方向节点(虚线/浅色 ≤8个,点击 → 弹出"建议画像模板" → 跳转 octopus-add-key-person?prefill=...)
    • 交互:

      • onTouchStart/Move/End 区分滑动与点击(移动 >10px 取消点击)
      • hitTest 圆形命中测试
      • 点击成效节点 → this.$emit('effect-click', effect) 父组件处理弹出关键人列表
      • 点击关键人 → 弹窗显示完整画像(单位/部门/职务/初识场合/如何认识/联系方式)
      • 点击拓展节点 → uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person?prefill=' + encodeURIComponent(JSON.stringify(prefill)) })
      • 长按/右上角 "重新分析" → this.$emit('reanalyze')
    • [ ] 步骤 2:样式(复用 PearlDiagram/AbilityDiagram 的 scoped CSS 模式)

    • 禁 ?.、禁 CSS Grid、禁 :key 表达式、日期格式化用 substring(0,10)

    • [ ] 步骤 3:Commit

      git add cfc-frontend/components/OctopusDiagram.vue
      git commit -m "feat: OctopusDiagram.vue 全新关键人拓展画布(三层 Canvas + 触摸交互 + 预填跳转)"
      

    任务 10:集成到 action-detail/index.vue

    文件:

    • 修改:cfc-frontend/pages/action-detail/index.vue

    • [ ] 步骤 1:更新 imports + components 注册

      // 删除旧 import
      // import OctopusDiagram from '../../components/OctopusDiagram.vue'
      // 新 import
      import OctopusDiagram from '../../components/OctopusDiagram.vue'
      import { getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest } from '../../utils/api.js'
      
      components: {
      // 删 OctopusDiagram 旧引用,保留新组件
      OctopusDiagram,
      ...
      }
      
    • [ ] 步骤 2:data 新增字段

      data() {
      return {
      // 旧字段保留...
      octopusEffects: null,
      octopusKeyPersons: {}, // effectId -> [{...}, ...]
      octopusExpansions: [],
      ...
      }
      }
      
    • [ ] 步骤 3:onShow 新增加载

      onShow() {
      this.loadOctopusData()
      // 旧的 loadPearlResources/loadAbilityMap 保留
      }
      
    • [ ] 步骤 4:methods 新增

      loadOctopusData() {
      var self = this
      getOctopusEffects().then(function(res) {
      if (res.code === 200 && res.data) self.octopusEffects = res.data
      })
      getOctopusAnalysisSummary().then(function(res) {
      if (res.code === 200 && res.data) self.octopusExpansions = res.data.directions || []
      })
      },
      goOctopusAddEffect() { uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' }) },
      onEffectClick(effect) {
      // 加载该成效的关键人
      var self = this
      getOctopusEffectKeyPersons({ effectId: effect.id }).then(function(res) {
      if (res.code === 200) self.$set(self.octopusKeyPersons, effect.id, res.data)
      })
      },
      onExpansionClick(expansion) {
      // 预填跳转关键人录入页
      var prefill = encodeURIComponent(JSON.stringify(expansion.prefill))
      uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person?prefill=' + prefill })
      },
      onReanalyze() { this.loadOctopusData() }
      
    • [ ] 步骤 5:模板替换

      <!-- 旧:<OctopusDiagram :tentacles="tentacles" ... /> -->
      <!-- 新: -->
      <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="goOctopusAddEffect">+ 记录成效</text>
      </view>
      <OctopusDiagram
      ref="octopusDiagram"
      :selfId="selfId"
      :effects="octopusEffects?.list || octopusEffects"
      :expansions="octopusExpansions"
      :interactive="true"
      @effect-click="onEffectClick"
      @expansion-click="onExpansionClick"
      @reanalyze="onReanalyze" />
      </view>
      
    • [ ] 步骤 3:Commit

      git add cfc-frontend/pages/action-detail/index.vue
      git commit -m "feat: action-detail 集成新 OctopusDiagram(三层画布 + 成效/关键人/拓展三层交互)"
      

    任务 11:API_REFERENCE.md 同步新接口

    文件:

    • 修改:docs/superpowers/api/API_REFERENCE.md

    • [ ] 步骤 1:Controller 总览表新增 KeyPersonController 行

      | `KeyPersonController` | `/api/octopus` | 关键人拓展(成效/关键人/分析/拓展) | — |
      
    • [ ] 步骤 2:在 4.39 后新增 4.40 节

      ### 4.40 关键人拓展章鱼图(`/api/octopus/*`)
      
      替代旧章鱼图(help_logs)。核心:成效记录 → 关键人画像 → 高频词分析 → 拓展方向。
      
      | 路径 | 说明 |
      |------|------|
      | `POST /api/octopus/effect/list` | 成效记录分页列表(支持 effect_type 筛选) |
      | `POST /api/octopus/effect/add` | 新增成效(member_order_id + effect_type bitmask + amount + desc) |
      | `POST /api/octopus/effect/delete` | 删除成效(级联关系) |
      | `POST /api/octopus/key-person/add` | 新增关键人(姓名/单位/部门/职务/初识场合/如何认识) |
      | `POST /api/octopus/key-person/update` | 修改关键人画像 |
      | `POST /api/octopus/key-person/delete` | 删除关键人(级联关系) |
      | `POST /api/octopus/effect/key-persons` | 某成效的关键人列表 |
      | `POST /api/octopus/analysis/keywords` | 高频词统计(单位/部门/职务/场合/路径) |
      | `POST /api/octopus/analysis/summary` | 高频词提炼 ≤8 个拓展方向 |
      | `POST /api/octopus/expansion/suggest` | 基于分析推荐关键人画像模板(预填) |
      
      > **已记录关键人拓展 API 端点 — 禁止重复注册。**
      
    • [ ] 步骤 3:标记旧 OctopusController 接口废弃

    在 "4.37 情绪识别" 后加旧接口废弃标记(见任务 5 步骤 3)。

    • [ ] 步骤 4:Commit

      git add docs/superpowers/api/API_REFERENCE.md
      git commit -m "docs: API_REFERENCE 同步关键人拓展 10 接口 + 旧章鱼图 4 接口标记废弃"
      

    任务 12:全链路验证 + 最终 Commit

    文件:

    • 全部已修改文件

    • [ ] 步骤 1:后端编译验证

      cd cfc-backend && mvn clean compile -DskipTests
      # 预期:BUILD SUCCESS
      
    • [ ] 步骤 2:前端语法检查

      cd cfc-frontend && npm run lint 2>&1 | head -50
      # 无 Error 级别报错(Warning 可忽略)
      
    • [ ] 步骤 3:API_REFERENCE 完整性检查

      grep -c "/api/octopus/" docs/superpowers/api/API_REFERENCE.md
      # 预期:包含 10 个新接口 + 4 个废弃接口标记
      
    • [ ] 步骤 4:全量 Commit + Push

      git add -A
      git commit -m "feat: 关键人拓展章鱼图全链路上线(迁移305-307 + 3实体/Mapper/Service/Controller + 新OctopusDiagram + 2新页面 + action-detail集成 + API文档同步 + 旧组件清理)"
      git push origin cfclub
      

    执行方式选择

    计划已完成并保存到 docs/superpowers/plans/2026-09-10-key-person-expansion-octopus.md。两种执行方式:

    1. 子代理驱动(推荐) - 每个任务调度一个新的子代理,任务间进行审查,快速迭代

    必需子技能: 使用 superpowers:subagent-driven-development 每个任务一个新子代理 + 两阶段审查

    2. 内联执行 - 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点供审查

    必需子技能: 使用 superpowers:executing-plans 批量执行并设有检查点供审查

    选哪种方式?