Просмотр исходного кода

docs: add FamilyRelationGraph redesign spec and implementation plan

Spec covers: layout rules (row/multi-row), node styles (avatar size mapping energy), line attributes (trust→color, communication→width), rendering flow, interaction model.
Plan covers: 5 tasks — skeleton, Canvas lines, interaction, unit tests, integration verification.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 4 месяцев назад
Родитель
Сommit
2c55b3b6b0

+ 767 - 0
docs/superpowers/plans/2026-06-22-family-relation-graph-redesign.md

@@ -0,0 +1,767 @@
+# 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 内容为:
+
+```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**
+
+```bash
+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 填充 }` 替换为:
+
+```javascript
+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**
+
+```bash
+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 填充 }` 替换为:
+
+```javascript
+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-id` → `dataset.memberId`,`data-member-type` → `dataset.memberType`,`data-member-nickname` → `dataset.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**
+
+```bash
+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: 编写组件测试**
+
+```javascript
+// 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: 运行测试**
+
+```bash
+node cfc-frontend/__tests__/components/FamilyRelationGraph.test.js
+```
+
+Expected: `All FamilyRelationGraph tests passed!`
+
+- [ ] **Step 3: Commit**
+
+```bash
+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: 运行全量测试**
+
+```bash
+node cfc-frontend/__tests__/components/FamilyRelationGraph.test.js
+```
+
+Expected: `All FamilyRelationGraph tests passed!`
+
+- [ ] **Step 3: Final commit**
+
+```bash
+git add -A
+git commit -m "refactor: FamilyRelationGraph complete — View+Canvas hybrid layout"
+```
+
+---
+
+## Self-Review Checklist
+
+- [x] **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
+- [x] **Placeholder scan**: No TBD/TODO/fill-in-later. All code steps contain full implementation.
+- [x] **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.

+ 210 - 0
docs/superpowers/specs/2026-06-22-family-relation-graph-redesign.md

