FamilyRelationGraph.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. <template>
  2. <!-- 全 Canvas 关系图谱 - Force-directed 物理布局 -->
  3. <view class="family-graph-wrapper" v-if="simNodes && simNodes.length >= 1">
  4. <canvas
  5. class="family-graph-canvas"
  6. canvas-id="forceGraphCanvas"
  7. id="forceGraphCanvas"
  8. type="2d"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  10. @touchstart="onTouchStart"
  11. @touchmove="onTouchMove"
  12. @touchend="onTouchEnd" />
  13. </view>
  14. <!-- 空状态 -->
  15. <view class="family-graph-empty" v-else-if="members && members.length === 0">
  16. <text class="empty-text">暂无家庭成员</text>
  17. </view>
  18. </template>
  19. <script>
  20. /**
  21. * FamilyRelationGraph - 全 Canvas force-directed 家庭关系图
  22. *
  23. * 物理引擎: 手动实现的 force-directed 布局
  24. * - 节点间斥力 (repulsion)
  25. * - 边弹簧引力 (spring attraction)
  26. * - 中心引力 (centering)
  27. * - 阻尼 (damping)
  28. *
  29. * 渲染: 全 Canvas 绘制 (节点 + 连线)
  30. * 交互: 节点拖拽 + 点击检测
  31. */
  32. export default {
  33. name: 'FamilyRelationGraph',
  34. props: {
  35. dimensionCode: {
  36. type: String,
  37. required: true,
  38. validator: function(val) {
  39. return ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
  40. }
  41. },
  42. selfId: {
  43. type: [Number, String],
  44. default: null
  45. },
  46. members: {
  47. type: Array,
  48. default: function() { return [] }
  49. },
  50. energyMap: {
  51. type: Object,
  52. default: function() { return {} }
  53. },
  54. intimacyMap: {
  55. type: Object,
  56. default: function() { return {} }
  57. },
  58. interactive: {
  59. type: Boolean,
  60. default: true
  61. },
  62. compatibilityMap: {
  63. type: Object,
  64. default: function() { return {} }
  65. }
  66. },
  67. data: function() {
  68. return {
  69. canvasWidth: 340,
  70. canvasHeight: 280,
  71. dpr: 1,
  72. // 物理模拟节点
  73. simNodes: [],
  74. // 物理参数
  75. physics: {
  76. repulsion: 800,
  77. springLength: 120,
  78. springStrength: 0.04,
  79. centering: 0.03,
  80. damping: 0.88,
  81. maxSpeed: 12,
  82. minDistance: 30
  83. },
  84. // 拖拽状态
  85. dragging: null,
  86. dragStartPos: null,
  87. // 动画
  88. animFrameId: null,
  89. isSimulating: false,
  90. // 主题色
  91. themeColors: {
  92. body: '#8D6E63',
  93. mind: '#FF6B35',
  94. action: '#4CAF50',
  95. home: '#5B9BD5',
  96. wisdom: '#FFD700'
  97. }
  98. }
  99. },
  100. computed: {
  101. themeColor: function() {
  102. return this.themeColors[this.dimensionCode] || '#8D6E63'
  103. },
  104. // 将 members 转换为 simNodes
  105. nodeData: function() {
  106. var self = this
  107. if (!this.members || this.members.length === 0) return []
  108. var centerX = this.canvasWidth / 2
  109. var centerY = this.canvasHeight / 2
  110. return this.members.map(function(m, idx) {
  111. var fid = m.memberId || m.id
  112. var energy = self.getEnergy(fid)
  113. var radius = 24 + (Math.max(0, Math.min(100, energy)) / 100) * 16
  114. var nick = m.nickname || m.name || '成员'
  115. if (nick === '用户') nick = '成员'
  116. var isSelf = (fid == self.selfId)
  117. var displayName = nick.charAt(0)
  118. // 圆形布局初始位置
  119. var angle = (2 * Math.PI * idx) / Math.max(1, self.members.length)
  120. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  121. var initX = centerX + r * Math.cos(angle)
  122. var initY = centerY + r * Math.sin(angle)
  123. return {
  124. id: fid,
  125. nickname: nick,
  126. displayName: displayName,
  127. isSelf: isSelf,
  128. memberType: m.memberType || 'child',
  129. relationshipType: m.relationshipType || '',
  130. radius: radius,
  131. x: initX,
  132. y: initY,
  133. vx: 0,
  134. vy: 0,
  135. fx: 0,
  136. fy: 0
  137. }
  138. })
  139. },
  140. // 构建边 (self 连接到其他所有成员)
  141. edges: function() {
  142. var self = this
  143. var selfNode = this.nodeData.find(function(n) { return n.isSelf })
  144. if (!selfNode) return []
  145. var result = []
  146. this.nodeData.forEach(function(n) {
  147. if (n.id !== selfNode.id) {
  148. var pairKey = selfNode.id < n.id
  149. ? selfNode.id + '-' + n.id
  150. : n.id + '-' + selfNode.id
  151. var comp = self.compatibilityMap[pairKey]
  152. var trust = self.getIntimacy(n.id, 'trust')
  153. var comm = self.getIntimacy(n.id, 'communication')
  154. var closeness = self.getIntimacy(n.id, 'closeness')
  155. result.push({
  156. source: selfNode,
  157. target: n,
  158. trust: trust,
  159. communication: comm,
  160. closeness: closeness,
  161. compatibility: comp && comp.overallScore !== undefined ? comp.overallScore : null
  162. })
  163. }
  164. })
  165. return result
  166. }
  167. },
  168. watch: {
  169. members: function() {
  170. this.resetSimulation()
  171. },
  172. canvasWidth: function() {
  173. this.resetSimulation()
  174. },
  175. canvasHeight: function() {
  176. this.resetSimulation()
  177. }
  178. },
  179. mounted: function() {
  180. var self = this
  181. this.dpr = uni.getSystemInfoSync().pixelRatio || 1
  182. this.$nextTick(function() {
  183. self.updateCanvasSize()
  184. self.$nextTick(function() {
  185. self.initSimulation()
  186. })
  187. })
  188. },
  189. beforeDestroy: function() {
  190. this.stopSimulation()
  191. },
  192. methods: {
  193. // ===== 物理引擎 =====
  194. resetSimulation: function() {
  195. this.stopSimulation()
  196. var centerX = this.canvasWidth / 2
  197. var centerY = this.canvasHeight / 2
  198. var self = this
  199. this.nodeData.forEach(function(n, idx) {
  200. var angle = (2 * Math.PI * idx) / Math.max(1, self.nodeData.length)
  201. var r = Math.min(self.canvasWidth, self.canvasHeight) * 0.32
  202. n.x = centerX + r * Math.cos(angle)
  203. n.y = centerY + r * Math.sin(angle)
  204. n.vx = 0
  205. n.vy = 0
  206. n.fx = 0
  207. n.fy = 0
  208. })
  209. this.startSimulation()
  210. },
  211. initSimulation: function() {
  212. if (!this.nodeData || this.nodeData.length === 0) return
  213. // 复制到 simNodes
  214. this.simNodes = this.nodeData.slice()
  215. this.startSimulation()
  216. },
  217. startSimulation: function() {
  218. if (this.isSimulating) return
  219. this.isSimulating = true
  220. // 延迟一帧启动,确保DOM已更新
  221. var self = this
  222. setTimeout(function() {
  223. self.simulationTick()
  224. }, 50)
  225. },
  226. stopSimulation: function() {
  227. this.isSimulating = false
  228. if (this.animFrameId) {
  229. clearTimeout(this.animFrameId)
  230. this.animFrameId = null
  231. }
  232. },
  233. simulationTick: function() {
  234. if (!this.isSimulating) return
  235. var self = this
  236. var nodes = this.simNodes
  237. var p = this.physics
  238. // 1. 清零力
  239. for (var i = 0; i < nodes.length; i++) {
  240. nodes[i].fx = 0
  241. nodes[i].fy = 0
  242. }
  243. // 2. 斥力
  244. for (var a = 0; a < nodes.length; a++) {
  245. for (var b = a + 1; b < nodes.length; b++) {
  246. var dx = nodes[b].x - nodes[a].x
  247. var dy = nodes[b].y - nodes[a].y
  248. var distSq = dx * dx + dy * dy
  249. var dist = Math.sqrt(distSq) || 0.1
  250. var force = p.repulsion / distSq
  251. var fx = (dx / dist) * force
  252. var fy = (dy / dist) * force
  253. nodes[a].fx -= fx
  254. nodes[a].fy -= fy
  255. nodes[b].fx += fx
  256. nodes[b].fy += fy
  257. }
  258. }
  259. // 3. 弹簧引力
  260. for (var e = 0; e < this.edges.length; e++) {
  261. var edge = this.edges[e]
  262. var s = edge.source
  263. var t = edge.target
  264. var dx = t.x - s.x
  265. var dy = t.y - s.y
  266. var dist = Math.sqrt(dx * dx + dy * dy) || 0.1
  267. var displacement = dist - p.springLength
  268. var force = p.springStrength * displacement
  269. var fx = (dx / dist) * force
  270. var fy = (dy / dist) * force
  271. s.fx += fx
  272. s.fy += fy
  273. t.fx -= fx
  274. t.fy -= fy
  275. }
  276. // 4. 中心引力
  277. var cx = this.canvasWidth / 2
  278. var cy = this.canvasHeight / 2
  279. for (var n = 0; n < nodes.length; n++) {
  280. nodes[n].fx += (cx - nodes[n].x) * p.centering
  281. nodes[n].fy += (cy - nodes[n].y) * p.centering
  282. }
  283. // 5. 更新速度 + 位置
  284. for (var i = 0; i < nodes.length; i++) {
  285. var node = nodes[i]
  286. node.vx += node.fx
  287. node.vy += node.fy
  288. node.vx *= p.damping
  289. node.vy *= p.damping
  290. var speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
  291. if (speed > p.maxSpeed) {
  292. node.vx = (node.vx / speed) * p.maxSpeed
  293. node.vy = (node.vy / speed) * p.maxSpeed
  294. }
  295. node.x += node.vx
  296. node.y += node.vy
  297. // 边界
  298. var r = node.radius + 4
  299. if (node.x < r) { node.x = r; node.vx *= -0.5 }
  300. if (node.x > this.canvasWidth - r) { node.x = this.canvasWidth - r; node.vx *= -0.5 }
  301. if (node.y < r) { node.y = r; node.vy *= -0.5 }
  302. if (node.y > this.canvasHeight - r) { node.y = this.canvasHeight - r; node.vy *= -0.5 }
  303. }
  304. // 6. 渲染
  305. this.render()
  306. // 7. 下一帧(使用 setTimeout 代替 requestAnimationFrame)
  307. this.animFrameId = setTimeout(function() {
  308. self.simulationTick()
  309. }, 33)
  310. },
  311. // ===== 渲染 =====
  312. render: function() {
  313. var self = this
  314. var query = uni.createSelectorQuery().in(this)
  315. query.select('#forceGraphCanvas')
  316. .fields({ node: true, size: true })
  317. .exec(function(res) {
  318. if (!res || !res[0] || !res[0].node) {
  319. // Canvas 节点尚未就绪,延迟重试
  320. if (self._retryCount === undefined) self._retryCount = 0
  321. if (self._retryCount < 5) {
  322. self._retryCount++
  323. setTimeout(function() {
  324. self.render()
  325. }, 200)
  326. }
  327. return
  328. }
  329. self._retryCount = 0
  330. var canvas = res[0].node
  331. var ctx = canvas.getContext('2d')
  332. canvas.width = self.canvasWidth * self.dpr
  333. canvas.height = self.canvasHeight * self.dpr
  334. ctx.scale(self.dpr, self.dpr)
  335. ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
  336. self.drawEdges(ctx)
  337. self.drawNodes(ctx)
  338. })
  339. },
  340. drawEdges: function(ctx) {
  341. var edges = this.edges
  342. for (var i = 0; i < edges.length; i++) {
  343. var edge = edges[i]
  344. var s = edge.source
  345. var t = edge.target
  346. var trust = edge.trust || 75
  347. var comm = edge.communication || 50
  348. var hue = 120 * trust / 100
  349. ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
  350. ctx.lineWidth = 1 + (comm / 100) * 4
  351. ctx.beginPath()
  352. ctx.moveTo(s.x, s.y)
  353. ctx.lineTo(t.x, t.y)
  354. ctx.stroke()
  355. }
  356. },
  357. drawNodes: function(ctx) {
  358. var nodes = this.simNodes
  359. for (var i = 0; i < nodes.length; i++) {
  360. var node = nodes[i]
  361. var r = node.radius
  362. if (node.isSelf) {
  363. // 自我:双层圆环
  364. ctx.beginPath()
  365. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  366. ctx.fillStyle = '#FFFFFF'
  367. ctx.fill()
  368. ctx.strokeStyle = this.themeColor
  369. ctx.lineWidth = 3
  370. ctx.stroke()
  371. // 内环
  372. ctx.beginPath()
  373. ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
  374. ctx.strokeStyle = this.themeColor + '88'
  375. ctx.lineWidth = 2
  376. ctx.stroke()
  377. } else if (node.relationshipType === 'spouse') {
  378. // 配偶:心形
  379. this.drawHeart(ctx, node.x, node.y, r)
  380. ctx.fillStyle = this.themeColor + '22'
  381. ctx.fill()
  382. } else if (node.relationshipType === 'parent') {
  383. // 父母:圆角方形
  384. this.drawRoundedRect(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
  385. ctx.fillStyle = this.themeColor + '22'
  386. ctx.fill()
  387. } else if (node.relationshipType === 'sibling') {
  388. // 兄弟姐妹:八边形
  389. this.drawPolygon(ctx, node.x, node.y, r, 8)
  390. ctx.fillStyle = this.themeColor + '22'
  391. ctx.fill()
  392. } else {
  393. // 孩子/默认:圆形
  394. ctx.beginPath()
  395. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  396. ctx.fillStyle = this.themeColor + '22'
  397. ctx.fill()
  398. }
  399. ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
  400. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  401. ctx.textAlign = 'center'
  402. ctx.textBaseline = 'middle'
  403. ctx.fillText(node.displayName, node.x, node.y)
  404. ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
  405. ctx.font = (r * 0.4) + 'px sans-serif'
  406. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  407. }
  408. },
  409. renderLegacy: function() {
  410. var ctx = uni.createCanvasContext('forceGraphCanvas', this)
  411. if (!ctx) return
  412. // hex转rgba辅助
  413. function hexToRgba(hex, alpha) {
  414. if (hex && hex.length === 7) {
  415. var r = parseInt(hex.slice(1, 3), 16)
  416. var g = parseInt(hex.slice(3, 5), 16)
  417. var b = parseInt(hex.slice(5, 7), 16)
  418. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  419. }
  420. return hex || '#FF6B9D'
  421. }
  422. ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  423. var edges = this.edges
  424. for (var i = 0; i < edges.length; i++) {
  425. var edge = edges[i]
  426. var comm = edge.communication || 50
  427. var hue = 120 * (edge.trust || 75) / 100
  428. ctx.strokeStyle = 'hsla(' + hue + ', 75%, 45%, 0.6)'
  429. ctx.lineWidth = 1 + (comm / 100) * 4
  430. ctx.beginPath()
  431. ctx.moveTo(edge.source.x, edge.source.y)
  432. ctx.lineTo(edge.target.x, edge.target.y)
  433. ctx.stroke()
  434. }
  435. var nodes = this.simNodes
  436. for (var j = 0; j < nodes.length; j++) {
  437. var node = nodes[j]
  438. var r = node.radius
  439. if (node.isSelf) {
  440. ctx.beginPath()
  441. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  442. ctx.fillStyle = '#FFFFFF'
  443. ctx.fill()
  444. ctx.strokeStyle = this.themeColor
  445. ctx.lineWidth = 3
  446. ctx.stroke()
  447. // inner ring
  448. ctx.beginPath()
  449. ctx.arc(node.x, node.y, r * 0.7, 0, 2 * Math.PI)
  450. ctx.strokeStyle = hexToRgba(this.themeColor, 0.53)
  451. ctx.lineWidth = 2
  452. ctx.stroke()
  453. } else if (node.relationshipType === 'spouse') {
  454. this.drawHeartLegacy(ctx, node.x, node.y, r)
  455. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  456. ctx.fill()
  457. } else if (node.relationshipType === 'parent') {
  458. this.drawRoundedRectLegacy(ctx, node.x, node.y, r * 2, r * 2, r * 0.3)
  459. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  460. ctx.fill()
  461. } else if (node.relationshipType === 'sibling') {
  462. this.drawPolygonLegacy(ctx, node.x, node.y, r, 8)
  463. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  464. ctx.fill()
  465. } else {
  466. ctx.beginPath()
  467. ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
  468. ctx.fillStyle = hexToRgba(this.themeColor, 0.13)
  469. ctx.fill()
  470. }
  471. ctx.fillStyle = node.isSelf ? this.themeColor : '#4A5568'
  472. ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
  473. ctx.textAlign = 'center'
  474. ctx.textBaseline = 'middle'
  475. ctx.fillText(node.displayName, node.x, node.y)
  476. ctx.fillStyle = node.isSelf ? this.themeColor : '#94A3B8'
  477. ctx.font = (r * 0.4) + 'px sans-serif'
  478. ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
  479. }
  480. ctx.draw()
  481. },
  482. // ===== 交互 =====
  483. getTouchNode: function(touchX, touchY) {
  484. var nodes = this.simNodes
  485. for (var i = 0; i < nodes.length; i++) {
  486. var node = nodes[i]
  487. var dx = touchX - node.x
  488. var dy = touchY - node.y
  489. var dist = Math.sqrt(dx * dx + dy * dy)
  490. if (dist <= node.radius + 8) {
  491. return node
  492. }
  493. }
  494. return null
  495. },
  496. onTouchStart: function(e) {
  497. if (!this.interactive) return
  498. var touch = e.touches ? e.touches[0] : e.detail
  499. if (!touch) return
  500. var self = this
  501. var rect = {}
  502. var query = uni.createSelectorQuery().in(this)
  503. query.select('#forceGraphCanvas').boundingClientRect()
  504. query.exec(function(res) {
  505. if (res && res[0]) {
  506. rect = res[0]
  507. var x = touch.clientX - rect.left
  508. var y = touch.clientY - rect.top
  509. var node = self.getTouchNode(x, y)
  510. if (node) {
  511. if (node.isSelf) {
  512. self.touchingSelf = true
  513. } else {
  514. self.dragging = { node: node, offsetX: x - node.x, offsetY: y - node.y }
  515. self.dragStartPos = { x: x, y: y }
  516. }
  517. }
  518. }
  519. })
  520. },
  521. onTouchMove: function(e) {
  522. if (!this.dragging) return
  523. e.preventDefault && e.preventDefault()
  524. var touch = e.touches ? e.touches[0] : e.detail
  525. if (!touch) return
  526. var self = this
  527. var rect = {}
  528. var query = uni.createSelectorQuery().in(this)
  529. query.select('#forceGraphCanvas').boundingClientRect()
  530. query.exec(function(res) {
  531. if (res && res[0]) {
  532. rect = res[0]
  533. var x = touch.clientX - rect.left
  534. var y = touch.clientY - rect.top
  535. var node = self.dragging.node
  536. node.x = x - self.dragging.offsetX
  537. node.y = y - self.dragging.offsetY
  538. }
  539. })
  540. },
  541. onTouchEnd: function(e) {
  542. var wasTouchingSelf = this.touchingSelf
  543. this.touchingSelf = false
  544. if (wasTouchingSelf) {
  545. // 触摸的是中心"我"节点 → 跳转添加成员页面
  546. if (this.interactive) {
  547. this.$emit('addMemberTap')
  548. this.goAddMember()
  549. }
  550. this.dragging = null
  551. this.dragStartPos = null
  552. return
  553. }
  554. if (!this.dragging) return
  555. var wasDragged = this.dragStartPos && (
  556. Math.abs(this.dragging.node.x - this.dragStartPos.x) > 5 ||
  557. Math.abs(this.dragging.node.y - this.dragStartPos.y) > 5
  558. )
  559. var node = this.dragging.node
  560. if (!wasDragged && this.interactive) {
  561. this.$emit('memberTap', {
  562. memberId: node.id,
  563. memberType: node.memberType,
  564. nickname: node.nickname
  565. })
  566. }
  567. this.dragging = null
  568. this.dragStartPos = null
  569. },
  570. // ===== 工具方法 =====
  571. getEnergy: function(memberId) {
  572. var data = this.energyMap[memberId]
  573. if (!data) return 0
  574. var key = this.dimensionCode + 'Score'
  575. var val = data[key]
  576. return (typeof val !== 'undefined' && val !== null) ? val : 0
  577. },
  578. getIntimacy: function(memberId, key) {
  579. var data = this.intimacyMap[memberId]
  580. if (!data) {
  581. var defaults = { closeness: 75, communication: 50, trust: 85 }
  582. return defaults[key]
  583. }
  584. var val = data[key]
  585. return (typeof val !== 'undefined' && val !== null) ? val : 75
  586. },
  587. // 跳转到添加成员页面
  588. goAddMember: function() {
  589. uni.navigateTo({
  590. url: '/pages/family/add-member'
  591. })
  592. },
  593. // 点击右上角小齿轮 → 跳转成员管理页
  594. onManageTap: function() {
  595. uni.navigateTo({
  596. url: '/pages/profile/family-members'
  597. })
  598. },
  599. updateCanvasSize: function() {
  600. var self = this
  601. var query = uni.createSelectorQuery().in(this)
  602. query.select('.family-graph-wrapper').boundingClientRect(function(rect) {
  603. if (rect) {
  604. self.canvasWidth = rect.width || 340
  605. self.canvasHeight = rect.height || 280
  606. }
  607. }).exec()
  608. },
  609. // ===== 形状绘制辅助方法 =====
  610. drawHeart: function(ctx, cx, cy, size) {
  611. ctx.beginPath()
  612. var topX = cx
  613. var topY = cy - size * 0.4
  614. ctx.moveTo(topX, topY)
  615. // 左曲线
  616. ctx.bezierCurveTo(
  617. cx - size * 0.9, cy - size * 0.7,
  618. cx - size * 0.6, cy + size * 0.5,
  619. cx, cy + size * 0.8
  620. )
  621. // 右曲线
  622. ctx.bezierCurveTo(
  623. cx + size * 0.6, cy + size * 0.5,
  624. cx + size * 0.9, cy - size * 0.7,
  625. cx, cy - size * 0.4
  626. )
  627. ctx.closePath()
  628. },
  629. drawRoundedRect: function(ctx, cx, cy, w, h, r) {
  630. ctx.beginPath()
  631. var x = cx - w / 2
  632. var y = cy - h / 2
  633. ctx.moveTo(x + r, y)
  634. ctx.arcTo(x + w, y, x + w, y + h, r)
  635. ctx.arcTo(x + w, y + h, x, y + h, r)
  636. ctx.arcTo(x, y + h, x, y, r)
  637. ctx.arcTo(x, y, x + w, y, r)
  638. ctx.closePath()
  639. },
  640. drawPolygon: function(ctx, cx, cy, r, sides) {
  641. ctx.beginPath()
  642. var startAngle = -Math.PI / 2
  643. for (var i = 0; i < sides; i++) {
  644. var angle = startAngle + (2 * Math.PI * i) / sides
  645. var px = cx + r * Math.cos(angle)
  646. var py = cy + r * Math.sin(angle)
  647. if (i === 0) {
  648. ctx.moveTo(px, py)
  649. } else {
  650. ctx.lineTo(px, py)
  651. }
  652. }
  653. ctx.closePath()
  654. },
  655. drawHeartLegacy: function(ctx, cx, cy, size) {
  656. ctx.beginPath()
  657. ctx.moveTo(cx, cy - size * 0.4)
  658. ctx.bezierCurveTo(cx - size * 0.9, cy - size * 0.7, cx - size * 0.6, cy + size * 0.5, cx, cy + size * 0.8)
  659. ctx.bezierCurveTo(cx + size * 0.6, cy + size * 0.5, cx + size * 0.9, cy - size * 0.7, cx, cy - size * 0.4)
  660. ctx.closePath()
  661. },
  662. drawRoundedRectLegacy: function(ctx, cx, cy, w, h, r) {
  663. ctx.beginPath()
  664. var x = cx - w / 2
  665. var y = cy - h / 2
  666. ctx.moveTo(x + r, y)
  667. ctx.arcTo(x + w, y, x + w, y + h, r)
  668. ctx.arcTo(x + w, y + h, x, y + h, r)
  669. ctx.arcTo(x, y + h, x, y, r)
  670. ctx.arcTo(x, y, x + w, y, r)
  671. ctx.closePath()
  672. },
  673. drawPolygonLegacy: function(ctx, cx, cy, r, sides) {
  674. ctx.beginPath()
  675. var startAngle = -Math.PI / 2
  676. for (var i = 0; i < sides; i++) {
  677. var angle = startAngle + (2 * Math.PI * i) / sides
  678. var px = cx + r * Math.cos(angle)
  679. var py = cy + r * Math.sin(angle)
  680. if (i === 0) {
  681. ctx.moveTo(px, py)
  682. } else {
  683. ctx.lineTo(px, py)
  684. }
  685. }
  686. ctx.closePath()
  687. }
  688. }
  689. }
  690. </script>
  691. <style scoped>
  692. .family-graph-wrapper {
  693. position: relative;
  694. width: 100%;
  695. min-height: 200rpx;
  696. margin: 10rpx 0;
  697. background: #FFFFFF;
  698. border-radius: 24rpx;
  699. overflow: hidden;
  700. }
  701. .family-graph-canvas {
  702. position: absolute;
  703. top: 0;
  704. left: 0;
  705. z-index: 1;
  706. }
  707. /* PC 端悬停检测覆盖层 */
  708. .hover-layer {
  709. position: absolute;
  710. top: 0;
  711. left: 0;
  712. z-index: 2;
  713. background: transparent;
  714. pointer-events: auto;
  715. }
  716. /* 右上角小齿轮 */
  717. .gear-icon {
  718. position: absolute;
  719. top: 12rpx;
  720. right: 12rpx;
  721. z-index: 10;
  722. width: 56rpx;
  723. height: 56rpx;
  724. display: flex;
  725. align-items: center;
  726. justify-content: center;
  727. background: rgba(255, 255, 255, 0.9);
  728. border-radius: 50%;
  729. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  730. }
  731. .gear-text {
  732. font-size: 32rpx;
  733. color: #94A3B8;
  734. }
  735. /* + 添加成员提示 */
  736. .add-hint {
  737. position: absolute;
  738. z-index: 5;
  739. width: 40rpx;
  740. height: 40rpx;
  741. border-radius: 50%;
  742. background: #10B981;
  743. display: flex;
  744. align-items: center;
  745. justify-content: center;
  746. opacity: 0;
  747. transform: scale(0.5);
  748. transition: opacity 0.15s, transform 0.15s;
  749. pointer-events: none;
  750. }
  751. .add-hint.add-hint-visible {
  752. opacity: 1;
  753. transform: scale(1);
  754. }
  755. .add-hint-text {
  756. font-size: 28rpx;
  757. color: #FFFFFF;
  758. font-weight: bold;
  759. line-height: 1;
  760. }
  761. .family-graph-empty {
  762. display: flex;
  763. align-items: center;
  764. justify-content: center;
  765. padding: 60rpx 0;
  766. background: #FFFFFF;
  767. border-radius: 24rpx;
  768. margin: 10rpx 0;
  769. }
  770. .empty-text {
  771. font-size: 28rpx;
  772. color: #94A3B8;
  773. }
  774. </style>