OctopusDiagram.vue 21 KB

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