DeviceClassifyTopCard.vue 5.6 KB

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