Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/feature/unified-member-selector' into merge/t24-t25

zxyj-web 2 týždňov pred
rodič
commit
b44ffda3fb

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

@@ -11,6 +11,21 @@ public class FamilyMemberVO {
 
     private Long id;
 
+    /** 统一身份键:family_members.id(T25 统一成员选择器 · 与 id 等价,前端选择器主键用此字段) */
+    private Long familyMemberId;
+
+    /**
+     * 统一身份键赋值入口(T25 统一成员选择器)。
+     * familyMemberId 恒等于 id(family_members.id),此处兜底保证各构造点不会漏设,
+     * 避免前端把 userId(users.id)误当选择器主键。
+     */
+    public void setId(Long id) {
+        this.id = id;
+        if (this.familyMemberId == null) {
+            this.familyMemberId = id;
+        }
+    }
+
     /** 兼容旧前端字段:getChildren 等废弃接口的调用方使用 memberId 作为主键 */
     private Long memberId;
 

+ 20 - 270
cfc-frontend/components/DimensionMemberTabs.vue

@@ -1,60 +1,27 @@
 <template>
   <!--
-    五维页 · 成员标签条(DimensionMemberTabs)
-    用途:已登录家庭用户在身/心/智/富页通过点击标签切换成员,查看该成员各维度数值。
-    视觉:chip 形态(比大卡更轻,适合作为 PageBanner 下的筛选器);选中态用所属维度色渐变。
-    交互:点击切换(emit select);长按唤出管理菜单(emit longpress);右侧管理入口(emit manage)。
-    规范:触摸目标 >= 88rpx;维度色取 uni.scss 令牌(编译期内联,规避 mp-weixin CSS var 不稳)。
+    薄包装(T25 统一成员选择器):委托 MemberSelector variant="tabs"。
+    保持旧组件 API(props/事件)不变,任何残留调用点仍会走 memberNormalize 折叠去重。
+    新页面请直接使用 components/MemberSelector.vue。
   -->
-  <view class="member-tabs" :class="'dim-' + (dimensionCode || 'body')">
-    <scroll-view
-      class="tabs-scroll"
-      scroll-x
-      :enhanced="true"
-      :scroll-with-animation="true"
-      show-scrollbar="false"
-      v-if="members && members.length > 0"
-    >
-      <view class="tabs-inner">
-        <view
-          v-for="m in members"
-          :key="m.id"
-          class="member-tab"
-          :class="{ 'is-selected': String(m.id) === String(selectedMemberId), 'is-self': m.isSelf }"
-          @click="onTap(m)"
-          @longpress="onLongPress(m)"
-        >
-          <view class="tab-avatar-wrap">
-            <image class="tab-avatar" :src="m.avatar || defaultAvatar" mode="aspectFill" />
-            <view class="tab-self-mark" v-if="m.isSelf">我</view>
-          </view>
-          <view class="tab-body">
-            <text class="tab-name">{{ m.nickname || '成员' }}</text>
-            <view class="tab-meta">
-              <text class="tab-relation">{{ relationText(m) }}</text>
-              <text class="tab-score" v-if="scoreOf(m) !== null">{{ scoreOf(m) }}<text class="tab-score-unit">分</text></text>
-            </view>
-          </view>
-        </view>
-
-        <!-- 管理入口 -->
-        <view class="tab-manage" @click="onManage" v-if="members.length > 1">
-          <text class="tab-manage-icon">管理</text>
-        </view>
-      </view>
-    </scroll-view>
-
-    <!-- 空态:未创建家庭 / 无可见成员 -->
-    <view class="tabs-empty" v-else>
-      <text class="tabs-empty-text">创建家庭后,可在这里切换查看每位家人的维度数值</text>
-      <view class="tabs-empty-btn" @click="onManage">+ 管理家庭成员</view>
-    </view>
-  </view>
+  <MemberSelector
+    variant="tabs"
+    :members="members"
+    :selectedMemberId="selectedMemberId"
+    :dimensionCode="dimensionCode"
+    :scoreMap="scoreMap"
+    @select="onSelect"
+    @longpress="onLongPress"
+    @manage="onManage"
+    @removed="onRemoved" />
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+
 export default {
   name: 'DimensionMemberTabs',
+  components: { MemberSelector },
   props: {
     members: { type: Array, default: function() { return [] } },
     selectedMemberId: { type: [Number, String], default: null },
@@ -63,228 +30,11 @@ export default {
     // { memberId: score },用于在标签上直接显示该成员的维度分数
     scoreMap: { type: Object, default: function() { return {} } }
   },
-  data: function() {
-    return { defaultAvatar: '/static/default-avatar.png' }
-  },
-  computed: {
-    dimensionList: function() {
-      return this.members || []
-    }
-  },
   methods: {
-    relationText: function(m) {
-      if (!m) return '成员'
-      return m.relationship || m.relativeLabel || m.roleLabel || (m.isSelf ? '本人' : '成员')
-    },
-    scoreOf: function(m) {
-      if (!m || !this.scoreMap) return null
-      var v = this.scoreMap[m.id]
-      if (v === undefined || v === null || v === '') return null
-      var n = Number(v)
-      return isNaN(n) ? null : Math.round(n)
-    },
-    onTap: function(m) {
-      if (m && String(m.id) === String(this.selectedMemberId)) return
-      this.$emit('select', m)
-    },
-    onLongPress: function(m) {
-      this.$emit('longpress', m)
-    },
-    onManage: function() {
-      this.$emit('manage')
-    }
+    onSelect: function(member) { this.$emit('select', member) },
+    onLongPress: function(member) { this.$emit('longpress', member) },
+    onManage: function() { this.$emit('manage') },
+    onRemoved: function(member) { this.$emit('removed', member) }
   }
 }
 </script>
-
-<style scoped lang="scss">
-// 维度色映射:选中态渐变用主色 -> 深阶,五维统一从 uni.scss 取令牌
-$tab-dims: (
-  body:   $dim-body,
-  mind:   $dim-mind,
-  wisdom: $dim-wisdom,
-  wealth: $dim-wealth,
-  action: $dim-action
-);
-
-.member-tabs {
-  padding: 8rpx 30rpx 4rpx;
-}
-
-.tabs-scroll {
-  width: 100%;
-  white-space: nowrap;
-}
-
-.tabs-inner {
-  display: inline-flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 16rpx;
-  padding: 4rpx 0;
-}
-
-/* ---- 成员标签(chip) ---- */
-.member-tab {
-  display: inline-flex;
-  flex-direction: row;
-  align-items: center;
-  min-height: 88rpx;
-  padding: 10rpx 22rpx 10rpx 12rpx;
-  background: $app-surface;
-  border: 2rpx solid $app-border-subtle;
-  border-radius: 44rpx;
-  transition: border-color $dur-state $ease-standard, background $dur-state $ease-standard, transform $dur-press $ease-standard;
-  flex-shrink: 0;
-
-  &:active {
-    transform: scale(0.97);
-    opacity: $press-opacity;
-  }
-}
-
-.tab-avatar-wrap {
-  position: relative;
-  flex-shrink: 0;
-}
-
-.tab-avatar {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
-  background: $app-surface-subtle;
-}
-
-.tab-self-mark {
-  position: absolute;
-  right: -6rpx;
-  bottom: -6rpx;
-  min-width: 36rpx;
-  height: 36rpx;
-  padding: 0 8rpx;
-  border-radius: 18rpx;
-  background: $brand-primary;
-  color: $app-surface;
-  font-size: 20rpx;
-  font-weight: 600;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.tab-body {
-  display: flex;
-  flex-direction: column;
-  margin-left: 14rpx;
-  max-width: 200rpx;
-}
-
-.tab-name {
-  font-size: 26rpx;
-  font-weight: 600;
-  color: $app-text;
-  line-height: 1.3;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.tab-meta {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  margin-top: 2rpx;
-  gap: 10rpx;
-}
-
-.tab-relation {
-  font-size: 20rpx;
-  color: $app-text-muted;
-  line-height: 1.2;
-}
-
-.tab-score {
-  font-size: 22rpx;
-  font-weight: 700;
-  color: $app-text-secondary;
-  line-height: 1.2;
-}
-
-.tab-score-unit {
-  font-size: 18rpx;
-  font-weight: 400;
-  margin-left: 1rpx;
-}
-
-/* ---- 管理入口 ---- */
-.tab-manage {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  min-height: 88rpx;
-  min-width: 88rpx;
-  padding: 0 20rpx;
-  border-radius: 44rpx;
-  background: $app-surface-subtle;
-  flex-shrink: 0;
-
-  &:active {
-    opacity: $press-opacity;
-  }
-}
-
-.tab-manage-icon {
-  font-size: 24rpx;
-  color: $app-text-secondary;
-  font-weight: 500;
-}
-
-/* ---- 空态 ---- */
-.tabs-empty {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  padding: 24rpx 28rpx;
-  background: $app-surface;
-  border-radius: 16rpx;
-  border: 2rpx dashed $app-border;
-}
-
-.tabs-empty-text {
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $app-text-secondary;
-  line-height: 1.5;
-}
-
-.tabs-empty-btn {
-  margin-top: 16rpx;
-  padding: 12rpx 32rpx;
-  border-radius: 40rpx;
-  background: $brand-primary;
-  color: $app-surface;
-  font-size: 26rpx;
-  font-weight: 500;
-
-  &:active {
-    opacity: $press-opacity;
-  }
-}
-
-/* ---- 选中态:按维度着色 ---- */
-@each $code, $color in $tab-dims {
-  .dim-#{$code} .member-tab.is-selected {
-    background: linear-gradient(135deg, $color, rgba(red($color), green($color), blue($color), 0.82));
-    border-color: $color;
-
-    .tab-name,
-    .tab-relation,
-    .tab-score {
-      color: $app-surface;
-    }
-
-    .tab-avatar {
-      border: 2rpx solid $app-surface;
-    }
-  }
-}
-</style>

+ 28 - 516
cfc-frontend/components/FamilyMemberStrip.vue

@@ -1,532 +1,44 @@
 <template>
-  <view class="family-member-strip">
-    <!-- 有成员:滚动条 / 居中模式 + 长按菜单 -->
-    <view v-if="renderedMembers && renderedMembers.length > 0">
-      <scroll-view
-        class="strip-scroll"
-        scroll-x
-        :scroll-with-animation="true"
-        :enhanced="true"
-        show-scrollbar="false"
-        v-if="renderedMembers.length >= 4"
-      >
-        <view class="strip-inner scroll-mode">
-          <FamilyMemberCard
-            v-for="member in renderedMembers"
-            :key="member.id"
-            :member="member"
-            :isSelected="member.id === selectedMemberId"
-            :energyScore="getMemberEnergy(member)"
-            :pendingCount="pendingCounts[member.id] || 0"
-            @select="onSelect"
-            @longpress="onLongPress"
-          />
-        </view>
-      </scroll-view>
-      <view class="strip-inner center-mode" v-else>
-        <FamilyMemberCard
-          v-for="member in renderedMembers"
-          :key="member.id"
-          :member="member"
-          :isSelected="member.id === selectedMemberId"
-          :energyScore="getMemberEnergy(member)"
-          :pendingCount="pendingCounts[member.id] || 0"
-          @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">
-          <image class="action-avatar" :src="actionMember ? (actionMember.avatar || '/static/default-avatar.png') : ''" mode="aspectFill" />
-          <view class="action-header-info">
-            <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
-            <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
-          </view>
-        </view>
-        <!-- 关系现状区块 -->
-        <view class="action-scores" v-if="actionMember">
-          <view class="score-row">
-            <text class="score-label">信任度</text>
-            <view class="score-bar-track">
-              <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
-            </view>
-            <text class="score-value">{{ scoreValues[0] }}</text>
-          </view>
-          <view class="score-row">
-            <text class="score-label">亲密度</text>
-            <view class="score-bar-track">
-              <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
-            </view>
-            <text class="score-value">{{ scoreValues[1] }}</text>
-          </view>
-          <view class="score-row">
-            <text class="score-label">沟通质量</text>
-            <view class="score-bar-track">
-              <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
-            </view>
-            <text class="score-value">{{ scoreValues[2] }}</text>
-          </view>
-        </view>
-        <view class="action-divider"></view>
-        <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
-          <text class="action-item-icon">➕</text>
-          <text class="action-item-text">添加成员</text>
-        </view>
-        <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
-          <text class="action-item-icon">📨</text>
-          <text class="action-item-text">邀请成员</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" v-if="canEdit">
-          <text class="action-item-icon">✏️</text>
-          <text class="action-item-text">编辑资料</text>
-        </view>
-        <view class="action-item" @click="questionnaireMember">
-          <text class="action-item-icon">📋</text>
-          <text class="action-item-text">关系问卷</text>
-          <text class="action-time" v-if="lastQuestionnaireText">  ·  上次填写 {{ lastQuestionnaireText }}</text>
-        </view>
-        <view class="action-item" @click="interactionMember">
-          <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>
-
-    <!-- 未创建家庭引导 -->
-    <view class="family-strip-empty" v-else>
-      <view class="empty-content">
-        <text class="empty-icon">🏠</text>
-        <text class="empty-title">还未创建家庭</text>
-        <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
-        <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
-      </view>
-    </view>
-  </view>
+  <!--
+    薄包装(T25 统一成员选择器):委托 MemberSelector variant="strip"。
+    保持旧组件 API(props/事件)不变,任何残留调用点仍会走 memberNormalize 折叠去重。
+    新页面请直接使用 components/MemberSelector.vue。
+  -->
+  <MemberSelector
+    variant="strip"
+    :members="members"
+    :selectedMemberId="selectedMemberId"
+    :energyMap="energyMap"
+    :isParent="isParent"
+    :centerMemberId="centerMemberId"
+    :pendingCounts="pendingCounts"
+    @select="onSelect"
+    @switch="onSwitch"
+    @removed="onRemoved"
+    @created="onCreated"
+    @loaded="onLoaded" />
 </template>
 
 <script>
-import FamilyMemberCard from '@/components/FamilyMemberCard'
-import { kickFamilyMemberById, createFamily, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
-import { parseDate } from '@/utils/format.js'
+import MemberSelector from '@/components/MemberSelector.vue'
 
 export default {
-  components: { FamilyMemberCard },
+  name: 'FamilyMemberStrip',
+  components: { MemberSelector },
   props: {
     members: { type: Array, default: function() { return [] } },
-    selectedMemberId: { type: Number, default: null },
+    selectedMemberId: { type: [Number, String], default: null },
     energyMap: { type: Object, default: function() { return {} } },
     isParent: { type: Boolean, default: false },
-    centerMemberId: { type: Number, default: null },
+    centerMemberId: { type: [Number, String], default: null },
     pendingCounts: { type: Object, default: function() { return {} } }
   },
-  data: function() {
-    return {
-      showActionMenu: false,
-      actionMember: null,
-      renderedMembers: [],
-      creatingFamily: false
-    }
-  },
-  watch: {
-    members: {
-      handler: function(val) {
-        this.renderedMembers = val || []
-      },
-      immediate: true
-    },
-    centerMemberId: {
-      handler: function(val) {
-        if (val) {
-          this.loadPairwise(val)
-        }
-      },
-      immediate: true
-    }
-  },
-  // 未显式传入 members 时(如 growth-main/health-main 无 props 用法),自动加载可见家庭成员。
-  // 注意:mp-weixin 平台微信 properties 会对未传入的 props 填充默认值,
-  // $options.propsData.members 恒为 [](而非 undefined),不能用它判断父组件是否传参。
-  // 改为按实际数据判断:父组件传入非空 members 时不自我加载,否则调用接口兜底。
-  created: function() {
-    if (!this.members || this.members.length === 0) {
-      this.loadVisibleMembers()
-    }
-  },
-  computed: {
-    inSelfView: function() {
-      // 成员视角(已切换到不可登录成员)下不再允许切换其他成员,只能退出切换
-      return !(this.$store && this.$store.state && this.$store.state.currentView === 'member')
-    },
-    canSwitch: function() {
-      // 仅本人视角(self)可切换;目标仅限不可登录成员(isSwitchable 由后端统一计算,含 family_member 与 parent 两种来源)
-      return this.inSelfView && this.actionMember && this.actionMember.isSwitchable
-    },
-    canEdit: function() {
-      return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
-    },
-    canRemove: function() {
-      // 家长可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
-      return this.isParent && this.actionMember && !this.actionMember.isSelf
-    },
-    // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
-    actionScores: function() {
-      var m = this.actionMember
-      if (!m) return []
-      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
-      return keys.map(function(k) {
-        var v = Number(m[k]) || 0
-        return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
-      })
-    },
-    scoreWidths: function() {
-      return this.actionScores.map(function(s) { return s.width })
-    },
-    scoreValues: function() {
-      return this.actionScores.map(function(s) { return s.value })
-    },
-    // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
-    lastQuestionnaireText: function() {
-      var m = this.actionMember
-      if (!m || !m.lastInteractionAt) return ''
-      var d = parseDate(m.lastInteractionAt)
-      if (!d) return ''
-      var now = new Date()
-      var diffMs = now.getTime() - d.getTime()
-      var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
-      if (diffDays === 0) return '今天'
-      if (diffDays === 1) return '昨天'
-      if (diffDays < 7) return diffDays + '天前'
-      if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
-      return Math.floor(diffDays / 30) + '个月前'
-    }
-  },
   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 (this.inSelfView && member && member.isSwitchable) {
-        this.$emit('switch', member)
-      }
-    },
-    editMember: function() {
-      var member = this.actionMember
-      this.closeMenu()
-      if (member) {
-        uni.navigateTo({ url: '/pages/family/add-member?memberId=' + member.id })
-      }
-    },
-    questionnaireMember: function() {
-      var member = this.actionMember
-      this.closeMenu()
-      if (member) {
-        uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
-      }
-    },
-    interactionMember: function() {
-      var member = this.actionMember
-      this.closeMenu()
-      if (member) {
-        uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
-      }
-    },
-    callMember: function() {
-      var member = this.actionMember
-      this.closeMenu()
-      if (member && member.phone) {
-        uni.makePhoneCall({ phoneNumber: member.phone })
-      }
-    },
-    removeMember: function() {
-      var self = this
-      var member = this.actionMember
-      this.closeMenu()
-      if (member) {
-        uni.showModal({
-          title: '确认移除',
-          content: '确定要移除 ' + member.nickname + ' 吗?',
-          success: function(res) {
-            if (res.confirm) {
-              uni.showLoading({ title: '移除中...' })
-              kickFamilyMemberById(member.id)
-                .then(function() {
-                  uni.hideLoading()
-                  uni.showToast({ title: '已移除', icon: 'success' })
-                  // 通知父组件刷新成员列表(替代无效的 globalData 标志)
-                  self.$emit('removed', member)
-                })
-                .catch(function(err) {
-                  uni.hideLoading()
-                  uni.showToast({ title: (err && err.message) || '移除失败', icon: 'none' })
-                })
-            }
-          }
-        })
-      }
-    },
-    getMemberEnergy: function(member) {
-      if (!member || !this.energyMap) return null
-      return this.energyMap[member.id] || this.energyMap[member.memberId] || null
-    },
-    // 自我加载可见家庭成员(无 props 用法兜底,替代父组件传 members)
-    loadVisibleMembers: function() {
-      var self = this
-      var token = uni.getStorageSync('token')
-      if (!token) return
-      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
-        if (res && res.code === 200) {
-          var arr = res.data || []
-          if (arr instanceof Array && arr.length > 0) {
-            self.renderedMembers = arr
-          }
-        }
-      }).catch(function() {
-        // 静默失败,保持空态
-      })
-    },
-    onCreateFamily: function() {
-      var self = this
-      if (this.creatingFamily) return
-      this.creatingFamily = true
-      // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
-      var userInfo = uni.getStorageSync('userInfo')
-      var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
-      uni.showLoading({ title: '创建中...' })
-      createFamily(nickname + '的家庭')
-        .then(function(res) {
-          uni.hideLoading()
-          self.creatingFamily = false
-          if (res.code === 200) {
-            uni.showToast({ title: '家庭创建成功', icon: 'success' })
-            self.$emit('created')
-          } else {
-            uni.showToast({ title: res.message || '创建失败', icon: 'none' })
-          }
-        })
-        .catch(function(err) {
-          uni.hideLoading()
-          self.creatingFamily = false
-          uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
-        })
-    },
-    addMember: function() {
-      this.closeMenu()
-      uni.navigateTo({ url: '/pages/family/add-member' })
-    },
-    // 邀请成员:跳转家庭成员管理页,使用小程序原生分享(转发邀请卡片给微信好友)
-    inviteMember: function() {
-      this.closeMenu()
-      uni.navigateTo({ url: '/pages/profile-extra/family-members' })
-    },
-    loadPairwise: function(centerMemberId) {
-      var self = this
-      getPairwiseRelationships(centerMemberId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
-          self.renderedMembers = arr
-        }
-      }).catch(function() {})
-    }
+    onSelect: function(member) { this.$emit('select', member) },
+    onSwitch: function(member) { this.$emit('switch', member) },
+    onRemoved: function(member) { this.$emit('removed', member) },
+    onCreated: function() { this.$emit('created') },
+    onLoaded: function(members) { this.$emit('loaded', members) }
   }
 }
 </script>
