2026-09-10-pearl-diagram-redesign.md 69 KB

珍珠图社会连接盘点系统 实现计划

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

目标: 将现有珍珠图从「4 组资源清单」重构为「25 类社会连接盘点系统」:两层同心圆布局(内圈必备 12 组 / 外圈理想 13 组)、珍珠大小按价值分级、互动日志 + 定期提醒。

架构: 后端新增 ConnectionType 枚举 + pearl_interaction_log/pearl_interaction_reminder 两张表 + 价值分自动计算 + 每日定时提醒任务;前端扩展 PearlDiagram.vue 为两层同心圆布局(珍珠半径按 priority/valueScore 映射),新增 25 类类型选择器和提醒列表页。旧 PERSON/SKILL/INFO/PLACE 数据保留为 legacyGroups 不迁移。

技术栈: Spring Boot 2.7.18 + MyBatis-Plus + Java 8;uni-app 微信小程序 (Vue 2 Options API, Canvas 2D);DatabaseInitializer 迁移


文件结构

后端(cfc-backend):

文件 职责 动作
src/main/java/com/etotem/cfc/enums/ConnectionType.java 25 类连接类型枚举(code/label/category/建议周期) 创建
src/main/java/com/etotem/cfc/entity/PearlInteractionLog.java 互动日志实体 创建
src/main/java/com/etotem/cfc/entity/PearlInteractionReminder.java 提醒实体 创建
src/main/java/com/etotem/cfc/mapper/PearlInteractionLogMapper.java 互动日志 Mapper 创建
src/main/java/com/etotem/cfc/mapper/PearlInteractionReminderMapper.java 提醒 Mapper 创建
src/main/java/com/etotem/cfc/entity/ResourceItem.java 扩展新字段(connectionType/priority/valueScore/lastInteractionAt/interactionCount) 修改
src/main/java/com/etotem/cfc/service/ResourceService.java getPearlResources 改为 25 组 + legacyGroups;新增互动/提醒/优先级/价值分方法 修改
src/main/java/com/etotem/cfc/controller/PearlController.java 新增 5 个接口 修改
src/main/java/com/etotem/cfc/task/PearlReminderScheduledTask.java 每日 08:00 生成提醒 创建
src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 迁移 304-306(加列 + 建两表) 修改
src/main/resources/schema.sql 同步 resource_items 新字段 + 两张新表 修改

前端(cfc-frontend):

文件 职责 动作
utils/api.js 新增 5 个 API 方法 修改
components/PearlDiagram.vue 两层同心圆布局 + 珍珠半径按价值映射 + 逾期角标 + 弹窗联系/优先级 修改
pages/action-detail/pearl-add-resource.vue 类型选择改为 25 类分组选择器 修改
pages/pearl-reminders/index.vue 提醒列表页 创建
pages.json 注册提醒页路由 修改

文档:

文件 职责 动作
docs/superpowers/api/API_REFERENCE.md §4.39 同步新增接口 修改

任务 1:数据库迁移(迁移 304-306)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() 末尾,迁移 303 块之后)
  • 修改:cfc-backend/src/main/resources/schema.sql(同步)

  • [ ] 步骤 1:确认现有迁移 303 位置

阅读 DatabaseInitializer.java 末尾(约 10508-10543 行),确认 // 迁移303 块和 runMigrations() 的收尾结构。找到 createResourceItemsTable() 方法(或内联的 CREATE TABLE resource_items 块)。

  • 步骤 2:添加迁移 304(resource_items 加列)

在迁移 303 块之后、方法结束之前,追加:

// 迁移304: resource_items 表添加社会连接分类字段(珍珠图重构:25 类连接类型 + 价值分级)
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN connection_type VARCHAR(50) NOT NULL DEFAULT '' COMMENT '连接类型枚举名(ConnectionType)'");
} catch (Exception e) {
    log.warn("迁移304: resource_items.connection_type 已存在或添加失败: {}", e.getMessage());
}
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN priority TINYINT DEFAULT 2 COMMENT '优先级: 1=高 2=中 3=低(手动覆盖)'");
} catch (Exception e) {
    log.warn("迁移304: resource_items.priority 已存在或添加失败: {}", e.getMessage());
}
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN value_score INT DEFAULT 0 COMMENT '价值分 0-100(自动计算,可手动覆盖)'");
} catch (Exception e) {
    log.warn("迁移304: resource_items.value_score 已存在或添加失败: {}", e.getMessage());
}
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN last_interaction_at DATETIME DEFAULT NULL COMMENT '最近互动时间'");
} catch (Exception e) {
    log.warn("迁移304: resource_items.last_interaction_at 已存在或添加失败: {}", e.getMessage());
}
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD COLUMN interaction_count INT DEFAULT 0 COMMENT '互动总次数'");
} catch (Exception e) {
    log.warn("迁移304: resource_items.interaction_count 已存在或添加失败: {}", e.getMessage());
}
try {
    jdbcTemplate.execute("ALTER TABLE resource_items ADD INDEX idx_ri_connection_type (connection_type)");
} catch (Exception e) {
    log.warn("迁移304: resource_items.connection_type 索引已存在或添加失败: {}", e.getMessage());
}
log.info("迁移304: resource_items 社会连接字段添加完成");
  • [ ] 步骤 3:添加迁移 305(创建 pearl_interaction_log 表)

    // 迁移305: 创建珍珠图互动日志表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_log (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "user_id BIGINT NOT NULL COMMENT '操作人', " +
            "resource_item_id BIGINT NOT NULL COMMENT '关联资源', " +
            "interaction_type VARCHAR(20) NOT NULL COMMENT 'PHONE/WECHAT/MEETING/OTHER', " +
            "content VARCHAR(500) DEFAULT NULL COMMENT '互动内容简记', " +
            "happened_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " +
            "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_pil_user (user_id), " +
            "INDEX idx_pil_resource (resource_item_id), " +
            "INDEX idx_pil_happened (happened_at) " +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动日志'");
    log.info("迁移305: 已创建 pearl_interaction_log 表");
    } catch (Exception e) {
    log.warn("迁移305: 创建 pearl_interaction_log 表失败: {}", e.getMessage());
    }
    
  • [ ] 步骤 4:添加迁移 306(创建 pearl_interaction_reminder 表)

    // 迁移306: 创建珍珠图互动提醒表
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "user_id BIGINT NOT NULL, " +
            "resource_item_id BIGINT NOT NULL, " +
            "connection_type VARCHAR(50) NOT NULL, " +
            "suggest_interval_month INT NOT NULL COMMENT '建议互动周期(月)', " +
            "last_interaction_at DATETIME DEFAULT NULL, " +
            "days_since_last INT NOT NULL COMMENT '距上次互动天数', " +
            "status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING/DONE/DISMISSED', " +
            "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
            "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "INDEX idx_pir_user_status (user_id, status), " +
            "INDEX idx_pir_resource (resource_item_id) " +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒'");
    log.info("迁移306: 已创建 pearl_interaction_reminder 表");
    } catch (Exception e) {
    log.warn("迁移306: 创建 pearl_interaction_reminder 表失败: {}", e.getMessage());
    }
    
  • [ ] 步骤 5:同步 schema.sql

在 schema.sql 的 resource_items 表定义(约 5251-5261 行)中同步新字段:

-- 珍珠图资源登记表(社会连接资源)
CREATE TABLE IF NOT EXISTS resource_items (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL COMMENT '资源拥有者(当前用户)',
    type VARCHAR(16) NOT NULL COMMENT '资源类型: INFO(信息)/PLACE(场所)/SKILL(技能)',
    connection_type VARCHAR(50) NOT NULL DEFAULT '' COMMENT '连接类型枚举名(ConnectionType)',
    priority TINYINT DEFAULT 2 COMMENT '优先级: 1=高 2=中 3=低(手动覆盖)',
    value_score INT DEFAULT 0 COMMENT '价值分 0-100(自动计算,可手动覆盖)',
    last_interaction_at DATETIME DEFAULT NULL COMMENT '最近互动时间',
    interaction_count INT DEFAULT 0 COMMENT '互动总次数',
    name VARCHAR(100) NOT NULL COMMENT '资源名称',
    description VARCHAR(255) COMMENT '资源描述(可调用方式/说明)',
    contact_id BIGINT DEFAULT NULL COMMENT '关联联系人ID(可选)',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_ri_user (user_id),
    INDEX idx_ri_connection_type (connection_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图资源登记表(社会连接资源)';

在 schema.sql 末尾(或 resource_items 表之后)追加两张新表的定义:

-- 珍珠图互动日志表
CREATE TABLE IF NOT EXISTS pearl_interaction_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL COMMENT '操作人',
    resource_item_id BIGINT NOT NULL COMMENT '关联资源',
    interaction_type VARCHAR(20) NOT NULL COMMENT 'PHONE/WECHAT/MEETING/OTHER',
    content VARCHAR(500) DEFAULT NULL COMMENT '互动内容简记',
    happened_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_pil_user (user_id),
    INDEX idx_pil_resource (resource_item_id),
    INDEX idx_pil_happened (happened_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动日志';

-- 珍珠图互动提醒表
CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    resource_item_id BIGINT NOT NULL,
    connection_type VARCHAR(50) NOT NULL,
    suggest_interval_month INT NOT NULL COMMENT '建议互动周期(月)',
    last_interaction_at DATETIME DEFAULT NULL,
    days_since_last INT NOT NULL COMMENT '距上次互动天数',
    status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING/DONE/DISMISSED',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_pir_user_status (user_id, status),
    INDEX idx_pir_resource (resource_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒';
  • 步骤 6:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS,无编译错误。

  • [ ] 步骤 7:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(pearl): 数据库迁移 304-306 社会连接字段与互动/提醒表"
    

任务 2:ConnectionType 枚举 + 新实体 + Mapper

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/enums/ConnectionType.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/PearlInteractionLog.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/PearlInteractionReminder.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/PearlInteractionLogMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/PearlInteractionReminderMapper.java

  • [ ] 步骤 1:创建 ConnectionType 枚举

    package com.etotem.cfc.enums;
    
    /**
    * 珍珠图社会连接类型枚举
    * category: ESSENTIAL(必备,内圈) / IDEAL(理想扩展,外圈)
    */
    public enum ConnectionType {
    
    // 必备社会连接(ESSENTIAL)— 内圈,12 类
    MEDICAL("医疗", "ESSENTIAL", 3),
    EDUCATION("教育", "ESSENTIAL", 6),
    PUBLIC_SECURITY("公安", "ESSENTIAL", 12),
    AUTO("汽车相关", "ESSENTIAL", 6),
    REAL_ESTATE("房产服务", "ESSENTIAL", 12),
    TICKETING("票务", "ESSENTIAL", 12),
    HANDYMAN("多能工人", "ESSENTIAL", 6),
    APPLIANCE("家电", "ESSENTIAL", 12),
    CATERING("餐饮", "ESSENTIAL", 3),
    FOOD("食品", "ESSENTIAL", 3),
    LEGAL("法律", "ESSENTIAL", 12),
    WEEKEND_EXPERT("周末达人", "ESSENTIAL", 1),
    
    // 理想社会连接扩展(IDEAL)— 外圈,13 类
    GOVERNMENT("政府综合", "IDEAL", 12),
    TAX("税务", "IDEAL", 12),
    BUSINESS_ADMIN("工商", "IDEAL", 12),
    BANK("银行", "IDEAL", 6),
    MEDIA("媒体", "IDEAL", 12),
    TRAVEL("旅游", "IDEAL", 12),
    LEADING_ENTERPRISE("领军企业", "IDEAL", 12),
    INDUSTRY_BENCHMARK("行业标杆", "IDEAL", 12),
    OVERSEAS("国外", "IDEAL", 12),
    UNIVERSITY("高校", "IDEAL", 12),
    FINANCE("金融", "IDEAL", 12),
    SENIOR_LOCAL("资深土著", "IDEAL", 6),
    KEY_CITY("北上广等关键城市", "IDEAL", 12);
    
    private final String label;
    private final String category;
    private final int suggestIntervalMonth;
    
    ConnectionType(String label, String category, int suggestIntervalMonth) {
        this.label = label;
        this.category = category;
        this.suggestIntervalMonth = suggestIntervalMonth;
    }
    
    public String getLabel() { return label; }
    public String getCategory() { return category; }
    public int getSuggestIntervalMonth() { return suggestIntervalMonth; }
    
    /** 是否为有效枚举名(addResourceItem 校验用) */
    public static boolean isValid(String name) {
        if (name == null || name.isEmpty()) return false;
        for (ConnectionType t : values()) {
            if (t.name().equals(name)) return true;
        }
        return false;
    }
    }
    
  • [ ] 步骤 2:创建 PearlInteractionLog 实体

    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("pearl_interaction_log")
    public class PearlInteractionLog implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private Long userId;
    
    private Long resourceItemId;
    
    /** PHONE/WECHAT/MEETING/OTHER */
    private String interactionType;
    
    private String content;
    
    private Date happenedAt;
    
    private Date createdAt;
    }
    
  • [ ] 步骤 3:创建 PearlInteractionReminder 实体

    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("pearl_interaction_reminder")
    public class PearlInteractionReminder implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private Long userId;
    
    private Long resourceItemId;
    
    private String connectionType;
    
    private Integer suggestIntervalMonth;
    
    private Date lastInteractionAt;
    
    private Integer daysSinceLast;
    
    /** PENDING/DONE/DISMISSED */
    private String status;
    
    private Date createdAt;
    
    private Date updatedAt;
    }
    
  • [ ] 步骤 4:创建两个 Mapper

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.PearlInteractionLog;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface PearlInteractionLogMapper extends BaseMapper<PearlInteractionLog> {
    }
    
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.PearlInteractionReminder;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface PearlInteractionReminderMapper extends BaseMapper<PearlInteractionReminder> {
    }
    
  • [ ] 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/enums/ cfc-backend/src/main/java/com/etotem/cfc/entity/PearlInteraction*.java cfc-backend/src/main/java/com/etotem/cfc/mapper/PearlInteraction*Mapper.java
    git commit -m "feat(pearl): ConnectionType 枚举与互动/提醒实体 Mapper"
    

任务 3:扩展 ResourceItem 实体

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/ResourceItem.java

  • [ ] 步骤 1:添加新字段

在 ResourceItem.java 的 createdAt 之前添加:

    /** 连接类型枚举名(ConnectionType),空串=历史资源 */
    private String connectionType;

    /** 优先级: 1=高 2=中 3=低(手动覆盖用) */
    private Integer priority;

    /** 价值分 0-100(自动计算,可手动覆盖) */
    private Integer valueScore;

    /** 最近互动时间 */
    private Date lastInteractionAt;

    /** 互动总次数 */
    private Integer interactionCount;
  • 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/ResourceItem.java
    git commit -m "feat(pearl): ResourceItem 实体新增社会连接字段"
    

任务 4:ResourceService 扩展(核心业务逻辑)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java

  • [ ] 步骤 1:注入新 Mapper + 添加常量/辅助方法

在 ResourceService.java 顶部添加注入和导入:

import com.etotem.cfc.entity.PearlInteractionLog;
import com.etotem.cfc.entity.PearlInteractionReminder;
import com.etotem.cfc.enums.ConnectionType;
import com.etotem.cfc.mapper.PearlInteractionLogMapper;
import com.etotem.cfc.mapper.PearlInteractionReminderMapper;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;

在 helpLogMapper 注入之后添加:

    @Resource
    private PearlInteractionLogMapper interactionLogMapper;

    @Resource
    private PearlInteractionReminderMapper reminderMapper;

    /** 25 类连接类型 → 前端选择器/渲染配置 */
    public List<Map<String, Object>> getConnectionTypes() {
        List<Map<String, Object>> list = new ArrayList<>();
        for (ConnectionType t : ConnectionType.values()) {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("code", t.name());
            m.put("label", t.getLabel());
            m.put("category", t.getCategory());
            m.put("suggestIntervalMonth", t.getSuggestIntervalMonth());
            list.add(m);
        }
        return list;
    }
  • 步骤 2:重写 getPearlResources 为 25 组 + legacyGroups

替换现有 getPearlResources 方法体(保留 contacts/skillLogs/resourceItems 查询逻辑,输出结构改造):

    /** 珍珠图聚合:25 类社会连接 + 历史资源(legacy PERSON/SKILL/INFO/PLACE) */
    public Result<Map<String, Object>> getPearlResources(Long userId) {
        // 历史资源:legacy 分组的 contacts(人脉)与 skillLogs(技能)照旧
        List<Contact> contacts = contactMapper.selectList(
                new LambdaQueryWrapper<Contact>().eq(Contact::getUserId, userId)
        );
        List<ContactHelpLog> skillLogs = helpLogMapper.selectList(
                new LambdaQueryWrapper<ContactHelpLog>()
                        .eq(ContactHelpLog::getUserId, userId)
                        .eq(ContactHelpLog::getHelpType, "SKILL")
                        .orderByDesc(ContactHelpLog::getHappenedAt)
        );
        List<ResourceItem> resourceItems = resourceItemMapper.selectList(
                new LambdaQueryWrapper<ResourceItem>()
                        .eq(ResourceItem::getUserId, userId)
                        .orderByDesc(ResourceItem::getCreatedAt)
        );

        // 25 组社会连接:按 connection_type 分组
        Map<String, List<Map<String, Object>>> connGroupMap = new LinkedHashMap<>();
        Map<String, String> connTypeLabel = new LinkedHashMap<>();
        for (ConnectionType t : ConnectionType.values()) {
            connGroupMap.put(t.name(), new ArrayList<>());
            connTypeLabel.put(t.name(), t.getLabel());
        }
        // 兼容历史数据:connection_type 为空的按原 type 归入 INFO/PLACE legacy
        List<Map<String, Object>> legacyInfo = new ArrayList<>();
        List<Map<String, Object>> legacyPlace = new ArrayList<>();
        List<Long> itemIds = resourceItems.stream().map(ResourceItem::getId).collect(Collectors.toList());
        // 批量取互动日志用于计算 daysSinceLast
        Map<Long, List<PearlInteractionLog>> logMap = new HashMap<>();
        if (!itemIds.isEmpty()) {
            interactionLogMapper.selectList(
                    new LambdaQueryWrapper<PearlInteractionLog>()
                            .in(PearlInteractionLog::getResourceItemId, itemIds)
                            .orderByDesc(PearlInteractionLog::getHappenedAt)
            ).forEach(l -> logMap.computeIfAbsent(l.getResourceItemId(), k -> new ArrayList<>()).add(l));
        }

        for (ResourceItem item : resourceItems) {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", item.getId());
            m.put("name", item.getName());
            m.put("description", item.getDescription());
            m.put("contactId", item.getContactId());
            m.put("createdAt", item.getCreatedAt());
            m.put("valueScore", item.getValueScore() != null ? item.getValueScore() : 0);
            m.put("priority", item.getPriority() != null ? item.getPriority() : 2);
            m.put("interactionCount", item.getInteractionCount() != null ? item.getInteractionCount() : 0);
            m.put("lastInteractionAt", item.getLastInteractionAt());
            if (item.getLastInteractionAt() != null) {
                long days = ChronoUnit.DAYS.between(
                        item.getLastInteractionAt().toInstant(), Instant.now());
                m.put("daysSinceLast", (int) Math.max(0, days));
            } else {
                m.put("daysSinceLast", null);
            }
            String ct = item.getConnectionType();
            if (ct != null && !ct.isEmpty() && connGroupMap.containsKey(ct)) {
                connGroupMap.get(ct).add(m);
            } else {
                // 历史资源
                if ("PLACE".equals(item.getType())) {
                    legacyPlace.add(m);
                } else {
                    legacyInfo.add(m);
                }
            }
        }

        // 组装 25 组(按枚举顺序)
        List<Map<String, Object>> groups = new ArrayList<>();
        for (ConnectionType t : ConnectionType.values()) {
            Map<String, Object> g = new LinkedHashMap<>();
            g.put("type", t.name());
            g.put("typeName", t.getLabel());
            g.put("category", t.getCategory());
            g.put("ring", "ESSENTIAL".equals(t.getCategory()) ? "inner" : "outer");
            g.put("items", connGroupMap.get(t.name()));
            groups.add(g);
        }

        // legacyGroups:人脉/技能/历史信息/历史场所
        List<Map<String, Object>> legacyGroups = new ArrayList<>();
        legacyGroups.add(group("PERSON", "人脉", contacts.stream().map(c -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", c.getId());
            m.put("name", c.getName());
            m.put("avatar", c.getAvatar());
            m.put("relationshipType", c.getRelationshipType());
            return m;
        }).collect(Collectors.toList())));
        legacyGroups.add(group("SKILL", "技能", buildSkillItems(skillLogs)));
        legacyGroups.add(group("INFO", "信息", legacyInfo));
        legacyGroups.add(group("PLACE", "场所", legacyPlace));

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("groups", groups);
        result.put("legacyGroups", legacyGroups);
        return Result.success(result);
    }

注意:logMap 在本方法中暂用于后续扩展,若编译器报未使用可删除该变量声明(保留查询有成本,建议删除:logMap 在此任务中不消费,删除以免死代码。详见步骤 2 说明)。

  • 步骤 3:addResourceItem 支持 connection_type

修改 addResourceItem 方法签名与校验:

    /** 登记资源(珍珠图:25 类社会连接,connectionType 必填) */
    public Result<ResourceItem> addResourceItem(Long userId, String type, String connectionType,
                                                String name, String description, Long contactId) {
        if (connectionType == null || !ConnectionType.isValid(connectionType)) {
            return Result.error("请选择有效的连接类型");
        }
        if (name == null || name.trim().isEmpty()) {
            return Result.error("资源名称不能为空");
        }
        if (contactId != null) {
            Contact contact = contactMapper.selectById(contactId);
            if (contact == null || !contact.getUserId().equals(userId)) {
                return Result.error("关联联系人不存在");
            }
        }

        ResourceItem item = new ResourceItem();
        item.setUserId(userId);
        item.setType(type != null && !type.isEmpty() ? type : connectionType);
        item.setConnectionType(connectionType);
        item.setPriority(2);
        item.setValueScore(50);
        item.setName(name.trim());
        item.setDescription(description != null ? description.trim() : null);
        item.setContactId(contactId);
        item.setInteractionCount(0);
        item.setCreatedAt(new Date());
        resourceItemMapper.insert(item);
        return Result.success(item);
    }
  • 步骤 4:新增互动/提醒/优先级/价值分方法

在 deleteResourceItem 方法之后追加:

    /** 记录一次互动并更新资源统计 */
    public Result<Void> addInteraction(Long userId, Long itemId, String interactionType, String content) {
        ResourceItem item = resourceItemMapper.selectById(itemId);
        if (item == null || !item.getUserId().equals(userId)) {
            return Result.error("资源不存在");
        }
        if (interactionType == null || !Arrays.asList("PHONE", "WECHAT", "MEETING", "OTHER").contains(interactionType)) {
            return Result.error("互动方式不合法");
        }

        PearlInteractionLog log = new PearlInteractionLog();
        log.setUserId(userId);
        log.setResourceItemId(itemId);
        log.setInteractionType(interactionType);
        log.setContent(content != null ? content.trim() : null);
        log.setHappenedAt(new Date());
        log.setCreatedAt(new Date());
        interactionLogMapper.insert(log);

        item.setLastInteractionAt(log.getHappenedAt());
        item.setInteractionCount((item.getInteractionCount() == null ? 0 : item.getInteractionCount()) + 1);
        // 重新计算价值分(互动后价值提升)
        List<PearlInteractionLog> logs = interactionLogMapper.selectList(
                new LambdaQueryWrapper<PearlInteractionLog>()
                        .eq(PearlInteractionLog::getResourceItemId, itemId)
                        .orderByDesc(PearlInteractionLog::getHappenedAt)
        );
        item.setValueScore(calculateValueScore(item, logs));
        resourceItemMapper.updateById(item);

        // 关闭该资源的 PENDING 提醒
        reminderMapper.update(null,
                new LambdaQueryWrapper<PearlInteractionReminder>()
                        .eq(PearlInteractionReminder::getUserId, userId)
                        .eq(PearlInteractionReminder::getResourceItemId, itemId)
                        .eq(PearlInteractionReminder::getStatus, "PENDING")
                        .set(PearlInteractionReminder::getStatus, "DONE")
        );
        return Result.success(null);
    }

    /** 互动历史(按时间倒序) */
    public Result<List<Map<String, Object>>> getInteractionList(Long userId, Long itemId, int page, int size) {
        ResourceItem item = resourceItemMapper.selectById(itemId);
        if (item == null || !item.getUserId().equals(userId)) {
            return Result.error("资源不存在");
        }
        List<PearlInteractionLog> logs = interactionLogMapper.selectList(
                new LambdaQueryWrapper<PearlInteractionLog>()
                        .eq(PearlInteractionLog::getResourceItemId, itemId)
                        .orderByDesc(PearlInteractionLog::getHappenedAt)
                        .last("LIMIT " + ((page - 1) * size) + "," + size)
        );
        List<Map<String, Object>> list = logs.stream().map(l -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", l.getId());
            m.put("interactionType", l.getInteractionType());
            m.put("content", l.getContent());
            m.put("happenedAt", l.getHappenedAt());
            return m;
        }).collect(Collectors.toList());
        return Result.success(list);
    }

    /** 手动覆盖优先级(1/2/3),覆盖后价值分同步调整 */
    public Result<Void> updatePriority(Long userId, Long itemId, Integer priority) {
        ResourceItem item = resourceItemMapper.selectById(itemId);
        if (item == null || !item.getUserId().equals(userId)) {
            return Result.error("资源不存在");
        }
        if (priority == null || priority < 1 || priority > 3) {
            return Result.error("优先级不合法");
        }
        item.setPriority(priority);
        // 手动覆盖时同步调整 valueScore 到一个合理档位
        item.setValueScore(priority == 1 ? 80 : priority == 2 ? 50 : 20);
        resourceItemMapper.updateById(item);
        return Result.success(null);
    }

    /** 待办提醒列表(PENDING) */
    public Result<List<Map<String, Object>>> getReminders(Long userId) {
        List<PearlInteractionReminder> reminders = reminderMapper.selectList(
                new LambdaQueryWrapper<PearlInteractionReminder>()
                        .eq(PearlInteractionReminder::getUserId, userId)
                        .eq(PearlInteractionReminder::getStatus, "PENDING")
                        .orderByDesc(PearlInteractionReminder::getDaysSinceLast)
        );
        Map<Long, ResourceItem> itemMap = new HashMap<>();
        List<Long> ids = reminders.stream().map(PearlInteractionReminder::getResourceItemId).collect(Collectors.toList());
        if (!ids.isEmpty()) {
            resourceItemMapper.selectBatchIds(ids).forEach(i -> itemMap.put(i.getId(), i));
        }
        List<Map<String, Object>> list = reminders.stream().map(r -> {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("id", r.getId());
            m.put("resourceItemId", r.getResourceItemId());
            ResourceItem item = itemMap.get(r.getResourceItemId());
            m.put("name", item != null ? item.getName() : "");
            m.put("connectionType", r.getConnectionType());
            m.put("suggestIntervalMonth", r.getSuggestIntervalMonth());
            m.put("daysSinceLast", r.getDaysSinceLast());
            return m;
        }).collect(Collectors.toList());
        return Result.success(list);
    }

    /** 自动计算价值分 0-100 */
    public int calculateValueScore(ResourceItem item, List<PearlInteractionLog> logs) {
        int score = 0;
        // 1. 类型基础权重
        ConnectionType ct = null;
        for (ConnectionType t : ConnectionType.values()) {
            if (t.name().equals(item.getConnectionType())) { ct = t; break; }
        }
        score += (ct != null && "ESSENTIAL".equals(ct.getCategory())) ? 30 : 20;
        // 2. 近90天互动次数 × 5,上限 30
        Date now = new Date();
        Date ninetyDaysAgo = new Date(now.getTime() - 90L * 24 * 3600 * 1000);
        int recent = 0;
        if (logs != null) {
            for (PearlInteractionLog l : logs) {
                if (l.getHappenedAt() != null && l.getHappenedAt().after(ninetyDaysAgo)) recent++;
            }
        }
        score += Math.min(recent * 5, 30);
        // 3. 最近互动时效性
        if (item.getLastInteractionAt() != null) {
            long days = ChronoUnit.DAYS.between(item.getLastInteractionAt().toInstant(), Instant.now());
            if (days <= 30) score += 20;
            else if (days <= 90) score += 10;
            else if (days <= 180) score += 5;
        }
        // 4. 互动总量 × 2,上限 20
        int count = item.getInteractionCount() == null ? 0 : item.getInteractionCount();
        score += Math.min(count * 2, 20);
        return Math.min(score, 100);
    }
  • 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java
    git commit -m "feat(pearl): ResourceService 25 组聚合/互动/提醒/价值分"
    

