404 lines
11 KiB
Vue
404 lines
11 KiB
Vue
<template>
|
||
<div>
|
||
<!-- 顶部统计卡片区 -->
|
||
<el-row :gutter="16" class="mb-16px">
|
||
<el-col :span="6">
|
||
<el-card shadow="hover" class="stat-card stat-card-total">
|
||
<div class="stat-value">{{ totalCount }}<span class="stat-unit">台</span></div>
|
||
<div class="stat-title">总设备数</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card shadow="hover" class="stat-card stat-card-online">
|
||
<div class="stat-value online">{{ onlineCount }}<span class="stat-unit">台</span></div>
|
||
<div class="stat-title">在线设备</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card shadow="hover" class="stat-card stat-card-offline">
|
||
<div class="stat-value offline">{{ offlineCount }}<span class="stat-unit">台</span></div>
|
||
<div class="stat-title">离线设备</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-card shadow="hover" class="stat-card stat-card-warning">
|
||
<div class="stat-value warning">{{ alertTotal }}<span class="stat-unit">条</span></div>
|
||
<div class="stat-title">预警信息 <span class="stat-warning-unhandled">未处理 {{ alertUnhandled }}</span></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 下方地图+柱状图 -->
|
||
<el-row :gutter="16">
|
||
<el-col :span="16">
|
||
<el-card shadow="never" class="modern-card" style="height: 700px">
|
||
<template #header>
|
||
<span class="modern-card-title">设备分布地图</span>
|
||
</template>
|
||
<Echart :options="mapOptions" :height="580" />
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-card shadow="never" class="modern-card" style="height: 700px;">
|
||
<template #header>
|
||
<span class="modern-card-title">会员增长趋势</span>
|
||
</template>
|
||
<Echart :options="memberLineOptions" :height="580" />
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { EChartsOption } from 'echarts'
|
||
import { Echart } from '@/components/Echart'
|
||
import chinaJson from '@/assets/map/json/china.json'
|
||
import * as echarts from 'echarts/core'
|
||
import { ElMessage } from 'element-plus'
|
||
import { DataAnalysis, Connection, CircleClose, Warning } from '@element-plus/icons-vue'
|
||
|
||
// 假设设备数据(实际可从API获取)
|
||
const devices = [
|
||
{ id: 1, name: '设备A', address: '上海市', status: 'online', type: '传感器' },
|
||
{ id: 2, name: '设备B', address: '上海市', status: 'offline', type: '摄像头' },
|
||
{ id: 3, name: '设备C', address: '北京市', status: 'online', type: '传感器' },
|
||
{ id: 4, name: '设备D', address: '广东省', status: 'offline', type: '摄像头' },
|
||
{ id: 5, name: '设备E', address: '广东省', status: 'online', type: '传感器' },
|
||
{ id: 6, name: '设备F', address: '江苏省', status: 'online', type: '摄像头' }
|
||
]
|
||
|
||
// 假设预警数据(实际可从API获取)
|
||
const alertList = [
|
||
{ id: 1, content: '温度过高', status: 0 },
|
||
{ id: 2, content: '电量过低', status: 1 },
|
||
{ id: 3, content: '设备离线', status: 0 }
|
||
]
|
||
const alertTotal = alertList.length
|
||
const alertUnhandled = alertList.filter(a => a.status === 0).length
|
||
|
||
// 统计卡片数据
|
||
const totalCount = devices.length
|
||
const onlineCount = devices.filter(d => d.status === 'online').length
|
||
const offlineCount = devices.filter(d => d.status === 'offline').length
|
||
|
||
// 设备按省份聚合
|
||
const areaCount: Record<string, number> = {}
|
||
devices.forEach(device => {
|
||
// 只取省份(如"上海市"、"广东省")
|
||
const match = device.address.match(/(.*?[省市自治区])/)
|
||
const area = match ? match[1] : device.address
|
||
areaCount[area] = (areaCount[area] || 0) + 1
|
||
})
|
||
const mapData = Object.entries(areaCount).map(([name, value]) => ({ name, value }))
|
||
|
||
// 省会经纬度表(部分示例,可补充完整)
|
||
const provinceCoords: Record<string, [number, number]> = {
|
||
'北京市': [116.4074, 39.9042],
|
||
'上海市': [121.4737, 31.2304],
|
||
'广东省': [113.2665, 23.1322],
|
||
'江苏省': [118.7969, 32.0603],
|
||
// ...可补充更多省份
|
||
}
|
||
|
||
// effectScatter 数据
|
||
const scatterData = mapData
|
||
.filter(item => provinceCoords[item.name])
|
||
.map(item => ({
|
||
name: item.name,
|
||
value: [...provinceCoords[item.name], item.value],
|
||
count: item.value
|
||
}))
|
||
|
||
// 地图配置(底图+点标注)
|
||
const mapOptions = ref<EChartsOption>({})
|
||
|
||
// 饼状图:设备类型分布
|
||
const typeCount: Record<string, number> = {}
|
||
devices.forEach(device => {
|
||
typeCount[device.type] = (typeCount[device.type] || 0) + 1
|
||
})
|
||
const pieData = Object.entries(typeCount).map(([name, value]) => ({ name, value }))
|
||
const barOptions = ref<any>({
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: {
|
||
orient: 'vertical',
|
||
left: 'left',
|
||
data: Object.keys(typeCount)
|
||
},
|
||
series: [
|
||
{
|
||
name: '设备类型分布',
|
||
type: 'pie',
|
||
radius: '70%',
|
||
center: ['60%', '50%'],
|
||
data: pieData,
|
||
label: {
|
||
formatter: '{b}: {c} ({d}%)',
|
||
fontSize: 14
|
||
},
|
||
emphasis: {
|
||
itemStyle: {
|
||
shadowBlur: 10,
|
||
shadowOffsetX: 0,
|
||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||
}
|
||
}
|
||
}
|
||
]
|
||
})
|
||
|
||
// 会员一周增长模拟数据
|
||
const memberGrowthData = [
|
||
{ date: '周一', count: 12 },
|
||
{ date: '周二', count: 18 },
|
||
{ date: '周三', count: 25 },
|
||
{ date: '周四', count: 20 },
|
||
{ date: '周五', count: 30 },
|
||
{ date: '周六', count: 40 },
|
||
{ date: '周日', count: 35 }
|
||
]
|
||
const memberLineOptions = ref({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 40, right: 24, top: 40, bottom: 40 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: memberGrowthData.map(d => d.date),
|
||
boundaryGap: false,
|
||
axisLine: { lineStyle: { color: '#dbeafe' } },
|
||
axisLabel: { color: '#666', fontSize: 14 }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
minInterval: 1,
|
||
axisLine: { show: false },
|
||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||
axisLabel: { color: '#888', fontSize: 13 }
|
||
},
|
||
series: [
|
||
{
|
||
name: '会员增长',
|
||
type: 'line',
|
||
data: memberGrowthData.map(d => d.count),
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 10,
|
||
lineStyle: { color: '#409EFF', width: 3 },
|
||
itemStyle: { color: '#409EFF', borderColor: '#fff', borderWidth: 2 },
|
||
areaStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0, y: 0, x2: 0, y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: 'rgba(64,158,255,0.18)' },
|
||
{ offset: 1, color: 'rgba(64,158,255,0.02)' }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
})
|
||
|
||
// 注册中国地图
|
||
onMounted(() => {
|
||
// @ts-ignore
|
||
echarts.registerMap('china', chinaJson)
|
||
mapOptions.value = {
|
||
geo: {
|
||
map: 'china',
|
||
roam: true,
|
||
label: {
|
||
show: true,
|
||
color: '#fff',
|
||
fontWeight: 'bold',
|
||
fontSize: 14,
|
||
formatter: (params) => params.name
|
||
},
|
||
itemStyle: { areaColor: '#232c3b', borderColor: '#4ea3ff' }
|
||
},
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: (params: any) => {
|
||
if (params.seriesType === 'effectScatter') {
|
||
return `${params.name}<br/>设备数量: ${params.value[2]}`
|
||
}
|
||
if (params.seriesType === 'map') {
|
||
return `${params.name}<br/>设备数量: ${params.data ? params.data.value : 0}`
|
||
}
|
||
return params.name
|
||
}
|
||
},
|
||
visualMap: {
|
||
min: 0,
|
||
max: Math.max(...mapData.map(d => d.value), 10),
|
||
left: 'left',
|
||
top: 'bottom',
|
||
text: ['多', '少'],
|
||
inRange: { color: ['#e0ffff', '#409EFF'] },
|
||
show: true
|
||
},
|
||
series: [
|
||
{
|
||
name: '设备分布',
|
||
type: 'map',
|
||
map: 'china',
|
||
geoIndex: 0,
|
||
roam: true,
|
||
data: mapData,
|
||
emphasis: {
|
||
label: { show: true }
|
||
}
|
||
},
|
||
{
|
||
name: '设备点',
|
||
type: 'effectScatter',
|
||
coordinateSystem: 'geo',
|
||
data: scatterData,
|
||
symbolSize: (val) => 16 + Math.sqrt(val[2]) * 2,
|
||
showEffectOn: 'render',
|
||
rippleEffect: { brushType: 'stroke' },
|
||
itemStyle: {
|
||
color: '#ff5722',
|
||
shadowBlur: 10,
|
||
shadowColor: '#333'
|
||
},
|
||
zlevel: 2
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
// 地图点击事件(弹窗显示设备数)
|
||
const handleMapClick = (params: any) => {
|
||
if (params.componentType === 'series') {
|
||
const area = params.name
|
||
const count = params.value
|
||
ElMessage.info(`${area} 有 ${count} 个设备`)
|
||
}
|
||
}
|
||
|
||
// 绑定事件
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
const chartDom = document.querySelector('.stat-card ~ .el-row .el-card .echart')
|
||
if (chartDom) {
|
||
// @ts-ignore
|
||
const chart = echarts.getInstanceByDom(chartDom)
|
||
chart && chart.on('click', handleMapClick)
|
||
}
|
||
}, 500)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.stat-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 120px;
|
||
border-radius: 18px;
|
||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.08);
|
||
background: linear-gradient(135deg, #f8fafc 0%, #e0e7ef 100%);
|
||
transition: box-shadow 0.2s, transform 0.2s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.stat-card:hover {
|
||
box-shadow: 0 8px 32px 0 rgba(64,158,255,0.18);
|
||
transform: translateY(-2px) scale(1.03);
|
||
}
|
||
.stat-card-total {
|
||
background: linear-gradient(135deg, #e0e7ef 0%, #b6c6e6 100%);
|
||
}
|
||
.stat-card-online {
|
||
background: linear-gradient(135deg, #e8f5e9 0%, #b2f0c0 100%);
|
||
}
|
||
.stat-card-offline {
|
||
background: linear-gradient(135deg, #ffeaea 0%, #ffc1c1 100%);
|
||
}
|
||
.stat-card-warning {
|
||
background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
|
||
}
|
||
.stat-card-warning .stat-icon {
|
||
color: #faad14;
|
||
}
|
||
.stat-value.warning {
|
||
color: #faad14;
|
||
}
|
||
.stat-warning-unhandled {
|
||
color: #f56c6c;
|
||
font-size: 13px;
|
||
margin-left: 8px;
|
||
}
|
||
.stat-icon {
|
||
font-size: 38px;
|
||
margin-bottom: 8px;
|
||
color: #409EFF;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.stat-card-online .stat-icon {
|
||
color: #4caf50;
|
||
}
|
||
.stat-card-offline .stat-icon {
|
||
color: #f44336;
|
||
}
|
||
.stat-value {
|
||
font-size: 38px;
|
||
font-weight: 700;
|
||
color: #222;
|
||
margin-bottom: 4px;
|
||
line-height: 1.1;
|
||
}
|
||
.stat-value.online {
|
||
color: #4caf50;
|
||
}
|
||
.stat-value.offline {
|
||
color: #f44336;
|
||
}
|
||
.stat-unit {
|
||
font-size: 16px;
|
||
font-weight: 400;
|
||
margin-left: 2px;
|
||
color: #888;
|
||
}
|
||
.stat-title {
|
||
font-size: 16px;
|
||
color: #666;
|
||
letter-spacing: 1px;
|
||
margin-top: 2px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 现代化大卡片样式 */
|
||
.modern-card {
|
||
border-radius: 18px;
|
||
box-shadow: 0 6px 32px 0 rgba(64,158,255,0.10);
|
||
background: #fff;
|
||
border: none;
|
||
overflow: hidden;
|
||
transition: box-shadow 0.2s, transform 0.2s;
|
||
}
|
||
.modern-card:hover {
|
||
box-shadow: 0 12px 48px 0 rgba(64,158,255,0.18);
|
||
transform: translateY(-2px) scale(1.01);
|
||
}
|
||
.modern-card .el-card__header {
|
||
background: transparent;
|
||
border-bottom: none;
|
||
padding: 24px 24px 12px 24px;
|
||
}
|
||
.modern-card-title {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #222;
|
||
letter-spacing: 1px;
|
||
}
|
||
.modern-card .el-card__body {
|
||
padding: 0 24px 24px 24px;
|
||
}
|
||
</style>
|