|
@@ -0,0 +1,360 @@
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import * as echarts from 'echarts'
|
|
|
|
|
+
|
|
|
|
|
+type StatItem = {
|
|
|
|
|
+ name?: string
|
|
|
|
|
+ category?: string
|
|
|
|
|
+ value?: number | string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps<{
|
|
|
|
|
+ data: StatItem[]
|
|
|
|
|
+ loading?: boolean
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const chartRef = ref<HTMLDivElement>()
|
|
|
|
|
+let chart: echarts.ECharts | null = null
|
|
|
|
|
+let resizeObserver: ResizeObserver | null = null
|
|
|
|
|
+
|
|
|
|
|
+const palette = [
|
|
|
|
|
+ '#1688f5',
|
|
|
|
|
+ '#35cdb2',
|
|
|
|
|
+ '#ffbd59',
|
|
|
|
|
+ '#ff6f91',
|
|
|
|
|
+ '#8c7cff',
|
|
|
|
|
+ '#4eb3ff',
|
|
|
|
|
+ '#52c41a',
|
|
|
|
|
+ '#fa8c16',
|
|
|
|
|
+ '#13c2c2',
|
|
|
|
|
+ '#722ed1',
|
|
|
|
|
+ '#eb2f96',
|
|
|
|
|
+ '#2f54eb'
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const chartData = computed(() =>
|
|
|
|
|
+ props.data.map((item) => ({
|
|
|
|
|
+ name: item.name || item.category || '未知',
|
|
|
|
|
+ value: Number(item.value ?? 0)
|
|
|
|
|
+ }))
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const total = computed(() => chartData.value.reduce((sum, item) => sum + item.value, 0))
|
|
|
|
|
+const legendItems = computed(() => chartData.value)
|
|
|
|
|
+
|
|
|
|
|
+const escapeHtml = (value: string) =>
|
|
|
|
|
+ value.replace(/[&<>"']/g, (char) => {
|
|
|
|
|
+ const entities: Record<string, string> = {
|
|
|
|
|
+ '&': '&',
|
|
|
|
|
+ '<': '<',
|
|
|
|
|
+ '>': '>',
|
|
|
|
|
+ '"': '"',
|
|
|
|
|
+ "'": '''
|
|
|
|
|
+ }
|
|
|
|
|
+ return entities[char]
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+const formatTooltip = (params: {
|
|
|
|
|
+ color?: string
|
|
|
|
|
+ marker?: string
|
|
|
|
|
+ name?: string
|
|
|
|
|
+ percent?: number
|
|
|
|
|
+ value?: number | string
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const color = typeof params.color === 'string' ? params.color : palette[0]
|
|
|
|
|
+ const marker =
|
|
|
|
|
+ params.marker ||
|
|
|
|
|
+ `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background:${color};"></span>`
|
|
|
|
|
+ const name = escapeHtml(params.name || '未知')
|
|
|
|
|
+ const value = params.value ?? 0
|
|
|
|
|
+ const percent = typeof params.percent === 'number' ? params.percent.toFixed(2) : '0.00'
|
|
|
|
|
+
|
|
|
|
|
+ return `<div style="line-height:1.8;font-size:14px;">${marker}<span>${name}</span><br/><span style="padding-left:14px;">${value} 台 (${percent}%)</span></div>`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const chartOption = computed(() => ({
|
|
|
|
|
+ color: palette,
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ trigger: 'item',
|
|
|
|
|
+ backgroundColor: '#fff',
|
|
|
|
|
+ borderColor: '#e8edf5',
|
|
|
|
|
+ borderWidth: 1,
|
|
|
|
|
+ padding: [8, 10],
|
|
|
|
|
+ textStyle: {
|
|
|
|
|
+ color: '#172033',
|
|
|
|
|
+ fontSize: 14
|
|
|
|
|
+ },
|
|
|
|
|
+ extraCssText: 'box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); border-radius: 10px;',
|
|
|
|
|
+ formatter: formatTooltip
|
|
|
|
|
+ },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'pie',
|
|
|
|
|
+ radius: ['56%', '90%'],
|
|
|
|
|
+ center: ['50%', '50%'],
|
|
|
|
|
+ minAngle: 8,
|
|
|
|
|
+ avoidLabelOverlap: true,
|
|
|
|
|
+ stillShowZeroSum: false,
|
|
|
|
|
+ label: { show: false },
|
|
|
|
|
+ labelLine: { show: false },
|
|
|
|
|
+ itemStyle: {
|
|
|
|
|
+ borderWidth: 0
|
|
|
|
|
+ },
|
|
|
|
|
+ emphasis: {
|
|
|
|
|
+ scale: true,
|
|
|
|
|
+ scaleSize: 4,
|
|
|
|
|
+ itemStyle: {
|
|
|
|
|
+ shadowBlur: 10,
|
|
|
|
|
+ shadowColor: 'rgba(15, 23, 42, 0.14)'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ data: chartData.value
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+const renderChart = () => {
|
|
|
|
|
+ if (!chartRef.value) return
|
|
|
|
|
+ if (props.loading || chartData.value.length === 0) {
|
|
|
|
|
+ chart?.clear()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!chart) {
|
|
|
|
|
+ chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
|
|
|
|
|
+ }
|
|
|
|
|
+ chart.setOption(chartOption.value, true)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => [props.loading, props.data],
|
|
|
|
|
+ () => nextTick(renderChart),
|
|
|
|
|
+ { deep: true, immediate: true }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ nextTick(renderChart)
|
|
|
|
|
+ if (chartRef.value) {
|
|
|
|
|
+ resizeObserver = new ResizeObserver(() => chart?.resize())
|
|
|
|
|
+ resizeObserver.observe(chartRef.value)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ resizeObserver?.disconnect()
|
|
|
|
|
+ chart?.dispose()
|
|
|
|
|
+ chart = null
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<template>
|
|
|
|
|
+ <section class="device-status-card">
|
|
|
|
|
+ <div class="card-header">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3>设备状态</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="soft-badge">{{ chartData.length }} 类</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="status-body">
|
|
|
|
|
+ <div class="chart-wrap">
|
|
|
|
|
+ <div v-show="chartData.length > 0 && !loading" ref="chartRef" class="chart-el"></div>
|
|
|
|
|
+ <div v-if="chartData.length === 0 || loading" class="empty-state">
|
|
|
|
|
+ {{ loading ? '加载中...' : '暂无状态数据' }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="chartData.length > 0 && !loading" class="chart-center">
|
|
|
|
|
+ <span>合计</span>
|
|
|
|
|
+ <strong>{{ total }}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="legend-list">
|
|
|
|
|
+ <div v-for="(item, index) in legendItems" :key="item.name" class="legend-item">
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="legend-dot"
|
|
|
|
|
+ :style="{ backgroundColor: palette[index % palette.length] }"></span>
|
|
|
|
|
+ <span class="legend-name">{{ item.name }}</span>
|
|
|
|
|
+ <strong>{{ item.value }}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.device-status-card {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ min-height: 164px;
|
|
|
|
|
+ padding: 12px 14px 10px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
|
|
+ border-radius: 16px;
|
|
|
|
|
+ box-shadow: 0 10px 26px rgb(15 23 42 / 8%);
|
|
|
|
|
+ container-type: inline-size;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.device-status-card::before,
|
|
|
|
|
+.device-status-card::after {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ content: '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.device-status-card::before {
|
|
|
|
|
+ top: -50px;
|
|
|
|
|
+ left: -34px;
|
|
|
|
|
+ width: 118px;
|
|
|
|
|
+ height: 118px;
|
|
|
|
|
+ background: rgb(53 205 178 / 10%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.device-status-card::after {
|
|
|
|
|
+ right: -38px;
|
|
|
|
|
+ bottom: -46px;
|
|
|
|
|
+ width: 126px;
|
|
|
|
|
+ height: 126px;
|
|
|
|
|
+ background: rgb(22 136 245 / 8%);
|
|
|
|
|
+ border: 1px solid rgb(22 136 245 / 10%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.card-header,
|
|
|
|
|
+.status-body,
|
|
|
|
|
+.legend-item {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ z-index: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.card-header {
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+h3 {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #25324a;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.soft-badge {
|
|
|
|
|
+ padding: 5px 11px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #1688f5;
|
|
|
|
|
+ background: #edf7ff;
|
|
|
|
|
+ border: 1px solid #d8ecff;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.status-body {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ margin-top: 6px;
|
|
|
|
|
+ grid-template-columns: minmax(240px, 1fr) minmax(300px, 330px);
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-wrap {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-el,
|
|
|
|
|
+.empty-state {
|
|
|
|
|
+ height: 168px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-center {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #8b98ac;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.chart-center strong {
|
|
|
|
|
+ margin-top: 3px;
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ line-height: 1;
|
|
|
|
|
+ color: #25324a;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.legend-list {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ width: min(100%, 330px);
|
|
|
|
|
+ max-height: 168px;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ padding-right: 4px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
+ gap: 7px 8px;
|
|
|
|
|
+ justify-self: end;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.legend-item {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #526178;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.legend-dot {
|
|
|
|
|
+ width: 9px;
|
|
|
|
|
+ height: 9px;
|
|
|
|
|
+ margin-right: 6px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.legend-name {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.legend-item strong {
|
|
|
|
|
+ min-width: 24px;
|
|
|
|
|
+ margin-left: 6px;
|
|
|
|
|
+ color: #172033;
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.empty-state {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #9aa6b8;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@container (max-width: 620px) {
|
|
|
|
|
+ .status-body {
|
|
|
|
|
+ grid-template-columns: minmax(220px, 1fr) minmax(150px, 176px);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .legend-list {
|
|
|
|
|
+ width: min(100%, 176px);
|
|
|
|
|
+ grid-template-columns: minmax(0, 1fr);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@media (width <= 1440px) {
|
|
|
|
|
+ .status-body {
|
|
|
|
|
+ grid-template-columns: minmax(190px, 1fr) minmax(132px, 156px);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .legend-list {
|
|
|
|
|
+ width: min(100%, 156px);
|
|
|
|
|
+ grid-template-columns: minmax(0, 1fr);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|