206.1.13最终vue(1)

This commit is contained in:
wangran
2026-01-13 20:18:49 +08:00
parent be53c587a4
commit 2f60462044
16 changed files with 4551 additions and 25 deletions

View File

@@ -0,0 +1,369 @@
<template>
<div style="width: 100%; height: 100vh; position: relative;">
<div style="position: absolute; top: 0; left: 0; right: 0; background: white; padding: 15px 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 1000; display: flex; align-items: center; gap: 15px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span>运营商:</span>
<select
v-model="selectedOperator"
style="
padding: 6px 10px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 14px;
color: #666;
outline: none;
"
@change="applyFilters"
>
<option value="ALL">ALL</option>
<option v-for="operator in operatorList" :key="operator" :value="operator">
{{ operator }}
</option>
</select>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span>网络类型:</span>
<select
v-model="selectedNetworkType"
style="
padding: 6px 10px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 14px;
color: #666;
outline: none;
"
@change="applyFilters"
>
<option value="ALL">ALL</option>
<option v-for="type in networkTypeList" :key="type" :value="type">
{{ type }}
</option>
</select>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span>起始日期:</span>
<input
type="date"
v-model="startDate"
style="
padding: 6px 10px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 14px;
color: #666;
outline: none;
"
@change="formatDate"
/>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span>结束日期:</span>
<input
type="date"
v-model="endDate"
style="
padding: 6px 10px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 14px;
color: #666;
outline: none;
"
@change="formatDate"
/>
</div>
<button
@click="applyFilters"
style="
padding: 6px 15px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
"
>
刷新
</button>
</div>
<div v-if="loading" style="display: flex; align-items: center; justify-content: center; height: 100%; background: #f5f5f5;">
<p style="color: #666;">{{ loadingText }}</p>
</div>
<div v-show="!loading" id="baidu-map" style="width: 100%; height: 100%; padding-top: 60px; box-sizing: border-box;"></div>
<div v-show="!loading" style="position: absolute; top: 80px; right: 20px; background: white; padding: 15px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 999;">
<div style="font-weight: bold; margin-bottom: 10px;">网络质量</div>
<div v-for="item in speedLevels" :key="item.label" style="display: flex; align-items: center; margin: 5px 0;">
<span style="display: inline-block; width: 20px; height: 20px; margin-right: 8px; border-radius: 2px;" :style="{ backgroundColor: item.color }"></span>
<span style="font-size: 14px;">{{ item.label }}</span>
</div>
</div>
<div v-show="!loading" style="position: absolute; top: 80px; left: 20px; background: white; padding: 15px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 999;">
<div style="margin: 5px 0;">数据点: <strong>{{ totalDataPoints }}</strong></div>
<div style="margin: 5px 0;">瓷砖数: <strong>{{ tileCount }}</strong></div>
<div style="margin: 5px 0;">有效瓷砖: <strong>{{ validTileCount }}</strong></div>
<div style="margin: 5px 0;">平均速度: <strong>{{ averageSpeed.toFixed(2) }} KB/s</strong></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { loadBaiduMap } from '../utils/loadBaiduMap.js';
const loading = ref(true);
const loadingText = ref('加载中...');
const totalDataPoints = ref(0);
const averageSpeed = ref(0);
const tileCount = ref(0);
const validTileCount = ref(0);
const selectedOperator = ref('ALL');
const selectedNetworkType = ref('ALL');
const startDate = ref('');
const endDate = ref('');
const operatorList = ref(['CMCC', 'CUCC', 'CTCC']);
const networkTypeList = ref([]);
const originalAllData = ref([]);
let map = null;
let tiles = [];
let allData = [];
const speedLevels = [
{ label: '0-50 KB/s', min: 0, max: 50, color: '#f56c6c' },
{ label: '50-100 KB/s', min: 50, max: 100, color: '#e6a23c' },
{ label: '100-150 KB/s', min: 100, max: 150, color: '#f0c929' },
{ label: '150-200 KB/s', min: 150, max: 200, color: '#95d475' },
{ label: '200-300 KB/s', min: 200, max: 300, color: '#67c23a' },
{ label: '> 300 KB/s', min: 300, max: Infinity, color: '#409eff' }
];
const formatDate = (dateStr) => {
if (!dateStr) return '';
const [year, month, day] = dateStr.split('-');
return `${year}${month}${day}`;
};
const getColorBySpeed = (speed) => {
if (speed === null) return '#cccccc';
const level = speedLevels.find(l => speed >= l.min && speed < l.max);
return level ? level.color : '#cccccc';
};
const filterDataByBounds = (data, bounds) => {
const { minLng, maxLng, minLat, maxLat } = bounds;
return data.filter(item =>
item.lng >= minLng && item.lng <= maxLng &&
item.lat >= minLat && item.lat <= maxLat
);
};
const groupDataByGrid = (data, bounds, cols, rows) => {
const { minLng, maxLng, minLat, maxLat } = bounds;
const lngStep = (maxLng - minLng) / cols;
const latStep = (maxLat - minLat) / rows;
const grid = Array(rows).fill().map(() => Array(cols).fill().map(() => []));
data.forEach(item => {
const col = Math.floor((item.lng - minLng) / lngStep);
const row = Math.floor((item.lat - minLat) / latStep);
if (col >= 0 && col < cols && row >= 0 && row < rows) {
grid[row][col].push(item);
}
});
return grid;
};
const calculateGridAverageSpeed = (cellData) => {
if (!cellData || cellData.length === 0) return null;
const total = cellData.reduce((sum, item) => sum + item.downloadSpeed, 0);
return total / cellData.length;
};
const createNetworkTiles = () => {
if (!map) return;
tiles.forEach(tile => map.removeOverlay(tile));
tiles = [];
const bounds = map.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const minLng = sw.lng, maxLng = ne.lng, minLat = sw.lat, maxLat = ne.lat;
const cols = 20, rows = 15;
const visibleData = filterDataByBounds(allData, { minLng, maxLng, minLat, maxLat });
const gridData = groupDataByGrid(visibleData, { minLng, maxLng, minLat, maxLat }, cols, rows);
const tileWidth = (maxLng - minLng) / cols;
const tileHeight = (maxLat - minLat) / rows;
let validCount = 0;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const tileLngMin = minLng + col * tileWidth;
const tileLngMax = minLng + (col + 1) * tileWidth;
const tileLatMin = minLat + row * tileHeight;
const tileLatMax = minLat + (row + 1) * tileHeight;
const BMap = window.BMap;
if (!BMap) continue;
const points = [
new BMap.Point(tileLngMin, tileLatMin),
new BMap.Point(tileLngMax, tileLatMin),
new BMap.Point(tileLngMax, tileLatMax),
new BMap.Point(tileLngMin, tileLatMax)
];
const cellData = gridData[row][col];
const avgSpeed = calculateGridAverageSpeed(cellData);
const color = avgSpeed !== null ? getColorBySpeed(avgSpeed) : '#e0e0e0';
const opacity = avgSpeed !== null ? 0.5 : 0.2;
if (avgSpeed !== null) validCount++;
const polygon = new BMap.Polygon(points, {
strokeColor: color,
strokeWeight: 1,
strokeOpacity: 0.3,
fillColor: color,
fillOpacity: opacity
});
polygon.addEventListener('click', () => {
let html = '<div style="padding: 10px;">';
if (avgSpeed !== null) {
html += `<p><strong>平均速度:</strong> ${avgSpeed.toFixed(2)} KB/s</p>`;
html += `<p><strong>数据点数:</strong> ${cellData.length}</p>`;
html += '<hr style="margin: 10px 0; border: none; border-top: 1px solid #eee;">';
cellData.slice(0, 3).forEach((item, index) => {
html += `<div style="margin: 8px 0; padding: 8px; background: #f5f5f5; border-radius: 4px; font-size: 12px;">`;
html += `<div><strong>点 ${index + 1}</strong></div>`;
html += `<div>运营商: ${item.operator || '未知'}</div>`;
html += `<div>下载: ${item.downloadSpeed} KB/s</div>`;
html += `<div>上传: ${item.uploadSpeed} KB/s</div>`;
html += `<div>延迟: ${item.delay} ms</div>`;
html += `</div>`;
});
if (cellData.length > 3) {
html += `<p style="color: #999; font-size: 12px;">...还有 ${cellData.length - 3} 条</p>`;
}
} else {
html += '<p style="color: #999;">该区域暂无数据</p>';
}
html += '</div>';
const infoWindow = new BMap.InfoWindow(html, { width: 250, height: avgSpeed !== null ? 300 : 80 });
const centerLng = (tileLngMin + tileLngMax) / 2;
const centerLat = (tileLatMin + tileLatMax) / 2;
map.openInfoWindow(infoWindow, new BMap.Point(centerLng, centerLat));
});
map.addOverlay(polygon);
tiles.push(polygon);
}
}
tileCount.value = cols * rows;
validTileCount.value = validCount;
};
const applyFilters = () => {
let filteredData = [...originalAllData.value];
if (selectedOperator.value !== 'ALL') {
filteredData = filteredData.filter(item => item.operator === selectedOperator.value);
}
if (selectedNetworkType.value !== 'ALL') {
filteredData = filteredData.filter(item => item.networkType === selectedNetworkType.value);
}
if (startDate.value) {
const start = formatDate(startDate.value);
filteredData = filteredData.filter(item => item.daytime >= start);
}
if (endDate.value) {
const end = formatDate(endDate.value);
filteredData = filteredData.filter(item => item.daytime <= end);
}
allData = filteredData;
totalDataPoints.value = allData.length;
if (allData.length > 0) {
const totalSpeed = allData.reduce((sum, item) => sum + item.downloadSpeed, 0);
averageSpeed.value = totalSpeed / allData.length;
} else {
averageSpeed.value = 0;
}
createNetworkTiles();
};
const init = async () => {
loading.value = true;
try {
const response = await fetch('http://localhost:8080/phoenix/nwquality/all');
const rawApiData = await response.json();
originalAllData.value = rawApiData.map(item => ({
lng: parseFloat(item.GPSLON) || 0,
lat: parseFloat(item.GPSLAT) || 0,
downloadSpeed: parseFloat(item.DLSPEED) || 0,
uploadSpeed: parseFloat(item.ULSPEED) || 0,
delay: parseFloat(item.LATENCY) || 0,
operator: item.NWOPERATOR || '',
networkType: item.NWTYPE || '',
daytime: item.DAYTIME || '',
device: item.COMPANYMODEL || '',
province: item.PROVINCE || ''
}));
networkTypeList.value = [...new Set(originalAllData.value.map(item => item.networkType))].filter(type => type);
allData = [...originalAllData.value];
totalDataPoints.value = allData.length;
if (allData.length > 0) {
const totalSpeed = allData.reduce((sum, item) => sum + item.downloadSpeed, 0);
averageSpeed.value = totalSpeed / allData.length;
}
await loadBaiduMap();
const BMap = window.BMap;
map = new BMap.Map('baidu-map');
if (allData.length > 0) {
const avgLng = allData.reduce((sum, item) => sum + item.lng, 0) / allData.length;
const avgLat = allData.reduce((sum, item) => sum + item.lat, 0) / allData.length;
map.centerAndZoom(new BMap.Point(avgLng, avgLat), 13);
} else {
map.centerAndZoom(new BMap.Point(116.404, 39.915), 13);
}
map.enableScrollWheelZoom(true);
map.addControl(new BMap.NavigationControl());
map.addControl(new BMap.ScaleControl());
createNetworkTiles();
map.addEventListener('moveend', createNetworkTiles);
map.addEventListener('zoomend', createNetworkTiles);
} catch (error) {
console.error('初始化失败:', error);
loadingText.value = '加载失败,请刷新重试';
} finally {
loading.value = false;
}
};
onMounted(() => {
init();
});
</script>

