2026-06-22-family-relation-graph-redesign.md 24 KB

FamilyRelationGraph 重构实施计划

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: 用 View+Canvas 混合架构重写 FamilyRelationGraph.vue,实现人数自适应布局、能量映射节点大小、Canvas 连线、原生 View 点击。

Architecture: 容器内分两层——Canvas 层(absolute, z-index:1)只画 self→other 关系线;View 层(relative, z-index:2)用 flex 布局渲染节点。mounted 后 $nextTick 读节点坐标,再在 Canvas 上画线。Props 接口不变,上层零改动。

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


File Structure

File Action Responsibility
cfc-frontend/components/FamilyRelationGraph.vue 重写 整个组件,View+Canvas 混合架构
cfc-frontend/__tests__/components/FamilyRelationGraph.test.js 新建 组件单元测试

Task 1: 搭建组件骨架 — template + props + computed

Files:

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

  • [ ] Step 1: 写组件骨架(template + script + style 的基本结构)

完全替换 FamilyRelationGraph.vue 内容为:

<template>
  <!-- 1人不渲染 -->
  <view class="family-relation-graph" v-if="members && members.length >= 2">
    <!-- Canvas 连线层 -->
    <canvas
      class="graph-canvas"
      canvas-id="relationGraphCanvas"
      id="relationGraphCanvas"
      type="2d"
      :style="{
        width: canvasWidth + 'px',
        height: canvasHeight + 'px'
      }" />
    <!-- View 节点层 -->
    <view class="graph-nodes">
      <!-- 2-3人: 单行布局 -->
      <template v-if="layoutMode === 'row'">
        <view class="row-layout">
          <view
            v-for="m in displayOrder"
            :key="m.id"
            class="member-node"
            :class="{ 'is-self': m.isSelf, 'is-parent': m.memberType === 'parent' }"
            :style="{ width: m.avatarSize + 'rpx' }"
            :data-member-id="m.id"
            :data-member-type="m.memberType"
            :data-member-nickname="m.nickname"
            @tap="onMemberTap">
            <view
              class="member-avatar"
              :class="{ 'avatar-square': m.memberType === 'parent' }"
              :style="{
                width: m.avatarSize + 'rpx',
                height: m.avatarSize + 'rpx',
                backgroundColor: m.isSelf ? '#FFFFFF' : themeColorRgba,
                borderColor: m.isSelf ? themeColor : 'transparent'
              }">
              <text class="avatar-letter" :style="{ color: m.isSelf ? themeColor : '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
            </view>
            <text class="member-name" :style="{ color: m.isSelf ? themeColor : '#94A3B8', fontWeight: m.isSelf ? 'bold' : 'normal' }">{{ m.nickname }}</text>
          </view>
        </view>
      </template>
      <!-- 4+人: 多行布局 -->
      <template v-else>
        <view class="self-row">
          <view
            class="member-node is-self"
            :style="{ width: selfMember.avatarSize + 'rpx' }"
            :data-member-id="selfMember.id"
            :data-member-type="selfMember.memberType"
            :data-member-nickname="selfMember.nickname"
            @tap="onMemberTap">
            <view
              class="member-avatar"
              :style="{
                width: selfMember.avatarSize + 'rpx',
                height: selfMember.avatarSize + 'rpx',
                backgroundColor: '#FFFFFF',
                borderColor: themeColor
              }">
              <text class="avatar-letter" :style="{ color: themeColor, fontSize: selfMember.avatarSize * 0.45 + 'rpx' }">{{ selfMember.displayName }}</text>
            </view>
            <text class="member-name" :style="{ color: themeColor, fontWeight: 'bold' }">{{ selfMember.nickname }}</text>
          </view>
        </view>
        <view class="others-row">
          <view
            v-for="m in otherMembers"
            :key="m.id"
            class="member-node"
            :class="{ 'is-parent': m.memberType === 'parent' }"
            :style="{ width: m.avatarSize + 'rpx' }"
            :data-member-id="m.id"
            :data-member-type="m.memberType"
            :data-member-nickname="m.nickname"
            @tap="onMemberTap">
            <view
              class="member-avatar"
              :class="{ 'avatar-square': m.memberType === 'parent' }"
              :style="{
                width: m.avatarSize + 'rpx',
                height: m.avatarSize + 'rpx',
                backgroundColor: themeColorRgba
              }">
              <text class="avatar-letter" :style="{ color: '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
            </view>
            <text class="member-name" :style="{ color: '#94A3B8' }">{{ m.nickname }}</text>
          </view>
        </view>
      </template>
    </view>
  </view>
  <!-- 0人空状态 -->
  <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
    <text class="empty-text">暂无家庭成员</text>
  </view>
