EquipmentStats.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. <template>
  2. <div class="panel equipment-stats">
  3. <div class="border-beam"></div>
  4. <div class="panel-header">
  5. <div class="panel-header-icon">🔬</div>
  6. <span class="panel-title">实验室设备分类及使用统计</span>
  7. </div>
  8. <!-- 4:6 vertical layout -->
  9. <div class="equip-layout">
  10. <!-- Top flex:4: ring chart - device classification -->
  11. <div class="equip-section equip-top">
  12. <div ref="ringChart" class="ring-chart"></div>
  13. </div>
  14. <!-- Bottom flex:6: stats row + two pie charts -->
  15. <div class="equip-section equip-bottom">
  16. <!-- 3 stat items -->
  17. <div class="equip-mid-row">
  18. <div class="equip-stat-item">
  19. <div class="ev">{{ totalEquipment.toLocaleString() }}</div>
  20. <div class="el">设备总数(台)</div>
  21. </div>
  22. <div class="equip-stat-item">
  23. <div class="ev">{{ totalHours.toLocaleString() }}</div>
  24. <div class="el">使用时长(h)</div>
  25. </div>
  26. <div class="equip-stat-item">
  27. <div class="ev">{{ usageRate }}%</div>
  28. <div class="el">设备使用率</div>
  29. </div>
  30. </div>
  31. <!-- Two pie charts side by side -->
  32. <div class="equip-pie-row">
  33. <div class="equip-pie-col">
  34. <div class="pie-col-title">设备状态统计</div>
  35. <div ref="pieStatusChart" class="pie-chart"></div>
  36. </div>
  37. <div class="equip-pie-col">
  38. <div class="pie-col-title">使用状态统计</div>
  39. <div ref="pieUsageChart" class="pie-chart"></div>
  40. </div>
  41. </div>
  42. </div>
  43. </div>
  44. </div>
  45. </template>
  46. <script>
  47. import * as echarts from 'echarts'
  48. import { getDeviceCategoryStat } from '@/api/screen'
  49. const TOOLTIP_CFG = {
  50. backgroundColor: 'rgba(3,14,42,0.92)',
  51. borderColor: 'rgba(30,144,255,0.3)',
  52. textStyle: { color: '#a8cce8', fontSize: 28 }
  53. }
  54. const CATEGORY_COLORS = ['#1e90ff', '#4361ee', '#00e676', '#ffd740', '#00e5c8', '#f97316', '#e040fb', '#ff5252']
  55. export default {
  56. name: 'EquipmentStats',
  57. data() {
  58. return {
  59. totalEquipment: 0,
  60. totalHours: 0,
  61. usageRate: 0,
  62. categories: [],
  63. deviceStatus: [],
  64. usageStatus: [],
  65. ringChart: null,
  66. pieStatusChart: null,
  67. pieUsageChart: null,
  68. pollTimer: null
  69. }
  70. },
  71. mounted() {
  72. this.fetchData()
  73. this.pollTimer = setInterval(this.fetchData, 5 * 60 * 1000)
  74. },
  75. beforeDestroy() {
  76. if (this.pollTimer) clearInterval(this.pollTimer)
  77. if (this.ringChart) this.ringChart.dispose()
  78. if (this.pieStatusChart) this.pieStatusChart.dispose()
  79. if (this.pieUsageChart) this.pieUsageChart.dispose()
  80. window.removeEventListener('resize', this.handleResize)
  81. },
  82. methods: {
  83. async fetchData() {
  84. try {
  85. const res = await getDeviceCategoryStat()
  86. if (res.code === 200) {
  87. const d = res.data
  88. this.totalEquipment = d.totalCount
  89. this.totalHours = d.totalUsageHours
  90. this.usageRate = d.usageRate
  91. this.categories = (d.categoryList || []).map((c, i) => ({
  92. value: c.totalCount,
  93. name: c.categoryOneName,
  94. color: CATEGORY_COLORS[i % CATEGORY_COLORS.length]
  95. }))
  96. const STATUS_COLORS = { '正常': '#00e676', '维修': '#f59e0b', '报废': '#ef4444' }
  97. const USAGE_COLORS = { '使用': '#1e90ff', '空闲': '#00e676' }
  98. this.deviceStatus = (d.deviceStatusList || []).map(s => ({
  99. value: s.count, name: s.name,
  100. color: STATUS_COLORS[s.name] || '#a8cce8'
  101. }))
  102. this.usageStatus = (d.usageStatusList || []).map(s => ({
  103. value: s.count, name: s.name,
  104. color: USAGE_COLORS[s.name] || '#a8cce8'
  105. }))
  106. }
  107. } catch (e) {
  108. // 保持默认值
  109. }
  110. if (this.ringChart) { this.ringChart.dispose(); this.ringChart = null }
  111. if (this.pieStatusChart) { this.pieStatusChart.dispose(); this.pieStatusChart = null }
  112. if (this.pieUsageChart) { this.pieUsageChart.dispose(); this.pieUsageChart = null }
  113. this.$nextTick(() => {
  114. this.initRingChart()
  115. this.initPieStatusChart()
  116. this.initPieUsageChart()
  117. window.addEventListener('resize', this.handleResize)
  118. })
  119. },
  120. handleResize() {
  121. if (this.ringChart) this.ringChart.resize()
  122. if (this.pieStatusChart) this.pieStatusChart.resize()
  123. if (this.pieUsageChart) this.pieUsageChart.resize()
  124. },
  125. /** 初始化环形图 - 设备分类 */
  126. initRingChart() {
  127. this.ringChart = echarts.init(this.$refs.ringChart, null, { renderer: 'canvas', devicePixelRatio: 2 })
  128. // Build count map for legend formatter
  129. const countMap = {}
  130. this.categories.forEach(c => { countMap[c.name] = c.value })
  131. const ringData = this.categories.map(c => ({
  132. value: c.value,
  133. name: c.name,
  134. itemStyle: {
  135. color: c.color,
  136. shadowBlur: 8,
  137. shadowColor: c.color.replace(')', ',0.5)').replace('rgb', 'rgba')
  138. }
  139. }))
  140. const option = {
  141. backgroundColor: 'transparent',
  142. tooltip: {
  143. trigger: 'item',
  144. formatter: '{b}: {c}台 ({d}%)',
  145. ...TOOLTIP_CFG
  146. },
  147. legend: {
  148. orient: 'vertical',
  149. right: '1%',
  150. top: 'middle',
  151. icon: 'circle',
  152. itemWidth: 28,
  153. itemHeight: 28,
  154. itemGap: 22,
  155. textStyle: { color: '#a8cce8', fontSize: 22 },
  156. formatter: function(name) {
  157. return name + ' ' + countMap[name] + '台'
  158. },
  159. rich: {
  160. nm: { fontSize: 22, color: '#a8cce8', width: 90 },
  161. vl: { fontSize: 26, fontWeight: 700, color: '#fff' }
  162. }
  163. },
  164. series: [{
  165. type: 'pie',
  166. radius: ['38%', '62%'],
  167. center: ['36%', '50%'],
  168. itemStyle: {
  169. borderRadius: 4,
  170. borderColor: 'rgba(3,14,31,0.5)',
  171. borderWidth: 2
  172. },
  173. label: {
  174. show: true,
  175. formatter: '{c}台',
  176. fontSize: 20,
  177. color: '#a8cce8'
  178. },
  179. labelLine: {
  180. length: 10,
  181. length2: 10,
  182. lineStyle: { color: 'rgba(30,144,255,0.4)', width: 2 }
  183. },
  184. data: ringData,
  185. emphasis: {
  186. scale: true,
  187. scaleSize: 5,
  188. itemStyle: {
  189. shadowBlur: 20,
  190. shadowColor: 'rgba(30,144,255,0.6)'
  191. }
  192. }
  193. }]
  194. }
  195. this.ringChart.setOption(option)
  196. },
  197. /** 初始化饼图 - 设备状态统计 */
  198. initPieStatusChart() {
  199. this.pieStatusChart = echarts.init(this.$refs.pieStatusChart, null, { renderer: 'canvas', devicePixelRatio: 2 })
  200. this.pieStatusChart.setOption(this._buildPieOption(this.deviceStatus))
  201. },
  202. /** 初始化饼图 - 使用状态统计 */
  203. initPieUsageChart() {
  204. this.pieUsageChart = echarts.init(this.$refs.pieUsageChart, null, { renderer: 'canvas', devicePixelRatio: 2 })
  205. this.pieUsageChart.setOption(this._buildPieOption(this.usageStatus))
  206. },
  207. _buildPieOption(list) {
  208. const countMap = {}
  209. list.forEach(s => { countMap[s.name] = s.value })
  210. const pieData = list.map(s => ({
  211. value: s.value, name: s.name,
  212. itemStyle: { color: s.color, shadowBlur: 10, shadowColor: s.color }
  213. }))
  214. return {
  215. backgroundColor: 'transparent',
  216. tooltip: { trigger: 'item', formatter: '{b}: {c}台 ({d}%)', ...TOOLTIP_CFG },
  217. legend: {
  218. show:false,
  219. orient: 'vertical', right: '2%', top: 'middle',
  220. icon: 'circle', itemWidth: 28, itemHeight: 28, itemGap: 28,
  221. textStyle: { color: '#a8cce8', fontSize: 22 },
  222. formatter: name => name + ' ' + countMap[name] + '台'
  223. },
  224. series: [{
  225. type: 'pie', radius: ['36%', '62%'], center: ['38%', '52%'],
  226. itemStyle: { borderRadius: 5, borderColor: 'rgba(3,14,31,0.5)', borderWidth: 2 },
  227. label: { show: true, formatter: '{b}\n{c}台', fontSize: 20, color: '#a8cce8', lineHeight: 30 },
  228. labelLine: { length: 24, length2: 40, lineStyle: { color: 'rgba(30,144,255,0.4)', width: 2 } },
  229. data: pieData,
  230. emphasis: { scale: true, scaleSize: 6, itemStyle: { shadowBlur: 25, shadowColor: 'rgba(30,144,255,0.6)' } }
  231. }]
  232. }
  233. }
  234. }
  235. }
  236. </script>
  237. <style lang="scss" scoped>
  238. .equipment-stats {
  239. position: relative;
  240. display: flex;
  241. flex-direction: column;
  242. height:1150px;
  243. }
  244. /* ---- Border beam ---- */
  245. .border-beam {
  246. position: absolute;
  247. inset: 0;
  248. pointer-events: none;
  249. border-radius: inherit;
  250. overflow: hidden;
  251. z-index: 2;
  252. &::before {
  253. content: '';
  254. position: absolute;
  255. top: 0;
  256. left: -100%;
  257. width: 40%;
  258. height: 3px;
  259. background: linear-gradient(90deg, transparent, rgba(30, 144, 255, 0.9), rgba(0, 216, 255, 0.7), transparent);
  260. animation: beamTop 5s linear infinite;
  261. }
  262. &::after {
  263. content: '';
  264. position: absolute;
  265. bottom: 0;
  266. right: -100%;
  267. width: 40%;
  268. height: 3px;
  269. background: linear-gradient(90deg, transparent, rgba(0, 216, 255, 0.7), rgba(30, 144, 255, 0.9), transparent);
  270. animation: beamBottom 5s linear infinite 2.5s;
  271. }
  272. }
  273. @keyframes beamTop { from { left: -40%; } to { left: 100%; } }
  274. @keyframes beamBottom { from { right: -40%; } to { right: 100%; } }
  275. /* ---- Panel header ---- */
  276. .panel-header {
  277. display: flex;
  278. align-items: center;
  279. gap: 25px;
  280. padding: 20px 30px 18px;
  281. border-bottom: 1px solid $border;
  282. background: linear-gradient(90deg, rgba(0, 60, 160, 0.18), transparent);
  283. flex-shrink: 0;
  284. }
  285. .panel-header-icon {
  286. width: 65px;
  287. height: 65px;
  288. border-radius: 12px;
  289. flex-shrink: 0;
  290. display: flex;
  291. align-items: center;
  292. justify-content: center;
  293. font-size: 32px;
  294. background: linear-gradient(135deg, rgba(30, 144, 255, 0.25), rgba(0, 216, 255, 0.15));
  295. border: 1px solid rgba(30, 144, 255, 0.35);
  296. animation: iconGlow 3s ease-in-out infinite;
  297. }
  298. @keyframes iconGlow {
  299. 0%, 100% { box-shadow: 0 0 15px rgba(30, 144, 255, 0.3); }
  300. 50% { box-shadow: 0 0 35px rgba(30, 144, 255, 0.7), 0 0 12px rgba(0, 216, 255, 0.3); }
  301. }
  302. .panel-title {
  303. font-size: 30px;
  304. font-weight: 600;
  305. letter-spacing: 2px;
  306. color: $cyan;
  307. }
  308. /* ---- 4:2:4 Layout ---- */
  309. .equip-layout {
  310. flex: 1;
  311. display: flex;
  312. flex-direction: column;
  313. padding: 15px 25px;
  314. gap: 12px;
  315. min-height: 0;
  316. }
  317. .equip-section {
  318. min-height: 0;
  319. }
  320. .equip-top {
  321. flex: 4;
  322. }
  323. .equip-bottom {
  324. flex: 6;
  325. display: flex;
  326. flex-direction: column;
  327. gap: 10px;
  328. }
  329. .equip-pie-row {
  330. flex: 1;
  331. min-height: 0;
  332. display: flex;
  333. gap: 12px;
  334. }
  335. .equip-pie-col {
  336. flex: 1;
  337. min-height: 0;
  338. display: flex;
  339. flex-direction: column;
  340. gap: 6px;
  341. .pie-col-title {
  342. text-align: center;
  343. font-size: 22px;
  344. font-weight: 600;
  345. letter-spacing: 3px;
  346. color: $cyan;
  347. padding: 5px 0 7px;
  348. border-bottom: 1px solid rgba(0, 216, 255, 0.22);
  349. text-shadow: 0 0 10px rgba(0, 216, 255, 0.45);
  350. flex-shrink: 0;
  351. }
  352. }
  353. .ring-chart,
  354. .pie-chart {
  355. width: 100%;
  356. height: 100%;
  357. }
  358. /* ---- Middle stat row ---- */
  359. .equip-mid-row {
  360. display: flex;
  361. gap: 15px;
  362. width: 100%;
  363. }
  364. .equip-stat-item {
  365. flex: 1;
  366. text-align: center;
  367. padding: 15px 10px;
  368. border-radius: 10px;
  369. background: $bg-card;
  370. border: 1px solid $border;
  371. .ev {
  372. font-size: 38px;
  373. font-weight: 700;
  374. color: $gold;
  375. }
  376. .el {
  377. font-size: 22px;
  378. color: $text-dim;
  379. margin-top: 5px;
  380. }
  381. }
  382. </style>