任务 5:PearlController 新增接口

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java

  • [ ] 步骤 1:更新 addItem 签名 + 新增 5 个接口

将现有 addItem 方法参数顺序调整并新增接口:

    /** 登记珍珠图资源(connectionType 必填,type 兼容保留) */
    @PostMapping("/item/add")
    public Result<ResourceItem> addItem(@RequestBody Map<String, Object> params,
                                        @RequestAttribute("userId") Long userId) {
        String type = (String) params.get("type");
        String connectionType = (String) params.get("connectionType");
        String name = (String) params.get("name");
        String description = (String) params.get("description");
        Long contactId = params.get("contactId") != null
                ? Long.valueOf(params.get("contactId").toString()) : null;
        return resourceService.addResourceItem(userId, type, connectionType, name, description, contactId);
    }

    /** 获取 25 类连接类型列表 */
    @PostMapping("/connection-types")
    public Result<List<Map<String, Object>>> connectionTypes() {
        return Result.success(resourceService.getConnectionTypes());
    }

    /** 记录互动 */
    @PostMapping("/interaction/add")
    public Result<Void> addInteraction(@RequestBody Map<String, Object> params,
                                       @RequestAttribute("userId") Long userId) {
        Long itemId = params.get("itemId") != null
                ? Long.valueOf(params.get("itemId").toString()) : null;
        String interactionType = (String) params.get("interactionType");
        String content = (String) params.get("content");
        return resourceService.addInteraction(userId, itemId, interactionType, content);
    }

    /** 互动历史 */
    @PostMapping("/interaction/list")
    public Result<List<Map<String, Object>>> interactionList(@RequestBody Map<String, Object> params,
                                                             @RequestAttribute("userId") Long userId) {
        Long itemId = params.get("itemId") != null
                ? Long.valueOf(params.get("itemId").toString()) : null;
        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 5;
        return resourceService.getInteractionList(userId, itemId, page, size);
    }

    /** 待办提醒列表 */
    @PostMapping("/reminder/list")
    public Result<List<Map<String, Object>>> reminders(@RequestAttribute("userId") Long userId) {
        return resourceService.getReminders(userId);
    }

    /** 手动覆盖优先级 */
    @PostMapping("/item/update-priority")
    public Result<Void> updatePriority(@RequestBody Map<String, Object> params,
                                       @RequestAttribute("userId") Long userId) {
        Long itemId = params.get("itemId") != null
                ? Long.valueOf(params.get("itemId").toString()) : null;
        Integer priority = params.get("priority") != null
                ? Integer.valueOf(params.get("priority").toString()) : null;
        return resourceService.updatePriority(userId, itemId, priority);
    }

