瀏覽代碼

fix: 智首页渲染层FLOW_DEPTH错误 + web baseURL/401提示修复 + 页面and组件完善

- FamilyRelationGraph.vue: simulationTick 里 simNodes → nodeData,
  修复 TypeError: Cannot read properties of undefined (reading 'length')
  及由此引发的 WeChat FLOW_DEPTH 渲染层错误
- cfc-web/.env.production: VUE_APP_BASE_API= → /admin,
  修复 env 缺失导致 baseURL 显示 ? 的问题
- JwtInterceptor.java: 两处 401 写入前添加
  response.setContentType(application/json; charset=utf-8),
  修复后端拒绝返回时前端 axios 无法解析 message 导致提示显示 ?
- 配套补: FamilyMemberCard/Strip 组件、action/body/mind/parent-index 页面逻辑完善
Xiaogang Liao 3 月之前
父節點
當前提交
26fdc9758b

+ 8 - 6
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -63,9 +63,10 @@ public class JwtInterceptor implements HandlerInterceptor {
         }
 
         if (!StringUtils.hasText(token)) {
-            log.warn("请求 {} 缺少Token", request.getRequestURI());
-            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
-            response.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}");
+        log.warn("请求 {} 缺少Token", request.getRequestURI());
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setContentType("application/json; charset=utf-8");
+        response.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}");
             return false;
         }
 
@@ -94,9 +95,10 @@ public class JwtInterceptor implements HandlerInterceptor {
 
             return true;
         } catch (Exception e) {
-            log.warn("Token解析失败: {}", e.getMessage());
-            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
-            response.getWriter().write("{\"code\":401,\"message\":\"登录已过期,请重新登录\"}");
+        log.warn("Token解析失败: {}", e.getMessage());
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setContentType("application/json; charset=utf-8");
+        response.getWriter().write("{\"code\":401,\"message\":\"登录已过期,请重新登录\"}");
             return false;
         }
     }

+ 116 - 0
cfc-frontend/components/FamilyMemberCard.vue

