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 | Action | Responsibility |
|---|---|---|
cfc-frontend/components/FamilyRelationGraph.vue |
重写 | 整个组件,View+Canvas 混合架构 |
cfc-frontend/__tests__/components/FamilyRelationGraph.test.js |
新建 | 组件单元测试 |
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"
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"
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-id → dataset.memberId,data-member-type → dataset.memberType,data-member-nickname → dataset.memberNickname。
在 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"
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"
Files:
No new files
[ ] Step 1: 确认所有引用页面 Props 兼容
grep 确认以下文件中 FamilyRelationGraph 的使用方式不变:
pages/index/parent-index.vuepages/body/index.vuepages/mind/index.vuepages/action/index.vuepages/body/member-body-detail.vuepages/mind/member-mind-detail.vuepages/action/member-action-detail.vuepages/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"
selfMember, otherMembers, displayOrder computed names match template v-for binds. drawLines/drawLinesLegacy signatures consistent. memberTap event payload {memberId, memberType, nickname} matches spec Section 4.