检查:getConnectionTypes() 需要是 public。若 connectionTypes() 与 getConnectionTypes() 命名有歧义(Spring Bean 无冲突,方法名不同即可)。同时确认 import java.util.List 已存在(控制器顶部已有)。

  • 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java
    git commit -m "feat(pearl): PearlController 新增连接类型/互动/提醒/优先级接口"
    

任务 6:定时提醒任务

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/task/PearlReminderScheduledTask.java

  • [ ] 步骤 1:创建定时任务类

    package com.etotem.cfc.task;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.PearlInteractionReminder;
    import com.etotem.cfc.entity.ResourceItem;
    import com.etotem.cfc.enums.ConnectionType;
    import com.etotem.cfc.mapper.PearlInteractionReminderMapper;
    import com.etotem.cfc.mapper.ResourceItemMapper;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.Resource;
    import java.time.Instant;
    import java.time.temporal.ChronoUnit;
    import java.util.Date;
    import java.util.List;
    
    @Slf4j
    @Component
    public class PearlReminderScheduledTask {
    
    @Resource
    private ResourceItemMapper resourceItemMapper;
    
    @Resource
    private PearlInteractionReminderMapper reminderMapper;
    
    /** 每日 08:00 生成珍珠图互动提醒 */
    @Scheduled(cron = "0 0 8 * * ?")
    public void generateReminders() {
        log.info("===== PearlReminderScheduledTask 开始执行 =====");
        try {
            // 遍历所有带 connection_type 的资源
            List<ResourceItem> items = resourceItemMapper.selectList(
                    new LambdaQueryWrapper<ResourceItem>()
                            .isNotNull(ResourceItem::getConnectionType)
                            .ne(ResourceItem::getConnectionType, "")
            );
            int created = 0;
            for (ResourceItem item : items) {
                if (!ConnectionType.isValid(item.getConnectionType())) continue;
                ConnectionType ct = null;
                for (ConnectionType t : ConnectionType.values()) {
                    if (t.name().equals(item.getConnectionType())) { ct = t; break; }
                }
                if (ct == null) continue;
    
                int intervalDays = ct.getSuggestIntervalMonth() * 30;
                long daysSinceLast;
                if (item.getLastInteractionAt() == null) {
                    // 从未互动:按创建时间起算
                    Date base = item.getCreatedAt() != null ? item.getCreatedAt() : new Date();
                    daysSinceLast = ChronoUnit.DAYS.between(base.toInstant(), Instant.now());
                } else {
                    daysSinceLast = ChronoUnit.DAYS.between(item.getLastInteractionAt().toInstant(), Instant.now());
                }
                if (daysSinceLast < intervalDays) continue; // 未到期
    
                // 检查是否已有 PENDING 提醒
                Long pendingCount = reminderMapper.selectCount(
                        new LambdaQueryWrapper<PearlInteractionReminder>()
                                .eq(PearlInteractionReminder::getUserId, item.getUserId())
                                .eq(PearlInteractionReminder::getResourceItemId, item.getId())
                                .eq(PearlInteractionReminder::getStatus, "PENDING")
                );
                if (pendingCount != null && pendingCount > 0) continue;
    
                PearlInteractionReminder r = new PearlInteractionReminder();
                r.setUserId(item.getUserId());
                r.setResourceItemId(item.getId());
                r.setConnectionType(item.getConnectionType());
                r.setSuggestIntervalMonth(ct.getSuggestIntervalMonth());
                r.setLastInteractionAt(item.getLastInteractionAt());
                r.setDaysSinceLast((int) Math.max(0, daysSinceLast));
                r.setStatus("PENDING");
                r.setCreatedAt(new Date());
                r.setUpdatedAt(new Date());
                reminderMapper.insert(r);
                created++;
            }
            log.info("珍珠图提醒生成完成,新增 {} 条", created);
        } catch (Exception e) {
            log.error("珍珠图提醒生成失败", e);
        }
        log.info("===== PearlReminderScheduledTask 执行完成 =====");
    }
    }
    
  • [ ] 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS。

  • [ ] 步骤 3:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/task/PearlReminderScheduledTask.java
    git commit -m "feat(pearl): 每日定时生成互动提醒任务"
    

