OctopusDiagram.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. <template>
  2. <!-- 章鱼图 - 关键人拓展图谱 -->
  3. <view class="octopus-wrapper" v-if="effects && effects.list && effects.list.length > 0" :style="{ height: canvasHeight + 'px' }">
  4. <canvas
  5. class="octopus-canvas"
  6. canvas-id="octopusCanvas"
  7. id="octopusCanvas"
  8. type="2d"
  9. :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
  10. @touchstart="onTouchStart"
  11. @touchmove="onTouchMove"
  12. @touchend="onTouchEnd" />
  13. <!-- 成效节点点击弹窗(显示关键人列表) -->
  14. <view class="effect-popup" v-if="selectedEffect" :style="effectPopupPosition">
  15. <view class="popup-content">
  16. <view class="popup-header">
  17. <text class="popup-name">{{ selectedEffect.name }}</text>
  18. <text class="popup-tags">
  19. <text class="popup-tag" v-for="t in selectedEffect.effectTypeList" :key="t">{{ t }}</text>
  20. </text>
  21. </view>
  22. <view class="popup-keypersons" v-if="keyPersons[selectedEffect.id] && keyPersons[selectedEffect.id].length > 0">
  23. <text class="popup-title">关键人</text>
  24. <text class="kp-item" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
  25. {{ kp.name }} · {{ kp.title || kp.organization || '' }}
  26. </text>
  27. </view>
  28. <view class="popup-empty" v-else>
  29. <text class="empty-text">暂无关键人,点击添加</text>
  30. </view>
  31. <view class="popup-actions">
  32. <text class="action-btn action-btn-primary" @tap="onAddKeyPerson(selectedEffect)">添加关键人</text>
  33. </view>
  34. </view>
  35. </view>
  36. <!-- 重新分析按钮 -->
  37. <view class="reanalyze-btn" @tap="$emit('reanalyze')">
  38. <text>🔄 重新分析</text>
  39. </view>
  40. </view>
  41. <!-- 空状态 -->
  42. <view class="octopus-empty" v-else-if="effects && effects.list && effects.list.length === 0">
  43. <text class="empty-icon">🧭</text>
  44. <text class="empty-text">暂无感恩记录</text>
  45. <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
  46. <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 感恩记录</text>
  47. </view>
  48. <!-- 加载状态 -->
  49. <view class="octopus-loading" v-else>
  50. <text class="loading-text">加载章鱼图...</text>
  51. </view>
  52. </template>
  53. <script>
  54. /**
  55. * OctopusDiagram - 章鱼图(关键人拓展)
  56. *
  57. * 三层结构:
  58. * 1. 中心节点:"我"(当前用户)
  59. * 2. 第1层:成效记录节点(标签:金额大/频次高/新成交)
  60. * 3. 第2层:拓展方向节点(虚线,≤8个)
  61. */
  62. export default {
  63. name: 'OctopusDiagram',
  64. props: {
  65. selfId: { type: [Number, String], default: null },
  66. effects: { type: Object, default: null },
  67. expansions: { type: Array, default: function() { return [] } },
  68. canvasWidth: { type: Number, default: 340 },
  69. canvasHeight: { type: Number, default: 420 },
  70. interactive: { type: Boolean, default: true }
  71. },
  72. data: function() {
  73. return {
  74. dpr: 1,
  75. ctx: null,
  76. _canvasNode: null,
  77. _canvasRect: null,
  78. canvasReady: false,
  79. _destroyed: false,
  80. // 节点数据
  81. effectNodes: [],
  82. expansionNodes: [],
  83. centerNode: null,
  84. // 选中状态
  85. selectedEffect: null,
  86. _touchStartPos: null,
  87. // 关键人缓存(effectId -> [{...}])
  88. keyPersons: {},
  89. // 颜色
  90. effectColors: { '金额大': '#F97316', '频次高': '#F59E0B', '新成交': '#10B981' },
  91. expansionColor: '#6366F1'
  92. }
  93. },
  94. computed: {
  95. effectPopupPosition: function() {
  96. if (!this.selectedEffect) return { display: 'none' }
  97. var px = this.selectedEffect.x
  98. var py = this.selectedEffect.y
  99. var halfPopup = 120
  100. var left = Math.max(halfPopup, Math.min(this.canvasWidth - halfPopup, px))
  101. return { left: left + 'px', top: (py - 20) + 'px' }
  102. }
  103. },
  104. watch: {
  105. effects: {
  106. handler: function() {
  107. this.selectedEffect = null
  108. this.buildLayout()
  109. if (this.canvasReady) this.drawAll()
  110. },
  111. deep: true
  112. },
  113. expansions: {
  114. handler: function() {
  115. this.buildExpansionNodes()
  116. if (this.canvasReady) this.drawAll()
  117. },
  118. deep: true
  119. }
  120. },
  121. mounted: function() {
  122. var self = this
  123. this._destroyed = false
  124. try {
  125. this.dpr = uni.getWindowInfo().pixelRatio || 1
  126. } catch (e) {
  127. this.dpr = 1
  128. }
  129. var retryCount = 0
  130. var maxRetry = 5
  131. function tryInit() {
  132. if (self._destroyed) return
  133. self.initCanvas(function(success) {
  134. if (self._destroyed) return
  135. if (!success && retryCount < maxRetry) {
  136. retryCount++
  137. setTimeout(tryInit, 60)
  138. }
  139. })
  140. }
  141. this.$nextTick(function() {
  142. if (!self._destroyed) tryInit()
  143. })
  144. },
  145. beforeDestroy: function() {
  146. this._destroyed = true
  147. },
  148. methods: {
  149. buildLayout: function() {
  150. this.effectNodes = []
  151. this.expansionNodes = []
  152. if (!this.effects || !this.effects.list) return
  153. var cx = this.canvasWidth / 2
  154. var cy = this.canvasHeight / 2
  155. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  156. var effects = this.effects.list
  157. var n = effects.length
  158. // 成效节点均匀分布在上方半圆
  159. for (var i = 0; i < n; i++) {
  160. var angle = -Math.PI / 2 - (Math.PI / (n + 1)) * (i + 1)
  161. var r = minDim * 0.30
  162. this.effectNodes.push({
  163. id: effects[i].id,
  164. name: '成交 #' + effects[i].id,
  165. displayName: 'E' + (i + 1),
  166. x: cx + r * Math.cos(angle),
  167. y: cy + r * Math.sin(angle),
  168. radius: 26,
  169. effectType: effects[i].effectType,
  170. effectTypeList: effects[i].effectTypeList || [],
  171. effectAmount: effects[i].effectAmount,
  172. color: this._getTypeColor(effects[i].effectType)
  173. })
  174. }
  175. },
  176. buildExpansionNodes: function() {
  177. if (!this.expansions || this.expansions.length === 0) {
  178. this.expansionNodes = []
  179. return
  180. }
  181. var cx = this.canvasWidth / 2
  182. var cy = this.canvasHeight / 2
  183. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  184. var n = this.expansions.length
  185. var baseRadius = minDim * 0.38
  186. for (var i = 0; i < n; i++) {
  187. var angle = -Math.PI / 2 - (Math.PI / (n + 1)) * (i + 1)
  188. var r = baseRadius + 40
  189. this.expansionNodes.push({
  190. id: 'exp_' + i,
  191. name: this.expansions[i].keyword,
  192. displayName: this.expansions[i].keyword.substring(0, 3),
  193. x: cx + r * Math.cos(angle),
  194. y: cy + r * Math.sin(angle),
  195. radius: 20,
  196. dimension: this.expansions[i].dimension,
  197. isExpansion: true
  198. })
  199. }
  200. },
  201. drawAll: function() {
  202. if (!this.ctx) return
  203. var ctx = this.ctx
  204. var w = this.canvasWidth
  205. var h = this.canvasHeight
  206. ctx.clearRect(0, 0, w, h)
  207. this._drawOctopusBackground(ctx)
  208. this._drawCenterNode(ctx)
  209. this._drawEdges(ctx)
  210. for (var i = 0; i < this.effectNodes.length; i++) {
  211. this._drawEffectNode(ctx, this.effectNodes[i])
  212. }
  213. for (var j = 0; j < this.expansionNodes.length; j++) {
  214. this._drawExpansionNode(ctx, this.expansionNodes[j])
  215. }
  216. this._drawLabels(ctx)
  217. },
  218. /** 绘制章鱼背景:头部 + 8 条波浪触手(低透明度,衬托节点) */
  219. _drawOctopusBackground: function(ctx) {
  220. var cx = this.canvasWidth / 2
  221. var cy = this.canvasHeight / 2
  222. var minDim = Math.min(this.canvasWidth, this.canvasHeight)
  223. var headR = minDim * 0.12
  224. // 触手:8 条波浪曲线向外延伸
  225. for (var i = 0; i < 8; i++) {
  226. var angle = (i / 8) * Math.PI * 2 - Math.PI / 2
  227. var len = minDim * 0.36
  228. var sx = cx + Math.cos(angle) * headR
  229. var sy = cy + Math.sin(angle) * headR
  230. var ex = cx + Math.cos(angle) * (headR + len)
  231. var ey = cy + Math.sin(angle) * (headR + len)
  232. // 控制点:垂直偏移制造波浪
  233. var perp = angle + Math.PI / 2
  234. var mid = headR + len * 0.5
  235. var wave = Math.sin(i * 2.2) * len * 0.16
  236. var mx = cx + Math.cos(angle) * mid + Math.cos(perp) * wave
  237. var my = cy + Math.sin(angle) * mid + Math.sin(perp) * wave
  238. ctx.beginPath()
  239. ctx.moveTo(sx, sy)
  240. ctx.quadraticCurveTo(mx, my, ex, ey)
  241. ctx.strokeStyle = 'rgba(16, 185, 129, 0.12)'
  242. ctx.lineWidth = 6
  243. ctx.lineCap = 'round'
  244. ctx.stroke()
  245. }
  246. // 头部(半透明圆)
  247. ctx.beginPath()
  248. ctx.arc(cx, cy, headR, 0, Math.PI * 2)
  249. ctx.fillStyle = 'rgba(16, 185, 129, 0.08)'
  250. ctx.fill()
  251. // 眼睛
  252. var eyeOff = headR * 0.35
  253. var eyeR = Math.max(3, headR * 0.09)
  254. ctx.beginPath()
  255. ctx.arc(cx - eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
  256. ctx.fillStyle = 'rgba(16, 185, 129, 0.22)'
  257. ctx.fill()
  258. ctx.beginPath()
  259. ctx.arc(cx + eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
  260. ctx.fill()
  261. },
  262. _drawCenterNode: function(ctx) {
  263. var cx = this.canvasWidth / 2
  264. var cy = this.canvasHeight / 2
  265. var r = 30
  266. ctx.beginPath()
  267. ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
  268. ctx.fillStyle = this._hexToRgba('#10B981', 0.15)
  269. ctx.fill()
  270. ctx.beginPath()
  271. ctx.arc(cx, cy, r, 0, Math.PI * 2)
  272. ctx.fillStyle = '#10B981'
  273. ctx.fill()
  274. ctx.strokeStyle = '#FFFFFF'
  275. ctx.lineWidth = 3
  276. ctx.stroke()
  277. ctx.font = 'bold 18px PingFang SC, sans-serif'
  278. ctx.fillStyle = '#FFFFFF'
  279. ctx.textAlign = 'center'
  280. ctx.textBaseline = 'middle'
  281. ctx.fillText('我', cx, cy + 1)
  282. },
  283. _drawEffectNode: function(ctx, node) {
  284. var x = node.x, y = node.y, r = node.radius
  285. if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) return
  286. // 光晕
  287. ctx.beginPath()
  288. ctx.arc(x, y, r + 3, 0, Math.PI * 2)
  289. ctx.fillStyle = this._hexToRgba(node.color, 0.12)
  290. ctx.fill()
  291. // 主体
  292. ctx.beginPath()
  293. ctx.arc(x, y, r, 0, Math.PI * 2)
  294. ctx.fillStyle = node.color
  295. ctx.fill()
  296. ctx.strokeStyle = '#FFFFFF'
  297. ctx.lineWidth = 2
  298. ctx.stroke()
  299. // 首字符
  300. ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
  301. ctx.fillStyle = '#FFFFFF'
  302. ctx.textAlign = 'center'
  303. ctx.textBaseline = 'middle'
  304. ctx.fillText(node.displayName, x, y + 1)
  305. },
  306. _drawExpansionNode: function(ctx, node) {
  307. var x = node.x, y = node.y, r = node.radius
  308. if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) return
  309. // 虚线圆
  310. ctx.beginPath()
  311. ctx.arc(x, y, r, 0, Math.PI * 2)
  312. ctx.strokeStyle = this.expansionColor
  313. ctx.lineWidth = 2
  314. ctx.setLineDash([4, 4])
  315. ctx.stroke()
  316. ctx.setLineDash([])
  317. // 填充
  318. ctx.beginPath()
  319. ctx.arc(x, y, r - 1, 0, Math.PI * 2)
  320. ctx.fillStyle = this._hexToRgba(this.expansionColor, 0.15)
  321. ctx.fill()
  322. // 文字
  323. ctx.font = '11px PingFang SC, sans-serif'
  324. ctx.fillStyle = this.expansionColor
  325. ctx.textAlign = 'center'
  326. ctx.textBaseline = 'middle'
  327. ctx.fillText(node.displayName, x, y + 1)
  328. },
  329. _drawEdges: function(ctx) {
  330. var cx = this.canvasWidth / 2
  331. var cy = this.canvasHeight / 2
  332. for (var i = 0; i < this.effectNodes.length; i++) {
  333. var node = this.effectNodes[i]
  334. ctx.beginPath()
  335. ctx.moveTo(cx, cy)
  336. ctx.lineTo(node.x, node.y)
  337. ctx.strokeStyle = this._hexToRgba(node.color, 0.3)
  338. ctx.lineWidth = 1.5
  339. ctx.stroke()
  340. }
  341. // 成效节点到拓展方向节点的边(虚线)
  342. for (var i = 0; i < this.effectNodes.length && i < this.expansionNodes.length; i++) {
  343. var e1 = this.effectNodes[i]
  344. var e2 = this.expansionNodes[i]
  345. ctx.beginPath()
  346. ctx.moveTo(e1.x, e1.y)
  347. ctx.lineTo(e2.x, e2.y)
  348. ctx.strokeStyle = this._hexToRgba(this.expansionColor, 0.2)
  349. ctx.lineWidth = 1
  350. ctx.setLineDash([3, 3])
  351. ctx.stroke()
  352. ctx.setLineDash([])
  353. }
  354. },
  355. _drawLabels: function(ctx) {
  356. var cx = this.canvasWidth / 2
  357. var cy = this.canvasHeight / 2
  358. // 成效标签(每侧显示)
  359. for (var i = 0; i < this.effectNodes.length; i++) {
  360. var node = this.effectNodes[i]
  361. var dx = node.x - cx
  362. var dy = node.y - cy
  363. var dist = Math.sqrt(dx * dx + dy * dy) || 1
  364. var lx = node.x + (dx / dist) * 36
  365. var ly = node.y + (dy / dist) * 20
  366. ctx.font = '11px PingFang SC, sans-serif'
  367. ctx.fillStyle = '#64748B'
  368. ctx.textAlign = 'center'
  369. ctx.textBaseline = 'middle'
  370. var label = ''
  371. if (node.effectTypeList && node.effectTypeList.length > 0) {
  372. label = node.effectTypeList.slice(0, 2).join('/')
  373. }
  374. if (label) ctx.fillText(label, lx, ly)
  375. }
  376. },
  377. onTouchStart: function(e) {
  378. if (!this.interactive) return
  379. var touches = e.touches || e.changedTouches
  380. if (!touches || touches.length === 0) return
  381. var touch = touches[0]
  382. var rect = this._getCanvasRect()
  383. if (!rect) return
  384. var x = touch.clientX - rect.left
  385. var y = touch.clientY - rect.top
  386. this._touchStartPos = { x: x, y: y }
  387. var hit = this.hitTest(x, y)
  388. if (!hit) {
  389. this.selectedEffect = null
  390. }
  391. },
  392. onTouchMove: function(e) {
  393. if (!this._touchStartPos) return
  394. var touches = e.touches || e.changedTouches
  395. if (!touches || touches.length === 0) return
  396. var touch = touches[0]
  397. var rect = this._getCanvasRect()
  398. if (!rect) return
  399. var x = touch.clientX - rect.left
  400. var y = touch.clientY - rect.top
  401. var dx = x - this._touchStartPos.x
  402. var dy = y - this._touchStartPos.y
  403. if (dx * dx + dy * dy > 100) {
  404. this._touchStartPos = null
  405. }
  406. },
  407. onTouchEnd: function(e) {
  408. if (!this._touchStartPos) return
  409. var touches = e.changedTouches
  410. if (!touches || touches.length === 0) {
  411. this._touchStartPos = null
  412. return
  413. }
  414. var touch = touches[0]
  415. var rect = this._getCanvasRect()
  416. if (!rect) { this._touchStartPos = null; return }
  417. var x = touch.clientX - rect.left
  418. var y = touch.clientY - rect.top
  419. var dx = x - this._touchStartPos.x
  420. var dy = y - this._touchStartPos.y
  421. this._touchStartPos = null
  422. if (dx * dx + dy * dy > 100) return
  423. var hit = this.hitTest(x, y)
  424. if (hit) {
  425. this.selectedEffect = hit
  426. } else {
  427. this.selectedEffect = null
  428. }
  429. },
  430. hitTest: function(x, y) {
  431. // 先检查成效节点
  432. for (var i = 0; i < this.effectNodes.length; i++) {
  433. var n = this.effectNodes[i]
  434. var dx = x - n.x
  435. var dy = y - n.y
  436. if (dx * dx + dy * dy <= n.radius * n.radius) return n
  437. }
  438. // 再检查拓展方向节点
  439. for (var j = 0; j < this.expansionNodes.length; j++) {
  440. var n = this.expansionNodes[j]
  441. var dx = x - n.x
  442. var dy = y - n.y
  443. if (dx * dx + dy * dy <= n.radius * n.radius) {
  444. // 拓展节点 → 触发 expansion-click 事件
  445. this.$emit('expansion-click', n)
  446. return n
  447. }
  448. }
  449. return null
  450. },
  451. onAddKeyPerson: function(effect) {
  452. this.$emit('effect-click', effect)
  453. // 如果有关键人列表则显示,否则触发 add-key-person
  454. if (!this.keyPersons[effect.id] || this.keyPersons[effect.id].length === 0) {
  455. this.$emit('add-key-person')
  456. }
  457. },
  458. initCanvas: function(cb) {
  459. var self = this
  460. uni.createSelectorQuery().in(this)
  461. .select('#octopusCanvas')
  462. .fields({ node: true, size: true, rect: true })
  463. .exec(function(res) {
  464. try {
  465. if (self._destroyed) return
  466. if (!res || !res[0]) { if (cb) cb(false); return }
  467. var info = res[0]
  468. if (!info.node) { if (cb) cb(false); return }
  469. var canvas = info.node
  470. var width = info.width
  471. var height = info.height
  472. if (!width || !height) {
  473. var winWidth = 375
  474. try {
  475. var wi = uni.getWindowInfo()
  476. winWidth = wi.windowWidth || 375
  477. } catch (e) {}
  478. width = Math.round((self.canvasWidth || 340) * winWidth / 750)
  479. height = Math.round((self.canvasHeight || 420) * winWidth / 750)
  480. }
  481. var pixelRatio = 2
  482. try {
  483. var sys = uni.getWindowInfo()
  484. pixelRatio = sys.pixelRatio || 2
  485. } catch (e) {}
  486. canvas.width = width * pixelRatio
  487. canvas.height = height * pixelRatio
  488. var ctx = canvas.getContext('2d')
  489. if (!ctx) { if (cb) cb(false); return }
  490. ctx.scale(pixelRatio, pixelRatio)
  491. self.ctx = ctx
  492. self._canvasNode = canvas
  493. self.canvasWidth = width
  494. self.canvasHeight = height
  495. self.dpr = pixelRatio
  496. self.canvasReady = true
  497. if (info.rect) {
  498. self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
  499. }
  500. self.buildLayout()
  501. self.buildExpansionNodes()
  502. self.drawAll()
  503. if (cb) cb(true)
  504. } catch (e) {
  505. if (cb) cb(false)
  506. }
  507. })
  508. },
  509. _getCanvasRect: function() {
  510. if (this._canvasRect) {
  511. return { left: this._canvasRect.left, top: this._canvasRect.top, width: this.canvasWidth, height: this.canvasHeight }
  512. }
  513. return { left: 0, top: 0, width: this.canvasWidth, height: this.canvasHeight }
  514. },
  515. _hexToRgba: function(hex, alpha) {
  516. var r = parseInt(hex.slice(1, 3), 16)
  517. var g = parseInt(hex.slice(3, 5), 16)
  518. var b = parseInt(hex.slice(5, 7), 16)
  519. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  520. },
  521. _getTypeColor: function(type) {
  522. if (!type) return '#64748B'
  523. // 返回第一个类型对应的颜色
  524. var list = []
  525. if (type & 1) list.push('金额大')
  526. if (type & 2) list.push('频次高')
  527. if (type & 4) list.push('新成交')
  528. if (list.length === 0) return '#64748B'
  529. return this.effectColors[list[0]] || '#64748B'
  530. },
  531. formatDate: function(dateStr) {
  532. if (!dateStr) return ''
  533. return String(dateStr).substring(0, 10)
  534. }
  535. }
  536. }
  537. </script>
  538. <style scoped>
  539. .octopus-wrapper {
  540. position: relative;
  541. width: 100%;
  542. min-height: 200px;
  543. height: auto;
  544. margin: 10rpx 0;
  545. background: #FFFFFF;
  546. border-radius: 24rpx;
  547. overflow: hidden;
  548. }
  549. .octopus-canvas {
  550. position: absolute;
  551. top: 0;
  552. left: 0;
  553. z-index: 1;
  554. }
  555. .octopus-empty, .octopus-loading {
  556. display: flex;
  557. flex-direction: column;
  558. align-items: center;
  559. justify-content: center;
  560. padding: 60rpx 20rpx;
  561. background: #FFFFFF;
  562. border-radius: 24rpx;
  563. margin: 10rpx 0;
  564. }
  565. .empty-icon {
  566. font-size: 80rpx;
  567. margin-bottom: 16rpx;
  568. }
  569. .empty-text {
  570. font-size: 28rpx;
  571. font-weight: 600;
  572. color: #1E293B;
  573. margin-bottom: 8rpx;
  574. }
  575. .empty-hint {
  576. font-size: 24rpx;
  577. color: #94A3B8;
  578. margin-bottom: 24rpx;
  579. }
  580. .loading-text {
  581. font-size: 28rpx;
  582. color: #64748B;
  583. }
  584. .action-btn {
  585. display: inline-block;
  586. padding: 12rpx 32rpx;
  587. border-radius: 20rpx;
  588. font-size: 26rpx;
  589. font-weight: 600;
  590. }
  591. .action-btn-primary {
  592. background: #F97316;
  593. color: #FFFFFF;
  594. }
  595. .reanalyze-btn {
  596. position: absolute;
  597. top: 12rpx;
  598. right: 16rpx;
  599. z-index: 10;
  600. background: rgba(255,255,255,0.9);
  601. border-radius: 20rpx;
  602. padding: 8rpx 16rpx;
  603. font-size: 22rpx;
  604. color: #64748B;
  605. }
  606. /* 成效弹窗 */
  607. .effect-popup {
  608. position: absolute;
  609. z-index: 10;
  610. pointer-events: none;
  611. transform: translate(-50%, -100%);
  612. margin-top: -16rpx;
  613. }
  614. .popup-content {
  615. background: #FFFFFF;
  616. border-radius: 16rpx;
  617. padding: 20rpx;
  618. box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.12);
  619. min-width: 200rpx;
  620. max-width: 320rpx;
  621. pointer-events: auto;
  622. }
  623. .popup-header {
  624. display: flex;
  625. align-items: center;
  626. margin-bottom: 12rpx;
  627. }
  628. .popup-name {
  629. font-size: 26rpx;
  630. font-weight: 600;
  631. color: #1E293B;
  632. }
  633. .popup-tags {
  634. display: flex;
  635. flex-wrap: wrap;
  636. margin-top: 4rpx;
  637. }
  638. .popup-tag {
  639. font-size: 18rpx;
  640. padding: 2rpx 10rpx;
  641. border-radius: 6rpx;
  642. margin-right: 8rpx;
  643. margin-top: 4rpx;
  644. background: #FFF7ED;
  645. color: #F97316;
  646. }
  647. .popup-keypersons {
  648. border-top: 1rpx solid #F1F5F9;
  649. padding-top: 10rpx;
  650. margin-top: 8rpx;
  651. }
  652. .popup-title {
  653. font-size: 22rpx;
  654. color: #94A3B8;
  655. display: block;
  656. margin-bottom: 6rpx;
  657. }
  658. .kp-item {
  659. font-size: 22rpx;
  660. color: #475569;
  661. display: block;
  662. margin-bottom: 4rpx;
  663. }
  664. .popup-empty {
  665. padding: 8rpx 0;
  666. }
  667. .empty-text {
  668. font-size: 22rpx;
  669. color: #94A3B8;
  670. }
  671. .popup-actions {
  672. display: flex;
  673. justify-content: center;
  674. margin-top: 12rpx;
  675. padding-top: 12rpx;
  676. border-top: 1rpx solid #F1F5F9;
  677. }
  678. </style>