Forráskód Böngészése

feat: 家庭成员关系卡片增强 — 关系标签+联系+辈分+长按菜单

E2E Test Bot 1 hónapja
szülő
commit
6f503349ce

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -51,4 +51,22 @@ public class FamilyMemberVO {
 
     /** 辈分值(从关系表获取,当前视角) */
     private Integer generationValue;
+
+    /** 关系标签(如"爸爸"/"妈妈"/"儿子"/"女儿"),从 relativeLabel 或视角计算 */
+    private String relationship;
+
+    /** 是否配偶 */
+    private Boolean isSpouse;
+
+    /** 信任度分数 */
+    private java.math.BigDecimal trustScore;
+
+    /** 亲密度分数 */
+    private java.math.BigDecimal intimacyScore;
+
+    /** 最近互动时间 */
+    private java.util.Date lastInteractionAt;
+
+    /** 连续打卡天数 */
+    private Integer streakDays;
 }

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -209,10 +209,18 @@ public class FamilyMemberService {
                 if (genValue != null) {
                     vo.setGenerationValue(genValue);
                     vo.setRelativeLabel(familyRelationshipService.computeRelationshipLabel(genValue, fm.getGender()));
+                    vo.setRelationship(familyRelationshipService.computeRelationshipLabel(genValue, fm.getGender()));
                 } else {
                     vo.setRelativeLabel(computeRelativeLabel(viewerMember, fm, familyMembers));
+                    vo.setRelationship(computeRelativeLabel(viewerMember, fm, familyMembers));
                 }
             }
+            // 补充字段
+            vo.setIsSpouse(fm.getIsSpouse());
+            vo.setTrustScore(fm.getTrustScore());
+            vo.setIntimacyScore(fm.getIntimacyScore());
+            vo.setLastInteractionAt(fm.getLastInteractionAt());
+            vo.setStreakDays(fm.getStreakDays());
             result.add(vo);
         }
 

+ 101 - 31
cfc-frontend/components/FamilyMemberCard.vue

@@ -1,16 +1,39 @@
 <template>
