family-member-card-fix-plan.md 12 KB

家庭成员关系卡片 — 修复方案

总体目标

FamilyMemberCard 从"简单的成员选择器"升级为"包含关系标签、交互操作、丰富信息的家庭关系卡片"。

方案概览

改动量 核心变更
后端 DTO FamilyMemberDTO 增加 relationship 字段
后端 Service FamilyMembersController.list() 增加关系计算逻辑
前端组件 FamilyMemberCard 增加关系标签/操作按钮/交互
前端容器 FamilyMemberStrip 增加长按菜单支持

第一步:后端 — FamilyMemberDTO 增加关系字段

修改 FamilyMemberDTO

// DTO 中增加以下字段(从 family_relationships 表计算)
private String relationship;     // 与当前用户的关系: 爸爸/妈妈/儿子/女儿/配偶
private Integer generation;      // 辈分: 1/2/3
private Boolean isSpouse;        // 是否配偶
private String relativeLabel;    // 关系标签显示名(如"爸爸 · 二代")

修改 FamilyMembersController.list()

@PostMapping("/list")
public Result<List<FamilyMemberVO>> list(@RequestAttribute("userId") Long userId) {
    User user = userMapper.selectById(userId);
    if (user == null || user.getFamilyId() == null) {
        return Result.error("用户未加入家庭");
    }
    List<FamilyMember> members = familyMemberMapper.selectList(
        new LambdaQueryWrapper<FamilyMember>()
            .eq(FamilyMember::getFamilyId, user.getFamilyId())
    );
    // 为每个成员计算关系标签
    List<FamilyMemberVO> result = members.stream().map(m -> {
        FamilyMemberVO vo = FamilyMemberVO.from(m);
        // 计算关系
        String relation = calculateRelation(userId, m.getUserId(), m.getGeneration(), m.getIsSpouse());
        vo.setRelationship(relation);
        vo.setRelativeLabel(buildRelativeLabel(relation, m.getGeneration()));
        return vo;
    }).collect(Collectors.toList());
    return Result.success(result);
}

关系计算逻辑

/**
 * 计算当前用户与目标成员的关系
 * 依赖 family_relationships 表中的双向关系数据
 */
private String calculateRelation(Long currentUserId, Long memberUserId,
                                  Integer generation, Boolean isSpouse) {
    if (isSpouse != null && isSpouse) return "配偶";

    // 查询 family_relationships 表获取关系
    FamilyRelationship rel = relationshipMapper.selectOne(
        new LambdaQueryWrapper<FamilyRelationship>()
            .eq(FamilyRelationship::getMemberId, currentUserId)
            .eq(FamilyRelationship::getRelatedMemberId, memberUserId)
    );
    if (rel != null && rel.getRelationshipType() != null) {
        return rel.getRelationshipType();  // 如 "爸爸"/"妈妈"/"儿子"/"女儿"
    }

    // Fallback: 根据 generation 推断
    if (generation != null) {
        if (generation == 1) return "长辈";
        if (generation == 2) return "同辈";
        if (generation == 3) return "晚辈";
    }
    return "家庭成员";
}

/**
 * 构建关系标签显示文本
 */
private String buildRelativeLabel(String relation, Integer generation) {
    String gen = generation != null ? " · 第" + generation + "代" : "";
    return relation + gen;
}

新建 FamilyMemberVO

@Data
public class FamilyMemberVO {
    private Long id;
    private Long userId;
    private String nickname;
    private String avatar;
    private String phone;
    private String gender;
    private Integer generation;
    private Boolean isSpouse;
    private String relationship;     // 关系: 爸爸/妈妈/儿子/女儿/配偶
    private String relativeLabel;    // 显示标签: "爸爸 · 二代"
    private Integer totalPoints;
    private BigDecimal trustScore;
    private BigDecimal intimacyScore;
    private Date lastInteractionAt;
    private Integer streakDays;

    public static FamilyMemberVO from(FamilyMember m) {
        FamilyMemberVO vo = new FamilyMemberVO();
        BeanUtils.copyProperties(m, vo);
        return vo;
    }
}

第二步:后端 — 关系数据查询

查询 family_relationships 表

