For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
Goal: 在行远(行动)TabBar 页新增「重要关系」维护功能,支持通讯录导入、关系管理、亲密度追踪
Design Spec:
docs/superpowers/specs/2026-06-14-action-page-relationship-design.md
| 文件 | 类型 | 说明 |
|---|---|---|
cfc-backend/.../entity/Contact.java |
entity | 通讯录关系实体 |
cfc-backend/.../dto/ContactDTO.java |
dto | 关系传输对象 |
cfc-backend/.../mapper/ContactMapper.java |
mapper | MyBatis Mapper |
cfc-backend/.../service/ContactService.java |
service | 关系 CRUD + 导入逻辑 |
cfc-backend/.../controller/ContactController.java |
controller | 关系 API |
cfc-frontend/components/ContactCard.vue |
component | 关系卡片组件 |
cfc-frontend/components/ContactImport.vue |
component | 通讯录导入浮层 |
cfc-frontend/pages/action/contact-detail.vue |
page | 关系详情/编辑页 |
| 文件 | 说明 |
|---|---|
cfc-frontend/pages/action/index.vue |
插入「重要关系」区块(登录后可见) |
cfc-frontend/pages.json |
注册 contact-detail 页面路径 |
cfc-frontend/utils/api.js |
新增 contact API 封装 |
cfc-backend/.../resources/mapper/ContactMapper.xml |
SQL 映射文件 |
目的: 创建数据库表和 ORM 映射
Step 1: 创建 Contact.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("contacts")
public class Contact implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String name;
private String phone;
private String avatar;
private String relationshipType; // family/friend/partner/colleague/other
private Date knownSince;
private String bio;
private Date birthday;
private Integer intimacyLevel;
private String contactSource; // phone/manual
private String tags;
private Date lastContactAt;
private Integer contactCount;
private String notes;
private Date createdAt;
private Date updatedAt;
}
Step 2: 创建 ContactMapper.java
@Mapper
public interface ContactMapper extends BaseMapper<Contact> {}
Step 3: 创建 ContactMapper.xml
MyBatis XML 映射文件,标准 CRUD。
Step 4: 创建 ContactDTO.java
@Data
public class ContactDTO {
private Long id;
private Long userId;
private String name;
private String phone;
private String avatar;
private String relationshipType;
private Date knownSince;
private String knownSinceLabel; // "认识5年"
private String bio;
private Date birthday;
private String birthdayLabel; // "06月15日"
private String birthdayCountdown; // "还有3天" / null
private Integer intimacyLevel;
private String contactSource;
private String tags;
private Date lastContactAt;
private Integer contactCount;
private String notes;
private Date createdAt;
private Date updatedAt;
}
验证: mvn clean compile
目的: 实现 CRUD 和通讯录导入逻辑
Step 1: ContactService.java
@Service
public class ContactService {
@Resource
private ContactMapper contactMapper;
// 获取当前用户的所有联系人
public Result<List<ContactDTO>> list(Long userId) {
LambdaQueryWrapper<Contact> query = new LambdaQueryWrapper<Contact>()
.eq(Contact::getUserId, userId)
.orderByDesc(Contact::getIntimacyLevel);
List<Contact> list = contactMapper.selectList(query);
return Result.success(toDTOList(list));
}
// 创建联系人
public Result<ContactDTO> create(Contact contact, Long userId) {
contact.setUserId(userId);
contact.setContactSource("manual");
contact.setIntimacyLevel(calcIntimacy(contact));
contact.setContactCount(0);
contact.setCreatedAt(new Date());
contact.setUpdatedAt(new Date());
contactMapper.insert(contact);
return Result.success(toDTO(contact));
}
// 从通讯录导入
public Result<ContactDTO> importFromPhone(String name, String phone, Map<String, Object> extra, Long userId) {
// 查重
LambdaQueryWrapper<Contact> query = new LambdaQueryWrapper<Contact>()
.eq(Contact::getUserId, userId)
.eq(Contact::getPhone, phone);
Contact existing = contactMapper.selectOne(query);
if (existing != null) {
return Result.error("该联系人已在您的关系列表中");
}
Contact contact = new Contact();
contact.setUserId(userId);
contact.setName(name);
contact.setPhone(phone);
contact.setContactSource("phone");
if (extra != null) {
if (extra.get("relationshipType") != null)
contact.setRelationshipType((String) extra.get("relationshipType"));
if (extra.get("knownSince") != null)
contact.setKnownSince((Date) extra.get("knownSince"));
if (extra.get("birthday") != null)
contact.setBirthday((Date) extra.get("birthday"));
if (extra.get("bio") != null)
contact.setBio((String) extra.get("bio"));
}
contact.setIntimacyLevel(30); // 初始值
contact.setContactCount(0);
contact.setCreatedAt(new Date());
contact.setUpdatedAt(new Date());
contactMapper.insert(contact);
return Result.success(toDTO(contact));
}
// 标记互动
public Result<ContactDTO> recordInteraction(Long id, Long userId) {
Contact contact = contactMapper.selectById(id);
if (contact == null || !contact.getUserId().equals(userId)) {
return Result.error("联系人不存在");
}
contact.setContactCount(contact.getContactCount() == null ? 1 : contact.getContactCount() + 1);
contact.setLastContactAt(new Date());
contact.setIntimacyLevel(calcIntimacy(contact));
contact.setUpdatedAt(new Date());
contactMapper.updateById(contact);
return Result.success(toDTO(contact));
}
// 亲密度计算
private int calcIntimacy(Contact c) {
int base = Math.min((c.getContactCount() == null ? 0 : c.getContactCount()) * 5, 60);
int active = 0;
if (c.getLastContactAt() != null) {
long days = (System.currentTimeMillis() - c.getLastContactAt().getTime()) / 86400000L;
if (days <= 7) active = 20;
else if (days <= 30) active = 10;
else if (days <= 90) active = 5;
else if (days > 365) active = -10;
}
return Math.max(0, Math.min(100, base + active));
}
}
Step 2: ContactController.java
@RestController
@RequestMapping("/api/contact")
public class ContactController {
@Resource
private ContactService contactService;
@PostMapping("/list")
public Result<List<ContactDTO>> list(@RequestAttribute("userId") Long userId) {
return contactService.list(userId);
}
@PostMapping("/create")
public Result<ContactDTO> create(@RequestBody Contact contact,
@RequestAttribute("userId") Long userId) {
return contactService.create(contact, userId);
}
@PostMapping("/update")
public Result<ContactDTO> update(@RequestBody Contact contact,
@RequestAttribute("userId") Long userId) {
return contactService.update(contact, userId);
}
@PostMapping("/delete")
public Result<String> delete(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
Long id = Long.valueOf(params.get("id").toString());
return contactService.delete(id, userId);
}
@PostMapping("/detail")
public Result<ContactDTO> detail(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
Long id = Long.valueOf(params.get("id").toString());
return contactService.detail(id, userId);
}
@PostMapping("/import-phone")
public Result<ContactDTO> importPhone(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
String name = (String) params.get("name");
String phone = (String) params.get("phone");
Map<String, Object> extra = (Map<String, Object>) params.get("extra");
return contactService.importFromPhone(name, phone, extra, userId);
}
@PostMapping("/interaction")
public Result<ContactDTO> interaction(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
Long id = Long.valueOf(params.get("id").toString());
return contactService.recordInteraction(id, userId);
}
}
验证: mvn clean compile + 启动测试接口
目的: 在 api.js 中新增 contact API
修改: cfc-frontend/utils/api.js
// ===== 通讯录关系(行远页) =====
export const getContactList = () => request('/api/contact/list', 'POST')
export const createContact = (data) => request('/api/contact/create', 'POST', data)
export const updateContact = (data) => request('/api/contact/update', 'POST', data)
export const deleteContact = (data) => request('/api/contact/delete', 'POST', data)
export const getContactDetail = (id) => request('/api/contact/detail', 'POST', { id })
export const importPhoneContact = (data) => request('/api/contact/import-phone', 'POST', data)
export const recordContactInteraction = (id) => request('/api/contact/interaction', 'POST', { id })
验证: 确保 api.js 语法无异常
目的: 关系卡片组件,用于行远页横向滚动列表
创建: cfc-frontend/components/ContactCard.vue
<template>
<view class="contact-card" @click="$emit('click', contact)">
<view class="card-avatar" :style="{ background: avatarColor }">
<text class="avatar-text">{{ initial }}</text>
</view>
<text class="card-name">{{ contact.name }}</text>
<view class="card-type-tag" :style="{ background: typeColor + '20', color: typeColor }">
{{ typeLabel }}
</view>
<text class="card-known" v-if="contact.knownSince">
认识 {{ knownYears }} 年
</text>
<text class="card-phone" v-if="contact.phone">{{ contact.phone }}</text>
<view class="card-intimacy">
<text class="intimacy-heart">❤️</text>
<text class="intimacy-value">{{ contact.intimacyLevel }}</text>
</view>
<text class="card-birthday" v-if="contact.birthdayCountdown">
🎂 {{ contact.birthdayLabel }} · {{ contact.birthdayCountdown }}
</text>
</view>
</template>
<script>
export default {
props: {
contact: { type: Object, required: true }
},
computed: {
initial: function() {
return this.contact.name ? this.contact.name[0] : '?'
},
typeLabel: function() {
var map = { family: '家人', friend: '朋友', partner: '伴侣', colleague: '同事', other: '其他' }
return map[this.contact.relationshipType] || '其他'
},
typeColor: function() {
var map = { family: '#10B981', friend: '#F59E0B', partner: '#FF6B35', colleague: '#3B82F6', other: '#94A3B8' }
return map[this.contact.relationshipType] || '#94A3B8'
},
avatarColor: function() {
var colors = ['#FF6B35', '#667eea', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899']
var idx = (this.contact.id || 0) % colors.length
return colors[idx]
},
knownYears: function() {
if (!this.contact.knownSince) return 0
var now = new Date()
var known = new Date(this.contact.knownSince)
return Math.floor((now - known) / (365.25 * 86400000))
}
}
}
</script>
<style scoped>
.contact-card {
width: 240rpx;
flex-shrink: 0;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-right: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
display: flex;
flex-direction: column;
align-items: center;
}
.contact-card:active { opacity: 0.7; }
.card-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.avatar-text {
font-size: 32rpx;
font-weight: bold;
color: #fff;
}
.card-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.card-type-tag {
font-size: 20rpx;
padding: 2rpx 14rpx;
border-radius: 10rpx;
margin-bottom: 8rpx;
}
.card-known { font-size: 22rpx; color: #999; margin-bottom: 4rpx; }
.card-phone { font-size: 22rpx; color: #bbb; margin-bottom: 8rpx; }
.card-intimacy {
display: flex;
align-items: center;
margin-bottom: 6rpx;
}
.intimacy-heart { font-size: 22rpx; margin-right: 4rpx; }
.intimacy-value {
font-size: 28rpx;
font-weight: bold;
color: #FF6B35;
}
.card-birthday {
font-size: 20rpx;
color: #EC4899;
}
</style>
目的: 通讯录导入后的补充信息填写浮层
创建: cfc-frontend/components/ContactImport.vue
交互流程:
wx.chooseContact(需在点击事件中触发)importPhoneContact({ name, phone, extra: ... })成功 → 关闭浮层 → 触发父组件刷新列表
<template>
<view class="overlay" v-if="visible" @click.self="onCancel">
<view class="modal">
<text class="modal-title">添加联系人</text>
<view class="form">
<view class="form-row">
<text class="form-label">姓名</text>
<input class="form-input" v-model="form.name" disabled />
</view>
<view class="form-row">
<text class="form-label">手机号</text>
<input class="form-input" v-model="form.phone" disabled />
</view>
<view class="form-row">
<text class="form-label">关系类型</text>
<picker mode="selector" :range="typeOptions" @change="onTypeChange">
<view class="form-picker">{{ form.relationshipTypeLabel || '请选择' }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">认识时间</text>
<picker mode="date" @change="onDateChange">
<view class="form-picker">{{ form.knownSince || '请选择' }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">生日</text>
<picker mode="date" @change="onBirthdayChange">
<view class="form-picker">{{ form.birthday || '请选择' }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">个人介绍</text>
<input class="form-input" v-model="form.bio" placeholder="选填" />
</view>
</view>
<view class="modal-actions">
<button class="btn-cancel" @click="onCancel">取消</button>
<button class="btn-confirm" @click="onConfirm">确认添加</button>
</view>
</view>
</view>
</template>
目的: 在行远页「今日待办」和「热门活动」之间插入「重要关系」区块
修改: cfc-frontend/pages/action/index.vue
模板修改(登录后区块间插入):
+ <!-- ===== 重要关系 ===== -->
+ <view class="section" v-if="isLoggedIn">
+ <view class="section-header">
+ <text class="section-title">🔥 重要关系</text>
+ <view class="section-actions">
+ <text class="section-hint" @click="showImportTip">?</text>
+ <text class="section-action" @click="importContact">📱 导入通讯录</text>
+ </view>
+ </view>
+ <!-- 有联系人 -->
+ <scroll-view v-if="contacts.length > 0" scroll-x class="contact-scroll" show-scrollbar="false">
+ <view class="contact-row">
+ <ContactCard v-for="c in contacts" :key="c.id" :contact="c" @click="goContactDetail(c.id)" />
+ </view>
+ </scroll-view>
+ <!-- 空状态 -->
+ <view v-else class="contact-empty">
+ <text class="empty-icon">📇</text>
+ <text class="empty-text">还没有记录重要关系</text>
+ <view class="empty-actions">
+ <button class="btn-import" @click="importContact">📱 导入通讯录</button>
+ <button class="btn-manual" @click="manualAdd">➕ 手动添加</button>
+ </view>
+ </view>
+ <view class="section-footer" v-if="contacts.length > 0">
+ <text class="footer-link" @click="goContactList">查看全部 {{ contacts.length }} 人 →</text>
+ </view>
+ </view>
Script 新增:
// data 新增:
contacts: [],
// methods 新增:
loadContacts: function() {
var self = this
getContactList().then(function(res) {
if (res.code === 200 && res.data) {
self.contacts = res.data.slice(0, 10)
}
}).catch(function() { self.contacts = [] })
},
importContact: function() {
var self = this
// 微信小程序: 选择联系人
uni.chooseContact({
success: function(res) {
self.pendingImport = { name: res.name, phone: res.phone }
self.showImportModal = true
},
fail: function() {
// 手动输入 fallback
uni.showModal({
title: '导入失败',
content: '可选择手动添加',
success: function(r) { if (r.confirm) self.manualAdd() }
})
}
})
},
manualAdd: function() {
uni.navigateTo({ url: '/pages/action/contact-detail?mode=create' })
},
goContactDetail: function(id) {
uni.navigateTo({ url: '/pages/action/contact-detail?id=' + id })
}
onShow 新增加载:
if (this.isLoggedIn) {
this.loadContacts()
}
目的: 查看联系人完整信息 + 编辑 + 标记互动
创建: cfc-frontend/pages/action/contact-detail.vue
布局:
┌─ 返回 ── 关系详情 ───── [编辑] ───┐
│ │
│ [头像] 张三 │
│ 朋友 · 认识5年 │
│ ❤️ 亲密度 85 │
│ │
├─ 基本信息 ─────────────────────────┤
│ 📞 138xxxx1234 │
│ 🎂 06月15日 │
│ 📝 个人介绍/备注 │
│ 🏷️ 标签 │
│ 💬 备注 │
│ │
├─ 互动记录 ─────────────────────────┤
│ 2026-06-10 查看了资料 │
│ 2026-05-20 更新了生日 │
│ [📞 标记联系] │
│ │
├─ 操作 ─────────────────────────────┤
│ [删除联系人] │
└────────────────────────────────────┘
编辑模式: 点击[编辑] → 字段变为可编辑 → 保存调用 updateContact
修改: cfc-frontend/pages.json
{
"path": "pages/action/contact-detail",
"style": {
"navigationBarTitleText": "关系详情"
}
}
Task 1 (Contact实体+Mapper) → Task 2 (Service+Controller)
↓
Task 3 (前端API封装)
↓
┌─────────────────────┼─────────────────────┐
↓ ↓ ↓
Task 4 (ContactCard) Task 5 (ContactImport) Task 7 (详情页)
↓ ↓ ↓
└─────────────────────┼─────────────────────┘
↓
Task 6 (行远页改造)
↓
Task 8 (pages.json注册)
推荐并行: Task 1 + Task 4 + Task 5 可同时执行(前后端无依赖)。 Task 2 依赖 Task 1。Task 3 依赖 Task 2。Task 6 依赖 Task 3 + Task 4 + Task 5。