FamilyRelationGraph.vue 21 KB

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