237
src/components/tu2.vue Normal file
View File

@@ -0,0 +1,237 @@
<template>
<div class="operator-statistics">
<!-- 筛选区域 -->
<div class="filter-area" style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<!-- 运营商下拉框 -->
<label style="margin-right: 10px;">运营商:</label>
<select v-model="selectedOperator" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<option value="all">全部</option>
<option value="CUCC">中国联通</option>
<option value="CMCC">中国移动</option>
<option value="CTCC">中国电信</option>
</select>
<!-- 日期范围选择 -->
<label style="margin-right: 10px;">起始日期:</label>
<input type="date" v-model="startDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<label style="margin-right: 10px;">结束日期:</label>
<input type="date" v-model="endDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<!-- 查询按钮 -->
<button @click="fetchAndRenderData" style="padding: 6px 16px; background: #409EFF; color: #fff; border: none; border-radius: 4px; cursor: pointer;">查询</button>
</div>
<!-- 图表区域 -->
<div id="operatorChart" style="width: 1000px; height: 600px; margin: 0 auto;"></div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'OperatorNetworkQuality',
data() {
return {
chartInstance: null,
// 筛选条件
selectedOperator: 'all', // 选中的运营商all/CMCC/CUCC/CTCC
startDate: '', // 起始日期
endDate: '', // 结束日期
// 原始数据(存储接口返回的所有数据,用于筛选)
rawData: [],
// 统计结构仅3G/4G无5G
networkStats: {
CUCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } },
CMCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } },
CTCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } }
}
}
},
mounted() {
// 初始化时先获取所有原始数据
this.fetchRawData().then(() => {
this.initChart()
this.calculateStats() // 初始化统计(默认展示全部数据)
this.renderChart()
})
},
beforeUnmount() {
if (this.chartInstance) {
this.chartInstance.dispose()
}
},
methods: {
// 1. 获取原始数据(只请求一次,后续筛选基于本地数据)
async fetchRawData() {
try {
const response = await fetch('http://localhost:8080/phoenix/nwquality/all')
if (!response.ok) throw new Error(`请求失败: ${response.status}`)
this.rawData = await response.json()
console.log('原始数据:', this.rawData)
} catch (error) {
console.error('获取原始数据失败:', error)
alert('数据加载失败!请检查接口地址或网络连接')
}
},
// 2. 根据筛选条件计算统计数据
calculateStats() {
// 重置统计结构
this.networkStats = {
CUCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } },
CMCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } },
CTCC: { '3G': { rateSum: 0, count: 0 }, '4G': { rateSum: 0, count: 0 } }
}
// 处理筛选条件
const filteredData = this.rawData.filter(item => {
// 运营商筛选
if (this.selectedOperator !== 'all' && item.NWOPERATOR !== this.selectedOperator) {
return false
}
// 日期筛选接口中DAYTIME格式为2017051920转换为YYYY-MM-DD
if (this.startDate || this.endDate) {
const dayTimeStr = item.DAYTIME?.toString() || ''
if (dayTimeStr.length < 8) return false
const itemDate = `${dayTimeStr.slice(0,4)}-${dayTimeStr.slice(4,6)}-${dayTimeStr.slice(6,8)}`
// 起始日期过滤
if (this.startDate && itemDate < this.startDate) return false
// 结束日期过滤
if (this.endDate && itemDate > this.endDate) return false
}
return true
})
// 按筛选后的数据统计3G/4G独立统计
filteredData.forEach(item => {
const operator = item.NWOPERATOR?.trim()
const dlSpeed = Number(item.DLSPEED) || 0 // 单位KB/s
let nwType = item.NWTYPE?.trim() || '4G'
nwType = ['3G', '4G'].includes(nwType) ? nwType : '4G'
if (!this.networkStats[operator] || !this.networkStats[operator][nwType] || isNaN(dlSpeed) || dlSpeed < 0) return
this.networkStats[operator][nwType].rateSum += dlSpeed
this.networkStats[operator][nwType].count += 1
})
console.log('筛选后统计结果:', this.networkStats)
},
// 3. 初始化图表实例
initChart() {
const chartDom = document.getElementById('operatorChart')
if (!chartDom) return
this.chartInstance = echarts.init(chartDom)
},
// 4. 渲染图表3G/4G均按速率降序X轴仅显示3G/4G
renderChart() {
// 计算平均速率保留KB/s不转MB/s保留2位小数
const getAvgRate = (operator, nwType) => {
const stat = this.networkStats[operator][nwType]
if (stat.count === 0) return 0
const avg = stat.rateSum / stat.count
return Math.round(avg * 100) / 100
}
// 3G/4G分别按速率降序排序
const operators = ['CUCC', 'CMCC', 'CTCC']
// 3G速率排序降序
const sorted3G = operators.map(op => ({
name: op,
rate: getAvgRate(op, '3G')
})).sort((a, b) => b.rate - a.rate)
// 4G速率排序降序
const sorted4G = operators.map(op => ({
name: op,
rate: getAvgRate(op, '4G')
})).sort((a, b) => b.rate - a.rate)
// 运营商颜色映射(固定颜色)
const colorMap = {
CUCC: '#409EFF', // 联通-蓝色
CMCC: '#67C23A', // 移动-绿色
CTCC: '#E6A23C' // 电信-橙色
}
// 构建系列数据
const seriesData = operators.map(op => ({
name: op,
type: 'bar',
barWidth: '25%', // 柱子宽度适配
data: [
sorted3G.find(item => item.name === op).rate, // 3G排序后速率
sorted4G.find(item => item.name === op).rate // 4G排序后速率
],
itemStyle: { color: colorMap[op] },
label: {
show: true,
position: 'top',
formatter: '{c} KB/s' // 标签显示KB/s单位
}
}))
// 图表配置项:增大标题、图例、图表的间距
const option = {
title: {
text: '各运营商不同网络制式的平均下行速率',
left: 'center',
top: 20, // 标题距离顶部20px增大与筛选栏的间距
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
formatter: '{b} - {a}: {c} KB/s' // 提示框显示单位
},
legend: {
data: operators, // 图例显示三个运营商
top: 60, // 图例距离顶部60px增大与标题的间距
textStyle: { fontSize: 14 }
},
grid: {
left: '5%',
right: '5%',
bottom: '10%',
top: 90, // 图表主体距离顶部90px增大与图例的间距
containLabel: true
},
xAxis: {
type: 'category',
data: ['3G', '4G'], // X轴仅显示3G/4G无多余排序说明
axisLabel: { fontSize: 14, fontWeight: 'bold' }
},
yAxis: {
type: 'value',
name: '平均下行速率 (KB/s)', // Y轴标注单位
min: 0
},
series: seriesData
}
this.chartInstance.setOption(option)
// 窗口自适应
window.addEventListener('resize', () => {
if (this.chartInstance) {
this.chartInstance.resize()
}
})
},
// 5. 点击查询:重新计算+渲染
fetchAndRenderData() {
this.calculateStats()
this.renderChart()
}
}
}
</script>
<style scoped>
.operator-statistics {
padding-bottom: 50px;
}
</style>

