PearlDiagram.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  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-contact" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onContact">联系</text>
  40. <text class="action-btn action-btn-priority" v-if="selectedNode.groupType !== 'PERSON' && selectedNode.groupType !== 'SKILL'" @tap="onAdjustPriority">优先级</text>
  41. <text class="action-btn action-btn-delete" v-if="canDelete" @tap="onDelete">删除</text>
  42. </view>
  43. <view class="popup-arrow" :class="{ 'popup-arrow-down': showPopupBelow }"></view>
  44. </view>
  45. </view>
  46. </view>
  47. <!-- 空状态 -->
  48. <view class="pearl-graph-empty" v-else-if="isEmpty">
  49. <text class="empty-icon">📿</text>
  50. <text class="empty-text">暂无珍珠图数据</text>
  51. <text class="empty-hint">登记你的社会连接,画出珍珠项链图</text>
  52. <text class="action-btn action-btn-primary" @tap="onAdd">+ 登记资源</text>
  53. </view>
  54. <!-- 加载状态 -->
  55. <view class="pearl-loading" v-else>
  56. <text class="loading-text">加载珍珠图...</text>
  57. </view>
  58. </template>
  59. <script>
  60. import { addPearlInteraction, updatePearlPriority } from '../utils/api.js'
  61. /**
  62. * PearlDiagram - 珍珠图 Canvas 组件
  63. *
  64. * 珍珠项链式资源清单可视化:
  65. * - 中心节点 = 当前用户"我"(固定不动)
  66. * - 珍珠节点 = 资源,按 25 组分布在中心周围(两层同心圆):
  67. * 内圈必备 12 组 + 外圈理想 13 组
  68. * - 每组以弧形分布在中心周围,颜色编码
  69. * - 点击珍珠 → 显示详情弹窗
  70. *
  71. * 数据源:父组件通过 resources prop 传入
  72. * 布局方式:环形均分布局(非力导向)
  73. */
  74. export default {
  75. name: 'PearlDiagram',
  76. props: {
  77. /** 资源数据 { groups: [{ type, typeName, items }] } */
  78. resources: {
  79. type: Object,
  80. default: null
  81. },
  82. /** 当前用户ID */
  83. selfId: {
  84. type: [Number, String],
  85. default: null
  86. },
  87. /** 画布宽度 px */
  88. canvasWidth: {
  89. type: Number,
  90. default: 340
  91. },
  92. /** 画布高度 px */
  93. canvasHeight: {
  94. type: Number,
  95. default: 400
  96. },
  97. /** 是否允许交互(点击珍珠查看详情) */
  98. interactive: {
  99. type: Boolean,
  100. default: true
  101. },
  102. /** 逾期提醒映射 { resourceItemId: daysSinceLast },父组件从 /reminder/list 构建 */
  103. overdueMap: {
  104. type: Object,
  105. default: null
  106. }
  107. },
  108. data: function() {
  109. return {
  110. dpr: 1,
  111. ctx: null,
  112. _canvasNode: null,
  113. _canvasRect: null,
  114. canvasReady: false,
  115. _destroyed: false,
  116. // 珍珠节点数据
  117. pearlNodes: [],
  118. // 选中的节点(弹窗用)
  119. selectedNode: null,
  120. // 触摸状态
  121. _touchStartPos: null,
  122. // 默认头像
  123. defaultAvatar: '/static/default-avatar.png',
  124. // 分组配置:25 类社会连接,内圈必备 12 组 / 外圈理想 13 组
  125. // 颜色盘:内圈偏暖(红橙),外圈偏冷(蓝紫绿)
  126. groupConfigs: [
  127. // ===== 内圈:必备 12 组(radiusPct 0.28)=====
  128. { type: 'MEDICAL', typeName: '医疗', color: '#EF4444', radiusPct: 0.28, ring: 'inner' },
  129. { type: 'EDUCATION', typeName: '教育', color: '#F97316', radiusPct: 0.28, ring: 'inner' },
  130. { type: 'PUBLIC_SECURITY', typeName: '公安', color: '#DC2626', radiusPct: 0.28, ring: 'inner' },
  131. { type: 'AUTO', typeName: '汽车相关', color: '#EA580C', radiusPct: 0.28, ring: 'inner' },
  132. { type: 'REAL_ESTATE', typeName: '房产服务', color: '#D97706', radiusPct: 0.28, ring: 'inner' },
  133. { type: 'TICKETING', typeName: '票务', color: '#CA8A04', radiusPct: 0.28, ring: 'inner' },
  134. { type: 'HANDYMAN', typeName: '多能工人', color: '#B45309', radiusPct: 0.28, ring: 'inner' },
  135. { type: 'APPLIANCE', typeName: '家电', color: '#92400E', radiusPct: 0.28, ring: 'inner' },
  136. { type: 'CATERING', typeName: '餐饮', color: '#E11D48', radiusPct: 0.28, ring: 'inner' },
  137. { type: 'FOOD', typeName: '食品', color: '#BE123C', radiusPct: 0.28, ring: 'inner' },
  138. { type: 'LEGAL', typeName: '法律', color: '#B91C1C', radiusPct: 0.28, ring: 'inner' },
  139. { type: 'WEEKEND_EXPERT', typeName: '周末达人', color: '#FB7185', radiusPct: 0.28, ring: 'inner' },
  140. // ===== 外圈:理想 13 组(radiusPct 0.55)=====
  141. { type: 'GOVERNMENT', typeName: '政府综合', color: '#8B5CF6', radiusPct: 0.55, ring: 'outer' },
  142. { type: 'TAX', typeName: '税务', color: '#7C3AED', radiusPct: 0.55, ring: 'outer' },
  143. { type: 'BUSINESS_ADMIN', typeName: '工商', color: '#6D28D9', radiusPct: 0.55, ring: 'outer' },
  144. { type: 'BANK', typeName: '银行', color: '#2563EB', radiusPct: 0.55, ring: 'outer' },
  145. { type: 'MEDIA', typeName: '媒体', color: '#1D4ED8', radiusPct: 0.55, ring: 'outer' },
  146. { type: 'TRAVEL', typeName: '旅游', color: '#0EA5E9', radiusPct: 0.55, ring: 'outer' },
  147. { type: 'LEADING_ENTERPRISE', typeName: '领军企业', color: '#0891B2', radiusPct: 0.55, ring: 'outer' },
  148. { type: 'INDUSTRY_BENCHMARK', typeName: '行业标杆', color: '#059669', radiusPct: 0.55, ring: 'outer' },
  149. { type: 'OVERSEAS', typeName: '国外', color: '#10B981', radiusPct: 0.55, ring: 'outer' },
  150. { type: 'UNIVERSITY', typeName: '高校', color: '#16A34A', radiusPct: 0.55, ring: 'outer' },
  151. { type: 'FINANCE', typeName: '金融', color: '#84CC16', radiusPct: 0.55, ring: 'outer' },
  152. { type: 'SENIOR_LOCAL', typeName: '资深土著', color: '#65A30D', radiusPct: 0.55, ring: 'outer' },
  153. { type: 'KEY_CITY', typeName: '北上广等', color: '#4D7C0F', radiusPct: 0.55, ring: 'outer' }
  154. ],
  155. }
  156. },
  157. computed: {
  158. /** 是否有至少一组包含资源 */
  159. hasData: function() {
  160. if (!this.resources) return false
  161. var groups = this.resources.groups
  162. if (!groups || !Array.isArray(groups)) return false
  163. for (var i = 0; i < groups.length; i++) {
  164. if (groups[i].items && groups[i].items.length > 0) return true
  165. }
  166. return false
  167. },
  168. /** 资源已加载但全部为空 */
  169. isEmpty: function() {
  170. if (!this.resources) return false
  171. var groups = this.resources.groups
  172. if (!groups || !Array.isArray(groups)) return true
  173. for (var i = 0; i < groups.length; i++) {
  174. if (groups[i].items && groups[i].items.length > 0) return false
  175. }
  176. return true
  177. },
  178. /** 选中节点是否可删除(仅 INFO/PLACE 为手工登记资源;PERSON 来自联系人、SKILL 来自帮助记录聚合,均不可删) */
  179. canDelete: function() {
  180. if (!this.selectedNode) return false
  181. return this.selectedNode.groupType === 'INFO' || this.selectedNode.groupType === 'PLACE'
  182. },
  183. /** 弹窗中展示的技能(前3个) */
  184. displaySkills: function() {
  185. if (!this.selectedNode || !this.selectedNode.skills) return []
  186. return this.selectedNode.skills.slice(0, 3)
  187. },
  188. /** 弹窗是否显示在节点下方(节点太靠近顶部时) */
  189. showPopupBelow: function() {
  190. if (!this.selectedNode) return false
  191. return this.selectedNode.y < 140
  192. },
  193. /** 弹窗定位样式 */
  194. popupPosition: function() {
  195. if (!this.selectedNode) return { display: 'none' }
  196. var px = this.selectedNode.x
  197. var py = this.selectedNode.y
  198. // 限制弹窗不超出画布水平范围
  199. var halfPopup = 120
  200. var left = Math.max(halfPopup, Math.min(this.canvasWidth - halfPopup, px))
  201. return {
  202. left: left + 'px',
  203. top: py + 'px'
  204. }
  205. }
  206. },
  207. watch: {
  208. resources: {
  209. handler: function() {
  210. this.selectedNode = null
  211. this.buildLayout()
  212. if (this.canvasReady) {
  213. this.drawAll()
  214. }
  215. },
  216. deep: true
  217. },
  218. canvasWidth: function() {
  219. this.buildLayout()
  220. if (this.canvasReady) {
  221. this.drawAll()
  222. }
  223. },
  224. canvasHeight: function() {
  225. this.buildLayout()
  226. if (this.canvasReady) {
  227. this.drawAll()
  228. }
  229. }
  230. },
  231. mounted: function() {
  232. this._destroyed = false
  233. var self = this
  234. try {
  235. this.dpr = uni.getWindowInfo().pixelRatio || 1
  236. } catch (e) {
  237. this.dpr = 1
  238. }
  239. // 延迟初始化 canvas(等待 DOM 布局完成)
  240. var retryCount = 0
  241. var maxRetry = 5
  242. function tryInit() {
  243. if (self._destroyed) return
  244. self.initCanvas(function(success) {
  245. if (self._destroyed) return
  246. if (!success && retryCount < maxRetry) {
  247. retryCount++
  248. setTimeout(tryInit, 60)
  249. } else if (!success) {
  250. console.log('[PearlDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
  251. }
  252. })
  253. }
  254. this.$nextTick(function() {
  255. if (self._destroyed) return
  256. self.updateCanvasSize()
  257. self.$nextTick(function() {
  258. if (!self._destroyed) {
  259. tryInit()
  260. }
  261. })
  262. })
  263. },
  264. beforeDestroy: function() {
  265. this._destroyed = true
  266. },
  267. methods: {
  268. /* ===========================
  269. * 布局构建(确定性弧形)
  270. * =========================== */
  271. /**
  272. * 根据 resources 构建珍珠节点的 x, y 坐标
  273. * 布局逻辑:
  274. * - 中心 = (canvasWidth/2, canvasHeight/2) → "我"
  275. * - 内圈 12 组均分整个圆周(radiusPct 0.28)
  276. * - 外圈 13 组均分整个圆周(radiusPct 0.55)
  277. * - 组内珍珠沿弧线等间距分布
  278. * - 珍珠半径按 priority 映射
  279. */
  280. buildLayout: function() {
  281. this.pearlNodes = []
  282. if (!this.resources || !this.resources.groups || !Array.isArray(this.resources.groups)) {
  283. return
  284. }
  285. var cx = this.canvasWidth / 2
  286. var cy = this.canvasHeight / 2
  287. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  288. var self = this
  289. // 按 ring 分组:内圈 12 / 外圈 13
  290. var innerConfigs = []
  291. var outerConfigs = []
  292. for (var c = 0; c < this.groupConfigs.length; c++) {
  293. var cfg = this.groupConfigs[c]
  294. if (cfg.ring === 'inner') innerConfigs.push(cfg)
  295. else outerConfigs.push(cfg)
  296. }
  297. function placeRing(ringConfigs, radiusPct) {
  298. var R = minDim * radiusPct
  299. var n = ringConfigs.length
  300. var totalSpan = Math.PI * 2
  301. var gap = totalSpan * 0.015
  302. var used = 0
  303. for (var i = 0; i < n; i++) {
  304. var config = ringConfigs[i]
  305. // 在 resources.groups 中查找该类型
  306. var group = null
  307. for (var g = 0; g < self.resources.groups.length; g++) {
  308. if (self.resources.groups[g].type === config.type) {
  309. group = self.resources.groups[g]
  310. break
  311. }
  312. }
  313. if (!group || !group.items || group.items.length === 0) {
  314. used += (totalSpan / n)
  315. continue
  316. }
  317. // 本组角度区间
  318. var groupSpan = totalSpan / n - gap
  319. var startAngle = -Math.PI / 2 + used + gap / 2
  320. var items = group.items
  321. for (var k = 0; k < items.length; k++) {
  322. var item = items[k]
  323. var t = items.length === 1 ? 0.5 : k / (items.length - 1)
  324. var angle = startAngle + t * groupSpan
  325. // 珍珠半径:按 priority 映射(高=大)
  326. var priority = item.priority || 2
  327. var multiplier = priority === 1 ? 1.5 : priority === 3 ? 0.7 : 1.0
  328. var baseR = minDim * radiusPct * 0.09
  329. var itemRadius = Math.max(8, Math.min(26, baseR * multiplier))
  330. self.pearlNodes.push({
  331. id: item.id || item.contactId || null,
  332. contactId: item.contactId || null,
  333. groupType: config.type,
  334. groupTypeName: config.typeName,
  335. name: item.name || '未知',
  336. displayName: item.name ? item.name.charAt(0) : '?',
  337. avatar: item.avatar || null,
  338. description: item.description || '',
  339. skillCount: item.skillCount || 0,
  340. skills: item.skills || [],
  341. relationshipType: item.relationshipType || '',
  342. priority: priority,
  343. valueScore: item.valueScore || 0,
  344. daysSinceLast: item.daysSinceLast || null,
  345. radius: itemRadius,
  346. color: config.color,
  347. ring: config.ring,
  348. x: cx + R * Math.cos(angle),
  349. y: cy + R * Math.sin(angle)
  350. })
  351. }
  352. used += (totalSpan / n)
  353. }
  354. }
  355. placeRing(innerConfigs, 0.28)
  356. placeRing(outerConfigs, 0.55)
  357. },
  358. /* ===========================
  359. * Canvas 渲染
  360. * =========================== */
  361. drawAll: function() {
  362. if (!this.ctx) return
  363. var ctx = this.ctx
  364. var w = this.canvasWidth
  365. var h = this.canvasHeight
  366. ctx.clearRect(0, 0, w, h)
  367. // 1. 绘制分组弧线背景(半透明色带,双层)
  368. this._drawGroupArcs(ctx)
  369. // 2. 绘制中心节点"我"
  370. this._drawCenterNode(ctx)
  371. // 3. 绘制珍珠节点(内圈先画,外圈后画)
  372. for (var i = 0; i < this.pearlNodes.length; i++) {
  373. this._drawPearlNode(ctx, this.pearlNodes[i])
  374. }
  375. // 4. 绘制分组标签
  376. this._drawGroupLabels(ctx)
  377. // 5. 逾期角标(红色圆点+天数,仅 daysSinceLast 超过建议周期的资源)
  378. this._drawOverdueBadges(ctx)
  379. },
  380. /** 绘制每组的弧形色带(背景)和连接线 */
  381. _drawGroupArcs: function(ctx) {
  382. var cx = this.canvasWidth / 2
  383. var cy = this.canvasHeight / 2
  384. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  385. var self = this
  386. // 绘制每组的连接线和色带
  387. for (var c = 0; c < this.groupConfigs.length; c++) {
  388. var config = this.groupConfigs[c]
  389. var R = minDim * config.radiusPct
  390. // 收集该组所有珍珠的角度
  391. var groupAngles = []
  392. for (var p = 0; p < this.pearlNodes.length; p++) {
  393. if (this.pearlNodes[p].groupType === config.type) {
  394. var dx = this.pearlNodes[p].x - cx
  395. var dy = this.pearlNodes[p].y - cy
  396. groupAngles.push(Math.atan2(dy, dx))
  397. }
  398. }
  399. if (groupAngles.length === 0) continue
  400. // 排序角度
  401. groupAngles.sort(function(a, b) { return a - b })
  402. var minA = groupAngles[0]
  403. var maxA = groupAngles[groupAngles.length - 1]
  404. // 色带背景(半透明弧)
  405. ctx.beginPath()
  406. ctx.arc(cx, cy, R, minA, maxA)
  407. ctx.lineWidth = 14
  408. ctx.strokeStyle = self._hexToRgba(config.color, 0.12)
  409. ctx.lineCap = 'round'
  410. ctx.stroke()
  411. // 连接线(细弧)
  412. ctx.beginPath()
  413. ctx.arc(cx, cy, R, minA, maxA)
  414. ctx.lineWidth = 1.5
  415. ctx.strokeStyle = self._hexToRgba(config.color, 0.3)
  416. ctx.lineCap = 'butt'
  417. ctx.stroke()
  418. // 中心到组内每个珍珠的连线
  419. for (var n = 0; n < this.pearlNodes.length; n++) {
  420. var node = this.pearlNodes[n]
  421. if (node.groupType !== config.type) continue
  422. ctx.beginPath()
  423. ctx.moveTo(cx, cy)
  424. ctx.lineTo(node.x, node.y)
  425. ctx.lineWidth = 1
  426. ctx.strokeStyle = self._hexToRgba(config.color, 0.15)
  427. ctx.stroke()
  428. }
  429. }
  430. },
  431. /** 绘制中心节点"我" */
  432. _drawCenterNode: function(ctx) {
  433. var cx = this.canvasWidth / 2
  434. var cy = this.canvasHeight / 2
  435. var r = 30
  436. // 外圈光晕
  437. ctx.beginPath()
  438. ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
  439. ctx.fillStyle = this._hexToRgba('#10B981', 0.15)
  440. ctx.fill()
  441. // 实心圆
  442. ctx.beginPath()
  443. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  444. ctx.fillStyle = '#10B981'
  445. ctx.fill()
  446. ctx.strokeStyle = '#FFFFFF'
  447. ctx.lineWidth = 3
  448. ctx.stroke()
  449. // "我" 字
  450. ctx.font = 'bold 18px PingFang SC, sans-serif'
  451. ctx.fillStyle = '#FFFFFF'
  452. ctx.textAlign = 'center'
  453. ctx.textBaseline = 'middle'
  454. ctx.fillText('我', cx, cy + 1)
  455. },
  456. /** 绘制单个珍珠节点 */
  457. _drawPearlNode: function(ctx, node) {
  458. var x = node.x
  459. var y = node.y
  460. var r = node.radius
  461. // 裁剪到画布范围
  462. if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
  463. return
  464. }
  465. // 珍珠光晕
  466. ctx.beginPath()
  467. ctx.arc(x, y, r + 3, 0, Math.PI * 2)
  468. ctx.fillStyle = this._hexToRgba(node.color, 0.12)
  469. ctx.fill()
  470. // 实心圆(珍珠主体)
  471. ctx.beginPath()
  472. ctx.arc(x, y, r, 0, Math.PI * 2)
  473. ctx.fillStyle = node.color
  474. ctx.fill()
  475. // 白边
  476. ctx.strokeStyle = '#FFFFFF'
  477. ctx.lineWidth = 2
  478. ctx.stroke()
  479. // 高优先级(priority=1)白色粗描边
  480. if (node.priority === 1) {
  481. ctx.beginPath()
  482. ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2)
  483. ctx.lineWidth = 2
  484. ctx.strokeStyle = '#FFFFFF'
  485. ctx.stroke()
  486. }
  487. // 首字
  488. ctx.font = 'bold ' + Math.max(12, r * 0.65) + 'px PingFang SC, sans-serif'
  489. ctx.fillStyle = '#FFFFFF'
  490. ctx.textAlign = 'center'
  491. ctx.textBaseline = 'middle'
  492. ctx.fillText(node.displayName, x, y + 1)
  493. },
  494. /** 绘制分组标签(弧外侧文字) */
  495. _drawGroupLabels: function(ctx) {
  496. var cx = this.canvasWidth / 2
  497. var cy = this.canvasHeight / 2
  498. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  499. var self = this
  500. for (var c = 0; c < this.groupConfigs.length; c++) {
  501. var config = this.groupConfigs[c]
  502. // 收集该组所有珍珠的角度和位置
  503. var groupAngles = []
  504. for (var p = 0; p < this.pearlNodes.length; p++) {
  505. if (this.pearlNodes[p].groupType === config.type) {
  506. var dx = this.pearlNodes[p].x - cx
  507. var dy = this.pearlNodes[p].y - cy
  508. groupAngles.push(Math.atan2(dy, dx))
  509. }
  510. }
  511. if (groupAngles.length === 0) continue
  512. // 中间角度
  513. var minA = groupAngles[0]
  514. var maxA = groupAngles[groupAngles.length - 1]
  515. var midAngle = (minA + maxA) / 2
  516. var R = minDim * config.radiusPct
  517. // 标签在弧线外侧
  518. var labelR = R + 18
  519. var lx = cx + labelR * Math.cos(midAngle)
  520. var ly = cy + labelR * Math.sin(midAngle)
  521. ctx.font = '12px PingFang SC, sans-serif'
  522. ctx.fillStyle = self._hexToRgba(config.color, 0.8)
  523. ctx.textAlign = 'center'
  524. ctx.textBaseline = 'middle'
  525. ctx.fillText(config.typeName, lx, ly)
  526. }
  527. },
  528. /** 逾期角标:红色小圆点 + 天数(仅当 PENDING 提醒语义成立时,由父组件传入 overdueMap) */
  529. _drawOverdueBadges: function(ctx) {
  530. if (!this.overdueMap) return
  531. var self = this
  532. var overdueIds = Object.keys(this.overdueMap)
  533. for (var i = 0; i < this.pearlNodes.length; i++) {
  534. var node = this.pearlNodes[i]
  535. if (!node.id) continue
  536. var days = this.overdueMap['' + node.id]
  537. if (days === undefined || days === null) continue
  538. var bx = node.x + node.radius - 4
  539. var by = node.y - node.radius + 4
  540. // 红底白字小徽标
  541. ctx.beginPath()
  542. ctx.arc(bx, by, 9, 0, Math.PI * 2)
  543. ctx.fillStyle = '#EF4444'
  544. ctx.fill()
  545. ctx.fillStyle = '#FFFFFF'
  546. ctx.font = 'bold 10px sans-serif'
  547. ctx.textAlign = 'center'
  548. ctx.textBaseline = 'middle'
  549. ctx.fillText(String(days), bx, by + 1)
  550. }
  551. },
  552. /* ===========================
  553. * 交互:触摸处理
  554. * =========================== */
  555. /** 命中测试:返回被点击的珍珠索引,-1 表示未命中 */
  556. hitTest: function(x, y) {
  557. for (var i = this.pearlNodes.length - 1; i >= 0; i--) {
  558. var node = this.pearlNodes[i]
  559. var dx = x - node.x
  560. var dy = y - node.y
  561. if (dx * dx + dy * dy <= node.radius * node.radius) {
  562. return i
  563. }
  564. }
  565. return -1
  566. },
  567. onTouchStart: function(e) {
  568. if (!this.interactive) return
  569. var touches = e.touches || e.changedTouches
  570. if (!touches || touches.length === 0) return
  571. var touch = touches[0]
  572. var canvasRect = this._getCanvasRect()
  573. if (!canvasRect) return
  574. var x = touch.clientX - canvasRect.left
  575. var y = touch.clientY - canvasRect.top
  576. this._touchStartPos = { x: x, y: y }
  577. // 未命中任何珍珠 → 关闭弹窗
  578. var hitIndex = this.hitTest(x, y)
  579. if (hitIndex < 0) {
  580. this.selectedNode = null
  581. }
  582. },
  583. onTouchMove: function(e) {
  584. if (!this._touchStartPos) return
  585. var touches = e.touches || e.changedTouches
  586. if (!touches || touches.length === 0) return
  587. var touch = touches[0]
  588. var canvasRect = this._getCanvasRect()
  589. if (!canvasRect) return
  590. var x = touch.clientX - canvasRect.left
  591. var y = touch.clientY - canvasRect.top
  592. var dx = x - this._touchStartPos.x
  593. var dy = y - this._touchStartPos.y
  594. // 移动超过 10px 取消点击判定
  595. if (dx * dx + dy * dy > 100) {
  596. this._touchStartPos = null
  597. }
  598. },
  599. onTouchEnd: function(e) {
  600. if (!this._touchStartPos) return
  601. var touches = e.changedTouches
  602. if (!touches || touches.length === 0) {
  603. this._touchStartPos = null
  604. return
  605. }
  606. var touch = touches[0]
  607. var canvasRect = this._getCanvasRect()
  608. if (!canvasRect) {
  609. this._touchStartPos = null
  610. return
  611. }
  612. var x = touch.clientX - canvasRect.left
  613. var y = touch.clientY - canvasRect.top
  614. var dx = x - this._touchStartPos.x
  615. var dy = y - this._touchStartPos.y
  616. this._touchStartPos = null
  617. // 移动超过 10px 不算点击
  618. if (dx * dx + dy * dy > 100) return
  619. var hitIndex = this.hitTest(x, y)
  620. if (hitIndex >= 0) {
  621. this.selectedNode = this.pearlNodes[hitIndex]
  622. }
  623. },
  624. /* ===========================
  625. * 事件发射
  626. * =========================== */
  627. onAdd: function() {
  628. this.$emit('add')
  629. },
  630. onDelete: function() {
  631. if (!this.selectedNode) return
  632. // INFO/PLACE 为手工登记资源,删除键是 resource_items 的真实 id;
  633. // 节点登记时可能关联了联系人(contactId),但不能作为删除标识
  634. var id = this.selectedNode.id || this.selectedNode.contactId
  635. this.$emit('delete', { type: this.selectedNode.groupType, id: id })
  636. this.selectedNode = null
  637. },
  638. /** 联系:弹出方式选择 */
  639. onContact: function() {
  640. var self = this
  641. var node = this.selectedNode
  642. if (!node || !node.id) return
  643. uni.showActionSheet({
  644. itemList: ['电话', '微信', '见面', '其他'],
  645. success: function(res) {
  646. var types = ['PHONE', 'WECHAT', 'MEETING', 'OTHER']
  647. addPearlInteraction({
  648. itemId: node.id,
  649. interactionType: types[res.tapIndex],
  650. content: ''
  651. }).then(function(resp) {
  652. if (resp.code === 200) {
  653. uni.showToast({ title: '已记录', icon: 'success' })
  654. self.$emit('interaction-updated')
  655. } else {
  656. uni.showToast({ title: resp.message || '记录失败', icon: 'none' })
  657. }
  658. })
  659. }
  660. })
  661. },
  662. /** 调整优先级 */
  663. onAdjustPriority: function() {
  664. var self = this
  665. var node = this.selectedNode
  666. if (!node || !node.id) return
  667. uni.showActionSheet({
  668. itemList: ['高(大珍珠)', '中(默认)', '低(小珍珠)'],
  669. success: function(res) {
  670. updatePearlPriority({
  671. itemId: node.id,
  672. priority: res.tapIndex + 1
  673. }).then(function(resp) {
  674. if (resp.code === 200) {
  675. uni.showToast({ title: '已调整', icon: 'success' })
  676. self.$emit('interaction-updated')
  677. } else {
  678. uni.showToast({ title: resp.message || '调整失败', icon: 'none' })
  679. }
  680. })
  681. }
  682. })
  683. },
  684. /* ===========================
  685. * Canvas 初始化
  686. * =========================== */
  687. updateCanvasSize: function() {
  688. var self = this
  689. var query = uni.createSelectorQuery().in(this)
  690. query.select('.pearl-graph-wrapper').boundingClientRect(function(rect) {
  691. if (rect) {
  692. self.canvasWidth = rect.width || 340
  693. }
  694. }).exec()
  695. },
  696. initCanvas: function(cb) {
  697. var self = this
  698. uni.createSelectorQuery().in(this)
  699. .select('#' + 'pearlGraphCanvas')
  700. .fields({ node: true, size: true, rect: true })
  701. .exec(function(res) {
  702. try {
  703. if (self._destroyed) return
  704. if (!res || !res[0]) {
  705. if (cb) cb(false)
  706. return
  707. }
  708. var info = res[0]
  709. if (!info.node) {
  710. if (cb) cb(false)
  711. return
  712. }
  713. var canvas = info.node
  714. var width = info.width
  715. var height = info.height
  716. if (!width || !height) {
  717. var winWidth = 375
  718. try {
  719. var winInfo = uni.getWindowInfo()
  720. winWidth = winInfo.windowWidth || 375
  721. } catch (e2) {}
  722. width = Math.round((self.canvasWidth || 340) * winWidth / 750)
  723. height = Math.round((self.canvasHeight || 400) * winWidth / 750)
  724. }
  725. var pixelRatio = 2
  726. try {
  727. var system = uni.getWindowInfo()
  728. pixelRatio = system.pixelRatio || 2
  729. } catch (e) {}
  730. canvas.width = width * pixelRatio
  731. canvas.height = height * pixelRatio
  732. var ctx = canvas.getContext('2d')
  733. if (!ctx) {
  734. if (cb) cb(false)
  735. return
  736. }
  737. ctx.scale(pixelRatio, pixelRatio)
  738. self.ctx = ctx
  739. self._canvasNode = canvas
  740. self.canvasWidth = width
  741. self.canvasHeight = height
  742. self.dpr = pixelRatio
  743. self.canvasReady = true
  744. // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
  745. if (info.rect) {
  746. self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
  747. }
  748. self.buildLayout()
  749. self.drawAll()
  750. if (cb) cb(true)
  751. } catch (e) {
  752. if (cb) cb(false)
  753. }
  754. })
  755. },
  756. /* ===========================
  757. * 工具方法
  758. * =========================== */
  759. _getCanvasRect: function() {
  760. if (this._canvasRect) {
  761. return {
  762. left: this._canvasRect.left,
  763. top: this._canvasRect.top,
  764. width: this.canvasWidth,
  765. height: this.canvasHeight
  766. }
  767. }
  768. return {
  769. left: 0,
  770. top: 0,
  771. width: this.canvasWidth,
  772. height: this.canvasHeight
  773. }
  774. },
  775. _hexToRgba: function(hex, alpha) {
  776. var r = parseInt(hex.slice(1, 3), 16)
  777. var g = parseInt(hex.slice(3, 5), 16)
  778. var b = parseInt(hex.slice(5, 7), 16)
  779. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  780. }
  781. }
  782. }
  783. </script>
  784. <style scoped>
  785. .pearl-graph-wrapper {
  786. position: relative;
  787. width: 100%;
  788. min-height: 200px;
  789. height: auto;
  790. margin: 10rpx 0;
  791. background: #FFFFFF;
  792. border-radius: 24rpx;
  793. overflow: visible;
  794. }
  795. .pearl-graph-canvas {
  796. position: absolute;
  797. top: 0;
  798. left: 0;
  799. z-index: 1;
  800. }
  801. /* 详情弹窗 */
  802. .pearl-detail-popup {
  803. position: absolute;
  804. z-index: 10;
  805. pointer-events: none;
  806. transform: translate(-50%, -100%);
  807. margin-top: -16rpx;
  808. }
  809. .pearl-detail-popup.popup-below {
  810. transform: translate(-50%, 0);
  811. margin-top: 16rpx;
  812. }
  813. .popup-content {
  814. background: #FFFFFF;
  815. border-radius: 16rpx;
  816. padding: 20rpx;
  817. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
  818. min-width: 220rpx;
  819. max-width: 300rpx;
  820. pointer-events: auto;
  821. }
  822. .popup-header {
  823. display: flex;
  824. align-items: center;
  825. margin-bottom: 12rpx;
  826. }
  827. .popup-avatar {
  828. width: 56rpx;
  829. height: 56rpx;
  830. border-radius: 50%;
  831. margin-right: 12rpx;
  832. }
  833. .popup-info {
  834. display: flex;
  835. flex-direction: column;
  836. }
  837. .popup-name {
  838. font-size: 28rpx;
  839. font-weight: 600;
  840. color: #1E293B;
  841. }
  842. .popup-tags {
  843. display: flex;
  844. flex-wrap: wrap;
  845. margin-top: 6rpx;
  846. }
  847. .popup-tag {
  848. font-size: 18rpx;
  849. padding: 2rpx 10rpx;
  850. border-radius: 6rpx;
  851. margin-right: 8rpx;
  852. }
  853. .popup-tag-rel {
  854. background: #F1F5F9;
  855. color: #64748B;
  856. }
  857. .popup-skills {
  858. padding: 10rpx 0;
  859. border-top: 1rpx solid #F1F5F9;
  860. }
  861. .popup-skill-count {
  862. font-size: 22rpx;
  863. font-weight: 600;
  864. color: #F59E0B;
  865. display: block;
  866. margin-bottom: 6rpx;
  867. }
  868. .popup-skill-list {
  869. display: flex;
  870. flex-wrap: wrap;
  871. }
  872. .popup-skill-item {
  873. font-size: 20rpx;
  874. color: #64748B;
  875. margin-right: 8rpx;
  876. margin-bottom: 4rpx;
  877. }
  878. .popup-desc {
  879. padding: 10rpx 0;
  880. border-top: 1rpx solid #F1F5F9;
  881. }
  882. .popup-desc-text {
  883. font-size: 22rpx;
  884. color: #475569;
  885. display: block;
  886. }
  887. .popup-actions {
  888. display: flex;
  889. justify-content: center;
  890. margin-top: 12rpx;
  891. }
  892. .action-btn {
  893. display: inline-block;
  894. padding: 10rpx 24rpx;
  895. border-radius: 20rpx;
  896. font-size: 24rpx;
  897. }
  898. .action-btn-primary {
  899. background: #F97316;
  900. color: #FFFFFF;
  901. }
  902. .action-btn-delete {
  903. background: #FEE2E2;
  904. color: #EF4444;
  905. margin-left: 16rpx;
  906. }
  907. .action-btn-contact {
  908. background: #3B82F6;
  909. color: #fff;
  910. }
  911. .action-btn-priority {
  912. background: #8B5CF6;
  913. color: #fff;
  914. }
  915. .popup-arrow {
  916. position: absolute;
  917. bottom: -8rpx;
  918. left: 50%;
  919. margin-left: -8rpx;
  920. width: 0;
  921. height: 0;
  922. border-left: 8rpx solid transparent;
  923. border-right: 8rpx solid transparent;
  924. border-top: 8rpx solid #FFFFFF;
  925. }
  926. .popup-arrow-down {
  927. bottom: auto;
  928. top: -8rpx;
  929. border-top: none;
  930. border-bottom: 8rpx solid #FFFFFF;
  931. }
  932. /* 空状态 */
  933. .pearl-graph-empty,
  934. .pearl-loading {
  935. display: flex;
  936. flex-direction: column;
  937. align-items: center;
  938. justify-content: center;
  939. padding: 60rpx 20rpx;
  940. background: #FFFFFF;
  941. border-radius: 24rpx;
  942. margin: 10rpx 0;
  943. }
  944. .empty-icon {
  945. font-size: 80rpx;
  946. margin-bottom: 16rpx;
  947. }
  948. .empty-text {
  949. font-size: 28rpx;
  950. font-weight: 600;
  951. color: #1E293B;
  952. margin-bottom: 8rpx;
  953. }
  954. .empty-hint {
  955. font-size: 24rpx;
  956. color: #94A3B8;
  957. margin-bottom: 24rpx;
  958. }
  959. .loading-text {
  960. font-size: 28rpx;
  961. color: #64748B;
  962. }
  963. </style>