2026-06-14-action-page-relationship.md 21 KB

行远页 · 重要关系维护 — 实施计划

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 映射文件

Task 1: 后端 Contact 实体 + Mapper

目的: 创建数据库表和 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


Task 2: 后端 ContactService + ContactController

目的: 实现 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 + 启动测试接口


Task 3: 前端 API 封装

目的: 在 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 语法无异常


Task 4: ContactCard 组件

目的: 关系卡片组件,用于行远页横向滚动列表

创建: 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>

Task 5: ContactImport 浮层组件

目的: 通讯录导入后的补充信息填写浮层

创建: cfc-frontend/components/ContactImport.vue

交互流程:

  1. 用户调用 wx.chooseContact(需在点击事件中触发)
  2. 获取 name + phone
  3. 组件弹窗显示输入表单:关系类型(picker)、认识时间(picker)、生日(picker)、个人介绍(input)
  4. 点击确认 → 调用 importPhoneContact({ name, phone, extra: ... })
  5. 成功 → 关闭浮层 → 触发父组件刷新列表

    <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>
    

Task 6: 改造行远页 — 插入关系区块

目的: 在行远页「今日待办」和「热门活动」之间插入「重要关系」区块

修改: 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()
}

Task 7: 关系详情/编辑页

目的: 查看联系人完整信息 + 编辑 + 标记互动

创建: cfc-frontend/pages/action/contact-detail.vue

布局:

┌─ 返回 ── 关系详情 ───── [编辑] ───┐
│                                    │
│  [头像]  张三                       │
│          朋友 · 认识5年             │
│          ❤️ 亲密度 85               │
│                                    │
├─ 基本信息 ─────────────────────────┤
│  📞 138xxxx1234                    │
│  🎂 06月15日                       │
│  📝 个人介绍/备注                  │
│  🏷️ 标签                           │
│  💬 备注                           │
│                                    │
├─ 互动记录 ─────────────────────────┤
│  2026-06-10  查看了资料             │
│  2026-05-20  更新了生日             │
│  [📞 标记联系]                      │
│                                    │
├─ 操作 ─────────────────────────────┤
│  [删除联系人]                       │
└────────────────────────────────────┘

编辑模式: 点击[编辑] → 字段变为可编辑 → 保存调用 updateContact


Task 8: 页面注册 + 配置

修改: 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。