lqq修改

This commit is contained in:
wangran
2026-01-13 09:32:02 +08:00
parent 5bc98df08e
commit be53c587a4
6 changed files with 1520 additions and 366 deletions

View File

@@ -16,6 +16,7 @@
}
</script>
<script type="text/javascript" src="https://api.map.baidu.com/api?v=3.0&ak=4CSApBVQYDpCbrRQxu86FOOwbFjVjDlb"></script>
<script type="text/javascript" src="//api.map.baidu.com/library/Heatmap/2.0/src/Heatmap_min.js"></script>
</head>
<body>
<div id="app"></div>

773
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
"echarts": "^6.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-baidu-map-3x": "^1.0.40",
"vue-router": "^4.6.4"
},
"devDependencies": {

View File

@@ -1,15 +1,302 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, watch, nextTick } from 'vue'
// 信号强度数据项接口
interface SignalStrengthItem {
id?: number
userLon?: string
userLat?: string
rssi?: number // 确保 rssi 是 number
[key: string]: any // 允许其他字段
}
const mapContainer = ref<HTMLDivElement | null>(null)
const networkType = ref<string>('ALL')
const networkName = ref<string>('ALL')
const startDate = ref<string>('')
const endDate = ref<string>('')
const loading = ref<boolean>(false)
const dataLoaded = ref<boolean>(false)
let map: any = null
let heatmapOverlay: any = null
// 将后端返回的数据转换为热力图库需要的格式
const transformDataForHeatmap = (data: SignalStrengthItem[]) => {
return data
.filter(item => {
const lat = parseFloat(String(item.userLat))
const lon = parseFloat(String(item.userLon))
// 过滤掉无效坐标和无效信号强度
return !isNaN(lat) && !isNaN(lon) && lat !== 0 && lon !== 0 && item.rssi != null
})
.map(item => {
// 热力图权重需要是正数我们将RSSI-120到0映射到一个正数范围例如1到100
// 信号越强越接近0权重越高
const rssi = item.rssi ?? -120;
const count = Math.max(1, 100 - (Math.abs(rssi) - 20));
return {
lng: parseFloat(String(item.userLon)),
lat: parseFloat(String(item.userLat)),
count: count
}
})
}
// 渲染热力图
const renderHeatmap = (points: any[]) => {
if (!map) return
// 如果已存在热力图层,先移除
if (heatmapOverlay) {
map.removeOverlay(heatmapOverlay)
}
if (points.length === 0) {
return; // 没有数据点,不创建图层
}
// 创建热力图实例
heatmapOverlay = new window.BMapLib.HeatmapOverlay({ radius: 20, opacity: 0.8 })
map.addOverlay(heatmapOverlay)
// 设置热力图数据
heatmapOverlay.setDataSet({
data: points,
max: 100 // 设置权重的最大值
});
// 自动调整地图视野以包含所有点
const viewport = map.getViewport(points.map((p: any) => new window.BMap.Point(p.lng, p.lat)));
map.centerAndZoom(viewport.center, viewport.zoom);
}
// 获取数据
const fetchData = async () => {
loading.value = true
dataLoaded.value = false
try {
const params = new URLSearchParams()
params.append('networkType', networkType.value)
params.append('networkName', networkName.value)
if (startDate.value) params.append('startTime', new Date(startDate.value).getTime().toString())
if (endDate.value) {
const end = new Date(endDate.value);
end.setHours(23, 59, 59, 999);
params.append('endTime', end.getTime().toString());
}
const response = await fetch(`http://localhost:8081/api/signal/heatmap?${params.toString()}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const responseData = await response.json()
if (responseData.code !== 200) throw new Error(responseData.message || '获取数据失败')
const heatmapPoints = transformDataForHeatmap(responseData.data || [])
if (map) {
renderHeatmap(heatmapPoints)
}
} catch (error) {
console.error('获取数据失败:', error)
if(map) renderHeatmap([]); // 清空热力图
} finally {
loading.value = false
dataLoaded.value = true
}
}
// 初始化地图
const initMap = async () => {
await nextTick()
// 轮询检查百度地图API和热力图库是否都已加载
if (!window.BMap || !window.BMapLib?.HeatmapOverlay) {
console.warn('百度地图API或热力图库未加载200ms后重试...')
setTimeout(initMap, 200)
return
}
if (!mapContainer.value) {
console.warn('地图容器未找到')
return
}
map = new window.BMap.Map(mapContainer.value)
map.centerAndZoom(new window.BMap.Point(105.8951, 36.5667), 5) // 默认中国中心
map.enableScrollWheelZoom(true)
map.addControl(new window.BMap.MapTypeControl({ mapTypes: [window.BMap.MapTypeId.NORMAL, window.BMap.MapTypeId.SATELLITE] }))
map.addControl(new window.BMap.NavigationControl())
// 地图初始化后立即获取数据
fetchData()
}
// 监听筛选条件变化
watch([networkType, networkName, startDate, endDate], () => {
if (map && dataLoaded.value) {
fetchData()
}
})
onMounted(() => {
initMap()
})
onUnmounted(() => {
if (heatmapOverlay && map) {
map.removeOverlay(heatmapOverlay)
}
map = null
})
</script>
<template>
<div class="page">
<h2>业务需求7信号覆盖-信号强度分布图</h2>
<p>这里展示信号强度分布图的相关内容</p>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p class="loading-text">加载数据中...</p>
</div>
</div>
<h2>业务需求7信号覆盖-信号强度分布热力图</h2>
<div class="filter-bar">
<div class="filter-item">
<label for="networkType">运营商:</label>
<select id="networkType" v-model="networkType" class="select-input" :disabled="loading">
<option value="ALL">ALL</option>
<option value="CMCC">中国移动</option>
<option value="CUCC">中国联通</option>
<option value="CTCC">中国电信</option>
</select>
</div>
<div class="filter-item">
<label for="networkName">网络类型:</label>
<select id="networkName" v-model="networkName" class="select-input" :disabled="loading">
<option value="ALL">ALL</option>
<option value="2G">2G</option>
<option value="3G">3G</option>
<option value="4G">4G</option>
<option value="5G">5G</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="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 {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.select-input,
.date-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.refresh-btn {
padding: 6px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.refresh-btn:hover:not(:disabled) {
background: #2563eb;
}
.refresh-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.map-container {
width: 100%;
height: calc(100vh - 220px);
min-height: 500px;
border: 1px solid #e5e7eb;
border-radius: 4px;
flex: 1;
}
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 16px;
color: #374151;
margin: 0;
}
</style>

View File

@@ -1,15 +1,556 @@
<script setup lang="ts"></script>
<template>
<div class="page">
<h2>业务需求8信号覆盖-典型地标信号强度跟踪</h2>
<p>这里展示典型地标信号强度跟踪的相关内容</p>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner"></div>
<p class="loading-text">加载数据中...</p>
</div>
</div>
<div class="filter-bar">
<div class="filter-item">
<label for="landmark">地标名称:</label>
<select
id="landmark"
v-model="landmark"
class="select-input"
:disabled="loading"
@change="fetchData"
>
<option value="">请选择地标</option>
<option v-for="item in landmarkOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
<button @click="loadLandmarks" :disabled="loading" class="refresh-btn">
刷新地标列表
</button>
</div>
<div class="filter-item">
<label for="networkType">网络制式:</label>
<select
id="networkType"
v-model="networkType"
class="select-input"
:disabled="loading"
@change="fetchData"
>
<option value="ALL">ALL</option>
<option value="2G">2G</option>
<option value="3G">3G</option>
<option value="4G">4G</option>
</select>
</div>
<div class="filter-item">
<label for="timeGranularity">时间粒度:</label>
<select
id="timeGranularity"
v-model="timeGranularity"
class="select-input"
:disabled="loading"
@change="fetchData"
>
<option value="hour">按小时</option>
<option value="day" selected>按天</option>
<option value="month">按月</option>
</select>
</div>
<div class="filter-item">
<label for="userRole">用户角色:</label>
<select
id="userRole"
v-model="userRole"
class="select-input"
:disabled="loading"
@change="fetchData"
>
<option value="admin">管理员</option>
<option value="user_CMCC">中国移动用户</option>
<option value="user_CUCC">中国联通用户</option>
<option value="user_CTCC">中国电信用户</option>
</select>
</div>
<div class="filter-item">
<label for="startTime">开始时间:</label>
<input
id="startTime"
type="text"
v-model="startTime"
class="date-input"
:disabled="loading"
placeholder="格式: 2023070100"
@change="fetchData"
/>
</div>
<div class="filter-item">
<label for="endTime">结束时间:</label>
<input
id="endTime"
type="text"
v-model="endTime"
class="date-input"
:disabled="loading"
placeholder="格式: 2023073123"
@change="fetchData"
/>
</div>
</div>
<div v-if="statistics" class="stats-panel">
<div class="stat-item">
<span class="stat-label">数据点数:</span>
<span class="stat-value">{{ statistics.totalDataPoints }}</span>
</div>
<div class="stat-item">
<span class="stat-label">时间周期数:</span>
<span class="stat-value">{{ statistics.timePeriods }}</span>
</div>
<div class="stat-item">
<span class="stat-label">运营商数:</span>
<span class="stat-value">{{ statistics.operatorCount }}</span>
</div>
</div>
<div class="chart-container" ref="chartContainer"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
// 接口返回的图表数据结构
interface LinechartData {
timeLabels: string[]
series: Array<{
name: string
type: string
data: (number | null)[]
smooth?: boolean
symbol?: string
symbolSize?: number
}>
operators: string[]
landmark: string
networkType: string
timeGranularity: string
statistics: {
totalDataPoints: number
timePeriods: number
operatorCount: number
}
}
const chartContainer = ref<HTMLDivElement | null>(null)
const landmark = ref<string>('大学') // 默认地标
const networkType = ref<string>('ALL')
const timeGranularity = ref<string>('day')
const userRole = ref<string>('admin')
const startTime = ref<string>('')
const endTime = ref<string>('')
const loading = ref<boolean>(false)
const landmarkOptions = ref<string[]>([])
const statistics = ref<any>(null)
let chartInstance: echarts.ECharts | null = null
// 运营商颜色映射
const operatorColorMap: Record<string, string> = {
'CMCC': '#ff8a48', // 中国移动 - 橙色
'CUCC': '#4b9cff', // 中国联通 - 蓝色
'CTCC': '#c572ff' // 中国电信 - 紫色
}
// 初始化图表
const initChart = () => {
if (chartContainer.value) {
chartInstance = echarts.init(chartContainer.value)
}
}
// 更新图表数据
const updateChart = (data: LinechartData) => {
if (!chartInstance || !data || !data.timeLabels || !data.series) return
const calculateLabelInterval = (length: number) => {
if (length <= 10) return 0
if (length <= 30) return 1
if (length <= 60) return 3
return Math.ceil(length / 20) // 最多显示20个标签
}
const labelInterval = calculateLabelInterval(data.timeLabels.length)
const option = {
title: {
text: `${data.landmark || '典型地标'} - 信号强度跟踪`,
subtext: `网络制式: ${data.networkType === 'ALL' ? '全部' : data.networkType} | 时间粒度: ${formatGranularityText(data.timeGranularity)}`,
left: 'center'
},
grid: {
left: '3%',
right: '4%',
bottom: '12%', // 增加底部边距
top: '20%', // 调整顶部边距
containLabel: true
},
xAxis: {
type: 'category',
data: data.timeLabels,
axisLabel: {
interval: labelInterval, // 动态计算标签间隔
rotate: 45,
margin: 12, // 增加标签与轴线的距离
formatter: function(value: string) {
if (data.timeGranularity === 'month') {
return value
} else if (data.timeGranularity === 'day') {
return value
} else {
// 对于小时数据,只显示日期和小时
return value.length > 10 ? value.substring(5, 16) : value
}
}
},
axisTick: {
alignWithLabel: true // 刻度线与标签对齐
}
},
yAxis: {
type: 'value',
name: '信号强度(dBm)',
min: -120,
max: 0,
axisLabel: {
formatter: '{value} dBm'
}
},
series: data.series.map(s => ({
...s,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
connectNulls: true, // 连接空值,解决线段不连续问题
itemStyle: {
color: operatorColorMap[s.name] || '#5470c6'
},
lineStyle: {
width: 2 // 稍微减小线宽
},
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
borderColor: '#000'
}
}
})),
dataZoom: [
{
type: 'inside',
xAxisIndex: 0,
filterMode: 'filter'
},
{
type: 'slider',
xAxisIndex: 0,
filterMode: 'filter',
bottom: 40, // 增加底部距离
height: 20,
handleSize: '80%' // 调整滑块大小
}
],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
},
formatter: function(params: any) {
let result = params[0].name + '<br/>'
params.forEach((param: any) => {
const value = param.value !== null ?
param.value.toFixed(2) + ' dBm' : '无数据'
result += `
<div style="display: flex; align-items: center; margin: 2px 0;">
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${param.color}"></span>
<span>${param.seriesName}: <strong>${value}</strong></span>
</div>`
})
return result
}
},
// 添加图例配置
legend: {
data: data.operators || data.series.map(s => s.name),
top: 50,
type: 'scroll', // 图例过多时可滚动
pageIconSize: 12,
pageTextStyle: {
color: '#666'
}
}
}
// 应用配置
chartInstance.setOption(option, true)
}
// 格式化时间粒度文本
const formatGranularityText = (granularity: string) => {
switch (granularity) {
case 'month': return '按月'
case 'day': return '按天'
case 'hour': return '按小时'
default: return granularity
}
}
// 加载地标列表
const loadLandmarks = async () => {
try {
const response = await fetch('http://localhost:8081/api/signal/landmarks')
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const responseData = await response.json()
if (responseData.code === 200 && responseData.data) {
landmarkOptions.value = responseData.data
}
} catch (error) {
console.error('加载地标列表失败:', error)
}
}
// 获取数据
const fetchData = async () => {
if (!landmark.value) {
console.error('请先选择地标')
return
}
loading.value = true
try {
const params = new URLSearchParams()
params.append('landmark', landmark.value)
params.append('networkType', networkType.value)
params.append('timeGranularity', timeGranularity.value)
params.append('userRole', userRole.value)
if (startTime.value) {
params.append('startTime', startTime.value)
}
if (endTime.value) {
params.append('endTime', endTime.value)
}
const response = await fetch(`http://localhost:8081/api/signal/linechart?${params.toString()}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const responseData = await response.json()
if (responseData.code !== 200) throw new Error(responseData.message || '获取数据失败')
const data = responseData.data
statistics.value = data.statistics
if (data && data.timeLabels && data.series) {
updateChart(data)
} else {
throw new Error('数据格式错误')
}
} catch (error) {
console.error('获取数据失败:', error)
if (chartInstance) {
chartInstance.clear()
}
console.error('获取数据失败: ' + (error as Error).message)
} finally {
loading.value = false
}
}
// 监听筛选条件变化
watch([landmark, networkType, timeGranularity, userRole, startTime, endTime], (newVal, oldVal) => {
// 避免初始加载时重复调用
if (newVal.some((val, index) => val !== oldVal[index])) {
fetchData()
}
})
const handleResize = () => {
chartInstance?.resize()
}
onMounted(() => {
nextTick(() => {
initChart()
loadLandmarks()
// 延迟一点加载数据,确保地标列表先加载
setTimeout(() => {
if (landmarkOptions.value.length > 0) {
fetchData()
}
}, 500)
window.addEventListener('resize', handleResize)
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<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 {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
}
.filter-item label {
min-width: 70px;
font-weight: 500;
}
.select-input,
.date-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
flex: 1;
min-width: 120px;
}
.date-input {
min-width: 140px;
}
.refresh-btn {
padding: 6px 12px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.refresh-btn:hover {
background-color: #2563eb;
}
.refresh-btn:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
.chart-container {
width: 100%;
height: calc(100vh - 250px);
min-height: 500px;
border: 1px solid #e5e7eb;
border-radius: 4px;
flex: 1;
margin-top: 16px;
}
.stats-panel {
display: flex;
gap: 24px;
margin-bottom: 16px;
padding: 12px;
background: #f0f9ff;
border-radius: 6px;
border: 1px solid #dbeafe;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #6b7280;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 16px;
color: #374151;
margin: 0;
}
</style>

View File

@@ -1,15 +1,274 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { onMounted, ref, onUnmounted, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
// 接口返回的图表数据结构
interface BarchartData {
landmarks: string[]
series: Array<{
name: string
type: string
data: (number | null)[]
}>
}
const chartContainer = ref<HTMLDivElement | null>(null)
const networkType = ref<string>('ALL')
const userRole = ref<string>('admin') // 新增用户角色状态
const startDate = ref<string>('')
const endDate = ref<string>('')
const loading = ref<boolean>(false)
let chartInstance: echarts.ECharts | null = null
// 网络类型颜色映射
const networkColorMap: Record<string, string> = {
'2G': '#ff8a48',
'3G': '#4b9cff',
'4G': '#c572ff'
}
// 初始化图表
const initChart = () => {
if (chartContainer.value) {
chartInstance = echarts.init(chartContainer.value)
}
}
// 更新图表数据
const updateChart = (data: BarchartData) => {
if (!chartInstance) return
const option = {
title: {
text: '典型地标信号强度统计',
subtext: '单位(dBm)',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: data.series.map(s => s.name),
top: 50,
left: 'center'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
top: '25%'
},
xAxis: {
type: 'category',
data: data.landmarks,
axisLabel: {
interval: 0,
rotate: 30
}
},
yAxis: {
type: 'value',
min: -120,
max: 0,
axisLabel: {
formatter: '{value} dBm'
}
},
series: data.series.map(s => ({
...s,
itemStyle: {
color: networkColorMap[s.name] || '#5470c6'
}
}))
}
chartInstance.setOption(option)
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const params = new URLSearchParams()
params.append('networkType', networkType.value)
params.append('userRole', userRole.value) // 添加userRole参数
if (startDate.value) {
params.append('startTime', new Date(startDate.value).getTime().toString())
}
if (endDate.value) {
const end = new Date(endDate.value)
end.setHours(23, 59, 59, 999)
params.append('endTime', end.getTime().toString())
}
const response = await fetch(`http://localhost:8081/api/signal/barchart?${params.toString()}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const responseData = await response.json()
if (responseData.code !== 200) throw new Error(responseData.message || '获取数据失败')
updateChart(responseData.data || { landmarks: [], series: [] })
} catch (error) {
console.error('获取数据失败:', error)
if (chartInstance) {
chartInstance.clear()
}
} finally {
loading.value = false
}
}
// 监听筛选条件变化
watch([networkType, userRole, startDate, endDate], fetchData)
const handleResize = () => {
chartInstance?.resize()
}
onMounted(() => {
nextTick(() => {
initChart()
fetchData()
window.addEventListener('resize', handleResize)
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</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>业务需求9信号覆盖-典型地标信号强度统计</h2>
<p>这里展示典型地标信号强度统计的相关内容</p>
<div class="filter-bar">
<div class="filter-item">
<label for="networkType">网络制式:</label>
<select id="networkType" v-model="networkType" class="select-input" :disabled="loading">
<option value="ALL">ALL</option>
<option value="2G">2G</option>
<option value="3G">3G</option>
<option value="4G">4G</option>
</select>
</div>
<div class="filter-item">
<label for="userRole">用户角色:</label>
<select id="userRole" v-model="userRole" class="select-input" :disabled="loading">
<option value="admin">管理员</option>
<option value="user_CMCC">中国移动用户</option>
<option value="user_CUCC">中国联通用户</option>
<option value="user_CTCC">中国电信用户</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>
<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 {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.select-input,
.date-input {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
}
.chart-container {
width: 100%;
height: calc(100vh - 200px);
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>