206.1.13最终vue(1)
This commit is contained in:
369
src/components/NetworkMap.vue
Normal file
369
src/components/NetworkMap.vue
Normal 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
237
src/components/tu2.vue
Normal 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
288
src/components/tu3.vue
Normal 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
290
src/components/tu4.vue
Normal 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
195
src/components/tu5.vue
Normal 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
38
src/utils/loadBaiduMap.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user