2026-01-13 09:32:02 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
// 信号强度数据项接口
|
|
|
|
|
|
interface SignalStrengthItem {
|
|
|
|
|
|
id?: number
|
|
|
|
|
|
userLon?: string
|
|
|
|
|
|
userLat?: string
|
|
|
|
|
|
rssi?: number // 确保 rssi 是 number
|
|
|
|
|
|
[key: string]: any // 允许其他字段
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mapContainer = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
const networkType = ref<string>('ALL')
|
|
|
|
|
|
const networkName = ref<string>('ALL')
|
|
|
|
|
|
const startDate = ref<string>('')
|
|
|
|
|
|
const endDate = ref<string>('')
|
|
|
|
|
|
const loading = ref<boolean>(false)
|
|
|
|
|
|
const dataLoaded = ref<boolean>(false)
|
|
|
|
|
|
|
|
|
|
|
|
let map: any = null
|
|
|
|
|
|
let heatmapOverlay: any = null
|
|
|
|
|
|
|
|
|
|
|
|
// 将后端返回的数据转换为热力图库需要的格式
|
|
|
|
|
|
const transformDataForHeatmap = (data: SignalStrengthItem[]) => {
|
|
|
|
|
|
return data
|
|
|
|
|
|
.filter(item => {
|
|
|
|
|
|
const lat = parseFloat(String(item.userLat))
|
|
|
|
|
|
const lon = parseFloat(String(item.userLon))
|
|
|
|
|
|
// 过滤掉无效坐标和无效信号强度
|
|
|
|
|
|
return !isNaN(lat) && !isNaN(lon) && lat !== 0 && lon !== 0 && item.rssi != null
|
|
|
|
|
|
})
|
|
|
|
|
|
.map(item => {
|
|
|
|
|
|
// 热力图权重需要是正数,我们将RSSI(-120到0)映射到一个正数范围(例如1到100)
|
|
|
|
|
|
// 信号越强(越接近0),权重越高
|
|
|
|
|
|
const rssi = item.rssi ?? -120;
|
|
|
|
|
|
const count = Math.max(1, 100 - (Math.abs(rssi) - 20));
|
|
|
|
|
|
return {
|
|
|
|
|
|
lng: parseFloat(String(item.userLon)),
|
|
|
|
|
|
lat: parseFloat(String(item.userLat)),
|
|
|
|
|
|
count: count
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染热力图
|
|
|
|
|
|
const renderHeatmap = (points: any[]) => {
|
|
|
|
|
|
if (!map) return
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已存在热力图层,先移除
|
|
|
|
|
|
if (heatmapOverlay) {
|
|
|
|
|
|
map.removeOverlay(heatmapOverlay)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (points.length === 0) {
|
|
|
|
|
|
return; // 没有数据点,不创建图层
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建热力图实例
|
|
|
|
|
|
heatmapOverlay = new window.BMapLib.HeatmapOverlay({ radius: 20, opacity: 0.8 })
|
|
|
|
|
|
map.addOverlay(heatmapOverlay)
|
|
|
|
|
|
|
|
|
|
|
|
// 设置热力图数据
|
|
|
|
|
|
heatmapOverlay.setDataSet({
|
|
|
|
|
|
data: points,
|
|
|
|
|
|
max: 100 // 设置权重的最大值
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 自动调整地图视野以包含所有点
|
|
|
|
|
|
const viewport = map.getViewport(points.map((p: any) => new window.BMap.Point(p.lng, p.lat)));
|
|
|
|
|
|
map.centerAndZoom(viewport.center, viewport.zoom);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取数据
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
dataLoaded.value = false
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = new URLSearchParams()
|
|
|
|
|
|
params.append('networkType', networkType.value)
|
|
|
|
|
|
params.append('networkName', networkName.value)
|
|
|
|
|
|
if (startDate.value) params.append('startTime', new Date(startDate.value).getTime().toString())
|
|
|
|
|
|
if (endDate.value) {
|
|
|
|
|
|
const end = new Date(endDate.value);
|
|
|
|
|
|
end.setHours(23, 59, 59, 999);
|
|
|
|
|
|
params.append('endTime', end.getTime().toString());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`http://localhost:8081/api/signal/heatmap?${params.toString()}`)
|
|
|
|
|
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
|
|
|
|
|
|
|
|
|
|
const responseData = await response.json()
|
|
|
|
|
|
if (responseData.code !== 200) throw new Error(responseData.message || '获取数据失败')
|
|
|
|
|
|
|
|
|
|
|
|
const heatmapPoints = transformDataForHeatmap(responseData.data || [])
|
|
|
|
|
|
|
|
|
|
|
|
if (map) {
|
|
|
|
|
|
renderHeatmap(heatmapPoints)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取数据失败:', error)
|
|
|
|
|
|
if(map) renderHeatmap([]); // 清空热力图
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
dataLoaded.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化地图
|
|
|
|
|
|
const initMap = async () => {
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
|
|
|
|
|
|
// 轮询检查百度地图API和热力图库是否都已加载
|
|
|
|
|
|
if (!window.BMap || !window.BMapLib?.HeatmapOverlay) {
|
|
|
|
|
|
console.warn('百度地图API或热力图库未加载,200ms后重试...')
|
|
|
|
|
|
setTimeout(initMap, 200)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!mapContainer.value) {
|
|
|
|
|
|
console.warn('地图容器未找到')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
map = new window.BMap.Map(mapContainer.value)
|
|
|
|
|
|
map.centerAndZoom(new window.BMap.Point(105.8951, 36.5667), 5) // 默认中国中心
|
|
|
|
|
|
map.enableScrollWheelZoom(true)
|
|
|
|
|
|
map.addControl(new window.BMap.MapTypeControl({ mapTypes: [window.BMap.MapTypeId.NORMAL, window.BMap.MapTypeId.SATELLITE] }))
|
|
|
|
|
|
map.addControl(new window.BMap.NavigationControl())
|
|
|
|
|
|
|
|
|
|
|
|
// 地图初始化后立即获取数据
|
|
|
|
|
|
fetchData()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听筛选条件变化
|
|
|
|
|
|
watch([networkType, networkName, startDate, endDate], () => {
|
|
|
|
|
|
if (map && dataLoaded.value) {
|
|
|
|
|
|
fetchData()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
initMap()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (heatmapOverlay && map) {
|
|
|
|
|
|
map.removeOverlay(heatmapOverlay)
|
|
|
|
|
|
}
|
|
|
|
|
|
map = null
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
2026-01-09 16:51:16 +08:00
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="page">
|
2026-01-13 09:32:02 +08:00
|
|
|
|
<div v-if="loading" class="loading-overlay">
|
|
|
|
|
|
<div class="loading-spinner">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<p class="loading-text">加载数据中...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<h2>业务需求7:信号覆盖-信号强度分布(热力图)</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="filter-bar">
|
|
|
|
|
|
<div class="filter-item">
|
|
|
|
|
|
<label for="networkType">运营商:</label>
|
|
|
|
|
|
<select id="networkType" v-model="networkType" class="select-input" :disabled="loading">
|
|
|
|
|
|
<option value="ALL">ALL</option>
|
|
|
|
|
|
<option value="CMCC">中国移动</option>
|
|
|
|
|
|
<option value="CUCC">中国联通</option>
|
|
|
|
|
|
<option value="CTCC">中国电信</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="filter-item">
|
|
|
|
|
|
<label for="networkName">网络类型:</label>
|
|
|
|
|
|
<select id="networkName" v-model="networkName" class="select-input" :disabled="loading">
|
|
|
|
|
|
<option value="ALL">ALL</option>
|
|
|
|
|
|
<option value="2G">2G</option>
|
|
|
|
|
|
<option value="3G">3G</option>
|
|
|
|
|
|
<option value="4G">4G</option>
|
|
|
|
|
|
<option value="5G">5G</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="filter-item">
|
|
|
|
|
|
<label for="startDate">起始日期:</label>
|
|
|
|
|
|
<input id="startDate" type="date" v-model="startDate" class="date-input" :disabled="loading" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="filter-item">
|
|
|
|
|
|
<label for="endDate">结束日期:</label>
|
|
|
|
|
|
<input id="endDate" type="date" v-model="endDate" class="date-input" :disabled="loading" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button @click="fetchData" class="refresh-btn" :disabled="loading">
|
|
|
|
|
|
{{ loading ? '加载中...' : '刷新' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="map-container" ref="mapContainer"></div>
|
2026-01-09 16:51:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page {
|
|
|
|
|
|
padding: 16px;
|
2026-01-13 09:32:02 +08:00
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
position: relative;
|
2026-01-09 16:51:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 09:32:02 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.map-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: calc(100vh - 220px);
|
|
|
|
|
|
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>
|