</template>

<script>
export default {
  name: 'FamilyRelationGraph',
  props: {
    dimensionCode: {
      type: String,
      required: true,
      validator: function(val) {
        return ['body', 'mind', 'action', 'home'].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 {} }
    },
    interactive: {
      type: Boolean,
      default: true
    }
  },
  data: function() {
    return {
      canvasWidth: 300,
      canvasHeight: 300,
      dpr: 1,
      themeColors: {
        body: '#8D6E63',
        mind: '#FF6B35',
        action: '#4CAF50',
        home: '#5B9BD5'
      }
    }
  },
  computed: {
    themeColor: function() {
      return this.themeColors[this.dimensionCode] || '#8D6E63'
    },
    themeColorRgba: function() {
      return this.themeColor + '26'
    },
    layoutMode: function() {
      if (!this.members || this.members.length <= 3) return 'row'
      return 'multi-row'
    },
    selfMember: function() {
      var self = this
      var found = null
      for (var i = 0; i < this.members.length; i++) {
        if (this.members[i].id == this.selfId) {
          found = this.members[i]
          break
        }
      }
      if (!found && this.members.length > 0) {
        found = this.members[0]
      }
      if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
      var energy = this.getEnergy(found.id)
      var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
      return {
        id: found.id,
        nickname: found.nickname || found.name || '我',
        memberType: found.memberType || 'child',
        isSelf: true,
        displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
        avatarSize: size
      }
    },
    otherMembers: function() {
      var self = this
      return this.members
        .filter(function(m) { return m.id != self.selfId })
        .map(function(m) {
          var energy = self.getEnergy(m.id)
          var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
          return {
            id: m.id,
            nickname: m.nickname || m.name || '成员',
            memberType: m.memberType || 'child',
            isSelf: false,
            displayName: (m.nickname && m.nickname.charAt(0)) || (m.name && m.name.charAt(0)) || '?',
            avatarSize: size
          }
        })
    },
    displayOrder: function() {
      if (this.layoutMode !== 'row') return []
      var others = this.otherMembers
      if (this.members.length === 2) {
        return [this.selfMember].concat(others)
      }
      if (this.members.length === 3 && others.length >= 2) {
        return [others[0], this.selfMember, others[1]]
      }
      return [this.selfMember].concat(others)
    }
  },
  watch: {
    members: function() { this.scheduleDraw() },
    energyMap: function() { this.scheduleDraw() },
    intimacyMap: function() { this.scheduleDraw() }
  },
  mounted: function() {
    this.dpr = uni.getSystemInfoSync().pixelRatio || 1
    this.scheduleDraw()
  },
  methods: {
    // 占位,后续 Task 填充
    getEnergy: function(memberId) {
      var data = this.energyMap[memberId]
      if (!data) return 0
      var key = this.dimensionCode + 'Score'
      var val = data[key]
      return (typeof val !== 'undefined' && val !== null) ? val : 0
    },
    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
    },
    scheduleDraw: function() {
      var self = this
      this.$nextTick(function() {
        self.updateCanvasSize()
        self.$nextTick(function() {
          self.drawLines()
        })
      })
    },
    updateCanvasSize: function() {
      var self = this
      var query = uni.createSelectorQuery().in(this)
      query.select('.graph-nodes').boundingClientRect(function(rect) {
        if (!rect) return
        self.canvasWidth = rect.width
        self.canvasHeight = rect.height
      }).exec()
    },
    drawLines: function() {
      // Task 2 填充
    },
    onMemberTap: function(e) {
      // Task 3 填充
    }
  }
}
</script>

<style scoped>
.family-relation-graph {
  position: relative;
  padding: 24rpx 30rpx;
  margin: 10rpx 0;
  background: #FFFFFF;
  border-radius: 24rpx;
}
.graph-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 60rpx 0;
}
.empty-text {
  font-size: 28rpx;
  color: #94A3B8;
}
/* Canvas 层: absolute, 只画线 */
.graph-canvas {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  pointer-events: none;
}
/* View 节点层: relative, flex 布局 */
.graph-nodes {
  position: relative;
  z-index: 2;
}
/* 2-3人单行 */
.row-layout {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-around;
}
/* 4+人多行 */
.self-row {
  display: flex;
  justify-content: center;
  margin-bottom: 16rpx;
}
.others-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
}
/* 成员节点 */
.member-node {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 0 12rpx;
  flex-shrink: 0;
}
/* 头像 */
.member-avatar {
  border-radius: 50%;
  border: 2rpx solid transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}
