Browse Source

feat: 家庭成员管理支持家长账号编辑/移除/切换及名称一致性修复

- FamilyMemberService: listMembers/switchToMember/kickMember 支持 source='parent'(users表) 成员
- updateMember 新增 updateParentMember 分支: 家长账号可编辑昵称/性别/生日/头像/手机号
- 新增 resolveMemberNickname: family_members.nickname 为空时回退 users 表真实昵称
- family-members.vue: 名称兜底链与我的家庭页一致, canEditMember 放开 parent 来源
- FamilyMemberStrip.vue: canSwitch/canRemove 放开 source 限制
- interaction-log.vue: .type-item 加 box-sizing 修复四列折行
liaoxg 8 hours ago
parent
commit
17a6dde352

+ 170 - 40
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -250,7 +250,7 @@ public class FamilyMemberService {
             vo.setSource("family_member");
             vo.setUserId(fm.getUserId());
             vo.setIsAdmin(isAdmin);
-            vo.setNickname(fm.getNickname());
+            vo.setNickname(resolveMemberNickname(fm));
             vo.setPhone(fm.getPhone());
             vo.setAvatar(fm.getAvatar());
             vo.setGender(fm.getGender());
@@ -291,19 +291,13 @@ public class FamilyMemberService {
             vo.setIntimacyScore(fm.getIntimacyScore());
             vo.setCommunicationScore(fm.getCommunicationScore());
             vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
+            // 设置 isSwitchable:同家庭非自己成员均可切换视角(含被邀请的能登录成员)
+            vo.setIsSwitchable(!(viewerMember != null && viewerMember.getId().equals(fm.getId())));
             vo.setLastInteractionAt(fm.getLastInteractionAt());
             vo.setStreakDays(fm.getStreakDays());
             // 兼容旧前端字段
             vo.setMemberId(fm.getId());
-            vo.setName(fm.getNickname());
-            // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换
-            if (fm.getUserId() != null) {
-                User memberUser = userMapper.selectById(fm.getUserId());
-                String openid = memberUser != null ? memberUser.getOpenid() : null;
-                vo.setIsSwitchable(openid == null || openid.isEmpty());
-            } else {
-                vo.setIsSwitchable(true);
-            }
+            vo.setName(resolveMemberNickname(fm));
             result.add(vo);
         }
 
@@ -343,6 +337,7 @@ public class FamilyMemberService {
             vo.setGeneration(0);
             vo.setIsAdmin(isAdmin);
             vo.setIsSelf(p.getId().equals(userId));
+            vo.setIsSwitchable(!p.getId().equals(userId)); // 非自己即可切换(含 source='parent' 段)
             vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
             vo.setIsSpouse(false);
             vo.setTotalPoints(p.getTotalPoints());
@@ -364,23 +359,46 @@ public class FamilyMemberService {
         // 从 family_members 查找
         FamilyMember member = familyMemberMapper.selectById(memberId);
         if (member != null && member.getFamilyId().equals(user.getFamilyId())) {
-            // 【重构】成员切换校验:仅不可登录成员可被切换
-            User memberUser = member.getUserId() != null ? userMapper.selectById(member.getUserId()) : null;
-            String openid = memberUser != null ? memberUser.getOpenid() : null;
-            boolean canLogin = openid != null && !openid.isEmpty();
-            if (canLogin) {
-                throw new RuntimeException("该成员可通过微信登录,不能切换为TA的身份");
+            // 不能切换到自己
+            if (member.getUserId() != null && member.getUserId().equals(userId)) {
+                throw new RuntimeException("不能切换到自己");
+            }
+            String effectiveRole = computeEffectiveRole(member);
+            // 直接登录的孩子端不能切换到家长端
+            if ("child".equals(user.getRole()) && "parent".equals(effectiveRole)) {
+                throw new RuntimeException("孩子账号不能切换到家长视角");
             }
 
             SwitchMemberVO vo = new SwitchMemberVO();
             vo.setMemberId(member.getId());
             vo.setSource("family_member");
-            vo.setEffectiveRole(computeEffectiveRole(member));
+            vo.setEffectiveRole(effectiveRole);
             vo.setNickname(member.getNickname());
             vo.setIsSwitchable(true);
             return vo;
         }
 
+        // 兼容 source='parent':无 family_members 记录的 users 表家长成员(memberId 即 userId)
+        Long familyId = user.getFamilyId();
+        User targetParent = userMapper.selectById(memberId);
+        if (targetParent != null && familyId.equals(targetParent.getFamilyId())) {
+            // 不能切换到自己
+            if (targetParent.getId().equals(userId)) {
+                throw new RuntimeException("不能切换到自己");
+            }
+            // 直接登录的孩子端不能切换到家长端
+            if ("child".equals(user.getRole())) {
+                throw new RuntimeException("孩子账号不能切换到家长视角");
+            }
+            SwitchMemberVO vo = new SwitchMemberVO();
+            vo.setMemberId(targetParent.getId());
+            vo.setSource("parent");
+            vo.setEffectiveRole("parent");
+            vo.setNickname(targetParent.getNickname());
+            vo.setIsSwitchable(true);
+            return vo;
+        }
+
         throw new RuntimeException("成员不存在或不属于您的家庭");
     }
 
@@ -408,18 +426,34 @@ public class FamilyMemberService {
             throw new RuntimeException("只有家庭管理员才能踢出成员");
         }
 
-        // 从 family_members 踢出
+        Long familyId = operator.getFamilyId();
+
+        // 从 family_members 查找
         FamilyMember member = familyMemberMapper.selectById(memberId);
-        if (member == null || !member.getFamilyId().equals(operator.getFamilyId())) {
-            throw new RuntimeException("成员不存在");
-        }
-        // 不能踢出家庭创建者
-        if (member.getUserId() != null && Long.valueOf(member.getUserId()).equals(family.getCreatorId())) {
-            throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+        String memberNickname = null;
+        if (member != null && familyId.equals(member.getFamilyId())) {
+            // 不能踢出家庭创建者
+            if (member.getUserId() != null && Long.valueOf(member.getUserId()).equals(family.getCreatorId())) {
+                throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+            }
+            memberNickname = member.getNickname();
+        } else {
+            // 兼容 source='parent':无 family_members 记录的 users 表家长成员(memberId 即 userId)
+            User targetUser = userMapper.selectById(memberId);
+            member = null;
+            if (targetUser != null && familyId.equals(targetUser.getFamilyId())) {
+                // 不能踢出家庭创建者
+                if (targetUser.getId().equals(family.getCreatorId())) {
+                    throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+                }
+                memberNickname = targetUser.getNickname();
+            } else {
+                throw new RuntimeException("成员不存在");
+            }
         }
 
-        Long oldFamilyId = member.getFamilyId();
-        Long targetUserId = member.getUserId();
+        Long oldFamilyId = familyId;
+        Long targetUserId = (member != null) ? member.getUserId() : memberId;
 
         // 收回该家庭发放给该成员的人口券
         try {
@@ -428,8 +462,10 @@ public class FamilyMemberService {
             log.warn("收回人口券异常: familyId={}, memberId={}", oldFamilyId, memberId, e);
         }
 
-        // 清理关系记录
-        familyRelationshipService.deleteRelationsForMember(memberId);
+        // 清理关系记录(source='parent' 的 users 表成员无关系记录,跳过)
+        if (member != null) {
+            familyRelationshipService.deleteRelationsForMember(memberId);
+        }
 
         if (targetUserId != null) {
             User targetUser = userMapper.selectById(targetUserId);
@@ -441,19 +477,23 @@ public class FamilyMemberService {
                     if (ownFamilyId == null) {
                         ownFamilyId = createFamilyForUser(targetUser);
                     }
-                    migrateMemberAssets(member, oldFamilyId, ownFamilyId);
-                    member.setFamilyId(ownFamilyId);
-                    member.setStatus("active");
-                    member.setUpdatedAt(new Date());
-                    familyMemberMapper.updateById(member);
+                    if (member != null) {
+                        migrateMemberAssets(member, oldFamilyId, ownFamilyId);
+                        member.setFamilyId(ownFamilyId);
+                        member.setStatus("active");
+                        member.setUpdatedAt(new Date());
+                        familyMemberMapper.updateById(member);
+                    }
                     targetUser.setFamilyId(ownFamilyId);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
                 } else {
-                    // 非微信用户:进入回收箱,资产保留在原家庭记录上
-                    member.setStatus("recycled");
-                    member.setUpdatedAt(new Date());
-                    familyMemberMapper.updateById(member);
+                    // 非微信用户:解除家庭关联,资产保留在原家庭记录上
+                    if (member != null) {
+                        member.setStatus("recycled");
+                        member.setUpdatedAt(new Date());
+                        familyMemberMapper.updateById(member);
+                    }
                     targetUser.setFamilyId(null);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
@@ -464,7 +504,7 @@ public class FamilyMemberService {
             familyMemberMapper.deleteById(memberId);
         }
 
-        logMemberAction(oldFamilyId, memberId, "kick", member.getNickname(), null, userId);
+        logMemberAction(oldFamilyId, memberId, "kick", memberNickname, null, userId);
 
         // 方案B:成员被踢出后,重新核算原家庭会员身份
         membershipService.reconcileFamilyMembership(oldFamilyId);
@@ -779,9 +819,13 @@ public class FamilyMemberService {
             throw new RuntimeException("用户未加入家庭");
         }
         FamilyMember member = familyMemberMapper.selectById(memberId);
-        if (member == null || !member.getFamilyId().equals(user.getFamilyId())) {
+        if (member != null && !member.getFamilyId().equals(user.getFamilyId())) {
             throw new RuntimeException("成员不存在或不属于您的家庭");
         }
+        // 家长账号(users 表成员)在 family_members 表无记录,走家长更新分支
+        if (member == null) {
+            return updateParentMember(user, memberId, dto);
+        }
 
         // 检查编辑权限:管理员可编辑所有成员;普通用户只能编辑无关联账号的成员
         boolean isAdmin = false;
@@ -829,14 +873,100 @@ public class FamilyMemberService {
         return toFamilyMemberVO(member);
     }
 
+    /**
+     * 更新家长账号(users 表成员)的基本资料。
+     * 家长账号只存在于 users 表(family_id + role=parent),无 family_members 记录,
+     * 因此编辑昵称/性别/生日/头像/手机号直接写 users 表;辈分关系仅 family_members 成员支持。
+     */
+    private FamilyMemberVO updateParentMember(User operator, Long memberId, AddFamilyMemberDTO dto) {
+        Family family = familyMapper.selectById(operator.getFamilyId());
+        boolean isAdmin = family != null && Long.valueOf(operator.getId()).equals(family.getCreatorId());
+        if (!isAdmin) {
+            throw new RuntimeException("仅家庭创建者可编辑该成员");
+        }
+        User parent = userMapper.selectById(memberId);
+        if (parent == null
+                || parent.getFamilyId() == null
+                || !parent.getFamilyId().equals(operator.getFamilyId())
+                || !"parent".equals(parent.getRole())) {
+            throw new RuntimeException("成员不存在或不属于您的家庭");
+        }
+        String oldNickname = parent.getNickname();
+        if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
+            parent.setNickname(dto.getNickname().trim());
+        }
+        if (dto.getPhone() != null) {
+            parent.setPhone(dto.getPhone());
+        }
+        if (dto.getAvatar() != null) {
+            parent.setAvatar(dto.getAvatar());
+        }
+        if (dto.getGender() != null) {
+            parent.setGender(dto.getGender());
+        }
+        if (dto.getBirthday() != null) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                parent.setBirthday(sdf.parse(dto.getBirthday()));
+            } catch (java.text.ParseException e) {
+                throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
+            }
+        }
+        parent.setUpdatedAt(new Date());
+        userMapper.updateById(parent);
+        logMemberAction(parent.getFamilyId(), parent.getId(), "update", oldNickname, parent.getNickname(), operator.getId());
+        // 返回与成员列表一致的 VO
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(parent.getId());
+        vo.setMemberId(parent.getId());
+        vo.setSource("parent");
+        vo.setMemberType("parent");
+        vo.setUserId(parent.getId());
+        vo.setNickname(parent.getNickname());
+        vo.setName(parent.getNickname());
+        vo.setAvatar(parent.getAvatar());
+        vo.setPhone(parent.getPhone());
+        vo.setGender(parent.getGender());
+        vo.setEffectiveRole("parent");
+        vo.setRoleLabel(parent.getFamilyRole() != null ? parent.getFamilyRole() : "家长");
+        if (parent.getBirthday() != null) {
+            vo.setAge(calculateAge(parent.getBirthday()));
+        }
+        vo.setGeneration(0);
+        vo.setIsAdmin(true);
+        vo.setIsSelf(parent.getId().equals(operator.getId()));
+        vo.setIsSwitchable(!parent.getId().equals(operator.getId()));
+        vo.setShowToFamily(parent.getShowToFamily() != null ? parent.getShowToFamily() : 1);
+        vo.setIsSpouse(false);
+        vo.setTotalPoints(parent.getTotalPoints());
+        return vo;
+    }
+
     // ===== 内部方法 =====
 
+    /**
+     * 解析成员显示名:优先 family_members.nickname,为空时回退到关联 users 表的 nickname。
+     * 被邀请/绑定登录账号的成员,family_members.nickname 可能为空或旧值,而 users.nickname 才是真实名字。
+     */
+    private String resolveMemberNickname(FamilyMember fm) {
+        if (fm.getNickname() != null && !fm.getNickname().trim().isEmpty()) {
+            return fm.getNickname();
+        }
+        if (fm.getUserId() != null) {
+            User u = userMapper.selectById(fm.getUserId());
+            if (u != null && u.getNickname() != null && !u.getNickname().trim().isEmpty()) {
+                return u.getNickname();
+            }
+        }
+        return fm.getNickname();
+    }
+
     private FamilyMemberVO toFamilyMemberVO(FamilyMember member) {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(member.getId());
         vo.setSource("family_member");
         vo.setUserId(member.getUserId());
-        vo.setNickname(member.getNickname());
+        vo.setNickname(resolveMemberNickname(member));
         vo.setPhone(member.getPhone());
         vo.setAvatar(member.getAvatar());
         vo.setGender(member.getGender());

+ 4 - 5
cfc-frontend/components/FamilyMemberStrip.vue

@@ -169,16 +169,15 @@ export default {
   },
   computed: {
     canSwitch: function() {
-      // 仅不可登录成员(isSwitchable=true)且来源为 family_member 时可切换视角;
-      // 可登录成员(已通过微信登录)不能被切换使用
-      return this.actionMember && this.actionMember.isSwitchable && this.actionMember.source === 'family_member'
+      // 非自己的成员可切换视角(isSwitchable 由后端统一计算,含 family_member 与 parent 两种来源)
+      return this.actionMember && this.actionMember.isSwitchable
     },
     canEdit: function() {
       return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
     },
     canRemove: function() {
-      // 仅家庭成员记录可移除(kick 接口只查 family_members 表),且不能移除自己
-      return this.isParent && this.actionMember && !this.actionMember.isSelf && this.actionMember.source === 'family_member'
+      // 家长可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
+      return this.isParent && this.actionMember && !this.actionMember.isSelf
     },
     // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
     actionScores: function() {

+ 1 - 0
cfc-frontend/pages/action-detail/interaction-log.vue

@@ -470,6 +470,7 @@ export default {
   gap: 16rpx;
 }
 .type-item {
+  box-sizing: border-box;
   width: calc(25% - 12rpx);
   padding: 20rpx 0;
   text-align: center;

+ 18 - 8
cfc-frontend/pages/profile-extra/family-members.vue

@@ -35,8 +35,8 @@
 <view class="member-card" v-for="m in filteredMembers" :key="m.id" @click="showMemberDetail(m)">
         <image class="member-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
         <view class="member-info">
-          <text class="member-name">{{ m.nickname || '未设置' }}</text>
-          <text class="member-role-tag">{{ m.relativeLabel || m.roleLabel || (m.effectiveRole === 'parent' ? '家长' : '孩子') }}</text>
+          <text class="member-name">{{ m.nickname || m.name || '成员' }}</text>
+          <text class="member-role-tag">{{ m.relationship || m.relativeLabel || m.roleLabel || '成员' }}</text>
         </view>
         <text class="member-arrow">›</text>
       </view>
@@ -56,17 +56,21 @@
         <view class="detail-header">
           <image class="detail-avatar" :src="detailMember && (detailMember.avatar || '/static/default-avatar.png')" mode="aspectFill"></image>
           <view class="detail-info">
-            <text class="detail-name">{{ detailMember && detailMember.nickname || '' }}</text>
-            <text class="detail-relation">{{ detailMember && (detailMember.relativeLabel || detailMember.roleLabel || '') }}</text>
+            <text class="detail-name">{{ detailMember && (detailMember.nickname || detailMember.name || '成员') || '' }}</text>
+            <text class="detail-relation">{{ detailMember && (detailMember.relationship || detailMember.relativeLabel || detailMember.roleLabel || '成员') }}</text>
           </view>
           <text class="detail-close" @click="closeMemberDetail">×</text>
         </view>
 
-        <!-- 详情:性别/年龄/手机号 -->
+        <!-- 详情:性别/年龄/手机号/角色/辈分/积分/来源 -->
         <view class="detail-meta">
           <text class="detail-meta-item" v-if="detailMember && detailMember.gender">性别: {{ detailMember.gender === 'male' ? '男' : '女' }}</text>
           <text class="detail-meta-item" v-if="detailMember && detailMember.age">{{ detailMember.age }}岁</text>
           <text class="detail-meta-item" v-if="detailMember && detailMember.phone">{{ detailMember.phone }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.roleLabel">角色: {{ detailMember.roleLabel }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.generation !== null && detailMember.generation !== undefined && generationLabel(detailMember.generation)">辈分: {{ generationLabel(detailMember.generation) }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.totalPoints !== null && detailMember.totalPoints !== undefined">积分: {{ detailMember.totalPoints }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.source">类型: {{ detailMember.source === 'parent' ? '家长账号' : '家庭成员' }}</text>
         </view>
 
         <!-- 三维评分展示 -->
@@ -97,11 +101,11 @@
 
         <!-- 操作按钮 -->
         <view class="detail-actions">
-          <button class="detail-action-btn action-switch" @click="onSwitchMember(detailMember)" v-if="!(detailMember.source === 'parent' && detailMember.userId === userId)">切换身份</button>
+          <button class="detail-action-btn action-switch" @click="onSwitchMember(detailMember)" v-if="detailMember && detailMember.isSwitchable">切换身份</button>
           <button class="detail-action-btn action-edit" @click="onEditRelation(detailMember)" v-if="canEditMember(detailMember)">编辑资料</button>
           <button class="detail-action-btn action-questionnaire" @click="doQuestionnaire(detailMember)">关系问卷</button>
           <button class="detail-action-btn action-interaction" @click="recordInteraction(detailMember)">记录互动</button>
-          <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember.source === 'family_member'">删除成员</button>
+          <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember && !detailMember.isSelf">删除成员</button>
         </view>
       </view>
     </view>
@@ -313,7 +317,8 @@ export default {
     },
     canEditMember(member) {
       if (!this.isAdmin) return false
-      return member.source === 'family_member'
+      // 家长账号(users 表成员)与手写家庭成员均可编辑资料(后端 updateMember/updateParentMember 分别处理)
+      return member.source === 'family_member' || member.source === 'parent'
     },
     onAddMember() {
       uni.navigateTo({ url: '/pages/family/add-member' })
@@ -518,6 +523,11 @@ export default {
       var map = { 3: 'great_grandparent', 2: 'grandparent', 1: 'parent', 0: 'peer', '-1': 'child', '-2': 'grandchild', '-3': 'great_grandchild' }
       return map[gen] || ''
     },
+    // 辈分数值 → 中文标签(弹窗展示用,与 utils/generationLevel.js GENERATION_LEVELS 对应)
+    generationLabel(gen) {
+      var map = { 3: '曾祖辈', 2: '祖辈', 1: '父辈', 0: '同辈', '-1': '子辈', '-2': '孙辈', '-3': '曾孙辈' }
+      return map[gen] || ''
+    },
     onEditRelation(member) {
       this.showMemberDetailModal = false
       var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')