AbilityDiagram.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. <template>
  2. <!-- 能力图 - 径向布局 Canvas 组件 -->
  3. <view class="ability-graph-wrapper" v-if="abilities && abilities.length > 0" :style="{ height: canvasHeight + 'px' }">
  4. <canvas
  5. class="ability-graph-canvas"
  6. canvas-id="abilityGraphCanvas"
  7. id="abilityGraphCanvas"
  8. type="2d"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  10. @tap="onCanvasTap" />
  11. <!-- 点击节点时显示详情弹窗 -->
  12. <view class="ability-detail-popup" v-if="selectedNode" :style="popupPosition">
  13. <view class="popup-content">
  14. <view class="popup-header">
  15. <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
  16. <view class="popup-info">
  17. <text class="popup-name">{{ selectedNode.name }}</text>
  18. <text class="popup-relation" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
  19. </view>
  20. </view>
  21. <view class="popup-skills-section">
  22. <text class="popup-skills-title">技能 x{{ selectedNode.skillCount || 0 }}</text>
  23. <view class="popup-skills-list">
  24. <text class="popup-skill-item" v-for="(sk, idx) in displaySkills" :key="idx">{{ sk }}</text>
  25. <text class="popup-skill-more" v-if="selectedNode.skills && selectedNode.skills.length > 8">+{{ selectedNode.skills.length - 8 }} 项</text>
  26. </view>
  27. </view>
  28. <view class="popup-time" v-if="selectedNode.lastHelpedAt">
  29. <text class="popup-time-label">最近帮助:</text>
  30. <text class="popup-time-value">{{ formatDate(selectedNode.lastHelpedAt) }}</text>
  31. </view>
  32. <view class="popup-arrow" :style="arrowPosition"></view>
  33. </view>
  34. </view>
  35. </view>
  36. <!-- 空状态 -->
  37. <view class="ability-graph-empty" v-else-if="abilities && abilities.length === 0">
  38. <text class="empty-icon">🛠️</text>
  39. <text class="empty-text">暂无能力图数据</text>
  40. <text class="empty-hint">记录技能类帮助后,这里会画出大家的能力</text>
  41. </view>
  42. <!-- 加载状态 -->
  43. <view class="ability-loading" v-else>
  44. <text class="loading-text">加载能力图...</text>
  45. </view>
  46. </template>
  47. <script>
  48. /**
  49. * AbilityDiagram - 能力图 Canvas 组件
  50. *
  51. * 径向布局,展示"谁有哪项能力、曾帮助过谁":
  52. * - 中心节点 = 当前用户"我"(受助人,静态)
  53. * - 外围节点 = 提供过 SKILL 类帮助的人
  54. * - 节点大小 = 技能数量(skillCount)
  55. * - 边标注 = 技能名称
  56. * - 点击节点 → 显示详情弹窗(技能列表、最近帮助时间)
  57. *
  58. * 数据源:父组件通过 abilities prop 传入
  59. */
  60. export default {
  61. name: 'AbilityDiagram',
  62. props: {
  63. /** 当前用户ID */
  64. selfId: {
  65. type: [Number, String],
  66. default: null
  67. },
  68. /** 能力数据数组或 null(null=加载中) */
  69. abilities: {
  70. type: Array,
  71. default: null
  72. },
  73. /** 画布宽度 px */
  74. canvasWidth: {
  75. type: Number,
  76. default: 340
  77. },
  78. /** 画布高度 px */
  79. canvasHeight: {
  80. type: Number,
  81. default: 280
  82. },
  83. /** 是否允许交互(点击节点查看明细) */
  84. interactive: {
  85. type: Boolean,
  86. default: true
  87. }
  88. },
  89. data: function() {
  90. return {
  91. dpr: 1,
  92. ctx: null,
  93. canvasReady: false,
  94. nodeData: [],
  95. edges: [],
  96. selectedNode: null,
  97. selectedNodePos: null,
  98. defaultAvatar: '/static/default-avatar.png',
  99. _destroyed: false,
  100. _canvasRect: null,
  101. // 节点颜色:技能统一金色
  102. skillColor: '#F59E0B',
  103. // 边颜色
  104. edgeColor: '#CBD5E1'
  105. }
  106. },
  107. computed: {
  108. popupPosition: function() {
  109. if (!this.selectedNodePos) return { display: 'none' }
  110. return {
  111. left: this.selectedNodePos.x + 'px',
  112. top: this.selectedNodePos.y + 'px'
  113. }
  114. },
  115. arrowPosition: function() {
  116. return {}
  117. },
  118. displaySkills: function() {
  119. if (!this.selectedNode || !this.selectedNode.skills) return []
  120. return this.selectedNode.skills.slice(0, 8)
  121. }
  122. },
  123. watch: {
  124. abilities: {
  125. handler: function() {
  126. this.buildNodesAndEdges()
  127. this.draw()
  128. },
  129. deep: true
  130. },
  131. canvasWidth: function() {
  132. this.buildNodesAndEdges()
  133. this.draw()
  134. },
  135. canvasHeight: function() {
  136. this.buildNodesAndEdges()
  137. this.draw()
  138. }
  139. },
  140. mounted: function() {
  141. this._destroyed = false
  142. var self = this
  143. try {
  144. this.dpr = uni.getWindowInfo().pixelRatio || 1
  145. } catch (e) {
  146. this.dpr = 1
  147. }
  148. // 延迟初始化 canvas(等待 DOM 布局完成)
  149. var retryCount = 0
  150. var maxRetry = 5
  151. function tryInit() {
  152. if (self._destroyed) return
  153. self.initCanvas(function(success) {
  154. if (self._destroyed) return
  155. if (!success && retryCount < maxRetry) {
  156. retryCount++
  157. setTimeout(tryInit, 60)
  158. } else if (!success) {
  159. console.log('[AbilityDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
  160. }
  161. })
  162. }
  163. this.$nextTick(function() {
  164. if (self._destroyed) return
  165. self.$nextTick(function() {
  166. if (!self._destroyed) {
  167. tryInit()
  168. }
  169. })
  170. })
  171. },
  172. beforeDestroy: function() {
  173. this._destroyed = true
  174. },
  175. methods: {
  176. /* ===========================
  177. * 数据构建
  178. * =========================== */
  179. buildNodesAndEdges: function() {
  180. if (!this.abilities || this.abilities.length === 0) {
  181. this.nodeData = []
  182. this.edges = []
  183. return
  184. }
  185. var centerX = this.canvasWidth / 2
  186. var centerY = this.canvasHeight / 2
  187. var n = this.abilities.length
  188. var self = this
  189. // 径向分布半径(确保不超出画布,留边距)
  190. var maxRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
  191. // 如果节点多,缩小半径避免重叠(最小 0.2)
  192. var layoutRadius = maxRadius
  193. if (n > 6) {
  194. layoutRadius = maxRadius * Math.max(0.22, 1 - (n - 6) * 0.06)
  195. }
  196. // 1. 中心节点(自己)
  197. var centerNode = {
  198. id: 'center',
  199. isCenter: true,
  200. displayName: '我',
  201. radius: 30,
  202. x: centerX,
  203. y: centerY
  204. }
  205. // 2. 能力节点(提供技能帮助的人)
  206. var abilityNodes = this.abilities.map(function(a, idx) {
  207. var angle = (2 * Math.PI * idx) / n
  208. var nodeRadius = 24 + Math.min(12, (a.skillCount - 1) * 2)
  209. return {
  210. id: 'ability_' + a.contactId,
  211. contactId: a.contactId,
  212. name: a.name,
  213. displayName: a.name ? a.name.charAt(0) : '?',
  214. avatar: a.avatar,
  215. relationshipType: a.relationshipType,
  216. skillCount: a.skillCount || 0,
  217. skills: a.skills || [],
  218. lastHelpedAt: a.lastHelpedAt,
  219. radius: nodeRadius,
  220. color: self.skillColor,
  221. x: centerX + layoutRadius * Math.cos(angle),
  222. y: centerY + layoutRadius * Math.sin(angle)
  223. }
  224. })
  225. this.nodeData = [centerNode].concat(abilityNodes)
  226. // 3. 边:中心连接到每个能力节点
  227. this.edges = abilityNodes.map(function(n) {
  228. return {
  229. source: centerNode,
  230. target: n,
  231. skills: n.skills
  232. }
  233. })
  234. },
  235. /* ===========================
  236. * 渲染
  237. * =========================== */
  238. draw: function() {
  239. if (!this.ctx) return
  240. var ctx = this.ctx
  241. var w = this.canvasWidth
  242. var h = this.canvasHeight
  243. ctx.clearRect(0, 0, w, h)
  244. // 1. 绘制边(连接线 + 箭头 + 技能标注)
  245. this._drawEdges(ctx)
  246. // 2. 绘制节点
  247. for (var i = 0; i < this.nodeData.length; i++) {
  248. this._drawNode(ctx, this.nodeData[i])
  249. }
  250. },
  251. _drawEdges: function(ctx) {
  252. var self = this
  253. var centerR = 30 // 中心节点半径
  254. this.edges.forEach(function(e) {
  255. var source = e.source
  256. var target = e.target
  257. // 计算方向
  258. var dx = target.x - source.x
  259. var dy = target.y - source.y
  260. var dist = Math.sqrt(dx * dx + dy * dy) || 1
  261. var nx = dx / dist
  262. var ny = dy / dist
  263. // 线段起点(从中心节点边缘)
  264. var sx = source.x + nx * centerR
  265. var sy = source.y + ny * centerR
  266. // 线段终点(到能力节点边缘)
  267. var tx = target.x - nx * target.radius
  268. var ty = target.y - ny * target.radius
  269. // 绘制线段
  270. ctx.beginPath()
  271. ctx.moveTo(sx, sy)
  272. ctx.lineTo(tx, ty)
  273. ctx.strokeStyle = self.edgeColor
  274. ctx.lineWidth = 1.5
  275. ctx.stroke()
  276. // 箭头(靠近中心侧)
  277. var arrowLen = 8
  278. var arrowWidth = 4
  279. // 箭头位置:在终点偏回一点
  280. var ax = tx
  281. var ay = ty
  282. ctx.beginPath()
  283. ctx.moveTo(ax, ay)
  284. ctx.lineTo(ax - nx * arrowLen + ny * arrowWidth, ay - ny * arrowLen - nx * arrowWidth)
  285. ctx.lineTo(ax - nx * arrowLen - ny * arrowWidth, ay - ny * arrowLen + nx * arrowWidth)
  286. ctx.closePath()
  287. ctx.fillStyle = self.edgeColor
  288. ctx.fill()
  289. // 技能标注(在能力节点附近)
  290. var skills = e.skills || []
  291. if (skills.length > 0) {
  292. var label = self._buildSkillLabel(skills)
  293. var labelX = target.x - nx * (target.radius + 10)
  294. var labelY = target.y - ny * (target.radius + 10)
  295. // 微调偏移让文字不压在连线上
  296. var perpX = -ny
  297. var perpY = nx
  298. labelX += perpX * 12
  299. labelY += perpY * 12
  300. ctx.font = '11px PingFang SC, sans-serif'
  301. ctx.fillStyle = '#475569'
  302. ctx.textAlign = 'center'
  303. ctx.textBaseline = 'middle'
  304. ctx.fillText(label, labelX, labelY)
  305. }
  306. })
  307. },
  308. _buildSkillLabel: function(skills) {
  309. if (!skills || skills.length === 0) return ''
  310. if (skills.length === 1) return skills[0]
  311. // 最多显示 2 个技能名
  312. var first = skills[0]
  313. var second = skills[1]
  314. var combined = first + '、' + second
  315. if (combined.length <= 12) {
  316. if (skills.length > 2) {
  317. return combined + ' +' + (skills.length - 2)
  318. }
  319. return combined
  320. }
  321. // 名字太长,只显示第一个 + 数量
  322. return first + ' +' + (skills.length - 1)
  323. },
  324. _drawNode: function(ctx, node) {
  325. var r = node.radius
  326. var x = node.x
  327. var y = node.y
  328. // 裁剪到画布范围
  329. if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
  330. return
  331. }
  332. if (node.isCenter) {
  333. // 中心节点:实心圆 + "我" 字
  334. ctx.beginPath()
  335. ctx.arc(x, y, r, 0, Math.PI * 2)
  336. ctx.fillStyle = '#10B981'
  337. ctx.fill()
  338. ctx.strokeStyle = '#FFFFFF'
  339. ctx.lineWidth = 2
  340. ctx.stroke()
  341. ctx.font = 'bold 18px PingFang SC, sans-serif'
  342. ctx.fillStyle = '#FFFFFF'
  343. ctx.textAlign = 'center'
  344. ctx.textBaseline = 'middle'
  345. ctx.fillText('我', x, y + 1)
  346. } else {
  347. // 能力节点:实心圆 + 头像(异步加载,兜底首字)
  348. ctx.beginPath()
  349. ctx.arc(x, y, r, 0, Math.PI * 2)
  350. ctx.fillStyle = node.color
  351. ctx.fill()
  352. // 白边
  353. ctx.strokeStyle = '#FFFFFF'
  354. ctx.lineWidth = 2
  355. ctx.stroke()
  356. // 首字(先画兜底,头像加载成功后覆盖)
  357. ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
  358. ctx.fillStyle = '#FFFFFF'
  359. ctx.textAlign = 'center'
  360. ctx.textBaseline = 'middle'
  361. ctx.fillText(node.displayName, x, y + 1)
  362. // 头像:圆形裁剪绘制
  363. if (node.avatar && this._canvasNode && this._canvasNode.createImage) {
  364. var img = this._canvasNode.createImage()
  365. var self = this
  366. img.onload = function() {
  367. if (self._destroyed || !self.ctx) return
  368. var ctx2 = self.ctx
  369. ctx2.save()
  370. ctx2.beginPath()
  371. ctx2.arc(x, y, r, 0, Math.PI * 2)
  372. ctx2.clip()
  373. ctx2.drawImage(img, x - r, y - r, r * 2, r * 2)
  374. ctx2.restore()
  375. // 重绘白边(覆盖裁剪边缘)
  376. ctx2.beginPath()
  377. ctx2.arc(x, y, r, 0, Math.PI * 2)
  378. ctx2.strokeStyle = '#FFFFFF'
  379. ctx2.lineWidth = 2
  380. ctx2.stroke()
  381. }
  382. img.onerror = function() {
  383. // 头像加载失败,保留首字兜底
  384. }
  385. img.src = node.avatar
  386. }
  387. }
  388. },
  389. /* ===========================
  390. * 交互
  391. * =========================== */
  392. onCanvasTap: function(e) {
  393. if (!this.interactive) return
  394. var touches = e.touches || e.changedTouches
  395. if (!touches || touches.length === 0) return
  396. var touch = touches[0]
  397. var canvasRect = this._getCanvasRect()
  398. if (!canvasRect) return
  399. var x = touch.clientX - canvasRect.left
  400. var y = touch.clientY - canvasRect.top
  401. var hitNode = this._hitTest(x, y)
  402. if (hitNode && !hitNode.isCenter) {
  403. this.selectedNode = hitNode
  404. this.selectedNodePos = { x: hitNode.x, y: hitNode.y }
  405. } else {
  406. this.selectedNode = null
  407. this.selectedNodePos = null
  408. }
  409. },
  410. _hitTest: function(x, y) {
  411. for (var i = this.nodeData.length - 1; i >= 0; i--) {
  412. var node = this.nodeData[i]
  413. var dx = x - node.x
  414. var dy = y - node.y
  415. if (dx * dx + dy * dy <= node.radius * node.radius) {
  416. return node
  417. }
  418. }
  419. return null
  420. },
  421. /* ===========================
  422. * 工具方法
  423. * =========================== */
  424. _getCanvasRect: function() {
  425. if (this._canvasRect) {
  426. return {
  427. left: this._canvasRect.left,
  428. top: this._canvasRect.top,
  429. width: this.canvasWidth,
  430. height: this.canvasHeight
  431. }
  432. }
  433. return {
  434. left: 0,
  435. top: 0,
  436. width: this.canvasWidth,
  437. height: this.canvasHeight
  438. }
  439. },
  440. formatDate: function(dateStr) {
  441. if (!dateStr) return ''
  442. // 直接截取前 10 位 yyyy-MM-dd,无需 Date 解析
  443. return String(dateStr).substring(0, 10)
  444. },
  445. /* ===========================
  446. * Canvas 初始化
  447. * =========================== */
  448. initCanvas: function(cb) {
  449. var self = this
  450. uni.createSelectorQuery().in(this)
  451. .select('#' + 'abilityGraphCanvas')
  452. .fields({ node: true, size: true, rect: true })
  453. .exec(function(res) {
  454. try {
  455. if (self._destroyed) return
  456. if (!res || !res[0]) {
  457. if (cb) cb(false)
  458. return
  459. }
  460. var info = res[0]
  461. if (!info.node) {
  462. if (cb) cb(false)
  463. return
  464. }
  465. var canvas = info.node
  466. var width = info.width
  467. var height = info.height
  468. if (!width || !height) {
  469. var winWidth = 375
  470. try {
  471. var winInfo = uni.getWindowInfo()
  472. winWidth = winInfo.windowWidth || 375
  473. } catch (e2) {}
  474. width = Math.round((self.canvasWidth || 340) * winWidth / 750)
  475. height = Math.round((self.canvasHeight || 280) * winWidth / 750)
  476. }
  477. var pixelRatio = 2
  478. try {
  479. var system = uni.getWindowInfo()
  480. pixelRatio = system.pixelRatio || 2
  481. } catch (e) {}
  482. canvas.width = width * pixelRatio
  483. canvas.height = height * pixelRatio
  484. var ctx = canvas.getContext('2d')
  485. if (!ctx) {
  486. if (cb) cb(false)
  487. return
  488. }
  489. ctx.scale(pixelRatio, pixelRatio)
  490. self.ctx = ctx
  491. self._canvasNode = canvas
  492. self.canvasWidth = width
  493. self.canvasHeight = height
  494. self.dpr = pixelRatio
  495. self.canvasReady = true
  496. // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
  497. if (info.rect) {
  498. self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
  499. }
  500. self.buildNodesAndEdges()
  501. self.draw()
  502. if (cb) cb(true)
  503. } catch (e) {
  504. if (cb) cb(false)
  505. }
  506. })
  507. }
  508. }
  509. }
  510. </script>
  511. <style scoped>
  512. .ability-graph-wrapper {
  513. position: relative;
  514. width: 100%;
  515. min-height: 200px;
  516. height: auto;
  517. margin: 10rpx 0;
  518. background: #FFFFFF;
  519. border-radius: 24rpx;
  520. overflow: hidden;
  521. }
  522. .ability-graph-canvas {
  523. position: absolute;
  524. top: 0;
  525. left: 0;
  526. z-index: 1;
  527. }
  528. /* 详情弹窗 */
  529. .ability-detail-popup {
  530. position: absolute;
  531. z-index: 10;
  532. pointer-events: none;
  533. transform: translate(-50%, -100%);
  534. margin-top: -16rpx;
  535. }
  536. .popup-content {
  537. background: #FFFFFF;
  538. border-radius: 16rpx;
  539. padding: 20rpx;
  540. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
  541. min-width: 200rpx;
  542. max-width: 320rpx;
  543. pointer-events: auto;
  544. }
  545. .popup-header {
  546. display: flex;
  547. align-items: center;
  548. margin-bottom: 16rpx;
  549. }
  550. .popup-avatar {
  551. width: 56rpx;
  552. height: 56rpx;
  553. border-radius: 50%;
  554. margin-right: 12rpx;
  555. }
  556. .popup-info {
  557. display: flex;
  558. flex-direction: column;
  559. }
  560. .popup-name {
  561. font-size: 28rpx;
  562. font-weight: 600;
  563. color: #1E293B;
  564. }
  565. .popup-relation {
  566. font-size: 22rpx;
  567. color: #64748B;
  568. margin-top: 2rpx;
  569. }
  570. .popup-skills-section {
  571. padding: 12rpx 0;
  572. border-top: 1rpx solid #F1F5F9;
  573. border-bottom: 1rpx solid #F1F5F9;
  574. margin-bottom: 12rpx;
  575. }
  576. .popup-skills-title {
  577. font-size: 24rpx;
  578. font-weight: 700;
  579. color: #F59E0B;
  580. display: block;
  581. margin-bottom: 8rpx;
  582. }
  583. .popup-skills-list {
  584. display: flex;
  585. flex-wrap: wrap;
  586. gap: 8rpx;
  587. }
  588. .popup-skill-item {
  589. font-size: 22rpx;
  590. color: #475569;
  591. background: #FEF3C7;
  592. border-radius: 8rpx;
  593. padding: 4rpx 10rpx;
  594. }
  595. .popup-skill-more {
  596. font-size: 22rpx;
  597. color: #94A3B8;
  598. padding: 4rpx 6rpx;
  599. }
  600. .popup-time {
  601. display: flex;
  602. align-items: center;
  603. margin-top: 4rpx;
  604. }
  605. .popup-time-label {
  606. font-size: 22rpx;
  607. color: #94A3B8;
  608. }
  609. .popup-time-value {
  610. font-size: 22rpx;
  611. color: #475569;
  612. }
  613. .popup-arrow {
  614. position: absolute;
  615. bottom: -8rpx;
  616. left: 50%;
  617. margin-left: -8rpx;
  618. width: 0;
  619. height: 0;
  620. border-left: 8rpx solid transparent;
  621. border-right: 8rpx solid transparent;
  622. border-top: 8rpx solid #FFFFFF;
  623. }
  624. /* 空状态 */
  625. .ability-graph-empty,
  626. .ability-loading {
  627. display: flex;
  628. flex-direction: column;
  629. align-items: center;
  630. justify-content: center;
  631. padding: 60rpx 20rpx;
  632. background: #FFFFFF;
  633. border-radius: 24rpx;
  634. margin: 10rpx 0;
  635. }
  636. .empty-icon {
  637. font-size: 80rpx;
  638. margin-bottom: 16rpx;
  639. }
  640. .empty-text {
  641. font-size: 28rpx;
  642. font-weight: 600;
  643. color: #1E293B;
  644. margin-bottom: 8rpx;
  645. }
  646. .empty-hint {
  647. font-size: 24rpx;
  648. color: #94A3B8;
  649. margin-bottom: 24rpx;
  650. }
  651. .loading-text {
  652. font-size: 28rpx;
  653. color: #64748B;
  654. }
  655. </style>