Selaa lähdekoodia

plan: 关键人拓展章鱼图替换实现计划(12任务/全链路)

Sisyphus Agent 1 viikko sitten
vanhempi
sitoutus
6053799247
1 muutettua tiedostoa jossa 1432 lisäystä ja 0 poistoa
  1. 1432 0
      docs/superpowers/plans/2026-09-10-key-person-expansion-octopus.md

+ 1432 - 0
docs/superpowers/plans/2026-09-10-key-person-expansion-octopus.md

@@ -0,0 +1,1432 @@
+# 关键人拓展章鱼图替换 实现计划
+
+> **面向 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<T>。
+- 前端: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:编写失败的测试(验证迁移不存在)**
+
+```bash
+# 验证当前无这 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**
+
+```java
+// 迁移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 表后追加)**
+
+```sql
+-- 关键人拓展-成效记录
+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:验证编译**
+
+```bash
+cd cfc-backend && mvn clean compile -DskipTests
+# 预期:BUILD SUCCESS
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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**
+
+```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**
+
+```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**
+
+```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)**
+
+```java
+// 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> {
+}
+```
+
+```java
+// 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> {
+}
+```
+
+```java
+// 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**
+
+```bash
+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:编写失败的测试(验证类不存在)**
+
+```bash
+ls cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
+# 预期:No such file
+```
+
+- [ ] **步骤 2:实现 KeyPersonService.java(参考 ResourceService 模式)**
+
+```java
+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**
+
+```bash
+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 模式)**
+
+```java
+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**
+
+```bash
+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**
+
+```java
+// 修改每个方法第一行返回 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**
+
+```bash
+# 删除文件
+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**
+
+```bash
+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 个函数**
+
+```javascript
+// ===== 关键人拓展章鱼图 =====
+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**
+
+```bash
+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 风格实现**
+
+```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**
+
+```json
+// pages.json action-detail 分包中,在 pearl-add-resource 后加:
+{
+  "path": "octopus-add-effect",
+  "style": { "navigationBarTitleText": "记录成效" }
+}
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+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 参数)**
+
+```vue
+<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**
+
+```json
+{
+  "path": "octopus-add-key-person",
+  "style": { "navigationBarTitleText": "添加关键人" }
+}
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+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**
+
+```bash
+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 注册**
+
+```js
+// 删除旧 import
+// import OctopusDiagram from '../../components/OctopusDiagram.vue'
+// 新 import
+import OctopusDiagram from '../../components/OctopusDiagram.vue'
+import { getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest } from '../../utils/api.js'
+```
+
+```js
+components: {
+  // 删 OctopusDiagram 旧引用,保留新组件
+  OctopusDiagram,
+  ...
+}
+```
+
+- [ ] **步骤 2:data 新增字段**
+
+```js
+data() {
+  return {
+    // 旧字段保留...
+    octopusEffects: null,
+    octopusKeyPersons: {}, // effectId -> [{...}, ...]
+    octopusExpansions: [],
+    ...
+  }
+}
+```
+
+- [ ] **步骤 3:onShow 新增加载**
+
+```js
+onShow() {
+  this.loadOctopusData()
+  // 旧的 loadPearlResources/loadAbilityMap 保留
+}
+```
+
+- [ ] **步骤 4:methods 新增**
+
+```js
+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:模板替换**
+
+```html
+<!-- 旧:<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**
+
+```bash
+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 行**
+
+```markdown
+| `KeyPersonController` | `/api/octopus` | 关键人拓展(成效/关键人/分析/拓展) | — |
+```
+
+- [ ] **步骤 2:在 4.39 后新增 4.40 节**
+
+```markdown
+### 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**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git commit -m "docs: API_REFERENCE 同步关键人拓展 10 接口 + 旧章鱼图 4 接口标记废弃"
+```
+
+---
+
+### 任务 12:全链路验证 + 最终 Commit
+
+**文件:**
+- 全部已修改文件
+
+- [ ] **步骤 1:后端编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -DskipTests
+# 预期:BUILD SUCCESS
+```
+
+- [ ] **步骤 2:前端语法检查**
+
+```bash
+cd cfc-frontend && npm run lint 2>&1 | head -50
+# 无 Error 级别报错(Warning 可忽略)
+```
+
+- [ ] **步骤 3:API_REFERENCE 完整性检查**
+
+```bash
+grep -c "/api/octopus/" docs/superpowers/api/API_REFERENCE.md
+# 预期:包含 10 个新接口 + 4 个废弃接口标记
+```
+
+- [ ] **步骤 4:全量 Commit + Push**
+
+```bash
+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
+> 批量执行并设有检查点供审查
+
+**选哪种方式?**