Files
shixi2026vue/src/views/hot-phone/Req26PhoneDistributionMapView.vue
2026-01-13 20:18:49 +08:00

691 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
interface RegionTopPhoneItem {
rowIndex: number
colIndex: number
centerLon: number
centerLat: number
phoneModel: string
os: string
osVersion: string
totalTraffic: number
recordCount: number
}
const mapContainer = ref<HTMLDivElement | null>(null)
const operator = ref<string>('CTCC') // 默认选择中国电信
const networkType = ref<string>('')
const minUploadTraffic = ref<number | null>(null)
const maxUploadTraffic = ref<number | null>(null)
const minDownloadTraffic = ref<number | null>(null)
const maxDownloadTraffic = ref<number | null>(null)
const startDate = ref<string>('')
const endDate = ref<string>('')
const minTrafficThreshold = ref<number>(1000) // 预设流量阈值KB
const gridRows = ref<number>(20) // 网格行数
const gridCols = ref<number>(15) // 网格列数
const loading = ref<boolean>(false)
const dataLoaded = ref<boolean>(false)
const dataList = ref<RegionTopPhoneItem[]>([])
let map: any = null
let overlays: any[] = []
let updateMarkersRetryCount = 0 // 重试计数器
const MAX_UPDATE_RETRIES = 10 // 最大重试次数
// 定义百度地图类型别名,简化访问
const BMap = (window as any).BMap
// 初始化地图参考需求7的实现方式
const initMap = async () => {
await nextTick()
// 轮询检查百度地图API是否已加载
if (!(window as any).BMap) {
console.warn('百度地图API未加载200ms后重试...')
setTimeout(initMap, 200)
return
}
else {
console.log('百度地图API已加载')
}
if (!mapContainer.value) {
console.warn('地图容器未找到等待100ms后重试')
setTimeout(initMap, 100)
return
}
console.log('地图容器已找到,开始创建地图实例')
try {
const BMap = (window as any).BMap
console.log('开始创建地图实例,容器:', mapContainer.value)
// 如果之前有地图实例,先清理
if (map) {
console.log('检测到已有地图实例,先清理')
try {
map.clearOverlays()
} catch (e) {
console.warn('清理旧地图实例失败:', e)
}
}
map = new BMap.Map(mapContainer.value)
console.log('地图实例创建成功')
map.centerAndZoom(new BMap.Point(105.8951, 36.5667), 5) // 默认中国中心
console.log('地图中心点设置完成')
map.enableScrollWheelZoom(true)
map.enableDragging(true)
console.log('地图交互功能启用完成')
map.addControl(new BMap.MapTypeControl({ mapTypes: [BMap.MapTypeId?.NORMAL || 1, BMap.MapTypeId?.SATELLITE || 2] }))
map.addControl(new BMap.NavigationControl())
map.addControl(new BMap.ScaleControl())
console.log('地图控件添加完成')
// 监听地图移动和缩放事件,更新标记(增加防抖和容错)
let updateTimer: any = null
const debouncedUpdateMarkers = () => {
if (updateTimer) clearTimeout(updateTimer)
updateTimer = setTimeout(() => {
updateMarkers()
}, 300)
}
map.addEventListener('moveend', debouncedUpdateMarkers)
map.addEventListener('zoomend', debouncedUpdateMarkers)
console.log('地图初始化完成')
// 地图初始化后立即获取数据
fetchData()
} catch (error) {
console.error('地图初始化过程中出错:', error)
// 即使出错也尝试获取数据
if (map) {
console.log('地图实例存在,尝试获取数据')
fetchData()
} else {
console.error('地图实例创建失败,无法获取数据')
loading.value = false
dataLoaded.value = true
}
}
}
// 创建手机logo图标使用文字作为占位符
const createPhoneIcon = (phoneModel: string, traffic: number): string => {
// 使用手机型号的版本号部分如android4.1.2中的4.1.2
let displayText = phoneModel
if (phoneModel.toLowerCase().includes('android')) {
// 提取版本号部分
const versionMatch = phoneModel.match(/android(\d+\.\d+\.\d+|\d+\.\d+)/i)
if (versionMatch) {
displayText = versionMatch[1] // 只显示版本号如4.1.2
} else {
// 如果没有匹配到版本号,使用前几个字符
displayText = phoneModel.length > 4 ? phoneModel.substring(0, 4) : phoneModel
}
} else {
displayText = phoneModel.length > 4 ? phoneModel.substring(0, 4) : phoneModel
}
const size = 40
const fontSize = 11
// 修复XML字符串中的换行问题避免base64编码错误
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="8" fill="#4b9cff" opacity="0.9"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-size="${fontSize}" font-weight="bold" fill="white">${displayText}</text>
<text x="50%" y="75%" text-anchor="middle" dominant-baseline="middle"
font-size="8" fill="white" opacity="0.8">${(traffic / 1000).toFixed(1)}K</text>
</svg>`
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgContent)))}`
}
// 更新地图标记(增加容错和延迟处理)
const updateMarkers = () => {
if (!map || !dataLoaded.value || dataList.value.length === 0) {
// 如果没有数据,只清除覆盖物
overlays.forEach(overlay => {
try {
map?.removeOverlay(overlay)
} catch (error) {
console.warn('移除覆盖物失败:', error)
}
})
overlays = []
return
}
// 清除旧的覆盖物
overlays.forEach(overlay => {
try {
map.removeOverlay(overlay)
} catch (error) {
console.warn('移除覆盖物失败:', error)
}
})
overlays = []
// 获取地图可视区域 - 增加容错处理
let bounds: any = null
try {
bounds = map.getBounds()
// 等待地图边界稳定(解决刚初始化时边界不完整问题)
if (!bounds) {
if (updateMarkersRetryCount < MAX_UPDATE_RETRIES) {
updateMarkersRetryCount++
console.log(`地图边界未获取到,${updateMarkersRetryCount}/${MAX_UPDATE_RETRIES}次重试`)
setTimeout(() => updateMarkers(), 500)
} else {
console.warn('地图边界获取失败,已达到最大重试次数,停止重试')
updateMarkersRetryCount = 0
}
return
}
} catch (error) {
if (updateMarkersRetryCount < MAX_UPDATE_RETRIES) {
updateMarkersRetryCount++
console.warn(`获取地图边界失败,${updateMarkersRetryCount}/${MAX_UPDATE_RETRIES}次重试:`, error)
setTimeout(() => updateMarkers(), 500)
} else {
console.warn('获取地图边界失败,已达到最大重试次数,停止重试')
updateMarkersRetryCount = 0
}
return
}
const sw = bounds.getSouthWest()
const ne = bounds.getNorthEast()
if (!sw || !ne || !sw.lat || !sw.lng || !ne.lat || !ne.lng) {
if (updateMarkersRetryCount < MAX_UPDATE_RETRIES) {
updateMarkersRetryCount++
console.warn(`地图边界数据不完整,${updateMarkersRetryCount}/${MAX_UPDATE_RETRIES}次重试更新标记`)
setTimeout(() => updateMarkers(), 500)
} else {
console.warn('地图边界数据不完整,已达到最大重试次数,停止重试')
updateMarkersRetryCount = 0
}
return
}
// 重置重试计数器(成功获取边界后)
updateMarkersRetryCount = 0
// 过滤出在可视区域内且流量超过阈值的数据
let visibleData = dataList.value.filter(item => {
// 检查是否在可视区域内
const inBounds = item.centerLat >= sw.lat && item.centerLat <= ne.lat &&
item.centerLon >= sw.lng && item.centerLon <= ne.lng
// 检查流量是否超过阈值
const exceedsThreshold = item.totalTraffic >= minTrafficThreshold.value
return inBounds && exceedsThreshold
})
// 再次限制最多显示100条确保性能
if (visibleData.length > 100) {
visibleData = visibleData.slice(0, 100)
console.log(`可视区域内数据过多限制显示前100条`)
}
console.log(`准备显示 ${visibleData.length} 个标记点`)
// 在地图上显示手机logo使用类型断言避免错误
const BMap = (window as any).BMap
visibleData.forEach(item => {
try {
const point = new BMap.Point(item.centerLon, item.centerLat)
const icon = new BMap.Icon(
createPhoneIcon(item.phoneModel, item.totalTraffic),
new BMap.Size(40, 40),
{ anchor: new BMap.Size(20, 20) }
)
const marker = new BMap.Marker(point, { icon })
// 信息窗口
const infoWindow = new BMap.InfoWindow(`
<div style="padding:8px">
<p><strong>手机型号:</strong> ${item.phoneModel}</p>
<p><strong>操作系统:</strong> ${item.os}</p>
<p><strong>系统版本:</strong> ${item.osVersion || '未指定'}</p>
<p><strong>总流量:</strong> ${(item.totalTraffic / 1024).toFixed(2)} MB</p>
<p><strong>记录数:</strong> ${item.recordCount}</p>
<p><strong>区域:</strong> 第${item.rowIndex + 1}行, 第${item.colIndex + 1}列</p>
<p><strong>坐标:</strong> ${item.centerLat.toFixed(6)}, ${item.centerLon.toFixed(6)}</p>
</div>
`, { width: 200 })
marker.addEventListener('click', () => map.openInfoWindow(infoWindow, point))
map.addOverlay(marker)
overlays.push(marker)
} catch (error) {
console.warn(`创建标记失败 (${item.phoneModel}):`, error)
}
})
}
// 获取数据
const fetchData = async () => {
console.log('fetchData 函数被调用')
loading.value = true
dataLoaded.value = false
// 清除旧数据
dataList.value = []
overlays.forEach(overlay => {
try {
map?.removeOverlay(overlay)
} catch (error) {
console.warn('移除覆盖物失败:', error)
}
})
overlays = []
try {
const body: any = {}
// 运营商筛选
if (operator.value && operator.value !== 'ALL') {
body.operator = operator.value
}
// 网络制式筛选
if (networkType.value) {
body.networkType = networkType.value
}
// 流量范围(手机流量)
if (minUploadTraffic.value !== null) {
body.minUploadTraffic = minUploadTraffic.value
}
if (maxUploadTraffic.value !== null) {
body.maxUploadTraffic = maxUploadTraffic.value
}
if (minDownloadTraffic.value !== null) {
body.minDownloadTraffic = minDownloadTraffic.value
}
if (maxDownloadTraffic.value !== null) {
body.maxDownloadTraffic = maxDownloadTraffic.value
}
// 日期范围
if (startDate.value) {
body.startDate = startDate.value
}
if (endDate.value) {
body.endDate = endDate.value
}
// 网格大小
body.gridRows = gridRows.value
body.gridCols = gridCols.value
console.log('准备发送请求,请求体:', body)
console.log('请求URL: http://localhost:8081/appTraffic/phoneRegionTop')
const response = await fetch('http://localhost:8081/appTraffic/phoneRegionTop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
console.log('收到响应,状态码:', response.status)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const data = await response.json() as RegionTopPhoneItem[]
console.log('收到数据:', data.length, '条')
// 限制只显示前100条数据避免数据量太大导致地图加载缓慢
dataList.value = data.slice(0, 100)
console.log('限制显示:', dataList.value.length, '条')
// 地图已初始化:更新标记
if (map) {
await nextTick()
// 重置重试计数器
updateMarkersRetryCount = 0
// 增加延迟,确保地图完全渲染后再更新标记
setTimeout(() => {
updateMarkers()
}, 500) // 延迟500ms确保地图完全加载
} else {
console.warn('地图未初始化,等待地图初始化后再更新标记')
}
} catch (error) {
console.error('获取数据失败:', error)
alert(`获取数据失败: ${error instanceof Error ? error.message : String(error)}`)
dataList.value = []
} finally {
loading.value = false
dataLoaded.value = true
console.log('数据加载完成loading状态:', loading.value)
}
}
// 监听筛选条件变化(增加防抖,避免频繁请求)
const fetchDataDebounced = () => {
let timer: any = null
return () => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
if (map && dataLoaded.value) fetchData()
}, 500)
}
}
const debouncedFetch = fetchDataDebounced()
watch([operator, networkType, minUploadTraffic, maxUploadTraffic, minDownloadTraffic, maxDownloadTraffic, startDate, endDate, minTrafficThreshold, gridRows, gridCols], debouncedFetch)
onMounted(() => {
console.log('组件已挂载,开始初始化地图')
initMap()
})
onUnmounted(() => {
console.log('Req26组件卸载清理地图资源')
overlays.forEach(overlay => {
try {
map?.removeOverlay(overlay)
} catch (error) {
console.warn('移除覆盖物失败:', error)
}
})
// 清理地图实例
if (map) {
try {
map.clearOverlays()
} catch (error) {
console.warn('清理地图覆盖物失败:', error)
}
}
map = null
overlays = []
// 重置状态
loading.value = false
dataLoaded.value = false
dataList.value = []
})
</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>业务需求26热门手机-热门手机分布图</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="ALL">全部</option>
<option value="CMCC">CMCC中国移动</option>
<option value="CUCC">CUCC中国联通</option>
<option value="CTCC">CTCC中国电信</option>
</select>
</div>
<div class="filter-item">
<label for="networkType">网络制式:</label>
<select id="networkType" v-model="networkType" class="select-input" :disabled="loading">
<option value="">全部</option>
<option value="Android">Android</option>
<option value="iOS">iOS</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>
<div class="filter-item">
<label for="minTrafficThreshold">流量阈值(KB):</label>
<input
id="minTrafficThreshold"
type="number"
v-model.number="minTrafficThreshold"
class="number-input"
min="0"
:disabled="loading"
/>
</div>
<button @click="fetchData" class="refresh-btn" :disabled="loading">
{{ loading ? '加载中...' : '查询' }}
</button>
</div>
<div class="filter-bar-secondary">
<div class="filter-item">
<label for="minUploadTraffic">最小上传流量(KB):</label>
<input
id="minUploadTraffic"
type="number"
v-model.number="minUploadTraffic"
class="number-input"
min="0"
step="0.1"
:disabled="loading"
/>
</div>
<div class="filter-item">
<label for="maxUploadTraffic">最大上传流量(KB):</label>
<input
id="maxUploadTraffic"
type="number"
v-model.number="maxUploadTraffic"
class="number-input"
min="0"
step="0.1"
:disabled="loading"
/>
</div>
<div class="filter-item">
<label for="minDownloadTraffic">最小下载流量(KB):</label>
<input
id="minDownloadTraffic"
type="number"
v-model.number="minDownloadTraffic"
class="number-input"
min="0"
step="0.1"
:disabled="loading"
/>
</div>
<div class="filter-item">
<label for="maxDownloadTraffic">最大下载流量(KB):</label>
<input
id="maxDownloadTraffic"
type="number"
v-model.number="maxDownloadTraffic"
class="number-input"
min="0"
step="0.1"
:disabled="loading"
/>
</div>
<div class="filter-item">
<label for="gridRows">网格行数:</label>
<input
id="gridRows"
type="number"
v-model.number="gridRows"
class="number-input"
min="1"
max="50"
:disabled="loading"
/>
</div>
<div class="filter-item">
<label for="gridCols">网格列数:</label>
<input
id="gridCols"
type="number"
v-model.number="gridCols"
class="number-input"
min="1"
max="50"
:disabled="loading"
/>
</div>
</div>
<div class="info-bar">
<p>地图将可视区域分为{{ gridRows }}×{{ gridCols }}={{ gridRows * gridCols }}个矩形区域显示每个区域流量Top1的热门手机流量需超过阈值{{ minTrafficThreshold }}KB</p>
</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,
.filter-bar-secondary {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.select-input,
.text-input,
.date-input,
.number-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.number-input {
width: 120px;
}
.text-input {
width: 150px;
}
.refresh-btn {
padding: 6px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.refresh-btn:hover:not(:disabled) {
background: #2563eb;
}
.refresh-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.info-bar {
margin-bottom: 12px;
padding: 8px 12px;
background: #e0f2fe;
border-radius: 4px;
font-size: 13px;
color: #0369a1;
}
.map-container {
width: 100%;
height: calc(100vh - 350px);
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>