feat:设备地图页面
parent
4b13db5faf
commit
1999b8e5d9
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="map-container">
|
||||
<div id="device-map" class="map-instance"></div>
|
||||
|
||||
<!-- Custom HTML Markers for Map -->
|
||||
<div style="display: none;">
|
||||
<div
|
||||
v-for="item in markersData"
|
||||
:key="item.name"
|
||||
:id="`marker-${item.name}`"
|
||||
class="custom-marker"
|
||||
:class="[getMarkerStatus(item), { 'is-active': selectedName === item.name }]"
|
||||
@click="onMarkerClick(item)"
|
||||
>
|
||||
<div class="marker-core">{{ item.total }}</div>
|
||||
<div class="marker-label">{{ item.name }}</div>
|
||||
<div class="ripple"></div>
|
||||
<div class="ripple ripple-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { Map, View } from 'ol'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
import XYZ from 'ol/source/XYZ'
|
||||
import Overlay from 'ol/Overlay'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import 'ol/ol.css'
|
||||
|
||||
defineOptions({ name: 'DeviceMap' })
|
||||
|
||||
const props = defineProps<{
|
||||
viewType: 'global' | 'china'
|
||||
markersData: any[]
|
||||
selectedName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', data: any): void
|
||||
}>()
|
||||
|
||||
const map = ref<Map | null>(null)
|
||||
const overlays = ref<Overlay[]>([])
|
||||
|
||||
const getMarkerStatus = (item: any) => {
|
||||
if (item.alarm > 0) return 'status-alarm'
|
||||
if (item.online === 0) return 'status-offline'
|
||||
return 'status-normal'
|
||||
}
|
||||
|
||||
const onMarkerClick = (item: any) => {
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
const initMap = () => {
|
||||
// Use a light map tile to match design (e.g. CartoDB Positron or similar)
|
||||
const baseLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: 'https://{a-c}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attributions: '© OpenStreetMap contributors © CARTO'
|
||||
})
|
||||
})
|
||||
|
||||
map.value = new Map({
|
||||
target: 'device-map',
|
||||
layers: [baseLayer],
|
||||
view: new View({
|
||||
center: fromLonLat([0, 20]),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: 10
|
||||
}),
|
||||
controls: [] // Hide default controls like zoom buttons
|
||||
})
|
||||
|
||||
renderMarkers()
|
||||
}
|
||||
|
||||
const renderMarkers = () => {
|
||||
if (!map.value) return
|
||||
|
||||
// Clear old overlays
|
||||
overlays.value.forEach(overlay => map.value?.removeOverlay(overlay))
|
||||
overlays.value = []
|
||||
|
||||
// Add new overlays
|
||||
props.markersData.forEach(item => {
|
||||
const el = document.getElementById(`marker-${item.name}`)
|
||||
if (el) {
|
||||
const overlay = new Overlay({
|
||||
element: el,
|
||||
position: fromLonLat([item.longitude, item.latitude]),
|
||||
positioning: 'center-center',
|
||||
stopEvent: true
|
||||
})
|
||||
map.value.addOverlay(overlay)
|
||||
overlays.value.push(overlay)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateView = () => {
|
||||
if (!map.value) return
|
||||
const view = map.value.getView()
|
||||
if (props.viewType === 'global') {
|
||||
view.animate({
|
||||
center: fromLonLat([0, 20]),
|
||||
zoom: 2,
|
||||
duration: 800
|
||||
})
|
||||
} else {
|
||||
view.animate({
|
||||
center: fromLonLat([104.1954, 35.8617]),
|
||||
zoom: 4,
|
||||
duration: 800
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.viewType, () => {
|
||||
updateView()
|
||||
})
|
||||
|
||||
watch(() => props.markersData, () => {
|
||||
nextTick(() => {
|
||||
renderMarkers()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map.value) {
|
||||
map.value.setTarget(undefined)
|
||||
map.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: #f0f4f8; /* Soft background color for oceans */
|
||||
}
|
||||
|
||||
.map-instance {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Custom Marker Styles */
|
||||
.custom-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover, &.is-active {
|
||||
transform: scale(1.15);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.marker-core {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.marker-label {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
animation: ripple-anim 2s infinite ease-out;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.ripple-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
&.status-normal {
|
||||
color: #10b981;
|
||||
.marker-core { background: #10b981; }
|
||||
}
|
||||
&.status-alarm {
|
||||
color: #f56c6c;
|
||||
.marker-core { background: #f56c6c; }
|
||||
}
|
||||
&.status-offline {
|
||||
color: #909399;
|
||||
.marker-core { background: #909399; }
|
||||
.ripple { animation: none; border-color: rgba(144, 147, 153, 0.4); }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple-anim {
|
||||
0% {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
width: 250%;
|
||||
height: 250%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,615 @@
|
||||
<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="mapData"
|
||||
: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.name"
|
||||
@click="handleMarkerSelect(item)"
|
||||
:class="{ 'is-active': selectedCountry?.name === item.name }"
|
||||
>
|
||||
<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'
|
||||
|
||||
defineOptions({ name: 'IoTDeviceMap' })
|
||||
|
||||
const currentView = ref<'global' | 'china'>('global')
|
||||
const selectedCountry = ref<any>(null)
|
||||
const runStatusStats = ref({
|
||||
totalDeviceCount: 0,
|
||||
offlineCount: 0,
|
||||
runningCount: 0,
|
||||
standbyCount: 0,
|
||||
faultStandbyCount: 0,
|
||||
alarmRunningCount: 0
|
||||
})
|
||||
|
||||
// Mock Data
|
||||
const mapData = ref([
|
||||
{
|
||||
name: '中国',
|
||||
longitude: 104.1954,
|
||||
latitude: 35.8617,
|
||||
total: 89,
|
||||
online: 82,
|
||||
alarm: 3,
|
||||
running: 79,
|
||||
offline: 7
|
||||
},
|
||||
{
|
||||
name: '美国',
|
||||
longitude: -95.7129,
|
||||
latitude: 37.0902,
|
||||
total: 25,
|
||||
online: 23,
|
||||
alarm: 1,
|
||||
running: 18,
|
||||
offline: 2
|
||||
},
|
||||
{
|
||||
name: '日本',
|
||||
longitude: 138.2529,
|
||||
latitude: 36.2048,
|
||||
total: 12,
|
||||
online: 11,
|
||||
alarm: 0,
|
||||
running: 11,
|
||||
offline: 1
|
||||
},
|
||||
{
|
||||
name: '德国',
|
||||
longitude: 10.4515,
|
||||
latitude: 51.1657,
|
||||
total: 15,
|
||||
online: 14,
|
||||
alarm: 0,
|
||||
running: 14,
|
||||
offline: 1
|
||||
},
|
||||
{
|
||||
name: '印度',
|
||||
longitude: 78.9629,
|
||||
latitude: 20.5937,
|
||||
total: 4,
|
||||
online: 3,
|
||||
alarm: 0,
|
||||
running: 3,
|
||||
offline: 1
|
||||
},
|
||||
{
|
||||
name: '巴西',
|
||||
longitude: -51.9253,
|
||||
latitude: -14.235,
|
||||
total: 2,
|
||||
online: 2,
|
||||
alarm: 0,
|
||||
running: 2,
|
||||
offline: 0
|
||||
},
|
||||
{
|
||||
name: '韩国',
|
||||
longitude: 127.7669,
|
||||
latitude: 35.9078,
|
||||
total: 8,
|
||||
online: 7,
|
||||
alarm: 1,
|
||||
running: 6,
|
||||
offline: 1
|
||||
},
|
||||
{
|
||||
name: '墨西哥',
|
||||
longitude: -102.5528,
|
||||
latitude: 23.6345,
|
||||
total: 1,
|
||||
online: 0,
|
||||
alarm: 0,
|
||||
running: 0,
|
||||
offline: 1
|
||||
}
|
||||
])
|
||||
|
||||
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 handleMarkerSelect = (item: any) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRunStatusStats()
|
||||
})
|
||||
</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;
|
||||
}
|
||||
|
||||
.dist-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.dist-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue