You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
618 lines
16 KiB
Vue
618 lines
16 KiB
Vue
<template>
|
|
<div class="device-map-page">
|
|
<!-- Top Header & Stats -->
|
|
<div class="stats-container">
|
|
<el-row :gutter="16">
|
|
<el-col :span="3" v-for="stat in topStats" :key="stat.label">
|
|
<el-card shadow="never" class="stat-card">
|
|
<div class="stat-content">
|
|
<div class="stat-icon" :class="`icon-${stat.type}`">
|
|
<Icon :icon="stat.icon" :size="24" />
|
|
</div>
|
|
<div class="stat-info">
|
|
<div class="stat-value">{{ stat.value }}</div>
|
|
<div class="stat-label">{{ stat.label }}</div>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
<!-- View Toggle Buttons -->
|
|
<el-col :span="6" class="toggle-col">
|
|
<div class="view-toggle">
|
|
<el-radio-group v-model="currentView" size="large">
|
|
<el-radio-button label="global">
|
|
<Icon icon="ep:map-location" class="mr-1" /> 全球视图
|
|
</el-radio-button>
|
|
<el-radio-button label="china">
|
|
<Icon icon="ep:location" class="mr-1" /> 中国视图
|
|
</el-radio-button>
|
|
</el-radio-group>
|
|
</div>
|
|
</el-col>
|
|
</el-row>
|
|
</div>
|
|
|
|
<!-- Main Map Area -->
|
|
<el-card
|
|
shadow="never"
|
|
class="map-section"
|
|
:body-style="{ padding: 0, height: '100%', display: 'flex', flexDirection: 'column' }"
|
|
>
|
|
<div class="map-header">
|
|
<div class="map-title">
|
|
<span class="dot"></span>
|
|
{{ currentView === 'global' ? '全球设备分布地图' : '中国设备分布地图' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="map-body">
|
|
<DeviceMap
|
|
:view-type="currentView"
|
|
:markers-data="markerData"
|
|
:selected-name="selectedCountry?.name"
|
|
@select="handleMarkerSelect"
|
|
/>
|
|
|
|
<!-- Legend -->
|
|
<div class="map-legend">
|
|
<div class="legend-title">图例说明</div>
|
|
<div class="legend-item"> <span class="legend-dot dot-normal"></span> 运行正常 </div>
|
|
<div class="legend-item"> <span class="legend-dot dot-alarm"></span> 存在报警 </div>
|
|
<div class="legend-item"> <span class="legend-dot dot-offline"></span> 离线设备 </div>
|
|
<div class="legend-desc">
|
|
<div><Icon icon="ep:pointer" /> 点击设备标记查看详细统计</div>
|
|
<div><Icon icon="ep:switch" /> 切换视图查看不同区域</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Drawer Panel -->
|
|
<transition name="slide-fade">
|
|
<div class="detail-panel" v-if="selectedCountry">
|
|
<div class="panel-header">
|
|
<span>{{ selectedCountry.name }}</span>
|
|
<Icon icon="ep:close" class="close-icon" @click="selectedCountry = null" />
|
|
</div>
|
|
<div class="panel-content">
|
|
<div class="detail-row">
|
|
<span class="label">设备总数</span>
|
|
<span class="value">{{ selectedCountry.total }}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="label">联网设备</span>
|
|
<span class="value text-success">{{ selectedCountry.online }}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="label">运行中</span>
|
|
<span class="value text-primary">{{ selectedCountry.running }}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="label">离线设备</span>
|
|
<span class="value text-info">{{ selectedCountry.offline }}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="label">报警设备</span>
|
|
<span class="value text-danger">{{ selectedCountry.alarm }}</span>
|
|
</div>
|
|
|
|
<div class="progress-section">
|
|
<div class="progress-bar">
|
|
<el-progress
|
|
:percentage="
|
|
Math.round((selectedCountry.online / selectedCountry.total) * 100) || 0
|
|
"
|
|
:show-text="false"
|
|
color="#10b981"
|
|
/>
|
|
</div>
|
|
<div class="progress-label"
|
|
>联网率:
|
|
{{
|
|
Math.round((selectedCountry.online / selectedCountry.total) * 100) || 0
|
|
}}%</div
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- Bottom Distribution List -->
|
|
<el-card shadow="never" class="dist-section">
|
|
<div class="dist-header">国家/地区分布</div>
|
|
<div class="dist-list">
|
|
<div
|
|
class="dist-item"
|
|
v-for="item in mapData"
|
|
:key="item.customerId"
|
|
@click="handleMarkerSelect(item)"
|
|
:class="[
|
|
`status-${getItemStatus(item)}`,
|
|
{ 'is-active': selectedCountry?.name === item.name }
|
|
]"
|
|
>
|
|
<span class="dist-status-dot"></span>
|
|
<span class="dist-name">{{ item.name }}</span>
|
|
<el-tag size="small" type="primary" class="dist-tag">{{ item.total }}台</el-tag>
|
|
<el-tag size="small" type="success" class="dist-tag" v-if="item.online > 0"
|
|
>{{ item.online }}在线</el-tag
|
|
>
|
|
<el-tag size="small" type="danger" class="dist-tag" v-if="item.alarm > 0"
|
|
>{{ item.alarm }}报警</el-tag
|
|
>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import DeviceMap from './components/DeviceMap.vue'
|
|
import { DeviceApi } from '@/api/iot/device'
|
|
import type { DeviceStatusCountByCustomerRespVO } from '@/api/iot/device'
|
|
|
|
defineOptions({ name: 'IoTDeviceMap' })
|
|
|
|
const currentView = ref<'global' | 'china'>('global')
|
|
type MapItem = {
|
|
customerId: number
|
|
name: string
|
|
longitude: number
|
|
latitude: number
|
|
total: number
|
|
online: number
|
|
alarm: number
|
|
running: number
|
|
offline: number
|
|
standby: number
|
|
}
|
|
|
|
const selectedCountry = ref<MapItem | null>(null)
|
|
const runStatusStats = ref({
|
|
totalDeviceCount: 0,
|
|
offlineCount: 0,
|
|
runningCount: 0,
|
|
standbyCount: 0,
|
|
faultStandbyCount: 0,
|
|
alarmRunningCount: 0
|
|
})
|
|
|
|
const mapData = ref<MapItem[]>([])
|
|
|
|
const markerData = computed(() =>
|
|
mapData.value.filter(
|
|
(item) =>
|
|
Number.isFinite(item.longitude) &&
|
|
Number.isFinite(item.latitude) &&
|
|
item.longitude !== 0 &&
|
|
item.latitude !== 0
|
|
)
|
|
)
|
|
|
|
const topStats = computed(() => [
|
|
{
|
|
label: '设备总数',
|
|
value: runStatusStats.value.totalDeviceCount,
|
|
icon: 'ep:data-line',
|
|
type: 'primary'
|
|
},
|
|
{
|
|
label: '离线设备',
|
|
value: runStatusStats.value.offlineCount,
|
|
icon: 'ep:turn-off',
|
|
type: 'offline'
|
|
},
|
|
{
|
|
label: '运行设备',
|
|
value: runStatusStats.value.runningCount,
|
|
icon: 'ep:setting',
|
|
type: 'running'
|
|
},
|
|
{
|
|
label: '待机设备',
|
|
value: runStatusStats.value.standbyCount,
|
|
icon: 'ep:clock',
|
|
type: 'success'
|
|
},
|
|
{
|
|
label: '故障待机',
|
|
value: runStatusStats.value.faultStandbyCount,
|
|
icon: 'ep:warning-filled',
|
|
type: 'alarm'
|
|
}
|
|
// {
|
|
// label: '报警运行',
|
|
// value: runStatusStats.value.alarmRunningCount,
|
|
// icon: 'ep:bell',
|
|
// type: 'alarm'
|
|
// }
|
|
])
|
|
|
|
const getItemStatus = (item: MapItem) => {
|
|
if (item.alarm > 0) return 'alarm'
|
|
if (item.total > 0 && item.offline >= item.total) return 'offline'
|
|
return 'normal'
|
|
}
|
|
|
|
const handleMarkerSelect = (item: MapItem) => {
|
|
selectedCountry.value = item
|
|
}
|
|
|
|
const loadRunStatusStats = async () => {
|
|
const data = await DeviceApi.getDeviceRunStatusStats()
|
|
runStatusStats.value = {
|
|
totalDeviceCount: Number(data?.totalDeviceCount || 0),
|
|
offlineCount: Number(data?.offlineCount || 0),
|
|
runningCount: Number(data?.runningCount || 0),
|
|
standbyCount: Number(data?.standbyCount || 0),
|
|
faultStandbyCount: Number(data?.faultStandbyCount || 0),
|
|
alarmRunningCount: Number(data?.alarmRunningCount || 0)
|
|
}
|
|
}
|
|
|
|
const loadMapData = async () => {
|
|
const data = await DeviceApi.getStatusCountByCustomer()
|
|
const transformedData = (data || []).map((item: DeviceStatusCountByCustomerRespVO) => {
|
|
const total = Number(item.totalDeviceCount || 0)
|
|
const offline = Number(item.offlineCount || 0)
|
|
const alarm = Number(item.faultStandbyCount || 0) + Number(item.alarmRunningCount || 0)
|
|
const online = Math.max(total - offline, 0)
|
|
return {
|
|
customerId: Number(item.customerId),
|
|
name: item.customerName || '-',
|
|
longitude: Number(item.longitude || 0),
|
|
latitude: Number(item.latitude || 0),
|
|
total,
|
|
online,
|
|
alarm,
|
|
running: Number(item.runningCount || 0),
|
|
offline,
|
|
standby: Number(item.standbyCount || 0)
|
|
}
|
|
})
|
|
mapData.value = transformedData
|
|
if (selectedCountry.value) {
|
|
selectedCountry.value =
|
|
transformedData.find((item) => item.customerId === selectedCountry.value?.customerId) || null
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadRunStatusStats(), loadMapData()])
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.device-map-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 120px);
|
|
gap: 16px;
|
|
min-height: 700px;
|
|
}
|
|
|
|
/* Stats Cards */
|
|
.stats-container {
|
|
flex: 0 0 auto;
|
|
}
|
|
.stat-card {
|
|
border-radius: 8px;
|
|
border: 1px solid var(--el-border-color-light);
|
|
.stat-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 4px;
|
|
}
|
|
.stat-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
&.icon-primary {
|
|
background: linear-gradient(135deg, #409eff, #53a8ff);
|
|
}
|
|
&.icon-success {
|
|
background: linear-gradient(135deg, #10b981, #34d399);
|
|
}
|
|
&.icon-running {
|
|
background: linear-gradient(135deg, #8b5cf6, #a78bfa);
|
|
}
|
|
&.icon-offline {
|
|
background: linear-gradient(135deg, #6b7280, #9ca3af);
|
|
}
|
|
&.icon-alarm {
|
|
background: linear-gradient(135deg, #ef4444, #f87171);
|
|
}
|
|
}
|
|
.stat-info {
|
|
flex: 1;
|
|
.stat-value {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--el-text-color-primary);
|
|
line-height: 1.2;
|
|
}
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--el-text-color-regular);
|
|
margin-top: 4px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.toggle-col {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
}
|
|
.view-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Map Section */
|
|
.map-section {
|
|
flex: 1 1 auto;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
|
|
.map-header {
|
|
flex: 0 0 auto;
|
|
padding: 16px 20px;
|
|
background: linear-gradient(90deg, #6d28d9, #8b5cf6);
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
.map-title {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
.dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background-color: #10b981;
|
|
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
|
|
}
|
|
}
|
|
}
|
|
|
|
.map-body {
|
|
flex: 1 1 auto;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
}
|
|
|
|
/* Legend */
|
|
.map-legend {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(4px);
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
width: 220px;
|
|
z-index: 100;
|
|
border: 1px solid var(--el-border-color-light);
|
|
|
|
.legend-title {
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
margin-bottom: 12px;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
font-size: 13px;
|
|
color: var(--el-text-color-regular);
|
|
.legend-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
&.dot-normal {
|
|
background-color: #10b981;
|
|
}
|
|
&.dot-alarm {
|
|
background-color: #ef4444;
|
|
}
|
|
&.dot-offline {
|
|
background-color: #9ca3af;
|
|
}
|
|
}
|
|
}
|
|
.legend-desc {
|
|
margin-top: 16px;
|
|
padding-top: 12px;
|
|
border-top: 1px dashed var(--el-border-color-lighter);
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
line-height: 1.8;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Right Detail Panel */
|
|
.detail-panel {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 260px;
|
|
width: 280px;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
z-index: 101;
|
|
overflow: hidden;
|
|
border: 1px solid var(--el-border-color-light);
|
|
|
|
.panel-header {
|
|
padding: 16px 20px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background-color: #f8fafc;
|
|
.close-icon {
|
|
cursor: pointer;
|
|
color: var(--el-text-color-secondary);
|
|
&:hover {
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
}
|
|
}
|
|
.panel-content {
|
|
padding: 20px;
|
|
.detail-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px 0;
|
|
border-bottom: 1px dashed var(--el-border-color-lighter);
|
|
font-size: 14px;
|
|
.label {
|
|
color: var(--el-text-color-regular);
|
|
}
|
|
.value {
|
|
font-weight: bold;
|
|
font-size: 15px;
|
|
}
|
|
.text-success {
|
|
color: #10b981;
|
|
}
|
|
.text-primary {
|
|
color: #409eff;
|
|
}
|
|
.text-info {
|
|
color: #9ca3af;
|
|
}
|
|
.text-danger {
|
|
color: #ef4444;
|
|
}
|
|
}
|
|
.progress-section {
|
|
margin-top: 24px;
|
|
.progress-label {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.slide-fade-enter-active {
|
|
transition: all 0.3s ease-out;
|
|
}
|
|
.slide-fade-leave-active {
|
|
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
|
}
|
|
.slide-fade-enter-from,
|
|
.slide-fade-leave-to {
|
|
transform: translateX(20px);
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Bottom Distribution */
|
|
.dist-section {
|
|
flex: 0 0 auto;
|
|
border-radius: 8px;
|
|
.dist-header {
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
color: var(--el-text-color-primary);
|
|
margin-bottom: 16px;
|
|
}
|
|
.dist-list {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
overflow-x: auto;
|
|
gap: 12px;
|
|
padding-bottom: 8px;
|
|
|
|
&::-webkit-scrollbar {
|
|
height: 6px;
|
|
}
|
|
&::-webkit-scrollbar-thumb {
|
|
background: #e5e7eb;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.dist-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
background: #f8fafc;
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
|
|
&:hover,
|
|
&.is-active {
|
|
background: #f0fdf4;
|
|
border-color: #86efac;
|
|
}
|
|
|
|
&.status-normal {
|
|
.dist-status-dot {
|
|
background-color: #10b981;
|
|
}
|
|
}
|
|
&.status-alarm {
|
|
.dist-status-dot {
|
|
background-color: #ef4444;
|
|
}
|
|
}
|
|
&.status-offline {
|
|
.dist-status-dot {
|
|
background-color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.dist-status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dist-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
margin-right: 4px;
|
|
}
|
|
.dist-tag {
|
|
border-radius: 4px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|