-
-<style scoped lang="scss">
-.family-member-strip {
-  margin: 16rpx 0;
-  padding: 0 30rpx;
-}
-.strip-scroll {
-  width: 100%;
-  white-space: nowrap;
-}
-.strip-inner {
-  display: flex;
-  flex-direction: row;
-}
-.strip-inner.scroll-mode {
-  gap: 16rpx;
-}
-.strip-inner.center-mode {
-  justify-content: center;
-  gap: 24rpx;
-  flex-wrap: wrap;
-}
-.strip-scroll .strip-inner.scroll-mode {
-  display: inline-flex;
-}
-/* 操作菜单 */
-.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: $app-surface;
-  border-radius: 24rpx 24rpx 0 0;
-  z-index: 1000;
-  padding: 20rpx 0;
-  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
-}
-.action-title {
-  font-size: calc(30rpx * var(--font-scale, 1));
-  font-weight: 600;
-  color: $facade-text-gray-800;
-}
-.action-relation {
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-gray-400;
-  margin-top: 4rpx;
-}
-.action-item {
-  display: flex;
-  align-items: center;
-  padding: 24rpx 30rpx;
-  border-bottom: 1rpx solid $facade-fill-gray-50;
-}
-.action-item:active {
-  background: $facade-fill-gray-100;
-}
-.action-item-icon {
-  font-size: 32rpx;
-  margin-right: 16rpx;
-}
-.action-item-text {
-  font-size: calc(28rpx * var(--font-scale, 1));
-  color: $facade-text-gray-700;
-}
-.action-danger .action-item-text {
-  color: $status-danger;
-}
-.action-cancel {
-  justify-content: center;
-  margin-top: 10rpx;
-  border-bottom: none;
-}
-.action-cancel .action-item-text {
-  color: $facade-text-slate;
-}
-.action-time {
-  font-size: calc(22rpx * var(--font-scale, 1));
-  color: $facade-text-gray-400;
-  margin-left: 4rpx;
-}
-/* 空状态 */
-.family-strip-empty {
-  margin: 16rpx 0;
-  padding: 40rpx 30rpx;
-  background: $app-surface;
-  border-radius: 24rpx;
-  display: flex;
-  justify-content: center;
-}
-.empty-content {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-.empty-icon { font-size: 56rpx; }
-.empty-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-gray-800; margin-top: 12rpx; }
-.empty-desc { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-gray-400; margin-top: 8rpx; }
-.empty-btn {
-  margin-top: 24rpx;
-  padding: 16rpx 48rpx;
-  background: $brand-primary;
-  color: $app-surface;
-  font-size: 28rpx;
-  border-radius: 40rpx;
-}
-/* Action Sheet 头部(覆盖原有 column 布局) */
-.action-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding: 24rpx 30rpx;
-  border-bottom: 1rpx solid $facade-fill-gray-100;
-}
-.action-avatar {
-  width: 88rpx;
-  height: 88rpx;
-  border-radius: 50%;
-  margin-right: 20rpx;
-  background: $facade-fill-gray-100;
-}
-.action-header-info {
-  display: flex;
-  flex-direction: column;
-}
-/* 关系现状 */
-.action-scores {
-  padding: 20rpx 30rpx;
-}
-.score-row {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  margin-bottom: 12rpx;
-}
-.score-label {
-  width: 110rpx;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-slate;
-}
-.score-bar-track {
-  flex: 1;
-  height: 14rpx;
-  background: $facade-fill-gray-100;
-  border-radius: 7rpx;
-  overflow: hidden;
-}
-.score-bar-fill {
-  height: 100%;
-  border-radius: 7rpx;
-}
-.score-bar-fill.trust { background: $status-success; }
-.score-bar-fill.intimacy { background: $status-info; }
-.score-bar-fill.communication { background: $dim-wealth; }
-.score-value {
-  width: 70rpx;
-  text-align: right;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-gray-800;
-  font-weight: 600;
-}
-.action-divider {
-  height: 1rpx;
-  background: $facade-fill-gray-100;
-  margin: 0 30rpx;
-}
-</style>