.member-avatar.avatar-square {
  border-radius: 16rpx;
}
.is-self .member-avatar {
  border-style: solid;
}
.avatar-letter {
  font-weight: bold;
  text-align: center;
}
/* 昵称 */
.member-name {
  font-size: 22rpx;
  margin-top: 6rpx;
  max-width: 120rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: center;
}
</style>
  • [ ] Step 2: Commit

    git add cfc-frontend/components/FamilyRelationGraph.vue
    git commit -m "refactor: FamilyRelationGraph View+Canvas hybrid skeleton"
    

Task 2: 实现 Canvas 连线绘制

Files:

  • Modify: cfc-frontend/components/FamilyRelationGraph.vue (drawLines method + drawLinesLegacy)

  • [ ] Step 1: 替换 drawLines 空方法为完整实现在 methods 中

drawLines: function() { // Task 2 填充 } 替换为:

drawLines: function() {
  var self = this
  if (!this.members || this.members.length < 2) return

  var query = uni.createSelectorQuery().in(this)
  query.selectAll('.member-node').boundingClientRect()
  query.select('.graph-nodes').boundingClientRect()
  query.exec(function(res) {
    if (!res || !res[0] || res[0].length === 0) return

    var nodes = res[0]  // 每个 member-node 的 boundingClientRect
    var containerRect = res[1]

    // 找 self 节点位置
    var selfRect = null
    var otherRects = []
    for (var i = 0; i < nodes.length; i++) {
      var n = nodes[i]
      var memberId = n.dataset || null
      // 通过 data 属性无法在 boundingClientRect 获取,改用索引匹配
    }

    // 改用另一种方案:直接通过 class 区分 self 和 other
    var selfQuery = uni.createSelectorQuery().in(self)
    selfQuery.select('.is-self .member-avatar').boundingClientRect()
    selfQuery.selectAll('.member-node:not(.is-self) .member-avatar').boundingClientRect()
    selfQuery.select('.graph-nodes').boundingClientRect()
    selfQuery.exec(function(res2) {
      if (!res2 || !res2[0]) return

      var selfAvatarRect = res2[0]
      var otherAvatarRects = res2[1] || []
      var containerRect2 = res2[2]
      if (!selfAvatarRect) return

      // self 中心坐标(相对于容器)
      var containerLeft = containerRect2 ? containerRect2.left : 0
      var containerTop = containerRect2 ? containerRect2.top : 0
      var selfCX = selfAvatarRect.left + selfAvatarRect.width / 2 - containerLeft
      var selfCY = selfAvatarRect.top + selfAvatarRect.height / 2 - containerTop
      var selfR = selfAvatarRect.width / 2

      // Canvas type=2d 绘制
      var canvasQuery = uni.createSelectorQuery().in(self)
      canvasQuery.select('#relationGraphCanvas')
        .fields({ node: true, size: true })
        .exec(function(canvasRes) {
          if (!canvasRes || !canvasRes[0] || !canvasRes[0].node) {
            self.drawLinesLegacy(selfCX, selfCY, selfR, otherAvatarRects, containerLeft, containerTop)
            return
          }
          var canvas = canvasRes[0].node
          var ctx = canvas.getContext('2d')

          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)

          for (var j = 0; j < otherAvatarRects.length; j++) {
            var rect = otherAvatarRects[j]
            var otherCX = rect.left + rect.width / 2 - containerLeft
            var otherCY = rect.top + rect.height / 2 - containerTop
            var otherR = rect.width / 2

            // 对应的 other member
            var otherMember = self.otherMembers[j]
            if (!otherMember) continue

            // 信任度 → 线颜色
            var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
            var hue = (120 * trust / 100)
            ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
            ctx.globalAlpha = 0.6

            // 沟通度 → 线粗细
            var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
            ctx.lineWidth = 2 + (comm / 100) * 8

            // 计算边缘起止点(中心到中心缩进一个半径)
            var dx = otherCX - selfCX
            var dy = otherCY - selfCY
            var dist = Math.sqrt(dx * dx + dy * dy)
            if (dist < 1) continue
            var ux = dx / dist
            var uy = dy / dist

            ctx.beginPath()
            ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
            ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
            ctx.stroke()
          }
          ctx.globalAlpha = 1.0
        })
    })
  })
},

