Эх сурвалжийг харах

feat: add FamilyRelationGraph canvas component to body/mind/action pages

Sisyphus 4 сар өмнө
parent
commit
fda7656278

+ 518 - 0
cfc-frontend/components/FamilyRelationGraph.vue

@@ -0,0 +1,518 @@
+<template>
+  <view class="family-relation-graph">
+    <!-- 空状态 -->
+    <view class="graph-empty" v-if="!members || members.length === 0">
+      <text class="empty-text">暂无家庭成员</text>
+    </view>
+    <!-- Canvas 画布 -->
+    <canvas
+      v-else
+      canvas-id="relationGraphCanvas"
+      id="relationGraphCanvas"
+      type="2d"
+      @tap="onCanvasTap"
+      :style="{
+        width: canvasWidth + 'px',
+        height: canvasHeight + 'px'
+      }" />
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'FamilyRelationGraph',
+  props: {
+    dimensionCode: {
+      type: String,
+      required: true,
+      validator: function(val) {
+        return ['body', 'mind', 'action'].indexOf(val) !== -1
+      }
+    },
+    selfId: {
+      type: [Number, String],
+      default: null
+    },
+    members: {
+      type: Array,
+      default: function() { return [] }
+    },
+    energyMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    intimacyMap: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data: function() {
+    return {
+      canvasWidth: 320,
+      canvasHeight: 320,
+      dpr: 1,
+      positionedMembers: [],
+      selfPosition: { x: 0, y: 0 },
+      themeColors: {
+        body: '#8D6E63',
+        mind: '#FF6B35',
+        action: '#4CAF50'
+      }
+    }
+  },
+  computed: {
+    themeColor: function() {
+      return this.themeColors[this.dimensionCode] || '#8D6E63'
+    },
+    themeColorRgba: function() {
+      // 主题色15%透明度 for fill
+      var c = this.themeColor
+      return c + '26'  // 15% alpha hex
+    },
+    themeColorStroke: function() {
+      var c = this.themeColor
+      return c + '66'  // 40% alpha hex
+    }
+  },
+  watch: {
+    members: function() { this.draw() },
+    energyMap: function() { this.draw() },
+    intimacyMap: function() { this.draw() }
+  },
+  mounted: function() {
+    this.initCanvas()
+  },
+  methods: {
+    initCanvas: function() {
+      var sysInfo = uni.getSystemInfoSync()
+      this.dpr = sysInfo.pixelRatio || 1
+      var size = uni.upx2px(690)
+      this.canvasWidth = size
+      this.canvasHeight = size
+      var self = this
+      this.$nextTick(function() {
+        if (self.members && self.members.length > 0) {
+          self.draw()
+        }
+      })
+    },
+
+    // 获取亲密度数据(有则用,无则返回默认值)
+    getIntimacy: function(memberId, key) {
+      var data = this.intimacyMap[memberId]
+      if (!data) {
+        var defaults = { closeness: 75, communication: 50, trust: 85 }
+        return defaults[key]
+      }
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 75
+    },
+
+    // 获取能量值
+    getEnergy: function(memberId) {
+      var data = this.energyMap[memberId]
+      if (!data) return 0
+      var key = this.dimensionCode + 'Score'  // bodyScore / mindScore / actionScore
+      var val = data[key]
+      return (typeof val !== 'undefined' && val !== null) ? val : 0
+    },
+
+    // 极坐标布局 — 计算每个成员的位置
+    polarLayout: function() {
+      var centerX = this.canvasWidth / 2
+      var centerY = this.canvasHeight / 2
+      this.selfPosition = { x: centerX, y: centerY }
+
+      var N = this.members.length
+      if (N === 0) {
+        this.positionedMembers = []
+        return
+      }
+
+      var R_INNER = uni.upx2px(160)   // ~74px
+      var R_OUTER = uni.upx2px(480)   // ~222px
+      var positioned = []
+
+      for (var i = 0; i < N; i++) {
+        var member = this.members[i]
+        var intimacy = this.getIntimacy(member.id, 'closeness')
+        // 亲密度0→半径最大,100→半径最小
+        var radius = R_INNER + (100 - intimacy) / 100 * (R_OUTER - R_INNER)
+        // 从正上方(12点钟)开始均匀分布
+        var angle = (2 * Math.PI * i) / N - Math.PI / 2
+        var x = centerX + radius * Math.cos(angle)
+        var y = centerY + radius * Math.sin(angle)
+
+        var isSelf = (member.id === this.selfId)
+        var memberType = member.memberType || 'child'
+
+        positioned.push({
+          id: member.id,
+          nickname: member.nickname || member.name || '成员',
+          memberType: memberType,
+          canvasX: x,
+          canvasY: y,
+          radius: isSelf ? uni.upx2px(150) : uni.upx2px(120),  // self 72px, others 56px -> divide by 2
+          hitRadius: isSelf ? uni.upx2px(180) : uni.upx2px(140),
+          isSelf: isSelf,
+          intimacy: this.getIntimacy(member.id, 'closeness'),
+          communication: this.getIntimacy(member.id, 'communication'),
+          trust: this.getIntimacy(member.id, 'trust')
+        })
+      }
+      this.positionedMembers = positioned
+    },
+
+    // 主绘制流程
+    draw: function() {
+      this.polarLayout()
+      if (this.positionedMembers.length === 0) return
+
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+
+      // 使用 type="2d" 的 node API
+      query.select('#relationGraphCanvas')
+        .fields({ node: true, size: true })
+        .exec(function(res) {
+          if (!res || !res[0]) {
+            // 降级方案:使用 CanvasContext
+            self.drawLegacy()
+            return
+          }
+          var canvas = res[0].node
+          if (!canvas) {
+            self.drawLegacy()
+            return
+          }
+          var ctx = canvas.getContext('2d')
+
+          // 设置 canvas 实际像素(适配高清屏)
+          canvas.width = self.canvasWidth * self.dpr
+          canvas.height = self.canvasHeight * self.dpr
+          ctx.scale(self.dpr, self.dpr)
+
+          // 清除画布
+          ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
+
+          // 绘制连线(先画线,让形状盖在线上)
+          self.drawLines(ctx)
+
+          // 绘制成员
+          for (var i = 0; i < self.positionedMembers.length; i++) {
+            self.drawMember(ctx, self.positionedMembers[i])
+          }
+        })
+    },
+
+    // 降级绘制(使用 CanvasContext 兼容旧版基础库)
+    drawLegacy: function() {
+      var ctx = uni.createCanvasContext('relationGraphCanvas', this)
+      var self = this
+
+      // 清除
+      ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+
+      // 连线
+      self.drawLinesLegacy(ctx)
+
+      // 成员
+      for (var i = 0; i < self.positionedMembers.length; i++) {
+        self.drawMemberLegacy(ctx, self.positionedMembers[i])
+      }
+
+      ctx.draw()
+    },
+
+    // 绘制连线(type=2d mode)
+    drawLines: function(ctx) {
+      var selfPos = this.selfPosition
+      var members = this.positionedMembers
+      for (var i = 0; i < members.length; i++) {
+        var m = members[i]
+        if (m.isSelf) continue
+
+        // 信任度 → 色相 (红0°→黄60°→绿120°)
+        var trust = Math.max(0, Math.min(100, m.trust || 0))
+        var hue = (120 * trust / 100)
+        ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
+        ctx.globalAlpha = 0.8
+
+        // 沟通度 → 线宽 2~10px
+        var comm = Math.max(0, Math.min(100, m.communication || 0))
+        ctx.lineWidth = 2 + (comm / 100) * 8
+
+        // 绘制从 self 到成员的线段
+        ctx.beginPath()
+        ctx.moveTo(selfPos.x, selfPos.y)
+        ctx.lineTo(m.canvasX, m.canvasY)
+        ctx.stroke()
+      }
+      ctx.globalAlpha = 1.0
+    },
+
+    // 绘制连线(legacy mode)
+    drawLinesLegacy: function(ctx) {
+      var selfPos = this.selfPosition
+      var members = this.positionedMembers
+      for (var i = 0; i < members.length; i++) {
+        var m = members[i]
+        if (m.isSelf) continue
+
+        var trust = Math.max(0, Math.min(100, m.trust || 0))
+        var hue = (120 * trust / 100)
+        ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
+        ctx.globalAlpha = 0.8
+
+        var comm = Math.max(0, Math.min(100, m.communication || 0))
+        ctx.setLineWidth(2 + (comm / 100) * 8)
+
+        ctx.beginPath()
+        ctx.moveTo(selfPos.x, selfPos.y)
+        ctx.lineTo(m.canvasX, m.canvasY)
+        ctx.stroke()
+      }
+    },
+
+    // 根据成员类型绘制形状 path
+    drawMemberShape: function(ctx, x, y, r, memberType) {
+      ctx.beginPath()
+      if (memberType === 'parent' || memberType === 'PARENT') {
+        // 圆角正方形
+        var cr = r * 0.25
+        var s = r * 0.8
+        ctx.moveTo(x - s + cr, y - s)
+        ctx.lineTo(x + s - cr, y - s)
+        ctx.arcTo(x + s, y - s, x + s, y - s + cr, cr)
+        ctx.lineTo(x + s, y + s - cr)
+        ctx.arcTo(x + s, y + s, x + s - cr, y + s, cr)
+        ctx.lineTo(x - s + cr, y + s)
+        ctx.arcTo(x - s, y + s, x - s, y + s - cr, cr)
+        ctx.lineTo(x - s, y - s + cr)
+        ctx.arcTo(x - s, y - s, x - s + cr, y - s, cr)
+        ctx.closePath()
+      } else if (memberType === 'child' || memberType === 'CHILD' || memberType === 'children') {
+        // 圆形
+        ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
+      } else if (memberType === 'partner' || memberType === 'spouse' || memberType === 'PARTNER') {
+        // 简化心形
+        var hr = r * 0.48
+        // 左上弧(左心耳)
+        ctx.arc(x - hr * 0.5, y - hr * 0.2, hr, 0, Math.PI * 1.7, true)
+        // 右上弧(右心耳)
+        ctx.arc(x + hr * 0.5, y - hr * 0.2, hr, Math.PI * 1.3, Math.PI * 0.5, true)
+        ctx.closePath()
+      } else {
+        // 默认圆形
+        ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
+      }
+    },
+
+    // 绘制环形能量进度条
+    drawEnergyRing: function(ctx, x, y, radius, fillRatio, color) {
+      var lineWidth = 4
+      // 灰色底圈
+      ctx.beginPath()
+      ctx.arc(x, y, radius, 0, 2 * Math.PI)
+      ctx.strokeStyle = 'rgba(200, 200, 200, 0.35)'
+      ctx.lineWidth = lineWidth
+      ctx.stroke()
+
+      if (fillRatio <= 0) return
+
+      // 彩色进度圈
+      ctx.beginPath()
+      var startAngle = -Math.PI / 2
+      var endAngle = startAngle + fillRatio * 2 * Math.PI
+      ctx.arc(x, y, radius, startAngle, endAngle)
+      ctx.strokeStyle = color
+      ctx.lineWidth = lineWidth
+      ctx.lineCap = 'round'
+      ctx.stroke()
+      ctx.lineCap = 'butt'  // reset
+    },
+
+    // 绘制成员形状(type=2d mode)
+    drawMember: function(ctx, member) {
+      var themeColor = this.themeColor
+      var x = member.canvasX
+      var y = member.canvasY
+      var r = member.radius
+      var energy = this.getEnergy(member.id)
+      var fillRatio = Math.max(0, Math.min(1, energy / 100))
+
+      ctx.save()
+
+      if (member.isSelf) {
+        // self:外层发光描边
+        ctx.beginPath()
+        ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
+        ctx.strokeStyle = themeColor
+        ctx.lineWidth = 3
+        ctx.globalAlpha = 0.4
+        ctx.stroke()
+        ctx.globalAlpha = 1.0
+
+        // 内圆填充
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, 2 * Math.PI)
+        ctx.fillStyle = '#FFFFFF'
+        ctx.fill()
+        ctx.strokeStyle = themeColor
+        ctx.lineWidth = 3
+        ctx.stroke()
+
+        // 中心文字(昵称首字)
+        ctx.fillStyle = themeColor
+        ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        var displayName = (member.nickname && member.nickname.charAt(0)) || '我'
+        ctx.fillText(displayName, x, y)
+      } else {
+        // 其他成员:根据 memberType 选择形状
+        this.drawMemberShape(ctx, x, y, r, member.memberType)
+
+        // 填充主题色(淡)
+        ctx.fillStyle = this.themeColorRgba
+        ctx.fill()
+        ctx.strokeStyle = this.themeColorStroke
+        ctx.lineWidth = 2
+        ctx.stroke()
+
+        // 环形能量进度条(在形状外围)
+        this.drawEnergyRing(ctx, x, y, r + 5, fillRatio, themeColor)
+
+        // 中心昵称首字
+        ctx.fillStyle = '#4A5568'
+        ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        var displayName = (member.nickname && member.nickname.charAt(0)) || '?'
+        ctx.fillText(displayName, x, y)
+      }
+
+      ctx.restore()
+    },
+
+    // 绘制成员(legacy mode)
+    drawMemberLegacy: function(ctx, member) {
+      var themeColor = this.themeColor
+      var x = member.canvasX
+      var y = member.canvasY
+      var r = member.radius
+      var energy = this.getEnergy(member.id)
+      var fillRatio = Math.max(0, Math.min(1, energy / 100))
+
+      if (member.isSelf) {
+        ctx.beginPath()
+        ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
+        ctx.setStrokeStyle(themeColor)
+        ctx.setLineWidth(3)
+        ctx.setGlobalAlpha(0.4)
+        ctx.stroke()
+        ctx.setGlobalAlpha(1.0)
+
+        ctx.beginPath()
+        ctx.arc(x, y, r, 0, 2 * Math.PI)
+        ctx.setFillStyle('#FFFFFF')
+        ctx.fill()
+        ctx.setStrokeStyle(themeColor)
+        ctx.setLineWidth(3)
+        ctx.stroke()
+
+        ctx.setFillStyle(themeColor)
+        ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        var name = (member.nickname && member.nickname.charAt(0)) || '我'
+        ctx.fillText(name, x, y)
+      } else {
+        this.drawMemberShape(ctx, x, y, r, member.memberType)
+        ctx.setFillStyle(this.themeColorRgba)
+        ctx.fill()
+        ctx.setStrokeStyle(this.themeColorStroke)
+        ctx.setLineWidth(2)
+        ctx.stroke()
+
+        // 环形进度条
+        ctx.beginPath()
+        ctx.arc(x, y, r + 5, 0, 2 * Math.PI)
+        ctx.setStrokeStyle('rgba(200, 200, 200, 0.35)')
+        ctx.setLineWidth(4)
+        ctx.stroke()
+
+        if (fillRatio > 0) {
+          ctx.beginPath()
+          var startAngle = -Math.PI / 2
+          var endAngle = startAngle + fillRatio * 2 * Math.PI
+          ctx.arc(x, y, r + 5, startAngle, endAngle)
+          ctx.setStrokeStyle(themeColor)
+          ctx.setLineWidth(4)
+          ctx.setLineCap('round')
+          ctx.stroke()
+          ctx.setLineCap('butt')
+        }
+
+        ctx.setFillStyle('#4A5568')
+        ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        var name = (member.nickname && member.nickname.charAt(0)) || '?'
+        ctx.fillText(name, x, y)
+      }
+    },
+
+    // Canvas 点击命中检测
+    onCanvasTap: function(e) {
+      if (!e || !e.detail || typeof e.detail.x === 'undefined') return
+
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('#relationGraphCanvas').boundingClientRect(function(rect) {
+        if (!rect) return
+        var tapX = e.detail.x - (rect.left || 0)
+        var tapY = e.detail.y - (rect.top || 0)
+
+        for (var i = 0; i < self.positionedMembers.length; i++) {
+          var m = self.positionedMembers[i]
+          var dx = tapX - m.canvasX
+          var dy = tapY - m.canvasY
+          var dist = Math.sqrt(dx * dx + dy * dy)
+          if (dist <= m.hitRadius) {
+            self.$emit('memberTap', {
+              memberId: m.id,
+              memberType: m.memberType,
+              nickname: m.nickname
+            })
+            return
+          }
+        }
+      }).exec()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.family-relation-graph {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+  margin: 10rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  position: relative;
+}
+.graph-empty {
+  padding: 60rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #94A3B8;
+}
+</style>

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

@@ -31,6 +31,16 @@
       :sandboxData="sandboxData"
       :dualDimension="dualDimension" />
 
+    <!-- 关系图谱(登录后可见) — 家庭关系网络 -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="action"
+      :selfId="currentChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+
     <!-- 登录后:用户快捷入口 -->
     <UserQuickEntry
       v-if="isLoggedIn"
@@ -122,10 +132,11 @@ import DimensionProducts from '../../components/DimensionProducts.vue'
 import ContactCard from '../../components/ContactCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers } from '../../utils/api.js'
 
 export default {
-  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyMemberStrip },
+  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyMemberStrip, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -153,6 +164,25 @@ export default {
       ]
     }
   },
