FamilyRelationGraph.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. <template>
  2. <!-- 家庭关系图谱 -->
  3. <view class="family-relation-graph" v-if="members && members.length >= 1">
  4. <!-- Canvas 连线层 -->
  5. <canvas
  6. class="graph-canvas"
  7. canvas-id="relationGraphCanvas"
  8. id="relationGraphCanvas"
  9. type="2d"
  10. :style="{
  11. width: canvasWidth + 'px',
  12. height: canvasHeight + 'px'
  13. }" />
  14. <!-- View 节点层 -->
  15. <view class="graph-nodes">
  16. <!-- 2-3人: 单行布局 -->
  17. <template v-if="layoutMode === 'row'">
  18. <view class="row-layout">
  19. <view
  20. v-for="m in displayOrder"
  21. :key="m.id"
  22. class="member-node"
  23. :class="{ 'is-self': m.isSelf, 'is-parent': m.memberType === 'parent' }"
  24. :style="{ width: m.avatarSize + 'rpx' }"
  25. :data-member-id="m.id"
  26. :data-member-type="m.memberType"
  27. :data-member-nickname="m.nickname"
  28. @tap="onMemberTap">
  29. <view
  30. class="member-avatar"
  31. :class="{ 'avatar-square': m.memberType === 'parent' }"
  32. :style="{
  33. width: m.avatarSize + 'rpx',
  34. height: m.avatarSize + 'rpx',
  35. backgroundColor: m.isSelf ? '#FFFFFF' : themeColorRgba,
  36. borderColor: m.isSelf ? themeColor : 'transparent'
  37. }">
  38. <text class="avatar-letter" :style="{ color: m.isSelf ? themeColor : '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
  39. </view>
  40. <text class="member-name" :style="{ color: m.isSelf ? themeColor : '#94A3B8', fontWeight: m.isSelf ? 'bold' : 'normal' }">{{ m.nickname }}</text>
  41. </view>
  42. </view>
  43. </template>
  44. <!-- 4+人: 多行布局 -->
  45. <template v-else>
  46. <view class="self-row">
  47. <view
  48. class="member-node is-self"
  49. :style="{ width: selfMember.avatarSize + 'rpx' }"
  50. :data-member-id="selfMember.id"
  51. :data-member-type="selfMember.memberType"
  52. :data-member-nickname="selfMember.nickname"
  53. @tap="onMemberTap">
  54. <view
  55. class="member-avatar"
  56. :style="{
  57. width: selfMember.avatarSize + 'rpx',
  58. height: selfMember.avatarSize + 'rpx',
  59. backgroundColor: '#FFFFFF',
  60. borderColor: themeColor
  61. }">
  62. <text class="avatar-letter" :style="{ color: themeColor, fontSize: selfMember.avatarSize * 0.45 + 'rpx' }">{{ selfMember.displayName }}</text>
  63. </view>
  64. <text class="member-name" :style="{ color: themeColor, fontWeight: 'bold' }">{{ selfMember.nickname }}</text>
  65. </view>
  66. </view>
  67. <view class="others-row">
  68. <view
  69. v-for="m in otherMembers"
  70. :key="m.id"
  71. class="member-node"
  72. :class="{ 'is-parent': m.memberType === 'parent' }"
  73. :style="{ width: m.avatarSize + 'rpx' }"
  74. :data-member-id="m.id"
  75. :data-member-type="m.memberType"
  76. :data-member-nickname="m.nickname"
  77. @tap="onMemberTap">
  78. <view
  79. class="member-avatar"
  80. :class="{ 'avatar-square': m.memberType === 'parent' }"
  81. :style="{
  82. width: m.avatarSize + 'rpx',
  83. height: m.avatarSize + 'rpx',
  84. backgroundColor: themeColorRgba
  85. }">
  86. <text class="avatar-letter" :style="{ color: '#4A5568', fontSize: m.avatarSize * 0.45 + 'rpx' }">{{ m.displayName }}</text>
  87. </view>
  88. <text class="member-name" :style="{ color: '#94A3B8' }">{{ m.nickname }}</text>
  89. </view>
  90. </view>
  91. </template>
  92. </view>
  93. </view>
  94. <!-- 0人空状态 -->
  95. <view class="family-relation-graph graph-empty" v-else-if="members && members.length === 0">
  96. <text class="empty-text">暂无家庭成员</text>
  97. </view>
  98. </template>
  99. <script>
  100. export default {
  101. name: 'FamilyRelationGraph',
  102. props: {
  103. dimensionCode: {
  104. type: String,
  105. required: true,
  106. validator: function(val) {
  107. return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
  108. }
  109. },
  110. selfId: {
  111. type: [Number, String],
  112. default: null
  113. },
  114. members: {
  115. type: Array,
  116. default: function() { return [] }
  117. },
  118. energyMap: {
  119. type: Object,
  120. default: function() { return {} }
  121. },
  122. intimacyMap: {
  123. type: Object,
  124. default: function() { return {} }
  125. },
  126. interactive: {
  127. type: Boolean,
  128. default: true
  129. },
  130. compatibilityMap: {
  131. type: Object,
  132. default: function() { return {} }
  133. }
  134. },
  135. data: function() {
  136. return {
  137. canvasWidth: 300,
  138. canvasHeight: 300,
  139. dpr: 1,
  140. themeColors: {
  141. body: '#8D6E63',
  142. mind: '#FF6B35',
  143. action: '#4CAF50',
  144. home: '#5B9BD5',
  145. wisdom: '#FFD700'
  146. }
  147. }
  148. },
  149. computed: {
  150. themeColor: function() {
  151. return this.themeColors[this.dimensionCode] || '#8D6E63'
  152. },
  153. themeColorRgba: function() {
  154. return this.themeColor + '26'
  155. },
  156. layoutMode: function() {
  157. if (!this.members || this.members.length <= 3) return 'row'
  158. return 'multi-row'
  159. },
  160. selfMember: function() {
  161. var self = this
  162. var found = null
  163. for (var i = 0; i < this.members.length; i++) {
  164. var mid = this.members[i].memberId || this.members[i].id
  165. if (mid != null && mid == this.selfId) {
  166. found = this.members[i]
  167. break
  168. }
  169. }
  170. if (!found && this.members.length > 0) {
  171. found = this.members[0]
  172. }
  173. if (!found) return { id: null, nickname: '我', memberType: 'child', isSelf: true, displayName: '我', avatarSize: 120 }
  174. var fid = found.memberId || found.id
  175. var energy = this.getEnergy(fid)
  176. var size = Math.max(96, 80 + (Math.max(0, Math.min(100, energy)) / 100) * 56)
  177. var nick = found.nickname || found.name || '我'
  178. if (nick === '用户') nick = '我'
  179. return {
  180. id: fid,
  181. nickname: nick,
  182. memberType: found.memberType || 'child',
  183. isSelf: true,
  184. displayName: (found.nickname && found.nickname.charAt(0)) || (found.name && found.name.charAt(0)) || '我',
  185. avatarSize: size
  186. }
  187. },
  188. otherMembers: function() {
  189. var self = this
  190. return this.members
  191. .filter(function(m) {
  192. var mid = m.memberId || m.id
  193. return mid != self.selfId
  194. })
  195. .map(function(m) {
  196. var fid = m.memberId || m.id
  197. var energy = self.getEnergy(fid)
  198. var size = 64 + (Math.max(0, Math.min(100, energy)) / 100) * 56
  199. var nick = m.nickname || m.name || '成员'
  200. if (nick === '用户') nick = '成员'
  201. return {
  202. id: fid,
  203. nickname: nick,
  204. memberType: m.memberType || 'child',
  205. isSelf: false,
  206. displayName: (m.nickname && m.nickname.charAt(0)) || (m.name && m.name.charAt(0)) || '?',
  207. avatarSize: size
  208. }
  209. })
  210. },
  211. displayOrder: function() {
  212. if (this.layoutMode !== 'row') return []
  213. var others = this.otherMembers
  214. if (this.members.length === 2) {
  215. return [this.selfMember].concat(others)
  216. }
  217. if (this.members.length === 3 && others.length >= 2) {
  218. return [others[0], this.selfMember, others[1]]
  219. }
  220. return [this.selfMember].concat(others)
  221. }
  222. },
  223. watch: {
  224. members: function() { this.scheduleDraw() },
  225. energyMap: function() { this.scheduleDraw() },
  226. intimacyMap: function() { this.scheduleDraw() }
  227. },
  228. mounted: function() {
  229. this.dpr = uni.getSystemInfoSync().pixelRatio || 1
  230. this.scheduleDraw()
  231. },
  232. methods: {
  233. // 获取能量值
  234. getEnergy: function(memberId) {
  235. var data = this.energyMap[memberId]
  236. if (!data) return 0
  237. var key = this.dimensionCode + 'Score'
  238. var val = data[key]
  239. return (typeof val !== 'undefined' && val !== null) ? val : 0
  240. },
  241. // 获取亲密度数据
  242. getIntimacy: function(memberId, key) {
  243. var data = this.intimacyMap[memberId]
  244. if (!data) {
  245. var defaults = { closeness: 75, communication: 50, trust: 85 }
  246. return defaults[key]
  247. }
  248. var val = data[key]
  249. return (typeof val !== 'undefined' && val !== null) ? val : 75
  250. },
  251. // 触发重绘:等 View 布局完成 → 读坐标 → 在 Canvas 画线
  252. scheduleDraw: function() {
  253. var self = this
  254. this.$nextTick(function() {
  255. self.updateCanvasSize()
  256. self.$nextTick(function() {
  257. self.drawLines()
  258. })
  259. })
  260. },
  261. // 读取 View 节点层尺寸,设置 Canvas 覆盖尺寸
  262. updateCanvasSize: function() {
  263. var self = this
  264. var query = uni.createSelectorQuery().in(this)
  265. query.select('.graph-nodes').boundingClientRect(function(rect) {
  266. if (!rect) return
  267. self.canvasWidth = rect.width
  268. self.canvasHeight = rect.height
  269. }).exec()
  270. },
  271. // Canvas 连线绘制(type=2d 优先,降级 CanvasContext)
  272. drawLines: function() {
  273. var self = this
  274. if (!this.members || this.members.length < 2) return
  275. var query = uni.createSelectorQuery().in(this)
  276. // 查 self 头像、other 头像、容器 三个 rect
  277. query.select('.is-self .member-avatar').boundingClientRect()
  278. query.selectAll('.member-node:not(.is-self) .member-avatar').boundingClientRect()
  279. query.select('.graph-nodes').boundingClientRect()
  280. query.exec(function(res) {
  281. if (!res || !res[0]) return
  282. var selfAvatarRect = res[0]
  283. var otherAvatarRects = res[1] || []
  284. var containerRect = res[2]
  285. if (!selfAvatarRect || otherAvatarRects.length === 0) return
  286. var containerLeft = containerRect ? containerRect.left : 0
  287. var containerTop = containerRect ? containerRect.top : 0
  288. var selfCX = selfAvatarRect.left + selfAvatarRect.width / 2 - containerLeft
  289. var selfCY = selfAvatarRect.top + selfAvatarRect.height / 2 - containerTop
  290. var selfR = selfAvatarRect.width / 2
  291. // Canvas type=2d
  292. var canvasQuery = uni.createSelectorQuery().in(self)
  293. canvasQuery.select('#relationGraphCanvas')
  294. .fields({ node: true, size: true })
  295. .exec(function(canvasRes) {
  296. if (!canvasRes || !canvasRes[0] || !canvasRes[0].node) {
  297. self.drawLinesLegacy(selfCX, selfCY, selfR, otherAvatarRects, containerLeft, containerTop)
  298. return
  299. }
  300. var canvas = canvasRes[0].node
  301. var ctx = canvas.getContext('2d')
  302. canvas.width = self.canvasWidth * self.dpr
  303. canvas.height = self.canvasHeight * self.dpr
  304. ctx.scale(self.dpr, self.dpr)
  305. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  306. for (var j = 0; j < otherAvatarRects.length; j++) {
  307. var rect = otherAvatarRects[j]
  308. var otherCX = rect.left + rect.width / 2 - containerLeft
  309. var otherCY = rect.top + rect.height / 2 - containerTop
  310. var otherR = rect.width / 2
  311. var otherMember = self.otherMembers[j]
  312. if (!otherMember) continue
  313. var selfId = self.selfMember && self.selfMember.id
  314. var pairKey = ''
  315. if (selfId) {
  316. pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
  317. }
  318. var comp = self.compatibilityMap[pairKey]
  319. if (comp && comp.overallScore !== undefined) {
  320. // 先天兼容度 → 线颜色 (红0→绿120, 紫90)
  321. var cScore = Math.max(0, Math.min(100, comp.overallScore))
  322. ctx.strokeStyle = 'hsl(' + (120 * cScore / 100) + ', 70%, 50%)'
  323. ctx.globalAlpha = 0.55
  324. ctx.lineWidth = 3 + (cScore / 100) * 6
  325. } else {
  326. // 信任度 → 线颜色 (红0°→绿120°)
  327. var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
  328. var hue = (120 * trust / 100)
  329. ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
  330. ctx.globalAlpha = 0.6
  331. // 沟通度 → 线粗细 (2~10px)
  332. var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
  333. ctx.lineWidth = 2 + (comm / 100) * 8
  334. }
  335. // 边缘起止点:沿中心向量缩进半径
  336. var dx = otherCX - selfCX
  337. var dy = otherCY - selfCY
  338. var dist = Math.sqrt(dx * dx + dy * dy)
  339. if (dist < 1) continue
  340. var ux = dx / dist
  341. var uy = dy / dist
  342. ctx.beginPath()
  343. ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
  344. ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
  345. ctx.stroke()
  346. }
  347. ctx.globalAlpha = 1.0
  348. })
  349. })
  350. },
  351. // 降级连线(CanvasContext)
  352. drawLinesLegacy: function(selfCX, selfCY, selfR, otherRects, containerLeft, containerTop) {
  353. var ctx = uni.createCanvasContext('relationGraphCanvas', this)
  354. var self = this
  355. for (var j = 0; j < otherRects.length; j++) {
  356. var rect = otherRects[j]
  357. var otherCX = rect.left + rect.width / 2 - containerLeft
  358. var otherCY = rect.top + rect.height / 2 - containerTop
  359. var otherR = rect.width / 2
  360. var otherMember = self.otherMembers[j]
  361. if (!otherMember) continue
  362. var selfId = self.selfMember && self.selfMember.id
  363. var pairKey = ''
  364. if (selfId) {
  365. pairKey = selfId < otherMember.id ? selfId + '-' + otherMember.id : otherMember.id + '-' + selfId
  366. }
  367. var comp = self.compatibilityMap[pairKey]
  368. if (comp && comp.overallScore !== undefined) {
  369. var cScore = Math.max(0, Math.min(100, comp.overallScore))
  370. ctx.setStrokeStyle('hsl(' + (120 * cScore / 100) + ', 70%, 50%)')
  371. ctx.globalAlpha = 0.55
  372. ctx.setLineWidth(3 + (cScore / 100) * 6)
  373. } else {
  374. var trust = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'trust')))
  375. var hue = (120 * trust / 100)
  376. ctx.setStrokeStyle('hsl(' + hue + ', 75%, 45%)')
  377. ctx.globalAlpha = 0.6
  378. var comm = Math.max(0, Math.min(100, self.getIntimacy(otherMember.id, 'communication')))
  379. ctx.setLineWidth(2 + (comm / 100) * 8)
  380. }
  381. var dx = otherCX - selfCX
  382. var dy = otherCY - selfCY
  383. var dist = Math.sqrt(dx * dx + dy * dy)
  384. if (dist < 1) continue
  385. var ux = dx / dist
  386. var uy = dy / dist
  387. ctx.beginPath()
  388. ctx.moveTo(selfCX + ux * selfR, selfCY + uy * selfR)
  389. ctx.lineTo(otherCX - ux * otherR, otherCY - uy * otherR)
  390. ctx.stroke()
  391. }
  392. ctx.draw()
  393. },
  394. // View 节点点击事件
  395. onMemberTap: function(e) {
  396. if (!this.interactive) return
  397. var target = e.currentTarget
  398. if (!target || !target.dataset) return
  399. var ds = target.dataset
  400. this.$emit('memberTap', {
  401. memberId: ds.memberId,
  402. memberType: ds.memberType,
  403. nickname: ds.memberNickname
  404. })
  405. }
  406. }
  407. }
  408. </script>
  409. <style scoped>
  410. .family-relation-graph {
  411. position: relative;
  412. padding: 24rpx 30rpx;
  413. margin: 10rpx 0;
  414. background: #FFFFFF;
  415. border-radius: 24rpx;
  416. }
  417. .graph-empty {
  418. display: flex;
  419. align-items: center;
  420. justify-content: center;
  421. padding: 60rpx 0;
  422. }
  423. .empty-text {
  424. font-size: 28rpx;
  425. color: #94A3B8;
  426. }
  427. /* Canvas 层: absolute, 只画线 */
  428. .graph-canvas {
  429. position: absolute;
  430. top: 0;
  431. left: 0;
  432. z-index: 1;
  433. pointer-events: none;
  434. }
  435. /* View 节点层: relative, flex 布局 */
  436. .graph-nodes {
  437. position: relative;
  438. z-index: 2;
  439. }
  440. /* 2-3人单行 */
  441. .row-layout {
  442. display: flex;
  443. flex-direction: row;
  444. align-items: center;
  445. justify-content: space-around;
  446. }
  447. /* 4+人多行 */
  448. .self-row {
  449. display: flex;
  450. justify-content: center;
  451. margin-bottom: 16rpx;
  452. }
  453. .others-row {
  454. display: flex;
  455. flex-wrap: wrap;
  456. justify-content: center;
  457. align-items: flex-start;
  458. }
  459. /* 成员节点 */
  460. .member-node {
  461. display: flex;
  462. flex-direction: column;
  463. align-items: center;
  464. margin: 0 12rpx;
  465. flex-shrink: 0;
  466. }
  467. /* 头像 */
  468. .member-avatar {
  469. border-radius: 50%;
  470. border: 2rpx solid transparent;
  471. display: flex;
  472. align-items: center;
  473. justify-content: center;
  474. box-sizing: border-box;
  475. }
  476. .member-avatar.avatar-square {
  477. border-radius: 16rpx;
  478. }
  479. .is-self .member-avatar {
  480. border-style: solid;
  481. }
  482. .avatar-letter {
  483. font-weight: bold;
  484. text-align: center;
  485. }
  486. /* 昵称 */
  487. .member-name {
  488. font-size: 22rpx;
  489. margin-top: 6rpx;
  490. max-width: 120rpx;
  491. overflow: hidden;
  492. text-overflow: ellipsis;
  493. white-space: nowrap;
  494. text-align: center;
  495. }
  496. </style>