|
|
@@ -0,0 +1,410 @@
|
|
|
+# 家庭成员关系卡片 — 修复方案
|
|
|
+
|
|
|
+## 总体目标
|
|
|
+
|
|
|
+让 `FamilyMemberCard` 从"简单的成员选择器"升级为"包含关系标签、交互操作、丰富信息的家庭关系卡片"。
|
|
|
+
|
|
|
+## 方案概览
|
|
|
+
|
|
|
+| 层 | 改动量 | 核心变更 |
|
|
|
+|----|--------|---------|
|
|
|
+| 后端 DTO | 小 | FamilyMemberDTO 增加 relationship 字段 |
|
|
|
+| 后端 Service | 中 | FamilyMembersController.list() 增加关系计算逻辑 |
|
|
|
+| 前端组件 | 中 | FamilyMemberCard 增加关系标签/操作按钮/交互 |
|
|
|
+| 前端容器 | 小 | FamilyMemberStrip 增加长按菜单支持 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 第一步:后端 — FamilyMemberDTO 增加关系字段
|
|
|
+
|
|
|
+### 修改 FamilyMemberDTO
|
|
|
+
|
|
|
+```java
|
|
|
+// DTO 中增加以下字段(从 family_relationships 表计算)
|
|
|
+private String relationship; // 与当前用户的关系: 爸爸/妈妈/儿子/女儿/配偶
|
|
|
+private Integer generation; // 辈分: 1/2/3
|
|
|
+private Boolean isSpouse; // 是否配偶
|
|
|
+private String relativeLabel; // 关系标签显示名(如"爸爸 · 二代")
|
|
|
+```
|
|
|
+
|
|
|
+### 修改 FamilyMembersController.list()
|
|
|
+
|
|
|
+```java
|
|
|
+@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);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 关系计算逻辑
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 计算当前用户与目标成员的关系
|
|
|
+ * 依赖 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
|
|
|
+
|
|
|
+```java
|
|
|
+@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 表
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 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
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private FamilyRelationshipMapper familyRelationshipMapper;
|
|
|
+```
|
|
|
+
|
|
|
+### 迁移109:family_members 表补充关系索引
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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 增强
|
|
|
+
|
|
|
+### 改造后的模板
|
|
|
+
|
|
|
+```vue
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+### 改造后的脚本
|
|
|
+
|
|
|
+```javascript
|
|
|
+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 })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 改造后的样式(关键部分)
|
|
|
+
|
|
|
+```css
|
|
|
+.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 增加长按菜单
|
|
|
+
|
|
|
+```vue
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+```javascript
|
|
|
+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. **权限控制**:移除成员操作需要校验当前用户角色(仅家长可操作)
|