+ 969 - 0
cfc-frontend/components/MemberSelector.vue

@@ -0,0 +1,969 @@
+<template>
+  <!--
+    统一家庭成员选择器(T25 统一成员选择器)
+
+    形态(variant):
+      · strip   —— 替代 FamilyMemberStrip(大卡 + 长按操作菜单:移除/切换/编辑/问卷/互动/电话)
+      · tabs    —— 替代 DimensionMemberTabs(轻 chip,选中态按维度色着色)
+      · minimal —— 替代各页面内联 member-bar / member-chip(家庭总览 / 个人 / 各成员)
+
+    统一约定:
+      · 渲染前一律经 utils/memberNormalize.js 规范化(折叠同 userId 多行,isSelf 唯一)
+      · 身份键统一为 familyMemberId(= family_members.id),userId 仅作折叠依据
+      · emit('select', member) 携带规范化后的 canonical memberId
+
+    边界说明(设计文档 §一.3):
+      前端折叠仅为防御层。重复的 family_members 行各自挂着不同外键数据,
+      彻底消除需 T24-C 数据合并(需单独授权),本组件不做任何数据写操作。
+  -->
+  <view class="member-selector">
+    <!-- ================================================================
+         strip 变体:大卡 + 长按操作菜单
+         ================================================================ -->
+    <view v-if="variant === 'strip'" class="family-member-strip">
+      <view v-if="renderedMembers && renderedMembers.length > 0">
+        <scroll-view
+          class="strip-scroll"
+          scroll-x
+          :scroll-with-animation="true"
+          :enhanced="true"
+          show-scrollbar="false"
+          v-if="renderedMembers.length >= 4"
+        >
+          <view class="strip-inner scroll-mode">
+            <FamilyMemberCard
+              v-for="member in renderedMembers"
+              :key="member.selectorKey"
+              :member="member"
+              :isSelected="isSameMember(member, selectedMemberId)"
+              :energyScore="getMemberEnergy(member)"
+              :pendingCount="pendingCounts[member.id] || 0"
+              @select="onSelect"
+              @longpress="onLongPress"
+            />
+          </view>
+        </scroll-view>
+        <view class="strip-inner center-mode" v-else>
+          <FamilyMemberCard
+            v-for="member in renderedMembers"
+            :key="member.selectorKey"
+            :member="member"
+            :isSelected="isSameMember(member, selectedMemberId)"
+            :energyScore="getMemberEnergy(member)"
+            :pendingCount="pendingCounts[member.id] || 0"
+            @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">
+            <image class="action-avatar" :src="actionMember ? (actionMember.avatar || defaultAvatar) : ''" mode="aspectFill" />
+            <view class="action-header-info">
+              <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
+              <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
+            </view>
+          </view>
+          <!-- 关系现状区块 -->
+          <view class="action-scores" v-if="actionMember">
+            <view class="score-row">
+              <text class="score-label">信任度</text>
+              <view class="score-bar-track">
+                <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
+              </view>
+              <text class="score-value">{{ scoreValues[0] }}</text>
+            </view>
+            <view class="score-row">
+              <text class="score-label">亲密度</text>
+              <view class="score-bar-track">
+                <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
+              </view>
+              <text class="score-value">{{ scoreValues[1] }}</text>
+            </view>
+            <view class="score-row">
+              <text class="score-label">沟通质量</text>
+              <view class="score-bar-track">
+                <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
+              </view>
+              <text class="score-value">{{ scoreValues[2] }}</text>
+            </view>
+          </view>
+          <view class="action-divider"></view>
+          <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
+            <text class="action-item-icon">➕</text>
+            <text class="action-item-text">添加成员</text>
+          </view>
+          <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
+            <text class="action-item-icon">📨</text>
+            <text class="action-item-text">邀请成员</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" v-if="canEdit">
+            <text class="action-item-icon">✏️</text>
+            <text class="action-item-text">编辑资料</text>
+          </view>
+          <view class="action-item" @click="questionnaireMember">
+            <text class="action-item-icon">📋</text>
+            <text class="action-item-text">关系问卷</text>
+            <text class="action-time" v-if="lastQuestionnaireText">  ·  上次填写 {{ lastQuestionnaireText }}</text>
+          </view>
+          <view class="action-item" @click="interactionMember">
+            <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>
+
+      <!-- 未创建家庭引导 -->
+      <view class="family-strip-empty" v-else>
+        <view class="empty-content">
+          <text class="empty-icon">🏠</text>
+          <text class="empty-title">还未创建家庭</text>
+          <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
+          <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ================================================================
+         tabs 变体:轻 chip(五维页筛选器)
+         ================================================================ -->
+    <view v-else-if="variant === 'tabs'" class="member-tabs" :class="'dim-' + (dimensionCode || 'body')">
+      <scroll-view
+        class="tabs-scroll"
+        scroll-x
+        :enhanced="true"
+        :scroll-with-animation="true"
+        show-scrollbar="false"
+        v-if="normalizedMembers && normalizedMembers.length > 0"
+      >
+        <view class="tabs-inner">
+          <view
+            v-for="m in decoratedMembers"
+            :key="m.selectorKey"
+            class="member-tab"
+            :class="{ 'is-selected': m._selected, 'is-self': m.isSelf }"
+            @click="onTap(m)"
+            @longpress="onTabLongPress(m)"
+          >
+            <view class="tab-avatar-wrap">
+              <image class="tab-avatar" :src="m.avatar || defaultAvatar" mode="aspectFill" />
+              <view class="tab-self-mark" v-if="m.isSelf">我</view>
+            </view>
+            <view class="tab-body">
+              <text class="tab-name">{{ m.nickname || '成员' }}</text>
+              <view class="tab-meta">
+                <text class="tab-relation">{{ relationText(m) }}</text>
+                <text class="tab-score" v-if="scoreOf(m) !== null">{{ scoreOf(m) }}<text class="tab-score-unit">分</text></text>
+              </view>
+            </view>
+          </view>
+
+          <!-- 管理入口(折叠后同一用户仅占一项,故按规范化后的数量判断) -->
+          <view class="tab-manage" @click="onManage" v-if="normalizedMembers.length > 1">
+            <text class="tab-manage-icon">管理</text>
+          </view>
+        </view>
+      </scroll-view>
+
+      <!-- 空态:未创建家庭 / 无可见成员 -->
+      <view class="tabs-empty" v-else>
+        <text class="tabs-empty-text">创建家庭后,可在这里切换查看每位家人的维度数值</text>
+        <view class="tabs-empty-btn" @click="onManage">+ 管理家庭成员</view>
+      </view>
+    </view>
+
+    <!-- ================================================================
+         minimal 变体:轻量 chip 条(替代各页面内联 member-bar)
+         形态:家庭总览(可选聚合项) / 个人(= isSelf 行) / 各成员
+         ================================================================ -->
+    <view v-else class="member-minimal" :class="{ 'is-embedded': embedded }">
+      <scroll-view
+        class="minimal-scroll"
+        scroll-x
+        :enhanced="true"
+        :scroll-with-animation="true"
+        show-scrollbar="false"
+      >
+        <view class="minimal-inner">
+          <!-- 家庭总览(聚合视图,非某个成员) -->
+          <view
+            v-if="showOverview"
+            class="minimal-chip"
+            :class="{ 'is-active': overviewActive }"
+            @click="onOverview"
+          >
+            <text class="minimal-chip-text">{{ overviewLabel }}</text>
+          </view>
+
+          <!-- 个人(= isSelf 行,canonical memberId;同一用户至多一项) -->
+          <view
+            v-if="showSelf && selfMember"
+            class="minimal-chip"
+            :class="{ 'is-active': selfChipActive }"
+            @click="onSelect(selfMember)"
+          >
+            <text class="minimal-chip-text">{{ selfLabel }}</text>
+          </view>
+
+          <!-- 各成员(排除本人行,避免同一人出现两次) -->
+          <view
+            v-for="m in otherMembers"
+            :key="m.selectorKey"
+            class="minimal-chip"
+            :class="{ 'is-active': m._selected }"
+            @click="onSelect(m)"
+          >
+            <text class="minimal-chip-text">{{ m.nickname || m.roleLabel || '成员' }}</text>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+  </view>
+</template>
+
+<script>
+import FamilyMemberCard from '@/components/FamilyMemberCard'
+import { kickFamilyMemberById, createFamily, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
+import { parseDate } from '@/utils/format.js'
+import { normalizeMembers, findSelfMember, membersExceptSelf } from '@/utils/memberNormalize.js'
+
+export default {
+  name: 'MemberSelector',
+  components: { FamilyMemberCard },
+  props: {
+    // 形态:strip / tabs / minimal
+    variant: { type: String, default: 'strip' },
+    // 原始成员列表(可选:缺省时组件自行拉取可见成员)
+    members: { type: Array, default: function() { return [] } },
+    // 当前选中成员的统一身份键(familyMemberId)
+    selectedMemberId: { type: [Number, String], default: null },
+    // tabs 专用:所属维度码,决定选中态配色
+    dimensionCode: { type: String, default: 'body' },
+    // tabs 专用:{ memberId: score }
+    scoreMap: { type: Object, default: function() { return {} } },
+    // minimal 专用:是否展示「家庭总览」聚合项
+    showOverview: { type: Boolean, default: false },
+    // minimal 专用:家庭总览项文案
+    overviewLabel: { type: String, default: '👨‍👩‍👧 家庭总览' },
+    // minimal 专用:家庭总览是否处于选中态
+    overviewActive: { type: Boolean, default: false },
+    // minimal 专用:是否展示「个人」项(= isSelf 行)
+    showSelf: { type: Boolean, default: true },
+    // minimal 专用:「个人」项文案
+    selfLabel: { type: String, default: '👤 个人' },
+    // minimal 专用:「个人」项强制选中态(用于「未显式选择即视为本人」的页面)
+    selfActive: { type: Boolean, default: false },
+    // minimal 专用:嵌入模式(去掉卡片底色/分隔线,用于页头等容器内)
+    embedded: { type: Boolean, default: false },
+    // strip 专用:{ memberId: energyScore }
+    energyMap: { type: Object, default: function() { return {} } },
+    // strip 专用:当前登录者是否为家庭管理员(决定长按菜单中的编辑/移除)
+    isParent: { type: Boolean, default: false },
+    // strip 专用:以该成员为中心加载关系成员
+    centerMemberId: { type: [Number, String], default: null },
+    // strip 专用:{ memberId: pendingCount }
+    pendingCounts: { type: Object, default: function() { return {} } }
+  },
+  data: function() {
+    return {
+      showActionMenu: false,
+      actionMember: null,
+      defaultAvatar: '/static/default-avatar.png',
+      creatingFamily: false,
+      // 组件自取到的成员(父组件未传 members 时的兜底)
+      selfLoadedMembers: [],
+      // centerMemberId 场景下按关系加载的成员
+      pairwiseMembers: [],
+      // 当前查看者(用于规范化时定位 isSelf 行)
+      viewer: { userId: null, memberId: null }
+    }
+  },
+  computed: {
+    // 有效成员来源:父组件传入优先,否则用组件自取结果
+    effectiveMembers: function() {
+      if (this.members && this.members.length > 0) return this.members
+      return this.selfLoadedMembers
+    },
+    // 渲染前统一规范化(折叠同 userId 多行 → canonical 行;isSelf 唯一)
+    normalizedMembers: function() {
+      return normalizeMembers(this.effectiveMembers, this.viewer)
+    },
+    // minimal 变体:「个人」= isSelf 行
+    selfMember: function() {
+      return findSelfMember(this.normalizedMembers)
+    },
+    // 渲染用成员:附带 _selected 标记(避免在 :class 中调方法,mp 模板编译器不支持)
+    decoratedMembers: function() {
+      var self = this
+      var out = []
+      var list = this.normalizedMembers || []
+      for (var i = 0; i < list.length; i++) {
+        var copy = Object.assign({}, list[i], { _selected: self.isSameMember(list[i], self.selectedMemberId) })
+        out.push(copy)
+      }
+      return out
+    },
+    // minimal 变体:其余成员(不含本人行,同一用户至多出现一次)
+    otherMembers: function() {
+      return membersExceptSelf(this.decoratedMembers)
+    },
+    // minimal 变体:「个人」项选中态(selfActive 兜底「未显式选择即视为本人」)
+    selfChipActive: function() {
+      return this.selfActive || this.isSameMember(this.selfMember, this.selectedMemberId)
+    },
+    // strip 变体渲染列表:centerMemberId 场景优先用关系成员
+    renderedMembers: function() {
+      if (this.centerMemberId && this.pairwiseMembers && this.pairwiseMembers.length > 0) {
+        return this.pairwiseMembers
+      }
+      return this.normalizedMembers
+    },
+    inSelfView: function() {
+      // 成员视角(已切换到不可登录成员)下不再允许切换其他成员,只能退出切换
+      return !(this.$store && this.$store.state && this.$store.state.currentView === 'member')
+    },
+    canSwitch: function() {
+      // 仅本人视角(self)可切换;目标仅限不可登录成员(isSwitchable 由后端统一计算)
+      return this.inSelfView && this.actionMember && this.actionMember.isSwitchable
+    },
+    canEdit: function() {
+      return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
+    },
+    canRemove: function() {
+      // 家长可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
+      return this.isParent && this.actionMember && !this.actionMember.isSelf
+    },
+    // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
+    actionScores: function() {
+      var m = this.actionMember
+      if (!m) return []
+      var keys = ['trustScore', 'intimacyScore', 'communicationScore']
+      return keys.map(function(k) {
+        var v = Number(m[k]) || 0
+        return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
+      })
+    },
+    scoreWidths: function() {
+      return this.actionScores.map(function(s) { return s.width })
+    },
+    scoreValues: function() {
+      return this.actionScores.map(function(s) { return s.value })
+    },
+    // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
+    lastQuestionnaireText: function() {
+      var m = this.actionMember
+      if (!m || !m.lastInteractionAt) return ''
+      var d = parseDate(m.lastInteractionAt)
+      if (!d) return ''
+      var now = new Date()
+      var diffMs = now.getTime() - d.getTime()
+      var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
+      if (diffDays === 0) return '今天'
+      if (diffDays === 1) return '昨天'
+      if (diffDays < 7) return diffDays + '天前'
+      if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
+      return Math.floor(diffDays / 30) + '个月前'
+    }
+  },
+  watch: {
+    centerMemberId: {
+      handler: function(val) {
+        if (val) {
+          this.loadPairwise(val)
+        }
+      },
+      immediate: true
+    }
+  },
+  created: function() {
+    // 当前查看者:登录账号 userId(isSelf 定位);memberId 缺失时后端 isSelf 标记兜底
+    var storedUserId = uni.getStorageSync('userId')
+    this.viewer = {
+      userId: (storedUserId === '' || storedUserId === null || storedUserId === undefined) ? null : storedUserId,
+      memberId: null
+    }
+    // 未显式传入 members 时自动拉取可见家庭成员(与原 FamilyMemberStrip 行为一致)
+    if (!this.members || this.members.length === 0) {
+      this.loadVisibleMembers()
+    }
+  },
+  methods: {
+    // 统一身份键比较(familyMemberId 优先,回退 id)
+    isSameMember: function(member, id) {
+      if (!member || id === null || id === undefined || id === '') return false
+      var key = member.memberId !== null && member.memberId !== undefined ? member.memberId : member.id
+      return String(key) === String(id)
+    },
+    onSelect: function(member) {
+      this.$emit('select', member)
+    },
+    onOverview: function() {
+      this.$emit('overview')
+    },
+    onLongPress: function(member) {
+      this.actionMember = member
+      this.showActionMenu = true
+    },
+    onTap: function(m) {
+      if (this.isSameMember(m, this.selectedMemberId)) return
+      this.$emit('select', m)
+    },
+    onTabLongPress: function(m) {
+      this.$emit('longpress', m)
+    },
+    onManage: function() {
+      this.$emit('manage')
+    },
+    closeMenu: function() {
+      this.showActionMenu = false
+      this.actionMember = null
+    },
+    switchToMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (this.inSelfView && member && member.isSwitchable) {
+        this.$emit('switch', member)
+      }
+    },
+    editMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.navigateTo({ url: '/pages/family/add-member?memberId=' + member.id })
+      }
+    },
+    questionnaireMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
+      }
+    },
+    interactionMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
+      }
+    },
+    callMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member && member.phone) {
+        uni.makePhoneCall({ phoneNumber: member.phone })
+      }
+    },
+    removeMember: function() {
+      var self = this
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        uni.showModal({
+          title: '确认移除',
+          content: '确定要移除 ' + member.nickname + ' 吗?',
+          success: function(res) {
+            if (res.confirm) {
+              uni.showLoading({ title: '移除中...' })
+              kickFamilyMemberById(member.id)
+                .then(function() {
+                  uni.hideLoading()
+                  uni.showToast({ title: '已移除', icon: 'success' })
+                  // 通知父组件刷新成员列表
+                  self.$emit('removed', member)
+                })
+                .catch(function(err) {
+                  uni.hideLoading()
+                  uni.showToast({ title: (err && err.message) || '移除失败', icon: 'none' })
+                })
+            }
+          }
+        })
+      }
+    },
+    getMemberEnergy: function(member) {
+      if (!member || !this.energyMap) return null
+      return this.energyMap[member.id] || this.energyMap[member.memberId] || null
+    },
+    relationText: function(m) {
+      if (!m) return '成员'
+      return m.relationship || m.relativeLabel || m.roleLabel || (m.isSelf ? '本人' : '成员')
+    },
+    scoreOf: function(m) {
+      if (!m || !this.scoreMap) return null
+      var v = this.scoreMap[m.id]
+      if (v === undefined || v === null || v === '') return null
+      var n = Number(v)
+      return isNaN(n) ? null : Math.round(n)
+    },
+    // 自我加载可见家庭成员(父组件未传 members 时的兜底)
+    loadVisibleMembers: function() {
+      var self = this
+      var token = uni.getStorageSync('token')
+      if (!token) return
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        if (res && res.code === 200) {
+          var arr = res.data || []
+          if (arr instanceof Array && arr.length > 0) {
+            self.selfLoadedMembers = arr
+            self.$emit('loaded', arr)
+          }
+        }
+      }).catch(function() {
+        // 静默失败,保持空态
+      })
+    },
+    onCreateFamily: function() {
+      var self = this
+      if (this.creatingFamily) return
+      this.creatingFamily = true
+      // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
+      var userInfo = uni.getStorageSync('userInfo')
+      var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
+      uni.showLoading({ title: '创建中...' })
+      createFamily(nickname + '的家庭')
+        .then(function(res) {
+          uni.hideLoading()
+          self.creatingFamily = false
+          if (res.code === 200) {
+            uni.showToast({ title: '家庭创建成功', icon: 'success' })
+            self.$emit('created')
+          } else {
+            uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          uni.hideLoading()
+          self.creatingFamily = false
+          uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
+        })
+    },
+    addMember: function() {
+      this.closeMenu()
+      uni.navigateTo({ url: '/pages/family/add-member' })
+    },
+    // 邀请成员:跳转家庭成员管理页
+    inviteMember: function() {
+      this.closeMenu()
+      uni.navigateTo({ url: '/pages/profile-extra/family-members' })
+    },
+    loadPairwise: function(centerMemberId) {
+      var self = this
+      getPairwiseRelationships(centerMemberId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
+          // 关系成员同样做规范化(防御:关系图成员也可能带重复 userId)
+          self.pairwiseMembers = normalizeMembers(arr, self.viewer)
+        }
+      }).catch(function() {})
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+/* ================================================================
+   strip 变体(原 FamilyMemberStrip 样式)
+   ================================================================ */
+.family-member-strip {
+  margin: 16rpx 0;
+  padding: 0 30rpx;
+}
+.strip-scroll {
+  width: 100%;
+  white-space: nowrap;
+}
+.strip-inner {
+  display: flex;
+  flex-direction: row;
+}
+.strip-inner.scroll-mode {
+  gap: 16rpx;
+}
+.strip-inner.center-mode {
+  justify-content: center;
+  gap: 24rpx;
+  flex-wrap: wrap;
+}
+.strip-scroll .strip-inner.scroll-mode {
+  display: inline-flex;
+}
+/* 操作菜单 */
+.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: $app-surface;
+  border-radius: 24rpx 24rpx 0 0;
+  z-index: 1000;
+  padding: 20rpx 0;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+}
+.action-title {
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-gray-800;
+}
+.action-relation {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-gray-400;
+  margin-top: 4rpx;
+}
+.action-item {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid $facade-fill-gray-50;
+}
+.action-item:active {
+  background: $facade-fill-gray-100;
+}
+.action-item-icon {
+  font-size: 32rpx;
+  margin-right: 16rpx;
+}
+.action-item-text {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-gray-700;
+}
+.action-danger .action-item-text {
+  color: $status-danger;
+}
+.action-cancel {
+  justify-content: center;
+  margin-top: 10rpx;
+  border-bottom: none;
+}
+.action-cancel .action-item-text {
+  color: $facade-text-slate;
+}
+.action-time {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-gray-400;
+  margin-left: 4rpx;
+}
+/* 空状态 */
+.family-strip-empty {
+  margin: 16rpx 0;
+  padding: 40rpx 30rpx;
+  background: $app-surface;
+  border-radius: 24rpx;
+  display: flex;
+  justify-content: center;
+}
+.empty-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.empty-icon { font-size: 56rpx; }
+.empty-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-gray-800; margin-top: 12rpx; }
+.empty-desc { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-gray-400; margin-top: 8rpx; }
+.empty-btn {
+  margin-top: 24rpx;
+  padding: 16rpx 48rpx;
+  background: $brand-primary;
+  color: $app-surface;
+  font-size: 28rpx;
+  border-radius: 40rpx;
+}
+/* Action Sheet 头部 */
+.action-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid $facade-fill-gray-100;
+}
+.action-avatar {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  margin-right: 20rpx;
+  background: $facade-fill-gray-100;
+}
+.action-header-info {
+  display: flex;
+  flex-direction: column;
+}
+/* 关系现状 */
+.action-scores {
+  padding: 20rpx 30rpx;
+}
+.score-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.score-label {
+  width: 110rpx;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-slate;
+}
+.score-bar-track {
+  flex: 1;
+  height: 14rpx;
+  background: $facade-fill-gray-100;
+  border-radius: 7rpx;
+  overflow: hidden;
+}
+.score-bar-fill {
+  height: 100%;
+  border-radius: 7rpx;
+}
+.score-bar-fill.trust { background: $status-success; }
+.score-bar-fill.intimacy { background: $status-info; }
+.score-bar-fill.communication { background: $dim-wealth; }
+.score-value {
+  width: 70rpx;
+  text-align: right;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-gray-800;
+  font-weight: 600;
+}
+.action-divider {
+  height: 1rpx;
+  background: $facade-fill-gray-100;
+  margin: 0 30rpx;
+}
+
+/* ================================================================
+   tabs 变体(原 DimensionMemberTabs 样式)
+   ================================================================ */
+// 维度色映射:选中态渐变用主色 -> 深阶,五维统一从 uni.scss 取令牌
+$tab-dims: (
+  body:   $dim-body,
+  mind:   $dim-mind,
+  wisdom: $dim-wisdom,
+  wealth: $dim-wealth,
+  action: $dim-action
+);
+
+.member-tabs {
+  padding: 8rpx 30rpx 4rpx;
+}
+.tabs-scroll {
+  width: 100%;
+  white-space: nowrap;
+}
+.tabs-inner {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+  padding: 4rpx 0;
+}
+/* ---- 成员标签(chip) ---- */
+.member-tab {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  min-height: 88rpx;
+  padding: 10rpx 22rpx 10rpx 12rpx;
+  background: $app-surface;
+  border: 2rpx solid $app-border-subtle;
+  border-radius: 44rpx;
+  transition: border-color $dur-state $ease-standard, background $dur-state $ease-standard, transform $dur-press $ease-standard;
+  flex-shrink: 0;
+
+  &:active {
+    transform: scale(0.97);
+    opacity: $press-opacity;
+  }
+}
+.tab-avatar-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+.tab-avatar {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: $app-surface-subtle;
+}
+.tab-self-mark {
+  position: absolute;
+  right: -6rpx;
+  bottom: -6rpx;
+  min-width: 36rpx;
+  height: 36rpx;
+  padding: 0 8rpx;
+  border-radius: 18rpx;
+  background: $brand-primary;
+  color: $app-surface;
+  font-size: 20rpx;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.tab-body {
+  display: flex;
+  flex-direction: column;
+  margin-left: 14rpx;
+  max-width: 200rpx;
+}
+.tab-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: $app-text;
+  line-height: 1.3;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.tab-meta {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 2rpx;
+  gap: 10rpx;
+}
+.tab-relation {
+  font-size: 20rpx;
+  color: $app-text-muted;
+  line-height: 1.2;
+}
+.tab-score {
+  font-size: 22rpx;
+  font-weight: 700;
+  color: $app-text-secondary;
+  line-height: 1.2;
+}
+.tab-score-unit {
+  font-size: 18rpx;
+  font-weight: 400;
+  margin-left: 1rpx;
+}
+/* ---- 管理入口 ---- */
+.tab-manage {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 88rpx;
+  min-width: 88rpx;
+  padding: 0 20rpx;
+  border-radius: 44rpx;
+  background: $app-surface-subtle;
+  flex-shrink: 0;
+
+  &:active {
+    opacity: $press-opacity;
+  }
+}
+.tab-manage-icon {
+  font-size: 24rpx;
+  color: $app-text-secondary;
+  font-weight: 500;
+}
+/* ---- 空态 ---- */
+.tabs-empty {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding: 24rpx 28rpx;
+  background: $app-surface;
+  border-radius: 16rpx;
+  border: 2rpx dashed $app-border;
+}
+.tabs-empty-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $app-text-secondary;
+  line-height: 1.5;
+}
+.tabs-empty-btn {
+  margin-top: 16rpx;
+  padding: 12rpx 32rpx;
+  border-radius: 40rpx;
+  background: $brand-primary;
+  color: $app-surface;
+  font-size: 26rpx;
+  font-weight: 500;
+
+  &:active {
+    opacity: $press-opacity;
+  }
+}
+/* ---- 选中态:按维度着色 ---- */
+@each $code, $color in $tab-dims {
+  .dim-#{$code} .member-tab.is-selected {
+    background: linear-gradient(135deg, $color, rgba(red($color), green($color), blue($color), 0.82));
+    border-color: $color;
+
+    .tab-name,
+    .tab-relation,
+    .tab-score {
+      color: $app-surface;
+    }
+
+    .tab-avatar {
+      border: 2rpx solid $app-surface;
+    }
+  }
+}
+
+/* ================================================================
+   minimal 变体(轻量 chip 条,替代各页面内联 member-bar)
+   ================================================================ */
+.member-minimal {
+  padding: 16rpx 30rpx 8rpx;
+  background: $app-surface;
+  border-bottom: 1rpx solid $facade-fill;
+}
+.member-minimal.is-embedded {
+  /* 嵌入模式:用于页头等容器内(去掉卡片底色/分隔线) */
+  padding: 4rpx 0;
+  background: transparent;
+  border-bottom: none;
+}
+.minimal-scroll {
+  width: 100%;
+  white-space: nowrap;
+}
+.minimal-inner {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+}
+.minimal-chip {
+  flex-shrink: 0;
+  padding: 10rpx 24rpx;
+  border-radius: 30rpx;
+  background: $facade-fill-2;
+  border: 2rpx solid transparent;
+}
+.minimal-chip.is-active {
+  background: linear-gradient(135deg, $dim-body, $dim-body-light);
+  color: $app-surface;
+}
+.minimal-chip-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  font-weight: 500;
+  color: inherit;
+}
+</style>