-  <view class="family-member-card" :class="{ selected: isSelected }" @click="onSelect">
+  <view class="family-member-card" :class="{ selected: isSelected, ['role-' + 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-role-badge" :style="{ background: roleBadgeColor }">
-        <text class="card-role-text">{{ member.roleLabel || member.relativeLabel || '成员' }}</text>
+      <!-- 关系标签徽章 -->
+      <view class="card-relation-badge" :style="{ background: relationColor }">
+        <text class="card-relation-text">{{ member.relationship || member.relativeLabel || 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-generation-line" v-if="member.generation">
+      <text class="card-generation-text">第{{ member.generation }}代</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>
@@ -28,18 +51,31 @@ export default {
     defaultAvatar: function() {
       return '/static/default-avatar.png'
     },
-    roleBadgeColor: function() {
-      var role = this.member && (this.member.effectiveRole || this.member.memberType)
+    relationColor: function() {
+      var rel = this.member && this.member.relationship
+      if (rel === '爸爸' || rel === '妈妈' || rel === '父亲' || rel === '母亲') return '#F97316'
+      if (rel === '儿子' || rel === '女儿') return '#0EA5E9'
+      if (rel === '爷爷' || rel === '奶奶' || rel === '外公' || rel === '外婆') return '#6B7280'
+      if (rel === '哥哥' || rel === '姐姐' || rel === '弟弟' || rel === '妹妹') return '#10B981'
+      if (this.member && this.member.isSpouse) return '#EC4899'
+      var role = this.member && this.member.effectiveRole
       if (role === 'parent') return '#F97316'
       if (role === 'child') return '#0EA5E9'
       if (role === 'teacher') return '#8B5CF6'
-      if (role === 'elderly') return '#6B7280'
       return '#9CA3AF'
     }
   },
   methods: {
-    onSelect: function() {
+    onClick: function() {
       this.$emit('select', this.member)
+    },
+    onLongPress: function() {
+      this.$emit('longpress', this.member)
+    },
+    onCall: function() {
+      if (this.member && this.member.phone) {
+        uni.makePhoneCall({ phoneNumber: this.member.phone })
+      }
     }
   }
 }
@@ -50,12 +86,12 @@ export default {
   display: flex;
   flex-direction: column;
   align-items: center;
-  width: 120rpx;
-  padding: 12rpx 8rpx;
-  border-radius: 16rpx;
+  width: 140rpx;
+  padding: 16rpx 12rpx;
+  border-radius: 20rpx;
   background: #FFFFFF;
   border: 2rpx solid transparent;
-  transition: border-color 0.2s;
+  position: relative;
   flex-shrink: 0;
 }
 .family-member-card.selected {
@@ -64,43 +100,77 @@ export default {
 }
 .card-avatar-wrap {
   position: relative;
-  width: 80rpx;
-  height: 80rpx;
-  margin-bottom: 8rpx;
+  width: 88rpx;
+  height: 88rpx;
+  margin-bottom: 10rpx;
 }
 .card-avatar {
-  width: 80rpx;
-  height: 80rpx;
+  width: 88rpx;
+  height: 88rpx;
   border-radius: 50%;
   background: #F3F4F6;
 }
-.card-role-badge {
+.card-relation-badge {
   position: absolute;
   bottom: -4rpx;
   left: 50%;
   transform: translateX(-50%);
-  padding: 2rpx 10rpx;
+  padding: 2rpx 12rpx;
   border-radius: 12rpx;
   white-space: nowrap;
+  max-width: 110rpx;
 }
-.card-role-text {
+.card-relation-text {
   font-size: 18rpx;
   color: #FFFFFF;
   font-weight: 500;
   line-height: 1.4;
 }
+.card-spouse-mark {
+  position: absolute;
+  top: -6rpx;
+  right: -6rpx;
+  width: 32rpx;
+  height: 32rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.spouse-icon {
+  font-size: 24rpx;
+}
 .card-nickname {
-  font-size: 22rpx;
+  font-size: 24rpx;
   color: #374151;
-  font-weight: 500;
+  font-weight: 600;
   text-align: center;
-  margin-top: 4rpx;
   line-height: 1.3;
-  max-width: 110rpx;
+  max-width: 130rpx;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+.card-generation-line {
+  margin-top: 2rpx;
+}
+.card-generation-text {
+  font-size: 16rpx;
+  color: #9CA3AF;
+}
+.card-contact-row {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 4rpx;
+}
+.contact-icon {
+  font-size: 22rpx;
+}
+.contact-phone {
+  font-size: 18rpx;
+  color: #3B82F6;
+  margin-left: 4rpx;
+}
 .card-energy {
   display: flex;
   align-items: baseline;
@@ -108,21 +178,21 @@ export default {
   margin-top: 4rpx;
 }
 .energy-value {
-  font-size: 20rpx;
+  font-size: 22rpx;
   color: #F97316;
   font-weight: bold;
 }
 .energy-unit {
-  font-size: 16rpx;
+  font-size: 18rpx;
   color: #9CA3AF;
   margin-left: 2rpx;
 }
 .card-selected-mark {
   position: absolute;
-  top: 4rpx;
-  right: 4rpx;
-  width: 28rpx;
-  height: 28rpx;
+  top: 6rpx;
+  right: 6rpx;
+  width: 32rpx;
+  height: 32rpx;
   border-radius: 50%;
   background: #F97316;
   display: flex;
@@ -130,8 +200,8 @@ export default {
   justify-content: center;
 }
 .mark-icon {
-  font-size: 18rpx;
+  font-size: 20rpx;
   color: #FFFFFF;
   font-weight: bold;
 }
-</style>
+</style>

+ 170 - 3
cfc-frontend/components/FamilyMemberStrip.vue

@@ -16,6 +16,7 @@
           :isSelected="member.id === selectedMemberId"
           :energyScore="getMemberEnergy(member)"
           @select="onSelect"
+          @longpress="onLongPress"
         />
       </view>
     </scroll-view>
@@ -27,8 +28,37 @@
         :isSelected="member.id === selectedMemberId"
         :energyScore="getMemberEnergy(member)"
         @select="onSelect"
+        @longpress="onLongPress"
       />
     </view>
+
+    <!-- 长按操作菜单 -->
+    <view class="action-overlay" v-if="showActionMenu" @click="closeMenu"></view>
+    <view class="action-sheet" v-if="showActionMenu">
+      <view class="action-header">
+        <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
+        <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || '') : '' }}</text>
+      </view>
+      <view class="action-item" @click="switchToMember" v-if="canSwitch">
+        <text class="action-item-icon">🔄</text>
+        <text class="action-item-text">切换到该成员视角</text>
+      </view>
+      <view class="action-item" @click="editMember">
+        <text class="action-item-icon">✏️</text>
+        <text class="action-item-text">编辑资料</text>
+      </view>
+      <view class="action-item" @click="callMember" v-if="actionMember && actionMember.phone">
+        <text class="action-item-icon">📞</text>
+        <text class="action-item-text">拨打电话</text>
+      </view>
+      <view class="action-item action-danger" @click="removeMember" v-if="canRemove">
+        <text class="action-item-icon">🗑️</text>
+        <text class="action-item-text">移除成员</text>
+      </view>
+      <view class="action-item action-cancel" @click="closeMenu">
+        <text class="action-item-text">取消</text>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -40,12 +70,87 @@ export default {
   props: {
     members: { type: Array, default: function() { return [] } },
     selectedMemberId: { type: Number, default: null },
-    energyMap: { type: Object, default: function() { return {} } }
+    energyMap: { type: Object, default: function() { return {} } },
+    isParent: { type: Boolean, default: false }
+  },
+  data: function() {
+    return {
+      showActionMenu: false,
+      actionMember: null
+    }
+  },
+  computed: {
+    canSwitch: function() {
+      return this.actionMember && this.actionMember.userId
+    },
+    canRemove: function() {
+      return this.isParent && this.actionMember && !this.actionMember.isSelf
+    }
   },
   methods: {
     onSelect: function(member) {
       this.$emit('select', member)
     },
+    onLongPress: function(member) {
+      this.actionMember = member
+      this.showActionMenu = true
+    },
+    closeMenu: function() {
+      this.showActionMenu = false
+      this.actionMember = null
+    },
+    switchToMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member && member.userId) {
+        this.$emit('switch', member)
+      }
+    },
+    editMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.navigateTo({ url: '/pages/profile-extra/family-members?editId=' + member.id })
+      }
+    },
+    callMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member && member.phone) {
+        uni.makePhoneCall({ phoneNumber: member.phone })
+      }
+    },
+    removeMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.showModal({
+          title: '确认移除',
+          content: '确定要移除 ' + member.nickname + ' 吗?',
+          success: function(res) {
+            if (res.confirm) {
+              uni.showLoading({ title: '移除中...' })
+              uni.request({
+                url: getApp().globalData.baseUrl + '/api/family/member/kick',
+                method: 'POST',
+                data: { memberId: member.id },
+                header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+                success: function(resp) {
+                  uni.hideLoading()
+                  if (resp.data && resp.data.code === 200) {
+                    uni.showToast({ title: '已移除', icon: 'success' })
+                    // 通知父组件刷新
+                    getApp().globalData.refreshFamilyMembers = true
+                  } else {
+                    uni.showToast({ title: resp.data.message || '移除失败', icon: 'none' })
+                  }
+                }
+              })
+            }
+          }
+        })
+      }
+    },
     getMemberEnergy: function(member) {
       if (!member || !this.energyMap) return null
       return this.energyMap[member.id] || this.energyMap[member.memberId] || null
@@ -75,8 +180,70 @@ export default {
   gap: 24rpx;
   flex-wrap: wrap;
 }
-/* scroll-view 内的 FamilyMemberCard 需要 display: inline-flex 支持横向滚动 */
 .strip-scroll .strip-inner.scroll-mode {
   display: inline-flex;
 }
-</style>
+/* 操作菜单 */
+.action-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.4);
+  z-index: 999;
+}
+.action-sheet {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #FFFFFF;
+  border-radius: 24rpx 24rpx 0 0;
+  z-index: 1000;
+  padding: 20rpx 0;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+}
+.action-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16rpx 30rpx 20rpx;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.action-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1F2937;
+}
+.action-relation {
+  font-size: 24rpx;
+  color: #9CA3AF;
+  margin-top: 4rpx;
+}
+.action-item {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid #F9FAFB;
+}
+.action-item:active {
+  background: #F3F4F6;
+}
+.action-item-icon {
+  font-size: 32rpx;
+  margin-right: 16rpx;
+}
+.action-item-text {
+  font-size: 28rpx;
+  color: #374151;
+}
+.action-danger .action-item-text {
+  color: #EF4444;
+}
+.action-cancel {
+  justify-content: center;
+  margin-top: 10rpx;
+  border-bottom: none;
+}
+.action-cancel .action-item-text {
+  color: #6B7280;
+}
+</style>