399 lines
10 KiB
Vue
399 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
|
||
|
||
interface DataConnectionItem {
|
||
id: number
|
||
imei: string | null
|
||
networkType: string | null
|
||
wifiBssid: string | null
|
||
wifiState: string | null
|
||
wifiRssi: string | null
|
||
mobileState: string | null
|
||
mobileNetworkType: string | null
|
||
networkId: string | null
|
||
gsmStrength: string | null
|
||
cdmaDbm: string | null
|
||
evdoDbm: string | null
|
||
internalIp: string | null
|
||
webUrl: string | null
|
||
pingValue: string
|
||
userLat: string
|
||
userLon: string
|
||
userLocationInfo: string | null
|
||
bsLat: string | null
|
||
bsLon: string | null
|
||
timeIndexClient: string | null
|
||
version: string | null
|
||
timeServerInsert: string | null
|
||
ds: string | null
|
||
dt: string | null
|
||
}
|
||
|
||
const mapContainer = ref<HTMLDivElement | null>(null)
|
||
const operator = ref<string>('CMCC') // 默认选择中国移动
|
||
const date = ref<string>('')
|
||
const loading = ref<boolean>(false)
|
||
const dataLoaded = ref<boolean>(false)
|
||
const dataList = ref<DataConnectionItem[]>([])
|
||
const markers: any[] = []
|
||
|
||
let map: any = null
|
||
// 存储所有有效坐标点(带原数据)
|
||
let validPointsList: Array<{ lat: number; lon: number; item: DataConnectionItem }> = []
|
||
|
||
// 核心函数:默认聚焦到第一个有效点并放大
|
||
const calculateDensityFocus = (): { center: any; zoom: number; hasValid: boolean } => {
|
||
if (!window.BMap || validPointsList.length === 0) {
|
||
return {
|
||
center: new window.BMap.Point(105.8951, 36.5667), // 中国中心
|
||
zoom: 4,
|
||
hasValid: false
|
||
}
|
||
}
|
||
|
||
// 默认聚焦到第一个有效点,并放大到17级(更大的缩放级别)
|
||
const firstPoint = validPointsList[0]
|
||
return {
|
||
center: new window.BMap.Point(firstPoint.lon, firstPoint.lat),
|
||
zoom: 17, // 放大到17级,可以看到更详细的区域
|
||
hasValid: true
|
||
}
|
||
}
|
||
|
||
// 清除标记点
|
||
const clearMarkers = () => {
|
||
if (!map) return
|
||
// 遍历所有标记点并移除
|
||
markers.forEach(marker => {
|
||
try {
|
||
map.removeOverlay(marker)
|
||
} catch (error) {
|
||
console.warn('移除标记点失败:', error)
|
||
}
|
||
})
|
||
// 清空标记点数组
|
||
markers.length = 0
|
||
}
|
||
|
||
// 获取标记点颜色
|
||
const getMarkerColor = (pingValue: string): string => {
|
||
if (pingValue === '11111') return '#10b981'
|
||
if (pingValue === '00000') return '#6b7280'
|
||
return '#3b82f6'
|
||
}
|
||
|
||
// 添加标记点并自动聚焦
|
||
const addMarkersAndFocus = () => {
|
||
if (!map || validPointsList.length === 0) return
|
||
clearMarkers()
|
||
|
||
// 批量添加标记点
|
||
validPointsList.forEach(({ lat, lon, item }) => {
|
||
const point = new window.BMap.Point(lon, lat)
|
||
const color = getMarkerColor(item.pingValue)
|
||
|
||
// 自定义图标(固定大小,确保缩放后可见)
|
||
const icon = new window.BMap.Icon(
|
||
`data:image/svg+xml;base64,${btoa(`
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||
<circle cx="10" cy="10" r="8" fill="${color}" stroke="white" stroke-width="2"/>
|
||
</svg>
|
||
`)}`,
|
||
new window.BMap.Size(20, 20),
|
||
{ anchor: new window.BMap.Size(10, 10) }
|
||
)
|
||
|
||
const marker = new window.BMap.Marker(point, { icon })
|
||
// 信息窗口
|
||
const infoWindow = new window.BMap.InfoWindow(`
|
||
<div style="padding:8px">
|
||
<p><strong>网络ID:</strong> ${item.networkId || 'N/A'}</p>
|
||
<p><strong>类型:</strong> ${item.networkType || 'N/A'}</p>
|
||
<p><strong>Ping值:</strong> ${item.pingValue}</p>
|
||
<p><strong>坐标:</strong> ${lat.toFixed(6)}, ${lon.toFixed(6)}</p>
|
||
</div>
|
||
`, { width: 200 })
|
||
|
||
marker.addEventListener('click', () => map.openInfoWindow(infoWindow, point))
|
||
map.addOverlay(marker)
|
||
markers.push(marker)
|
||
})
|
||
|
||
// 标记点添加完成后,立即聚焦密度中心(关键:同步执行,无延迟)
|
||
const { center, zoom } = calculateDensityFocus()
|
||
map.centerAndZoom(center, zoom)
|
||
}
|
||
|
||
// 过滤有效坐标点(中国境内)
|
||
const filterValidPoints = (data: DataConnectionItem[]) => {
|
||
validPointsList = data.filter(item => {
|
||
if (!item.userLat || !item.userLon) return false
|
||
const lat = parseFloat(item.userLat)
|
||
const lon = parseFloat(item.userLon)
|
||
// 中国经纬度范围:纬度18°N-53°N,经度73°E-135°E
|
||
return !isNaN(lat) && !isNaN(lon) && lat >= 18 && lat <= 53 && lon >= 73 && lon <= 135
|
||
}).map(item => ({
|
||
lat: parseFloat(item.userLat!),
|
||
lon: parseFloat(item.userLon!),
|
||
item
|
||
}))
|
||
}
|
||
|
||
// 获取数据
|
||
const fetchData = async () => {
|
||
loading.value = true
|
||
dataLoaded.value = false
|
||
|
||
// 先清除旧数据:清空有效点列表和地图上的标记点
|
||
validPointsList = []
|
||
if (map) {
|
||
clearMarkers()
|
||
}
|
||
|
||
try {
|
||
const body: { operator?: string; date?: string } = {}
|
||
if (operator.value) body.operator = operator.value
|
||
if (date.value) body.date = date.value
|
||
|
||
const response = await fetch('http://localhost:8081/dataConnection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
})
|
||
|
||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||
const data = await response.json() as DataConnectionItem[]
|
||
dataList.value = data
|
||
|
||
// 过滤有效点
|
||
filterValidPoints(data)
|
||
|
||
// 地图已初始化:直接添加标记点并聚焦
|
||
if (map) {
|
||
await nextTick()
|
||
addMarkersAndFocus()
|
||
}
|
||
} catch (error) {
|
||
console.error('获取数据失败:', error)
|
||
validPointsList = []
|
||
clearMarkers()
|
||
} finally {
|
||
loading.value = false
|
||
dataLoaded.value = true
|
||
}
|
||
}
|
||
|
||
// 初始化地图(必须在数据加载完成后执行!)
|
||
const initMapAfterData = async () => {
|
||
// 等待DOM渲染和百度地图API加载
|
||
await nextTick()
|
||
|
||
// 检查百度地图API是否加载完成
|
||
if (!window.BMap || !window.BMap.Map) {
|
||
console.warn('百度地图API未加载,等待中...')
|
||
setTimeout(() => initMapAfterData(), 200)
|
||
return
|
||
}
|
||
|
||
if (!mapContainer.value) {
|
||
console.warn('地图容器未找到')
|
||
return
|
||
}
|
||
|
||
// 如果地图已存在,先清除
|
||
if (map) {
|
||
clearMarkers()
|
||
}
|
||
|
||
// 初始化地图
|
||
map = new window.BMap.Map(mapContainer.value)
|
||
map.enableScrollWheelZoom(true)
|
||
map.enableDragging(true)
|
||
// 添加地图控件
|
||
map.addControl(new window.BMap.MapTypeControl({
|
||
mapTypes: [window.BMap.MapTypeId.NORMAL, window.BMap.MapTypeId.SATELLITE]
|
||
}))
|
||
map.addControl(new window.BMap.NavigationControl())
|
||
map.addControl(new window.BMap.ScaleControl())
|
||
|
||
// 如果有数据,添加标记点并聚焦
|
||
if (validPointsList.length > 0) {
|
||
// 等待地图完全初始化后再添加标记点
|
||
setTimeout(() => {
|
||
addMarkersAndFocus()
|
||
}, 100)
|
||
} else {
|
||
// 无数据时显示全国地图
|
||
map.centerAndZoom(new window.BMap.Point(105.8951, 36.5667), 4)
|
||
}
|
||
}
|
||
|
||
// 监听筛选条件变化
|
||
watch([operator, date], () => {
|
||
if (map && dataLoaded.value) fetchData()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
// 第一步:先加载数据
|
||
await fetchData()
|
||
// 第二步:数据加载完成后,再初始化地图
|
||
await initMapAfterData()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
clearMarkers()
|
||
map = null
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<!-- 加载遮罩 -->
|
||
<div v-if="loading || !dataLoaded" class="loading-overlay">
|
||
<div class="loading-spinner">
|
||
<div class="spinner"></div>
|
||
<p class="loading-text">加载数据中...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>业务需求15:数据链接-数据链接率分布</h2>
|
||
|
||
<div class="filter-bar">
|
||
<div class="filter-item">
|
||
<label for="operator">运营商:</label>
|
||
<select id="operator" v-model="operator" class="select-input" :disabled="loading">
|
||
<option value="CMCC">中国移动</option>
|
||
<option value="CUCC">中国联通</option>
|
||
<option value="CTCC">中国电信</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label for="date">日期:</label>
|
||
<input id="date" type="date" v-model="date" class="date-input" :disabled="loading" />
|
||
</div>
|
||
<button @click="fetchData" class="refresh-btn" :disabled="loading">
|
||
{{ loading ? '加载中...' : '刷新' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="legend">
|
||
<div class="legend-item">
|
||
<span class="legend-dot" style="background-color: #10b981;"></span>
|
||
<span>正常 (11111)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="legend-dot" style="background-color: #6b7280;"></span>
|
||
<span>异常 (00000)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="map-container" ref="mapContainer"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
padding: 16px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative; /* 让加载遮罩只覆盖当前页面,而不是全局 */
|
||
}
|
||
|
||
h2 {
|
||
margin: 0 0 16px 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
padding: 12px;
|
||
background: #f9fafb;
|
||
border-radius: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.select-input, .date-input {
|
||
padding: 6px 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.refresh-btn {
|
||
padding: 6px 16px;
|
||
background: #3b82f6;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.refresh-btn:hover:not(:disabled) { background: #2563eb; }
|
||
.refresh-btn:disabled { background: #9ca3af; cursor: not-allowed; }
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 24px;
|
||
margin-bottom: 12px;
|
||
padding: 8px 12px;
|
||
background: #f9fafb;
|
||
border-radius: 4px;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
}
|
||
.legend-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
border: 1px solid white;
|
||
box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.map-container {
|
||
width: 100%;
|
||
height: calc(100vh - 280px);
|
||
min-height: 500px;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 4px;
|
||
flex: 1;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(255,255,255,0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10;
|
||
}
|
||
.loading-spinner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
.spinner {
|
||
width: 48px; height: 48px;
|
||
border: 4px solid #e5e7eb;
|
||
border-top-color: #3b82f6;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.loading-text { font-size: 16px; color: #374151; margin: 0; }
|
||
</style> |