+ 4 - 3
cfc-frontend/pages/action-detail/index.vue

@@ -17,7 +17,8 @@
     </view>
 
     <!-- 家庭成员选择条 -->
-    <FamilyMemberStrip
+    <MemberSelector
+      variant="strip"
       v-if="currentView === 'self' && isLoggedIn"
       :members="familyMembersVisible"
       :selectedMemberId="selectedMemberId"
@@ -264,7 +265,7 @@ import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import PearlDiagram from '@/pages/action-detail/components/PearlDiagram.vue'
 import AbilityDiagram from '@/pages/action-detail/components/AbilityDiagram.vue'
 import OctopusDiagram from '@/pages/action-detail/components/OctopusDiagram.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -279,7 +280,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims, DimensionPageSkeleton },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, MemberSelector, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
       gridColor: BrandColors.emerald100,

+ 4 - 3
cfc-frontend/pages/body-detail/index.vue

@@ -206,7 +206,8 @@
       </view>
 
       <!-- 家庭成员选择条 -->
-      <DimensionMemberTabs
+      <MemberSelector
+        variant="tabs"
         v-if="currentView === 'self'"
         :members="familyMembersVisible"
         :selectedMemberId="selectedMemberId"
@@ -251,7 +252,7 @@ import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
-import DimensionMemberTabs from '../../components/DimensionMemberTabs.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 
 import {
   getVisibleSections,
@@ -271,7 +272,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, MemberSelector, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
       gridColor: BrandColors.orange200,

+ 26 - 22
cfc-frontend/pages/diet/food-query.vue

@@ -1,25 +1,15 @@
 <template>
   <view class="food-query-page" :style="{ '--font-scale': fontScale }">
-    <!-- 成员切换条 -->
-    <view class="member-bar" v-if="memberList.length > 1 || familyMode">
-      <view class="member-tab"
-        :class="{'active': currentView === 'family'}"
-        @tap="switchView('family')">
-        <text class="member-name">👨‍👩‍👧 家庭总览</text>
-      </view>
-      <view class="member-tab"
-        :class="{'active': currentView === 'personal'}"
-        @tap="switchView('personal')">
-        <text class="member-name">👤 个人</text>
-      </view>
-      <view class="member-tab"
-        v-for="(m, mi) in memberList"
-        :key="m.userId"
-        :class="{'active': currentMemberId === m.userId}"
-        @tap="switchMember(m)">
-        <text class="member-name">{{ m.nickname || m.roleLabel || '成员' }}</text>
-      </view>
-    </view>
+    <!-- 成员切换条(统一组件 MemberSelector:家庭总览 / 个人 / 各成员,同一用户只出现一次) -->
+    <MemberSelector
+      variant="minimal"
+      v-if="memberList.length > 1"
+      :members="memberList"
+      :selected-member-id="currentMemberId"
+      :show-overview="true"
+      :overview-active="currentView === 'family'"
+      @overview="switchView('family')"
+      @select="switchMember" />
 
     <!-- 模式切换(仅家庭模式展示) -->
     <view class="mode-bar" v-if="familyMode">
@@ -142,9 +132,12 @@
 <script>
 import config from '@/config.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
+import MemberSelector from '@/components/MemberSelector.vue'
+import { memberIdOf } from '@/utils/memberNormalize.js'
 
 export default {
   mixins: [fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       searchQuery: '',
@@ -249,8 +242,10 @@ export default {
         success: function(res) {
           if (res.data && res.data.code === 200 && res.data.data) {
             self.memberList = res.data.data
+            // 默认选中「个人」= isSelf 行(统一身份键 familyMemberId,不再用 userId)
             if (!self.currentMemberId && self.memberList.length > 0) {
-              self.currentMemberId = self.memberList[0].userId
+              var defaultMember = self.getSelfRow() || self.memberList[0]
+              self.currentMemberId = memberIdOf(defaultMember)
             }
           }
         }
@@ -400,8 +395,17 @@ export default {
     switchView: function(view) {
       this.currentView = view
     },
+    // 取「本人」行(isSelf):后端已按 user_id 折叠,此处仅取第一行 isSelf
+    getSelfRow: function() {
+      var members = this.memberList || []
+      for (var i = 0; i < members.length; i++) {
+        if (members[i] && members[i].isSelf) return members[i]
+      }
+      return null
+    },
     switchMember: function(member) {
-      this.currentMemberId = member.userId
+      // 统一身份键:familyMemberId(= family_members.id);userId 仅用于折叠,不再作主键
+      this.currentMemberId = memberIdOf(member)
       this.currentView = 'personal'
       this.loadFoods()
     },

+ 4 - 3
cfc-frontend/pages/home-pages/child-index.vue

@@ -64,7 +64,8 @@
   :sandboxData="sandboxData" />
 
 <!-- 家庭成员选择条 -->
-<FamilyMemberStrip
+<MemberSelector
+  variant="strip"
   v-if="hasChildren && familyMembersVisible.length > 0"
   :members="familyMembersVisible"
   :selectedMemberId="selectedMemberId"
@@ -329,7 +330,7 @@ import GateGuideBar from '@/pages/home-pages/components/gate-guide-bar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
 import ConfettiCelebration from '@/pages/home-pages/components/ConfettiCelebration.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 
 /**
  * 等级配置表
@@ -370,7 +371,7 @@ export default {
     OnboardingGuide,
     ConfigurableGuide,
     ConfettiCelebration,
-    FamilyMemberStrip,
+    MemberSelector,
     GateLock,
     GateGuideBar
   },

+ 4 - 3
cfc-frontend/pages/home-pages/member-home-detail.vue

@@ -22,7 +22,8 @@
       </view>
 
       <!-- 以该成员为中心的家庭成员关系条 -->
-      <FamilyMemberStrip
+      <MemberSelector
+        variant="strip"
         v-if="memberId"
         :center-member-id="memberId"
         :selected-member-id="memberId"
@@ -65,13 +66,13 @@
 import { BrandColors } from '../../utils/brand-colors.js'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import { getFamilyEnergySandbox, getChildren } from '../../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { FamilyRelationGraph, DimensionTasks, FamilyMemberStrip },
+  components: { FamilyRelationGraph, DimensionTasks, MemberSelector },
   data() {
     return {
       memberId: null,

+ 4 - 3
cfc-frontend/pages/home-pages/parent-index.vue

@@ -9,7 +9,8 @@
 <OnboardingProgressBar ref="onboardingProgress" />
 
 <!-- ===== 家庭成员选择条 ===== -->
-<FamilyMemberStrip
+<MemberSelector
+  variant="strip"
   v-if="isLoggedIn"
   :members="familyMembersVisible"
   :selectedMemberId="selectedMemberId"
@@ -508,7 +509,7 @@ import FamilyHealthRanking from '@/pages/home-pages/components/FamilyHealthRanki
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 import ContactImport from '@/pages/home-pages/components/ContactImport.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import MicroActionCard from '@/pages/home-pages/components/MicroActionCard.vue'
 import FeedbackToast from '@/pages/home-pages/components/FeedbackToast.vue'
 import MilestoneCelebration from '@/pages/home-pages/components/MilestoneCelebration.vue'
@@ -525,7 +526,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar, AdBanner },
+  components: { RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, MemberSelector, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar, AdBanner },
   data() {
     return {
       nickname: '',

+ 4 - 3
cfc-frontend/pages/mind-detail/index.vue

@@ -27,7 +27,8 @@
       :traditionalData="traditionalData" />
 
     <!-- 家庭成员选择条 -->
-    <DimensionMemberTabs
+    <MemberSelector
+      variant="tabs"
       v-if="currentView === 'self' && isLoggedIn"
       :members="familyMembersVisible"
       :selectedMemberId="selectedMemberId"
@@ -268,7 +269,7 @@ import PsychCrisisBanner from '@/pages/mind-detail/components/PsychCrisisBanner.
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyTianpanCard from '@/pages/mind-detail/components/FamilyTianpanCard.vue'
 import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
-import DimensionMemberTabs from '../../components/DimensionMemberTabs.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import { getVisibleSections, getEmiReport, getFamilyEnergySandbox, getEnergySandbox, getChildren, getFamilyMemberList, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune, getLatestIndicators, getRadarData, kickFamilyMemberById } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '../../config.js'
@@ -277,7 +278,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
+components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, MemberSelector, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
       gridColor: BrandColors.pink100,

+ 34 - 12
cfc-frontend/pages/tasks/tasks.vue

@@ -7,14 +7,16 @@
         <text class="account-name">{{ selfMember ? selfMember.nickname : '我' }}</text>
       </view>
       <view class="header-tip">可查看家人的任务,仅能完成自己的</view>
-      <scroll-view scroll-x class="member-scroll">
-        <view class="member-chips">
-          <view class="member-chip" :class="{ active: !selectedMemberId }" @click="selectMember(null)">本人</view>
-          <view class="member-chip" v-for="member in familyMembers" :key="member.id" :class="{ active: selectedMemberId && String(selectedMemberId) === String(member.id) }" @click="selectMember(member.id)">
-            {{ member.nickname }}
-          </view>
-        </view>
-      </scroll-view>
+      <!-- 成员选择(统一组件 MemberSelector:本人 + 各成员,同一用户只出现一次) -->
+      <MemberSelector
+        variant="minimal"
+        embedded
+        :members="familyMembers"
+        :selected-member-id="selectedMemberId"
+        :show-self="true"
+        self-label="本人"
+        :self-active="!selectedMemberId"
+        @select="onMemberSelect" />
     </view>
 
     <!-- Tab 切换 -->
@@ -250,9 +252,12 @@ import { getMyPendingTasks, acceptTask as acceptTaskApi, rejectTask as rejectTas
 } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
+import MemberSelector from '@/components/MemberSelector.vue'
+import { memberIdOf, normalizeMembers } from '@/utils/memberNormalize.js'
 
 export default {
   mixins: [fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       activeTab: 'today',
@@ -315,14 +320,25 @@ export default {
       var members = this.familyMembers || []
       for (var i = 0; i < members.length; i++) {
         if (members[i] && members[i].isSelf) {
-          var n = parseInt(members[i].id, 10)
-          this.selfMemberId = isNaN(n) ? members[i].id : n
+          // 统一身份键:familyMemberId(family_members.id),不再用 userId
+          this.selfMemberId = memberIdOf(members[i])
           return
         }
       }
       this.selfMemberId = null
     },
-    // 切换今日任务的查看成员:选中成员的任务列表(选中本人/不传 memberId 由后端按 JWT 兜底)
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect(member) {
+      if (!member) return
+      // 「本人」且来源为 users 表家长账号(无 family_members 行)时,memberId 不属于
+      // family_members 主键空间,传 null 让后端按 JWT currentMemberId 兜底(保持原行为)
+      if (member.isSelf && member.source === 'parent') {
+        this.selectMember(null)
+        return
+      }
+      this.selectMember(memberIdOf(member))
+    },
+    // 切换今日任务的查看成员:选中成员的任务列表(选中本人 → 传本人 canonical memberId)
     selectMember(memberId) {
       this.selectedMemberId = memberId || null
       this.activeTab = 'today'
@@ -353,8 +369,14 @@ export default {
       try {
         const membersRes = await getFamilyMemberList({ visibleOnly: true })
         var raw = (membersRes && membersRes.data) || []
-        this.familyMembers = Array.isArray(raw) ? raw : []
+        // 前端防御层:按 user_id 折叠同用户多行,保证「本人」只出现一次
+        var normalized = normalizeMembers(raw, { userId: this.currentUserId })
+        this.familyMembers = Array.isArray(normalized) ? normalized : []
         this.resolveSelfMemberId()
+        // 「本人」行默认激活(未选成员时由后端按 JWT currentMemberId 兜底)
+        if (!this.selectedMemberId && this.selfMemberId != null) {
+          this.selectedMemberId = this.selfMemberId
+        }
         this.loadTodayTasks()
         this.loadPendingTasks()
         this.loadCreatedTasks()

+ 4 - 3
cfc-frontend/pages/wealth/index.vue

@@ -55,7 +55,8 @@
 
     <template v-else>
 <!-- 家庭成员选择条 -->
-      <DimensionMemberTabs
+      <MemberSelector
+        variant="tabs"
         v-if="currentView === 'self' && isLoggedIn"
         :members="familyMembersVisible"
         :selectedMemberId="selectedMemberId"
@@ -276,7 +277,7 @@ import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import DimensionSubDims from './components/DimensionSubDims.vue'
 import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
-import DimensionMemberTabs from '../../components/DimensionMemberTabs.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import {
   getChildren,
   getWealthDetail,
@@ -299,7 +300,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, DimensionMemberTabs, FamilyEnergyBar, DimensionIntroCard, DimensionVisitorCard, DimensionSubDims, DimensionPageSkeleton },
+  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, MemberSelector, FamilyEnergyBar, DimensionIntroCard, DimensionVisitorCard, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
       wealthColor: BrandColors.wealth,

+ 4 - 3
cfc-frontend/pages/wisdom-detail/index.vue

@@ -34,7 +34,8 @@
     </view>
 
     <!-- 家庭成员选择条 -->
-    <DimensionMemberTabs
+    <MemberSelector
+      variant="tabs"
       v-if="currentView === 'self' && isLoggedIn"
       :members="familyMembersVisible"
       :selectedMemberId="selectedMemberId"
@@ -248,14 +249,14 @@ import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import DimensionPageSkeleton from '../../components/DimensionPageSkeleton.vue'
-import DimensionMemberTabs from '../../components/DimensionMemberTabs.vue'
+import MemberSelector from '@/components/MemberSelector.vue'
 import { getVisibleSections, getChildren, getFamilyEnergySandbox, getEnergySandbox, getAssessmentLatestResult, getDanReportByChild, getGutCognitionIndicators, getLatestIndicators, getRadarData, getFamilyMemberList, kickFamilyMemberById } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { TabTransition, RadarChart, FamilyEnergyBar, FloatingAvatar, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
+  components: { TabTransition, RadarChart, FamilyEnergyBar, FloatingAvatar, MemberSelector, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
       gridColor: BrandColors.indigo100,

+ 219 - 0
cfc-frontend/utils/memberNormalize.js

@@ -0,0 +1,219 @@
+/**
+ * 家庭成员去重规范化工具(T25 统一成员选择器 · 层3 前端防御)
+ *
+ * 背景:
+ *   family_members 存量数据中同一 (user_id, family_id) 可能存在多条重复行,
+ *   导致同一用户在前端被渲染成两项("本人" + "本人所对应的成员")。
+ *
+ * 重要边界(见设计文档 §一.3):
+ *   本工具只是**前端防御层**。重复的 family_members 行各自挂着不同的外键数据
+ *   (任务/打卡/健康等),单纯折叠会"隐藏"其中一行的数据。
+ *   真正的根治是数据层合并(T24-C),需单独授权执行。
+ *   后端 FamilyMemberService.listMembers 已按 user_id 做确定性折叠,
+ *   本工具用于兜住仍返回重复行的历史接口/前端缓存数据。
+ *
+ * 折叠规则:
+ *   1. 按 user_id 分组折叠(userId 为空的"不能登录用户"各自独立成组)
+ *   2. canonical 行优先级:isSelf 行 → status='active' → 外键数据最多 → id 最小
+ *   3. 每个真实用户至多输出一行,且全列表 isSelf 唯一
+ *   4. 统一身份键为 familyMemberId(family_members.id),userId 仅作折叠依据
+ */
+
+/**
+ * 取成员的统一身份键(familyMemberId)。
+ * 后端 FamilyMemberVO 已下发 familyMemberId;历史接口缺失时回退到 id。
+ */
+export function memberIdOf(member) {
+  if (!member) return null
+  if (member.familyMemberId !== null && member.familyMemberId !== undefined && member.familyMemberId !== '') {
+    return member.familyMemberId
+  }
+  if (member.id !== null && member.id !== undefined && member.id !== '') {
+    return member.id
+  }
+  return null
+}
+
+/** 成员是否为有效状态(active 优先;未带 status 字段的历史数据视为 active) */
+function isActive(member) {
+  if (!member || member.status === null || member.status === undefined || member.status === '') {
+    return true
+  }
+  return member.status === 'active'
+}
+
+/**
+ * 成员"数据量"权重(用于 canonical 选取的第三级 tie-break)。
+ * 后端若下发 fkCount(外键引用数)则直接采用;否则用可见的数据丰度做近似。
+ */
+function dataWeight(member) {
+  if (!member) return 0
+  if (typeof member.fkCount === 'number' && !isNaN(member.fkCount)) return member.fkCount
+  if (typeof member.refCount === 'number' && !isNaN(member.refCount)) return member.refCount
+  var n = 0
+  if (member.totalPoints) n += 1
+  if (member.streakDays) n += 1
+  if (member.lastInteractionAt) n += 1
+  if (member.trustScore !== null && member.trustScore !== undefined) n += 1
+  if (member.intimacyScore !== null && member.intimacyScore !== undefined) n += 1
+  if (member.communicationScore !== null && member.communicationScore !== undefined) n += 1
+  return n
+}
+
+function numericId(member) {
+  var id = memberIdOf(member)
+  var n = Number(id)
+  return isNaN(n) ? Number.MAX_SAFE_INTEGER : n
+}
+
+/**
+ * canonical 行比较器:返回 <0 表示 a 更优先。
+ * 优先级:isSelf → status='active' → 数据量最多 → id 最小。
+ */
+function compareCanonical(a, b) {
+  var aSelf = a && a.isSelf ? 1 : 0
+  var bSelf = b && b.isSelf ? 1 : 0
+  if (aSelf !== bSelf) return bSelf - aSelf
+
+  var aActive = isActive(a) ? 1 : 0
+  var bActive = isActive(b) ? 1 : 0
+  if (aActive !== bActive) return bActive - aActive
+
+  var aWeight = dataWeight(a)
+  var bWeight = dataWeight(b)
+  if (aWeight !== bWeight) return bWeight - aWeight
+
+  return numericId(a) - numericId(b)
+}
+
+function hasLoginUser(member) {
+  return !!member && member.userId !== null && member.userId !== undefined && member.userId !== '' && Number(member.userId) !== 0
+}
+
+/**
+ * 规范化成员列表。
+ *
+ * @param {Array} rawMembers 原始成员数组(后端 VO 或其变体)
+ * @param {Object} [viewer]  当前查看者,形如 { userId, memberId }(可只给其一)
+ * @returns {Array} 每个真实用户至多一条、isSelf 唯一的成员数组
+ */
+export function normalizeMembers(rawMembers, viewer) {
+  var list = rawMembers instanceof Array ? rawMembers : []
+  if (list.length === 0) return []
+
+  var viewerUserId = viewer && viewer.userId !== null && viewer.userId !== undefined && viewer.userId !== ''
+    ? String(viewer.userId) : ''
+  var viewerMemberId = viewer && viewer.memberId !== null && viewer.memberId !== undefined && viewer.memberId !== ''
+    ? String(viewer.memberId) : ''
+
+  // 1) 按 user_id 分组(保留首次出现顺序)
+  var groups = []
+  var groupIndex = {}
+  for (var i = 0; i < list.length; i++) {
+    var row = list[i]
+    if (!row || typeof row !== 'object') continue
+    var key
+    if (hasLoginUser(row)) {
+      key = 'u:' + String(row.userId)
+    } else {
+      // 不能登录用户(无 userId):每行独立,按自身 id 去重
+      key = 'm:' + String(row.id !== undefined && row.id !== null ? row.id : ('idx:' + i))
+    }
+    if (groupIndex[key] === undefined) {
+      groupIndex[key] = groups.length
+      groups.push([])
+    }
+    groups[groupIndex[key]].push(row)
+  }
+
+  // 2) 每组选 canonical 行(shallow-clone 输出,避免污染父组件共享的成员数组)
+  var result = []
+  for (var g = 0; g < groups.length; g++) {
+    var group = groups[g]
+    var winner = group[0]
+    if (group.length > 1) {
+      for (var k = 1; k < group.length; k++) {
+        if (compareCanonical(group[k], winner) < 0) {
+          winner = group[k]
+        }
+      }
+    }
+    var copy = {}
+    for (var key in winner) {
+      if (Object.prototype.hasOwnProperty.call(winner, key)) {
+        copy[key] = winner[key]
+      }
+    }
+    result.push(copy)
+  }
+
+  // 3) isSelf 唯一化:优先认 viewer,其次保留后端标记的那一条
+  var selfIndex = -1
+  if (viewerMemberId) {
+    for (var j = 0; j < result.length; j++) {
+      if (String(memberIdOf(result[j])) === viewerMemberId) {
+        selfIndex = j
+        break
+      }
+    }
+  }
+  if (selfIndex < 0 && viewerUserId) {
+    for (var j2 = 0; j2 < result.length; j2++) {
+      if (hasLoginUser(result[j2]) && String(result[j2].userId) === viewerUserId) {
+        selfIndex = j2
+        break
+      }
+    }
+  }
+  if (selfIndex < 0) {
+    for (var j3 = 0; j3 < result.length; j3++) {
+      if (result[j3] && result[j3].isSelf) {
+        selfIndex = j3
+        break
+      }
+    }
+  }
+  for (var j4 = 0; j4 < result.length; j4++) {
+    result[j4].isSelf = (j4 === selfIndex)
+  }
+
+  // 4) 补齐统一身份键 + 渲染用唯一 key
+  var usedKeys = {}
+  for (var j5 = 0; j5 < result.length; j5++) {
+    var m = result[j5]
+    var mid = memberIdOf(m)
+    m.familyMemberId = mid
+    m.memberId = mid
+    var base = (m.source === 'parent' ? 'parent:' : 'member:') + String(mid)
+    if (usedKeys[base]) {
+      var n = 2
+      while (usedKeys[base + '#' + n]) n++
+      base = base + '#' + n
+    }
+    usedKeys[base] = true
+    m.selectorKey = base
+  }
+
+  return result
+}
+
+/** 从(已规范化或未规范化的)成员列表中找出"本人"一行 */
+export function findSelfMember(members) {
+  if (!(members instanceof Array)) return null
+  for (var i = 0; i < members.length; i++) {
+    if (members[i] && members[i].isSelf) return members[i]
+  }
+  return null
+}
+
+/** 从成员列表中排除"本人"行(用于"个人 + 各成员"形态,避免同一人出现两项) */
+export function membersExceptSelf(members) {
+  if (!(members instanceof Array)) return []
+  var out = []
+  for (var i = 0; i < members.length; i++) {
+    if (members[i] && !members[i].isSelf) out.push(members[i])
+  }
+  return out
+}
+
+export default normalizeMembers