// 降级连线(CanvasContext)
drawLinesLegacy: function(selfCX, selfCY, selfR, otherRects, containerLeft, containerTop) {
  var ctx = uni.createCanvasContext('relationGraphCanvas', this)
  var self = this
  ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

  for (var j = 0; j < otherRects.length; j++) {
    var rect = otherRects[j]
    var otherCX = rect.left + rect.width / 2 - containerLeft
    var otherCY = rect.top + rect.height / 2 - containerTop
    var otherR = rect.width / 2

    var otherMember = self.otherMembers[j]
    if (!otherMember) continue

    var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
    var hue = (120 * trust / 100)
    ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
    ctx.globalAlpha = 0.6

    var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
    ctx.setLineWidth(2 + (comm / 100) * 8)

    var dx = otherCX - selfCX
    var dy = otherCY - selfCY
    var dist = Math.sqrt(dx * dx + dy * dy)
    if (dist < 1) continue
    var ux = dx / dist
    var uy = dy / dist

    ctx.beginPath()
    ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
    ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
    ctx.stroke()
  }
  ctx.draw()
},
  • [ ] Step 2: Commit

    git add cfc-frontend/components/FamilyRelationGraph.vue
    git commit -m "feat: implement Canvas line drawing for FamilyRelationGraph"
    

Task 3: 实现点击事件 + 清理

Files:

  • Modify: cfc-frontend/components/FamilyRelationGraph.vue (onMemberTap method)

  • [ ] Step 1: 替换 onMemberTap 空方法为完整实现