288
src/components/tu3.vue Normal file
View File

@@ -0,0 +1,288 @@
<template>
<div class="province-operator-chart">
<!-- 筛选区域样式不变 -->
<div class="filter-area" style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<!-- 运营商下拉框 -->
<label style="margin-right: 10px;">运营商:</label>
<select v-model="selectedOperator" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<option value="all">全部</option>
<option value="CUCC">中国联通</option>
<option value="CMCC">中国移动</option>
<option value="CTCC">中国电信</option>
</select>
<!-- 日期范围选择 -->
<label style="margin-right: 10px;">起始日期:</label>
<input type="date" v-model="startDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<label style="margin-right: 10px;">结束日期:</label>
<input type="date" v-model="endDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<!-- 查询按钮 -->
<button @click="fetchAndRenderData" style="padding: 6px 16px; background: #409EFF; color: #fff; border: none; border-radius: 4px; cursor: pointer;">查询</button>
</div>
<!-- 图表区域 -->
<div id="provinceOperatorChart" style="width: 100%; height: 800px; margin: 0 auto;"></div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'ProvinceOperatorChart',
data() {
return {
chartInstance: null,
// 筛选条件
selectedOperator: 'all', // 选中的运营商all/CMCC/CUCC/CTCC
startDate: '', // 起始日期
endDate: '', // 结束日期
// 原始接口数据(存储所有返回数据,用于筛选)
rawData: [],
// 统计结构
provinceOperatorStats: {}
}
},
mounted() {
// 确保DOM渲染完成后初始化
this.$nextTick(async () => {
await this.fetchRawData() // 从接口获取原始数据
this.initChart() // 初始化图表实例
this.calculateStats() // 初始化统计(默认全部数据)
this.renderChart() // 渲染图表
})
},
beforeUnmount() {
// 销毁图表实例,避免内存泄漏
if (this.chartInstance) {
this.chartInstance.dispose()
}
},
methods: {
/**
* 从后端接口获取原始数据替换原CSV读取逻辑
*/
async fetchRawData() {
try {
// 替换为你的实际接口地址
const response = await fetch('http://localhost:8080/phoenix/nwquality/all')
if (!response.ok) throw new Error(`请求失败: ${response.status}`)
const dataList = await response.json()
console.log('接口返回原始数据:', dataList)
// 格式化接口数据,适配后续筛选逻辑
this.rawData = dataList.map(item => {
return {
// 适配接口字段名(根据实际接口返回调整)
operator: item.NWOPERATOR?.trim() || '',
avgRateValue: Number(item.DLSPEED) || 0, // 下行速率作为速率值
province: item.PROVINCE?.trim() || '', // 省份字段(根据实际接口调整)
dayTime: item.DAYTIME?.toString() || '' // 日期字段(根据实际接口调整)
}
})
} catch (error) {
console.error('获取接口数据失败:', error)
alert('数据加载失败!请检查接口地址或网络连接')
}
},
/**
* 根据筛选条件重新统计数据(逻辑不变,仅适配接口数据格式)
*/
calculateStats() {
// 重置统计结构
this.provinceOperatorStats = {}
// 过滤数据
const filteredData = this.rawData.filter(item => {
// 1. 运营商筛选
if (this.selectedOperator !== 'all' && item.operator !== this.selectedOperator) {
return false
}
// 2. 日期筛选适配接口返回的日期格式如2017051920
if (this.startDate || this.endDate) {
const dayTimeStr = item.dayTime || ''
if (dayTimeStr.length < 8) return false
// 转换为YYYY-MM-DD格式适配日期选择器
const itemDate = `${dayTimeStr.slice(0,4)}-${dayTimeStr.slice(4,6)}-${dayTimeStr.slice(6,8)}`
// 起始日期过滤
if (this.startDate && itemDate < this.startDate) return false
// 结束日期过滤
if (this.endDate && itemDate > this.endDate) return false
}
// 3. 基础数据有效性过滤
return ['CUCC', 'CMCC', 'CTCC'].includes(item.operator) && item.province && item.avgRateValue > 0
})
// 按筛选后的数据统计
filteredData.forEach(item => {
const { province, operator, avgRateValue } = item
// 初始化省份-运营商统计结构
if (!this.provinceOperatorStats[province]) {
this.provinceOperatorStats[province] = {
CUCC: { rateSum: 0, count: 0 },
CMCC: { rateSum: 0, count: 0 },
CTCC: { rateSum: 0, count: 0 }
}
}
// 累加速率和计数
this.provinceOperatorStats[province][operator].rateSum += avgRateValue
this.provinceOperatorStats[province][operator].count += 1
})
},
/**
* 初始化ECharts实例逻辑不变
*/
initChart() {
const chartDom = document.getElementById('provinceOperatorChart')
if (!chartDom) return
this.chartInstance = echarts.init(chartDom)
},
/**
* 渲染图表(核心逻辑完全不变,仅调整标题/图例/网格间距)
*/
renderChart() {
// 计算平均速率
const getAvgRate = (province, operator) => {
if (!this.provinceOperatorStats[province]) return 0
const stat = this.provinceOperatorStats[province][operator]
if (stat.count === 0) return 0
const avg = stat.rateSum / stat.count
return Math.round(avg * 100) / 100
}
// 1. 提取所有有效省份和运营商
const provinceList = Object.keys(this.provinceOperatorStats)
const operatorList = ['CUCC', 'CMCC', 'CTCC']
// 2. 整理速率数据并按速率降序排序省份
const rateDataList = []
provinceList.forEach(province => {
operatorList.forEach(operator => {
const rate = getAvgRate(province, operator)
if (rate > 0) {
rateDataList.push({ province, operator, rate })
}
})
})
rateDataList.sort((a, b) => b.rate - a.rate)
const sortedProvinceList = [...new Set(rateDataList.map(item => item.province))]
// 3. 构建系列数据
const seriesData = operatorList.map(operator => {
return {
name: operator,
type: 'bar',
barWidth: '20%',
data: sortedProvinceList.map(province => getAvgRate(province, operator)),
itemStyle: {
color: operator === 'CUCC' ? '#409EFF' :
operator === 'CMCC' ? '#67C23A' :
'#E6A23C'
},
label: {
show: true,
position: 'top',
fontSize: 10,
formatter: '{c}'
}
}
})
// 图表配置(调整标题/图例/网格间距)
const option = {
title: {
text: '各运营商各省份平均网络速率',
left: 'center',
top: 20, // 标题距离顶部20px增大与筛选栏的间距
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const province = params[0].axisValue
let tip = `${province}<br/>`
params.forEach(item => {
if (item.value > 0) {
tip += `${item.seriesName}${item.value}<br/>`
}
})
return tip
}
},
legend: {
data: operatorList,
top: 60, // 图例距离顶部60px增大与标题的间距
selected: {
CUCC: false,
CMCC: false,
CTCC: true
}
},
grid: {
left: '8%',
right: '4%',
bottom: '18%',
top: 90, // 图表主体距离顶部90px增大与图例的间距
containLabel: true
},
xAxis: {
type: 'category',
data: sortedProvinceList,
axisLabel: {
fontSize: 12,
rotate: 45,
interval: 0
}
},
yAxis: {
type: 'value',
name: '平均网络速率(下行)',
min: 0,
axisLabel: {
formatter: '{value}'
}
},
series: seriesData
}
// 渲染图表
this.chartInstance.setOption(option)
// 窗口自适应
window.addEventListener('resize', () => {
if (this.chartInstance) {
this.chartInstance.resize()
}
})
},
/**
* 点击查询按钮:重新统计+渲染(逻辑不变)
*/
fetchAndRenderData() {
this.calculateStats()
this.renderChart()
}
}
}
</script>
<style scoped>
.province-operator-chart {
padding: 20px;
background-color: #f9f9f9;
}
</style>

