FamilyRelationGraph.vue 20 KB

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