+  computed: {
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
   onShow() {
     var token = uni.getStorageSync('token')
     this.isLoggedIn = !!token
@@ -368,6 +398,12 @@ export default {
       }
     },
     goTasks: function() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
+    goMemberDetail: function(member) {
+      if (!member || !member.memberId) return
+      uni.navigateTo({
+        url: '/pages/action/member-action-detail?childId=' + member.memberId
+      })
+    },
     goLogin: function() { uni.navigateTo({ url: '/pages/login/login' }) }
   }
 }

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

@@ -32,6 +32,16 @@
       :sandboxData="sandboxData"
       :dualDimension="dualDimension" />
 
+    <!-- 关系图谱(登录后可见) -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="body"
+      :selfId="activeChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+
     <!-- 登录后:用户快捷入口 -->
     <UserQuickEntry
       v-if="isLoggedIn"
@@ -222,6 +232,7 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers } from '../../utils/api.js'
 import config from '../../config.js'
 
@@ -253,7 +264,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip },
+  components: { PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -291,6 +302,23 @@ export default {
     reportTypeLabel: function() {
       if (!this.latestReport) return ''
       return this.latestReport.reportType === 'gut_flora' ? '肠道检测报告' : '体检报告'
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
     }
   },
   onShow() {
@@ -474,6 +502,12 @@ export default {
       uni.navigateTo({ url: '/pages/discover/index?type=product&domain=body' })
     },
     goTasks: function() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
+    goMemberDetail: function(member) {
+      if (!member || !member.memberId) return
+      uni.navigateTo({
+        url: '/pages/body/member-body-detail?childId=' + member.memberId
+      })
+    },
     sectionVisible: function(key) {
       return this.visibleSections.length === 0 || this.visibleSections.indexOf(key) !== -1
     },

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

@@ -31,6 +31,16 @@
       :sandboxData="sandboxData"
       :dualDimension="dualDimension" />
 
+    <!-- 关系图谱(登录后可见) -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="mind"
+      :selfId="activeChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+
     <!-- 登录后:用户快捷入口 -->
     <UserQuickEntry
       v-if="isLoggedIn"
@@ -311,11 +321,12 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import { getVisibleSections, getFeaturedArticles, getEmiReport, getAssessmentLatestResult, getDailyTip, getReadingStats, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers } from '../../utils/api.js'
 import config from '../../config.js'
 
 export default {
-  components: { PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip },
+  components: { PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -400,6 +411,23 @@ export default {
         this.emiData.agreeablenessScore || 0,
         this.emiData.neuroticismScore || 0
       ]
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
     }
   },
   onShow() {
@@ -783,6 +811,12 @@ export default {
       uni.navigateTo({ url: '/pages/discover/index?type=product&domain=mind' })
     },
     goTasks: function() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
+    goMemberDetail: function(member) {
+      if (!member || !member.memberId) return
+      uni.navigateTo({
+        url: '/pages/mind/member-mind-detail?childId=' + member.memberId
+      })
+    },
 
     goAssessment: function() { uni.navigateTo({ url: '/pages/assessment/apply' }) },
     goLogin: function() { uni.navigateTo({ url: '/pages/login/login' }) }