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

<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>