Jelajahi Sumber

fix(energy): 修复家庭能量沙盘成员切换与家庭去重

Xiaogang Liao 4 hari lalu
induk
melakukan
4d92fc1462

+ 73 - 22
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -118,10 +118,9 @@ public class EnergyService {
             }
         }
 
-        List<FamilyMember> children = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getFamilyId, familyId)
-        );
+        // family_members 可能包含“家长本人”的重复记录(user_id 指向家长),
+        // 必须先按家长 userId 排除,并按孩子 userId 去重,否则同一家长会被重复计算。
+        List<FamilyMember> children = getActualChildren(familyId);
 
         Date now = new Date();
 
@@ -259,6 +258,45 @@ public class EnergyService {
         }
     }
 
+    /**
+     * 获取家庭中真正的孩子成员记录。
+     * 排除 user_id 指向家长本人的重复记录,并对同一孩子账号只保留一条。
+     */
+    List<FamilyMember> getActualChildren(Long familyId) {
+        if (familyId == null) return Collections.emptyList();
+        List<FamilyMember> rows = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+        );
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+        );
+        Set<Long> parentUserIds = new HashSet<>();
+        if (users != null) {
+            for (User u : users) {
+                if (hasParentRole(u)) parentUserIds.add(u.getId());
+            }
+        }
+        return filterActualChildren(rows, parentUserIds);
+    }
+
+    static List<FamilyMember> filterActualChildren(List<FamilyMember> rows, Set<Long> parentUserIds) {
+        List<FamilyMember> result = new ArrayList<>();
+        if (rows == null) return result;
+        Set<Long> seenChildUserIds = new HashSet<>();
+        for (FamilyMember row : rows) {
+            if (row == null) continue;
+            Long userId = row.getUserId();
+            if (userId != null) {
+                if (parentUserIds != null && parentUserIds.contains(userId)) continue;
+                if (!seenChildUserIds.add(userId)) continue;
+            }
+            result.add(row);
+        }
+        return result;
+    }
+
     // ==================== 家长能量计算 ====================
 
     private MemberEnergyDTO calcParentEnergy(User parent) {
@@ -300,29 +338,25 @@ public class EnergyService {
      * Optimized: batch-fetch all children's health scores in a single query
      */
     private int calcParentBody(User parent) {
+        // 1. 家长本人有 active 健康报告时,优先使用自己的数据,
+        //    避免多个家长共用同一条孩子报告(此前所有家长 body 都会变成 75)。
+        if (parent.getId() != null) {
+            HealthReport ownReport = queryLatestActiveReport(Collections.singletonList(parent.getId()));
+            if (ownReport != null && ownReport.getOverallScore() != null) {
+                return ownReport.getOverallScore();
+            }
+        }
+
         if (parent.getFamilyId() != null) {
-            List<FamilyMember> children = familyMemberMapper.selectList(
-                    new LambdaQueryWrapper<FamilyMember>()
-                            .eq(FamilyMember::getFamilyId, parent.getFamilyId())
-            );
-            // Batch query: fetch health reports for ALL children at once
+            List<FamilyMember> children = getActualChildren(parent.getFamilyId());
+            // Batch query: fetch health reports for ALL actual children at once
             List<Long> childUserIds = children.stream()
                     .map(FamilyMember::getUserId)
+                    .filter(Objects::nonNull)
+                    .distinct()
                     .collect(Collectors.toList());
             if (!childUserIds.isEmpty()) {
-                // Single query for all children — picks the first available health score
-                LambdaQueryWrapper<HealthReport> hrWrapper = new LambdaQueryWrapper<HealthReport>()
-                        .in(HealthReport::getUserId, childUserIds)
-                        .eq(HealthReport::getStatus, "active")
-                        .orderByDesc(HealthReport::getReportDate)
-                        .last("LIMIT 1");
-                HealthReport report = null;
-                try {
-                    SortUtil.applySort(hrWrapper);
-                    report = healthReportMapper.selectOne(hrWrapper);
-                } catch (Exception e) {
-                    log.warn("健康报告查询异常, userIds={}: {}", childUserIds, e.getMessage());
-                }
+                HealthReport report = queryLatestActiveReport(childUserIds);
                 if (report != null && report.getOverallScore() != null) {
                     return report.getOverallScore();
                 }
@@ -346,6 +380,23 @@ public class EnergyService {
                 Arrays.asList("运动", "体育", "户外", "健康"), 365);
     }
 
+    private HealthReport queryLatestActiveReport(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) return null;
+        LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<HealthReport>()
+                .in(HealthReport::getUserId, userIds)
+                .eq(HealthReport::getStatus, "active")
+                .isNotNull(HealthReport::getOverallScore)
+                .orderByDesc(HealthReport::getReportDate)
+                .last("LIMIT 1");
+        try {
+            SortUtil.applySort(wrapper);
+            return healthReportMapper.selectOne(wrapper);
+        } catch (Exception e) {
+            log.warn("健康报告查询异常, userIds={}: {}", userIds, e.getMessage());
+            return null;
+        }
+    }
+
     /** 家长 心 — 学习/阅读/亲子类任务完成率 */
     private int calcParentMind(User parent) {
         return calcTaskCompletionRate(parent.getId(), "parent",

+ 84 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/EnergyServiceFamilyTest.java

@@ -0,0 +1,84 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 家庭能量成员去重测试。
+ *
+ * 背景:family_members 中可能同时存在“家长本人”的记录(user_id 指向家长),
+ * 沙盘计算若不排除,会把同一个家长再当成孩子算一遍。
+ */
+class EnergyServiceFamilyTest {
+
+    @Test
+    void filterActualChildrenExcludesParentLinkedRowsAndDeduplicatesChildUsers() {
+        List<FamilyMember> rows = Arrays.asList(
+                member(21L, 12L),   // 家长 12 的重复成员记录
+                member(23L, null),  // 真实孩子(无登录账号)
+                member(24L, 99L),   // 孩子账号
+                member(25L, 99L),   // 同一孩子账号的重复记录
+                member(40L, 78L)    // 家长 78 的重复成员记录
+        );
+        Set<Long> parentUserIds = new HashSet<>(Arrays.asList(12L, 78L));
+
+        List<Long> ids = EnergyService.filterActualChildren(rows, parentUserIds)
+                .stream().map(FamilyMember::getId).collect(Collectors.toList());
+
+        assertEquals(Arrays.asList(23L, 24L), ids);
+    }
+
+    @Test
+    void parentBodyPrefersOwnHealthReportOverChildrenAndTasks() throws Exception {
+        EnergyService service = new EnergyService();
+        FamilyMemberMapper familyMemberMapper = Mockito.mock(FamilyMemberMapper.class);
+        HealthReportMapper healthReportMapper = Mockito.mock(HealthReportMapper.class);
+        TaskMapper taskMapper = Mockito.mock(TaskMapper.class);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "healthReportMapper", healthReportMapper);
+        inject(service, "taskMapper", taskMapper);
+
+        Mockito.when(familyMemberMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
+        Mockito.when(taskMapper.selectList(Mockito.any())).thenReturn(Collections.emptyList());
+        HealthReport ownReport = new HealthReport();
+        ownReport.setOverallScore(82);
+        Mockito.when(healthReportMapper.selectOne(Mockito.any())).thenReturn(ownReport);
+
+        User parent = new User();
+        parent.setId(12L);
+        parent.setFamilyId(72L);
+
+        java.lang.reflect.Method method = EnergyService.class.getDeclaredMethod("calcParentBody", User.class);
+        method.setAccessible(true);
+        assertEquals(82, method.invoke(service, parent));
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field field = EnergyService.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private FamilyMember member(Long id, Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        return m;
+    }
+}

+ 79 - 0
cfc-frontend/__tests__/utils/wuxing-sandbox.test.js

@@ -0,0 +1,79 @@
+import {
+  buildMemberOptions,
+  dedupeMembers,
+  nextSandboxSelection,
+  resolveSandboxOverall,
+  resolveSandboxScores
+} from '@/utils/wuxing-sandbox'
+
+const familyScores = {
+  bodyScore: 59,
+  mindScore: 0,
+  wisdomScore: 8,
+  actionScore: 0,
+  wealthScore: 8,
+  overallScore: 15
+}
+
+const personalData = {
+  bodyScore: 75,
+  mindScore: 0,
+  wisdomScore: 3,
+  actionScore: 0,
+  wealthScore: 30,
+  overallScore: 21,
+  members: [{ memberId: 12, memberType: 'parent', name: '他爹' }]
+}
+
+const members = [
+  { memberId: 12, memberType: 'parent', name: '他爹', bodyScore: 75, mindScore: 0, wisdomScore: 3, actionScore: 0, wealthScore: 30, overallScore: 21 },
+  { memberId: 34, memberType: 'parent', name: '书宁宁', bodyScore: 78, mindScore: 0, wisdomScore: 10, actionScore: 0, wealthScore: 6, overallScore: 19 },
+  { memberId: 78, memberType: 'parent', name: '云泽', bodyScore: 0, mindScore: 0, wisdomScore: 0, actionScore: 0, wealthScore: 0, overallScore: 0 },
+  { memberId: 23, memberType: 'child', name: '老二', bodyScore: 0, mindScore: 0, wisdomScore: 43, actionScore: 0, wealthScore: 21, overallScore: 13 },
+  // 完全重复的成员记录只能出现一次
+  { memberId: 34, memberType: 'parent', name: '书宁宁', bodyScore: 78, mindScore: 0, wisdomScore: 10, actionScore: 0, wealthScore: 6, overallScore: 19 }
+]
+
+describe('wuxing sandbox member selection', () => {
+  test('dedupeMembers removes exact duplicate member rows', () => {
+    expect(dedupeMembers(members).map(m => m.memberId)).toEqual([12, 34, 78, 23])
+  })
+
+  test('buildMemberOptions excludes self, dedupes members and appends family', () => {
+    const options = buildMemberOptions(members, personalData)
+    expect(options.map(o => o.label)).toEqual(['本人', '书宁宁', '云泽', '老二', '全家'])
+    expect(options[0].viewMode).toBe('personal')
+    expect(options[options.length - 1].viewMode).toBe('family')
+  })
+
+  test('member view uses the selected member scores instead of personalData', () => {
+    const selected = members[1]
+    expect(resolveSandboxScores('member', familyScores, personalData, selected)).toEqual([78, 0, 10, 0, 6])
+    expect(resolveSandboxOverall('member', familyScores, personalData, selected)).toBe(19)
+  })
+
+  test('personal view keeps the login user scores', () => {
+    expect(resolveSandboxScores('personal', familyScores, personalData, null)).toEqual([75, 0, 3, 0, 30])
+    expect(resolveSandboxOverall('personal', familyScores, personalData, null)).toBe(21)
+  })
+
+  test('family view uses family aggregate scores', () => {
+    expect(resolveSandboxScores('family', familyScores, personalData, null)).toEqual([59, 0, 8, 0, 8])
+    expect(resolveSandboxOverall('family', familyScores, personalData, null)).toBe(15)
+  })
+
+  test('center click cycles personal -> other members -> family -> personal', () => {
+    const options = buildMemberOptions(members, personalData)
+    expect(nextSandboxSelection(options, 'personal', null)).toMatchObject({ viewMode: 'member', memberId: 34 })
+    expect(nextSandboxSelection(options, 'member', 34)).toMatchObject({ viewMode: 'member', memberId: 78 })
+    expect(nextSandboxSelection(options, 'member', 23)).toMatchObject({ viewMode: 'family' })
+    expect(nextSandboxSelection(options, 'family', null)).toMatchObject({ viewMode: 'personal' })
+  })
+
+  test('only family and self falls back to self instead of showing an empty member', () => {
+    const onlySelf = { ...personalData, members: [{ memberId: 12, memberType: 'parent', name: '他爹' }] }
+    const options = buildMemberOptions([], onlySelf)
+    expect(options.map(o => o.viewMode)).toEqual(['personal', 'family'])
+    expect(nextSandboxSelection(options, 'personal', null)).toMatchObject({ viewMode: 'family' })
+  })
+})

+ 1 - 1
cfc-frontend/components/FamilyChallengeCard.vue

@@ -68,7 +68,7 @@
 </template>
 
 <script>
-import { parseDate } from '../../utils/format.js'
+import { parseDate } from '../utils/format.js'
 export default {
   props: {
     challenge: { type: Object, default: null },

+ 225 - 110
cfc-frontend/components/wuxing-sandbox.vue

@@ -6,9 +6,9 @@
       <image class="sandbox-logo" src="/static/logo.png" mode="aspectFit"></image>
       <text class="sandbox-title">浠艾福能量沙盘</text>
       <!-- 右上角:live 模式显示个人/全家切换,preview 模式显示徽章文案 -->
-      <view class="sandbox-switch" v-if="mode !== 'preview'" @click="onToggle(currentView === 'family' ? 'personal' : 'family')">
-        <text class="sandbox-switch-icon">{{ currentView === 'family' ? '👨‍👩‍👧‍👦' : '👤' }}</text>
-        <text class="sandbox-switch-label">{{ currentView === 'family' ? '全家' : (currentMember ? currentMember.name : '个人') }}</text>
+      <view class="sandbox-switch" v-if="mode !== 'preview'" @click="onToggle">
+        <text class="sandbox-switch-icon">{{ activeMemberIcon }}</text>
+        <text class="sandbox-switch-label">{{ activeMemberLabel }}</text>
         <text class="sandbox-switch-arrow">⇄</text>
       </view>
       <text class="sandbox-badge" v-else>{{ displayBadgeText }}</text>
@@ -78,6 +78,23 @@
       <view class="dim-item dim-item--water">💧富 {{ activeScores[4] }}%</view>
     </view>
   </slot>
+
+  <!-- 右上角成员选择面板 -->
+  <view v-if="memberPickerVisible" class="member-picker-mask" @click="memberPickerVisible = false">
+    <view class="member-picker-panel" @click.stop>
+      <view class="member-picker-title">切换视角</view>
+      <view
+        v-for="option in memberOptions"
+        :key="option.key"
+        class="member-picker-item"
+        :class="{ 'member-picker-item--active': isOptionActive(option) }"
+        @click="selectOption(option)"
+      >
+        <text class="member-picker-label">{{ option.label }}</text>
+        <text class="member-picker-check" v-if="isOptionActive(option)">✓</text>
+      </view>
+    </view>
+  </view>
 </view>
 </template>
 
@@ -86,6 +103,14 @@ import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js
 import { FillAnimator } from './wuxing-sandbox-helpers/animation.js'
 import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js'
 import { FlowAnimation } from './wuxing-sandbox-helpers/flow-animation.js'
+import {
+  buildMemberOptions,
+  findMemberById,
+  getSelfMember,
+  nextSandboxSelection,
+  resolveSandboxOverall,
+  resolveSandboxScores
+} from '@/utils/wuxing-sandbox.js'
 
 var DIM_KEYS = ['body', 'mind', 'wisdom', 'action', 'wealth']
 
@@ -174,7 +199,8 @@ export default {
   data: function() {
     return {
       viewMode: 'personal',
-      selectedMemberIndex: 0,
+      selectedMemberId: null,
+      memberPickerVisible: false,
       previewScores: [0, 0, 0, 0, 0],
       canvasReady: false,
       canvasWidth: 0,
@@ -197,56 +223,50 @@ export default {
     isPreview: function() {
       return this.mode === 'preview'
     },
+    selfMember: function() {
+      return getSelfMember(this.personalData)
+    },
+    memberOptions: function() {
+      return buildMemberOptions(this.members, this.personalData)
+    },
+    totalMemberCount: function() {
+      if (this.memberCount > 0) return this.memberCount
+      return this.members ? this.members.length : 0
+    },
     currentMember: function() {
-      if (this.isPreview) return null
-      var m = this.members
-      if (!m || m.length === 0) return null
-      if (this.selectedMemberIndex >= m.length) return m[0]
-      return m[this.selectedMemberIndex]
+      if (this.isPreview || this.viewMode === 'family') return null
+      if (this.viewMode === 'member') {
+        return findMemberById(this.members, this.selectedMemberId)
+      }
+      return this.selfMember
+    },
+    selectedMemberIndex: function() {
+      if (this.viewMode !== 'member' || this.selectedMemberId == null) return -1
+      var list = this.members || []
+      for (var i = 0; i < list.length; i++) {
+        if (String(list[i].memberId) === String(this.selectedMemberId)) return i
+      }
+      return -1
+    },
+    activeMemberLabel: function() {
+      if (this.viewMode === 'family') return '全家'
+      if (this.viewMode === 'member' && this.currentMember) return this.currentMember.name || '家庭成员'
+      return '本人'
+    },
+    activeMemberIcon: function() {
+      return this.viewMode === 'family' ? '👨‍👩‍👧‍👦' : '👤'
     },
     /** 当前活跃的五维值数组 [body, mind, wisdom, action, wealth] */
     activeScores: function() {
       if (this.isPreview) return this.previewScores
-      if (this.viewMode === 'family') {
-        var s = this.scores
-        return [
-          s.body || 0,
-          s.mind || 0,
-          s.wisdom || 0,
-          s.action || 0,
-          s.wealth || 0
-        ]
-      }
-      var p = this.personalData
-      if (p) {
-        return [
-          p.bodyScore || 0,
-          p.mindScore || 0,
-          p.wisdomScore || 0,
-          p.actionScore || 0,
-          p.wealthScore || 0
-        ]
-      }
-      var m = this.currentMember
-      if (!m) return [0, 0, 0, 0, 0]
-      return [
-        m.bodyScore || 0,
-        m.mindScore || 0,
-        m.wisdomScore || 0,
-        m.actionScore || 0,
-        m.wealthScore || 0
-      ]
+      return resolveSandboxScores(this.viewMode, this.scores, this.personalData, this.currentMember)
     },
     activeOverall: function() {
       if (this.isPreview) {
         var s = this.previewScores
         return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
       }
-      if (this.viewMode === 'family') return this.overallScore || 0
-      var p = this.personalData
-      if (p && p.overallScore != null) return p.overallScore
-      var m = this.currentMember
-      return m ? (m.overallScore || 0) : 0
+      return resolveSandboxOverall(this.viewMode, this.scores, this.personalData, this.currentMember)
     },
     activeTs: function() {
       return [
@@ -259,14 +279,14 @@ export default {
     },
     displayBadgeText: function() {
       if (this.badgeText) return this.badgeText
-      var label = this.viewMode === 'family' ? '家庭能量' : (this.currentMember ? this.currentMember.name : '个人能量')
+      var label = this.viewMode === 'family' ? '家庭能量' : (this.activeMemberLabel + '能量')
       return label + ' ' + (this.activeOverall || 0) + '%'
     },
     _allDimTs: function() {
       return this.activeTs
     },
     hasMultipleMembers: function() {
-      return this.members && this.members.length > 1
+      return this.totalMemberCount > 1
     },
     activeClassName: function() {
       return this.currentView === 'personal' ? 'toggle-item--active' : ''
@@ -280,18 +300,12 @@ export default {
         var s = this.previewScores
         return Math.round((s[0] + s[1] + s[2] + s[3] + s[4]) / 5)
       }
-      return this.viewMode === 'family' ? (this.overallScore || 0) : (this.overallScore || 0)
+      return this.overallScore || 0
     },
-    /** 个人能量值(用于太极阴面) */
+    /** 当前选中视角的能量值(用于太极阴面) */
     personalEnergy: function() {
       if (this.isPreview) return 0
-      if (this.viewMode === 'member') {
-        var m = this.currentMember
-        return m ? (m.overallScore || 0) : 0
-      }
-      // 家庭模式下取当前选中成员
-      var m = this.currentMember
-      return m ? (m.overallScore || 0) : 0
+      return this.activeOverall || 0
     },
     /** 按五行顺序绕圈排列
      *  固定序:心(火)→身(土)→智(金)→富(水)→行(木),心始终在顶部
@@ -299,6 +313,13 @@ export default {
      */
     sortedDimensions: function() {
       var order = ['mind', 'body', 'wisdom', 'wealth', 'action']
+      var scoreByCode = {
+        body: this.activeScores[0] || 0,
+        mind: this.activeScores[1] || 0,
+        wisdom: this.activeScores[2] || 0,
+        action: this.activeScores[3] || 0,
+        wealth: this.activeScores[4] || 0
+      }
       var dimMap = {}
       for (var i = 0; i < this.dimensions.length; i++) {
         var d = this.dimensions[i]
@@ -307,16 +328,17 @@ export default {
       var all = []
       for (var j = 0; j < order.length; j++) {
         var code = order[j]
-        var d = dimMap[code]
+        var d = dimMap[code] || {}
         var meta = DIM_META[code] || {}
-        if (d) {
-          if (!d.name) d.name = meta.name || ''
-          if (!d.icon) d.icon = meta.icon || ''
-          if (!d.element) d.element = meta.element || ''
-          all.push(d)
-        } else {
-          all.push({ code: code, name: meta.name || '', icon: meta.icon || '', element: meta.element || '', energy: 0, healthIndex: 0 })
-        }
+        var score = scoreByCode[code] || 0
+        all.push({
+          code: code,
+          name: d.name || meta.name || '',
+          icon: d.icon || meta.icon || '',
+          element: d.element || meta.element || '',
+          energy: score,
+          healthIndex: score
+        })
       }
       return all
       // all = [心→顶, 身→右, 智→右下, 富→左下, 行→左]
@@ -335,9 +357,28 @@ export default {
       }
     },
     currentView: function(newVal) {
-      if (newVal && newVal !== this.viewMode) {
+      if (!newVal) return
+      if (newVal !== this.viewMode) {
         this.viewMode = newVal
       }
+      if (newVal === 'personal') {
+        this.selectedMemberId = null
+      }
+    },
+    memberOptions: function(options) {
+      if (this.viewMode !== 'member') return
+      var exists = false
+      for (var i = 0; i < options.length; i++) {
+        if (options[i].viewMode === 'member' &&
+            String(options[i].memberId) === String(this.selectedMemberId)) {
+          exists = true
+          break
+        }
+      }
+      if (!exists) {
+        this.viewMode = 'personal'
+        this.selectedMemberId = null
+      }
     },
     hasMultipleMembers: function(newVal) {
       if (!newVal && this.viewMode === 'family') {
@@ -384,36 +425,72 @@ export default {
     视图切换
     =========================== */
 
+    selectionPayload: function() {
+      return {
+        viewMode: this.viewMode,
+        memberId: this.selectedMemberId,
+        selectedMemberIndex: this.selectedMemberIndex,
+        member: this.currentMember
+      }
+    },
+
+    applySelection: function(selection) {
+      if (!selection) return
+      this.memberPickerVisible = false
+      this.viewMode = selection.viewMode
+      this.selectedMemberId = selection.viewMode === 'member' ? selection.memberId : null
+      if (this.viewMode !== 'member') {
+        this.$emit('view-change', this.viewMode)
+      }
+      this.$emit('mode-change', this.selectionPayload())
+    },
+
+    showFamilyRequirement: function() {
+      uni.showModal({
+        title: '需要更多家庭成员',
+        content: '至少需要 2 位家庭成员才能查看全家能量,快去添加吧!',
+        confirmText: '去添加',
+        cancelText: '取消',
+        success: function(res) {
+          if (res.confirm) {
+            uni.navigateTo({ url: '/pages/profile-extra/family-members' })
+          }
+        }
+      })
+    },
+
+    isOptionActive: function(option) {
+      if (!option) return false
+      if (option.viewMode !== this.viewMode) return false
+      if (option.viewMode === 'member') {
+        return String(option.memberId) === String(this.selectedMemberId)
+      }
+      return true
+    },
+
+    selectOption: function(option) {
+      if (!option) return
+      if (option.viewMode === 'family' && this.totalMemberCount < 2) {
+        this.memberPickerVisible = false
+        this.showFamilyRequirement()
+        return
+      }
+      this.applySelection(option)
+    },
+
     onCenterClick: function() {
       // 预览模式不切换
       if (this.isPreview) {
-        this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
+        this.$emit('center-click', this.selectionPayload())
         return
       }
-      var members = this.members
-      if (!members || members.length === 0) {
-        this.viewMode = 'family'
-        this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
-        return
-      }
-      if (this.viewMode === 'family') {
-        this.viewMode = 'member'
-      } else {
-        if (members.length === 1) {
-          this.viewMode = 'family'
-          this.selectedMemberIndex = 0
-        } else {
-          var nextIdx = this.selectedMemberIndex + 1
-          if (nextIdx >= members.length) {
-            this.viewMode = 'family'
-            this.selectedMemberIndex = 0
-          } else {
-            this.selectedMemberIndex = nextIdx
-          }
-        }
+      var next = nextSandboxSelection(this.memberOptions, this.viewMode, this.selectedMemberId)
+      // 家庭成员不足 2 人时不进入全家视角
+      if (next.viewMode === 'family' && this.totalMemberCount < 2) {
+        next = { viewMode: 'personal', memberId: null, label: '本人' }
       }
-      this.$emit('center-click', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
-      this.$emit('mode-change', { viewMode: this.viewMode, selectedMemberIndex: this.selectedMemberIndex })
+      this.applySelection(next)
+      this.$emit('center-click', this.selectionPayload())
     },
 
     onPointClick: function(code) {
@@ -424,28 +501,10 @@ export default {
       })
     },
 
-    /** 个人/全家切换(由父组件控制数据源) */
-    onToggle: function(view) {
-      if (view === 'family') {
-        var total = this.memberCount > 0 ? this.memberCount : (this.members ? this.members.length : 0)
-        if (total < 2) {
-          uni.showModal({
-            title: '需要更多家庭成员',
-            content: '至少需要 2 位家庭成员才能查看全家能量,快去添加吧!',
-            confirmText: '去添加',
-            cancelText: '取消',
-            success: function(res) {
-              if (res.confirm) {
-                uni.navigateTo({ url: '/pages/profile-extra/family-members' })
-              }
-            }
-          })
-          return
-        }
-      }
-      this.viewMode = view
-      if (view === this.currentView) return
-      this.$emit('view-change', view)
+    /** 右上角:打开成员选择面板(本人 / 各成员 / 全家) */
+    onToggle: function() {
+      if (this.isPreview) return
+      this.memberPickerVisible = true
     },
 
     /** 维度 code → CSS 颜色类名(不依赖 API 的 element 字段) */
@@ -668,6 +727,62 @@ export default {
 .sandbox-switch-label { font-size: 22rpx; font-weight: 700; max-width: 140rpx; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
 .sandbox-switch-arrow { font-size: 20rpx; opacity: 0.8; }
 
+/* ---- 成员选择面板 ---- */
+.member-picker-mask {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+  background: rgba(0, 0, 0, 0.42);
+}
+.member-picker-panel {
+  width: 100%;
+  max-height: 70vh;
+  overflow-y: auto;
+  box-sizing: border-box;
+  padding: 28rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
+  background: #ffffff;
+  border-radius: 28rpx 28rpx 0 0;
+  box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.12);
+}
+.member-picker-title {
+  padding-bottom: 16rpx;
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #333333;
+  text-align: center;
+}
+.member-picker-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 88rpx;
+  padding: 0 24rpx;
+  margin-top: 8rpx;
+  border-radius: 16rpx;
+  background: #f7f8fc;
+}
+.member-picker-item--active {
+  background: rgba(102, 126, 234, 0.12);
+}
+.member-picker-label {
+  font-size: 28rpx;
+  color: #333333;
+}
+.member-picker-item--active .member-picker-label {
+  color: #667eea;
+  font-weight: 700;
+}
+.member-picker-check {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #667eea;
+}
+
 /* ---- 五角星区域 ---- */
 .sandbox-star {
   position: relative;

+ 140 - 0
cfc-frontend/utils/wuxing-sandbox.js

@@ -0,0 +1,140 @@
+/**
+ * 五行能量沙盘 — 成员切换与数值解析工具。
+ *
+ * 沙盘里的数据有三类:
+ * - personalData:当前登录用户(或个人视角)的沙盘数据
+ * - members:家庭沙盘返回的所有成员数据
+ * - familyScores:家庭聚合数据
+ *
+ * 组件只负责渲染,成员列表、当前选中项和最终展示分数都通过这里的纯函数解析,
+ * 避免“切换了成员但数值仍是本人”的问题。
+ */
+
+function scoreOf(data) {
+  return {
+    body: data && data.bodyScore != null ? data.bodyScore : 0,
+    mind: data && data.mindScore != null ? data.mindScore : 0,
+    wisdom: data && data.wisdomScore != null ? data.wisdomScore : 0,
+    action: data && data.actionScore != null ? data.actionScore : 0,
+    wealth: data && data.wealthScore != null ? data.wealthScore : 0
+  }
+}
+
+function memberKey(member) {
+  if (!member) return ''
+  if (member.memberId != null) {
+    return String(member.memberId) + '|' + String(member.memberType || '')
+  }
+  return 'name|' + String(member.name || '')
+}
+
+export function dedupeMembers(members) {
+  const list = Array.isArray(members) ? members : []
+  const seen = {}
+  const result = []
+  for (let i = 0; i < list.length; i++) {
+    const member = list[i]
+    if (!member) continue
+    const key = memberKey(member)
+    if (seen[key]) continue
+    seen[key] = true
+    result.push(member)
+  }
+  return result
+}
+
+export function getSelfMember(personalData) {
+  if (!personalData || !Array.isArray(personalData.members) || !personalData.members.length) {
+    return null
+  }
+  return personalData.members[0]
+}
+
+export function findMemberById(members, memberId) {
+  if (memberId == null) return null
+  const list = dedupeMembers(members)
+  for (let i = 0; i < list.length; i++) {
+    if (String(list[i].memberId) === String(memberId)) return list[i]
+  }
+  return null
+}
+
+export function buildMemberOptions(members, personalData) {
+  const self = getSelfMember(personalData)
+  const selfId = self && self.memberId != null ? String(self.memberId) : null
+  const options = [{
+    key: 'personal',
+    label: '本人',
+    viewMode: 'personal',
+    memberId: self && self.memberId != null ? self.memberId : null,
+    member: null
+  }]
+
+  const list = dedupeMembers(members)
+  for (let i = 0; i < list.length; i++) {
+    const member = list[i]
+    if (selfId != null && String(member.memberId) === selfId) continue
+    options.push({
+      key: 'member-' + memberKey(member),
+      label: member.name || '家庭成员',
+      viewMode: 'member',
+      memberId: member.memberId,
+      member
+    })
+  }
+
+  options.push({
+    key: 'family',
+    label: '全家',
+    viewMode: 'family',
+    memberId: null,
+    member: null
+  })
+  return options
+}
+
+export function resolveSandboxScores(viewMode, familyScores, personalData, currentMember) {
+  let source = personalData
+  if (viewMode === 'family') {
+    source = familyScores
+  } else if (viewMode === 'member' && currentMember) {
+    source = currentMember
+  }
+  const s = scoreOf(source)
+  return [s.body, s.mind, s.wisdom, s.action, s.wealth]
+}
+
+export function resolveSandboxOverall(viewMode, familyScores, personalData, currentMember) {
+  let source = personalData
+  if (viewMode === 'family') {
+    source = familyScores
+  } else if (viewMode === 'member' && currentMember) {
+    source = currentMember
+  }
+  return source && source.overallScore != null ? source.overallScore : 0
+}
+
+/**
+ * 太极图中心点击:本人 -> 其他成员 -> 全家 -> 本人。
+ * 找不到当前项时从第一项重新开始,保证不会卡死。
+ */
+export function nextSandboxSelection(options, viewMode, memberId) {
+  const list = Array.isArray(options) && options.length ? options : [
+    { key: 'personal', label: '本人', viewMode: 'personal', memberId: null },
+    { key: 'family', label: '全家', viewMode: 'family', memberId: null }
+  ]
+  let current = -1
+  for (let i = 0; i < list.length; i++) {
+    const option = list[i]
+    if (option.viewMode !== viewMode) continue
+    if (viewMode === 'member' && String(option.memberId) !== String(memberId)) continue
+    current = i
+    break
+  }
+  const next = list[(current + 1) % list.length]
+  return {
+    viewMode: next.viewMode,
+    memberId: next.memberId != null ? next.memberId : null,
+    label: next.label
+  }
+}