onMemberTap: function(e) { // Task 3 填充 } 替换为:

onMemberTap: function(e) {
  if (!this.interactive) return
  var target = e.currentTarget
  if (!target || !target.dataset) return
  var ds = target.dataset
  this.$emit('memberTap', {
    memberId: ds.memberId,
    memberType: ds.memberType,
    nickname: ds.memberNickname
  })
},

注意:uni-app 中 dataset 的 key 会转为小驼峰,data-member-iddataset.memberIddata-member-typedataset.memberTypedata-member-nicknamedataset.memberNickname

  • Step 2: 移除不再使用的 data 属性和旧代码

在 data() 中移除:

  • positionedMembers: []
  • selfPosition: { x: 0, y: 0 }

在 methods 中移除:

  • polarLayout()
  • drawMember()
  • drawMemberLegacy()
  • drawMemberShape()
  • drawEnergyRing()
  • initCanvas()
  • onCanvasTap()

在 mounted 中确认是 this.dpr = ... + this.scheduleDraw(),不含旧的 this.initCanvas()

  • [ ] Step 3: Commit

    git add cfc-frontend/components/FamilyRelationGraph.vue
    git commit -m "feat: implement View tap events and remove old Canvas hit-detection code"
    

Task 4: 编写单元测试

Files:

  • Create: cfc-frontend/__tests__/components/FamilyRelationGraph.test.js

  • [ ] Step 1: 编写组件测试

    // FamilyRelationGraph.test.js
    var assert = require('assert')
    
    // 模拟组件的核心计算逻辑(不依赖 uni-app 运行时)
    function getEnergy(energyMap, memberId, dimensionCode) {
    var data = energyMap[memberId]
    if (!data) return 0
    var key = dimensionCode + 'Score'
    var val = data[key]
    return (typeof val !== 'undefined' && val !== null) ? val : 0
    }
    
    function getAvatarSize(energy, isSelf) {
    var clamped = Math.max(0, Math.min(100, energy))
    var base = 64 + (clamped / 100) * 56
    if (isSelf) return Math.max(96, base + 16)
    return base
    }
    
    function getLayoutMode(memberCount) {
    if (memberCount <= 3) return 'row'
    return 'multi-row'
    }
    
    function getDisplayOrder(members, selfId) {
    var self = null
    var others = []
    for (var i = 0; i < members.length; i++) {
    if (members[i].id == selfId) {
      self = members[i]
    } else {
      others.push(members[i])
    }
    }
    if (members.length === 2) return [self, others[0]]
    if (members.length === 3 && others.length >= 2) return [others[0], self, others[1]]
    return [self].concat(others)
    }
    
    function getLineEndPoints(selfCX, selfCY, selfR, otherCX, otherCY, otherR) {
    var dx = otherCX - selfCX
    var dy = otherCY - selfCY
    var dist = Math.sqrt(dx * dx + dy * dy)
    if (dist < 1) return null
    var ux = dx / dist
    var uy = dy / dist
    return {
    x1: selfCX + ux * selfR,
    y1: selfCY + uy * selfR,
    x2: otherCX - ux * otherR,
    y2: otherCY - uy * otherR
    }
    }
    
    // --- Tests ---
    
    // 能量→尺寸映射
    assert.strictEqual(getAvatarSize(0, false), 64, 'min size for other at energy 0')
    assert.strictEqual(getAvatarSize(100, false), 120, 'max size for other at energy 100')
    assert.strictEqual(getAvatarSize(50, false), 92, 'mid size for other at energy 50')
    assert.strictEqual(getAvatarSize(0, true), 96, 'self min is 96')
    assert.strictEqual(getAvatarSize(100, true), 120, 'self at max energy is 120 (capped by 80+16+56 but max formula: max(96, 80+16+56=152→capped to 120))')
    assert.strictEqual(getAvatarSize(-10, false), 64, 'negative energy clamped to 0')
    assert.strictEqual(getAvatarSize(150, false), 120, 'energy >100 clamped to 100')
    
    // 布局模式
    assert.strictEqual(getLayoutMode(0), 'row', '0 members → row (but v-if hides)')
    assert.strictEqual(getLayoutMode(2), 'row', '2 members → row')
    assert.strictEqual(getLayoutMode(3), 'row', '3 members → row')
    assert.strictEqual(getLayoutMode(4), 'multi-row', '4 members → multi-row')
    assert.strictEqual(getLayoutMode(10), 'multi-row', '10 members → multi-row')
    
    // 2人排列
    var m2 = [{ id: 1, nickname: '爸' }, { id: 2, nickname: '我' }]
    var order2 = getDisplayOrder(m2, 2)
    assert.strictEqual(order2[0].id, 2, '2-person: self first')
    assert.strictEqual(order2[1].id, 1, '2-person: other second')
    
    // 3人排列
    var m3 = [{ id: 1, nickname: '爸' }, { id: 2, nickname: '我' }, { id: 3, nickname: '妈' }]
    var order3 = getDisplayOrder(m3, 2)
    assert.strictEqual(order3[0].id, 1, '3-person: other1 left')
    assert.strictEqual(order3[1].id, 2, '3-person: self center')
    assert.strictEqual(order3[2].id, 3, '3-person: other2 right')
    
    // 连线端点计算
    var pts = getLineEndPoints(100, 100, 30, 300, 100, 20)
    assert.strictEqual(pts.x1, 130, 'line start x = self center + self radius')
    assert.strictEqual(pts.x2, 280, 'line end x = other center - other radius')
    assert.strictEqual(pts.y1, 100, 'line start y = self center y')
    assert.strictEqual(pts.y2, 100, 'line end y = other center y')
    
    // 能量值读取
    var em = { 1: { bodyScore: 80, mindScore: 40 } }
    assert.strictEqual(getEnergy(em, 1, 'body'), 80, 'get body energy')
    assert.strictEqual(getEnergy(em, 1, 'mind'), 40, 'get mind energy')
    assert.strictEqual(getEnergy(em, 2, 'body'), 0, 'missing member returns 0')
    
    console.log('All FamilyRelationGraph tests passed!')
    
  • [ ] Step 2: 运行测试

    node cfc-frontend/__tests__/components/FamilyRelationGraph.test.js
    

Expected: All FamilyRelationGraph tests passed!

  • [ ] Step 3: Commit

    git add cfc-frontend/__tests__/components/FamilyRelationGraph.test.js
    git commit -m "test: add unit tests for FamilyRelationGraph core logic"
    

Task 5: 集成验证

Files:

  • No new files

  • [ ] Step 1: 确认所有引用页面 Props 兼容

grep 确认以下文件中 FamilyRelationGraph 的使用方式不变:

  • pages/index/parent-index.vue
  • pages/body/index.vue
  • pages/mind/index.vue
  • pages/action/index.vue
  • pages/body/member-body-detail.vue
  • pages/mind/member-mind-detail.vue
  • pages/action/member-action-detail.vue
  • pages/index/member-home-detail.vue

每个页面应使用:dimensionCode, selfId, members, energyMap, intimacyMap, interactive 这些 prop,无新增或删除。

  • [ ] Step 2: 运行全量测试

    node cfc-frontend/__tests__/components/FamilyRelationGraph.test.js
    

Expected: All FamilyRelationGraph tests passed!

  • [ ] Step 3: Final commit

    git add -A
    git commit -m "refactor: FamilyRelationGraph complete — View+Canvas hybrid layout"
    

Self-Review Checklist

  • Spec coverage: Section 5 (布局规则) → Task 1 computed; Section 6 (节点样式) → Task 1 template+style; Section 7 (连线) → Task 2; Section 9 (交互) → Task 3; Section 4 (接口兼容) → Task 5
  • Placeholder scan: No TBD/TODO/fill-in-later. All code steps contain full implementation.
  • Type consistency: selfMember, otherMembers, displayOrder computed names match template v-for binds. drawLines/drawLinesLegacy signatures consistent. memberTap event payload {memberId, memberType, nickname} matches spec Section 4.