# 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 ``` - [ ] **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.