任务 7:前端 API 方法 + 路由注册

文件:

  • 修改:cfc-frontend/utils/api.js(getPearlResources 附近,约 2277 行)
  • 修改:cfc-frontend/pages.json(pearl-add-resource 路由附近,约 1246 行)

  • [ ] 步骤 1:api.js 新增 5 个方法

在 deletePearlResource 之后追加:

export const getConnectionTypes = () => request('/api/pearl/connection-types', 'POST')
export const addPearlInteraction = (data) => request('/api/pearl/interaction/add', 'POST', data)
export const getPearlInteractionList = (data) => request('/api/pearl/interaction/list', 'POST', data)
export const getPearlReminders = () => request('/api/pearl/reminder/list', 'POST')
export const updatePearlPriority = (data) => request('/api/pearl/item/update-priority', 'POST', data)
  • 步骤 2:pages.json 注册提醒页路由

阅读 pages.json 中 pearl-add-resource 路由的格式(约 1246 行),在其后追加:

{
  "path": "pearl-reminders/index",
  "style": {
    "navigationBarTitleText": "互动提醒"
  }
}

注意:确认现有 pages 数组的根路径约定——pearl-add-resource 位于 pages/action-detail/ 下(root: pages/action-detail)。新增页面 pages/pearl-reminders/index.vue 需要在 pages.json 的主 pages 数组(顶层 "pages")中注册,路径为 "pages/pearl-reminders/index",并检查是否已有同名 root 子包。若项目页面全部平铺在顶层 pages 数组,则直接追加该路径项。

  • 步骤 3:检查 pages.json 结构

阅读 pages.json 前 30 行确认顶层结构(pages 数组 vs 分包 subPackages),确保步骤 2 的插入位置正确。若存在 subPackages 结构且 action-detail 是分包,pearl-reminders 应放在同一分包或顶层 pages,遵循现有约定。

  • [ ] 步骤 4:Commit

    git add cfc-frontend/utils/api.js cfc-frontend/pages.json
    git commit -m "feat(pearl): 前端 API 方法与提醒页路由"
    

任务 8:PearlDiagram.vue 两层同心圆重构

文件:

  • 修改:cfc-frontend/components/PearlDiagram.vue

⚠️ 小程序限制:禁止可选链 ?.、禁止 CSS Grid、禁止 :key 表达式、禁止直接 new Date(string)。本组件全部为 Canvas 绘制 + 现有弹窗结构,遵守 Vue 2 Options API。

  • 步骤 1:重写 groupConfigs 为 25 组两层配置

