2026-06-19-family-relation-graph.md 19 KB

家庭成员关系图谱实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 创建 Canvas 关系图谱组件 FamilyRelationGraph.vue,集成到 身体/心智/行远 三个 Tab 页,以图形化方式展示家庭成员关系(亲密度/沟通度/信任度)与各维度能量值。

Architecture: 纯前端 Canvas 组件(components/FamilyRelationGraph.vue),接收 members + energyMap + intimacyMap 作为 props,极坐标布局绘制。三个 Tab 页各自已加载 getFamilyEnergySandbox()getVisibleFamilyMembers(),只需将已有数据转换格式传入即可。

Tech Stack: uni-app Vue 2 (Options API), Canvas 2D, 微信小程序基础库 2.9.0+

Spec: docs/superpowers/specs/2026-06-19-family-relation-graph-design.md


文件结构

文件 操作 职责
cfc-frontend/components/FamilyRelationGraph.vue 创建 Canvas 关系图谱组件(核心)
cfc-frontend/pages/body/index.vue 修改 集成图谱,传递数据
cfc-frontend/pages/mind/index.vue 修改 集成图谱,传递数据
cfc-frontend/pages/action/index.vue 修改 集成图谱,传递数据

不涉及后端修改 — 所有数据(家庭可见成员 + 能量沙盘)已有 API 支持。


Task 1: 创建 FamilyRelationGraph.vue 组件

Files:

  • Create: cfc-frontend/components/FamilyRelationGraph.vue

  • [ ] Step 1: 组件骨架与 props 定义

    <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"
      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 }
    }
    },
    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
      this.$nextTick(function() {
        if (this.members && this.members.length > 0) {
          this.draw()
        }
      }.bind(this))
    },
    // 后续步骤填充
    draw: function() {},
    polarLayout: function() {},
    drawLines: function(ctx) {},
    drawMember: function(ctx, member, pos) {},
    onCanvasTap: function(e) {}
    }
    }
    </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;
    }
    .empty-text {
    font-size: 28rpx;
    color: #94A3B8;
    }
    </style>
    
  • [ ] Step 2: 极坐标布局算法

methods 中添加布局方法:

// 维度主题色映射
dimensionTheme: function() {
  var map = {
    body: { color: '#8D6E63', name: '身' },
    mind: { color: '#FF6B35', name: '心' },
    action: { color: '#4CAF50', name: '行' }
  }
  return map[this.dimensionCode] || map.body
},

// 极坐标布局 — 计算每个成员的位置
polarLayout: function() {
  var centerX = this.canvasWidth / 2
  var centerY = this.canvasHeight / 2
  var self = this
  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
    positioned.push({
      id: member.id,
      nickname: member.nickname || member.name || '成员',
      memberType: member.memberType || 'child',
      avatar: member.avatar || '',
      canvasX: x,
      canvasY: y,
      radius: isSelf ? uni.upx2px(150) : uni.upx2px(120),  // self 72px, others 56px
      hitRadius: isSelf ? uni.upx2px(160) : uni.upx2px(130),
      isSelf: isSelf,
      intimacy: this.getIntimacy(member.id, 'closeness'),
      communication: this.getIntimacy(member.id, 'communication'),
      trust: this.getIntimacy(member.id, 'trust')
    })
  }
  this.positionedMembers = positioned
},

// 获取亲密度数据(有则用,无则返回默认值)
getIntimacy: function(memberId, key) {
  var data = this.intimacyMap[memberId]
  if (!data) {
    var defaults = { closeness: 75, communication: 50, trust: 85 }
    return defaults[key]
  }
  return data[key] || 75
},