290
src/components/tu4.vue Normal file
View File

@@ -0,0 +1,290 @@
<template>
<div class="time-operator-chart">
<!-- 新增筛选区域和之前一致的样式 -->
<div class="filter-area" style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<!-- 运营商下拉框 -->
<label style="margin-right: 10px;">运营商:</label>
<select v-model="selectedOperator" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<option value="all">全部</option>
<option value="CUCC">中国联通</option>
<option value="CMCC">中国移动</option>
<option value="CTCC">中国电信</option>
</select>
<!-- 日期范围选择 -->
<label style="margin-right: 10px;">起始日期:</label>
<input type="date" v-model="startDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<label style="margin-right: 10px;">结束日期:</label>
<input type="date" v-model="endDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<!-- 查询按钮 -->
<button @click="fetchAndRenderData" style="padding: 6px 16px; background: #409EFF; color: #fff; border: none; border-radius: 4px; cursor: pointer;">查询</button>
</div>
<!-- 图表区域 -->
<div id="timeOperatorChart" style="width: 1200px; height: 700px; margin: 0 auto;"></div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'MonthOnlyCurveChart',
data() {
return {
chartInstance: null,
// 筛选条件
selectedOperator: 'all', // 选中的运营商all/CMCC/CUCC/CTCC
startDate: '', // 起始日期
endDate: '', // 结束日期
// 原始接口数据(存储所有返回数据,用于筛选)
rawData: [],
// 统计结构
locationMonthStats: {}
}
},
mounted() {
this.$nextTick(async () => {
await this.fetchRawData() // 从接口获取原始数据
this.initChart() // 初始化图表实例
this.calculateStats() // 初始化统计(默认全部数据)
this.renderChart() // 渲染图表
})
},
beforeUnmount() {
if (this.chartInstance) this.chartInstance.dispose()
},
methods: {
/**
* 从后端接口获取原始数据替换原CSV读取逻辑
*/
async fetchRawData() {
try {
// 替换为你的实际接口地址
const response = await fetch('http://localhost:8080/phoenix/nwquality/all')
if (!response.ok) throw new Error(`请求失败: ${response.status}`)
const dataList = await response.json()
console.log('接口返回原始数据:', dataList)
// 格式化接口数据,适配后续筛选逻辑
this.rawData = dataList.map(item => {
return {
// 适配接口字段名(根据实际接口返回调整)
operator: item.NWOPERATOR?.trim() || '',
avgRateValue: Number(item.DLSPEED) || 0, // 下行速率作为速率值
location: item.PROVINCE?.trim() || '未知地标', // 省份/地区字段
dayTime: item.DAYTIME?.toString() || '' // 日期字段格式如2018112310
}
})
} catch (error) {
console.error('获取接口数据失败:', error)
alert('数据加载失败!请检查接口地址或网络连接')
}
},
/**
* 根据筛选条件重新统计数据(适配筛选逻辑)
*/
calculateStats() {
// 重置统计结构
this.locationMonthStats = {}
// 过滤数据
const filteredData = this.rawData.filter(item => {
// 1. 运营商筛选
if (this.selectedOperator !== 'all' && item.operator !== this.selectedOperator) {
return false
}
// 2. 日期筛选适配接口返回的日期格式如2018112310
if (this.startDate || this.endDate) {
const dayTimeStr = item.dayTime || ''
if (dayTimeStr.length < 8) return false
// 转换为YYYY-MM-DD格式适配日期选择器
const itemDate = `${dayTimeStr.slice(0,4)}-${dayTimeStr.slice(4,6)}-${dayTimeStr.slice(6,8)}`
// 起始日期过滤
if (this.startDate && itemDate < this.startDate) return false
// 结束日期过滤
if (this.endDate && itemDate > this.endDate) return false
}
// 3. 基础数据有效性过滤
return ['CUCC', 'CMCC', 'CTCC'].includes(item.operator) && item.dayTime && item.location
})
// 按筛选后的数据统计(保留原有的月份统计逻辑)
filteredData.forEach(item => {
const { operator, avgRateValue, location, dayTime } = item
// 提取月份如2018112310 → 11月
const pureMonth = dayTime.slice(4,6)
// 初始化统计结构:仅统计速率总和和数据条数
if (!this.locationMonthStats[location]) this.locationMonthStats[location] = {}
if (!this.locationMonthStats[location][pureMonth]) {
this.locationMonthStats[location][pureMonth] = {
CUCC: { rateSum: 0, count: 0 },
CMCC: { rateSum: 0, count: 0 },
CTCC: { rateSum: 0, count: 0 }
}
}
// 累加:速率总和 + 数据条数(用于计算平均值)
this.locationMonthStats[location][pureMonth][operator].rateSum += avgRateValue
this.locationMonthStats[location][pureMonth][operator].count += 1
})
},
/**
* 初始化ECharts实例仅创建实例不渲染数据
*/
initChart() {
const chartDom = document.getElementById('timeOperatorChart')
if (!chartDom) return
this.chartInstance = echarts.init(chartDom)
},
/**
* 渲染图表(核心逻辑完全保留,仅复用筛选后的统计数据)
*/
renderChart() {
const targetLocation = '北京市'
const monthStats = this.locationMonthStats[targetLocation] || {}
if (Object.keys(monthStats).length === 0) {
alert(`未找到${targetLocation}的有效月份数据!`)
return
}
// 计算平均速率(逻辑不变)
const getAvgRate = (pureMonth, operator) => {
const stat = monthStats[pureMonth]?.[operator] || {}
if (stat.count === 0) return 0
const avg = stat.rateSum / stat.count
return Math.round(avg * 100) / 100
}
// 月份排序01-12月逻辑不变
const sortedMonthList = Object.keys(monthStats)
.map(month => parseInt(month))
.sort((a, b) => a - b)
.map(month => month.toString().padStart(2, '0'))
const operatorList = ['CUCC', 'CMCC', 'CTCC']
// 系列数据(保留所有样式:平滑曲线、区域填充、配色等)
const seriesData = operatorList.map(operator => ({
name: operator,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 10,
data: sortedMonthList.map(pureMonth => getAvgRate(pureMonth, operator)),
itemStyle: {
color: operator === 'CUCC' ? '#409EFF' :
operator === 'CMCC' ? '#67C23A' : '#E6A23C'
},
lineStyle: { width: 4 },
label: {
show: true,
fontSize: 12,
position: 'top',
fontWeight: 'bold'
},
// 保留区域填充样式
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: operator === 'CUCC' ? '#409EFF80' :
operator === 'CMCC' ? '#67C23A80' : '#E6A23C80' },
{ offset: 1, color: operator === 'CUCC' ? '#409EFF20' :
operator === 'CMCC' ? '#67C23A20' : '#E6A23C20' }
]
},
opacity: 0.7
}
}))
// 图表配置(样式完全保留)
const option = {
title: {
text: `${targetLocation}各运营商网络速率(按月统计)`,
left: 'center',
top: 20, // 增大标题与筛选栏的间距
textStyle: { fontSize: 20, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
let tip = `<b>${params[0].name}月</b><br/>`
params.forEach(item => {
if (item.value > 0) {
tip += `${item.seriesName}${item.value}<br/>`
}
})
return tip
}
},
legend: {
data: operatorList,
top: 60 // 增大图例与标题的间距
},
grid: {
left: '7%',
right: '5%',
bottom: '10%',
top: '12%',
containLabel: true
},
xAxis: {
type: 'category',
data: sortedMonthList,
axisLabel: {
fontSize: 14,
interval: 0,
formatter: (value) => `${value}`
},
axisLine: { lineStyle: { width: 2 } },
name: '月份'
},
yAxis: {
type: 'value',
name: '平均网络速率(下行)',
min: 0,
axisLabel: { formatter: '{value}' },
splitLine: { lineStyle: { type: 'dashed' } }
},
series: seriesData
}
this.chartInstance.setOption(option)
window.addEventListener('resize', () => this.chartInstance.resize())
},
/**
* 点击查询按钮:重新统计+渲染
*/
fetchAndRenderData() {
this.calculateStats()
this.renderChart()
}
}
}
</script>
<style scoped>
.time-operator-chart {
padding: 20px;
background-color: #f8f8f8;
}
</style>