替换 data() 中的 groupConfigs(原 4 组):

      // 分组配置:25 类社会连接,内圈必备 12 组 / 外圈理想 13 组
      // 颜色盘:内圈偏暖(红橙),外圈偏冷(蓝紫绿)
      groupConfigs: [
        // ===== 内圈:必备 12 组(radiusPct 0.28)=====
        { type: 'MEDICAL', typeName: '医疗', color: '#EF4444', radiusPct: 0.28, ring: 'inner' },
        { type: 'EDUCATION', typeName: '教育', color: '#F97316', radiusPct: 0.28, ring: 'inner' },
        { type: 'PUBLIC_SECURITY', typeName: '公安', color: '#DC2626', radiusPct: 0.28, ring: 'inner' },
        { type: 'AUTO', typeName: '汽车相关', color: '#EA580C', radiusPct: 0.28, ring: 'inner' },
        { type: 'REAL_ESTATE', typeName: '房产服务', color: '#D97706', radiusPct: 0.28, ring: 'inner' },
        { type: 'TICKETING', typeName: '票务', color: '#CA8A04', radiusPct: 0.28, ring: 'inner' },
        { type: 'HANDYMAN', typeName: '多能工人', color: '#B45309', radiusPct: 0.28, ring: 'inner' },
        { type: 'APPLIANCE', typeName: '家电', color: '#92400E', radiusPct: 0.28, ring: 'inner' },
        { type: 'CATERING', typeName: '餐饮', color: '#E11D48', radiusPct: 0.28, ring: 'inner' },
        { type: 'FOOD', typeName: '食品', color: '#BE123C', radiusPct: 0.28, ring: 'inner' },
        { type: 'LEGAL', typeName: '法律', color: '#B91C1C', radiusPct: 0.28, ring: 'inner' },
        { type: 'WEEKEND_EXPERT', typeName: '周末达人', color: '#FB7185', radiusPct: 0.28, ring: 'inner' },
        // ===== 外圈:理想 13 组(radiusPct 0.55)=====
        { type: 'GOVERNMENT', typeName: '政府综合', color: '#8B5CF6', radiusPct: 0.55, ring: 'outer' },
        { type: 'TAX', typeName: '税务', color: '#7C3AED', radiusPct: 0.55, ring: 'outer' },
        { type: 'BUSINESS_ADMIN', typeName: '工商', color: '#6D28D9', radiusPct: 0.55, ring: 'outer' },
        { type: 'BANK', typeName: '银行', color: '#2563EB', radiusPct: 0.55, ring: 'outer' },
        { type: 'MEDIA', typeName: '媒体', color: '#1D4ED8', radiusPct: 0.55, ring: 'outer' },
        { type: 'TRAVEL', typeName: '旅游', color: '#0EA5E9', radiusPct: 0.55, ring: 'outer' },
        { type: 'LEADING_ENTERPRISE', typeName: '领军企业', color: '#0891B2', radiusPct: 0.55, ring: 'outer' },
        { type: 'INDUSTRY_BENCHMARK', typeName: '行业标杆', color: '#059669', radiusPct: 0.55, ring: 'outer' },
        { type: 'OVERSEAS', typeName: '国外', color: '#10B981', radiusPct: 0.55, ring: 'outer' },
        { type: 'UNIVERSITY', typeName: '高校', color: '#16A34A', radiusPct: 0.55, ring: 'outer' },
        { type: 'FINANCE', typeName: '金融', color: '#84CC16', radiusPct: 0.55, ring: 'outer' },
        { type: 'SENIOR_LOCAL', typeName: '资深土著', color: '#65A30D', radiusPct: 0.55, ring: 'outer' },
        { type: 'KEY_CITY', typeName: '北上广等', color: '#4D7C0F', radiusPct: 0.55, ring: 'outer' }
      ],
  • 步骤 2:重写 buildLayout 为环形均分布局

替换 buildLayout 方法体(25 组平均分角度,内圈 12 / 外圈 13 各占一半圆周,按 ring 分组计算):

    buildLayout: function() {
      this.pearlNodes = []
      if (!this.resources || !this.resources.groups || !Array.isArray(this.resources.groups)) {
        return
      }

      var cx = this.canvasWidth / 2
      var cy = this.canvasHeight / 2
      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
      var self = this

      // 按 ring 分组计算角度:内圈从 -PI/2(顶) 顺时针一圈,外圈同理错开 1/2 组距
      var innerConfigs = []
      var outerConfigs = []
      for (var c = 0; c < this.groupConfigs.length; c++) {
        var cfg = this.groupConfigs[c]
        if (cfg.ring === 'inner') innerConfigs.push(cfg)
        else outerConfigs.push(cfg)
      }

      function placeRing(ringConfigs, radiusPct) {
        var R = minDim * radiusPct
        var n = ringConfigs.length
        var totalSpan = Math.PI * 2
        var gap = totalSpan * 0.015 // 组间间隙
        var used = 0
        for (var i = 0; i < n; i++) {
          var config = ringConfigs[i]
          // 在 resources.groups 中查找该类型
          var group = null
          for (var g = 0; g < self.resources.groups.length; g++) {
            if (self.resources.groups[g].type === config.type) {
              group = self.resources.groups[g]
              break
            }
          }
          if (!group || !group.items || group.items.length === 0) {
            used += (totalSpan / n)
            continue
          }

          // 本组角度区间
          var groupSpan = totalSpan / n - gap
          var startAngle = -Math.PI / 2 + used + gap / 2
          var items = group.items

          for (var k = 0; k < items.length; k++) {
            var item = items[k]
            var t = items.length === 1 ? 0.5 : k / (items.length - 1)
            var angle = startAngle + t * groupSpan

            // 珍珠半径:按 priority / valueScore 映射(高=大)
            var priority = item.priority || 2
            var multiplier = priority === 1 ? 1.5 : priority === 3 ? 0.7 : 1.0
            var baseR = minDim * radiusPct * 0.09
            var itemRadius = Math.max(8, Math.min(26, baseR * multiplier))

            self.pearlNodes.push({
              id: item.id || item.contactId || null,
              contactId: item.contactId || null,
              groupType: config.type,
              groupTypeName: config.typeName,
              name: item.name || '未知',
              displayName: item.name ? item.name.charAt(0) : '?',
              avatar: item.avatar || null,
              description: item.description || '',
              skillCount: item.skillCount || 0,
              skills: item.skills || [],
              relationshipType: item.relationshipType || '',
              priority: priority,
              valueScore: item.valueScore || 0,
              daysSinceLast: item.daysSinceLast || null,
              radius: itemRadius,
              color: config.color,
              ring: config.ring,
              x: cx + R * Math.cos(angle),
              y: cy + R * Math.sin(angle)
            })
          }
          used += (totalSpan / n)
        }
      }

      placeRing(innerConfigs, 0.28)
      placeRing(outerConfigs, 0.55)
    },

⚠️ 原布局逻辑:内圈/外圈每组各占半圈会导致 12+13 组分布不均。上述实现让内圈 12 组均分整个圆周、外圈 13 组均分整个圆周——两层叠加,靠半径与颜色区分。若视觉重叠严重(内外圈珍珠在同一角度),可在 placeRing 给外层加 + (Math.PI / n / 2) 偏移错开。此偏移已通过 startAngle = -PI/2 + used + gap/2 天然错开(内外圈组数不同,used 步长不同)。

  • 步骤 3:重写绘制逻辑(drawAll 支持双层 + 逾期角标)

替换 drawAll 方法(增加逾期角标绘制)与 _drawPearlNode 的描边逻辑:

    drawAll: function() {
      if (!this.ctx) return
      var ctx = this.ctx
      var w = this.canvasWidth
      var h = this.canvasHeight

      ctx.clearRect(0, 0, w, h)

      // 1. 绘制分组弧线背景(半透明色带,双层)
      this._drawGroupArcs(ctx)

      // 2. 绘制中心节点"我"
      this._drawCenterNode(ctx)

      // 3. 绘制珍珠节点(内圈先画,外圈后画)
      for (var i = 0; i < this.pearlNodes.length; i++) {
        this._drawPearlNode(ctx, this.pearlNodes[i])
      }

      // 4. 绘制分组标签
      this._drawGroupLabels(ctx)

      // 5. 逾期角标(红色圆点+天数,仅 daysSinceLast 超过建议周期的资源)
      this._drawOverdueBadges(ctx)
    },

    /** 逾期角标:红色小圆点 + 天数(仅当 PENDING 提醒语义成立时,由父组件传入 overdueMap) */
    _drawOverdueBadges: function(ctx) {
      if (!this.overdueMap) return
      var self = this
      var overdueIds = Object.keys(this.overdueMap)
      for (var i = 0; i < this.pearlNodes.length; i++) {
        var node = this.pearlNodes[i]
        if (!node.id) continue
        var days = this.overdueMap['' + node.id]
        if (days === undefined || days === null) continue
        var bx = node.x + node.radius - 4
        var by = node.y - node.radius + 4
        // 红底白字小徽标
        ctx.beginPath()
        ctx.arc(bx, by, 9, 0, Math.PI * 2)
        ctx.fillStyle = '#EF4444'
        ctx.fill()
        ctx.fillStyle = '#FFFFFF'
        ctx.font = 'bold 10px sans-serif'
        ctx.textAlign = 'center'
        ctx.textBaseline = 'middle'
        ctx.fillText(String(days), bx, by + 1)
      }
    },
  • 步骤 4:更新 _drawPearlNode 高价值描边 + 空状态文案

在 _drawPearlNode 中,画完珍珠主体后追加高优先级白色描边:

      // 高优先级(priority=1)白色粗描边
      if (node.priority === 1) {
        ctx.beginPath()
        ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2)
        ctx.lineWidth = 2
        ctx.strokeStyle = '#FFFFFF'
        ctx.stroke()
      }

同时更新模板中空状态文案(pearl-graph-empty 部分):

    <text class="empty-hint">登记你的社会连接,画出珍珠项链图</text>
  • 步骤 5:添加 overdueMap prop

