面向 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 同步新增接口 | 修改 |
文件:
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 块)。
在迁移 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='珍珠图互动提醒';
运行: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 社会连接字段与互动/提醒表"
文件:
cfc-backend/src/main/java/com/etotem/cfc/enums/ConnectionType.javacfc-backend/src/main/java/com/etotem/cfc/entity/PearlInteractionLog.javacfc-backend/src/main/java/com/etotem/cfc/entity/PearlInteractionReminder.javacfc-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"
文件:
修改: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;
运行: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 实体新增社会连接字段"
文件:
修改: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;
}
替换现有 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 说明)。
修改 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);
}
在 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);
}
运行: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 组聚合/互动/提醒/价值分"
文件:
修改: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 无冲突,方法名不同即可)。同时确认 importjava.util.List已存在(控制器顶部已有)。
运行: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 新增连接类型/互动/提醒/优先级接口"
文件:
创建: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): 每日定时生成互动提醒任务"
文件:
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)
阅读 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 数组,则直接追加该路径项。
阅读 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 方法与提醒页路由"
文件:
cfc-frontend/components/PearlDiagram.vue⚠️ 小程序限制:禁止可选链
?.、禁止 CSS Grid、禁止:key表达式、禁止直接new Date(string)。本组件全部为 Canvas 绘制 + 现有弹窗结构,遵守 Vue 2 Options API。
替换 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' }
],
替换 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步长不同)。
替换 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)
}
},
_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>
在 props 中添加:
/** 逾期提醒映射 { resourceItemId: daysSinceLast },父组件从 /reminder/list 构建 */
overdueMap: {
type: Object,
default: null
}
在 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" ...>。
阅读 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。
在 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): 珍珠图两层同心圆布局 + 价值分级 + 互动/优先级交互"
文件:
修改: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 类连接类型分组选择器"
文件:
创建: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): 互动提醒列表页"
文件:
修改: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)。
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 同步新增接口"
文件:
验证:cfc-backend 编译 + cfc-frontend 编译
[ ] 步骤 1:后端完整编译
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS,无 error/warning 新增。
在 cfc-frontend 下运行(若 CLI 可用):
npm run build:mp-weixin
或使用微信开发者工具导入项目,确认无编译报错(重点:可选链/Grid/:key 表达式等小程序限制未违反)。
运行路由重复检查(确认无新路由冲突):
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): 端到端验证修复"
规格覆盖度:
占位符扫描: 无"待定/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 使用——一致 ✓