|
@@ -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.
|