DeviceClassifyTopCard.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <script setup lang="ts">
  2. import * as echarts from 'echarts'
  3. type ClassifyItem = {
  4. category?: string
  5. name?: string
  6. deviceCode?: string
  7. value?: number | string
  8. }
  9. const props = defineProps<{
  10. data: ClassifyItem[]
  11. loading?: boolean
  12. }>()
  13. const chartRef = ref<HTMLDivElement>()
  14. let chart: echarts.ECharts | null = null
  15. let resizeObserver: ResizeObserver | null = null
  16. const barPalette = [
  17. ['#47b5ff', '#147ff3'],
  18. ['#5f8dff', '#6a5cff'],
  19. ['#34d3b3', '#12a99d'],
  20. ['#ffbd59', '#f58b2d'],
  21. ['#8c7cff', '#6854ee']
  22. ]
  23. const displayData = computed(() => {
  24. const topData = props.data.filter((item) => item.deviceCode === 'top')
  25. return topData.length > 0 ? topData : props.data
  26. })
  27. const categories = computed(() =>
  28. displayData.value.map((item) => item.category || item.name || '未分类')
  29. )
  30. const values = computed(() => displayData.value.map((item) => Number(item.value ?? 0)))
  31. const escapeHtml = (value: string) =>
  32. value.replace(/[&<>"']/g, (char) => {
  33. const entities: Record<string, string> = {
  34. '&': '&amp;',
  35. '<': '&lt;',
  36. '>': '&gt;',
  37. '"': '&quot;',
  38. "'": '&#39;'
  39. }
  40. return entities[char]
  41. })
  42. const formatTooltip = (params: {
  43. color?: string
  44. marker?: string
  45. name?: string
  46. value?: number | string
  47. }) => {
  48. const marker =
  49. params.marker ||
  50. '<span style="display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:#1688f5;"></span>'
  51. const name = escapeHtml(params.name || '未分类')
  52. const value = params.value ?? 0
  53. return `<div style="line-height:1.8;font-size:14px;">${marker}<span>${name}</span><br/><span style="padding-left:15px;">设备数量&nbsp;&nbsp;<strong>${value}</strong></span></div>`
  54. }
  55. const seriesData = computed(() =>
  56. values.value.map((value, index) => {
  57. const colors = barPalette[index % barPalette.length]
  58. return {
  59. value,
  60. itemStyle: {
  61. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  62. { offset: 0, color: colors[0] },
  63. { offset: 1, color: colors[1] }
  64. ])
  65. }
  66. }
  67. })
  68. )
  69. const chartOption = computed(() => ({
  70. grid: {
  71. top: 16,
  72. right: 18,
  73. bottom: 50,
  74. left: 38
  75. },
  76. tooltip: {
  77. trigger: 'item',
  78. backgroundColor: '#fff',
  79. borderColor: '#e8edf5',
  80. borderWidth: 1,
  81. padding: [10, 13],
  82. textStyle: {
  83. color: '#172033',
  84. fontSize: 14
  85. },
  86. extraCssText: 'box-shadow: 0 12px 28px rgba(15, 23, 42, 0.14); border-radius: 12px;',
  87. formatter: formatTooltip
  88. },
  89. xAxis: {
  90. type: 'category',
  91. data: categories.value,
  92. axisTick: { show: false },
  93. axisLine: { lineStyle: { color: '#e8edf5' } },
  94. axisLabel: {
  95. color: '#738199',
  96. rotate: 30,
  97. interval: 0,
  98. fontSize: 11,
  99. margin: 10
  100. }
  101. },
  102. yAxis: {
  103. type: 'value',
  104. splitLine: {
  105. lineStyle: {
  106. color: '#edf1f6',
  107. type: 'dashed'
  108. }
  109. },
  110. axisLabel: {
  111. color: '#8b98ac',
  112. fontSize: 11
  113. }
  114. },
  115. series: [
  116. {
  117. name: '设备数量',
  118. type: 'bar',
  119. data: seriesData.value,
  120. barWidth: 16,
  121. barMinHeight: 6,
  122. showBackground: true,
  123. backgroundStyle: {
  124. color: 'rgba(22, 136, 245, 0.055)'
  125. },
  126. itemStyle: {},
  127. emphasis: {
  128. scale: true,
  129. itemStyle: {
  130. shadowBlur: 12,
  131. shadowColor: 'rgba(22, 136, 245, 0.22)'
  132. }
  133. }
  134. }
  135. ]
  136. }))
  137. const renderChart = () => {
  138. if (!chartRef.value || props.loading || displayData.value.length === 0) return
  139. if (!chart) {
  140. chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
  141. }
  142. chart.setOption(chartOption.value, true)
  143. }
  144. watch(
  145. () => [props.loading, props.data],
  146. () => nextTick(renderChart),
  147. { deep: true, immediate: true }
  148. )
  149. onMounted(() => {
  150. nextTick(renderChart)
  151. if (chartRef.value) {
  152. resizeObserver = new ResizeObserver(() => chart?.resize())
  153. resizeObserver.observe(chartRef.value)
  154. }
  155. })
  156. onBeforeUnmount(() => {
  157. resizeObserver?.disconnect()
  158. chart?.dispose()
  159. chart = null
  160. })
  161. </script>
  162. <template>
  163. <section class="device-classify-card">
  164. <div class="card-header">
  165. <div>
  166. <h3>分类 Top</h3>
  167. </div>
  168. <span class="soft-badge">Top {{ displayData.length }}</span>
  169. </div>
  170. <div v-show="displayData.length > 0 && !loading" ref="chartRef" class="chart-el"></div>
  171. <div v-if="displayData.length === 0 || loading" class="empty-state">
  172. {{ loading ? '加载中...' : '暂无分类数据' }}
  173. </div>
  174. </section>
  175. </template>
  176. <style scoped lang="scss">
  177. .device-classify-card {
  178. position: relative;
  179. min-width: 0;
  180. min-height: 164px;
  181. padding: 12px 14px 6px;
  182. overflow: hidden;
  183. background: radial-gradient(circle at 92% 18%, rgb(22 136 245 / 9%) 0, transparent 28%),
  184. linear-gradient(180deg, #fff 0%, #fbfdff 100%);
  185. border: 1px solid #e8edf5;
  186. border-radius: 16px;
  187. box-shadow: 0 10px 26px rgb(15 23 42 / 8%);
  188. }
  189. .device-classify-card::before,
  190. .device-classify-card::after {
  191. position: absolute;
  192. pointer-events: none;
  193. border-radius: 999px;
  194. content: '';
  195. }
  196. .device-classify-card::before {
  197. top: -52px;
  198. right: 120px;
  199. width: 112px;
  200. height: 112px;
  201. background: rgb(53 205 178 / 8%);
  202. }
  203. .device-classify-card::after {
  204. right: -42px;
  205. bottom: -48px;
  206. width: 132px;
  207. height: 132px;
  208. background: rgb(140 124 255 / 7%);
  209. border: 1px solid rgb(140 124 255 / 10%);
  210. }
  211. .card-header {
  212. position: relative;
  213. z-index: 1;
  214. display: flex;
  215. align-items: center;
  216. justify-content: space-between;
  217. }
  218. h3 {
  219. margin: 0;
  220. font-size: 16px;
  221. font-weight: 700;
  222. color: #25324a;
  223. }
  224. .soft-badge {
  225. padding: 5px 11px;
  226. font-size: 12px;
  227. color: #1688f5;
  228. background: #edf7ff;
  229. border: 1px solid #d8ecff;
  230. border-radius: 999px;
  231. }
  232. .chart-el,
  233. .empty-state {
  234. position: relative;
  235. z-index: 1;
  236. height: 168px;
  237. }
  238. .empty-state {
  239. display: flex;
  240. font-size: 13px;
  241. color: #9aa6b8;
  242. align-items: center;
  243. justify-content: center;
  244. }
  245. </style>