195
src/components/tu5.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div class="operator-location-chart">
<!-- 筛选区域 -->
<div class="filter-area" style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<label style="margin-right: 10px;">运营商:</label>
<select v-model="selectedOperator" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<option value="all">全部</option>
<option value="CUCC">中国联通</option>
<option value="CMCC">中国移动</option>
<option value="CTCC">中国电信</option>
</select>
<label style="margin-right: 10px;">起始日期:</label>
<input type="date" v-model="startDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<label style="margin-right: 10px;">结束日期:</label>
<input type="date" v-model="endDate" style="padding: 6px 12px; margin-right: 20px; border-radius: 4px; border: 1px solid #ddd;">
<button @click="fetchAndRenderData" style="padding: 6px 16px; background: #409EFF; color: #fff; border: none; border-radius: 4px; cursor: pointer;">查询</button>
</div>
<!-- 图表区域 -->
<div id="locationOperatorChart" style="width: 1000px; height: 600px; margin: 0 auto;"></div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'LocationOperatorBarChart',
data() {
return {
chartInstance: null,
selectedOperator: 'all',
startDate: '',
endDate: '',
rawData: [],
locationTypeStats: {}
}
},
mounted() {
this.$nextTick(async () => {
await this.fetchRawData()
this.initChart()
this.calculateStats()
this.renderChart()
})
},
beforeUnmount() {
if (this.chartInstance) this.chartInstance.dispose()
},
methods: {
async fetchRawData() {
try {
const response = await fetch('http://localhost:8080/phoenix/nwquality/all')
if (!response.ok) throw new Error(`请求失败: ${response.status}`)
const dataList = await response.json()
console.log('接口返回原始数据:', dataList)
this.rawData = dataList.map(item => {
return {
operator: item.NWOPERATOR?.trim() || '',
avgRateValue: Number(item.DLSPEED) || 0,
// 【修改点1】这里字段名改为 LANDMARK
locationTypeStr: item.LANDMARK?.trim() || '',
dayTime: item.DAYTIME?.toString() || ''
}
})
} catch (error) {
console.error('获取接口数据失败:', error)
alert('数据加载失败!')
}
},
calculateStats() {
this.locationTypeStats = {
'大学': { CUCC: { rateSum: 0, count: 0 }, CMCC: { rateSum: 0, count: 0 }, CTCC: { rateSum: 0, count: 0 } },
'其他': { CUCC: { rateSum: 0, count: 0 }, CMCC: { rateSum: 0, count: 0 }, CTCC: { rateSum: 0, count: 0 } }
}
const filteredData = this.rawData.filter(item => {
if (this.selectedOperator !== 'all' && item.operator !== this.selectedOperator) return false;
if (this.startDate || this.endDate) {
const dayTimeStr = item.dayTime || ''
if (dayTimeStr.length < 8) return false
const itemDate = `${dayTimeStr.slice(0,4)}-${dayTimeStr.slice(4,6)}-${dayTimeStr.slice(6,8)}`
if (this.startDate && itemDate < this.startDate) return false
if (this.endDate && itemDate > this.endDate) return false
}
return ['CUCC', 'CMCC', 'CTCC'].includes(item.operator)
})
filteredData.forEach(item => {
const { operator, avgRateValue, locationTypeStr } = item
const locationType = locationTypeStr === 'university' ? '大学' : '其他'
this.locationTypeStats[locationType][operator].rateSum += avgRateValue
this.locationTypeStats[locationType][operator].count += 1
})
console.log('统计结果:', this.locationTypeStats)
},
initChart() {
const chartDom = document.getElementById('locationOperatorChart')
if (!chartDom) return
this.chartInstance = echarts.init(chartDom)
},
renderChart() {
const getAvgRate = (locationType, operator) => {
const stat = this.locationTypeStats[locationType][operator]
if (stat.count === 0) return 0
const avg = stat.rateSum / stat.count
return Math.round(avg * 100) / 100
}
const locationTypeList = ['大学', '其他']
const operatorList = ['CUCC', 'CMCC', 'CTCC']
const seriesData = operatorList.map(operator => ({
name: operator,
type: 'bar',
barWidth: '20%',
data: locationTypeList.map(type => getAvgRate(type, operator)),
itemStyle: {
color: operator === 'CUCC' ? '#409EFF' : operator === 'CMCC' ? '#67C23A' : '#E6A23C'
},
label: {
show: true,
position: 'top',
fontSize: 12,
fontWeight: 'bold'
}
}))
const option = {
title: {
text: '各运营商在不同地标类型的网络速率对比',
left: 'center',
top: 20,
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
let tip = `<b>${params[0].axisValue}</b><br/>`
params.forEach(item => {
if (item.value > 0) tip += `${item.seriesName}${item.value}<br/>`
})
return tip
}
},
legend: { data: operatorList, top: 60 },
grid: { left: '8%', right: '5%', bottom: '10%', top: '12%', containLabel: true },
xAxis: {
type: 'category',
data: locationTypeList,
axisLabel: { fontSize: 15, interval: 0 }
},
yAxis: {
type: 'value',
name: '平均网络速率(下行)',
min: 0,
axisLabel: { formatter: '{value}' }
},
series: seriesData
}
this.chartInstance.setOption(option)
window.addEventListener('resize', () => this.chartInstance.resize())
},
fetchAndRenderData() {
this.calculateStats()
this.renderChart()
}
}
}
</script>
<style scoped>
.operator-location-chart {
padding: 20px;
background-color: #f8f8f8;
}
</style>

