| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- <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', 'wisdom'].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',
- wisdom: '#FFD700'
- }
- }
- },
- 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: {
- // 获取能量值
- 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
- },
- // 触发重绘:等 View 布局完成 → 读坐标 → 在 Canvas 画线
- scheduleDraw: function() {
- var self = this
- this.$nextTick(function() {
- self.updateCanvasSize()
- self.$nextTick(function() {
- self.drawLines()
- })
- })
- },
- // 读取 View 节点层尺寸,设置 Canvas 覆盖尺寸
- 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()
- },
- // Canvas 连线绘制(type=2d 优先,降级 CanvasContext)
- drawLines: function() {
- var self = this
- if (!this.members || this.members.length < 2) return
- var query = uni.createSelectorQuery().in(this)
- // 查 self 头像、other 头像、容器 三个 rect
- query.select('.is-self .member-avatar').boundingClientRect()
- query.selectAll('.member-node:not(.is-self) .member-avatar').boundingClientRect()
- query.select('.graph-nodes').boundingClientRect()
- query.exec(function(res) {
- if (!res || !res[0]) return
- var selfAvatarRect = res[0]
- var otherAvatarRects = res[1] || []
- var containerRect = res[2]
- if (!selfAvatarRect || otherAvatarRects.length === 0) return
- var containerLeft = containerRect ? containerRect.left : 0
- var containerTop = containerRect ? containerRect.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
- var otherMember = self.otherMembers[j]
- if (!otherMember) continue
- // 信任度 → 线颜色 (红0°→绿120°)
- 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
- // 沟通度 → 线粗细 (2~10px)
- 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
- 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()
- },
- // View 节点点击事件
- 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
- })
- }
- }
- }
- </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>
|