Files
fe.ems.vue3/src/views/perfManage/kpiOverView/index.vue
2025-09-18 16:27:23 +08:00

1597 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, nextTick, computed, h } from 'vue';
import * as echarts from 'echarts/core';
import {
GridComponent,
TooltipComponent,
TitleComponent,
LegendComponent,
} from 'echarts/components';
import { LineChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { getKPITitle, listKPIData } from '@/api/perfManage/goldTarget';
import useI18n from '@/hooks/useI18n';
import { message } from 'ant-design-vue';
import {
RESULT_CODE_ERROR,
RESULT_CODE_SUCCESS,
} from '@/constants/result-constants';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { OptionsType, WS } from '@/plugins/ws-websocket';
import { generateColorRGBA } from '@/utils/generate-utils';
import { parseSizeFromKbs } from '@/utils/parse-utils';
import { LineOutlined, InfoCircleOutlined, EyeOutlined, EyeInvisibleOutlined,UpOutlined } from '@ant-design/icons-vue';
import { TableColumnType } from 'ant-design-vue';
import useNeInfoStore from '@/store/modules/neinfo';
const { t, currentLocale } = useI18n();
//定义KPI接口
interface KPIBase {
kpiId: string;
title: string;
}
//继承接口
interface KPIColumn extends KPIBase {
dataIndex: string;
key: string;
neType: string;
}
// 在这里定义 ChartDataItem 接口
interface ChartDataItem {
date: string; // 将存储完整的时间字符串包含时分秒
[kpiId: string]: string | number; // 动态指标
}
const tableLoading = ref(false);
const rangeLoading = ref(false);
//网元类型定义
const ALL_NE_TYPES = ['AMF', 'UPF', 'IMS','MME'] as const;
type NeType = (typeof ALL_NE_TYPES)[number];
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
TitleComponent,
CanvasRenderer,
LegendComponent,
]);
// WebSocket连接
const ws = ref<WS | null>(null);
//时间选择
const ranges = ref([
{
label: t('views.perfManage.customTarget.toDay'),
value: [dayjs().startOf('day'), dayjs()],
},
{
label: t('views.perfManage.customTarget.ago1Hour'),
value: [
dayjs().subtract(1, 'hour').startOf('hour'),
dayjs().subtract(1, 'hour').endOf('hour'),
],
},
// {
// label: t('views.perfManage.customTarget.ago3Hour'),
// value: [dayjs().subtract(3, 'hours'), dayjs()],
// },
// {
// label: t('views.perfManage.customTarget.ago6Hour'),
// value: [dayjs().subtract(6, 'hours'), dayjs()],
// },
{
label: t('views.perfManage.customTarget.ago1Day'),
value: [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
},
{
label: t('views.perfManage.customTarget.ago7Day'),
value: [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
},
{
label: t('views.perfManage.customTarget.ago15Day'),
value: [
dayjs().subtract(15, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
},
]);
//日期范围响应式变量
const dateRange = ref<[string, string]>([
dayjs().subtract(1, 'hour').startOf('hour').valueOf().toString(), // 上一小时开始
dayjs().startOf('hour').add(1, 'hour').valueOf().toString(), // 当前小时结束
]);
//实时数据状态
const isRealtime = ref(false);
//图表显示状态
const isChartVisible = ref(true);
//图表数据响应式数组
const chartData = ref<ChartDataItem[]>([]);
//储存Echarts的实例的变量
let chart: echarts.ECharts | null = null;
//observer 变量 监听图表容器大小
let observer: ResizeObserver | null = null;
//日期变化时更新图表数据
const handleDateChange = (
value: [string, string] | [Dayjs, Dayjs],
dateStrings: [string, string]
) => {
if (!dateStrings[0] || !dateStrings[1]) {
console.warn('Invalid date strings:', dateStrings);
return;
}
dateRange.value = [
dayjs(dateStrings[0]).valueOf().toString(),
dayjs(dateStrings[1]).valueOf().toString(),
];
fetchChartData();
};
//切换实时数据
const toggleRealtime = () => {
fnRealTimeSwitch(isRealtime.value);
};
//切换图表显示状态
const toggleChartVisibility = () => {
isChartVisible.value = !isChartVisible.value;
// 当图表重新显示时,需要重新调整大小
if (isChartVisible.value && chart) {
nextTick(() => {
chart?.resize();
});
}
};
// 定义要筛选的指标 ID按网元类型组织
const TARGET_KPI_IDS: Record<NeType, string[]> = {
AMF: ['AMF.02', 'AMF.03'],
UPF: ['UPF.04', 'UPF.05'],
IMS: ['SCSCF.03', 'SCSCF.04', 'SCSCF.05', 'SCSCF.06', 'SCSCF.07', 'SCSCF.08'],
MME: ['MME.A.01','MME.A.02'],
// AMF: ['AMF.02', 'AMF.03', 'AMF.A.07', 'AMF.A.08'],
// SMF: ['SMF.02', 'SMF.03', 'SMF.04', 'SMF.05'],
// UPF: ['UPF.03', 'UPF.04', 'UPF.05', 'UPF.06'],
// MME: ['MME.A.01', 'MME.A.02', 'MME.A.03'],
// IMS: ['SCSCF.01', 'SCSCF.02', 'SCSCF.05', 'SCSCF.06'],
// SMSC: ['SMSC.A.01', 'SMSC.A.02', 'SMSC.A.03'],
};
// 原始KPI标题用于内部计算
const BASE_KPI_TITLE: Record<string, string> = {
'AMF.02': '5G Registration Request',
'AMF.03': '5G Registration Success',
'UPF.04': 'UPF Downlink Throughput',
'UPF.05': 'UPF Uplink Throughput',
'SCSCF.03': 'IMS Registration Success',
'SCSCF.04': 'IMS Registration Request',
'SCSCF.05': 'MO Call Success',
'SCSCF.06': 'MO Call Attempt',
'SCSCF.07': 'MT Call Success',
'SCSCF.08': 'MT Call Attempt',
'MME.A.01':'4G Registration Request',
'MME.A.02':'4G Registration Success',
};
// 计算型KPI定义
interface CalculatedKPI {
id: string;
title: string;
numerator: string; // 分子指标ID
denominator: string; // 分母指标ID
isPercentage: boolean; // 是否为百分比
}
// 计算型KPI配置
const CALCULATED_KPIS: Record<NeType, CalculatedKPI[]> = {
AMF: [
{
id: 'AMF_5G_REG_SUCCESS_RATE',
title: '5G Registration Success Rate',
numerator: 'AMF.03',
denominator: 'AMF.02',
isPercentage: true
}
],
UPF: [
{
id: 'UPF.04',
title: 'UPF Downlink Throughput',
numerator: 'UPF.04',
denominator: '',
isPercentage: false
},
{
id: 'UPF.05',
title: 'UPF Uplink Throughput',
numerator: 'UPF.05',
denominator: '',
isPercentage: false
}
],
IMS: [
{
id: 'IMS_REG_SUCCESS_RATE',
title: 'IMS Registration Success Rate',
numerator: 'SCSCF.03',
denominator: 'SCSCF.04',
isPercentage: true
},
{
id: 'MO_CALL_SUCCESS_RATE',
title: 'MO Call Success Rate',
numerator: 'SCSCF.05',
denominator: 'SCSCF.06',
isPercentage: true
},
{
id: 'MT_CALL_SUCCESS_RATE',
title: 'MT Call Success Rate',
numerator: 'SCSCF.07',
denominator: 'SCSCF.08',
isPercentage: true
}
],
MME: [
{
id: 'MME_4G_REG_SUCCESS_RATE',
title: '4G Registration Success Rate',
numerator: 'MME.A.02',
denominator: 'MME.A.01',
isPercentage: true
}
]
};
// 显示用的KPI标题
const KPI_TITLE: Record<string, string> = {};
// 初始化显示标题
Object.values(CALCULATED_KPIS).flat().forEach(kpi => {
KPI_TITLE[kpi.id] = kpi.title;
});
// UPF吞吐量指标ID列表
const UPF_THROUGHPUT_KPIS = ['UPF.04', 'UPF.05'];
// 判断是否为UPF吞吐量指标
const isUPFThroughputKPI = (kpiId: string): boolean => {
return UPF_THROUGHPUT_KPIS.includes(kpiId);
};
// 格式化UPF吞吐量数据 - 根据数据来源使用不同的时间间隔
const formatUPFThroughput = (value: number, timeInterval: number = 900): number => {
if (value === 0 || value === null || value === undefined) return 0;
// 使用parseSizeFromKbs函数进行格式化转换为Mbps
const [formattedValue] = parseSizeFromKbs(value, timeInterval);
return Number(formattedValue);
};
// 判断当前选中的指标中是否包含UPF吞吐量指标
const hasUPFThroughputKPIs = (): boolean => {
return selectedKPIs.value.some(kpiId => isUPFThroughputKPI(kpiId));
};
// 计算KPI值的函数
const calculateKPIValue = (kpi: CalculatedKPI, rawData: Record<string, any>): number => {
if (!kpi.denominator) {
// 单一指标如UPF吞吐量
const value = Number(rawData[kpi.numerator]) || 0;
return isUPFThroughputKPI(kpi.id) ? formatUPFThroughput(value, 900) : value;
} else {
// 计算型指标(百分比)
const numerator = Number(rawData[kpi.numerator]) || 0;
const denominator = Number(rawData[kpi.denominator]) || 0;
if (denominator === 0) return 0;
const percentage = (numerator / denominator) * 100;
return Number(percentage.toFixed(2));
}
};
// 获取网元类型的所有计算型KPI
const getCalculatedKPIsForNeType = (neType: NeType): CalculatedKPI[] => {
return CALCULATED_KPIS[neType] || [];
};
// 获取Y轴单位
const getYAxisUnit = (): string => {
const allKPIs = Object.values(CALCULATED_KPIS).flat();
const hasUPF = allKPIs.some(kpi => isUPFThroughputKPI(kpi.id));
const hasPercentage = allKPIs.some(kpi => kpi.isPercentage);
if (hasUPF && hasPercentage) {
return ''; // 混合单位时不显示
} else if (hasUPF) {
return '(Mbps)';
} else if (hasPercentage) {
return '(%)';
}
return '';
};
// 添加网元信息 store
const neInfoStore = useNeInfoStore();
// 添加网元列表相关变量
const neList = ref<Record<NeType, { neId: string; neName: string }[]>>({
AMF: [],
UPF: [],
IMS: [],
MME: [],
});
// 添加获取网元列表的函数
const fetchNeList = async () => {
try {
const res = await neInfoStore.fnNelist();
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
// 初始化网元列表
ALL_NE_TYPES.forEach(type => {
neList.value[type] = [];
});
// 过滤并分类网元
res.data.forEach(ne => {
const neType = ne.neType as NeType;
if (ALL_NE_TYPES.includes(neType)) {
neList.value[neType].push({
neId: ne.neId,
neName: ne.neName,
});
}
});
} else {
message.warning({
content: t('common.noData'),
duration: 2,
});
}
} catch (error) {
console.error('Failed to fetch NE list:', error);
message.error(t('common.getInfoFail'));
}
};
// 实时数据开关函数
const fnRealTimeSwitch = (bool: boolean) => {
if (bool) {
if (!chart) {
isRealtime.value = false;
return;
}
if (!ws.value) {
ws.value = new WS();
}
// 只清空图表数据,不影响表格统计数据
chartData.value = [];
const options: OptionsType = {
url: '/ws',
params: {
// 为所有网元创建订阅
subGroupID: ALL_NE_TYPES.flatMap(type =>
neList.value[type].map(ne => `10_${type}_${ne.neId}`)
).join(','),
},
onmessage: wsMessage,
onerror: wsError,
};
ws.value.connect(options);
} else if (ws.value) {
ws.value.close();
ws.value = null;
}
};
// 接收数据后错误回调
const wsError = () => {
message.error(t('common.websocketError'));
};
const wsMessage = (res: Record<string, any>) => {
if (!chart) {
return;
}
const { code, data } = res;
if (code === RESULT_CODE_ERROR || !data?.groupId) {
return;
}
// 解析订阅组ID获取网元类型和ID
const [_, neType, neId] = data.groupId.split('_');
if (!neType || !neId) return;
const kpiEvent = data.data;
if (!kpiEvent) return;
// 构造新的数据点
const newData: ChartDataItem = {
date: kpiEvent.timeGroup?.toString() || Date.now().toString(),
};
// 为每个网元的每个计算型指标添加数据
const calculatedKPIs = getCalculatedKPIsForNeType(neType as NeType);
for (const kpi of calculatedKPIs) {
const key = `${kpi.id}_${neId}`;
if (isUPFThroughputKPI(kpi.id)) {
// UPF吞吐量指标使用1分钟间隔
const rawValue = Number(kpiEvent[kpi.numerator]) || 0;
newData[key] = formatUPFThroughput(rawValue, 60);
} else {
// 其他计算型指标
newData[key] = calculateKPIValue(kpi, kpiEvent);
}
}
// 更新图表数据(只影响图表,不影响表格)
updateChartData(newData);
};
// 添加数据处理函数
const processChartData = (rawData: any[]) => {
const groupedData = new Map<string, any>(); //数据按时间分组
rawData.forEach(item => {
//合并相同时间点的数据
const timeKey = item.timeGroup;
if (!groupedData.has(timeKey)) {
//按时间排序
groupedData.set(timeKey, { timeGroup: timeKey });
}
Object.assign(groupedData.get(timeKey), item);
});
return Array.from(groupedData.values())
.sort((a, b) => Number(a.timeGroup) - Number(b.timeGroup))
.map(item => {
//转换成图表需要的格式
const dataItem: ChartDataItem = { date: item.timeGroup.toString() };
selectedKPIs.value.forEach(kpiId => {
dataItem[kpiId] = Number(item[kpiId]) || 0;
});
return dataItem;
});
};
// 获取图表数据方法
const fetchChartData = async () => {
if (kpiColumns.value.length === 0) {
updateChart();
return;
}
rangeLoading.value = true;
try {
const [startTime, endTime] = dateRange.value;
if (!startTime || !endTime) {
console.warn('Invalid date range:', dateRange.value);
return;
}
// 创建并行请求数组
const requests = [];
for (const neType of ALL_NE_TYPES) {
for (const ne of neList.value[neType]) {
const params = {
neType,
neId: ne.neId,
startTime: String(startTime),
endTime: String(endTime),
sortField: 'timeGroup',
sortOrder: 'asc',
interval: 60 * 15,
kpiIds: TARGET_KPI_IDS[neType].join(','),
};
requests.push(
listKPIData(params).then(res => {
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
return res.data.map(item => ({
...item,
_neId: ne.neId,
neName: `${neType}-${ne.neId}`,
}));
}
return [];
})
);
}
}
// 并行执行所有请求
const results = await Promise.all(requests);
const allData = results.flat();
// 按时间分组处理数据
const groupedData = new Map<string, any>();
allData.forEach(item => {
const timeKey = item.timeGroup;
if (!groupedData.has(timeKey)) {
groupedData.set(timeKey, { timeGroup: timeKey });
}
Object.assign(groupedData.get(timeKey), item);
});
// 转换为图表数据格式
chartData.value = Array.from(groupedData.values())
.sort((a, b) => Number(a.timeGroup) - Number(b.timeGroup))
.map(item => {
const dataItem: ChartDataItem = { date: item.timeGroup.toString() };
// 为每个网元的每个计算型指标添加数据
for (const neType of ALL_NE_TYPES) {
for (const ne of neList.value[neType]) {
const calculatedKPIs = getCalculatedKPIsForNeType(neType);
for (const kpi of calculatedKPIs) {
const key = `${kpi.id}_${ne.neId}`;
if (isUPFThroughputKPI(kpi.id)) {
// UPF吞吐量指标使用15分钟间隔
const rawValue = Number(item[kpi.numerator]) || 0;
dataItem[key] = formatUPFThroughput(rawValue, 900);
} else {
// 其他计算型指标
dataItem[key] = calculateKPIValue(kpi, item);
}
}
}
}
return dataItem;
});
updateChart();
} catch (error) {
console.error('Failed to fetch chart data:', error);
message.error(t('common.getInfoFail'));
} finally {
rangeLoading.value = false;
}
};
// 存储每个指标的临时固定颜色
const kpiColors = new Map<string, string>();
// 更新图表类型
const getSeriesConfig = () => ({
symbol: 'none',
symbolSize: 6,
smooth: 0.6,
showSymbol: true,
});
// 添加一个函数来获取当前主题下的网格线颜色
const getSplitLineColor = () => {
return document.documentElement.getAttribute('data-theme') === 'dark'
? '#333333'
: '#E8E8E8'; // 亮色模式返回 undefined使用默认颜色
};
// 添加主题变化的观察器
const themeObserver = new MutationObserver(() => {
if (chart) {
//清空颜色缓存
kpiColors.clear();
// 获取当前的主题色
const splitLineColor = getSplitLineColor();
// 重新设置完整的图表配置并触发重新渲染
requestAnimationFrame(() => {
// 先生成所有新的颜色并存储
selectedKPIs.value.forEach(kpiId => {
const color = generateColorRGBA();
kpiColors.set(kpiId, color);
});
// 使用存储的颜色更新图表系列
const series = selectedKPIs.value
.map(kpiId => {
const kpi = kpiColumns.value.find(col => col.kpiId === kpiId);
if (!kpi) return null;
return {
name: kpi.title,
type: 'line',
data: chartData.value.map(item => item[kpiId] || 0),
itemStyle: { color: kpiColors.get(kpiId) },
...getSeriesConfig(),
};
})
.filter(Boolean);
const option = {
title: {
text: t('views.perfManage.kpiOverView.kpiChartTitle'),
left: 'center',
textStyle: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
},
xAxis: {
// 保持现有的 xAxis 配置
type: 'category',
boundaryGap: false,
data: chartData.value.map(item =>
dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss')
),
axisLabel: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '{value}',
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
splitNumber: 5,
scale: true,
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
// 根据指标类型显示单位
name: getYAxisUnit(),
nameTextStyle: {
fontSize: 12,
padding: [0, 0, 0, 0],
color: document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
},
legend: {
show: false,
selected: Object.fromEntries(
selectedKPIs.value.map(kpiId => [
kpiColumns.value.find(col => col.kpiId === kpiId)?.title || kpiId,
selectedRows.value.length === 0
? true
: selectedRows.value.includes(kpiId),
])
),
},
tooltip: {
trigger: 'axis',
confine: true, // 限制 tooltip 显示范围
backgroundColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? 'rgba(48, 48, 48, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
borderColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#555'
: '#ddd',
textStyle: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
extraCssText: 'box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);',
},
// 重新设置系列数据
series,
};
// 使用新的配置更新图表
chart!.setOption(option, true);
// 强制重新渲染
chart!.resize();
// 更新统计数据表格(不重新获取数据)
nextTick(() => {
fnInitKpiStatsData();
});
});
}
});
const updateChart = () => {
if (!chart || !kpiColumns.value.length) return;
const series = [];
for (const neType of ALL_NE_TYPES) {
for (const ne of neList.value[neType]) {
const calculatedKPIs = getCalculatedKPIsForNeType(neType);
for (const kpi of calculatedKPIs) {
const key = `${kpi.id}_${ne.neId}`;
const color = kpiColors.get(key) || generateColorRGBA();
kpiColors.set(key, color);
series.push({
name: `${kpi.title}(${ne.neName})`,
type: 'line',
data: chartData.value.map(item => item[key] || 0),
itemStyle: { color },
symbol: 'none',
symbolSize: 6,
smooth: 0.6,
showSymbol: true,
});
}
}
}
const option = {
title: {
text: t('views.perfManage.kpiOverView.kpiChartTitle'),
left: 'center',
// 添加文字颜色配置,根据主题切换
textStyle: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
},
tooltip: {
trigger: 'axis',
confine: true,
backgroundColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? 'rgba(48, 48, 48, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
borderColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#555'
: '#ddd',
textStyle: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
extraCssText: 'box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);',
},
legend: {
data: series.map(s => s.name),
type: 'scroll',
orient: 'horizontal',
top: 25,
textStyle: {
fontSize: 12,
},
selected: Object.fromEntries(
kpiStats.value.map(item => [
item.title,
selectedRows.value.length === 0 ||
selectedRows.value.includes(item.kpiId),
])
),
show: false,
left: 'center',
width: '80%',
height: 50,
padding: [5, 10],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
// 指定x轴类型为类目轴适用于离散的类目数据
type: 'category',
boundaryGap: false,
data: chartData.value.map(item =>
dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss')
),
axisLabel: {
// formatter: '{value}',
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
splitLine: {
show: true,
lineStyle: {
color: getSplitLineColor(),
},
},
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '{value}',
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
// 添加自计算的分割段数
splitNumber: 5,
// 添加自动计算的最小/最大值范围
scale: true,
splitLine: {
show: true,
lineStyle: {
color: getSplitLineColor(),
},
},
// 根据指标类型显示单位
name: getYAxisUnit(),
nameTextStyle: {
fontSize: 12,
padding: [0, 0, 0, 0],
color: document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
},
series: series, //配置数据
};
if (chart) {
requestAnimationFrame(() => {
if (chart) {
// 添加额外的空值检查
chart.setOption(option, true);
chart.resize();
}
});
}
};
//钩子函数
onMounted(async () => {
try {
// 获取网元列表和指标信息(这两个很快)
await fetchNeList();
await fetchSpecificKPI();
await nextTick();
// 立即初始化表格结构,显示标题行(空的统计值)
fnInitKpiStatsData();
// 初始化图表
const container = document.getElementById('chartContainer');
if (container && !chart) {
chart = echarts.init(container);
if (kpiColumns.value.length > 0) {
await fetchChartData();
updateChart();
} else {
console.warn('No KPI columns available after fetching');
}
// 创建 ResizeObserver 实例监听图表容器大小变化
observer = new ResizeObserver(() => {
if (chart) {
chart.resize();
}
});
// 监听元素大小变化
observer.observe(container);
} else if (chart) {
console.warn('Chart already initialized, skipping initialization');
} else {
console.error('Chart container not found');
}
// 添加主题观察器
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
// 异步获取统计数据(这个慢,放在最后)
nextTick(() => {
fnGetKpiStatsData();
});
} catch (error) {
console.error('Failed to initialize:', error);
message.error(t('common.initFail'));
}
});
// 存储指标列信
const kpiColumns = ref<KPIColumn[]>([]);
// 添加选中指标的的状态
const selectedKPIs = ref<string[]>(Object.values(CALCULATED_KPIS).flat().map(kpi => kpi.id));
// 获取网元指标
const fetchSpecificKPI = async () => {
const language =
currentLocale.value.split('_')[0] === 'zh'
? 'cn'
: currentLocale.value.split('_')[0];
try {
let allKPIs: KPIColumn[] = [];
// 为每个网元类型创建计算型KPI列
for (const neType of ALL_NE_TYPES) {
const calculatedKPIs = getCalculatedKPIsForNeType(neType);
for (const kpi of calculatedKPIs) {
allKPIs.push({
title: kpi.title,
dataIndex: kpi.id,
key: kpi.id,
kpiId: kpi.id,
neType: neType,
});
}
}
kpiColumns.value = allKPIs;
// 使用计算型指标
selectedKPIs.value = Object.values(CALCULATED_KPIS).flat().map(kpi => kpi.id);
if (kpiColumns.value.length === 0) {
console.warn('No KPIs found');
} else {
// console.log(`Found ${kpiColumns.value.length} total KPIs`);
}
return kpiColumns.value;
} catch (error) {
console.error('Failed to fetch KPI titles:', error);
message.error(t('common.getInfoFail'));
return [];
}
};
// onUnmounted 钩子
onUnmounted(() => {
if (ws.value && ws.value.state() === WebSocket.OPEN) {
ws.value.close();
}
if (observer) {
observer.disconnect();
observer = null;
}
if (chart) {
chart.dispose();
chart = null;
}
// 断开主题观察器
themeObserver.disconnect();
});
// 实时数据更新图表数据方法
const updateChartData = (newData: ChartDataItem) => {
if (!chart) {
return;
}
chartData.value.push(newData);
if (chartData.value.length > 100) {
//100改为50
chartData.value.shift(); //大于100条时删除最早的数据
}
//使用try-catch包裹图表更新逻辑
try {
requestAnimationFrame(() => {
if (!chart) return;
const option = {
xAxis: {
data: chartData.value.map(item =>
dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss')
),
},
series: ALL_NE_TYPES.flatMap(type =>
neList.value[type].map(ne =>
getCalculatedKPIsForNeType(type).map(kpi => {
const key = `${kpi.id}_${ne.neId}`;
return {
type: 'line',
data: chartData.value.map(item => item[key] || 0),
name: `${kpi.title}(${ne.neName})`,
};
})
)
).flat(),
};
chart.setOption(option);
});
// 实时数据只更新图表,不影响统计表格数据
} catch (error) {
console.error('Failed to update chart:', error);
}
};
// 添加一个接口定义指标统计数据的类型
interface KPIStats {
kpiId: string;
title: string;
last1Day: number | string;
last7Days: number | string;
last30Days: number | string;
}
// 添加计算属性,用于计算每个指标的统计数据
// 将 kpiStats 从计算属性改为响应式引用
const kpiStats = ref<KPIStats[]>([]);
/**初始化关键指标统计表格数据 */
function fnInitKpiStatsData() {
// 先初始化表格,显示指标×网元的列表和默认值
kpiStats.value = [];
for (const neType of ALL_NE_TYPES) {
for (const ne of neList.value[neType]) {
const calculatedKPIs = getCalculatedKPIsForNeType(neType);
for (const kpi of calculatedKPIs) {
kpiStats.value.push({
kpiId: `${kpi.id}_${ne.neId}`,
title: `${kpi.title}(${ne.neName})`,
last1Day: '', // 空白显示loading状态表示正在获取数据
last7Days: '',
last30Days: '',
});
}
}
}
}
/**获取关键指标近期统计数据 */
async function fnGetKpiStatsData() {
if (!kpiColumns.value.length) {
return;
}
tableLoading.value = true;
const now = new Date();
const now_ms = now.getTime();
const day1_start = now_ms - (1 * 24 * 60 * 60 * 1000); // 1天前
const day7_start = now_ms - (7 * 24 * 60 * 60 * 1000); // 7天前
const day30_start = now_ms - (30 * 24 * 60 * 60 * 1000); // 30天前
try {
// 创建所有网元的并发请求
const requests = [];
for (const neType of ALL_NE_TYPES) {
for (const ne of neList.value[neType]) {
const params = {
neType,
neId: ne.neId,
startTime: String(day30_start),
endTime: String(now_ms),
sortField: 'timeGroup',
sortOrder: 'asc',
interval: 60 * 15,
kpiIds: TARGET_KPI_IDS[neType].join(','),
};
// 添加到并发请求数组
requests.push(
listKPIData(params).then(res => ({
neType,
neId: ne.neId,
success: res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data),
data: res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data) ? res.data : []
})).catch(error => {
console.error(`获取网元${ne.neId}统计数据失败:`, error);
return {
neType,
neId: ne.neId,
success: false,
data: []
};
})
);
}
}
// 并发执行所有请求
const results = await Promise.all(requests);
// 处理所有结果
results.forEach(result => {
const { neType, neId, success, data } = result;
if (success && data.length > 0) {
// 为每个计算型指标计算统计值
const calculatedKPIs = getCalculatedKPIsForNeType(neType as NeType);
for (const kpi of calculatedKPIs) {
const key = `${kpi.id}_${neId}`;
// 根据时间范围筛选有效数据(非零数据)
const data1Day = data.filter((item: any) => {
const itemTime = Number(item.timeGroup);
if (itemTime < day1_start) return false;
// 检查相关指标是否有有效数值
if (kpi.isPercentage) {
// 百分比指标分母必须大于0
const denominator = Number(item[kpi.denominator]) || 0;
return denominator > 0;
} else {
// 其他指标数值必须大于0
const value = Number(item[kpi.numerator]) || 0;
return value > 0;
}
});
const data7Days = data.filter((item: any) => {
const itemTime = Number(item.timeGroup);
if (itemTime < day7_start) return false;
// 检查相关指标是否有有效数值
if (kpi.isPercentage) {
// 百分比指标分母必须大于0
const denominator = Number(item[kpi.denominator]) || 0;
return denominator > 0;
} else {
// 其他指标数值必须大于0
const value = Number(item[kpi.numerator]) || 0;
return value > 0;
}
});
const data30Days = data.filter((item: any) => {
const itemTime = Number(item.timeGroup);
if (itemTime < day30_start) return false;
// 检查相关指标是否有有效数值
if (kpi.isPercentage) {
// 百分比指标分母必须大于0
const denominator = Number(item[kpi.denominator]) || 0;
return denominator > 0;
} else {
// 其他指标数值必须大于0
const value = Number(item[kpi.numerator]) || 0;
return value > 0;
}
});
// 计算统计值(传入的数据已经是有效数据)
const calculateValue = (dataArray: any[]) => {
if (dataArray.length === 0) return 0;
if (isUPFThroughputKPI(kpi.id)) {
// UPF吞吐量指标先格式化每个数据点然后计算平均值
const values = dataArray.map((item: any) => Number(item[kpi.numerator]) || 0);
const formattedValues = values.map(val => formatUPFThroughput(val, 900));
const average = formattedValues.reduce((sum, val) => sum + val, 0) / formattedValues.length;
return Number(average.toFixed(2));
} else if (kpi.isPercentage) {
// 百分比指标计算平均成功率数据已经过滤分母都大于0
const percentages = dataArray.map((item: any) => {
const numerator = Number(item[kpi.numerator]) || 0;
const denominator = Number(item[kpi.denominator]) || 0;
return (numerator / denominator) * 100;
});
const average = percentages.reduce((sum, val) => sum + val, 0) / percentages.length;
return Number(average.toFixed(2));
} else {
// 其他指标:累计值(数据已经过滤,都是非零值)
const values = dataArray.map((item: any) => Number(item[kpi.numerator]) || 0);
const sum = values.reduce((sum, val) => sum + val, 0);
return Number(sum.toFixed(2));
}
};
// 更新对应的统计数据
const statsIndex = kpiStats.value.findIndex((item: any) => item.kpiId === key);
if (statsIndex !== -1) {
kpiStats.value[statsIndex].last1Day = calculateValue(data1Day);
kpiStats.value[statsIndex].last7Days = calculateValue(data7Days);
kpiStats.value[statsIndex].last30Days = calculateValue(data30Days);
}
}
} else {
// 如果获取失败,保持空白显示
const calculatedKPIs = getCalculatedKPIsForNeType(neType as NeType);
for (const kpi of calculatedKPIs) {
const key = `${kpi.id}_${neId}`;
const statsIndex = kpiStats.value.findIndex((item: any) => item.kpiId === key);
if (statsIndex !== -1) {
kpiStats.value[statsIndex].last1Day = '';
kpiStats.value[statsIndex].last7Days = '';
kpiStats.value[statsIndex].last30Days = '';
}
}
}
});
// 更新图表显示
updateChartLegendSelect();
} catch (error) {
console.error('获取统计数据失败:', error);
// 如果获取失败,保持空白显示
for (const statsItem of kpiStats.value) {
statsItem.last1Day = '';
statsItem.last7Days = '';
statsItem.last30Days = '';
}
} finally {
tableLoading.value = false;
}
}
// 添加一个计算函数来更新统计数据(保持向后兼容)
const updateKpiStats = () => {
if (!kpiColumns.value.length) {
kpiStats.value = [];
return;
}
// 只初始化统计数据表格结构,不获取数据
fnInitKpiStatsData();
};
// 添加表列定义
const statsColumns: TableColumnType<KPIStats>[] = [
{
title: '',
key: 'icon',
width: 50,
customRender: ({ record }: { record: KPIStats }) => {
// 获取当前主题下的颜色
// 直接使用 kpiColors 中存储的颜色
const color = kpiColors.get(record.kpiId);
return h(LineOutlined, {
style: {
color: color || '#000', // 使用与折线图相同的颜色
fontSize: '30px', // 增大图标尺寸到30px
fontWeight: 'bold', // 加粗
},
});
},
},
{
title: t('views.perfManage.kpiOverView.kpiName'),
dataIndex: 'title',
key: 'title',
width: '40%',
},
{
title: () => h('div', { style: { display: 'flex', alignItems: 'right', gap: '4px' } }, [
h('span', t('views.perfManage.customTarget.ago1')),
h(InfoCircleOutlined, {
title: t('views.perfManage.kpiOverView.tips'),
}),
]),
dataIndex: 'last1Day',
key: 'last1Day',
width: '20%',
sortDirections: ['ascend', 'descend'],
customRender: ({ record }: { record: KPIStats }) => {
const value = record.last1Day;
// 如果是空值,直接显示空白
if (value === '' || value === null || value === undefined) {
return '';
}
// 根据指标类型显示不同单位
const kpiId = record.kpiId.split('_')[0];
const isUPFThroughput = isUPFThroughputKPI(kpiId);
const isPercentage = Object.values(CALCULATED_KPIS).flat().find(kpi => kpi.id === kpiId)?.isPercentage;
if (isUPFThroughput) {
return `${value} Mbps`;
} else if (isPercentage) {
return `${value}%`;
} else {
return `${value}`;
}
},
},
{
title: t('views.perfManage.customTarget.ago7'),
dataIndex: 'last7Days',
key: 'last7Days',
width: '20%',
sortDirections: ['ascend', 'descend'],
customRender: ({ record }: { record: KPIStats }) => {
const value = record.last7Days;
// 如果是空值,直接显示空白
if (value === '' || value === null || value === undefined) {
return '';
}
// 根据指标类型显示不同单位
const kpiId = record.kpiId.split('_')[0];
const isUPFThroughput = isUPFThroughputKPI(kpiId);
const isPercentage = Object.values(CALCULATED_KPIS).flat().find(kpi => kpi.id === kpiId)?.isPercentage;
if (isUPFThroughput) {
return `${value} Mbps`;
} else if (isPercentage) {
return `${value}%`;
} else {
return `${value}`;
}
},
},
{
title: t('views.perfManage.customTarget.ago30'),
dataIndex: 'last30Days',
key: 'last30Days',
width: '20%',
sortDirections: ['ascend', 'descend'],
customRender: ({ record }: { record: KPIStats }) => {
const value = record.last30Days;
// 如果是空值,直接显示空白
if (value === '' || value === null || value === undefined) {
return '';
}
// 根据指标类型显示不同单位
const kpiId = record.kpiId.split('_')[0];
const isUPFThroughput = isUPFThroughputKPI(kpiId);
const isPercentage = Object.values(CALCULATED_KPIS).flat().find(kpi => kpi.id === kpiId)?.isPercentage;
if (isUPFThroughput) {
return `${value} Mbps`;
} else if (isPercentage) {
return `${value}%`;
} else {
return `${value}`;
}
},
},
];
// 将 selectedRow 改为 selectedRows 数组
const selectedRows = ref<string[]>([]);
// 修改处理行点击的方法
const handleRowClick = (record: KPIStats) => {
const index = selectedRows.value.indexOf(record.kpiId);
if (index > -1) {
// 如果已经选中,则取消选中
selectedRows.value.splice(index, 1);
} else {
// 添加新的选中项
selectedRows.value.push(record.kpiId);
}
// 更新图表显示
updateChartLegendSelect();
};
// 修改更新图表图例选中态的方法
const updateChartLegendSelect = () => {
if (!chart) return;
const legendSelected = Object.fromEntries(
kpiStats.value.map(item => [
item.title,
selectedRows.value.length === 0 ||
selectedRows.value.includes(item.kpiId),
])
);
chart.setOption({
legend: {
selected: legendSelected,
},
});
};
// 修改表格行的自定义配置
const tableRowConfig = computed(() => {
return (record: KPIStats) => ({
onClick: () => handleRowClick(record),
class: selectedRows.value.includes(record.kpiId) ? 'selected-row' : '',
});
});
</script>
<template>
<div class="kpi-overview">
<div class="controls">
<a-range-picker
v-model:value="dateRange"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:value-format="'x'"
:disabled="isRealtime"
:presets="ranges"
:loading="rangeLoading"
@change="handleDateChange"
/>
<a-form-item
:label="
isRealtime
? t('views.dashboard.cdr.realTimeDataStop')
: t('views.dashboard.cdr.realTimeDataStart')
"
>
<a-switch v-model:checked="isRealtime" @change="toggleRealtime" />
</a-form-item>
<!-- <a-button-->
<!-- type="primary"-->
<!-- @click="toggleChartVisibility"-->
<!-- class="chart-toggle-btn"-->
<!-- :title="isChartVisible ? 'hide' : 'show'"-->
<!-- >-->
<!-- <DownOutlined v-if="isChartVisible" />-->
<!-- <UpOutlined v-else />-->
<!-- <span style="margin-left: 4px;">{{ isChartVisible ? 'Foldable the chart' : 'Expand the chart' }}</span>-->
<!-- </a-button>-->
</div>
<div class="chart-wrapper">
<a-button
type="link"
@click="toggleChartVisibility"
class="chart-toggle-btn"
:title="isChartVisible ? 'hide' : 'show'"
>
<DownOutlined v-if="isChartVisible" />
<RightOutlined v-else />
<span style="margin-left: 4px;">{{ isChartVisible ? 'Expand Graph' : 'Collapse Graph' }}</span>
</a-button>
<div
id="chartContainer"
class="chart-container"
:style="{ display: isChartVisible ? 'block' : 'none' }"
></div>
<div class="table-container">
<a-table
:columns="statsColumns"
:data-source="kpiStats"
:pagination="false"
:scroll="{ y: 250 }"
size="small"
:loading="tableLoading"
:custom-row="tableRowConfig"
>
</a-table>
</div>
</div>
</div>
</template>
<style scoped>
/* 基础布局样式 */
.kpi-overview {
padding: 20px;
}
.controls {
display: flex;
gap: 16px;
margin-bottom: 20px;
align-items: center;
}
.chart-wrapper {
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
[data-theme='light'] .chart-wrapper {
background: #fff;
}
[data-theme='dark'] .chart-wrapper {
background: #1f1f1f;
}
.chart-container {
height: 450px;
width: 100%;
}
.table-container {
height: 282px;
width: 100%;
margin-top: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 图表隐藏时,表格容器的高度调整 */
.chart-wrapper:has(.chart-container[style*="display: none"]) .table-container {
height: 500px;
margin-top: 0;
}
/* 表格布局相关样式 */
:deep(.ant-table-wrapper),
:deep(.ant-table),
:deep(.ant-table-container),
:deep(.ant-table-content) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.ant-table-body) {
flex: 1;
overflow-y: auto !important;
min-height: 0;
}
/* 表格行和表头样式 */
:deep(.ant-table-thead tr th),
:deep(.ant-table-tbody tr td) {
padding: 8px;
height: 40px;
}
/* 组件统一样式 */
:deep(.ant-form-item),
:deep(.ant-picker),
:deep(.ant-btn) {
height: 32px;
}
:deep(.ant-form-item) {
margin-bottom: 0;
display: flex;
align-items: center;
}
/* 美化滚动条样式 */
:deep(.ant-table-body::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
:deep(.ant-table-body::-webkit-scrollbar-thumb) {
background: #ccc;
border-radius: 3px;
}
:deep(.ant-table-body::-webkit-scrollbar-track) {
background: #f1f1f1;
border-radius: 3px;
}
[data-theme='dark'] :deep(.ant-table-body::-webkit-scrollbar-thumb) {
background: #4c4c4c;
}
[data-theme='dark'] :deep(.ant-table-body::-webkit-scrollbar-track) {
background: #2a2a2a;
}
/* 选中行样式 */
:deep(.selected-row) {
background-color: rgba(24, 144, 255, 0.1) !important;
}
[data-theme='dark'] :deep(.selected-row) {
background-color: rgba(24, 144, 255, 0.2) !important;
}
/* 选中行悬停效果 */
:deep(.selected-row:hover) {
background-color: rgba(24, 144, 255, 0.2) !important;
}
:deep(.ant-checkbox-wrapper) {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 图表切换按钮样式 */
.chart-toggle-btn {
margin-left: 8px;
}
</style>