38
src/utils/loadBaiduMap.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* 异步加载百度地图API
*/
export function loadBaiduMap() {
return new Promise((resolve, reject) => {
if (window.BMap) {
resolve(window.BMap);
return;
}
if (window.baiduMapLoading) {
const checkInterval = setInterval(() => {
if (window.BMap) {
clearInterval(checkInterval);
resolve(window.BMap);
}
}, 100);
return;
}
window.baiduMapLoading = true;
const ak = 'OJq5T5pqYPI6LV3i8g6DoqWxtZmayZGp';
const script = document.createElement('script');
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${ak}&callback=initBaiduMap`;
script.onerror = () => {
window.baiduMapLoading = false;
reject(new Error('百度地图加载失败'));
};
window.initBaiduMap = () => {
window.baiduMapLoading = false;
resolve(window.BMap);
delete window.initBaiduMap;
};
document.head.appendChild(script);
});
}

View File

@@ -1,15 +1,514 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import * as echarts from 'echarts'
interface PhoneQualityUsageDTO {
phoneModel: string
os: string
osVersion: string
operator: string
networkType: string
totalUploadTraffic: number
totalDownloadTraffic: number
totalTraffic: number
recordCount: number
}
const chartContainer = ref<HTMLDivElement | null>(null)
const chartInstance = ref<echarts.ECharts | null>(null)
// 筛选条件
const operator = ref<string>('')
const networkType = ref<string>('') // Android/iOS
const startDate = ref<string>('')
const endDate = 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 loading = ref<boolean>(false)
const chartData = ref<PhoneQualityUsageDTO[]>([]) // 保存当前数据
// 初始化图表
const initChart = () => {
if (!chartContainer.value) return
if (chartInstance.value) {
chartInstance.value.dispose()
}
chartInstance.value = echarts.init(chartContainer.value)
}
// 更新柱状图
const updateChart = (data: PhoneQualityUsageDTO[]) => {
if (!chartInstance.value) return
if (!data || data.length === 0) {
chartInstance.value.setOption({
title: { text: '暂无数据', left: 'center' },
xAxis: { data: [] },
series: [{ type: 'bar', data: [] }]
})
return
}
// 过滤掉负数或零流量,并按总流量降序排序
const filteredData = data.filter(item => item.totalTraffic > 0)
const sortedData = [...filteredData].sort((a, b) => b.totalTraffic - a.totalTraffic)
// 只显示排名前8的数据
const displayData = sortedData.slice(0, 8)
const phoneNames = displayData.map(item => {
// 截断过长的手机型号名称最多显示15个字符
const name = item.phoneModel || '未知手机'
return name.length > 15 ? name.substring(0, 15) + '...' : name
})
// 将KB转换为MB显示
const trafficValues = displayData.map(item => item.totalTraffic / 1024)
const option = {
title: {
text: '热门手机网络质量排名Top8',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params: any) => {
const idx = params[0].dataIndex
const item = displayData[idx]
return `
<div style="padding: 8px;">
<div><strong>${item.phoneModel}</strong></div>
<div>操作系统: ${item.os}</div>
<div>系统版本: ${item.osVersion || '未指定'}</div>
<div>运营商: ${item.operator}</div>
<div>总流量: <strong>${formatTraffic(item.totalTraffic)}</strong></div>
<div>上传流量: ${formatTraffic(item.totalUploadTraffic)}</div>
<div>下载流量: ${formatTraffic(item.totalDownloadTraffic)}</div>
<div>记录数: ${item.recordCount}</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: phoneNames,
axisLabel: {
fontSize: 11,
rotate: 0,
interval: 0 // 显示所有标签
}
},
yAxis: {
type: 'value',
name: '单位(MB)',
nameTextStyle: {
fontSize: 12
},
min: 0, // 强制Y轴从0开始避免负数
axisLabel: {
fontSize: 11,
formatter: (value: number) => {
// value已经是MB直接显示
if (value >= 1000) {
return (value / 1000).toFixed(0) + 'K'
}
return value.toFixed(0)
}
}
},
series: [
{
name: '流量',
type: 'bar',
data: trafficValues,
itemStyle: {
color: '#4b9cff'
},
emphasis: {
itemStyle: {
color: '#2563eb'
}
},
label: {
show: true,
position: 'top',
formatter: (params: any) => {
// 显示MB值保留整数或一位小数
const mbValue = params.value
if (mbValue >= 1000) {
return (mbValue / 1000).toFixed(1) + 'K'
}
return mbValue.toFixed(0)
},
fontSize: 11
}
}
]
}
chartInstance.value.setOption(option)
}
// 格式化流量显示用于tooltip保持KB单位
const formatTraffic = (value: number): string => {
// value是KB
const mbValue = value / 1024
if (mbValue >= 1000) {
return (mbValue / 1000).toFixed(2) + 'K MB'
}
return mbValue.toFixed(2) + ' MB'
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const body: any = {}
// 运营商筛选空字符串或ALL表示全部
if (operator.value && operator.value !== 'ALL') {
body.operator = operator.value
}
// 网络制式筛选OS字段Android/iOS
if (networkType.value) {
body.networkType = networkType.value
}
// 日期范围
if (startDate.value) {
body.startDate = startDate.value
}
if (endDate.value) {
body.endDate = endDate.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
}
console.log('发送请求:', body)
const response = await fetch('http://localhost:8081/appTraffic/phoneQuality', {
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 PhoneQualityUsageDTO[]
console.log('收到数据:', data.length, '条')
// 过滤掉负数或零流量的数据
const validData = data.filter(item => item.totalTraffic > 0)
console.log('过滤负数后数据:', validData.length, '条')
chartData.value = validData
await nextTick()
updateChart(validData)
} catch (error) {
console.error('获取数据失败:', error)
alert(`获取数据失败: ${error instanceof Error ? error.message : String(error)}`)
if (chartInstance.value) {
chartInstance.value.setOption({
title: { text: '数据加载失败', left: 'center' },
xAxis: { data: [] },
series: [{ type: 'bar', data: [] }]
})
}
} finally {
loading.value = false
}
}
// 窗口缩放自适应
const handleResize = () => {
if (chartInstance.value) {
chartInstance.value.resize()
}
}
onMounted(async () => {
await nextTick()
initChart()
await fetchData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (chartInstance.value) {
chartInstance.value.dispose()
chartInstance.value = null
}
})
</script>
<template>
<div class="page">
<!-- 加载遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p class="loading-text">加载数据中...</p>
</div>
</div>
<h2>业务需求23热门手机-热门手机网络质量排名</h2>
<p>这里展示热门手机网络质量排名的相关内容</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="">全部</option>
<option value="ALL">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>
<button class="refresh-btn" @click="fetchData" :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="chart-container" ref="chartContainer"></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,
.date-input,
.number-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.number-input {
width: 120px;
}
.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;
}
.chart-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>

View File

