# 家庭成员关系图谱实现计划 > **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 支持。 --- ### Task 1: 创建 FamilyRelationGraph.vue 组件 **Files:** - Create: `cfc-frontend/components/FamilyRelationGraph.vue` - [ ] **Step 1: 组件骨架与 props 定义** ```vue ``` - [ ] **Step 2: 极坐标布局算法** 在 `methods` 中添加布局方法: ```javascript // 维度主题色映射 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 主绘制流程** ```javascript 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: 绘制连线** ```javascript 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: 绘制成员形状** ```javascript 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 点击命中检测** ```javascript 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: 验证 — 编译检查** ```bash cd /sc-data/cfc/cfc-frontend npm run build:mp-weixin 2>&1 | tail -20 ``` Expected: Build succeeds with no errors related to `FamilyRelationGraph` --- ### Task 2: 集成到 身体(body/index.vue) 页面 **Files:** - Modify: `cfc-frontend/pages/body/index.vue` (多处修改) - [ ] **Step 1: 注册组件** 在 script 顶部 import 区域添加: ```javascript // body/index.vue — 现有 import 区块 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue' ``` 在 `components` 注册对象中添加: ```javascript components: { /* 现有组件... */ , FamilyRelationGraph } ``` - [ ] **Step 2: 添加 computed 属性转换数据** 在 `computed` 区块添加两个转换方法: ```javascript // 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 {} } ``` - [ ] **Step 3: 在模板中添加图谱组件** 在 `