// 获取能量值
getEnergy: function(memberId) {
  var data = this.energyMap[memberId]
  if (!data) return 0
  var key = this.dimensionCode + 'Score'  // bodyScore / mindScore / actionScore
  return data[key] || 0
}
  • [ ] Step 3: Canvas 主绘制流程

    draw: function() {
    this.polarLayout()
    var self = this
    
    // uni-app Canvas 2D API
    var query = uni.createSelectorQuery().in(this)
    query.select('#relationGraphCanvas')
    .fields({ node: true, size: true })
    .exec(function(res) {
      if (!res || !res[0]) return
      var canvas = res[0].node
      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])
      }
    })
    },
    
    // 获取 Canvas 上下文(备选方案,兼容基础库 2.9+)
    getCanvasContext: function() {
    // type="2d" 模式使用 node API
    // 回退方案适用 createCanvasContext
    return uni.createCanvasContext('relationGraphCanvas', this)
    }
    
  • [ ] Step 4: 绘制连线

    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
    }
    
  • [ ] Step 5: 绘制成员形状

    drawMember: function(ctx, member) {
    var themeColor = this.dimensionTheme().color
    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 = themeColor + '26'  // 15% 透明度
    ctx.fill()
    ctx.strokeStyle = themeColor + '66'  // 40% 透明度
    ctx.lineWidth = 2
    ctx.stroke()
    
    // 环形能量进度条
    this.drawEnergyRing(ctx, x, y, r + 4, 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()
    },
    
    // 根据成员类型绘制形状 path
    drawMemberShape: function(ctx, x, y, r, memberType) {
    ctx.beginPath()
    if (memberType === 'parent') {
    // 圆角正方形
    var cornerRadius = r * 0.25
    var s = r * 0.8
    ctx.moveTo(x - s + cornerRadius, y - s)
    ctx.lineTo(x + s - cornerRadius, y - s)
    ctx.arcTo(x + s, y - s, x + s, y - s + cornerRadius, cornerRadius)
    ctx.lineTo(x + s, y + s - cornerRadius)
    ctx.arcTo(x + s, y + s, x + s - cornerRadius, y + s, cornerRadius)
    ctx.lineTo(x - s + cornerRadius, y + s)
    ctx.arcTo(x - s, y + s, x - s, y + s - cornerRadius, cornerRadius)
    ctx.lineTo(x - s, y - s + cornerRadius)
    ctx.arcTo(x - s, y - s, x - s + cornerRadius, y - s, cornerRadius)
    ctx.closePath()
    } else if (memberType === 'child' || memberType === 'children') {
    // 圆形
    ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
    } else if (memberType === 'partner' || memberType === 'spouse') {
    // 心形(简化版:两个半圆 + 三角)
    var hr = r * 0.5
    // 左上弧
    ctx.arc(x - hr * 0.6, y - hr * 0.3, hr, Math.PI * 0.5, Math.PI * 1.8, true)
    // 右上弧
    ctx.arc(x + hr * 0.6, y - hr * 0.3, hr, Math.PI * 1.2, 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()
    }
    
  • [ ] Step 6: Canvas 点击命中检测

    onCanvasTap: function(e) {
    if (!e || !e.detail || !e.detail.x || !e.detail.y) return
    
    // 获取 touch 坐标相对于 canvas 的位置
    var query = uni.createSelectorQuery().in(this)
    var self = this
    query.select('#relationGraphCanvas').boundingClientRect(function(rect) {
    if (!rect) return
    var tapX = e.detail.x - rect.left
    var tapY = e.detail.y - rect.top
    
    // 遍历所有成员,检测点击命中
    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
      }
    }
    }.bind(this)).exec()
    }
    
  • [ ] Step 7: 验证 — 编译检查

    cd /sc-data/cfc/cfc-frontend
    npm run build:mp-weixin 2>&1 | tail -20
    

Expected: Build succeeds with no errors related to FamilyRelationGraph


Task 2: 集成到 身体(body/index.vue) 页面

Files:

  • Modify: cfc-frontend/pages/body/index.vue (多处修改)

  • [ ] Step 1: 注册组件

在 script 顶部 import 区域添加:

// body/index.vue — 现有 import 区块
import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'

components 注册对象中添加:

components: { /* 现有组件... */ , FamilyRelationGraph }
  • Step 2: 添加 computed 属性转换数据

computed 区块添加两个转换方法:

// body/index.vue — computed
energyMapForGraph: function() {
  // 将 sandboxData.members 转为 { memberId: { bodyScore, mindScore, actionScore } }
  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() {
  // 目前使用默认值,未来可从后端获取
  // 结构: { memberId: { closeness, communication, trust } }
  return {}
}
  • Step 3: 在模板中添加图谱组件

<template> 中找到 FamilyEnergyBar 后面、用户快捷入口之前的区域插入(约第 34 行后):

    <!-- 关系图谱(登录后可见) -->
    <FamilyRelationGraph
      v-if="isLoggedIn && familyMembersVisible.length > 0"
      dimensionCode="body"
      :selfId="activeChildId"
      :members="familyMembersVisible"
      :energyMap="energyMapForGraph"
      :intimacyMap="intimacyMapForGraph"
      @memberTap="goMemberDetail" />
  • Step 4: 添加成员详情跳转方法

methods 中添加:

goMemberDetail: function(member) {
  if (!member || !member.memberId) return
  uni.navigateTo({
    url: '/pages/body/member-body-detail?childId=' + member.memberId
  })
}
  • [ ] Step 5: 验证

    cd /sc-data/cfc/cfc-frontend
    npm run build:mp-weixin 2>&1 | tail -20
    

Expected: Build passes clean


Task 3: 集成到 心智(mind/index.vue) 页面

Files:

  • Modify: cfc-frontend/pages/mind/index.vue

  • [ ] Step 1: 注册组件

Import 添加:

import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'

components 注册:

components: { /* 现有 */ , FamilyRelationGraph }
  • Step 2: 添加 computed 属性

与 Task 2 Step 2 完全相同的 energyMapForGraphintimacyMapForGraph 方法(视图页遵循已有模式,各页面各自定义)

  • Step 3: 模板中添加图谱

插入位置:FamilyEnergyBar 后面(约第 33 行后)

    <!-- 关系图谱(登录后可见) -->
    <FamilyRelationGraph
      v-if="isLoggedIn && familyMembersVisible.length > 0"
      dimensionCode="mind"
      :selfId="activeChildId"
      :members="familyMembersVisible"
      :energyMap="energyMapForGraph"
      :intimacyMap="intimacyMapForGraph"
      @memberTap="goMemberDetail" />
  • [ ] Step 4: 添加跳转方法

    goMemberDetail: function(member) {
    if (!member || !member.memberId) return
    uni.navigateTo({
    url: '/pages/mind/member-mind-detail?childId=' + member.memberId
    })
    }
    
  • [ ] Step 5: 验证

    cd /sc-data/cfc/cfc-frontend
    npm run build:mp-weixin 2>&1 | tail -20
    

Expected: Build passes clean


Task 4: 集成到 行远(action/index.vue) 页面

Files:

  • Modify: cfc-frontend/pages/action/index.vue

  • [ ] Step 1: 注册组件

Import 添加:

import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'

components 注册:

components: { /* 现有包括 ContactCard, ContactImport */, FamilyRelationGraph }
  • Step 2: 添加 computed 属性

与 Task 2 Step 2 相同的 energyMapForGraphintimacyMapForGraph

  • Step 3: 模板中添加图谱

插入到 FamilyEnergyBar 之后、contact-section(重要关系)区块之前(约第 34 行后):

    <!-- 关系图谱(登录后可见) — 家庭关系在上,外部联系人在下 -->
    <FamilyRelationGraph
      v-if="isLoggedIn && familyMembersVisible.length > 0"
      dimensionCode="action"
      :selfId="activeChildId"
      :members="familyMembersVisible"
      :energyMap="energyMapForGraph"
      :intimacyMap="intimacyMapForGraph"
      @memberTap="goMemberDetail" />
  • [ ] Step 4: 添加跳转方法

    goMemberDetail: function(member) {
    if (!member || !member.memberId) return
    uni.navigateTo({
    url: '/pages/action/member-action-detail?childId=' + member.memberId
    })
    }
    
  • [ ] Step 5: 验证

    cd /sc-data/cfc/cfc-frontend
    npm run build:mp-weixin 2>&1 | tail -20
    

Expected: Build passes clean


Task 5: 最终验证与联调

  • [ ] Step 1: 编译全量检查

    cd /sc-data/cfc/cfc-frontend
    npm run build:mp-weixin 2>&1
    

Expected: 无编译错误,无模板语法错误

  • Step 2: 微信小程序预览(开发者工具)

手动检查要点:

  1. 身体页:图谱显示正常,形状填充 body 能量值,点击跳转到 member-body-detail
  2. 心智页:图谱显示正常,形状填充 mind 能量值,点击跳转到 member-mind-detail
  3. 行远页:图谱在上方、外部联系人 ContactCard 在下方,两者共存
  4. 游客状态:图谱不显示(v-if="isLoggedIn"
  5. 空成员:显示「暂无家庭成员」提示
  6. 连线颜色随信任度变化(默认全部绿色)

自检清单

检查项 状态
所有 spec 需求有对应 task ✅ §4-§9 全覆盖
无占位符(TBD/TODO)
路径类型一致 ✅ (memberId 统一)
三个页面模式一致 ✅ body/mind/action 遵循相同模式
边界状态处理 ✅ 空成员、数据缺失、游客态
编译验证步骤 ✅ 每个 task 含 build 验证
无后端变更依赖 ✅ 仅前端修改