@@ -1,15 +1,634 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
interface PhoneTrafficRatioItem {
phoneModel: string
os: string
osVersion: string
operator: string
networkType: string
totalUploadTraffic: number
totalDownloadTraffic: number
totalTraffic: number
trafficRatio: number
recordCount: number
}
const barChartContainer = ref<HTMLDivElement | null>(null)
const pieChartContainer = ref<HTMLDivElement | null>(null)
const operator = ref<string>('CMCC') // 默认选择中国移动
const networkType = ref<string>('') // 网络制式Android/iOS
const startDate = ref<string>('')
const endDate = 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 loading = ref<boolean>(false)
const barChartInstance = ref<echarts.ECharts | null>(null)
const pieChartInstance = ref<echarts.ECharts | null>(null)
// 运营商名称映射
const operatorMap: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信'
}
// 初始化柱状图
const initBarChart = () => {
if (!barChartContainer.value) return
if (barChartInstance.value) {
barChartInstance.value.dispose()
}
barChartInstance.value = echarts.init(barChartContainer.value)
}
// 初始化饼状图
const initPieChart = () => {
if (!pieChartContainer.value) return
if (pieChartInstance.value) {
pieChartInstance.value.dispose()
}
pieChartInstance.value = echarts.init(pieChartContainer.value)
}
// 更新柱状图
const updateBarChart = (data: PhoneTrafficRatioItem[]) => {
if (!barChartInstance.value) return
// 按总流量排序取前20名
const sortedData = [...data]
.sort((a, b) => b.totalTraffic - a.totalTraffic)
.slice(0, 20)
const phoneNames = sortedData.map(item => item.phoneModel || '未知手机')
const trafficData = sortedData.map(item => item.totalTraffic)
const option = {
title: {
text: '热门手机流量使用情况Top20',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params: any) => {
const dataIndex = params[0].dataIndex
const item = sortedData[dataIndex]
return `
<div style="padding: 8px;">
<div><strong>${item.phoneModel}</strong></div>
<div>操作系统: ${item.os}</div>
<div>系统版本: ${item.osVersion || '未指定'}</div>
<div>运营商: ${item.operator}</div>
<div>总流量: <strong>${(item.totalTraffic / 1024 / 1024).toFixed(2)} MB</strong></div>
<div>上传流量: ${(item.totalUploadTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>下载流量: ${(item.totalDownloadTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>流量占比: ${item.trafficRatio.toFixed(2)}%</div>
<div>记录数: ${item.recordCount}</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: phoneNames,
axisLabel: {
fontSize: 11,
rotate: 45, // 旋转45度避免重叠
interval: 0 // 显示所有标签
}
},
yAxis: {
type: 'value',
name: '流量 (KB)',
axisLabel: {
formatter: (value: number) => {
if (value >= 1024 * 1024) {
return (value / 1024 / 1024).toFixed(1) + 'M'
} else if (value >= 1024) {
return (value / 1024).toFixed(1) + 'K'
}
return value.toFixed(0)
}
}
},
series: [
{
name: '总流量',
type: 'bar',
data: trafficData,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#4b9cff' },
{ offset: 0.5, color: '#2563eb' },
{ offset: 1, color: '#2563eb' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2563eb' },
{ offset: 0.7, color: '#2563eb' },
{ offset: 1, color: '#4b9cff' }
])
}
},
label: {
show: true,
position: 'top',
formatter: (params: any) => {
const value = params.value
if (value >= 1024 * 1024) {
return (value / 1024 / 1024).toFixed(1) + 'M'
} else if (value >= 1024) {
return (value / 1024).toFixed(1) + 'K'
}
return value.toFixed(0)
},
fontSize: 10
}
}
]
}
barChartInstance.value.setOption(option)
}
// 更新饼状图
const updatePieChart = (data: PhoneTrafficRatioItem[]) => {
if (!pieChartInstance.value) return
// 按流量占比排序取前15名其余归为"其他"
const sortedData = [...data].sort((a, b) => b.trafficRatio - a.trafficRatio)
const top15 = sortedData.slice(0, 15)
const others = sortedData.slice(15)
const pieData = top15.map(item => ({
name: item.phoneModel || '未知手机',
value: item.trafficRatio
}))
// 如果有其他数据,添加到饼图中
if (others.length > 0) {
const othersRatio = others.reduce((sum, item) => sum + item.trafficRatio, 0)
pieData.push({
name: '其他',
value: othersRatio
})
}
const option = {
title: {
text: '手机流量占比分布',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.name === '其他') {
return `
<div style="padding: 8px;">
<div><strong>${params.name}</strong></div>
<div>流量占比: <strong>${params.value.toFixed(2)}%</strong></div>
<div>包含 ${others.length} 个手机型号</div>
</div>
`
}
const foundItem = sortedData.find(d => d.phoneModel === params.name)
if (foundItem) {
return `
<div style="padding: 8px;">
<div><strong>${foundItem.phoneModel}</strong></div>
<div>流量占比: <strong>${params.value.toFixed(2)}%</strong></div>
<div>总流量: ${(foundItem.totalTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>记录数: ${foundItem.recordCount}</div>
</div>
`
}
return `${params.name}: ${params.value.toFixed(2)}%`
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: {
fontSize: 12
},
formatter: (name: string) => {
const item = sortedData.find(d => d.phoneModel === name)
if (item) {
return `${name} (${item.trafficRatio.toFixed(2)}%)`
}
return name
}
},
series: [
{
name: '流量占比',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: (params: any) => {
if (params.percent < 3) {
return '' // 占比小于3%的不显示标签,避免重叠
}
return `${params.name}\n${params.percent.toFixed(1)}%`
},
fontSize: 11
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
data: pieData
}
]
}
pieChartInstance.value.setOption(option)
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const body: any = {}
if (operator.value && operator.value !== 'ALL') {
body.operator = operator.value
} else if (operator.value === 'ALL') {
body.operator = 'ALL'
}
if (networkType.value) {
body.networkType = networkType.value
}
if (startDate.value) {
body.startDate = startDate.value
}
if (endDate.value) {
body.endDate = endDate.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
}
console.log('发送请求:', body)
const response = await fetch('http://localhost:8081/appTraffic/phoneTrafficRatio', {
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 PhoneTrafficRatioItem[]
console.log('收到数据:', data.length, '条')
await nextTick()
updateBarChart(data)
updatePieChart(data)
} catch (error) {
console.error('获取数据失败:', error)
alert(`获取数据失败: ${error instanceof Error ? error.message : String(error)}`)
// 清空图表
if (barChartInstance.value) {
barChartInstance.value.setOption({
series: [{ data: [] }]
})
}
if (pieChartInstance.value) {
pieChartInstance.value.setOption({
series: [{ data: [] }]
})
}
} finally {
loading.value = false
}
}
// 监听窗口大小变化,自动调整图表大小
const handleResize = () => {
if (barChartInstance.value) {
barChartInstance.value.resize()
}
if (pieChartInstance.value) {
pieChartInstance.value.resize()
}
}
// 监听筛选条件变化
watch([operator, networkType, startDate, endDate, minUploadTraffic, maxUploadTraffic, minDownloadTraffic, maxDownloadTraffic], () => {
if (barChartInstance.value && pieChartInstance.value) {
fetchData()
}
})
onMounted(async () => {
await nextTick()
initBarChart()
initPieChart()
await fetchData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (barChartInstance.value) {
barChartInstance.value.dispose()
barChartInstance.value = null
}
if (pieChartInstance.value) {
pieChartInstance.value.dispose()
pieChartInstance.value = null
}
})
</script>
<template>
<div class="page">
<!-- 加载遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p class="loading-text">加载数据中...</p>
</div>
</div>
<h2>业务需求24热门手机-热门手机流量排名</h2>
<p>这里展示热门手机流量排名的相关内容</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="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>
<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="charts-container">
<div class="chart-wrapper">
<div class="chart-container" ref="barChartContainer"></div>
</div>
<div class="chart-wrapper">
<div class="chart-container" ref="pieChartContainer"></div>
</div>
</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,
.date-input,
.number-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.number-input {
width: 120px;
}
.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;
}
.charts-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
flex: 1;
min-height: 0;
}
.chart-wrapper {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
}
.chart-container {
width: 100%;
height: 100%;
min-height: 400px;
}
.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>

View File

@@ -1,15 +1,644 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
interface OsTrafficRatioItem {
osName: string
os: string
osVersion: string
operator: string
networkType: string
totalUploadTraffic: number
totalDownloadTraffic: number
totalTraffic: number
trafficRatio: number
recordCount: number
}
const barChartContainer = ref<HTMLDivElement | null>(null)
const pieChartContainer = ref<HTMLDivElement | null>(null)
const operator = ref<string>('CMCC') // 默认选择中国移动
const networkType = ref<string>('') // 网络制式Android/iOS
const startDate = ref<string>('')
const endDate = 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 loading = ref<boolean>(false)
const barChartInstance = ref<echarts.ECharts | null>(null)
const pieChartInstance = ref<echarts.ECharts | null>(null)
// 运营商名称映射
const operatorMap: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信'
}
// 初始化柱状图
const initBarChart = () => {
if (!barChartContainer.value) return
if (barChartInstance.value) {
barChartInstance.value.dispose()
}
barChartInstance.value = echarts.init(barChartContainer.value)
}
// 初始化饼状图
const initPieChart = () => {
if (!pieChartContainer.value) return
if (pieChartInstance.value) {
pieChartInstance.value.dispose()
}
pieChartInstance.value = echarts.init(pieChartContainer.value)
}
// 更新柱状图
const updateBarChart = (data: OsTrafficRatioItem[]) => {
if (!barChartInstance.value) return
// 过滤掉 os 或 osVersion 无效的数据
const filteredData = data.filter(item => {
const osLower = (item.os || '').toLowerCase()
const osVersionLower = (item.osVersion || '').toLowerCase()
return osLower.includes('android') && osVersionLower !== 'unknown' && osVersionLower !== ''
})
// 按总流量排序取前20名
const sortedData = [...filteredData]
.sort((a, b) => b.totalTraffic - a.totalTraffic)
.slice(0, 20)
// 使用 osVersion 作为横坐标
const osNames = sortedData.map(item => item.osVersion || '未知版本')
const trafficData = sortedData.map(item => item.totalTraffic)
const option = {
title: {
text: '手机操作系统流量使用情况Top20',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params: any) => {
const dataIndex = params[0].dataIndex
const item = sortedData[dataIndex]
return `
<div style="padding: 8px;">
<div><strong>${item.osName}</strong></div>
<div>操作系统: ${item.os}</div>
<div>系统版本: ${item.osVersion || '未指定'}</div>
<div>运营商: ${item.operator}</div>
<div>总流量: <strong>${(item.totalTraffic / 1024 / 1024).toFixed(2)} MB</strong></div>
<div>上传流量: ${(item.totalUploadTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>下载流量: ${(item.totalDownloadTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>流量占比: ${item.trafficRatio.toFixed(2)}%</div>
<div>记录数: ${item.recordCount}</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: osNames,
axisLabel: {
fontSize: 11,
rotate: 45, // 旋转45度避免重叠
interval: 0 // 显示所有标签
}
},
yAxis: {
type: 'value',
name: '流量 (KB)',
axisLabel: {
formatter: (value: number) => {
if (value >= 1024 * 1024) {
return (value / 1024 / 1024).toFixed(1) + 'M'
} else if (value >= 1024) {
return (value / 1024).toFixed(1) + 'K'
}
return value.toFixed(0)
}
}
},
series: [
{
name: '总流量',
type: 'bar',
data: trafficData,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#c572ff' },
{ offset: 0.5, color: '#9333ea' },
{ offset: 1, color: '#9333ea' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#9333ea' },
{ offset: 0.7, color: '#9333ea' },
{ offset: 1, color: '#c572ff' }
])
}
},
label: {
show: true,
position: 'top',
formatter: (params: any) => {
const value = params.value
if (value >= 1024 * 1024) {
return (value / 1024 / 1024).toFixed(1) + 'M'
} else if (value >= 1024) {
return (value / 1024).toFixed(1) + 'K'
}
return value.toFixed(0)
},
fontSize: 10
}
}
]
}
barChartInstance.value.setOption(option)
}
// 更新饼状图
const updatePieChart = (data: OsTrafficRatioItem[]) => {
if (!pieChartInstance.value) return
// 过滤掉无效数据
const filteredData = data.filter(item => {
const osLower = (item.os || '').toLowerCase()
const osVersionLower = (item.osVersion || '').toLowerCase()
return osLower.includes('android') && osVersionLower !== 'unknown' && osVersionLower !== ''
})
// 按总流量排序取前10名
const sortedData = [...filteredData]
.sort((a, b) => b.totalTraffic - a.totalTraffic)
.slice(0, 10)
// 构造饼图数据
const pieData = sortedData.map(item => ({
name: item.osVersion || '未知版本',
value: item.totalTraffic
}))
const option = {
title: {
text: '操作系统流量占比分布',
left: 'center',
textStyle: {
fontSize: 18,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.name === '其他') {
return `
<div style="padding: 8px;">
<div><strong>${params.name}</strong></div>
<div>流量占比: <strong>${params.value.toFixed(2)}%</strong></div>
<div>包含 ${others.length} 个操作系统</div>
</div>
`
}
const foundItem = sortedData.find(d => d.osName === params.name)
if (foundItem) {
return `
<div style="padding: 8px;">
<div><strong>${foundItem.osName}</strong></div>
<div>流量占比: <strong>${params.value.toFixed(2)}%</strong></div>
<div>总流量: ${(foundItem.totalTraffic / 1024 / 1024).toFixed(2)} MB</div>
<div>记录数: ${foundItem.recordCount}</div>
</div>
`
}
return `${params.name}: ${params.value.toFixed(2)}%`
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: {
fontSize: 12
},
formatter: (name: string) => {
const item = sortedData.find(d => d.osName === name)
if (item) {
return `${name} (${item.trafficRatio.toFixed(2)}%)`
}
return name
}
},
series: [
{
name: '流量占比',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: (params: any) => {
if (params.percent < 3) {
return '' // 占比小于3%的不显示标签,避免重叠
}
return `${params.name}\n${params.percent.toFixed(1)}%`
},
fontSize: 11
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
data: pieData
}
]
}
pieChartInstance.value.setOption(option)
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const body: any = {}
if (operator.value && operator.value !== 'ALL') {
body.operator = operator.value
} else if (operator.value === 'ALL') {
body.operator = 'ALL'
}
if (networkType.value) {
body.networkType = networkType.value
}
if (startDate.value) {
body.startDate = startDate.value
}
if (endDate.value) {
body.endDate = endDate.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
}
console.log('发送请求:', body)
const response = await fetch('http://localhost:8081/appTraffic/osTrafficRatio', {
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 OsTrafficRatioItem[]
console.log('收到数据:', data.length, '条')
console.log('数据示例:', data.slice(0, 5))
await nextTick()
updateBarChart(data)
updatePieChart(data)
} catch (error) {
console.error('获取数据失败:', error)
alert(`获取数据失败: ${error instanceof Error ? error.message : String(error)}`)
// 清空图表
if (barChartInstance.value) {
barChartInstance.value.setOption({
series: [{ data: [] }]
})
}
if (pieChartInstance.value) {
pieChartInstance.value.setOption({
series: [{ data: [] }]
})
}
} finally {
loading.value = false
}
}
// 监听窗口大小变化,自动调整图表大小
const handleResize = () => {
if (barChartInstance.value) {
barChartInstance.value.resize()
}
if (pieChartInstance.value) {
pieChartInstance.value.resize()
}
}
// 监听筛选条件变化
watch([operator, networkType, startDate, endDate, minUploadTraffic, maxUploadTraffic, minDownloadTraffic, maxDownloadTraffic], () => {
if (barChartInstance.value && pieChartInstance.value) {
fetchData()
}
})
onMounted(async () => {
await nextTick()
initBarChart()
initPieChart()
await fetchData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (barChartInstance.value) {
barChartInstance.value.dispose()
barChartInstance.value = null
}
if (pieChartInstance.value) {
pieChartInstance.value.dispose()
pieChartInstance.value = null
}
})
</script>
<template>
<div class="page">
<!-- 加载遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p class="loading-text">加载数据中...</p>
</div>
</div>
<h2>业务需求25热门手机-手机OS流量排名</h2>
<p>这里展示手机OS流量排名的相关内容</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="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>
<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="charts-container">
<div class="chart-wrapper">
<div class="chart-container" ref="barChartContainer"></div>
</div>
<div class="chart-wrapper">
<div class="chart-container" ref="pieChartContainer"></div>
</div>
</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,
.date-input,
.number-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.number-input {
width: 120px;
}
.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;
}
.charts-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
flex: 1;
min-height: 0;
}
.chart-wrapper {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
}
.chart-container {
width: 100%;
height: 100%;
min-height: 400px;
}
.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>

View File

@@ -1,15 +1,690 @@
<script setup lang="ts"></script>
<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>
<p>这里展示热门手机分布图的相关内容</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">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>

View File

@@ -1,15 +1,682 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
interface RegionTopOsItem {
rowIndex: number
colIndex: number
centerLon: number
centerLat: number
osName: string
os: 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<RegionTopOsItem[]>([])
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
}
}
}
// 创建OS logo图标使用文字作为占位符
const createOsIcon = (osName: string, traffic: number): string => {
// 使用OS名称的前几个字符
let displayText = osName
if (osName.toLowerCase().includes('android')) {
displayText = 'A' // Android简写为A
} else if (osName.toLowerCase().includes('ios')) {
displayText = 'iOS'
} else {
displayText = osName.length > 4 ? osName.substring(0, 4) : osName
}
const size = 40
const fontSize = 12
// 修复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="#10b981" 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} 个标记点`)
// 在地图上显示OS 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(
createOsIcon(item.osName, 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.osName}</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.osName}):`, 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/osRegionTop')
const response = await fetch('http://localhost:8081/appTraffic/osRegionTop', {
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 RegionTopOsItem[]
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('Req27组件卸载清理地图资源')
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>业务需求27热门手机-手机OS分布图</h2>
<p>这里展示手机OS分布图的相关内容</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">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的手机OS流量需超过阈值{{ 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>

View File

@@ -1,9 +1,15 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import NetworkMap from '@/components/NetworkMap.vue';
</script>
<template>
<div class="page">
<h2>业务需求10网络质量-网络质量分布</h2>
<p>这里展示网络质量分布的相关内容</p>
<NetworkMap></NetworkMap>
</div>
</template>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import tu2 from '@/components/tu2.vue'
</script>
<template>
<div class="page">
<h2>业务需求11网络质量-网络质量统计</h2>
<p>这里展示网络质量统计的相关内容</p>
<tu2></tu2>
</div>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import tu3 from '@/components/tu3.vue'
</script>
<template>
<div class="page">
<h2>业务需求12网络质量-网络速率排名</h2>
<p>这里展示网络速率排名的相关内容</p>
<tu3></tu3>
</div>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import tu4 from '@/components/tu4.vue'
</script>
<template>
<div class="page">
<h2>业务需求13网络质量-典型地标网络质量跟踪</h2>
<p>这里展示典型地标网络质量跟踪的相关内容</p>
<tu4></tu4>
</div>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import tu5 from '@/components/tu5.vue'
</script>
<template>
<div class="page">
<h2>业务需求14网络质量-典型地标网络质量统计</h2>
<p>这里展示典型地标网络质量统计的相关内容</p>
<tu5></tu5>
</div>
</template>
@@ -11,5 +14,6 @@
.page {
padding: 16px;
}
</style>