@@ -0,0 +1,210 @@
+# FamilyRelationGraph 重构设计
+
+**日期**: 2026-06-22  
+**状态**: 待审核  
+**范围**: cfc-frontend/components/FamilyRelationGraph.vue 完整重构
+
+---
+
+## 1. 背景
+
+现有 FamilyRelationGraph 使用 Canvas 2D 极坐标布局绘制家庭成员关系图。存在以下问题:
+
+- 极坐标布局按角度均匀分布成员,不区分人数,2人和10人用同一套逻辑
+- 布局规则不直观:用户期望2人横排、3人自己居中、多人紧凑排列
+- 节点大小固定(self vs other 两档),无法直观表达能量差异
+- Canvas 点击命中检测复杂,需要手动计算距离
+
+## 2. 目标
+
+用 View + Canvas 混合架构替换纯 Canvas 实现,实现:
+
+- **条件渲染**:1人时不显示组件
+- **自适应布局**:2人/3人横排,4+人多行紧凑排列
+- **能量可视化**:节点大小映射能量值
+- **保留连线**:Canvas 画 self→其他成员的关系线
+- **原生点击**:View 元素原生 tap 事件,零命中检测
+
+## 3. 已确认决策
+
+| 决策项 | 选择 | 理由 |
+|--------|------|------|
+| 与 FamilyRelationGraph 关系 | 完全替换(同名同接口) | 避免上层调用方改动 |
+| 渲染架构 | View(布局+交互)+ Canvas(只画线) | 方案A,flex 布局天然适配多人数规则 |
+| 每个成员展示 | 头像 + 昵称 | 能量值用节点大小体现 |
+| 能量可视化方式 | 节点大小(不用环形进度条) | 用户明确要求 |
+| 连线 | 保留,Canvas 绘制 | 信任度→颜色,沟通度→粗细 |
+
+## 4. 组件接口
+
+### Props(保持兼容)
+
+| Prop | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `dimensionCode` | String | 必填 | 维度编码:body/mind/action/home,决定主题色 |
+| `selfId` | Number\|String | null | 当前用户 ID |
+| `members` | Array | `[]` | 成员列表 `[{id, nickname, memberType}]` |
+| `energyMap` | Object | `{}` | 能量数据 `{memberId: {bodyScore, mindScore, actionScore}}` |
+| `intimacyMap` | Object | `{}` | 亲密度 `{memberId: {closeness, communication, trust}}` |
+| `interactive` | Boolean | true | 是否可点击 |
+
+### Events
+
+| 事件 | 参数 | 触发条件 |
+|------|------|---------|
+| `memberTap` | `{memberId, memberType, nickname}` | interactive=true 时点击非 self 成员 |
+
+## 5. 布局规则
+
+### 5.1 条件渲染
+
+- `members.length === 0`:渲染空状态(文字"暂无家庭成员")
+- `members.length === 1`:**不渲染**(组件返回空 v-if,非 v-show 隐藏)
+- `members.length >= 2`:按规则渲染
+
+### 5.2 人数对应布局
+
+**2 人**:
+```
+容器: flex, flex-direction: row, justify-content: space-around, align-items: center
+[我]        [对方]
+```
+self 在左,对方在右。单行横排。
+
+**3 人**:
+```
+容器: flex, flex-direction: row, justify-content: space-between, align-items: center
+数据重排: [其他, self, 其他]
+[对方]     [我]     [对方]
+```
+self 在中间,其余两人分列左右。
+
+**4+ 人**:
+```
+两行结构:
+  第一行: flex, justify-content: center — 只有 [我]
+  第二行+: flex, flex-wrap: wrap, justify-content: center — 其他成员
+
+          [我]
+  [爸] [妈] [大宝] [二宝]
+```
+
+核心原则:**用宽度,省高度**。其他成员尽量排在一行,排不下才换行。
+
+### 5.3 节点排列算法
+
+1. 从 `members` 中分离出 self 和 others
+2. 按 `layoutMode` 排列:
+   - `layoutMode = 'row'` (2-3人): self 和 others 在同一行,按 `[other1, self, other2, ...]` 排列
+   - `layoutMode = 'multi-row'` (4+人): self 占第一行居中,others 在第二行 flex-wrap 排列
+
+## 6. 节点样式
+
+### 6.1 头像尺寸
+
+能量值映射到头像直径:
+
+```javascript
+//能量值来自当前维度的 score(如 dimensionCode='body' 则取 bodyScore)
+//energy = energyMap[member.id][dimensionCode + 'Score'] || 0
+//范围 0-100
+
+size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56  // 单位: rpx, 范围 64-120,clamped to 0-100
+```
+
+- 最小直径:64rpx(能量 0)
+- 最大直径:120rpx(能量 100)
+- self 额外 +16rpx,且不小于 96rpx
+
+### 6.2 头像形状
+
+| memberType | 形状 | CSS |
+|-----------|------|-----|
+| parent | 圆角方形 | `border-radius: 16rpx` |
+| child | 圆形 | `border-radius: 50%` |
+| partner/spouse | 圆形 + 底部心形装饰 | 圆形 + `::after` 心形 |
+
+### 6.3 头像内容
+
+- 背景色:主题色 15% 透明度
+- 文字:昵称首字,居中
+- self:白色背景 + 主题色边框(2rpx) + 主题色文字
+- other:主题色淡背景 + 灰色文字
+
+### 6.4 昵称
+
+- 位置:头像正下方
+- 字号:22rpx
+- 颜色:self 用主题色+加粗,other 用 #94A3B8
+- 截断:单行,overflow: hidden, text-overflow: ellipsis
+
+## 7. 连线
+
+### 7.1 Canvas 层
+
+- 绝对定位在 View 节点层下方(z-index: 1)
+- 尺寸与容器一致
+- 只负责画线,不画节点
+
+### 7.2 线条属性
+
+| 数据维度 | 映射目标 | 映射规则 |
+|---------|---------|---------|
+| 信任度 trust | 线颜色 | 0 → hsl(0°,75%,45%) 红色; 100 → hsl(120°,75%,45%) 绿色 |
+| 沟通度 communication | 线粗细 | 0 → 2px; 100 → 10px; 公式: `2 + (comm/100)*8` |
+| 全局 | 透明度 | α = 0.6 |
+
+### 7.3 线条起止点
+
+- 起点:self 头像边缘(非中心)
+- 终点:other 头像边缘(非中心)
+- 计算方式:从 self 中心到 other 中心的向量,沿向量方向缩短一个半径距离到边缘
+
+### 7.4 兼容性
+
+- 优先使用 type=2d Canvas API
+- 降级为 CanvasContext(保留 drawLegacy 模式)
+
+## 8. 渲染流程
+
+```
+mounted / watch(members, energyMap, intimacyMap):
+  1. Vue 渲染 View 节点(flex 自动布局完成)
+  2. $nextTick → 
+     a. 遍历每个节点,通过 getBoundingClientRect 读取屏幕坐标
+     b. 计算 self 中心坐标和每个 other 中心坐标
+     c. 初始化 Canvas(type=2d),设置 dpr
+     d. 清除 Canvas → drawLines() 画所有 self→other 连线
+```
+
+## 9. 交互
+
+- `interactive=true`(默认):View 节点绑定 `@tap` → `$emit('memberTap', {memberId, memberType, nickname})`
+- `interactive=false`:不绑定 tap 事件
+- Canvas 层 `pointer-events: none`,所有交互由 View 处理
+
+## 10. 受影响的文件
+
+| 文件 | 改动类型 | 说明 |
+|------|---------|------|
+| `components/FamilyRelationGraph.vue` | 重写 | View+Canvas 替换纯 Canvas |
+| 所有引用 FamilyRelationGraph 的页面 | 无改动 | Props 接口不变,drop-in 替换 |
+
+引用方列表(无需改动):
+- `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`
+- `pages/wealth/index.vue`
+- `pages/profile/profile.vue`
+
+## 11. 不做的事
+
+- 不改动上层调用方的 Props 传参方式
+- 不引入新依赖
+- 不改变 intimacyMap/energyMap 的数据格式
+- 不改变 memberTap 事件的参数结构