Просмотр исходного кода

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

E2E Test Bot 3 месяцев назад
Родитель
Сommit
07211a8636
1 измененных файлов с 410 добавлено и 0 удалено
  1. 410 0
      docs/analysis/family-member-card-fix-plan.md

+ 410 - 0
docs/analysis/family-member-card-fix-plan.md

@@ -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. **权限控制**:移除成员操作需要校验当前用户角色(仅家长可操作)