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

Merge remote-tracking branch 'origin/feature/unified-member-selector' into HEAD

zxyj-web 2 недель назад
Родитель
Сommit
63fe069f10

+ 4 - 2
cfc-frontend/components/MemberSelector.vue

@@ -320,9 +320,11 @@ export default {
       }
       return out
     },
-    // minimal 变体:其余成员(不含本人行,同一用户至多出现一次)
+    // minimal 变体:各成员 chip 列表。
+    // showSelf=true 时排除本人行(本人已由「个人」项承载,避免同一人出现两次);
+    // showSelf=false 时保留全部成员(含本人,按昵称展示,保持原页面语义)。
     otherMembers: function() {
-      return membersExceptSelf(this.decoratedMembers)
+      return this.showSelf ? membersExceptSelf(this.decoratedMembers) : this.decoratedMembers
     },
     // minimal 变体:「个人」项选中态(selfActive 兜底「未显式选择即视为本人」)
     selfChipActive: function() {

+ 9 - 17
cfc-frontend/pages/action-detail/interaction-log.vue

@@ -9,8 +9,8 @@
     <view class="form-section" v-if="!preselectedMemberId">
       <text class="section-label">互动对象</text>
       <view class="member-select">
-        <view class="member-item" v-for="m in members" :key="m.id"
-              :class="{selected: selectedMemberId === m.id}"
+        <view class="member-item" v-for="m in members" :key="m.selectorKey"
+              :class="{selected: selectedMemberId === m.memberId}"
               @click="selectMember(m)">
           <text class="member-name">{{ m.nickname }}</text>
           <text class="member-rel">{{ m.relationship || m.relativeLabel || m.roleLabel }}</text>
@@ -125,6 +125,7 @@
 </template>
 
 <script>
+import { normalizeMembers, memberIdOf, findSelfMember, membersExceptSelf } from '../../utils/memberNormalize.js'
 import { getFamilyMemberList, addInteraction, reportAppEnter
 } from '../../utils/api.js'
 import config from '../../config.js'
@@ -193,28 +194,19 @@ export default {
       try {
         var res = await getFamilyMemberList()
         if (res.code === 200 && res.data) {
-          var all = res.data || []
-          // 使用后端返回的 isSelf 标记(比匹配 userId 更可靠)
-          var found = null
-          for (var i = 0; i < all.length; i++) {
-            var m = all[i]
-            if (m && m.isSelf === true) {
-              found = m
-              break
-            }
-          }
-          this.currentMemberId = found ? found.id : null
+          // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+          var list = normalizeMembers(res.data || [], { userId: uni.getStorageSync('userId') })
+          var selfRow = findSelfMember(list)
+          this.currentMemberId = selfRow ? memberIdOf(selfRow) : null
           // 互动对象列表过滤掉"我"(from==to 后端会拒绝)
-          this.members = all.filter(function(m) {
-            return !(m && m.isSelf === true)
-          })
+          this.members = membersExceptSelf(list)
         }
       } catch (e) {
         uni.showToast({ title: '加载成员失败', icon: 'none' })
       }
     },
     selectMember(member) {
-      this.selectedMemberId = member.id
+      this.selectedMemberId = memberIdOf(member)
     },
     selectType(type) {
       this.selectedType = type

+ 1 - 1
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -731,7 +731,7 @@ export default {
                 name: m.nickname || m.name || '',
                 phone: m.phone || '',
                 relation: '孩子',
-                memberId: m.userId || m.id,
+                memberId: m.id,
                 source: 'child',
                 targetType: 'member'
               })

+ 28 - 37
cfc-frontend/pages/cognitive-hall.vue

@@ -7,17 +7,13 @@
 
     <view class="member-selector" v-if="members.length > 1">
       <text class="selector-label">选择测评对象:</text>
-      <view class="member-tabs">
-        <view
-          class="member-tab"
-          :class="{ 'member-tab-active': selectedMemberId === m.id }"
-          v-for="m in members"
-          :key="m.id"
-          @click="selectMember(m.id)"
-        >
-          <text>{{ m.nickname }}</text>
-        </view>
-      </view>
+      <MemberSelector
+        variant="minimal"
+        embedded
+        :members="members"
+        :selected-member-id="selectedMemberId"
+        :show-self="false"
+        @select="onMemberSelect" />
     </view>
 
     <view class="loading" v-if="loading">
@@ -57,12 +53,15 @@
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { normalizeMembers, memberIdOf, resolveMemberId } from '@/utils/memberNormalize.js'
 import { getCognitiveChildScores } from '../utils/api.js'
 import { getFamilyMemberList } from '../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       memberId: '',
@@ -108,10 +107,18 @@ export default {
       getFamilyMemberList({ visibleOnly: true })
         .then(function(res) {
           if (res && res.code === 200 && res.data) {
-            self.members = res.data
-            if (!self.memberId && res.data.length > 0) {
-              self.memberId = res.data[0].id
-              self.selectedMemberId = res.data[0].id
+            // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一
+            self.members = normalizeMembers(res.data, { userId: uni.getStorageSync('userId') })
+            // 历史标识(users.id / 遗留 children.id)归一到统一身份键 memberId
+            var mid = resolveMemberId(self.members, self.memberId) || self.memberId
+            if (mid && String(mid) !== String(self.memberId)) {
+              self.memberId = mid
+              self.selectedMemberId = mid
+              self.loadScores()
+            } else if (!self.memberId && self.members.length > 0) {
+              var first = memberIdOf(self.members[0])
+              self.memberId = first
+              self.selectedMemberId = first
               self.loadScores()
             }
           }
@@ -120,8 +127,11 @@ export default {
           // 静默失败
         })
     },
-    selectMember: function(id) {
-      this.selectedMemberId = id
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect: function(member) {
+      var mid = memberIdOf(member)
+      this.memberId = mid
+      this.selectedMemberId = mid
       this.loadScores()
     },
     loadScores: function() {
@@ -194,6 +204,7 @@ $c-ink-night:    $app-ink-night;   // 深夜蓝黑(本页深色文字)
   padding: 24rpx 30rpx;
   background: $app-surface;
   margin: 20rpx 0;
+.member-selector .member-minimal { margin-top: 16rpx; }
   border-radius: 16rpx;
   margin-left: 30rpx;
   margin-right: 30rpx;
@@ -204,26 +215,6 @@ $c-ink-night:    $app-ink-night;   // 深夜蓝黑(本页深色文字)
   color: $facade-text-body;
 }
 
-.member-tabs {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: wrap;
-  margin-top: 16rpx;
-}
-
-.member-tab {
-  padding: 10rpx 28rpx;
-  border-radius: 30rpx;
-  margin: 8rpx 8rpx 0 0;
-  background: $c-border-hair;
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-}
-
-.member-tab-active {
-  background: $dim-wisdom;
-  color: $app-surface;
-}
 
 .game-list {
   padding: 0 30rpx;

+ 26 - 21
cfc-frontend/pages/diet/records.vue

@@ -1,12 +1,17 @@
 <template>
   <view class="page" :style="{ '--font-scale': fontScale }">
-    <view class="member-bar" v-if="memberList.length > 1">
-      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
-        v-for="(item, mi) in memberList" :key="mi"
-        @tap="switchMember(item)">
-        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
-      </view>
-    </view>
+    <!-- 成员切换条(统一组件 MemberSelector:同一用户只出现一次,主键为 canonical memberId) -->
+    <MemberSelector
+      variant="minimal"
+      v-if="memberList.length > 0"
+      :members="memberList"
+      :selected-member-id="currentMemberId"
+      :show-overview="true"
+      overview-label="全部"
+      :overview-active="!currentMemberId || currentMemberId < 0"
+      :show-self="false"
+      @overview="selectAllMembers"
+      @select="onMemberSelect" />
 
     <!-- 周历 -->
     <view class="week-strip">
@@ -175,6 +180,8 @@
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { memberIdOf } from '@/utils/memberNormalize.js'
 import { getMealList, getMealDaily, getMealWeekly, getMealAlerts, getCautionFamilyMembers, createMealRecord } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import shareMixin from '../../components/share-mixin.js'
@@ -182,6 +189,7 @@ import fontScaleMixin from '../../mixins/font-scale.js'
 
 export default {
   mixins: [shareMixin, fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       memberId: null,
@@ -213,8 +221,16 @@ export default {
       var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
       return m[t] || t || '饮食'
     },
-    switchMember: function(item) {
-      this.currentMemberId = item.id || item.userId
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect: function(member) {
+      this.currentMemberId = memberIdOf(member)
+      this.loadWeekly()
+      this.loadAlerts()
+      this.loadDayRecords()
+    },
+    // 「全部」聚合项:不传 memberId,后端按全家庭返回
+    selectAllMembers: function() {
+      this.currentMemberId = -1
       this.loadWeekly()
       this.loadAlerts()
       this.loadDayRecords()
@@ -223,8 +239,7 @@ export default {
       try {
         var res = await getCautionFamilyMembers()
         if (res && res.code === 200 && res.data && res.data.length > 0) {
-          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
-          this.memberList = [allItem].concat(res.data)
+          this.memberList = res.data
           if (!this.currentMemberId) this.currentMemberId = -1
         }
       } catch(e) {}
@@ -377,16 +392,6 @@ export default {
 <style scoped lang="scss">
 .page { min-height: 100vh; background: $uni-primary-pale; display: flex; flex-direction: column; }
 
-/* 成员切换栏 */
-.member-bar {
-  display: flex; overflow-x: auto; padding: 16rpx 30rpx 8rpx; background: $app-surface;
-  border-bottom: 1rpx solid $facade-fill; gap: 16rpx; -webkit-overflow-scrolling: touch;
-}
-.member-tab {
-  flex-shrink: 0; padding: 8rpx 20rpx; border-radius: 20rpx; background: $facade-fill-2; color: $facade-text-body;
-  font-size: 26rpx; font-weight: 500;
-}
-.member-tab.active { background: linear-gradient(135deg, $dim-body, $dim-body-light); color: $app-surface; }
 
 /* 周历条 */
 .week-strip {

+ 3 - 1
cfc-frontend/pages/energy/index.vue

@@ -142,6 +142,7 @@
 <script>
 import { primary, success } from '@/utils/brand-colors.js'
 import RadarChart from '../../components/RadarChart.vue'
+import { normalizeMembers } from '../../utils/memberNormalize.js'
 import { getFamilyEnergySandbox, getFamilyMemberList } from '../../utils/api.js'
 import { BrandColors } from '../../utils/brand-colors.js'
 
@@ -324,7 +325,8 @@ export default {
         // 加载家庭成员(visibleOnly 列表,用于 isSelf/name 等展示属性)
         var memRes = await getFamilyMemberList({ visibleOnly: true })
         if (memRes && memRes.code === 200 && memRes.data) {
-          this.familyMembers = memRes.data
+          // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+          this.familyMembers = normalizeMembers(memRes.data, { userId: uni.getStorageSync('userId') })
         }
         // 确定默认选中成员:优先全局切换的成员
         var switchedId = uni.getStorageSync('currentMemberId') || uni.getStorageSync('currentChildId')

+ 24 - 34
cfc-frontend/pages/growth/caution-detail.vue

@@ -6,13 +6,18 @@
       <text class="banner-text">以下食材推荐指数低于 0,建议谨慎食用或避免</text>
     </view>
 
-    <view class="member-bar" v-if="memberList.length > 1">
-      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
-        v-for="(item, mi) in memberList" :key="mi"
-        @tap="switchMember(item)">
-        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
-      </view>
-    </view>
+    <!-- 成员切换条(统一组件 MemberSelector:同一用户只出现一次,主键为 canonical memberId) -->
+    <MemberSelector
+      variant="minimal"
+      v-if="memberList.length > 0"
+      :members="memberList"
+      :selected-member-id="currentMemberId"
+      :show-overview="true"
+      overview-label="全部"
+      :overview-active="!currentMemberId || currentMemberId < 0"
+      :show-self="false"
+      @overview="selectAllMembers"
+      @select="onMemberSelect" />
 
     <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
       <view class="food-card" v-for="(item, idx) in foods" :key="idx">
@@ -62,13 +67,15 @@
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { memberIdOf } from '@/utils/memberNormalize.js'
 import { getFoodCautionList, getFoodCautionTrend, getCautionFamilyMembers } from '../../utils/api.js'
 import MiniTrend from './mini-trend.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { MiniTrend },
+  components: { MiniTrend, MemberSelector },
   data() {
     return {
       foods: [],
@@ -94,16 +101,21 @@ export default {
     goBack: function() {
       uni.navigateBack()
     },
-    switchMember: function(item) {
-      this.currentMemberId = item.id || item.userId
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect: function(member) {
+      this.currentMemberId = memberIdOf(member)
+      this.loadFoods()
+    },
+    // 「全部」聚合项:不传 memberId,后端按全家庭返回
+    selectAllMembers: function() {
+      this.currentMemberId = -1
       this.loadFoods()
     },
     async loadMemberList() {
       try {
         var res = await getCautionFamilyMembers()
         if (res && res.code === 200 && res.data && res.data.length > 0) {
-          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
-          this.memberList = [allItem].concat(res.data)
+          this.memberList = res.data
           if (!this.currentMemberId) {
             this.currentMemberId = -1
           }
@@ -257,28 +269,6 @@ export default {
 .banner-icon { font-size: 32rpx; }
 .banner-text { font-size: calc(24rpx * var(--font-scale, 1)); color: #856404; flex: 1; }
 
-.member-bar {
-  display: flex;
-  overflow-x: auto;
-  padding: 16rpx 30rpx 8rpx;
-  background: $app-surface;
-  border-bottom: 1rpx solid $facade-fill;
-  gap: 16rpx;
-  -webkit-overflow-scrolling: touch;
-}
-.member-tab {
-  flex-shrink: 0;
-  padding: 8rpx 20rpx;
-  border-radius: 20rpx;
-  background: $facade-fill-2;
-  color: $facade-text-body;
-  font-size: calc(26rpx * var(--font-scale, 1));
-  font-weight: 500;
-}
-.member-tab.active {
-  background: linear-gradient(135deg, $status-danger, $status-danger-deep);
-  color: $app-surface;
-}
 
 .content {
   padding: 20rpx 30rpx;

+ 4 - 1
cfc-frontend/pages/health/components/MemberChipPicker.vue

@@ -26,6 +26,7 @@
 
 <script>
 import { getFamilyMemberList } from '@/utils/api.js'
+import { normalizeMembers } from '@/utils/memberNormalize.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -175,7 +176,9 @@ export default {
           })
         })
       }).then(function(list) {
-        var count = self.applyList(list || [])
+        // 前端防御层:按 user_id 折叠同用户多行(消除"本人 + 本人所对应的成员"双 chip)
+        var normalized = normalizeMembers(list || [], { userId: uni.getStorageSync('userId') })
+        var count = self.applyList(normalized)
         if (count === 0) {
           uni.showToast({ title: '成员列表为空', icon: 'none' })
         }

+ 6 - 4
cfc-frontend/pages/health/doa-stage-create.vue

@@ -6,9 +6,9 @@
       <view class="member-grid">
         <view
           v-for="m in members"
-          :key="m.id"
+          :key="m.selectorKey"
           class="member-item"
-          :class="{ active: form.memberId === m.id }"
+          :class="{ active: form.memberId === m.memberId }"
           @click="selectMember(m)"
         >
           <text>{{ m.nickname }}</text>
@@ -60,6 +60,7 @@
 </template>
 
 <script>
+import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { createDoaStage, getFamilyMemberList } from '../../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
@@ -107,7 +108,8 @@ export default {
     async loadMembers() {
       try {
         var res = await getFamilyMemberList({ visibleOnly: true })
-        var list = res.data || []
+        // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+        var list = normalizeMembers(res.data || [], { userId: uni.getStorageSync('userId') })
         this.members = list
         this.hasLocalMembers = list.some(function(m) { return !m.userId })
         // 若从总览"为他创建"进入且目标成员未登录 → 直接锁定
@@ -120,7 +122,7 @@ export default {
       }
     },
     selectMember(m) {
-      this.form.memberId = m.id
+      this.form.memberId = memberIdOf(m)
     },
     addGoal() {
       if (this.form.goals.length >= 3) return

+ 11 - 6
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -276,7 +276,7 @@
             class="candidate-item"
             v-for="(cand, idx) in candidates"
             :key="idx"
-            :class="{ 'candidate-selected': selectedMemberId === (cand.id || cand.userId) }"
+            :class="{ 'candidate-selected': selectedMemberId === cand._mid }"
             @tap="selectAndCloseDraft(cand)"
           >
             <view class="candidate-left">
@@ -295,7 +295,7 @@
             class="member-item"
             v-for="(member, mIdx) in familyMembers"
             :key="mIdx"
-            :class="{ 'member-selected': selectedMemberId === (member.userId || member.id) }"
+            :class="{ 'member-selected': selectedMemberId === member._mid }"
             @tap="selectAndCloseDraft(member)"
           >
             <text class="member-avatar">{{ (member.nickname || '?').substring(0, 1) }}</text>
@@ -354,6 +354,7 @@
 </template>
 
 <script>
+import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember, getIndicatorChangeRanking } from '../../utils/api.js'
 import HealthKnowledgePopup from '@/pages/health/components/health-knowledge-popup.vue'
 import { BrandColors } from '../../utils/brand-colors.js'
@@ -552,12 +553,15 @@ riskGroupAbnormalCount: function() {
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res.code === 200) {
-          self.familyMembers = res.data || []
+          // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+          var list = normalizeMembers(res.data || [], { userId: uni.getStorageSync('userId') })
+          for (var i = 0; i < list.length; i++) list[i]._mid = memberIdOf(list[i])
+          self.familyMembers = list
         }
       }).catch(function() {})
     },
     selectAndCloseDraft: function(member) {
-      this.selectedMemberId = member.id || member.userId
+      this.selectedMemberId = member._mid != null ? member._mid : memberIdOf(member)
       this.selectedMemberObj = member
       this.createdMemberId = null
       this.needBind = false
@@ -590,7 +594,7 @@ riskGroupAbnormalCount: function() {
         self.creatingMember = false
         if (res.code === 200) {
           var member = res.data || {}
-          self.selectedMemberId = member.id || member.userId
+          self.selectedMemberId = memberIdOf(member)
           self.selectedMemberObj = member
           self.createdMemberId = self.selectedMemberId
           self.needBind = false
@@ -699,12 +703,13 @@ riskGroupAbnormalCount: function() {
         self.matchConfidence = res.data.confidence !== undefined ? parseFloat(res.data.confidence) : null
         self.matchLabel = res.data.matchLabel || ''
         self.candidates = res.data.candidates || []
+        for (var ci = 0; ci < self.candidates.length; ci++) self.candidates[ci]._mid = memberIdOf(self.candidates[ci])
         // 高置信度自动选中
         if (self.matchedMemberId && self.matchConfidence >= 0.8) {
           self.selectedMemberId = self.matchedMemberId
           // 在 candidates 中找对应对象
           for (var i = 0; i < self.candidates.length; i++) {
-            if ((self.candidates[i].id || self.candidates[i].userId) === self.selectedMemberId) {
+            if (self.candidates[i]._mid === self.selectedMemberId) {
               self.selectedMemberObj = self.candidates[i]
               break
             }

+ 24 - 35
cfc-frontend/pages/health/gut-flora-foods-detail.vue

@@ -1,13 +1,18 @@
 <template>
   <view class="page" :style="{ '--font-scale': fontScale }">
 
-    <view class="member-bar" v-if="memberList.length > 1">
-      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
-        v-for="(item, mi) in memberList" :key="mi"
-        @tap="switchMember(item)">
-        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
-      </view>
-    </view>
+    <!-- 成员切换条(统一组件 MemberSelector:同一用户只出现一次,主键为 canonical memberId) -->
+    <MemberSelector
+      variant="minimal"
+      v-if="memberList.length > 0"
+      :members="memberList"
+      :selected-member-id="currentMemberId"
+      :show-overview="true"
+      overview-label="全部"
+      :overview-active="!currentMemberId || currentMemberId < 0"
+      :show-self="false"
+      @overview="selectAllMembers"
+      @select="onMemberSelect" />
 
     <view class="search-bar">
       <view class="search-input-wrap">
@@ -134,13 +139,15 @@
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { memberIdOf } from '@/utils/memberNormalize.js'
 import { getHealthFoods, editHealthReport, queryFoodKnowledge, getCautionFamilyMembers, getFoodCautionTrend } from '../../utils/api.js'
 import MiniTrend from '../growth/mini-trend.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { MiniTrend },
+  components: { MiniTrend, MemberSelector },
   data() {
     return {
       reportId: null,
@@ -204,16 +211,21 @@ export default {
   },
 methods: {
     goBack: function() { uni.navigateBack() },
-    switchMember: function(item) {
-      this.currentMemberId = item.id || item.userId
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect: function(member) {
+      this.currentMemberId = memberIdOf(member)
+      this.loadData()
+    },
+    // 「全部」聚合项:不传 memberId,后端按全家庭返回
+    selectAllMembers: function() {
+      this.currentMemberId = -1
       this.loadData()
     },
     async loadMemberList() {
       try {
         var res = await getCautionFamilyMembers()
         if (res && res.code === 200 && res.data && res.data.length > 0) {
-          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
-          this.memberList = [allItem].concat(res.data)
+          this.memberList = res.data
           if (!this.currentMemberId) this.currentMemberId = -1
         }
       } catch(e) {}
@@ -384,29 +396,6 @@ methods: {
 <style scoped lang="scss">
 .page { min-height: 100vh; background: $uni-primary-pale; }
 
-/* 成员切换栏 */
-.member-bar {
-  display: flex;
-  overflow-x: auto;
-  padding: 16rpx 30rpx 8rpx;
-  background: $app-surface;
-  border-bottom: 1rpx solid $facade-fill;
-  gap: 16rpx;
-  -webkit-overflow-scrolling: touch;
-}
-.member-tab {
-  flex-shrink: 0;
-  padding: 8rpx 20rpx;
-  border-radius: 20rpx;
-  background: $facade-fill-2;
-  color: $facade-text-body;
-  font-size: calc(26rpx * var(--font-scale, 1));
-  font-weight: 500;
-}
-.member-tab.active {
-  background: linear-gradient(135deg, $dim-body, $dim-body-light);
-  color: $app-surface;
-}
 
 /* 搜索框 */
 .search-bar { padding: 16rpx 30rpx 8rpx; background: $app-surface; }

+ 16 - 11
cfc-frontend/pages/health/report-confirm.vue

@@ -43,7 +43,7 @@
               class="candidate-item"
               v-for="(cand, idx) in candidates"
               :key="idx"
-              :class="{ 'candidate-selected': selectedMemberId === (cand.id || cand.userId) }"
+              :class="{ 'candidate-selected': selectedMemberId === cand._mid }"
               @click="selectAndClose(cand)"
             >
               <view class="candidate-left">
@@ -70,7 +70,7 @@
               class="member-item"
               v-for="(member, mIdx) in otherMembers"
               :key="mIdx"
-              :class="{ 'member-selected': selectedMemberId === (member.userId || member.id) }"
+              :class="{ 'member-selected': selectedMemberId === member._mid }"
               @click="selectAndClose(member)"
             >
               <text class="member-avatar">{{ (member.nickname || '?').substring(0, 1) }}</text>
@@ -356,6 +356,7 @@
 </template>
 
 <script>
+import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, parseReportDraft } from '../../utils/api.js'
 import { addFamilyMember, getFamilyMemberList } from '../../utils/api.js'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
@@ -461,14 +462,13 @@ export default {
     otherMembers() {
       var candIds = {}
       for (var i = 0; i < this.candidates.length; i++) {
-        var c = this.candidates[i]
-        var cid = c.id || c.userId
+        var cid = this.candidates[i]._mid
         if (cid !== null && cid !== undefined) candIds[cid] = true
       }
       var list = []
       for (var j = 0; j < this.familyMembers.length; j++) {
         var m = this.familyMembers[j]
-        var mid = m.userId || m.id
+        var mid = m._mid
         if (mid !== null && mid !== undefined && candIds[mid]) continue
         list.push(m)
       }
@@ -575,6 +575,7 @@ export default {
     if (candidatesStr) {
       try {
         this.candidates = JSON.parse(candidatesStr)
+        for (var ci = 0; ci < this.candidates.length; ci++) this.candidates[ci]._mid = memberIdOf(this.candidates[ci])
       } catch (e) {
         this.candidates = []
       }
@@ -644,6 +645,7 @@ export default {
       this.matchConfidence = detail.confidence !== undefined && detail.confidence !== null ? parseFloat(detail.confidence) : null
       this.matchLabel = detail.matchLabel || ''
       this.candidates = detail.candidates || []
+      for (var ci = 0; ci < this.candidates.length; ci++) this.candidates[ci]._mid = memberIdOf(this.candidates[ci])
 
       // 始终加载家庭成员
       this.loadFamilyMembers()
@@ -659,12 +661,14 @@ export default {
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         var list = (res && res.data) || []
         var parents = list.filter(function(m) { return m.source === 'parent' }).map(function(p) {
-          return { id: p.id, nickname: p.nickname, gender: p.gender, role: 'parent' }
+          return { id: p.id, userId: p.userId, nickname: p.nickname, gender: p.gender, role: 'parent' }
         })
         var children = list.filter(function(m) { return m.source !== 'parent' }).map(function(c) {
-          return { id: c.id, nickname: c.nickname, gender: c.gender, age: c.age, role: 'child' }
+          return { id: c.id, userId: c.userId, nickname: c.nickname, gender: c.gender, age: c.age, role: 'child' }
         })
-        self.familyMembers = parents.concat(children)
+        // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+        self.familyMembers = normalizeMembers(parents.concat(children), { userId: uni.getStorageSync('userId') })
+        for (var i = 0; i < self.familyMembers.length; i++) self.familyMembers[i]._mid = memberIdOf(self.familyMembers[i])
         self.syncSelectedMemberObj()
       }).catch(function(e) {
         console.log('加载家庭成员失败', e)
@@ -678,7 +682,7 @@ export default {
       }
     },
     selectAndClose(member) {
-      var memberId = member.id || member.userId
+      var memberId = member._mid != null ? member._mid : memberIdOf(member)
       this.selectedMemberId = memberId
       this.selectedMemberObj = member
       this.extractedName = member.nickname || member.name || this.extractedName
@@ -695,7 +699,8 @@ export default {
       var all = this.familyMembers.concat(this.candidates)
       for (var i = 0; i < all.length; i++) {
         var m = all[i]
-        if ((m.id || m.userId) === this.selectedMemberId) {
+        var mid = m._mid != null ? m._mid : memberIdOf(m)
+        if (mid === this.selectedMemberId) {
           this.selectedMemberObj = m
           return
         }
@@ -732,7 +737,7 @@ export default {
       }).then(function(res) {
         uni.hideLoading()
         if (res.code === 200) {
-          var memberId = res.data && (res.data.userId || res.data.id)
+          var memberId = res.data && memberIdOf(res.data)
           if (memberId) {
             self.createdMemberId = memberId
             self.selectedMemberId = memberId

+ 14 - 10
cfc-frontend/pages/health/report-detail.vue

@@ -67,7 +67,7 @@
                   class="candidate-item"
                   v-for="(cand, idx) in candidates"
                   :key="idx"
-                  :class="{ 'candidate-selected': selectedMemberId === (cand.id || cand.userId) }"
+                  :class="{ 'candidate-selected': selectedMemberId === cand._mid }"
                   @click="selectAndClose(cand)"
                 >
                   <view class="candidate-left">
@@ -94,7 +94,7 @@
                   class="member-item"
                   v-for="(member, mIdx) in otherMembers"
                   :key="mIdx"
-                  :class="{ 'member-selected': selectedMemberId === (member.userId || member.id) }"
+                  :class="{ 'member-selected': selectedMemberId === member._mid }"
                   @click="selectAndClose(member)"
                 >
                   <text class="member-avatar">{{ (member.nickname || '?').substring(0, 1) }}</text>
@@ -407,6 +407,7 @@
 <script>
 import ReportBlocksRenderer from './report-blocks-renderer.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
+import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -519,14 +520,13 @@ export default {
     otherMembers() {
       var candIds = {}
       for (var i = 0; i < this.candidates.length; i++) {
-        var c = this.candidates[i]
-        var cid = c.id || c.userId
+        var cid = this.candidates[i]._mid
         if (cid !== null && cid !== undefined) candIds[cid] = true
       }
       var list = []
       for (var j = 0; j < this.familyMembers.length; j++) {
         var m = this.familyMembers[j]
-        var mid = m.userId || m.id
+        var mid = m._mid
         if (mid !== null && mid !== undefined && candIds[mid]) continue
         list.push(m)
       }
@@ -745,6 +745,7 @@ export default {
       self.matchedMemberId = detail.matchedMemberId || null
       self.needBind = !!detail.needBind
       self.candidates = detail.candidates || []
+      for (var ci = 0; ci < self.candidates.length; ci++) self.candidates[ci]._mid = memberIdOf(self.candidates[ci])
       self.loadFamilyMembers()
       console.log('[报告入库] applyParseResult 后状态', {
         needBind: self.needBind,
@@ -757,8 +758,8 @@ export default {
         self.selectedMemberId = self.matchedMemberId
       }
       var best = self.candidates[0]
-      if (best && (best.id || best.userId)) {
-        self.selectedMemberId = best.id || best.userId
+      if (best && best._mid != null) {
+        self.selectedMemberId = best._mid
         self.selectedMemberObj = best
       }
       // 如果需要绑定且有候选成员,默认自动选中第一个
@@ -777,7 +778,10 @@ export default {
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         if (res.code === 200) {
-          self.familyMembers = res.data || []
+          // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+          var list = normalizeMembers(res.data || [], { userId: uni.getStorageSync('userId') })
+          for (var i = 0; i < list.length; i++) list[i]._mid = memberIdOf(list[i])
+          self.familyMembers = list
         }
       }).catch(function() {})
     },
@@ -788,7 +792,7 @@ export default {
       }
     },
     selectAndClose(member) {
-      this.selectedMemberId = member.id || member.userId
+      this.selectedMemberId = member._mid != null ? member._mid : memberIdOf(member)
       this.selectedMemberObj = member
       this.createdMemberId = null
       this.needBind = false
@@ -827,7 +831,7 @@ export default {
         self.creatingMember = false
         if (res.code === 200) {
           var member = res.data || {}
-          self.selectedMemberId = member.id || member.userId
+          self.selectedMemberId = memberIdOf(member)
           self.selectedMemberObj = member
           self.createdMemberId = self.selectedMemberId
           self.needBind = false

+ 24 - 37
cfc-frontend/pages/health/report-list.vue

@@ -1,24 +1,16 @@
 <template>
   <view class="list-container" :style="{ '--font-scale': fontScale }">
-    <!-- 成员过滤 -->
-    <view class="member-filter">
-      <scroll-view class="filter-scroll" scroll-x="true" :show-scrollbar="false">
-        <view class="filter-chips">
-          <view class="chip" :class="{ 'chip-active': !selectedMemberId }" @click="selectAllMembers">
-            <text class="chip-text">全部</text>
-          </view>
-          <view
-            class="chip"
-            v-for="item in members"
-            :key="item.id"
-            :class="{ 'chip-active': selectedMemberId === item._filterId }"
-            @click="selectMember(item)"
-          >
-            <text class="chip-text">{{ item.nickname || '成员' }}</text>
-          </view>
-        </view>
-      </scroll-view>
-    </view>
+    <!-- 成员过滤(统一组件 MemberSelector:同一用户只出现一次,主键为 canonical memberId) -->
+    <MemberSelector
+      variant="minimal"
+      :members="members"
+      :selected-member-id="selectedMemberId"
+      :show-overview="true"
+      overview-label="全部"
+      :overview-active="selectedMemberId === null"
+      :show-self="false"
+      @overview="selectAllMembers"
+      @select="selectMember" />
 
     <!-- 跨家庭报告转移申请 -->
     <view class="transfer-wrap" v-if="transferIncoming.length > 0 || transferOutgoing.length > 0">
@@ -161,11 +153,14 @@
 <script>
 import { BrandColors } from '../../utils/brand-colors.js'
 import { parseDate } from '../../utils/format.js'
+import MemberSelector from '@/components/MemberSelector.vue'
+import { normalizeMembers, memberIdOf, resolveMemberId } from '@/utils/memberNormalize.js'
 import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport, getReportTransferList, approveReportTransfer, denyReportTransfer } from '../../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       reports: [],
@@ -313,28 +308,20 @@ export default {
       var self = this
       getFamilyMemberList({}).then(function(res) {
         if (res.code === 200 && Array.isArray(res.data)) {
-          // _filterId 预计算,供 :class 内联比较使用(小程序 :class 禁止方法调用)
-          var list = []
-          for (var i = 0; i < res.data.length; i++) {
-            var m = res.data[i]
-            list.push({
-              id: m.id,
-              userId: m.userId,
-              nickname: m.nickname,
-              avatar: m.avatar,
-              _filterId: m.id || m.userId
-            })
+          // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+          var list = normalizeMembers(res.data, { userId: uni.getStorageSync('userId') })
+          // _filterId 预计算,供调整归属面板 :class 内联比较使用(小程序 :class 禁止方法调用)
+          for (var i = 0; i < list.length; i++) {
+            list[i]._filterId = memberIdOf(list[i])
           }
           self.members = list
-          // 默认选中本人时,把 chip 高亮从 users.id 归一到本人的 family_members.id(_filterId):
+          // 默认选中本人时,把 chip 高亮从 users.id 归一到本人的 family_members.id(memberId):
           // 两个 id 后端返回同一份报告,数据已在 onLoad 拉取过,这里只修高亮、不重复请求
           if (self._selfUserId && self.selectedMemberId != null &&
               Number(self.selectedMemberId) === Number(self._selfUserId)) {
-            for (var j = 0; j < list.length; j++) {
-              if (list[j].userId != null && Number(list[j].userId) === Number(self._selfUserId)) {
-                self.selectedMemberId = list[j]._filterId
-                break
-              }
+            var resolved = resolveMemberId(list, self.selectedMemberId)
+            if (resolved != null) {
+              self.selectedMemberId = resolved
             }
           }
         }
@@ -352,7 +339,7 @@ export default {
       this.selectedAdjustMemberId = null
     },
     selectMember(item) {
-      var filterId = item._filterId
+      var filterId = item._filterId != null ? item._filterId : memberIdOf(item)
       if (this.selectedMemberId === filterId) return
       this.selectedMemberId = filterId
       this.loadData()

+ 5 - 2
cfc-frontend/pages/health/report-upload.vue

@@ -137,6 +137,7 @@
 </template>
 
 <script>
+import { normalizeMembers, memberIdOf } from '../../utils/memberNormalize.js'
 import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList, requestReportTransfer } from '../../utils/api.js'
 import { pickImage } from '../../utils/image-picker.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -183,7 +184,7 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
   methods: {
     resolveMemberId: function(member) {
       if (!member) return null
-      var raw = member.id || member.userId
+      var raw = memberIdOf(member)
       if (raw === 0 || raw === '0') return 0
       if (!raw) return null
       var id = parseInt(raw)
@@ -220,7 +221,9 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
         var list = (res && res.data) || []
-        self.familyMembers = list instanceof Array ? list : []
+        // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一;统一身份键 memberId
+        var normalized = normalizeMembers(list instanceof Array ? list : [], { userId: uni.getStorageSync('userId') })
+        self.familyMembers = normalized
         if (!self.memberId && self.familyMembers.length === 1) {
           self.memberId = self.resolveMemberId(self.familyMembers[0])
         }

+ 23 - 36
cfc-frontend/pages/psych-assessment/index.vue

@@ -7,17 +7,13 @@
 
     <view class="member-selector" v-if="members.length > 1">
       <text class="selector-label">选择测评对象:</text>
-      <view class="member-tabs">
-        <view
-          class="member-tab"
-          :class="{ 'member-tab-active': selectedMemberId === m.id }"
-          v-for="m in members"
-          :key="m.id"
-          @click="selectMember(m.id)"
-        >
-          <text>{{ m.nickname }}</text>
-        </view>
-      </view>
+      <MemberSelector
+        variant="minimal"
+        embedded
+        :members="members"
+        :selected-member-id="selectedMemberId"
+        :show-self="false"
+        @select="onMemberSelect" />
     </view>
 
     <view class="scale-list">
@@ -107,6 +103,8 @@
 </template>
 
 <script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { normalizeMembers, memberIdOf, resolveMemberId } from '@/utils/memberNormalize.js'
 import { BrandColors } from '../../utils/brand-colors.js'
 import { getCognitiveChildScores, submitCognitiveSelfTest } from '../../utils/api.js'
 import { getFamilyMemberList } from '../../utils/api.js'
@@ -138,6 +136,7 @@ import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
+  components: { MemberSelector },
   data() {
     return {
       selectedMemberId: null,
@@ -173,9 +172,15 @@ export default {
       getFamilyMemberList({ visibleOnly: true })
         .then(function(res) {
           if (res && res.code === 200 && res.data) {
-            self.members = res.data
-            if (!self.selectedMemberId && res.data.length > 0) {
-              self.selectedMemberId = res.data[0].id
+            // 前端防御层:按 user_id 折叠同用户多行,isSelf 唯一
+            self.members = normalizeMembers(res.data, { userId: uni.getStorageSync('userId') })
+            // 历史标识(users.id / 遗留 children.id)归一到统一身份键 memberId
+            var mid = resolveMemberId(self.members, self.selectedMemberId) || self.selectedMemberId
+            if (mid && String(mid) !== String(self.selectedMemberId)) {
+              self.selectedMemberId = mid
+              self.loadScores()
+            } else if (!self.selectedMemberId && self.members.length > 0) {
+              self.selectedMemberId = memberIdOf(self.members[0])
               self.loadScores()
             }
           }
@@ -184,8 +189,9 @@ export default {
           // 静默失败
         })
     },
-    selectMember: function(id) {
-      this.selectedMemberId = id
+    // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
+    onMemberSelect: function(member) {
+      this.selectedMemberId = memberIdOf(member)
       this.loadScores()
     },
     loadScores: function() {
@@ -305,6 +311,7 @@ export default {
   padding: 24rpx 30rpx;
   background: $app-surface;
   margin: 20rpx 0;
+.member-selector .member-minimal { margin-top: 16rpx; }
   border-radius: 16rpx;
   margin-left: 30rpx;
   margin-right: 30rpx;
@@ -315,26 +322,6 @@ export default {
   color: $facade-text-body;
 }
 
-.member-tabs {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: wrap;
-  margin-top: 16rpx;
-}
-
-.member-tab {
-  padding: 10rpx 28rpx;
-  border-radius: 30rpx;
-  margin: 8rpx 8rpx 0 0;
-  background: $facade-hairline-2;
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-}
-
-.member-tab-active {
-  background: $dim-wisdom;
-  color: $app-surface;
-}
 
 .scale-list {
   padding: 0 30rpx;

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

@@ -197,6 +197,41 @@ export function normalizeMembers(rawMembers, viewer) {
   return result
 }
 
+/**
+ * 把历史遗留的成员标识归一到统一身份键(familyMemberId)。
+ *
+ * 背景:部分页面从 URL 参数 / 本地缓存拿到的还是旧口径标识
+ *      (users.id 或遗留 children.id),直接当选择器主键会选不中任何一项。
+ * 命中规则:先按 canonical memberId 精确匹配 → 再按 id 匹配 → 最后按 userId 匹配。
+ *
+ * @param {Array}  members 成员列表(已规范化或原始均可)
+ * @param {String|Number} rawId 历史标识
+ * @returns {String|Number|null} 统一身份键;无法匹配时返回 null
+ */
+export function resolveMemberId(members, rawId) {
+  if (rawId === null || rawId === undefined || rawId === '') return null
+  var list = members instanceof Array ? members : []
+  var target = String(rawId)
+  var i
+  // 1) canonical memberId 精确匹配
+  for (i = 0; i < list.length; i++) {
+    if (String(memberIdOf(list[i])) === target) return memberIdOf(list[i])
+  }
+  // 2) 行 id 匹配
+  for (i = 0; i < list.length; i++) {
+    if (list[i] && list[i].id !== null && list[i].id !== undefined && String(list[i].id) === target) {
+      return memberIdOf(list[i])
+    }
+  }
+  // 3) userId 匹配(旧口径:把 users.id 当成员主键)
+  for (i = 0; i < list.length; i++) {
+    if (list[i] && hasLoginUser(list[i]) && String(list[i].userId) === target) {
+      return memberIdOf(list[i])
+    }
+  }
+  return null
+}
+
 /** 从(已规范化或未规范化的)成员列表中找出"本人"一行 */
 export function findSelfMember(members) {
   if (!(members instanceof Array)) return null