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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user