Implement interactive map and data filtering in Req18AppAnalysisView: add functionality for displaying app traffic data on a grid-based map, including user-defined filters for operator, app name, and traffic thresholds.

This commit is contained in:
wangran
2026-01-10 16:29:11 +08:00
parent ea66d7aadc
commit c7190a49a9

View File

@@ -1,15 +1,648 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
interface AppTrafficItem {
id: number
userLon: string
userLat: string
uploadTraffic: number
downloadTraffic: number
packageName: string
networkName: string
appName: string
os: string | null
osVersion: string | null
company: string | null
startTime: string
endTime: string
}
interface GridCell {
row: number
col: number
minLat: number
maxLat: number
minLon: number
maxLon: number
apps: Map<string, { totalTraffic: number; count: number; appName: string }>
topApp: { appName: string; totalTraffic: number } | null
}
const mapContainer = ref<HTMLDivElement | null>(null)
const operator = ref<string>('CMCC') // 默认选择中国移动
const appName = 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) // 流量阈值,超过此值才显示
const loading = ref<boolean>(false)
const dataLoaded = ref<boolean>(false)
const dataList = ref<AppTrafficItem[]>([])
let map: any = null
let gridCells: GridCell[] = []
let overlays: any[] = []
const GRID_ROWS = 15 // 15行
const GRID_COLS = 20 // 20列总共300个矩形
// 初始化地图
const initMap = async (): Promise<void> => {
await nextTick()
// 等待百度地图API加载
return new Promise((resolve) => {
const checkBMap = () => {
if (window.BMap && window.BMap.Map) {
if (!mapContainer.value) {
console.warn('地图容器未找到')
resolve()
return
}
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())
// 默认显示中国中心
map.centerAndZoom(new window.BMap.Point(105.8951, 36.5667), 5)
// 监听地图移动和缩放事件,更新网格(使用防抖)
let updateTimer: any = null
const debouncedUpdateGrid = () => {
if (updateTimer) clearTimeout(updateTimer)
updateTimer = setTimeout(() => {
updateGrid()
}, 300)
}
map.addEventListener('moveend', debouncedUpdateGrid)
map.addEventListener('zoomend', debouncedUpdateGrid)
resolve()
} else {
setTimeout(checkBMap, 200)
}
}
checkBMap()
})
}
// 获取地图可视区域
const getViewBounds = () => {
if (!map) return null
const bounds = map.getBounds()
const sw = bounds.getSouthWest() // 西南角
const ne = bounds.getNorthEast() // 东北角
return {
minLat: sw.lat,
maxLat: ne.lat,
minLon: sw.lng,
maxLon: ne.lng
}
}
// 创建网格
const createGrid = () => {
const bounds = getViewBounds()
if (!bounds) return
gridCells = []
const latStep = (bounds.maxLat - bounds.minLat) / GRID_ROWS
const lonStep = (bounds.maxLon - bounds.minLon) / GRID_COLS
for (let row = 0; row < GRID_ROWS; row++) {
for (let col = 0; col < GRID_COLS; col++) {
gridCells.push({
row,
col,
minLat: bounds.minLat + row * latStep,
maxLat: bounds.minLat + (row + 1) * latStep,
minLon: bounds.minLon + col * lonStep,
maxLon: bounds.minLon + (col + 1) * lonStep,
apps: new Map(),
topApp: null
})
}
}
}
// 判断点是否在网格内
const isPointInGrid = (lat: number, lon: number, cell: GridCell): boolean => {
return lat >= cell.minLat && lat < cell.maxLat &&
lon >= cell.minLon && lon < cell.maxLon
}
// 计算每个网格的top1 APP
const calculateGridTopApps = () => {
// 重置所有网格的APP数据
gridCells.forEach(cell => {
cell.apps.clear()
cell.topApp = null
})
// 遍历数据,分配到对应的网格
dataList.value.forEach(item => {
const lat = parseFloat(item.userLat)
const lon = parseFloat(item.userLon)
if (isNaN(lat) || isNaN(lon)) return
// 找到包含此点的网格
const cell = gridCells.find(c => isPointInGrid(lat, lon, c))
if (!cell) return
const totalTraffic = item.uploadTraffic + item.downloadTraffic
const appKey = item.appName || '未知APP'
if (!cell.apps.has(appKey)) {
cell.apps.set(appKey, {
totalTraffic: 0,
count: 0,
appName: appKey
})
}
const appData = cell.apps.get(appKey)!
appData.totalTraffic += totalTraffic
appData.count += 1
})
// 计算每个网格的top1 APP
gridCells.forEach(cell => {
if (cell.apps.size === 0) return
let maxTraffic = 0
let topAppName = ''
cell.apps.forEach((data, appName) => {
if (data.totalTraffic > maxTraffic) {
maxTraffic = data.totalTraffic
topAppName = appName
}
})
if (maxTraffic >= minTrafficThreshold.value) {
cell.topApp = {
appName: topAppName,
totalTraffic: maxTraffic
}
}
})
}
// 创建APP logo图标使用文字作为占位符
const createAppIcon = (appName: string, traffic: number): string => {
// 使用APP名称的首字符或前两个字符
const displayText = appName.length > 2 ? appName.substring(0, 2) : appName
const size = 40
const fontSize = 12
return `data:image/svg+xml;base64,${btoa(`
<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="#3b82f6" 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>
`)}`
}
// 更新网格显示
const updateGrid = () => {
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 = []
// 重新创建网格
createGrid()
// 计算top1 APP
calculateGridTopApps()
// 在地图上显示top1 APP的logo
gridCells.forEach(cell => {
if (!cell.topApp) return
const centerLat = (cell.minLat + cell.maxLat) / 2
const centerLon = (cell.minLon + cell.maxLon) / 2
const point = new window.BMap.Point(centerLon, centerLat)
const icon = new window.BMap.Icon(
createAppIcon(cell.topApp.appName, cell.topApp.totalTraffic),
new window.BMap.Size(40, 40),
{ anchor: new window.BMap.Size(20, 20) }
)
const marker = new window.BMap.Marker(point, { icon })
// 信息窗口
const infoWindow = new window.BMap.InfoWindow(`
<div style="padding:8px">
<p><strong>APP名称:</strong> ${cell.topApp.appName}</p>
<p><strong>总流量:</strong> ${cell.topApp.totalTraffic.toFixed(2)} KB</p>
<p><strong>区域:</strong> 第${cell.row + 1}行, 第${cell.col + 1}列</p>
</div>
`, { width: 200 })
marker.addEventListener('click', () => map.openInfoWindow(infoWindow, point))
map.addOverlay(marker)
overlays.push(marker)
})
}
// 获取数据
const fetchData = async () => {
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 (appName.value) {
body.appName = appName.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
}
console.log('发送请求:', body)
const response = await fetch('http://localhost:8081/appTraffic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const data = await response.json() as AppTrafficItem[]
console.log('收到数据:', data.length, '条')
// 过滤有效坐标点(中国境内)
dataList.value = data.filter(item => {
const lat = parseFloat(item.userLat)
const lon = parseFloat(item.userLon)
return !isNaN(lat) && !isNaN(lon) && lat >= 18 && lat <= 53 && lon >= 73 && lon <= 135
})
console.log('过滤后数据:', dataList.value.length, '条')
// 地图已初始化:更新网格
if (map) {
await nextTick()
// 延迟一下确保地图完全渲染
setTimeout(() => {
updateGrid()
}, 100)
} 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)
}
}
// 监听筛选条件变化
watch([operator, appName, minUploadTraffic, maxUploadTraffic, minDownloadTraffic, maxDownloadTraffic, startDate, endDate, minTrafficThreshold], () => {
if (map && dataLoaded.value) fetchData()
})
onMounted(async () => {
// 先初始化地图,再加载数据
try {
await initMap()
console.log('地图初始化完成')
} catch (error) {
console.error('地图初始化失败:', error)
}
// 无论地图是否初始化完成,都可以先加载数据
await fetchData()
})
onUnmounted(() => {
overlays.forEach(overlay => {
try {
map?.removeOverlay(overlay)
} catch (error) {
console.warn('移除覆盖物失败:', error)
}
})
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>业务需求18热门APP-热门APP分析</h2>
<p>这里展示热门APP分析的相关内容</p>
<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">中国移动</option>
<option value="CUCC">中国联通</option>
<option value="CTCC">中国电信</option>
</select>
</div>
<div class="filter-item">
<label for="appName">APP名称:</label>
<input
id="appName"
type="text"
v-model="appName"
class="text-input"
placeholder="如QQ"
:disabled="loading"
/>
</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>
<div class="info-bar">
<p>地图将可视区域分为{{ GRID_ROWS }}×{{ GRID_COLS }}={{ GRID_ROWS * GRID_COLS }}个矩形区域显示每个区域流量Top1的APP流量需超过阈值</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>