feat:图表自适应布局重构优化

This commit is contained in:
zhongzm
2025-10-10 11:34:34 +08:00
parent 2ac730dfe2
commit c85b588719

View File

@@ -215,10 +215,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch,nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent ,GraphicComponent} from 'echarts/components'
import { GridComponent, GraphicComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import useNeInfoStore from '@/store/modules/neinfo'
import { WS } from '@/plugins/ws-websocket'
@@ -226,7 +226,7 @@ import { listKPIData ,getbusyhour, getMosHour, getCctHour} from '@/api/perfManag
import { RESULT_CODE_SUCCESS } from '@/constants/result-constants'
import useI18n from '@/hooks/useI18n';
const { t } = useI18n();
echarts.use([LineChart, GridComponent, CanvasRenderer, GraphicComponent])
echarts.use([LineChart, GridComponent, CanvasRenderer, GraphicComponent, TooltipComponent])
const callsChartRef = ref<HTMLDivElement | null>(null)
const mosChartRef = ref<HTMLDivElement | null>(null)
@@ -237,6 +237,59 @@ const cctChartRef = ref<HTMLDivElement | null>(null)
const regChartRef = ref<HTMLDivElement | null>(null)
const failedRegChartRef = ref<HTMLDivElement | null>(null)
// 存储ResizeObserver实例
const chartObservers = new Map<HTMLElement, ResizeObserver>()
// 图表实例映射
const chartInstances = new Map<HTMLElement, echarts.ECharts>()
// 设置图表ResizeObserver
function setupChartResizeObserver(container: HTMLElement, chart: echarts.ECharts) {
// 如果已存在observer先断开连接
if (chartObservers.has(container)) {
chartObservers.get(container)?.disconnect()
chartObservers.delete(container)
}
// 创建新的ResizeObserver
const observer = new ResizeObserver(() => {
if (chart && !chart.isDisposed()) {
// 使用requestAnimationFrame确保在DOM更新完成后执行resize
requestAnimationFrame(() => {
chart.resize()
})
}
})
// 开始观察容器尺寸变化
observer.observe(container)
// 存储observer实例
chartObservers.set(container, observer)
}
// 清理单个图表的ResizeObserver
function cleanupChartObserver(container: HTMLElement) {
const observer = chartObservers.get(container)
if (observer) {
observer.disconnect()
chartObservers.delete(container)
}
chartInstances.delete(container)
}
// 全局resize处理函数用于处理页面缩放变化
function handleGlobalResize() {
// 延迟执行以确保DOM完全更新
requestAnimationFrame(() => {
chartInstances.forEach((chart, container) => {
if (chart && !chart.isDisposed()) {
chart.resize()
}
})
})
}
// IMS网元列表
const imsNeList = ref<{ neId: string, neName: string }[]>([])
// 当前选中的IMS网元ID
@@ -258,6 +311,9 @@ const wsStatus = ref('no connection')
onMounted(async () => {
// console.log('组件挂载开始获取IMS网元列表') // 调试信息
// 添加全局窗口resize监听器处理页面缩放变化
window.addEventListener('resize', handleGlobalResize)
const res = await useNeInfoStore().fnNelist()
// console.log('获取到的网元列表响应:', res) // 调试信息
@@ -305,10 +361,7 @@ async function fetchHistoryData(neId: string) {
pageNum: 1
}
// console.log('获取历史数据参数:', params)
const res = await listKPIData(params)
//console.log('历史数据响应:', res)
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
// 将历史数据转换为与实时数据相同的格式
@@ -317,15 +370,10 @@ async function fetchHistoryData(neId: string) {
data: item
}))
//console.log('转换后的历史数据:', historyData)
// 将历史数据添加到实时数据数组中(追加而不是覆盖)
// 注意:这里直接赋值,因为这是初始加载历史数据
imsRealtimeRawData.value = historyData
//console.log('历史数据加载完成,数据点数量:', imsRealtimeRawData.value.length)
//console.log('最新历史数据时间戳:', historyData.length > 0 ? historyData[historyData.length - 1].timestamp : '无数据')
// 更新所有图表
updateActiveCallsChart()
updateFailedCallsChart()
@@ -529,13 +577,28 @@ function subscribeImsRealtime(neId: string) {
})
}
// 组件卸载时关闭WebSocket
// 组件卸载时关闭WebSocket和清理ResizeObserver
onBeforeUnmount(() => {
// 移除全局resize监听器
window.removeEventListener('resize', handleGlobalResize)
if (imsWs.value) {
imsWs.value.close()
imsWs.value = null
}
wsStatus.value = 'Disconnect' // 更新状态
// 清理所有ResizeObserver
chartObservers.forEach((observer, container) => {
observer.disconnect()
// 销毁对应的图表实例
const chart = chartInstances.get(container)
if (chart && !chart.isDisposed()) {
chart.dispose()
}
})
chartObservers.clear()
chartInstances.clear()
})
// 更新active calls图表
@@ -547,17 +610,8 @@ function updateActiveCallsChart() {
lineColor: '#1890ff',
areaColor: 'rgba(24,144,255,0.1)',
labelColor: '#1890ff',
dataType: 'realtime',
formatValue: (value: number) => value.toString(),
timeCalculator: (data) => {
const displayDataLength = Math.min(data.length, 30)
const oldestDisplayIndex = Math.max(0, data.length - displayDataLength)
const oldestDisplayData = data[oldestDisplayIndex]
if (oldestDisplayData && oldestDisplayData.timestamp) {
return calculateRelativeTime(oldestDisplayData.timestamp)
}
return '--'
}
dataType: 'realtime-enhanced', // 使用增强的实时数据类型
formatValue: (value: number) => value.toString()
})
}
@@ -575,17 +629,8 @@ function updateFailedCallsChart() {
lineColor: '#faad14',
areaColor: 'rgba(250,173,20,0.1)',
labelColor: '#faad14',
dataType: 'realtime',
formatValue: (value: number) => value.toString(),
timeCalculator: (data) => {
const displayDataLength = Math.min(data.length, 30)
const oldestDisplayIndex = Math.max(0, data.length - displayDataLength)
const oldestDisplayData = data[oldestDisplayIndex]
if (oldestDisplayData && oldestDisplayData.timestamp) {
return calculateRelativeTime(oldestDisplayData.timestamp)
}
return '--'
}
dataType: 'realtime-enhanced', // 使用增强的实时数据类型
formatValue: (value: number) => value.toString()
})
}
@@ -598,17 +643,8 @@ function updateActiveRegistrationsChart() {
lineColor: '#1890ff',
areaColor: 'rgba(24,144,255,0.1)',
labelColor: '#1890ff',
dataType: 'realtime',
formatValue: (value: number) => value.toString(),
timeCalculator: (data) => {
const displayDataLength = Math.min(data.length, 30)
const oldestDisplayIndex = Math.max(0, data.length - displayDataLength)
const oldestDisplayData = data[oldestDisplayIndex]
if (oldestDisplayData && oldestDisplayData.timestamp) {
return calculateRelativeTime(oldestDisplayData.timestamp)
}
return '--'
}
dataType: 'realtime-enhanced', // 使用增强的实时数据类型
formatValue: (value: number) => value.toString()
})
}
@@ -626,17 +662,8 @@ function updateFailedRegistrationsChart() {
lineColor: '#f5222d',
areaColor: 'rgba(245,34,45,0.1)',
labelColor: '#f5222d',
dataType: 'realtime',
formatValue: (value: number) => value.toString(),
timeCalculator: (data) => {
const displayDataLength = Math.min(data.length, 30)
const oldestDisplayIndex = Math.max(0, data.length - displayDataLength)
const oldestDisplayData = data[oldestDisplayIndex]
if (oldestDisplayData && oldestDisplayData.timestamp) {
return calculateRelativeTime(oldestDisplayData.timestamp)
}
return '--'
}
dataType: 'realtime-enhanced', // 使用增强的实时数据类型
formatValue: (value: number) => value.toString()
})
}
@@ -1038,7 +1065,7 @@ function updateChart(config: {
lineColor: string,
areaColor: string,
labelColor: string,
dataType?: 'realtime' | 'hourly',
dataType?: 'realtime' | 'hourly' | 'realtime-enhanced',
formatValue?: (value: number) => string,
timeCalculator?: (data: any[]) => string
}) {
@@ -1048,8 +1075,14 @@ function updateChart(config: {
let chart = echarts.getInstanceByDom(config.chartRef.value)
if (!chart) {
chart = echarts.init(config.chartRef.value)
// 设置ResizeObserver以监听容器尺寸变化
setupChartResizeObserver(config.chartRef.value, chart)
}
// 更新图表实例映射
chartInstances.set(config.chartRef.value, chart)
// 准备图表数据
const chartData = config.data.map(config.dataExtractor)
@@ -1059,12 +1092,66 @@ function updateChart(config: {
const xAxisData = [1, 2, 3, 4, 5]
chart.setOption({
grid: { left: 0, right: 30, top: 10, bottom: 10 },
xAxis: { type: 'category', show: false, data: xAxisData },
grid: {
left: 5, // 给图表左侧留出少量空间,避免被容器边界遮挡
right: 20, // 减少右侧边距为gap腾出空间
top: 5, // 减少顶部边距
bottom: (config.dataType === 'hourly' || config.dataType === 'realtime-enhanced') ? 25 : 5 // 调整底部边距
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none'
},
formatter: (params: any) => {
if (!params || params.length === 0) return ''
const param = params[0]
if (config.dataType === 'hourly') {
return `${param.name}Hour: 0`
} else if (config.dataType === 'realtime-enhanced') {
const minutesAgo = defaultData.length - 1 - param.dataIndex
return `${minutesAgo}Min ago: 0`
} else {
return ` ${param.dataIndex + 1}: 0`
}
},
backgroundColor: 'rgba(0,0,0,0.8)',
textStyle: { color: '#fff', fontSize: 12 },
borderWidth: 0,
padding: [6, 10],
extraCssText: 'border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);'
},
xAxis: {
type: 'category',
show: config.dataType === 'hourly',
data: config.dataType === 'hourly' ?
(() => {
const now = new Date()
const currentHour = now.getHours()
const maxHour = Math.min(currentHour, 4) // 最多显示到4小时或当前小时
return Array.from({ length: maxHour + 1 }, (_, i) =>
i === maxHour ? `${i}Hour` : i.toString()
)
})() : xAxisData,
axisLabel: {
fontSize: 11,
color: '#999',
interval: 0
},
axisLine: { show: false },
axisTick: { show: false }
},
yAxis: { type: 'value', show: false },
series: [{
data: defaultData,
type: 'line', symbol: 'none',
data: (config.dataType === 'hourly' || config.dataType === 'realtime-enhanced') ? defaultData.map((value, index) => ({
value,
itemStyle: {
color: '#d9d9d9' // 空数据时所有数据点都使用灰色
}
})) : defaultData,
type: 'line',
symbol: 'circle', // 所有图表都显示数据点以支持hover tooltip
symbolSize: 4, // 设置数据点大小
lineStyle: { width: 2, color: '#d9d9d9' },
areaStyle: { color: 'rgba(217,217,217,0.1)' }
}],
@@ -1082,7 +1169,7 @@ function updateChart(config: {
// 实时数据需要补充和限制数据点
let processedData = [...chartData]
if (config.dataType === 'realtime') {
if (config.dataType === 'realtime' || config.dataType === 'realtime-enhanced') {
// 如果数据不足,补充默认数据
while (processedData.length < 5) {
processedData.unshift(0)
@@ -1094,7 +1181,28 @@ function updateChart(config: {
}
// 生成时间轴数据
const xAxisData = Array.from({ length: processedData.length }, (_, i) => i + 1)
let xAxisData: (string | number)[]
if (config.dataType === 'hourly') {
// 小时数据:生成绝对时间标签,基于实际数据长度
const now = new Date()
const currentHour = now.getHours()
// 生成统一格式的时间标签,避免标签宽度不一致
xAxisData = Array.from({ length: processedData.length }, (_, i) => i.toString())
} else if (config.dataType === 'realtime-enhanced') {
// 增强实时数据:生成分钟级时间标签,但只显示关键时间点
xAxisData = Array.from({ length: processedData.length }, (_, i) => {
const minutesAgo = processedData.length - 1 - i
// 只为特定时间点生成标签,其他返回空字符串
if (i === 0 || i === processedData.length - 1 || minutesAgo % 5 === 0) {
return minutesAgo.toString()
}
return '' // 空标签,但数据点仍然存在
})
} else {
// 普通实时数据:使用数字序列
xAxisData = Array.from({ length: processedData.length }, (_, i) => i + 1)
}
// 计算最大值、最小值、最新值
const maxValue = Math.max(...processedData)
@@ -1102,30 +1210,89 @@ function updateChart(config: {
const latestValue = processedData[processedData.length - 1]
chart.setOption({
grid: { left: 0, right: 30, top: 10, bottom: 10 },
xAxis: { type: 'category', show: false, data: xAxisData },
grid: {
left: 5, // 给图表左侧留出少量空间,避免被容器边界遮挡
right: 20, // 减少右侧边距为gap腾出空间
top: 5, // 减少顶部边距
bottom: (config.dataType === 'hourly' || config.dataType === 'realtime-enhanced') ? 25 : 5 // 调整底部边距
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none'
},
formatter: (params: any) => {
if (!params || params.length === 0) return ''
const param = params[0]
const formatValue = config.formatValue || ((val: number) => val.toString())
const value = typeof param.value === 'object' ? param.value.value : param.value
if (config.dataType === 'hourly') {
return `${param.name}Hour: ${formatValue(value)}`
} else if (config.dataType === 'realtime-enhanced') {
// 对于增强实时数据,计算实际的分钟数
const minutesAgo = processedData.length - 1 - param.dataIndex
return `${minutesAgo}Min ago: ${formatValue(value)}`
} else {
// 对于普通实时数据,显示数据点索引和值
return `数据点 ${param.dataIndex + 1}: ${formatValue(value)}`
}
},
backgroundColor: 'rgba(0,0,0,0.8)',
textStyle: { color: '#fff', fontSize: 12 },
borderWidth: 0,
padding: [6, 10],
extraCssText: 'border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);'
},
xAxis: {
type: 'category',
show: config.dataType === 'hourly' || config.dataType === 'realtime-enhanced', // 小时数据和增强实时数据显示x轴
data: xAxisData,
axisLabel: {
fontSize: 11,
color: '#999',
interval: 0, // 显示所有标签(已在数据生成阶段控制标签密度)
rotate: 0,
margin: 8,
overflow: 'truncate',
formatter: undefined // 移除Hour后缀保持所有标签格式一致
},
axisLine: { show: false },
axisTick: { show: false }
},
yAxis: { type: 'value', show: false },
series: [{
data: processedData,
type: 'line', symbol: 'none',
data: processedData.map((value, index) => ({
value,
itemStyle: {
color: index === processedData.length - 1
? config.lineColor // 最新值使用原色
: `${config.lineColor}80` // 其他数据点使用50%透明度的颜色
}
})),
type: 'line',
symbol: 'circle', // 所有图表都显示数据点以支持hover tooltip
symbolSize: 4, // 设置数据点大小
lineStyle: { width: 2, color: config.lineColor },
areaStyle: { color: config.areaColor }
}],
graphic: []
})
// 添加数值标签
addChartLabels({
container: config.chartRef.value,
maxValue,
minValue,
latestValue,
labelColor: config.labelColor,
formatValue: config.formatValue
})
// 只为普通实时数据添加侧边数值标签小时数据和增强实时数据通过hover tooltip显示
if (config.dataType !== 'hourly' && config.dataType !== 'realtime-enhanced') {
addChartLabels({
container: config.chartRef.value,
maxValue,
minValue,
latestValue,
labelColor: config.labelColor,
formatValue: config.formatValue,
dataType: config.dataType
})
}
// 添加时间标签(如果有时间计算器
if (config.timeCalculator && config.data.length > 0) {
// 添加时间标签(只为普通实时数据添加小时数据和增强实时数据通过xAxis显示
if (config.data.length > 0 && config.dataType !== 'hourly' && config.dataType !== 'realtime-enhanced' && config.timeCalculator) {
addTimeLabels({
container: config.chartRef.value,
timeText: config.timeCalculator(config.data)
@@ -1140,7 +1307,8 @@ function addChartLabels(config: {
minValue: number,
latestValue: number,
labelColor: string,
formatValue?: (value: number) => string
formatValue?: (value: number) => string,
dataType?: 'realtime' | 'hourly'
}) {
if (!config.container) return
@@ -1150,18 +1318,60 @@ function addChartLabels(config: {
const formatValue = config.formatValue || ((val: number) => val.toString())
// 小时数据使用改进的定位策略 - 相对于图表容器定位
if (config.dataType === 'hourly') {
// 直接在图表容器内定位,确保图表容器有相对定位
const chartContainer = config.container
// 创建hourly标签的辅助函数
const createHourlyLabel = (value: string, color: string, position: string) => {
const label = document.createElement('div')
label.className = 'chart-label hourly-label'
label.style.cssText = `
position: absolute;
right: -45px; /* 相对于图表容器右侧外部定位,稍微靠近一些 */
${position}
font-size: 12px;
font-weight: bold;
color: ${color};
pointer-events: none;
text-align: left; /* 标签文本左对齐,向右延伸 */
min-width: 40px;
white-space: nowrap;
z-index: 10;
`
label.textContent = value
return label
}
// 清除旧的hourly标签
const existingHourlyLabels = chartContainer.querySelectorAll('.hourly-label')
existingHourlyLabels.forEach((label: any) => label.remove())
// 只显示当前值标签 - 居中显示
chartContainer.appendChild(createHourlyLabel(formatValue(config.latestValue), config.labelColor, 'top: 50%; transform: translateY(-50%);'))
return // 早期返回,不执行下面的标准标签逻辑
}
// 实时数据使用原有逻辑
const rightPosition = '8px'
// 添加右侧数值标注
const maxLabel = document.createElement('div')
maxLabel.className = 'chart-label'
maxLabel.style.cssText = `
position: absolute;
right: 8px;
top: 8px;
right: ${rightPosition};
top: 8px; /* 实时数据固定位置 */
font-size: 12px;
font-weight: bold;
color: #666;
pointer-events: none;
z-index: 10;
text-align: right;
min-width: 40px;
white-space: nowrap;
`
maxLabel.textContent = formatValue(config.maxValue)
@@ -1169,14 +1379,17 @@ function addChartLabels(config: {
latestLabel.className = 'chart-label'
latestLabel.style.cssText = `
position: absolute;
right: 8px;
top: 50%;
right: ${rightPosition};
top: 50%; /* 实时数据居中 */
transform: translateY(-50%);
font-size: 12px;
font-weight: bold;
color: ${config.labelColor};
pointer-events: none;
z-index: 10;
text-align: right;
min-width: 40px;
white-space: nowrap;
`
latestLabel.textContent = formatValue(config.latestValue)
@@ -1184,13 +1397,16 @@ function addChartLabels(config: {
minLabel.className = 'chart-label'
minLabel.style.cssText = `
position: absolute;
right: 8px;
bottom: 8px;
right: ${rightPosition};
bottom: 8px; /* 实时数据固定位置 */
font-size: 12px;
font-weight: bold;
color: #666;
pointer-events: none;
z-index: 10;
text-align: right;
min-width: 40px;
white-space: nowrap;
`
minLabel.textContent = formatValue(config.minValue)
@@ -1237,6 +1453,72 @@ function addTimeLabels(config: {
config.container.appendChild(nowTimeLabel)
}
// 小时数据专用时间标签函数
function addHourlyTimeLabels(config: {
container: any,
data: any[]
}) {
if (!config.container || !config.data || config.data.length === 0) return
// 计算当前小时
const now = new Date()
const currentHour = now.getHours()
// 清除之前的时间标签
const existingTimeLabels = config.container.querySelectorAll('.chart-time-label')
existingTimeLabels.forEach((label: any) => label.remove())
// 添加左侧时间标签0点
const startTimeLabel = document.createElement('div')
startTimeLabel.className = 'chart-time-label'
startTimeLabel.style.cssText = `
position: absolute;
left: 8px;
bottom: -20px;
font-size: 11px;
color: #999;
pointer-events: none;
z-index: 10;
`
startTimeLabel.textContent = '0'
// 添加中间时间标签如果当前小时大于6显示中间时间
if (currentHour > 6) {
const midHour = Math.floor(currentHour / 2)
const midTimeLabel = document.createElement('div')
midTimeLabel.className = 'chart-time-label'
midTimeLabel.style.cssText = `
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -20px;
font-size: 11px;
color: #999;
pointer-events: none;
z-index: 10;
`
midTimeLabel.textContent = midHour.toString()
config.container.appendChild(midTimeLabel)
}
// 添加右侧时间标签当前小时Hour
const endTimeLabel = document.createElement('div')
endTimeLabel.className = 'chart-time-label'
endTimeLabel.style.cssText = `
position: absolute;
right: 8px;
bottom: -20px;
font-size: 11px;
color: #999;
pointer-events: none;
z-index: 10;
`
endTimeLabel.textContent = `${currentHour}Hour`
config.container.appendChild(startTimeLabel)
config.container.appendChild(endTimeLabel)
}
// 通用计算值函数
function calculateMetricValue(config: {
data: any[] | null,
@@ -2027,22 +2309,28 @@ function calculateCctChange() {
}
.card-content {
display: flex;
align-items: center;
align-items: stretch; /* 改为stretch让子元素填满高度 */
position: relative; /* 为绝对定位的小时数据标签提供定位上下文 */
gap: 8px; /* 减少gap进一步缩小间隙 */
height: 100px; /* 设置固定高度参考kpiKeyTarget的做法 */
}
.trend-chart {
flex: 2;
height: 60px;
flex: 3; /* 增加图表区域的flex比例参考kpiKeyTarget */
min-width: 0; /* 允许flex容器收缩参考kpiKeyTarget */
height: 100%; /* 占满父容器高度 */
display: flex;
flex-direction: column;
justify-content: flex-end;
margin-bottom: 25px; /* 底部时间标注留出空间 */
padding-bottom: 25px; /* 底部留出时间轴空间 */
/* 移除margin-bottom和padding-top使用父容器高度控制 */
}
.mini-chart {
width: 100%;
height: 100%;
height: 60px; /* 设置固定图表高度 */
margin-bottom: 0;
display: block;
position: relative;
overflow: visible; /* 确保标签可以超出容器边界 */
}
.card-subtext {
font-size: 12px;
@@ -2055,10 +2343,13 @@ function calculateCctChange() {
}
.metric-info {
flex: 1;
margin-right: 8px;
margin-left: 16px;
text-align: right;
min-width: 90px;
min-width: 90px; /* 保持最小宽度确保数值显示完整 */
flex-shrink: 0; /* 防止收缩,确保数值显示区域稳定 */
display: flex;
flex-direction: column;
justify-content: center; /* 垂直居中对齐 */
/* 移除margin使用父容器的gap控制间隙 */
}
.metric-value {
font-size: 24px;