在 props 中添加:

    /** 逾期提醒映射 { resourceItemId: daysSinceLast },父组件从 /reminder/list 构建 */
    overdueMap: {
      type: Object,
      default: null
    }
  • 步骤 6:更新数据源解析(父组件传 25 组 + legacyGroups)

在 action-detail/index.vue 中,加载资源后构建 overdueMap 并传入组件。修改该页面的资源加载逻辑:

      // 现有 getPearlResources 调用之后,新增:
      var remindersRes = await getPearlReminders()
      var overdueMap = {}
      if (remindersRes.code === 200 && remindersRes.data) {
        for (var i = 0; i < remindersRes.data.length; i++) {
          var r = remindersRes.data[i]
          overdueMap['' + r.resourceItemId] = r.daysSinceLast
        }
      }
      this.overdueMap = overdueMap

若 action-detail/index.vue 未引入 getPearlReminders,需在 import 语句中添加。同时确认 overdueMap 作为 prop 绑定到 <PearlDiagram :overdue-map="overdueMap" ...>。

  • 步骤 7:检查弹窗交互(联系按钮 + 优先级调整)

阅读 PearlDiagram.vue 中弹窗部分(selectedNode 相关),在 popup-actions 中追加「联系」和「优先级」按钮(样式沿用现有 .action-btn):

        <!-- 操作按钮 -->
        <view class="popup-actions">
          <text class="action-btn action-btn-contact" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onContact">联系</text>
          <text class="action-btn action-btn-priority" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onAdjustPriority">优先级</text>
          <text class="action-btn action-btn-delete" v-if="canDelete" @tap="onDelete">删除</text>
        </view>

在 methods 中新增:

    /** 联系:弹出方式选择 */
    onContact: function() {
      var self = this
      var node = this.selectedNode
      if (!node || !node.id) return
      uni.showActionSheet({
        itemList: ['电话', '微信', '见面', '其他'],
        success: function(res) {
          var types = ['PHONE', 'WECHAT', 'MEETING', 'OTHER']
          uni.showModal({
            title: '记录互动',
            editable: true,
            placeholderText: '简单记录这次互动(选填)',
            success: function(mr) {
              if (mr.confirm) {
                var content = mr.content || ''
                addPearlInteraction({
                  itemId: node.id,
                  interactionType: types[res.tapIndex],
                  content: content
                }).then(function(res2) {
                  if (res2.code === 200) {
                    uni.showToast({ title: '已记录', icon: 'success' })
                    self.$emit('interaction-updated')
                  } else {
                    uni.showToast({ title: res2.message || '记录失败', icon: 'none' })
                  }
                })
              }
            }
          })
        }
      })
    },

    /** 调整优先级 */
    onAdjustPriority: function() {
      var self = this
      var node = this.selectedNode
      if (!node || !node.id) return
      uni.showActionSheet({
        itemList: ['高(大珍珠)', '中(默认)', '低(小珍珠)'],
        success: function(res) {
          updatePearlPriority({
            itemId: node.id,
            priority: res.tapIndex + 1
          }).then(function(res2) {
            if (res2.code === 200) {
              uni.showToast({ title: '已调整', icon: 'success' })
              self.$emit('interaction-updated')
            } else {
              uni.showToast({ title: res2.message || '调整失败', icon: 'none' })
            }
          })
        }
      })
    },

⚠️ uni.showModal 的 editable 参数在部分微信基础库版本可用,若目标基础库 2.25.0 不支持,改用自定义输入弹窗(或先只记录方式不记录内容)。实现时检查现有代码是否有可复用的输入弹窗组件。

  • [ ] 步骤 8:在组件顶部引入新 API

    import { addPearlInteraction, updatePearlPriority } from '../utils/api.js'
    

注意:PearlDiagram.vue 位于 components/ 下,import 路径需与现有文件(如 utils/api.js)的相对路径约定一致——确认现有组件如何 import api.js。

  • 步骤 9:编译验证(HBuilderX 或 CLI)

在 cfc-frontend 目录运行(若配置了 CLI):npm run dev:mp-weixin 或使用微信开发者工具导入,检查无编译错误。

  • [ ] 步骤 10:Commit

    git add cfc-frontend/components/PearlDiagram.vue cfc-frontend/pages/action-detail/index.vue
    git commit -m "feat(pearl): 珍珠图两层同心圆布局 + 价值分级 + 互动/优先级交互"
    

任务 9:pearl-add-resource.vue 25 类类型选择器

文件:

  • 修改:cfc-frontend/pages/action-detail/pearl-add-resource.vue

  • [ ] 步骤 1:改为 25 类分组选择器

替换模板中的 resourceTypes 网格为两段式选择(必备/理想 Tab + 类型网格):

    <!-- 选择连接类型 -->
    <view class="form-section">
      <text class="section-label">连接类型 <text class="required-mark">*</text></text>
      <view class="cat-tabs">
        <view class="cat-tab" :class="{active: selectedCategory === 'ESSENTIAL'}" @click="selectedCategory = 'ESSENTIAL'">必备</view>
        <view class="cat-tab" :class="{active: selectedCategory === 'IDEAL'}" @click="selectedCategory = 'IDEAL'">理想</view>
      </view>
      <view class="type-grid">
        <view class="type-item" v-for="(t, idx) in filteredTypes" :key="idx"
              :class="{selected: selectedType === t.code}"
              @click="selectedType = t.code">
          <text class="type-name">{{ t.label }}</text>
        </view>
      </view>
    </view>
  • [ ] 步骤 2:更新 script 数据与方法

    import { getContactList, getConnectionTypes, addPearlResource } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      contacts: [],
      selectedContactId: null,
      selectedCategory: 'ESSENTIAL',
      connectionTypes: [],
      selectedType: '',
      name: '',
      description: ''
    }
    },
    computed: {
    canSubmit() {
      return this.selectedType && this.name.trim()
    },
    filteredTypes() {
      var self = this
      return this.connectionTypes.filter(function(t) {
        return t.category === self.selectedCategory
      })
    }
    },
    onLoad(options) {
    this.loadConnectionTypes()
    this.loadContacts()
    },
    methods: {
    async loadConnectionTypes() {
      try {
        var res = await getConnectionTypes()
        if (res.code === 200 && res.data) {
          this.connectionTypes = res.data
        }
      } catch (e) {
        uni.showToast({ title: '连接类型加载失败', icon: 'none' })
      }
    },
    // ... 原有 loadContacts / selectContact 保留
    async onSubmit() {
      if (!this.canSubmit) {
        uni.showToast({ title: '请选择类型并填写名称', icon: 'none' })
        return
      }
      try {
        var data = {
          connectionType: this.selectedType,
          name: this.name.trim(),
          description: this.description.trim()
        }
        if (this.selectedContactId) {
          data.contactId = this.selectedContactId
        }
        var res = await addPearlResource(data)
        if (res.code === 200) {
          uni.showToast({ title: '保存成功', icon: 'success' })
          setTimeout(function() {
            uni.navigateBack()
          }, 1500)
        } else {
          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
        }
      } catch (e) {
        uni.showToast({ title: '保存失败', icon: 'none' })
      }
    }
    }
    }
    
  • [ ] 步骤 3:补充 cat-tabs 样式

在 <style scoped> 中添加:

.cat-tabs {
  display: flex;
  gap: 16rpx;
  margin-bottom: 20rpx;
}
.cat-tab {
  padding: 12rpx 32rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 999rpx;
  font-size: 26rpx;
  color: #666;
  background: #fafafa;
}
.cat-tab.active {
  border-color: #F97316;
  color: #F97316;
  background: #FFF7ED;
}
  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages/action-detail/pearl-add-resource.vue
    git commit -m "feat(pearl): 登记资源页 25 类连接类型分组选择器"
    

任务 10:提醒列表页

