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 支持。
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
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 }
在 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 {}
}
在 <template> 中找到 FamilyEnergyBar 后面、用户快捷入口之前的区域插入(约第 34 行后):
<!-- 关系图谱(登录后可见) -->
<FamilyRelationGraph
v-if="isLoggedIn && familyMembersVisible.length > 0"
dimensionCode="body"
:selfId="activeChildId"
:members="familyMembersVisible"
:energyMap="energyMapForGraph"
:intimacyMap="intimacyMapForGraph"
@memberTap="goMemberDetail" />
在 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
Files:
Modify: cfc-frontend/pages/mind/index.vue
[ ] Step 1: 注册组件
Import 添加:
import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
components 注册:
components: { /* 现有 */ , FamilyRelationGraph }
与 Task 2 Step 2 完全相同的 energyMapForGraph 和 intimacyMapForGraph 方法(视图页遵循已有模式,各页面各自定义)
插入位置: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
Files:
Modify: cfc-frontend/pages/action/index.vue
[ ] Step 1: 注册组件
Import 添加:
import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
components 注册:
components: { /* 现有包括 ContactCard, ContactImport */, FamilyRelationGraph }
与 Task 2 Step 2 相同的 energyMapForGraph 和 intimacyMapForGraph
插入到 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
[ ] Step 1: 编译全量检查
cd /sc-data/cfc/cfc-frontend
npm run build:mp-weixin 2>&1
Expected: 无编译错误,无模板语法错误
手动检查要点:
v-if="isLoggedIn")| 检查项 | 状态 |
|---|---|
| 所有 spec 需求有对应 task | ✅ §4-§9 全覆盖 |
| 无占位符(TBD/TODO) | ✅ |
| 路径类型一致 | ✅ (memberId 统一) |
| 三个页面模式一致 | ✅ body/mind/action 遵循相同模式 |
| 边界状态处理 | ✅ 空成员、数据缺失、游客态 |
| 编译验证步骤 | ✅ 每个 task 含 build 验证 |
| 无后端变更依赖 | ✅ 仅前端修改 |