EventStats.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. <template>
  2. <div class="panel lab-stats">
  3. <!-- Border beam animation -->
  4. <div class="border-beam"></div>
  5. <!-- Corner ornaments -->
  6. <svg class="pc tl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
  7. <svg class="pc tr" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
  8. <svg class="pc bl" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
  9. <svg class="pc br" viewBox="0 0 14 14"><path d="M0 14 V0 H14" fill="none" stroke="rgba(30,144,255,0.85)" stroke-width="1.5"/></svg>
  10. <!-- Panel header -->
  11. <div class="panel-header">
  12. <div class="panel-header-icon">🏛️</div>
  13. <span class="panel-title">实验室基本情况统计</span>
  14. <div class="status-dot green"></div>
  15. </div>
  16. <!-- Upper section: left gauge + right donut -->
  17. <div class="upper-section">
  18. <!-- Left: SVG gauge showing total -->
  19. <div class="gauge-side">
  20. <svg width="320" height="320" viewBox="0 0 320 320">
  21. <defs>
  22. <linearGradient id="arcG1" x1="0%" y1="0%" x2="100%" y2="100%">
  23. <stop offset="0%" stop-color="#1e90ff"/>
  24. <stop offset="100%" stop-color="#00d8ff"/>
  25. </linearGradient>
  26. <filter id="glow1">
  27. <feGaussianBlur stdDeviation="4" result="blur"/>
  28. <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
  29. </filter>
  30. </defs>
  31. <!-- Outermost decorative ring -->
  32. <circle cx="160" cy="160" r="148" fill="none" stroke="rgba(30,144,255,0.08)" stroke-width="1" stroke-dasharray="6 6"/>
  33. <!-- Outer track -->
  34. <circle cx="160" cy="160" r="136" fill="none" stroke="rgba(30,144,255,0.14)" stroke-width="3"/>
  35. <!-- Main arc 300deg -->
  36. <circle cx="160" cy="160" r="136" fill="none" stroke="url(#arcG1)" stroke-width="8"
  37. stroke-dasharray="711 143" stroke-linecap="round" transform="rotate(-240 160 160)" filter="url(#glow1)"/>
  38. <!-- Inner track -->
  39. <circle cx="160" cy="160" r="110" fill="rgba(3,14,31,0.7)" stroke="rgba(0,216,255,0.15)" stroke-width="2"/>
  40. <!-- Four-level color arcs -->
  41. <circle cx="160" cy="160" r="88" fill="none" stroke="#cc0000" stroke-width="12"
  42. :stroke-dasharray="levelArcs[0].dash" stroke-linecap="round" transform="rotate(-90 160 160)" opacity="0.9"/>
  43. <circle cx="160" cy="160" r="88" fill="none" stroke="#ff8000" stroke-width="12"
  44. :stroke-dasharray="levelArcs[1].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[1].rotate + ' 160 160)'" opacity="0.9"/>
  45. <circle cx="160" cy="160" r="88" fill="none" stroke="#ffcc00" stroke-width="12"
  46. :stroke-dasharray="levelArcs[2].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[2].rotate + ' 160 160)'" opacity="0.9"/>
  47. <circle cx="160" cy="160" r="88" fill="none" stroke="#0066cc" stroke-width="12"
  48. :stroke-dasharray="levelArcs[3].dash" stroke-linecap="round" :transform="'rotate(' + levelArcs[3].rotate + ' 160 160)'" opacity="0.9"/>
  49. <!-- Endpoint glow -->
  50. <circle cx="160" cy="24" r="6" fill="#1e90ff" opacity="0.9"/>
  51. <!-- Center number -->
  52. <text x="160" y="148" text-anchor="middle" fill="#ffd740" font-size="46" font-weight="900" font-family="Arial,sans-serif" letter-spacing="-2">{{ total }}</text>
  53. <text x="160" y="178" text-anchor="middle" fill="rgba(168,204,232,0.75)" font-size="20" font-family="Arial,sans-serif" letter-spacing="4">间</text>
  54. </svg>
  55. <!-- Labels -->
  56. <div class="gauge-label">
  57. <div class="gauge-label-main">实验室总数</div>
  58. <div class="gauge-label-sub">TOTAL LABORATORIES</div>
  59. </div>
  60. <!-- Color bar showing proportions -->
  61. <div class="color-bar-wrap">
  62. <div class="color-bar">
  63. <div v-for="level in levels" :key="level.name" :style="{ flex: level.value, background: level.color, boxShadow: '0 0 10px ' + level.color + '80' }"></div>
  64. </div>
  65. <div class="color-bar-labels">
  66. <span v-for="level in levels" :key="'lbl-' + level.name" :style="{ flex: level.value }">{{ level.name.replace('级', '') }}</span>
  67. </div>
  68. </div>
  69. </div>
  70. <!-- Right: ECharts donut + level detail list -->
  71. <div class="donut-side">
  72. <div ref="donutChart" class="donut-chart"></div>
  73. <div class="level-list">
  74. <div
  75. v-for="level in levels"
  76. :key="'detail-' + level.name"
  77. class="level-item"
  78. :style="{ background: level.color + '12', borderLeftColor: level.color }"
  79. >
  80. <span class="level-item-left">
  81. <span class="level-dot" :style="{ background: level.color, boxShadow: '0 0 8px ' + level.color + 'cc' }"></span>
  82. {{ level.label }}
  83. </span>
  84. <span class="level-item-val" :style="{ color: levelValColor(level.color) }">{{ level.value }}间</span>
  85. </div>
  86. </div>
  87. </div>
  88. </div>
  89. <!-- Lower section: 3 status badges -->
  90. <div class="lower-section">
  91. <div class="status-row">
  92. <div class="status-badge active">
  93. <div class="val">{{ status.active }}</div>
  94. <div class="lbl">使用(间)</div>
  95. </div>
  96. <div class="status-badge warning">
  97. <div class="val">{{ status.warning }}</div>
  98. <div class="lbl">异常(间)</div>
  99. </div>
  100. <div class="status-badge idle">
  101. <div class="val">{{ status.idle }}</div>
  102. <div class="lbl">空闲(间)</div>
  103. </div>
  104. </div>
  105. </div>
  106. </div>
  107. </template>
  108. <script>
  109. import * as echarts from 'echarts'
  110. import { getLabBasicStatistics,getLevelTitleList } from '@/api/screen'
  111. export default {
  112. name: 'EventStats',
  113. data() {
  114. return {
  115. total: 0,
  116. levels: [],
  117. status: { active: 0, warning: 0, idle: 0 },
  118. chart: null,
  119. timer: null
  120. }
  121. },
  122. computed: {
  123. /** 计算SVG仪表盘中四个分级色弧的dasharray和旋转角度 */
  124. levelArcs() {
  125. if (!this.levels.length) {
  126. return [
  127. { dash: '0 553', rotate: -90 },
  128. { dash: '0 553', rotate: -90 },
  129. { dash: '0 553', rotate: -90 },
  130. { dash: '0 553', rotate: -90 }
  131. ]
  132. }
  133. const circumference = 2 * Math.PI * 88 // ~553
  134. const totalVal = this.levels.reduce((s, l) => s + l.value, 0)
  135. const arcs = []
  136. let accAngle = -90 // start at top
  137. for (let i = 0; i < this.levels.length; i++) {
  138. const ratio = this.levels[i].value / totalVal
  139. const arcLen = Math.round(ratio * circumference)
  140. const gapLen = Math.round(circumference - arcLen)
  141. arcs.push({
  142. dash: arcLen + ' ' + gapLen,
  143. rotate: Math.round(accAngle)
  144. })
  145. accAngle += ratio * 360
  146. }
  147. return arcs
  148. }
  149. },
  150. mounted() {
  151. this.getLevelTitleList()
  152. this.timer = setInterval(this.fetchData, 5 * 60 * 1000)
  153. },
  154. beforeDestroy() {
  155. if (this.timer) clearInterval(this.timer)
  156. if (this.chart) {
  157. this.chart.dispose()
  158. this.chart = null
  159. }
  160. },
  161. methods: {
  162. async getLevelTitleList() {
  163. const res = await getLevelTitleList()
  164. if (res.code === 200) {
  165. let list = [];
  166. for(let i=0;i<res.data.length;i++){
  167. list.push({
  168. levelGrade: res.data[i].levelGrade,
  169. name: res.data[i].titleName,
  170. label: res.data[i].showTitleName,
  171. value: '',
  172. color: res.data[i].titleColor
  173. })
  174. }
  175. this.$set(this,'levels',list);
  176. this.fetchData();
  177. }
  178. },
  179. async fetchData() {
  180. let self = this;
  181. try {
  182. const res = await getLabBasicStatistics()
  183. if (res.code === 200) {
  184. const d = res.data
  185. this.total = d.labTotal
  186. let list = JSON.parse(JSON.stringify(self.levels));
  187. for(let i=0;i<list.length;i++){
  188. if(list[i].levelGrade == 1){
  189. list[i].value = d.levelOneCount;
  190. }else if(list[i].levelGrade == 2){
  191. list[i].value = d.levelTwoCount;
  192. }else if(list[i].levelGrade == 3){
  193. list[i].value = d.levelThreeCount;
  194. }else if(list[i].levelGrade == 4){
  195. list[i].value = d.levelFourCount;
  196. }
  197. }
  198. this.$set(this,'levels',list);
  199. this.status = {
  200. active: d.useTotal,
  201. warning: d.exceptionalTotal,
  202. idle: d.availableTotal
  203. }
  204. this.$nextTick(() => this.initDonutChart())
  205. }
  206. } catch (e) {
  207. // 错误已由拦截器处理
  208. }
  209. },
  210. /** 根据分级主色计算稍亮的数值展示色 */
  211. levelValColor(color) {
  212. const map = {
  213. '#cc0000': '#ff6666',
  214. '#ff8000': '#ffaa44',
  215. '#ffcc00': '#ffe066',
  216. '#0066cc': '#4499ff'
  217. }
  218. return map[color] || color
  219. },
  220. /** 初始化ECharts环形图 - 展示四级分类占比,中心显示总数 */
  221. initDonutChart() {
  222. this.chart = echarts.init(this.$refs.donutChart, null, {
  223. renderer: 'canvas',
  224. devicePixelRatio: 2
  225. })
  226. const option = {
  227. backgroundColor: 'transparent',
  228. tooltip: {
  229. trigger: 'item',
  230. formatter: '{b}: {c}间 ({d}%)',
  231. backgroundColor: 'rgba(3,14,42,0.92)',
  232. borderColor: 'rgba(30,144,255,0.3)',
  233. textStyle: { color: '#a8cce8', fontSize: 13 }
  234. },
  235. graphic: [
  236. {
  237. type: 'text',
  238. left: 'center',
  239. top: '38%',
  240. style: {
  241. text: String(this.total),
  242. fill: '#ffd740',
  243. font: 'bold 38px Arial',
  244. textAlign: 'center'
  245. }
  246. },
  247. {
  248. type: 'text',
  249. left: 'center',
  250. top: '56%',
  251. style: {
  252. text: '实验室总数(间)',
  253. fill: 'rgba(168,204,232,0.65)',
  254. font: '13px Arial',
  255. textAlign: 'center'
  256. }
  257. }
  258. ],
  259. series: [{
  260. type: 'pie',
  261. radius: ['42%', '68%'],
  262. center: ['50%', '50%'],
  263. startAngle: 100,
  264. itemStyle: {
  265. borderRadius: 6,
  266. borderColor: 'rgba(3,14,31,0.6)',
  267. borderWidth: 3
  268. },
  269. label: {
  270. show: true,
  271. formatter: function (params) {
  272. return '{lvl|' + params.name + '}\n{cnt|' + params.value + '间}\n{pct|' + params.percent + '%}'
  273. },
  274. rich: {
  275. lvl: { fontSize: 13, fontWeight: 700, color: '#ddf0ff', lineHeight: 22 },
  276. cnt: { fontSize: 16, fontWeight: 900, color: '#ffd740', lineHeight: 26 },
  277. pct: { fontSize: 12, color: 'rgba(168,204,232,0.6)', lineHeight: 20 }
  278. },
  279. distanceToLabelLine: 8
  280. },
  281. labelLine: {
  282. show: true,
  283. length: 20,
  284. length2: 25,
  285. lineStyle: { color: 'rgba(30,144,255,0.45)', width: 2 }
  286. },
  287. data: this.levels.map(function (l) {
  288. return {
  289. value: l.value,
  290. name: l.name,
  291. itemStyle: {
  292. color: l.color,
  293. shadowBlur: 12,
  294. shadowColor: l.color + '80'
  295. }
  296. }
  297. }),
  298. emphasis: {
  299. scale: true,
  300. scaleSize: 8,
  301. itemStyle: { shadowBlur: 30, shadowColor: 'rgba(30,144,255,0.6)' },
  302. label: { fontSize: 14 }
  303. }
  304. }]
  305. }
  306. this.chart.setOption(option)
  307. }
  308. }
  309. }
  310. </script>
  311. <style lang="scss" scoped>
  312. .lab-stats {
  313. display: flex;
  314. flex-direction: column;
  315. position: relative;
  316. border-radius: 15px;
  317. overflow: hidden;
  318. background: $bg-panel;
  319. border: 1px solid $border;
  320. }
  321. .lab-stats::before {
  322. content: '';
  323. position: absolute;
  324. inset: 0;
  325. pointer-events: none;
  326. border-radius: inherit;
  327. background: linear-gradient(135deg, rgba(30, 144, 255, 0.05) 0%, transparent 50%, rgba(0, 216, 255, 0.03) 100%);
  328. }
  329. /* ---- Border beam ---- */
  330. .border-beam {
  331. position: absolute;
  332. inset: 0;
  333. pointer-events: none;
  334. border-radius: inherit;
  335. overflow: hidden;
  336. z-index: 2;
  337. &::before {
  338. content: '';
  339. position: absolute;
  340. top: 0;
  341. left: -100%;
  342. width: 40%;
  343. height: 3px;
  344. background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
  345. animation: beamTop 5s linear infinite;
  346. }
  347. &::after {
  348. content: '';
  349. position: absolute;
  350. bottom: 0;
  351. right: -100%;
  352. width: 40%;
  353. height: 3px;
  354. background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
  355. animation: beamBottom 5s linear infinite 2.5s;
  356. }
  357. }
  358. @keyframes beamTop { from { left: -40%; } to { left: 100%; } }
  359. @keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
  360. /* ---- Corner ornaments ---- */
  361. .pc {
  362. position: absolute;
  363. width: 35px;
  364. height: 35px;
  365. z-index: 3;
  366. pointer-events: none;
  367. &.tl { top: 0; left: 0; }
  368. &.tr { top: 0; right: 0; transform: scaleX(-1); }
  369. &.bl { bottom: 0; left: 0; transform: scaleY(-1); }
  370. &.br { bottom: 0; right: 0; transform: scale(-1); }
  371. }
  372. /* ---- Panel header ---- */
  373. .panel-header {
  374. display: flex;
  375. align-items: center;
  376. gap: 25px;
  377. padding: 20px 30px 18px;
  378. border-bottom: 1px solid $border;
  379. background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
  380. flex-shrink: 0;
  381. }
  382. .panel-header-icon {
  383. width: 65px;
  384. height: 65px;
  385. border-radius: 12px;
  386. flex-shrink: 0;
  387. display: flex;
  388. align-items: center;
  389. justify-content: center;
  390. font-size: 32px;
  391. background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
  392. border: 1px solid rgba(30, 144, 255, 0.35);
  393. animation: iconGlow 3s ease-in-out infinite;
  394. }
  395. @keyframes iconGlow {
  396. 0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
  397. 50% { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
  398. }
  399. .panel-title {
  400. font-size: 30px;
  401. font-weight: 600;
  402. letter-spacing: 2px;
  403. color: $cyan;
  404. }
  405. /* ---- Status dot ---- */
  406. .status-dot {
  407. width: 20px;
  408. height: 20px;
  409. border-radius: 50%;
  410. display: inline-block;
  411. margin-left: auto;
  412. animation: dotPulse 2s ease-in-out infinite;
  413. &.green {
  414. background: $green;
  415. box-shadow: 0 0 15px $green;
  416. }
  417. }
  418. @keyframes dotPulse {
  419. 0%, 100% { transform: scale(1); opacity: 1; }
  420. 50% { transform: scale(1.3); opacity: 0.7; }
  421. }
  422. /* ---- Upper section ---- */
  423. .upper-section {
  424. flex: 4;
  425. display: flex;
  426. min-height: 0;
  427. overflow: hidden;
  428. }
  429. /* Left gauge side */
  430. .gauge-side {
  431. flex: 4;
  432. display: flex;
  433. flex-direction: column;
  434. align-items: center;
  435. justify-content: center;
  436. gap: 18px;
  437. padding: 25px 20px;
  438. border-right: 1px solid $border;
  439. background: linear-gradient(180deg, rgba(30, 144, 255, 0.04), transparent);
  440. }
  441. .gauge-label {
  442. text-align: center;
  443. &-main {
  444. font-size: 30px;
  445. font-weight: 700;
  446. color: $cyan;
  447. letter-spacing: 4px;
  448. }
  449. &-sub {
  450. font-size: 22px;
  451. color: $text-dim;
  452. margin-top: 6px;
  453. letter-spacing: 2px;
  454. }
  455. }
  456. .color-bar-wrap {
  457. width: 100%;
  458. padding: 0 10px;
  459. }
  460. .color-bar {
  461. display: flex;
  462. gap: 5px;
  463. height: 12px;
  464. border-radius: 6px;
  465. overflow: hidden;
  466. }
  467. .color-bar-labels {
  468. display: flex;
  469. gap: 5px;
  470. margin-top: 8px;
  471. font-size: 22px;
  472. color: $text-dim;
  473. text-align: center;
  474. }
  475. /* Right donut side */
  476. .donut-side {
  477. flex: 6;
  478. display: flex;
  479. flex-direction: column;
  480. justify-content: center;
  481. padding: 25px 25px 20px;
  482. gap: 0;
  483. }
  484. .donut-chart {
  485. flex: 0 0 340px;
  486. width: 100%;
  487. }
  488. /* ---- Level detail list ---- */
  489. .level-list {
  490. display: flex;
  491. flex-direction: column;
  492. gap: 10px;
  493. margin-top: 10px;
  494. }
  495. .level-item {
  496. display: flex;
  497. justify-content: space-between;
  498. align-items: center;
  499. font-size: 26px;
  500. padding: 10px 16px;
  501. border-radius: 8px;
  502. border-left: 4px solid transparent;
  503. &-left {
  504. display: flex;
  505. align-items: center;
  506. gap: 12px;
  507. }
  508. &-val {
  509. font-weight: 700;
  510. font-size: 28px;
  511. }
  512. }
  513. .level-dot {
  514. width: 16px;
  515. height: 16px;
  516. border-radius: 3px;
  517. display: inline-block;
  518. }
  519. /* ---- Lower section ---- */
  520. .lower-section {
  521. flex: 1;
  522. display: flex;
  523. align-items: center;
  524. padding: 0 25px 20px;
  525. border-top: 1px solid $border;
  526. gap: 15px;
  527. min-height: 0;
  528. }
  529. .status-row {
  530. display: flex;
  531. gap: 15px;
  532. width: 100%;
  533. }
  534. .status-badge {
  535. flex: 1;
  536. padding: 15px 10px;
  537. border-radius: 10px;
  538. text-align: center;
  539. background: $bg-card;
  540. border: 1px solid $border;
  541. &.active {
  542. border-color: $green;
  543. background: rgba(0, 230, 118, 0.07);
  544. .val { color: $green; }
  545. }
  546. &.warning {
  547. border-color: #f59e0b;
  548. background: rgba(245, 158, 11, 0.07);
  549. .val { color: #f59e0b; }
  550. }
  551. &.idle {
  552. border-color: $indigo;
  553. background: rgba(67, 97, 238, 0.07);
  554. .val { color: $indigo; }
  555. }
  556. .val {
  557. font-size: 48px;
  558. font-weight: 700;
  559. }
  560. .lbl {
  561. font-size: 25px;
  562. color: $text-dim;
  563. margin-top: 5px;
  564. }
  565. }
  566. </style>