@@ -0,0 +1,116 @@
+<template>
+  <view class="family-member-card" :class="{ selected: isSelected }" @click="onSelect">
+    <view class="card-avatar-wrap">
+      <image class="card-avatar" :src="member.avatar || defaultAvatar" mode="aspectFill" />
+      <view class="card-role-badge" :style="{ background: roleBadgeColor }">
+        <text class="card-role-text">{{ member.roleLabel || member.relativeLabel || '成员' }}</text>
+      </view>
+    </view>
+    <text class="card-nickname">{{ member.nickname }}</text>
+    <view class="card-selected-mark" v-if="isSelected">
+      <text class="mark-icon">✓</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    member: { type: Object, required: true },
+    isSelected: { type: Boolean, default: false }
+  },
+  computed: {
+    defaultAvatar: function() {
+      return '/static/images/default-avatar.png'
+    },
+    roleBadgeColor: function() {
+      var role = member && (member.effectiveRole || member.memberType)
+      if (role === 'parent') return '#F97316'
+      if (role === 'child') return '#0EA5E9'
+      if (role === 'teacher') return '#8B5CF6'
+      if (role === 'elderly') return '#6B7280'
+      return '#9CA3AF'
+    }
+  },
+  methods: {
+    onSelect: function() {
+      this.$emit('select', this.member)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.family-member-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 120rpx;
+  padding: 12rpx 8rpx;
+  border-radius: 16rpx;
+  background: #FFFFFF;
+  border: 2rpx solid transparent;
+  transition: border-color 0.2s;
+  flex-shrink: 0;
+}
+.family-member-card.selected {
+  border-color: #F97316;
+  box-shadow: 0 0 0 2rpx rgba(249, 115, 22, 0.15);
+}
+.card-avatar-wrap {
+  position: relative;
+  width: 80rpx;
+  height: 80rpx;
+  margin-bottom: 8rpx;
+}
+.card-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: #F3F4F6;
+}
+.card-role-badge {
+  position: absolute;
+  bottom: -4rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  padding: 2rpx 10rpx;
+  border-radius: 12rpx;
+  white-space: nowrap;
+}
+.card-role-text {
+  font-size: 18rpx;
+  color: #FFFFFF;
+  font-weight: 500;
+  line-height: 1.4;
+}
+.card-nickname {
+  font-size: 22rpx;
+  color: #374151;
+  font-weight: 500;
+  text-align: center;
+  margin-top: 4rpx;
+  line-height: 1.3;
+  max-width: 110rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.card-selected-mark {
+  position: absolute;
+  top: 4rpx;
+  right: 4rpx;
+  width: 28rpx;
+  height: 28rpx;
+  border-radius: 50%;
+  background: #F97316;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.mark-icon {
+  font-size: 18rpx;
+  color: #FFFFFF;
+  font-weight: bold;
+}
+</style>

+ 75 - 0
cfc-frontend/components/FamilyMemberStrip.vue

@@ -0,0 +1,75 @@
+<template>
+  <view class="family-member-strip" v-if="members && members.length > 0">
+    <scroll-view
+      class="strip-scroll"
+      scroll-x
+      :scroll-with-animation="true"
+      :enhanced="true"
+      show-scrollbar="false"
+      v-if="members.length >= 4"
+    >
+      <view class="strip-inner scroll-mode">
+        <FamilyMemberCard
+          v-for="member in members"
+          :key="member.id"
+          :member="member"
+          :isSelected="member.id === selectedMemberId"
+          @select="onSelect"
+        />
+      </view>
+    </scroll-view>
+    <view class="strip-inner center-mode" v-else>
+      <FamilyMemberCard
+        v-for="member in members"
+        :key="member.id"
+        :member="member"
+        :isSelected="member.id === selectedMemberId"
+        @select="onSelect"
+      />
+    </view>
+  </view>
+</template>
+
+<script>
+import FamilyMemberCard from '@/components/FamilyMemberCard'
+
+export default {
+  components: { FamilyMemberCard },
+  props: {
+    members: { type: Array, default: function() { return [] } },
+    selectedMemberId: { type: Number, default: null }
+  },
+  methods: {
+    onSelect: function(member) {
+      this.$emit('select', member)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.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;
+}
+/* scroll-view 内的 FamilyMemberCard 需要 display: inline-flex 支持横向滚动 */
+.strip-scroll .strip-inner.scroll-mode {
+  display: inline-flex;
+}
+</style>

+ 2 - 2
cfc-frontend/components/FamilyRelationGraph.vue

@@ -277,8 +277,8 @@ export default {
       if (!this.isSimulating) return
 
       var self = this
-      var nodes = this.simNodes
-      var edges = this.edges
+        var nodes = this.nodeData
+        var edges = this.edges
       var p = this.physics
 
       // 1. 清零力

+ 5 - 1
cfc-frontend/pages/action/index.vue

@@ -135,7 +135,7 @@ import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, ContactCard, ContactImport, FamilyRelationGraph, OctopusGraph, WuxingSandbox },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, ContactCard, ContactImport, FamilyRelationGraph, OctopusGraph, WuxingSandbox, FamilyMemberStrip },
   data() {
     return {
       showTabTransition: true,
@@ -143,6 +143,7 @@ export default {
       role: null,
       currentChildId: null,
       activeChildId: null,
+      selectedMemberId: null,
       currentChildName: '',
       userName: '',
       children: [],
@@ -353,6 +354,9 @@ export default {
       this.showImportModal = false
       this.loadContacts()
     },
+    onFamilyMemberSelect: function(member) {
+      this.selectedMemberId = member.id
+    },
     onChildChanged: function(child) {
       this.currentChildId = child.childId
       this.currentChildName = child.name || child.nickname || '孩子'

+ 12 - 1
cfc-frontend/pages/body/index.vue

@@ -8,6 +8,13 @@
       tagline="科学管理全家健康"
       quote="身体健康是幸福的基础" />
 
+    <!-- 家庭成员选择条 -->
+    <FamilyMemberStrip
+      v-if="isLoggedIn"
+      :members="familyMembersVisible"
+      :selectedMemberId="selectedMemberId"
+      @select="onFamilyMemberSelect" />
+
     <!-- 游客专属区块 -->
     <template v-if="!isLoggedIn">
       <LoginGuideCard
@@ -165,13 +172,14 @@ import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview, getFeaturedArticles } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionProductList, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, HealthCheckinCard, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionProductList, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, HealthCheckinCard, AIFloatingAvatar, FamilyMemberStrip },
   data() {
     return {
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
       activeChildId: null,
+      selectedMemberId: null,
       activeChildName: '',
       userName: '',
       children: [],
@@ -418,6 +426,9 @@ export default {
         }
       }.bind(this)).catch(function() {})
     },
+    onFamilyMemberSelect: function(member) {
+      this.selectedMemberId = member.id
+    },
     onChildChanged: function(child) {
       this.activeChildId = child.childId
       this.activeChildName = child.name || child.nickname || '孩子'

+ 13 - 2
cfc-frontend/pages/index/parent-index.vue

@@ -9,6 +9,13 @@
   tagline="家庭健康成长俱乐部"
   quote="给全家的一站式幸福提案" />
 
+<!-- ===== 家庭成员选择条 ===== -->
+<FamilyMemberStrip
+  v-if="isLoggedIn"
+  :members="familyMembersVisible"
+  :selectedMemberId="selectedMemberId"
+  @select="onFamilyMemberSelect" />
+
 <!-- ===== 家庭成员关系图谱 ===== -->
 <FamilyRelationGraph
   v-if="familyMembersVisible.length > 0"
@@ -361,7 +368,7 @@ import ContactImport from '../../components/ContactImport.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
 
 export default {
-  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport },
+  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip },
   data() {
     return {
       nickname: '',
@@ -410,7 +417,8 @@ export default {
         'linear-gradient(135deg, #8B5CF6, #C084FC)',
         'linear-gradient(135deg, #F97316, #FB923C)',
         'linear-gradient(135deg, #6366F1, #818CF8)'
-      ]
+      ],
+      selectedMemberId: null
     }
   },
   computed: {
@@ -743,6 +751,9 @@ export default {
         url: '/pages/energy/detail?childId=' + childId + '&code=' + code
       })
     },
+    onFamilyMemberSelect(member) {
+      this.selectedMemberId = member.id
+    },
     goMemberDetail(member) {
       if (!member || !member.memberId) return
       uni.navigateTo({

+ 12 - 1
cfc-frontend/pages/mind/index.vue

@@ -8,6 +8,13 @@
       tagline="心智成长 · 智慧人生"
       quote="知己知彼,百战不殆" />
 
+    <!-- 家庭成员选择条 -->
+    <FamilyMemberStrip
+      v-if="isLoggedIn"
+      :members="familyMembersVisible"
+      :selectedMemberId="selectedMemberId"
+      @select="onFamilyMemberSelect" />
+
     <!-- ===== 心·心理 ===== -->
     <view class="section-title-bar">
       <text class="section-title-icon">&#x2764;</text>
@@ -370,13 +377,14 @@ import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergyS
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar, FamilyTianpanCard },
+  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar, FamilyTianpanCard, FamilyMemberStrip },
   data() {
     return {
       isLoggedIn: false,
       role: null,
       showTabTransition: true,
       currentChildId: null,
+      selectedMemberId: null,
       visibleSections: [],
       familyMembersVisible: [],
 
@@ -1049,6 +1057,9 @@ export default {
         }
       }).catch(function() { self.dimensionProducts = [] })
     },
+    onFamilyMemberSelect: function(member) {
+      this.selectedMemberId = member.id
+    },
     onChildChanged: function(child) {
       this.currentChildId = child.childId
       // FIX: 同步提交到 Vuex store