文件:

  • 创建:cfc-frontend/pages/pearl-reminders/index.vue

  • [ ] 步骤 1:创建提醒列表页

    <template>
    <view class="container">
    <view class="page-header">
      <text class="page-title">互动提醒</text>
      <text class="page-sub" v-if="reminders.length > 0">{{ reminders.length }} 个连接该联系了</text>
    </view>
    
    <view class="reminder-list" v-if="reminders.length > 0">
      <view class="reminder-item" v-for="(r, idx) in reminders" :key="idx">
        <view class="reminder-info">
          <text class="reminder-name">{{ r.name }}</text>
          <text class="reminder-tag">{{ r.connectionType }}</text>
          <text class="reminder-days">已 {{ r.daysSinceLast }} 天未互动(建议每 {{ r.suggestIntervalMonth }} 个月)</text>
        </view>
        <view class="reminder-actions">
          <text class="btn-contact" @tap="onContact(r)">去联系</text>
          <text class="btn-later" @tap="onLater(r)">稍后</text>
        </view>
      </view>
    </view>
    
    <view class="empty" v-else>
      <text class="empty-icon">🎉</text>
      <text class="empty-text">所有连接都在维护中</text>
    </view>
    </view>
    </template>
    
    <script>
    import { getPearlReminders, addPearlInteraction } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      reminders: []
    }
    },
    onShow() {
    this.loadReminders()
    },
    methods: {
    async loadReminders() {
      try {
        var res = await getPearlReminders()
        if (res.code === 200 && res.data) {
          this.reminders = res.data
        }
      } catch (e) {
        uni.showToast({ title: '加载失败', icon: 'none' })
      }
    },
    onContact(r) {
      var self = this
      uni.showActionSheet({
        itemList: ['电话', '微信', '见面', '其他'],
        success: function(res) {
          var types = ['PHONE', 'WECHAT', 'MEETING', 'OTHER']
          addPearlInteraction({
            itemId: r.resourceItemId,
            interactionType: types[res.tapIndex],
            content: ''
          }).then(function(res2) {
            if (res2.code === 200) {
              uni.showToast({ title: '已记录', icon: 'success' })
              self.loadReminders()
            } else {
              uni.showToast({ title: res2.message || '记录失败', icon: 'none' })
            }
          })
        }
      })
    },
    onLater(r) {
      // 简化:暂不支持 DISMISS,直接刷新隐藏(PENDING 保留)
      this.loadReminders()
    }
    }
    }
    </script>
    
    <style scoped>
    .container {
    min-height: 100vh;
    background: #f5f5f5;
    padding: 24rpx;
    }
    .page-header {
    padding: 20rpx 0 30rpx;
    }
    .page-title {
    font-size: 36rpx;
    font-weight: bold;
    color: #333;
    }
    .page-sub {
    font-size: 26rpx;
    color: #999;
    margin-top: 8rpx;
    display: block;
    }
    .reminder-list {
    display: flex;
    flex-direction: column;
    gap: 20rpx;
    }
    .reminder-item {
    background: #fff;
    border-radius: 16rpx;
    padding: 24rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
    }
    .reminder-info {
    flex: 1;
    }
    .reminder-name {
    font-size: 30rpx;
    font-weight: 600;
    color: #333;
    display: block;
    }
    .reminder-tag {
    font-size: 22rpx;
    color: #F97316;
    background: #FFF7ED;
    border-radius: 6rpx;
    padding: 4rpx 12rpx;
    margin-top: 8rpx;
    display: inline-block;
    }
    .reminder-days {
    font-size: 24rpx;
    color: #999;
    display: block;
    margin-top: 8rpx;
    }
    .reminder-actions {
    display: flex;
    gap: 16rpx;
    }
    .btn-contact {
    background: #F97316;
    color: #fff;
    border-radius: 999rpx;
    padding: 12rpx 28rpx;
    font-size: 26rpx;
    }
    .btn-later {
    border: 2rpx solid #e0e0e0;
    color: #666;
    border-radius: 999rpx;
    padding: 12rpx 28rpx;
    font-size: 26rpx;
    }
    .empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: 200rpx;
    }
    .empty-icon {
    font-size: 80rpx;
    }
    .empty-text {
    font-size: 28rpx;
    color: #999;
    margin-top: 20rpx;
    }
    </style>
    
  • [ ] 步骤 2:检查 import 路径

确认 '../../utils/api.js' 相对路径从 pages/pearl-reminders/index.vue 到 utils/api.js 正确(与 pages/action-detail/ 下页面一致,均为 ../../utils/api.js)。

  • [ ] 步骤 3:Commit

    git add cfc-frontend/pages/pearl-reminders/index.vue
    git commit -m "feat(pearl): 互动提醒列表页"
    

任务 11:API 文档同步

文件:

  • 修改:docs/superpowers/api/API_REFERENCE.md(§4.39,约 1093 行)

  • [ ] 步骤 1:更新 §4.39 接口清单

在现有珍珠图接口表(约 1103-1105 行)追加:

| `POST /api/pearl/connection-types` | 获取 25 类社会连接类型列表(code/label/category/suggestIntervalMonth) |
| `POST /api/pearl/interaction/add` | 记录互动(itemId/interactionType/content),更新资源价值分并关闭 PENDING 提醒 |
| `POST /api/pearl/interaction/list` | 互动历史(itemId/page/size) |
| `POST /api/pearl/reminder/list` | 待办提醒列表(PENDING) |
| `POST /api/pearl/item/update-priority` | 手动覆盖优先级(itemId/priority 1-3) |

并更新 /api/pearl/resources 响应结构说明:新增 groups(25 组社会连接)+ legacyGroups(历史 PERSON/SKILL/INFO/PLACE)。

  • 步骤 2:更新 item/add 参数说明

将 item/add 行参数说明更新为:connectionType 必填(25 类枚举),name 必填,contactId 可选。

  • [ ] 步骤 3:Commit

    git add docs/superpowers/api/API_REFERENCE.md
    git commit -m "docs(pearl): API_REFERENCE 同步新增接口"
    

任务 12:端到端验证

文件:

  • 验证:cfc-backend 编译 + cfc-frontend 编译

  • [ ] 步骤 1:后端完整编译

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS,无 error/warning 新增。

  • 步骤 2:前端编译检查

在 cfc-frontend 下运行(若 CLI 可用):

npm run build:mp-weixin

或使用微信开发者工具导入项目,确认无编译报错(重点:可选链/Grid/:key 表达式等小程序限制未违反)。

  • 步骤 3:代码复查

运行路由重复检查(确认无新路由冲突):

grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u

确认 /api/pearl/* 无重复路由。

  • [ ] 步骤 4:Commit(若修复了问题)

    git add -A
    git commit -m "fix(pearl): 端到端验证修复"
    

自检记录

规格覆盖度:

  • 25 类 ConnectionType(必备 12 + 理想 13)→ 任务 1(迁移)、任务 2(枚举)、任务 8(前端配置)✓
  • 两层同心圆布局(内圈必备/外圈理想)→ 任务 8 步骤 1-2 ✓
  • 珍珠大小按价值分级 → 任务 8 步骤 2(priorityMultiplier)✓
  • 系统自动价值分 + 手动覆盖 → 任务 4 步骤 4(calculateValueScore/updatePriority)✓
  • 互动日志 + 定期提醒 → 任务 4 步骤 4、任务 6(定时任务)、任务 10(提醒页)✓
  • 25 类固定不允许自定义 → 枚举固定,addResourceItem 校验 ConnectionType.isValid ✓
  • 旧数据保留为历史资源 → 任务 4 步骤 2(legacyGroups)✓
  • 数据库迁移 + schema.sql 同步 → 任务 1 ✓
  • API 文档同步 → 任务 11 ✓

占位符扫描: 无"待定/TODO/补充细节"等占位符;所有代码块完整。

类型一致性:

  • addResourceItem(userId, type, connectionType, name, description, contactId) 签名在任务 4 定义、任务 5 调用——一致 ✓
  • calculateValueScore(ResourceItem, List<PearlInteractionLog>) 定义与调用签名一致 ✓
  • overdueMap prop 在任务 8 步骤 5 定义、步骤 6 绑定、步骤 3 使用——一致 ✓
  • 前端 addPearlInteraction/updatePearlPriority/getPearlReminders/getConnectionTypes 在任务 7 定义、任务 8/9/10 使用——一致 ✓