让 FamilyMemberCard 从"简单的成员选择器"升级为"包含关系标签、交互操作、丰富信息的家庭关系卡片"。
| 层 | 改动量 | 核心变更 |
|---|---|---|
| 后端 DTO | 小 | FamilyMemberDTO 增加 relationship 字段 |
| 后端 Service | 中 | FamilyMembersController.list() 增加关系计算逻辑 |
| 前端组件 | 中 | FamilyMemberCard 增加关系标签/操作按钮/交互 |
| 前端容器 | 小 | FamilyMemberStrip 增加长按菜单支持 |
// DTO 中增加以下字段(从 family_relationships 表计算)
private String relationship; // 与当前用户的关系: 爸爸/妈妈/儿子/女儿/配偶
private Integer generation; // 辈分: 1/2/3
private Boolean isSpouse; // 是否配偶
private String relativeLabel; // 关系标签显示名(如"爸爸 · 二代")
@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;
}
@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 表结构(已存在)
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
);
@Resource
private FamilyRelationshipMapper familyRelationshipMapper;
// 迁移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) {
// 索引已存在,忽略
}
<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; }
<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 |
family_relationships 表中没有数据,Fallback 逻辑使用 generation 推断uni.makePhoneCall 需要用户授权,uni-popup 需要 uni-ui 组件库