PearlDiagram.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  1. <template>
  2. <!-- 珍珠图 - 珍珠项链资源清单 -->
  3. <view class="pearl-graph-wrapper" v-if="hasData" :style="{ minHeight: canvasHeight + 'px' }">
  4. <canvas
  5. class="pearl-graph-canvas"
  6. canvas-id="pearlGraphCanvas"
  7. id="pearlGraphCanvas"
  8. type="2d"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  10. @touchstart="onTouchStart"
  11. @touchmove="onTouchMove"
  12. @touchend="onTouchEnd" />
  13. <!-- 点击珍珠时显示详情弹窗 -->
  14. <view class="pearl-detail-popup" :class="{ 'popup-below': showPopupBelow }" v-if="selectedNode" :style="popupPosition">
  15. <view class="popup-content">
  16. <view class="popup-header">
  17. <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
  18. <view class="popup-info">
  19. <text class="popup-name">{{ selectedNode.name }}</text>
  20. <view class="popup-tags">
  21. <text class="popup-tag" :style="{ background: selectedNode.color, color: '#FFFFFF' }">{{ selectedNode.groupTypeName }}</text>
  22. <text class="popup-tag popup-tag-rel" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
  23. </view>
  24. </view>
  25. </view>
  26. <!-- 技能列表(仅 SKILL 组) -->
  27. <view class="popup-skills" v-if="selectedNode.groupType === 'SKILL' && selectedNode.skills && selectedNode.skills.length > 0">
  28. <text class="popup-skill-count">技能数 ×{{ selectedNode.skillCount || 0 }}</text>
  29. <view class="popup-skill-list">
  30. <text class="popup-skill-item" v-for="(s, idx) in displaySkills" :key="idx">{{ s }}</text>
  31. </view>
  32. </view>
  33. <!-- 描述 -->
  34. <view class="popup-desc" v-if="selectedNode.description">
  35. <text class="popup-desc-text">{{ selectedNode.description }}</text>
  36. </view>
  37. <!-- 操作按钮 -->
  38. <view class="popup-actions">
  39. <text class="action-btn action-btn-delete" v-if="canDelete" @tap="onDelete">删除</text>
  40. </view>
  41. <view class="popup-arrow" :class="{ 'popup-arrow-down': showPopupBelow }"></view>
  42. </view>
  43. </view>
  44. </view>
  45. <!-- 空状态 -->
  46. <view class="pearl-graph-empty" v-else-if="isEmpty">
  47. <text class="empty-icon">📿</text>
  48. <text class="empty-text">暂无珍珠图数据</text>
  49. <text class="empty-hint">登记你的资源,画出珍珠项链图</text>
  50. <text class="action-btn action-btn-primary" @tap="onAdd">+ 登记资源</text>
  51. </view>
  52. <!-- 加载状态 -->
  53. <view class="pearl-loading" v-else>
  54. <text class="loading-text">加载珍珠图...</text>
  55. </view>
  56. </template>
  57. <script>
  58. /**
  59. * PearlDiagram - 珍珠图 Canvas 组件
  60. *
  61. * 珍珠项链式资源清单可视化:
  62. * - 中心节点 = 当前用户"我"(固定不动)
  63. * - 珍珠节点 = 资源,按 4 组分布在中心周围:
  64. * 人脉(PERSON) / 技能(SKILL) / 信息(INFO) / 场所(PLACE)
  65. * - 每组以弧形分布在中心周围,颜色编码
  66. * - 点击珍珠 → 显示详情弹窗
  67. *
  68. * 数据源:父组件通过 resources prop 传入
  69. * 布局方式:确定性弧形布局(非力导向)
  70. */
  71. export default {
  72. name: 'PearlDiagram',
  73. props: {
  74. /** 资源数据 { groups: [{ type, typeName, items }] } */
  75. resources: {
  76. type: Object,
  77. default: null
  78. },
  79. /** 当前用户ID */
  80. selfId: {
  81. type: [Number, String],
  82. default: null
  83. },
  84. /** 画布宽度 px */
  85. canvasWidth: {
  86. type: Number,
  87. default: 340
  88. },
  89. /** 画布高度 px */
  90. canvasHeight: {
  91. type: Number,
  92. default: 400
  93. },
  94. /** 是否允许交互(点击珍珠查看详情) */
  95. interactive: {
  96. type: Boolean,
  97. default: true
  98. }
  99. },
  100. data: function() {
  101. return {
  102. dpr: 1,
  103. ctx: null,
  104. _canvasNode: null,
  105. _canvasRect: null,
  106. canvasReady: false,
  107. _destroyed: false,
  108. // 珍珠节点数据
  109. pearlNodes: [],
  110. // 选中的节点(弹窗用)
  111. selectedNode: null,
  112. // 触摸状态
  113. _touchStartPos: null,
  114. // 默认头像
  115. defaultAvatar: '/static/default-avatar.png',
  116. // 分组配置:类型、颜色、半径比例、弧度范围(弧度)
  117. // 0=右(3点钟), PI/2=下(6点), PI=左(9点), -PI/2=上(12点)
  118. groupConfigs: [
  119. { type: 'PERSON', typeName: '人脉', color: '#F97316', radiusPct: 0.28, startAngle: -Math.PI / 2, endAngle: 0 },
  120. { type: 'SKILL', typeName: '技能', color: '#F59E0B', radiusPct: 0.38, startAngle: Math.PI, endAngle: Math.PI * 1.5 },
  121. { type: 'INFO', typeName: '信息', color: '#10B981', radiusPct: 0.38, startAngle: 0, endAngle: Math.PI / 2 },
  122. { type: 'PLACE', typeName: '场所', color: '#6366F1', radiusPct: 0.28, startAngle: Math.PI / 2, endAngle: Math.PI }
  123. ],
  124. // 弧度边距(每侧留白角度)
  125. arcMargin: Math.PI / 20
  126. }
  127. },
  128. computed: {
  129. /** 是否有至少一组包含资源 */
  130. hasData: function() {
  131. if (!this.resources) return false
  132. var groups = this.resources.groups
  133. if (!groups || !Array.isArray(groups)) return false
  134. for (var i = 0; i < groups.length; i++) {
  135. if (groups[i].items && groups[i].items.length > 0) return true
  136. }
  137. return false
  138. },
  139. /** 资源已加载但全部为空 */
  140. isEmpty: function() {
  141. if (!this.resources) return false
  142. var groups = this.resources.groups
  143. if (!groups || !Array.isArray(groups)) return true
  144. for (var i = 0; i < groups.length; i++) {
  145. if (groups[i].items && groups[i].items.length > 0) return false
  146. }
  147. return true
  148. },
  149. /** 选中节点是否可删除(仅 INFO/PLACE 为手工登记资源;PERSON 来自联系人、SKILL 来自帮助记录聚合,均不可删) */
  150. canDelete: function() {
  151. if (!this.selectedNode) return false
  152. return this.selectedNode.groupType === 'INFO' || this.selectedNode.groupType === 'PLACE'
  153. },
  154. /** 弹窗中展示的技能(前3个) */
  155. displaySkills: function() {
  156. if (!this.selectedNode || !this.selectedNode.skills) return []
  157. return this.selectedNode.skills.slice(0, 3)
  158. },
  159. /** 弹窗是否显示在节点下方(节点太靠近顶部时) */
  160. showPopupBelow: function() {
  161. if (!this.selectedNode) return false
  162. return this.selectedNode.y < 140
  163. },
  164. /** 弹窗定位样式 */
  165. popupPosition: function() {
  166. if (!this.selectedNode) return { display: 'none' }
  167. var px = this.selectedNode.x
  168. var py = this.selectedNode.y
  169. // 限制弹窗不超出画布水平范围
  170. var halfPopup = 120
  171. var left = Math.max(halfPopup, Math.min(this.canvasWidth - halfPopup, px))
  172. return {
  173. left: left + 'px',
  174. top: py + 'px'
  175. }
  176. }
  177. },
  178. watch: {
  179. resources: {
  180. handler: function() {
  181. this.selectedNode = null
  182. this.buildLayout()
  183. if (this.canvasReady) {
  184. this.drawAll()
  185. }
  186. },
  187. deep: true
  188. },
  189. canvasWidth: function() {
  190. this.buildLayout()
  191. if (this.canvasReady) {
  192. this.drawAll()
  193. }
  194. },
  195. canvasHeight: function() {
  196. this.buildLayout()
  197. if (this.canvasReady) {
  198. this.drawAll()
  199. }
  200. }
  201. },
  202. mounted: function() {
  203. this._destroyed = false
  204. var self = this
  205. try {
  206. this.dpr = uni.getWindowInfo().pixelRatio || 1
  207. } catch (e) {
  208. this.dpr = 1
  209. }
  210. // 延迟初始化 canvas(等待 DOM 布局完成)
  211. var retryCount = 0
  212. var maxRetry = 5
  213. function tryInit() {
  214. if (self._destroyed) return
  215. self.initCanvas(function(success) {
  216. if (self._destroyed) return
  217. if (!success && retryCount < maxRetry) {
  218. retryCount++
  219. setTimeout(tryInit, 60)
  220. } else if (!success) {
  221. console.log('[PearlDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
  222. }
  223. })
  224. }
  225. this.$nextTick(function() {
  226. if (self._destroyed) return
  227. self.updateCanvasSize()
  228. self.$nextTick(function() {
  229. if (!self._destroyed) {
  230. tryInit()
  231. }
  232. })
  233. })
  234. },
  235. beforeDestroy: function() {
  236. this._destroyed = true
  237. },
  238. methods: {
  239. /* ===========================
  240. * 布局构建(确定性弧形)
  241. * =========================== */
  242. /**
  243. * 根据 resources 构建珍珠节点的 x, y 坐标
  244. * 布局逻辑:
  245. * - 中心 = (canvasWidth/2, canvasHeight/2) → "我"
  246. * - 每组分配一个象限 + 特定半径
  247. * - 组内珍珠沿弧线等间距分布
  248. */
  249. buildLayout: function() {
  250. this.pearlNodes = []
  251. if (!this.resources || !this.resources.groups || !Array.isArray(this.resources.groups)) {
  252. return
  253. }
  254. var cx = this.canvasWidth / 2
  255. var cy = this.canvasHeight / 2
  256. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  257. var configs = this.groupConfigs
  258. for (var c = 0; c < configs.length; c++) {
  259. var config = configs[c]
  260. // 在 resources.groups 中查找该类型
  261. var group = null
  262. for (var g = 0; g < this.resources.groups.length; g++) {
  263. if (this.resources.groups[g].type === config.type) {
  264. group = this.resources.groups[g]
  265. break
  266. }
  267. }
  268. if (!group || !group.items || group.items.length === 0) continue
  269. var R = minDim * config.radiusPct
  270. var effStart = config.startAngle + this.arcMargin
  271. var effEnd = config.endAngle - this.arcMargin
  272. var arcSpan = effEnd - effStart
  273. var items = group.items
  274. for (var i = 0; i < items.length; i++) {
  275. var item = items[i]
  276. var t = items.length === 1 ? 0.5 : i / (items.length - 1)
  277. var angle = effStart + t * arcSpan
  278. this.pearlNodes.push({
  279. id: item.id || item.contactId || null,
  280. contactId: item.contactId || null,
  281. groupType: config.type,
  282. groupTypeName: config.typeName,
  283. name: item.name || '未知',
  284. displayName: item.name ? item.name.charAt(0) : '?',
  285. avatar: item.avatar || null,
  286. description: item.description || '',
  287. skillCount: item.skillCount || 0,
  288. skills: item.skills || [],
  289. relationshipType: item.relationshipType || '',
  290. radius: (config.type === 'PERSON' || config.type === 'SKILL') ? 24 : 20,
  291. color: config.color,
  292. x: cx + R * Math.cos(angle),
  293. y: cy + R * Math.sin(angle)
  294. })
  295. }
  296. }
  297. },
  298. /* ===========================
  299. * Canvas 渲染
  300. * =========================== */
  301. drawAll: function() {
  302. if (!this.ctx) return
  303. var ctx = this.ctx
  304. var w = this.canvasWidth
  305. var h = this.canvasHeight
  306. ctx.clearRect(0, 0, w, h)
  307. // 1. 绘制分组弧线背景(半透明色带)
  308. this._drawGroupArcs(ctx)
  309. // 2. 绘制中心节点"我"
  310. this._drawCenterNode(ctx)
  311. // 3. 绘制珍珠节点
  312. for (var i = 0; i < this.pearlNodes.length; i++) {
  313. this._drawPearlNode(ctx, this.pearlNodes[i])
  314. }
  315. // 4. 绘制分组标签
  316. this._drawGroupLabels(ctx)
  317. },
  318. /** 绘制每组的弧形色带(背景)和连接线 */
  319. _drawGroupArcs: function(ctx) {
  320. var cx = this.canvasWidth / 2
  321. var cy = this.canvasHeight / 2
  322. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  323. var configs = this.groupConfigs
  324. var self = this
  325. for (var c = 0; c < configs.length; c++) {
  326. var config = configs[c]
  327. // 检查该组是否有节点
  328. var hasItems = false
  329. for (var p = 0; p < this.pearlNodes.length; p++) {
  330. if (this.pearlNodes[p].groupType === config.type) {
  331. hasItems = true
  332. break
  333. }
  334. }
  335. if (!hasItems) continue
  336. var R = minDim * config.radiusPct
  337. var effStart = config.startAngle + this.arcMargin
  338. var effEnd = config.endAngle - this.arcMargin
  339. // 色带背景(粗线,低透明度)
  340. ctx.beginPath()
  341. ctx.arc(cx, cy, R, effStart, effEnd)
  342. ctx.lineWidth = 14
  343. ctx.strokeStyle = self._hexToRgba(config.color, 0.12)
  344. ctx.lineCap = 'round'
  345. ctx.stroke()
  346. // 连接线(细线,沿弧串联珍珠)
  347. ctx.beginPath()
  348. ctx.arc(cx, cy, R, effStart, effEnd)
  349. ctx.lineWidth = 1.5
  350. ctx.strokeStyle = self._hexToRgba(config.color, 0.3)
  351. ctx.lineCap = 'butt'
  352. ctx.stroke()
  353. // 中心到组内每个珍珠的连线
  354. for (var n = 0; n < this.pearlNodes.length; n++) {
  355. var node = this.pearlNodes[n]
  356. if (node.groupType !== config.type) continue
  357. ctx.beginPath()
  358. ctx.moveTo(cx, cy)
  359. ctx.lineTo(node.x, node.y)
  360. ctx.lineWidth = 1
  361. ctx.strokeStyle = self._hexToRgba(config.color, 0.15)
  362. ctx.stroke()
  363. }
  364. }
  365. },
  366. /** 绘制中心节点"我" */
  367. _drawCenterNode: function(ctx) {
  368. var cx = this.canvasWidth / 2
  369. var cy = this.canvasHeight / 2
  370. var r = 30
  371. // 外圈光晕
  372. ctx.beginPath()
  373. ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
  374. ctx.fillStyle = this._hexToRgba('#10B981', 0.15)
  375. ctx.fill()
  376. // 实心圆
  377. ctx.beginPath()
  378. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  379. ctx.fillStyle = '#10B981'
  380. ctx.fill()
  381. ctx.strokeStyle = '#FFFFFF'
  382. ctx.lineWidth = 3
  383. ctx.stroke()
  384. // "我" 字
  385. ctx.font = 'bold 18px PingFang SC, sans-serif'
  386. ctx.fillStyle = '#FFFFFF'
  387. ctx.textAlign = 'center'
  388. ctx.textBaseline = 'middle'
  389. ctx.fillText('我', cx, cy + 1)
  390. },
  391. /** 绘制单个珍珠节点 */
  392. _drawPearlNode: function(ctx, node) {
  393. var x = node.x
  394. var y = node.y
  395. var r = node.radius
  396. // 裁剪到画布范围
  397. if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
  398. return
  399. }
  400. // 珍珠光晕
  401. ctx.beginPath()
  402. ctx.arc(x, y, r + 3, 0, Math.PI * 2)
  403. ctx.fillStyle = this._hexToRgba(node.color, 0.12)
  404. ctx.fill()
  405. // 实心圆(珍珠主体)
  406. ctx.beginPath()
  407. ctx.arc(x, y, r, 0, Math.PI * 2)
  408. ctx.fillStyle = node.color
  409. ctx.fill()
  410. // 白边
  411. ctx.strokeStyle = '#FFFFFF'
  412. ctx.lineWidth = 2
  413. ctx.stroke()
  414. // 首字
  415. ctx.font = 'bold ' + Math.max(12, r * 0.65) + 'px PingFang SC, sans-serif'
  416. ctx.fillStyle = '#FFFFFF'
  417. ctx.textAlign = 'center'
  418. ctx.textBaseline = 'middle'
  419. ctx.fillText(node.displayName, x, y + 1)
  420. },
  421. /** 绘制分组标签(弧外侧文字) */
  422. _drawGroupLabels: function(ctx) {
  423. var cx = this.canvasWidth / 2
  424. var cy = this.canvasHeight / 2
  425. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  426. var configs = this.groupConfigs
  427. var self = this
  428. for (var c = 0; c < configs.length; c++) {
  429. var config = configs[c]
  430. // 检查该组是否有节点
  431. var hasItems = false
  432. for (var p = 0; p < this.pearlNodes.length; p++) {
  433. if (this.pearlNodes[p].groupType === config.type) {
  434. hasItems = true
  435. break
  436. }
  437. }
  438. if (!hasItems) continue
  439. var R = minDim * config.radiusPct
  440. var midAngle = (config.startAngle + config.endAngle) / 2
  441. // 标签在弧线外侧
  442. var labelR = R + 18
  443. var lx = cx + labelR * Math.cos(midAngle)
  444. var ly = cy + labelR * Math.sin(midAngle)
  445. ctx.font = '12px PingFang SC, sans-serif'
  446. ctx.fillStyle = self._hexToRgba(config.color, 0.8)
  447. ctx.textAlign = 'center'
  448. ctx.textBaseline = 'middle'
  449. ctx.fillText(config.typeName, lx, ly)
  450. }
  451. },
  452. /* ===========================
  453. * 交互:触摸处理
  454. * =========================== */
  455. /** 命中测试:返回被点击的珍珠索引,-1 表示未命中 */
  456. hitTest: function(x, y) {
  457. for (var i = this.pearlNodes.length - 1; i >= 0; i--) {
  458. var node = this.pearlNodes[i]
  459. var dx = x - node.x
  460. var dy = y - node.y
  461. if (dx * dx + dy * dy <= node.radius * node.radius) {
  462. return i
  463. }
  464. }
  465. return -1
  466. },
  467. onTouchStart: function(e) {
  468. if (!this.interactive) return
  469. var touches = e.touches || e.changedTouches
  470. if (!touches || touches.length === 0) return
  471. var touch = touches[0]
  472. var canvasRect = this._getCanvasRect()
  473. if (!canvasRect) return
  474. var x = touch.clientX - canvasRect.left
  475. var y = touch.clientY - canvasRect.top
  476. this._touchStartPos = { x: x, y: y }
  477. // 未命中任何珍珠 → 关闭弹窗
  478. var hitIndex = this.hitTest(x, y)
  479. if (hitIndex < 0) {
  480. this.selectedNode = null
  481. }
  482. },
  483. onTouchMove: function(e) {
  484. if (!this._touchStartPos) return
  485. var touches = e.touches || e.changedTouches
  486. if (!touches || touches.length === 0) return
  487. var touch = touches[0]
  488. var canvasRect = this._getCanvasRect()
  489. if (!canvasRect) return
  490. var x = touch.clientX - canvasRect.left
  491. var y = touch.clientY - canvasRect.top
  492. var dx = x - this._touchStartPos.x
  493. var dy = y - this._touchStartPos.y
  494. // 移动超过 10px 取消点击判定
  495. if (dx * dx + dy * dy > 100) {
  496. this._touchStartPos = null
  497. }
  498. },
  499. onTouchEnd: function(e) {
  500. if (!this._touchStartPos) return
  501. var touches = e.changedTouches
  502. if (!touches || touches.length === 0) {
  503. this._touchStartPos = null
  504. return
  505. }
  506. var touch = touches[0]
  507. var canvasRect = this._getCanvasRect()
  508. if (!canvasRect) {
  509. this._touchStartPos = null
  510. return
  511. }
  512. var x = touch.clientX - canvasRect.left
  513. var y = touch.clientY - canvasRect.top
  514. var dx = x - this._touchStartPos.x
  515. var dy = y - this._touchStartPos.y
  516. this._touchStartPos = null
  517. // 移动超过 10px 不算点击
  518. if (dx * dx + dy * dy > 100) return
  519. var hitIndex = this.hitTest(x, y)
  520. if (hitIndex >= 0) {
  521. this.selectedNode = this.pearlNodes[hitIndex]
  522. }
  523. },
  524. /* ===========================
  525. * 事件发射
  526. * =========================== */
  527. onAdd: function() {
  528. this.$emit('add')
  529. },
  530. onDelete: function() {
  531. if (!this.selectedNode) return
  532. // INFO/PLACE 为手工登记资源,删除键是 resource_items 的真实 id;
  533. // 节点登记时可能关联了联系人(contactId),但不能作为删除标识
  534. var id = this.selectedNode.id || this.selectedNode.contactId
  535. this.$emit('delete', { type: this.selectedNode.groupType, id: id })
  536. this.selectedNode = null
  537. },
  538. /* ===========================
  539. * Canvas 初始化
  540. * =========================== */
  541. updateCanvasSize: function() {
  542. var self = this
  543. var query = uni.createSelectorQuery().in(this)
  544. query.select('.pearl-graph-wrapper').boundingClientRect(function(rect) {
  545. if (rect) {
  546. self.canvasWidth = rect.width || 340
  547. }
  548. }).exec()
  549. },
  550. initCanvas: function(cb) {
  551. var self = this
  552. uni.createSelectorQuery().in(this)
  553. .select('#' + 'pearlGraphCanvas')
  554. .fields({ node: true, size: true, rect: true })
  555. .exec(function(res) {
  556. try {
  557. if (self._destroyed) return
  558. if (!res || !res[0]) {
  559. if (cb) cb(false)
  560. return
  561. }
  562. var info = res[0]
  563. if (!info.node) {
  564. if (cb) cb(false)
  565. return
  566. }
  567. var canvas = info.node
  568. var width = info.width
  569. var height = info.height
  570. if (!width || !height) {
  571. var winWidth = 375
  572. try {
  573. var winInfo = uni.getWindowInfo()
  574. winWidth = winInfo.windowWidth || 375
  575. } catch (e2) {}
  576. width = Math.round((self.canvasWidth || 340) * winWidth / 750)
  577. height = Math.round((self.canvasHeight || 400) * winWidth / 750)
  578. }
  579. var pixelRatio = 2
  580. try {
  581. var system = uni.getWindowInfo()
  582. pixelRatio = system.pixelRatio || 2
  583. } catch (e) {}
  584. canvas.width = width * pixelRatio
  585. canvas.height = height * pixelRatio
  586. var ctx = canvas.getContext('2d')
  587. if (!ctx) {
  588. if (cb) cb(false)
  589. return
  590. }
  591. ctx.scale(pixelRatio, pixelRatio)
  592. self.ctx = ctx
  593. self._canvasNode = canvas
  594. self.canvasWidth = width
  595. self.canvasHeight = height
  596. self.dpr = pixelRatio
  597. self.canvasReady = true
  598. // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
  599. if (info.rect) {
  600. self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
  601. }
  602. self.buildLayout()
  603. self.drawAll()
  604. if (cb) cb(true)
  605. } catch (e) {
  606. if (cb) cb(false)
  607. }
  608. })
  609. },
  610. /* ===========================
  611. * 工具方法
  612. * =========================== */
  613. _getCanvasRect: function() {
  614. if (this._canvasRect) {
  615. return {
  616. left: this._canvasRect.left,
  617. top: this._canvasRect.top,
  618. width: this.canvasWidth,
  619. height: this.canvasHeight
  620. }
  621. }
  622. return {
  623. left: 0,
  624. top: 0,
  625. width: this.canvasWidth,
  626. height: this.canvasHeight
  627. }
  628. },
  629. _hexToRgba: function(hex, alpha) {
  630. var r = parseInt(hex.slice(1, 3), 16)
  631. var g = parseInt(hex.slice(3, 5), 16)
  632. var b = parseInt(hex.slice(5, 7), 16)
  633. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  634. }
  635. }
  636. }
  637. </script>
  638. <style scoped>
  639. .pearl-graph-wrapper {
  640. position: relative;
  641. width: 100%;
  642. min-height: 200px;
  643. height: auto;
  644. margin: 10rpx 0;
  645. background: #FFFFFF;
  646. border-radius: 24rpx;
  647. overflow: visible;
  648. }
  649. .pearl-graph-canvas {
  650. position: absolute;
  651. top: 0;
  652. left: 0;
  653. z-index: 1;
  654. }
  655. /* 详情弹窗 */
  656. .pearl-detail-popup {
  657. position: absolute;
  658. z-index: 10;
  659. pointer-events: none;
  660. transform: translate(-50%, -100%);
  661. margin-top: -16rpx;
  662. }
  663. .pearl-detail-popup.popup-below {
  664. transform: translate(-50%, 0);
  665. margin-top: 16rpx;
  666. }
  667. .popup-content {
  668. background: #FFFFFF;
  669. border-radius: 16rpx;
  670. padding: 20rpx;
  671. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
  672. min-width: 220rpx;
  673. max-width: 300rpx;
  674. pointer-events: auto;
  675. }
  676. .popup-header {
  677. display: flex;
  678. align-items: center;
  679. margin-bottom: 12rpx;
  680. }
  681. .popup-avatar {
  682. width: 56rpx;
  683. height: 56rpx;
  684. border-radius: 50%;
  685. margin-right: 12rpx;
  686. }
  687. .popup-info {
  688. display: flex;
  689. flex-direction: column;
  690. }
  691. .popup-name {
  692. font-size: 28rpx;
  693. font-weight: 600;
  694. color: #1E293B;
  695. }
  696. .popup-tags {
  697. display: flex;
  698. flex-wrap: wrap;
  699. margin-top: 6rpx;
  700. }
  701. .popup-tag {
  702. font-size: 18rpx;
  703. padding: 2rpx 10rpx;
  704. border-radius: 6rpx;
  705. margin-right: 8rpx;
  706. }
  707. .popup-tag-rel {
  708. background: #F1F5F9;
  709. color: #64748B;
  710. }
  711. .popup-skills {
  712. padding: 10rpx 0;
  713. border-top: 1rpx solid #F1F5F9;
  714. }
  715. .popup-skill-count {
  716. font-size: 22rpx;
  717. font-weight: 600;
  718. color: #F59E0B;
  719. display: block;
  720. margin-bottom: 6rpx;
  721. }
  722. .popup-skill-list {
  723. display: flex;
  724. flex-wrap: wrap;
  725. }
  726. .popup-skill-item {
  727. font-size: 20rpx;
  728. color: #64748B;
  729. margin-right: 8rpx;
  730. margin-bottom: 4rpx;
  731. }
  732. .popup-desc {
  733. padding: 10rpx 0;
  734. border-top: 1rpx solid #F1F5F9;
  735. }
  736. .popup-desc-text {
  737. font-size: 22rpx;
  738. color: #475569;
  739. display: block;
  740. }
  741. .popup-actions {
  742. display: flex;
  743. justify-content: center;
  744. margin-top: 12rpx;
  745. }
  746. .action-btn {
  747. display: inline-block;
  748. padding: 10rpx 24rpx;
  749. border-radius: 20rpx;
  750. font-size: 24rpx;
  751. }
  752. .action-btn-primary {
  753. background: #F97316;
  754. color: #FFFFFF;
  755. }
  756. .action-btn-delete {
  757. background: #FEE2E2;
  758. color: #EF4444;
  759. margin-left: 16rpx;
  760. }
  761. .popup-arrow {
  762. position: absolute;
  763. bottom: -8rpx;
  764. left: 50%;
  765. margin-left: -8rpx;
  766. width: 0;
  767. height: 0;
  768. border-left: 8rpx solid transparent;
  769. border-right: 8rpx solid transparent;
  770. border-top: 8rpx solid #FFFFFF;
  771. }
  772. .popup-arrow-down {
  773. bottom: auto;
  774. top: -8rpx;
  775. border-top: none;
  776. border-bottom: 8rpx solid #FFFFFF;
  777. }
  778. /* 空状态 */
  779. .pearl-graph-empty,
  780. .pearl-loading {
  781. display: flex;
  782. flex-direction: column;
  783. align-items: center;
  784. justify-content: center;
  785. padding: 60rpx 20rpx;
  786. background: #FFFFFF;
  787. border-radius: 24rpx;
  788. margin: 10rpx 0;
  789. }
  790. .empty-icon {
  791. font-size: 80rpx;
  792. margin-bottom: 16rpx;
  793. }
  794. .empty-text {
  795. font-size: 28rpx;
  796. font-weight: 600;
  797. color: #1E293B;
  798. margin-bottom: 8rpx;
  799. }
  800. .empty-hint {
  801. font-size: 24rpx;
  802. color: #94A3B8;
  803. margin-bottom: 24rpx;
  804. }
  805. .loading-text {
  806. font-size: 28rpx;
  807. color: #64748B;
  808. }
  809. </style>