1125 lines
30 KiB
Vue
1125 lines
30 KiB
Vue
<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 { LineOutlined } from '@ant-design/icons-vue';
|
|
import { TableColumnType } from 'ant-design-vue';
|
|
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'] as const;
|
|
type NeType = (typeof ALL_NE_TYPES)[number];
|
|
|
|
echarts.use([
|
|
LineChart,
|
|
GridComponent,
|
|
TooltipComponent,
|
|
TitleComponent,
|
|
CanvasRenderer,
|
|
LegendComponent,
|
|
]);
|
|
// WebSocket连接
|
|
const ws = ref<WS | null>(null);
|
|
|
|
//时间选择
|
|
// let ranges = ref<Record<string, [Dayjs, Dayjs]>>({
|
|
// [t('views.perfManage.customTarget.sixHoursAgo')]: [
|
|
// dayjs().subtract(6, 'hours'),
|
|
// dayjs(),
|
|
// ],
|
|
// [t('views.perfManage.customTarget.threeHoursAgo')]: [
|
|
// dayjs().subtract(3, 'hours'),
|
|
// dayjs(),
|
|
// ],
|
|
// [t('views.monitor.monitor.today')]: [dayjs().startOf('day'), dayjs()],
|
|
// });
|
|
const ranges = ref([
|
|
{
|
|
label: t('views.perfManage.customTarget.sixHoursAgo'),
|
|
value: [dayjs().subtract(6, 'hours'), dayjs()],
|
|
},
|
|
{
|
|
label: t('views.perfManage.customTarget.threeHoursAgo'),
|
|
value: [dayjs().subtract(3, 'hours'), dayjs()],
|
|
},
|
|
{
|
|
label: t('views.monitor.monitor.today'),
|
|
value: [dayjs().startOf('day'), dayjs()],
|
|
},
|
|
]);
|
|
//日期范围响应式变量
|
|
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 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);
|
|
};
|
|
|
|
// 定义要筛选的指标 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'],
|
|
|
|
// 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'],
|
|
};
|
|
|
|
// 实时数据开关函数
|
|
const fnRealTimeSwitch = (bool: boolean) => {
|
|
if (bool) {
|
|
if (!chart) {
|
|
isRealtime.value = false;
|
|
return;
|
|
}
|
|
if (!ws.value) {
|
|
ws.value = new WS();
|
|
}
|
|
chartData.value = [];
|
|
|
|
tableLoading.value = true;
|
|
const options: OptionsType = {
|
|
url: '/ws',
|
|
params: {
|
|
subGroupID: ALL_NE_TYPES.map(type => `10_${type}_001`).join(','),
|
|
},
|
|
onmessage: wsMessage,
|
|
onerror: wsError,
|
|
};
|
|
ws.value.connect(options);
|
|
} else if (ws.value) {
|
|
ws.value.close(); //断开链接
|
|
ws.value = null; //清空链接
|
|
tableLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// 接收数据后错误回调
|
|
const wsError = () => {
|
|
message.error(t('common.websocketError'));
|
|
};
|
|
|
|
const handleWebSocketMessage = (kpiEvent: any) => {
|
|
if (!kpiEvent) return;
|
|
|
|
// 构造新的数据点
|
|
const newData: ChartDataItem = {
|
|
date: kpiEvent.timeGroup?.toString() || Date.now().toString(),
|
|
};
|
|
|
|
// 添加已选中的指标的数据
|
|
selectedKPIs.value.forEach(kpiId => {
|
|
newData[kpiId] = Number(kpiEvent[kpiId]) || 0;
|
|
});
|
|
|
|
// 更新数据
|
|
updateChartData(newData);
|
|
if (tableLoading.value) {
|
|
tableLoading.value = false;
|
|
}
|
|
};
|
|
//成功回调
|
|
const wsMessage = (res: Record<string, any>) => {
|
|
if (!chart) {
|
|
return;
|
|
}
|
|
const { code, data } = res;
|
|
if (code === RESULT_CODE_ERROR || !data?.groupId) {
|
|
tableLoading.value = false;
|
|
return;
|
|
}
|
|
handleWebSocketMessage(data.data);
|
|
};
|
|
// 添加数据处理函数
|
|
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;
|
|
}
|
|
tableLoading.value = true;
|
|
rangeLoading.value = true;
|
|
try {
|
|
const [startTime, endTime] = dateRange.value;
|
|
if (!startTime || !endTime) {
|
|
console.warn('Invalid date range:', dateRange.value);
|
|
return;
|
|
}
|
|
|
|
// 创建并行请求数组
|
|
const requests = ALL_NE_TYPES.map(async neType => {
|
|
const params = {
|
|
neType,
|
|
neId: '001',
|
|
startTime: String(startTime),
|
|
endTime: String(endTime),
|
|
sortField: 'timeGroup',
|
|
sortOrder: 'asc',
|
|
interval: 60 * 15,
|
|
kpiIds: TARGET_KPI_IDS[neType].join(','),
|
|
};
|
|
|
|
try {
|
|
const res = await listKPIData(params);
|
|
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
|
|
return res.data;
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
console.error(`Failed to fetch data for ${neType}:`, error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
// 并行执行所有请求
|
|
const results = await Promise.all(requests);
|
|
const allData = results.flat();
|
|
|
|
chartData.value = processChartData(allData);
|
|
updateChart();
|
|
updateKpiStats();
|
|
} catch (error) {
|
|
console.error('Failed to fetch chart data:', error);
|
|
message.error(t('common.getInfoFail'));
|
|
} finally {
|
|
tableLoading.value = false;
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
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(() => {
|
|
updateKpiStats();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
const updateChart = () => {
|
|
if (!chart || !kpiColumns.value.length) return;
|
|
//获取图表配置
|
|
const commonConfig = getSeriesConfig();
|
|
//构建数据系列
|
|
const series = selectedKPIs.value
|
|
.map(kpiId => {
|
|
const kpi = kpiColumns.value.find(col => col.kpiId === kpiId);
|
|
if (!kpi) return null;
|
|
//为每个KPI分配临时的固定颜色
|
|
const color = kpiColors.get(kpiId) || generateColorRGBA();
|
|
kpiColors.set(kpiId, color);
|
|
|
|
return {
|
|
name: kpi.title,
|
|
type: 'line',
|
|
data: chartData.value.map(item => item[kpiId] || 0),
|
|
itemStyle: { color },
|
|
...commonConfig,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
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, // 限制 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);',
|
|
},
|
|
legend: {
|
|
data: selectedKPIs.value.map(
|
|
kpiId =>
|
|
kpiColumns.value.find(col => col.kpiId === kpiId)?.title || kpiId
|
|
),
|
|
type: 'scroll',
|
|
orient: 'horizontal',
|
|
top: 25,
|
|
textStyle: {
|
|
fontSize: 12,
|
|
},
|
|
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),
|
|
])
|
|
),
|
|
show: false,
|
|
left: 'center',
|
|
width: '80%',
|
|
height: 50,
|
|
padding: [5, 10],
|
|
},
|
|
grid: {
|
|
//网格配置
|
|
left: '6%',
|
|
right: '6%',
|
|
bottom: '3%',
|
|
containLabel: true,
|
|
},
|
|
xAxis: {
|
|
// 指定x轴类型为类目轴,适用于离散的类目数据
|
|
type: 'category',
|
|
axisLabel: {
|
|
formatter: '{value}',
|
|
color:
|
|
document.documentElement.getAttribute('data-theme') === 'dark'
|
|
? '#CACADA'
|
|
: '#333',
|
|
},
|
|
splitLine: {
|
|
show: true,
|
|
lineStyle: {
|
|
color: getSplitLineColor(),
|
|
},
|
|
},
|
|
//控制坐标轴两边留白
|
|
// 当为折线图时(isLine为true)时不留白,柱状图时留白
|
|
// 这样可以让折线图从原点开始,柱状图有合适的间距
|
|
boundaryGap: false,
|
|
// 设置x轴的数据
|
|
// 将时间戳转换为格式化的时间字符串
|
|
data: chartData.value.map(item =>
|
|
dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss')
|
|
),
|
|
//设置坐标轴刻度标签的样式
|
|
// axisLabel: {
|
|
// interval: function(index: number, value: string) {
|
|
// const currentTime = dayjs(value);
|
|
// const minutes = currentTime.minute();
|
|
// const seconds = currentTime.second();
|
|
//
|
|
// // 始终显示小时的起始和结束时间点
|
|
// if ((minutes === 0 && seconds === 0) ||
|
|
// (minutes === 59 && seconds === 59)) {
|
|
// return true;
|
|
// }
|
|
//
|
|
// // 对于中间的时间点,使用 auto 的逻辑
|
|
// if (index % Math.ceil(chartData.value.length / 6) === 0) {
|
|
// return true;
|
|
// }
|
|
//
|
|
// return false;
|
|
// },
|
|
// rotate: 0,
|
|
// align: 'center',
|
|
// hideOverlap: true
|
|
// }
|
|
},
|
|
yAxis: {
|
|
// y轴配置
|
|
type: 'value',
|
|
axisLabel: {
|
|
formatter: '{value}',
|
|
color:
|
|
document.documentElement.getAttribute('data-theme') === 'dark'
|
|
? '#CACADA'
|
|
: '#333',
|
|
},
|
|
// 添加自计算的分割段数
|
|
splitNumber: 5,
|
|
// 添加自动计算的最小/最大值范围
|
|
scale: true,
|
|
splitLine: {
|
|
show: true,
|
|
lineStyle: {
|
|
color: getSplitLineColor(),
|
|
},
|
|
},
|
|
},
|
|
series: series, //配置数据
|
|
};
|
|
if (chart) {
|
|
requestAnimationFrame(() => {
|
|
chart!.setOption(option, true); //使用新的配置更新图表
|
|
chart!.resize(); //调整图表大小适应容器
|
|
});
|
|
}
|
|
// 如果已经有 observer,先断开连接
|
|
if (observer) {
|
|
observer.disconnect();
|
|
}
|
|
|
|
// 创建新的 ResizeObserver
|
|
observer = new ResizeObserver(() => {
|
|
if (chart) {
|
|
chart.resize();
|
|
}
|
|
});
|
|
|
|
// 观察图表容器
|
|
const container = document.getElementById('chartContainer');
|
|
if (container) {
|
|
observer.observe(container);
|
|
}
|
|
};
|
|
|
|
//钩子函数
|
|
onMounted(async () => {
|
|
try {
|
|
// 获取所有网元的指标
|
|
await fetchSpecificKPI();
|
|
await nextTick();
|
|
|
|
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');
|
|
}
|
|
} 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'],
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize:', error);
|
|
message.error(t('common.initFail'));
|
|
}
|
|
});
|
|
|
|
// 存储指标列信
|
|
const kpiColumns = ref<KPIColumn[]>([]);
|
|
// 添加选中指标的的状态
|
|
const selectedKPIs = ref<string[]>(Object.values(TARGET_KPI_IDS).flat());
|
|
|
|
// 获取网元指标
|
|
const fetchSpecificKPI = async () => {
|
|
const language =
|
|
currentLocale.value.split('_')[0] === 'zh'
|
|
? 'cn'
|
|
: currentLocale.value.split('_')[0];
|
|
|
|
try {
|
|
let allKPIs: KPIColumn[] = [];
|
|
|
|
// 获取所有网元的指标
|
|
for (const neType of ALL_NE_TYPES) {
|
|
const res = await getKPITitle(neType.toUpperCase());
|
|
|
|
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
|
|
const formattedKPIs = res.data.map(item => ({
|
|
title: item[`${language}Title`],
|
|
dataIndex: item.kpiId,
|
|
key: item.kpiId,
|
|
kpiId: item.kpiId,
|
|
neType: neType,
|
|
}));
|
|
|
|
allKPIs = [...allKPIs, ...formattedKPIs];
|
|
}
|
|
}
|
|
|
|
kpiColumns.value = allKPIs;
|
|
// 直接使用重要指标
|
|
selectedKPIs.value = Object.values(TARGET_KPI_IDS).flat();
|
|
|
|
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: selectedKPIs.value.map(kpiId => {
|
|
const kpi = kpiColumns.value.find(col => col.kpiId === kpiId);
|
|
return {
|
|
type: 'line',
|
|
data: chartData.value.map(item => item[kpiId] || 0),
|
|
name: kpi?.title || kpiId,
|
|
};
|
|
}),
|
|
};
|
|
chart.setOption(option);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update chart:', error);
|
|
}
|
|
};
|
|
|
|
// 添加一个接口定义指标统计数据的类型
|
|
interface KPIStats {
|
|
kpiId: string;
|
|
title: string;
|
|
max: number;
|
|
min: number;
|
|
avg: number;
|
|
total: number;
|
|
}
|
|
|
|
// 添加计算属性,用于计算每个指标的最大值和最小值
|
|
// 将 kpiStats 从计算属性改为响应式引用
|
|
const kpiStats = ref<KPIStats[]>([]);
|
|
|
|
// 添加一个计算函数来更新统计数据
|
|
const updateKpiStats = () => {
|
|
if (!chartData.value.length || !kpiColumns.value.length) {
|
|
kpiStats.value = selectedKPIs.value.map(kpiId => {
|
|
// 找到对应的KPI标题
|
|
let title = kpiId;
|
|
const kpi = kpiColumns.value.find(col => col.kpiId === kpiId);
|
|
if (kpi) {
|
|
title = kpi.title;
|
|
}
|
|
return {
|
|
kpiId: kpiId,
|
|
title: title,
|
|
max: 0,
|
|
min: 0,
|
|
avg: 0,
|
|
total: 0,
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
kpiStats.value = selectedKPIs.value
|
|
.map(kpiId => {
|
|
// 找到对应的KPI标题
|
|
const kpi = kpiColumns.value.find(col => col.kpiId === kpiId);
|
|
if (!kpi) return null;
|
|
|
|
// 获取该指标的所有数值
|
|
const values = chartData.value.map(item => Number(item[kpiId]) || 0);
|
|
|
|
// 计算总值
|
|
const total = Number(
|
|
values.reduce((sum, val) => sum + val, 0).toFixed(2)
|
|
);
|
|
|
|
// 计算平均值
|
|
const avg =
|
|
values.length > 0 ? Number((total / values.length).toFixed(2)) : 0;
|
|
|
|
return {
|
|
kpiId: kpiId,
|
|
title: kpi.title,
|
|
max: Math.max(...values),
|
|
min: Math.min(...values),
|
|
avg: avg,
|
|
total: total,
|
|
};
|
|
})
|
|
.filter((item): item is KPIStats => item !== null);
|
|
};
|
|
|
|
// 添加表列定义
|
|
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: '50%',
|
|
},
|
|
{
|
|
title: t('views.perfManage.kpiOverView.totalValue'),
|
|
dataIndex: 'total',
|
|
key: 'total',
|
|
width: '12%',
|
|
sortDirections: ['ascend', 'descend'],
|
|
},
|
|
{
|
|
title: t('views.perfManage.kpiOverView.avgValue'),
|
|
dataIndex: 'avg',
|
|
key: 'avg',
|
|
width: '12%',
|
|
sortDirections: ['ascend', 'descend'],
|
|
},
|
|
{
|
|
title: t('views.perfManage.kpiOverView.maxValue'),
|
|
dataIndex: 'max',
|
|
key: 'max',
|
|
width: '12%',
|
|
sortDirections: ['ascend', 'descend'],
|
|
},
|
|
{
|
|
title: t('views.perfManage.kpiOverView.minValue'),
|
|
dataIndex: 'min',
|
|
key: 'min',
|
|
width: '12%',
|
|
sortDirections: ['ascend', 'descend'],
|
|
},
|
|
];
|
|
|
|
// 将 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(
|
|
selectedKPIs.value.map(kpiId => [
|
|
kpiColumns.value.find(col => col.kpiId === kpiId)?.title || kpiId,
|
|
selectedRows.value.length === 0
|
|
? true
|
|
: selectedRows.value.includes(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>
|
|
</div>
|
|
<div class="chart-wrapper">
|
|
<div id="chartContainer" class="chart-container"></div>
|
|
<div class="table-container">
|
|
<a-table
|
|
:columns="statsColumns"
|
|
:data-source="kpiStats"
|
|
:pagination="false"
|
|
:scroll="{ y: 250 }"
|
|
size="small"
|
|
:loading="tableLoading"
|
|
:custom-row="tableRowConfig"
|
|
>
|
|
<template #headerCell="{ column }">
|
|
<template v-if="column.key === 'total'">
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.totalValue') }}
|
|
<a-tooltip placement="bottom">
|
|
<template #title>
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.totalValueTip') }}
|
|
</span>
|
|
</template>
|
|
<InfoCircleOutlined />
|
|
</a-tooltip>
|
|
</span>
|
|
</template>
|
|
<template v-if="column.key === 'avg'">
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.avgValue') }}
|
|
<a-tooltip placement="bottom">
|
|
<template #title>
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.avgValueTip') }}
|
|
</span>
|
|
</template>
|
|
<InfoCircleOutlined />
|
|
</a-tooltip>
|
|
</span>
|
|
</template>
|
|
<template v-if="column.key === 'max'">
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.maxValue') }}
|
|
<a-tooltip placement="bottom">
|
|
<template #title>
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.maxValueTip') }}
|
|
</span>
|
|
</template>
|
|
<InfoCircleOutlined />
|
|
</a-tooltip>
|
|
</span>
|
|
</template>
|
|
<template v-if="column.key === 'min'">
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.minValue') }}
|
|
<a-tooltip placement="bottom">
|
|
<template #title>
|
|
<span>
|
|
{{ t('views.perfManage.kpiOverView.minValueTip') }}
|
|
</span>
|
|
</template>
|
|
<InfoCircleOutlined />
|
|
</a-tooltip>
|
|
</span>
|
|
</template>
|
|
</template>
|
|
</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: 400px;
|
|
width: 100%;
|
|
}
|
|
|
|
.table-container {
|
|
height: 282px;
|
|
width: 100%;
|
|
margin-top: 20px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* 表格布局相关样式 */
|
|
: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;
|
|
}
|
|
</style>
|