# 珍珠图社会连接盘点系统 实现计划 > **面向 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 块之后、方法结束之前,追加: ```java // 迁移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 表)** ```java // 迁移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 表)** ```java // 迁移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 行)中同步新字段: ```sql -- 珍珠图资源登记表(社会连接资源) 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 表之后)追加两张新表的定义: ```sql -- 珍珠图互动日志表 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** ```bash 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 枚举** ```java 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 实体** ```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("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 实体** ```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("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** ```java 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 { } ``` ```java 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 { } ``` - [ ] **步骤 5:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS。 - [ ] **步骤 6:Commit** ```bash 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` 之前添加: ```java /** 连接类型枚举名(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** ```bash 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` 顶部添加注入和导入: ```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` 注入之后添加: ```java @Resource private PearlInteractionLogMapper interactionLogMapper; @Resource private PearlInteractionReminderMapper reminderMapper; /** 25 类连接类型 → 前端选择器/渲染配置 */ public List> getConnectionTypes() { List> list = new ArrayList<>(); for (ConnectionType t : ConnectionType.values()) { Map 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 查询逻辑,输出结构改造): ```java /** 珍珠图聚合:25 类社会连接 + 历史资源(legacy PERSON/SKILL/INFO/PLACE) */ public Result> getPearlResources(Long userId) { // 历史资源:legacy 分组的 contacts(人脉)与 skillLogs(技能)照旧 List contacts = contactMapper.selectList( new LambdaQueryWrapper().eq(Contact::getUserId, userId) ); List skillLogs = helpLogMapper.selectList( new LambdaQueryWrapper() .eq(ContactHelpLog::getUserId, userId) .eq(ContactHelpLog::getHelpType, "SKILL") .orderByDesc(ContactHelpLog::getHappenedAt) ); List resourceItems = resourceItemMapper.selectList( new LambdaQueryWrapper() .eq(ResourceItem::getUserId, userId) .orderByDesc(ResourceItem::getCreatedAt) ); // 25 组社会连接:按 connection_type 分组 Map>> connGroupMap = new LinkedHashMap<>(); Map 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> legacyInfo = new ArrayList<>(); List> legacyPlace = new ArrayList<>(); List itemIds = resourceItems.stream().map(ResourceItem::getId).collect(Collectors.toList()); // 批量取互动日志用于计算 daysSinceLast Map> logMap = new HashMap<>(); if (!itemIds.isEmpty()) { interactionLogMapper.selectList( new LambdaQueryWrapper() .in(PearlInteractionLog::getResourceItemId, itemIds) .orderByDesc(PearlInteractionLog::getHappenedAt) ).forEach(l -> logMap.computeIfAbsent(l.getResourceItemId(), k -> new ArrayList<>()).add(l)); } for (ResourceItem item : resourceItems) { Map 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> groups = new ArrayList<>(); for (ConnectionType t : ConnectionType.values()) { Map 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> legacyGroups = new ArrayList<>(); legacyGroups.add(group("PERSON", "人脉", contacts.stream().map(c -> { Map 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 result = new LinkedHashMap<>(); result.put("groups", groups); result.put("legacyGroups", legacyGroups); return Result.success(result); } ``` > 注意:`logMap` 在本方法中暂用于后续扩展,若编译器报未使用可删除该变量声明(保留查询有成本,建议删除:`logMap` 在此任务中不消费,删除以免死代码。详见步骤 2 说明)。 - [ ] **步骤 3:addResourceItem 支持 connection_type** 修改 `addResourceItem` 方法签名与校验: ```java /** 登记资源(珍珠图:25 类社会连接,connectionType 必填) */ public Result 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` 方法之后追加: ```java /** 记录一次互动并更新资源统计 */ public Result 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 logs = interactionLogMapper.selectList( new LambdaQueryWrapper() .eq(PearlInteractionLog::getResourceItemId, itemId) .orderByDesc(PearlInteractionLog::getHappenedAt) ); item.setValueScore(calculateValueScore(item, logs)); resourceItemMapper.updateById(item); // 关闭该资源的 PENDING 提醒 reminderMapper.update(null, new LambdaQueryWrapper() .eq(PearlInteractionReminder::getUserId, userId) .eq(PearlInteractionReminder::getResourceItemId, itemId) .eq(PearlInteractionReminder::getStatus, "PENDING") .set(PearlInteractionReminder::getStatus, "DONE") ); return Result.success(null); } /** 互动历史(按时间倒序) */ public Result>> 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 logs = interactionLogMapper.selectList( new LambdaQueryWrapper() .eq(PearlInteractionLog::getResourceItemId, itemId) .orderByDesc(PearlInteractionLog::getHappenedAt) .last("LIMIT " + ((page - 1) * size) + "," + size) ); List> list = logs.stream().map(l -> { Map 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 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>> getReminders(Long userId) { List reminders = reminderMapper.selectList( new LambdaQueryWrapper() .eq(PearlInteractionReminder::getUserId, userId) .eq(PearlInteractionReminder::getStatus, "PENDING") .orderByDesc(PearlInteractionReminder::getDaysSinceLast) ); Map itemMap = new HashMap<>(); List ids = reminders.stream().map(PearlInteractionReminder::getResourceItemId).collect(Collectors.toList()); if (!ids.isEmpty()) { resourceItemMapper.selectBatchIds(ids).forEach(i -> itemMap.put(i.getId(), i)); } List> list = reminders.stream().map(r -> { Map 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 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** ```bash 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` 方法参数顺序调整并新增接口: ```java /** 登记珍珠图资源(connectionType 必填,type 兼容保留) */ @PostMapping("/item/add") public Result addItem(@RequestBody Map 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>> connectionTypes() { return Result.success(resourceService.getConnectionTypes()); } /** 记录互动 */ @PostMapping("/interaction/add") public Result addInteraction(@RequestBody Map 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>> interactionList(@RequestBody Map 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>> reminders(@RequestAttribute("userId") Long userId) { return resourceService.getReminders(userId); } /** 手动覆盖优先级 */ @PostMapping("/item/update-priority") public Result updatePriority(@RequestBody Map 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** ```bash 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:创建定时任务类** ```java 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 items = resourceItemMapper.selectList( new LambdaQueryWrapper() .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() .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** ```bash 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` 之后追加: ```javascript 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 行),在其后追加: ```json { "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** ```bash 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 组): ```javascript // 分组配置: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` 分组计算): ```javascript 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` 的描边逻辑: ```javascript 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` 中,画完珍珠主体后追加高优先级白色描边: ```javascript // 高优先级(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` 部分): ```html 登记你的社会连接,画出珍珠项链图 ``` - [ ] **步骤 5:添加 overdueMap prop** 在 `props` 中添加: ```javascript /** 逾期提醒映射 { resourceItemId: daysSinceLast },父组件从 /reminder/list 构建 */ overdueMap: { type: Object, default: null } ``` - [ ] **步骤 6:更新数据源解析(父组件传 25 组 + legacyGroups)** 在 `action-detail/index.vue` 中,加载资源后构建 `overdueMap` 并传入组件。修改该页面的资源加载逻辑: ```javascript // 现有 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 绑定到 ``。 - [ ] **步骤 7:检查弹窗交互(联系按钮 + 优先级调整)** 阅读 `PearlDiagram.vue` 中弹窗部分(`selectedNode` 相关),在 `popup-actions` 中追加「联系」和「优先级」按钮(样式沿用现有 `.action-btn`): ```html 联系 优先级 删除 ``` 在 `methods` 中新增: ```javascript /** 联系:弹出方式选择 */ 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** ```javascript 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** ```bash 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 + 类型网格): ```html 必备 理想 {{ t.label }} ``` - [ ] **步骤 2:更新 script 数据与方法** ```javascript 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 样式** 在 ` ``` - [ ] **步骤 2:检查 import 路径** 确认 `'../../utils/api.js'` 相对路径从 `pages/pearl-reminders/index.vue` 到 `utils/api.js` 正确(与 `pages/action-detail/` 下页面一致,均为 `../../utils/api.js`)。 - [ ] **步骤 3:Commit** ```bash 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 行)追加: ```markdown | `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** ```bash 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 可用): ```bash npm run build:mp-weixin ``` 或使用微信开发者工具导入项目,确认无编译报错(重点:可选链/Grid/:key 表达式等小程序限制未违反)。 - [ ] **步骤 3:代码复查** 运行路由重复检查(确认无新路由冲突): ```bash grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u ``` 确认 `/api/pearl/*` 无重复路由。 - [ ] **步骤 4:Commit(若修复了问题)** ```bash 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)` 定义与调用签名一致 ✓ - `overdueMap` prop 在任务 8 步骤 5 定义、步骤 6 绑定、步骤 3 使用——一致 ✓ - 前端 `addPearlInteraction`/`updatePearlPriority`/`getPearlReminders`/`getConnectionTypes` 在任务 7 定义、任务 8/9/10 使用——一致 ✓