-- family_relationships 表结构(已存在)
CREATE TABLE family_relationships (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    family_id BIGINT NOT NULL,
    member_id BIGINT NOT NULL COMMENT '关系主体',
    related_member_id BIGINT NOT NULL COMMENT '关系客体',
    relationship_type VARCHAR(20) NOT NULL COMMENT '关系类型: 爸爸/妈妈/儿子/女儿/丈夫/妻子',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

注入 RelationshipMapper

@Resource
private FamilyRelationshipMapper familyRelationshipMapper;

迁移109:family_members 表补充关系索引

// 迁移109: family_members 表添加关系查询索引
try {
    jdbcTemplate.execute("ALTER TABLE family_members ADD INDEX idx_family_user (family_id, user_id)");
    log.info("已添加family_members关系查询索引");
} catch (Exception e) {
    // 索引已存在,忽略
}

第三步:前端 — FamilyMemberCard 增强

改造后的模板

<template>
  <view class="family-member-card" :class="{ selected: isSelected, [member.effectiveRole || '']: true }"
        @click="onClick" @longpress="onLongPress">
    <!-- 头像区域 -->
    <view class="card-avatar-wrap">
      <image class="card-avatar" :src="member.avatar || defaultAvatar" mode="aspectFill" />
      <!-- 关系标签徽章 -->
      <view class="card-relation-badge" :style="{ background: relationColor }">
        <text class="card-relation-text">{{ member.relationship || member.roleLabel || '成员' }}</text>
      </view>
      <!-- 配偶标记 -->
      <view class="card-spouse-mark" v-if="member.isSpouse">
        <text class="spouse-icon">💍</text>
      </view>
    </view>

    <!-- 昵称 -->
    <text class="card-nickname">{{ member.nickname }}</text>

    <!-- 关系标签 + 辈分 -->
    <view class="card-relation-line">
      <text class="card-relation-label">{{ member.relativeLabel || '' }}</text>
    </view>

    <!-- 联系方式(仅当有电话时显示) -->
    <view class="card-contact-row" v-if="member.phone" @click.stop="onCall">
      <text class="contact-icon">📞</text>
      <text class="contact-phone">{{ member.phone }}</text>
    </view>

    <!-- 能量分数 -->
    <view class="card-energy" v-if="energyScore !== null && energyScore !== undefined">
      <text class="energy-value">{{ energyScore }}</text>
      <text class="energy-unit">分</text>
    </view>

    <!-- 选中标记 -->
    <view class="card-selected-mark" v-if="isSelected">
      <text class="mark-icon">✓</text>
    </view>
  </view>
</template>

改造后的脚本

export default {
  props: {
    member: { type: Object, required: true },
    isSelected: { type: Boolean, default: false },
    energyScore: { type: Number, default: null },
    showActions: { type: Boolean, default: false }  // 是否显示操作按钮
  },
  computed: {
    defaultAvatar: function() { return '/static/default-avatar.png' },
    relationColor: function() {
      var rel = this.member && this.member.relationship
      if (rel === '爸爸' || rel === '妈妈') return '#F97316'  // 父母-橙色
      if (rel === '儿子' || rel === '女儿') return '#0EA5E9'  // 子女-蓝色
      if (rel === '配偶') return '#EC4899'                     // 配偶-粉色
      if (rel === '爷爷' || rel === '奶奶') return '#6B7280'   // 祖辈-灰色
      var role = this.member && this.member.effectiveRole
      if (role === 'parent') return '#F97316'
      if (role === 'child') return '#0EA5E9'
      if (role === 'teacher') return '#8B5CF6'
      return '#9CA3AF'
    }
  },
  methods: {
    onClick: function() {
      this.$emit('select', this.member)
    },
    onLongPress: function() {
      this.$emit('longpress', this.member)
    },
    onCall: function() {
      if (this.member.phone) {
        uni.makePhoneCall({ phoneNumber: this.member.phone })
      }
    }
  }
}

改造后的样式(关键部分)

.card-relation-badge {
  position: absolute;
  bottom: -4rpx;
  left: 50%;
  transform: translateX(-50%);
  padding: 2rpx 10rpx;
  border-radius: 12rpx;
  white-space: nowrap;
  max-width: 100rpx;
}
.card-relation-text {
  font-size: 16rpx;
  color: #FFFFFF;
  font-weight: 500;
}
.card-spouse-mark {
  position: absolute;
  top: -4rpx;
  right: -4rpx;
  width: 28rpx;
  height: 28rpx;
}
.card-relation-line {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 2rpx;
}
.card-relation-label {
  font-size: 18rpx;
  color: #9CA3AF;
}
.card-contact-row {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 4rpx;
}
.contact-icon { font-size: 20rpx; }
.contact-phone { font-size: 18rpx; color: #3B82F6; margin-left: 4rpx; }

第四步:前端 — FamilyMemberStrip 增加长按菜单

<template>
  <view class="family-member-strip">
    <scroll-view scroll-x :show-scrollbar="false" v-if="members.length >= 4">
      <view class="strip-inner scroll-mode">
        <FamilyMemberCard
          v-for="member in members"
          :key="member.id"
          :member="member"
          :isSelected="member.id === selectedMemberId"
          :energyScore="getMemberEnergy(member)"
          @select="onSelect"
          @longpress="onLongPress"
        />
      </view>
    </scroll-view>
    <!-- 长按操作菜单 -->
    <uni-popup ref="actionMenu" type="bottom">
      <view class="action-menu">
        <view class="action-item" @click="switchToMember">
          <text class="action-icon">🔄</text>
          <text>切换到该成员</text>
        </view>
        <view class="action-item" @click="editMember">
          <text class="action-icon">✏️</text>
          <text>编辑资料</text>
        </view>
        <view class="action-item" @click="removeMember" v-if="isParent">
          <text class="action-icon">🗑️</text>
          <text style="color:#EF4444">移除成员</text>
        </view>
        <view class="action-item cancel" @click="closeMenu">取消</view>
      </view>
    </uni-popup>
  </view>
</template>
export default {
  data() {
    return {
      longPressedMember: null,
      isParent: false
    }
  },
  methods: {
    onLongPress: function(member) {
      this.longPressedMember = member
      this.$refs.actionMenu.open()
    },
    switchToMember: function() {
      this.closeMenu()
      if (this.longPressedMember) {
        uni.navigateTo({ url: '/pages/home-pages/child-index?memberId=' + this.longPressedMember.id })
      }
    },
    editMember: function() {
      this.closeMenu()
      if (this.longPressedMember) {
        uni.navigateTo({ url: '/pages/profile-extra/family-members?editId=' + this.longPressedMember.id })
      }
    },
    removeMember: function() {
      this.closeMenu()
      // 调用 /api/family/member/kick
    },
    closeMenu: function() {
      this.$refs.actionMenu.close()
    }
  }
}

第五步:适配现有页面

现有9个使用 FamilyMemberStrip 的页面需要适配:

页面 改动 说明
parent-index 不需要改 直接使用增强后的组件
child-index 不需要改 直接使用增强后的组件
mind/body/wisdom/wealth/action/index 不需要改 直接使用增强后的组件
index-home 不需要改 直接使用增强后的组件

组件增强后,现有页面无需修改即可获得关系标签、联系按钮等新功能。


实施路线图

步骤 内容 涉及文件 估算工时
1 创建 FamilyMemberVO + 修改 FamilyMembersController.list() 后端 2 文件 1h
2 实现关系计算逻辑 后端 1 文件 1h
3 迁移109:添加索引 后端 1 文件 0.5h
4 增强 FamilyMemberCard 组件 前端 1 文件 1.5h
5 增强 FamilyMemberStrip 组件(长按菜单) 前端 1 文件 1h
6 测试验证 前后端 1h
合计 7 文件 ~6h

风险与注意事项

  1. 兼容性:增强后的 FamilyMemberCard 新增 props 都有默认值,现有页面无需修改即可使用
  2. 关系数据:如果 family_relationships 表中没有数据,Fallback 逻辑使用 generation 推断
  3. 小程序限制uni.makePhoneCall 需要用户授权,uni-popup 需要 uni-ui 组件库
  4. 权限控制:移除成员操作需要校验当前用户角色(仅家长可操作)