Files
fe.ems.vue3/src/views/perfManage/kpiKeyTarget/index.vue
2025-06-13 14:21:56 +08:00

1697 lines
46 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 setup lang="ts">
import * as echarts from 'echarts';
import { PageContainer } from 'antdv-pro-layout';
import {
onMounted,
reactive,
ref,
markRaw,
nextTick,
onUnmounted,
watch,
h,
} from 'vue';
import {
RESULT_CODE_ERROR,
RESULT_CODE_SUCCESS,
} from '@/constants/result-constants';
import { SizeType } from 'ant-design-vue/es/config-provider';
import { listKPIData, getKPITitle } from '@/api/perfManage/goldTarget';
import useI18n from '@/hooks/useI18n';
import { parseDateToStr } from '@/utils/date-utils';
import dayjs from 'dayjs';
import useNeStore from '@/store/modules/ne';
import { message } from 'ant-design-vue';
import { generateColorRGBA } from '@/utils/generate-utils';
import { LineSeriesOption } from 'echarts/charts';
import { SeriesOption } from 'echarts';
import { OptionsType, WS } from '@/plugins/ws-websocket';
import { useDebounceFn } from '@vueuse/core';
import { LineOutlined } from '@ant-design/icons-vue';
import { TableColumnType } from 'ant-design-vue';
const { t, currentLocale } = useI18n();
const neListStore = useNeStore();
//日期快捷选择
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()],
},
]);
//WebSocket连接
const ws = ref<WS | null>(null);
//添加实时数据状态
const realTimeEnabled = ref(false);
//实时数据开关
const handleRealTimeSwitch = (checked: any) => {
fnRealTimeSwitch(!!checked);
};
// 定义所有网元类型
const ALL_NE_TYPES = [
'ims',
'amf',
'udm',
'upf',
'smf',
'pcf',
'mme',
'mocngw',
'smsc',
'cbc',
] as const;
type AllChartType = (typeof ALL_NE_TYPES)[number] & string;
// 在 ALL_NE_TYPES 定义之后添加 小写转大写
const neTypeOptions = neListStore.getNeCascaderOptions
.filter(v => ALL_NE_TYPES.includes(v.value.toLowerCase()))
.map(v => ({
label: v.label,
value: v.value.toLowerCase(),
}));
// 使用 ref 来使 networkElementTypes 变为响应式,并使用 ALL_NE_TYPES 初始化
const networkElementTypes = ref<AllChartType[]>([...ALL_NE_TYPES]);
// 选择的网元类型
const selectedNeTypes = ref<AllChartType[]>([]);
// 临时状态 存储最新的选择
const latestSelectedTypes = ref<AllChartType[]>([]);
// watch 监控函数
watch(
selectedNeTypes,
async (newTypes, oldTypes) => {
networkElementTypes.value = [...newTypes];
latestSelectedTypes.value = [...newTypes];
// 获取被移除和新增的类型
const removedTypes = oldTypes.filter(type => !newTypes.includes(type));
const addedTypes = newTypes.filter(type => !oldTypes.includes(type));
// 清理被移除的图表状态
removedTypes.forEach(type => {
const state = chartStates[type];
if (state) {
if (state.chart.value) {
state.chart.value.dispose();
}
if (state.observer.value) {
state.observer.value.disconnect();
}
state.chart.value = null;
state.observer.value = null;
state.chartDataXAxisData = [];
state.chartDataYSeriesData = [];
state.tableState.data = [];
}
});
// 确保每个选中的类型都有对应的图表状态
newTypes.forEach(type => {
if (!chartStates[type]) {
chartStates[type] = createChartState(type);
}
});
// 初始化新选中的图表
for (const type of newTypes) {
try {
// 只有当图表不存在或者是新增的类型时才初始化
const needsInit =
!chartStates[type].chart.value || addedTypes.includes(type);
if (needsInit) {
await fetchKPITitle(type);
await nextTick();
await initChart(type);
// 只有在非实时模式下且是新增的网元时才获取数据
if (!realTimeEnabled.value && addedTypes.includes(type)) {
await fetchData(type);
}
}
// 恢复选中状态
if (selectedRows.value[type]) {
await nextTick();
updateChartLegendSelect(type, selectedRows.value[type]);
}
} catch (error) {
console.error(`Error initializing chart for ${type}:`, error);
}
}
// 更新 WebSocket 订阅
if (realTimeEnabled.value && ws.value) {
ws.value.close();
const options: OptionsType = {
url: '/ws',
params: {
subGroupID: newTypes
.map(type => `10_${type.toUpperCase()}_001`)
.join(','),
},
onmessage: wsMessage,
onerror: wsError,
};
ws.value.connect(options);
}
// 保存选中的网元类型到本地存储
localStorage.setItem('selectedNeTypes', JSON.stringify(newTypes));
},
{ deep: true }
);
// 防抖函数
useDebounceFn(() => {
// 比较当前选择和最新选择
if (
JSON.stringify(latestSelectedTypes.value) !==
JSON.stringify(selectedNeTypes.value)
) {
// 如果不一致,以最新选择为准
selectedNeTypes.value = latestSelectedTypes.value;
}
const newTypes = selectedNeTypes.value;
// 确保 chartStates 包含新的网元类型
newTypes.forEach(type => {
if (!chartStates[type]) {
chartStates[type] = createChartState(type);
}
});
// 保存选中的网元型到本地存储
localStorage.setItem('selectedNeTypes', JSON.stringify(newTypes));
// 初始图表
nextTick(() => {
initCharts();
});
}, 300);
// 改变状态时重新初始化图表 数
const initCharts = async () => {
// 保存当前的选中状态和图表状态
const currentSelectedStates = { ...selectedRows.value };
const savedChartStates = new Map(Object.entries(chartStates));
// 清除不再需要的图表
Object.keys(chartStates).forEach(key => {
if (!networkElementTypes.value.includes(key as AllChartType)) {
const state = chartStates[key as AllChartType];
if (state.chart.value) {
state.chart.value.dispose();
}
if (state.observer.value) {
state.observer.value.disconnect();
}
// 不要删除状态,只是清理图表实例
state.chart.value = null;
state.observer.value = null;
}
});
// 初始化或更新需要的图表
for (const type of networkElementTypes.value) {
try {
if (!chartStates[type]) {
// 检查是否有保存的状态
const savedState = savedChartStates.get(type);
if (savedState) {
chartStates[type] = savedState;
} else {
chartStates[type] = createChartState(type);
}
}
await fetchKPITitle(type);
await nextTick();
await initChart(type);
await fetchData(type);
// 恢复选中状态
if (currentSelectedStates[type]) {
await nextTick();
updateChartLegendSelect(type, currentSelectedStates[type]);
}
} catch (error) {
console.error(`Error initializing chart for ${type}:`, error);
}
}
};
// 定义表格状态类型
type TableStateType = {
loading: boolean;
size: SizeType;
seached: boolean;
data: Record<string, any>[];
selectedRowKeys: (string | number)[];
};
// 创建可复用的状态
const createChartState = (neType: AllChartType) => {
const chartDom = ref<HTMLElement | null>(null);
const chart = ref<echarts.ECharts | null>(null);
const observer = ref<ResizeObserver | null>(null);
const currentTheme = ref<string | null>(
document.documentElement.getAttribute('data-theme')
);
const themeObserver = ref<MutationObserver | null>(null);
return {
chartDom,
chart,
observer,
tableColumns: ref<any[]>([]),
tableState: reactive<TableStateType>({
loading: false,
size: 'small',
seached: true,
data: [],
selectedRowKeys: [],
}),
chartLegendSelected: {} as Record<string, boolean>,
chartDataXAxisData: [] as string[],
chartDataYSeriesData: [] as CustomSeriesOption[],
seriesColors: reactive(new Map<string, string>()),
currentTheme,
themeObserver,
neType,
};
};
// 图表类型状态
const chartStates: Record<
AllChartType,
ReturnType<typeof createChartState>
> = Object.fromEntries(
networkElementTypes.value.map(type => [type, createChartState(type)])
) as Record<AllChartType, ReturnType<typeof createChartState>>;
// 日期选择器状态
const rangePicker = reactive({
...(Object.fromEntries(
networkElementTypes.value.map(type => [
type,
[
dayjs().subtract(1, 'hour').startOf('hour').valueOf().toString(), // 上一小时开始
dayjs().startOf('hour').add(1, 'hour').valueOf().toString(), // 当前小时结束
],
])
) as Record<AllChartType, [string, string]>),
placeholder: [
t('views.monitor.monitor.startTime'),
t('views.monitor.monitor.endTime'),
] as [string, string],
});
// 可复用的图表初始化函数
const initChart = (type: AllChartType) => {
const tryInit = (retries = 3) => {
nextTick(async () => {
const state = chartStates[type];
if (!state) {
console.warn(`Chart state for ${type} not found`);
return;
}
// 更新当前主题
state.currentTheme.value =
document.documentElement.getAttribute('data-theme');
// 等待 DOM 更新
await nextTick();
const container = state.chartDom.value;
if (!container) {
if (retries > 0) {
console.warn(
`Chart container for ${type} not found, retrying... (${retries} attempts left)`
);
setTimeout(() => tryInit(retries - 1), 100);
} else {
console.error(
`Chart container for ${type} not found after multiple attempts`
);
}
return;
}
// 确保旧的图表实例被正确销毁
if (state.chart.value) {
state.chart.value.dispose();
state.chart.value = null;
}
// 创建新的图表实例
state.chart.value = markRaw(echarts.init(container));
// 更新图表配置
const updateChartTheme = (type: AllChartType) => {
if (!state.chart.value) return;
const splitLineColor = getSplitLineColor();
const isDark =
document.documentElement.getAttribute('data-theme') === 'dark';
const option = {
tooltip: {
trigger: 'axis',
position: function (
point: number[],
_params: any,
_dom: HTMLElement,
_rect: any,
size: { viewSize: number[]; contentSize: number[] }
) {
const [x] = point;
const [viewWidth] = size.viewSize;
const [tooltipWidth] = size.contentSize;
// 计算距离右边界的距离
const rightSpace = viewWidth - x;
// 如果右侧空间不足以显示 tooltip加上20px的安全距离
if (rightSpace < tooltipWidth + 20) {
// 向左显示tooltip 右边界对齐鼠标位置
return [x - tooltipWidth - 10, point[1]];
}
// 默认向右显示tooltip 左边界对齐鼠标位置
return [x + 10, point[1]];
},
axisPointer: {
type: 'line',
z: 0,
},
className: `chart-tooltip-${type}`,
z: 1000,
extraCssText: 'z-index: 1000; pointer-events: none;',
confine: true,
backgroundColor: isDark
? 'rgba(48, 48, 48, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
borderColor: isDark ? '#555' : '#ddd',
textStyle: {
color: isDark ? '#CACADA' : '#333',
},
},
xAxis: {
type: 'category',
boundaryGap: false,
data: state.chartDataXAxisData,
axisLabel: {
formatter: '{value}',
color: isDark ? '#CACADA' : '#333',
},
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
},
yAxis: {
type: 'value',
boundaryGap: [0, '100%'],
axisLabel: {
formatter: '{value}',
color: isDark ? '#CACADA' : '#333',
},
splitNumber: 5,
scale: true,
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
},
legend: {
type: 'scroll',
orient: 'horizontal',
top: -5,
itemWidth: 20,
textStyle: {
color: isDark ? '#CACADA' : '#646A73',
},
show: false,
icon: 'circle',
selected: state.chartLegendSelected,
backgroundColor: isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.9)',
borderColor: isDark ? '#555' : '#ddd',
},
grid: {
left: '10%',
right: '10%',
bottom: '15%',
},
series: state.chartDataYSeriesData.map(series => {
const color =
state.seriesColors.get(series.customKey as string) ||
generateColorRGBA();
return {
...series,
itemStyle: { color },
lineStyle: { color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace(')', ',0.8)') },
{ offset: 1, color: color.replace(')', ',0.3)') },
]),
},
};
}) as SeriesOption[],
};
state.chart.value.setOption(option, true);
};
// 初始化时更新一次
updateChartTheme(type);
// 添加主题变化监听
if (state.themeObserver.value) {
state.themeObserver.value.disconnect();
}
const observer = new MutationObserver(() => {
state.currentTheme.value =
document.documentElement.getAttribute('data-theme');
updateChartTheme(type);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
// 正确地设置 ref 的值
state.themeObserver.value = observer;
// 重新绑定 ResizeObserver
if (state.observer.value) {
state.observer.value.disconnect();
}
state.observer.value = new ResizeObserver(() => {
if (state.chart.value) {
state.chart.value.resize();
}
});
state.observer.value.observe(container);
});
};
tryInit();
};
// 可复用的数据获取函数
const fetchData = async (type: AllChartType) => {
const state = chartStates[type];
state.tableState.loading = true;
try {
const dateRange = rangePicker[type] as [string, string];
const [beginTime, endTime] = dateRange;
const res = await listKPIData({
neType: type.toUpperCase(),
neId: '001',
beginTime,
endTime,
sortField: 'timeGroup',
sortOrder: 'desc',
interval: 60 * 15,
});
if (res.code === RESULT_CODE_SUCCESS) {
state.tableState.data = res.data;
await renderChart(type);
}
} catch (error) {
console.error(error);
message.error(t('common.getInfoFail'));
} finally {
state.tableState.loading = false;
}
};
//实时数连接开关
function fnRealTimeSwitch(bool: boolean) {
realTimeEnabled.value = bool;
if (bool) {
if (!ws.value) {
ws.value = new WS();
}
// 清空所有图表的现有数据并设置 loading 状态
Object.values(chartStates).forEach(state => {
state.tableState.seached = false;
state.tableState.loading = true;
state.tableState.data = []; // 清空表格数据
state.chartDataXAxisData = [];
state.chartDataYSeriesData.forEach(series => {
series.data = [];
});
if (state.chart.value) {
state.chart.value.setOption({
xAxis: { data: [] },
series: state.chartDataYSeriesData,
});
}
});
const options: OptionsType = {
url: '/ws',
params: {
subGroupID: selectedNeTypes.value
.map(type => `10_${type.toUpperCase()}_001`)
.join(','),
},
onmessage: wsMessage,
onerror: wsError,
};
ws.value.connect(options);
} else {
if (ws.value) {
Object.values(chartStates).forEach(state => {
state.tableState.seached = true;
state.tableState.loading = false;
});
ws.value.close();
ws.value = null;
// 关闭实时数据时保留当前数据,不清空也不重新获取
}
}
}
// 接收数据后错误回调
function wsError() {
message.error(t('common.websocketError'));
}
// 接收数据回调
function wsMessage(res: Record<string, any>) {
const { code, data } = res;
if (code === RESULT_CODE_ERROR || !data?.groupId) {
console.warn(res.msg);
return;
}
const neType = data.groupId.split('_')[1].toLowerCase() as AllChartType;
const state = chartStates[neType];
if (!state) {
console.warn(`No chart state found for ${neType}`);
return;
}
const kpiEvent = data.data;
if (!kpiEvent) {
console.warn(`No data found for ${neType}`);
return;
}
const newTime = parseDateToStr(
kpiEvent.timeGroup ? +kpiEvent.timeGroup : Date.now()
);
// 只有在实时数据模式下才更新图表
if (realTimeEnabled.value) {
// 如果是第一条数据,关闭 loading 状态
if (state.tableState.data.length === 0) {
state.tableState.loading = false;
}
// 更新表格数据
state.tableState.data.unshift(kpiEvent);
if (state.tableState.data.length > 100) {
state.tableState.data.pop();
}
// 更新X轴数据
state.chartDataXAxisData.push(newTime);
if (state.chartDataXAxisData.length > 100) {
state.chartDataXAxisData.shift();
}
// 更新每个系列的数据
state.chartDataYSeriesData.forEach(series => {
const kpiKey = series.customKey as string;
if (kpiEvent[kpiKey] !== undefined) {
const newValue = +kpiEvent[kpiKey];
series.data.push([newTime, newValue]);
if (series.data.length > 100) {
series.data.shift();
}
}
});
// 立即更新图表
if (state.chart.value) {
state.chart.value.setOption({
xAxis: { data: state.chartDataXAxisData },
series: state.chartDataYSeriesData as SeriesOption[],
});
}
// 更新统计信息
nextTick(() => {
const stats = getKpiStats(state.neType);
if (stats.length > 0) {
renderChart(state.neType);
}
});
}
}
interface CustomSeriesOption extends Omit<LineSeriesOption, 'data'> {
customKey?: string;
data: Array<[string, number]>;
}
// 建可复用的图表渲染函数
const renderChart = async (type: AllChartType) => {
const state = chartStates[type];
if (!state?.chart.value) return;
// 保存当前的选中状态
const currentSelectedKpiIds = selectedRows.value[type] || [];
// 重置数据
state.chartLegendSelected = {};
state.chartDataXAxisData = [];
state.chartDataYSeriesData = [];
// 处理数据
for (const column of state.tableColumns.value) {
if (['neName', 'startIndex', 'timeGroup'].includes(column.key as string))
continue;
// 检查是否已有该指标的颜色,如果没有才生成新颜色
let color = state.seriesColors.get(column.key as string);
if (!color) {
color = generateColorRGBA();
state.seriesColors.set(column.key as string, color);
}
const seriesData = {
name: column.title as string,
customKey: column.key as string,
type: 'line',
symbol: 'none',
smooth: 0.6,
sampling: 'lttb',
itemStyle: { color },
lineStyle: { color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace(')', ',0.8)') },
{ offset: 1, color: color.replace(')', ',0.3)') },
]),
},
data: [],
} as CustomSeriesOption;
state.chartDataYSeriesData.push(seriesData);
state.chartLegendSelected[column.title as string] = true;
}
const orgData = [...state.tableState.data].reverse();
for (const item of orgData) {
const timeStr = parseDateToStr(+item.timeGroup);
state.chartDataXAxisData.push(timeStr);
for (const series of state.chartDataYSeriesData) {
series.data.push([timeStr, +item[series.customKey as string]]);
}
}
// 更新图表时考虑当前选中状态
const legendSelected: Record<string, boolean> = {};
state.chartDataYSeriesData.forEach(series => {
const title =
state.tableColumns.value.find(col => col.key === series.customKey)
?.title || series.customKey;
if (typeof title === 'string') {
// 如果没有选中的指标或选中列表为空,显示所有指标
// 否则只显示选中的指标
legendSelected[title] =
!currentSelectedKpiIds.length ||
currentSelectedKpiIds.includes(series.customKey as string);
}
});
// 更新图表
state.chart.value.setOption(
{
legend: {
selected: legendSelected,
textStyle: {
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#646A73',
},
backgroundColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.9)',
borderColor:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#555'
: '#ddd',
},
xAxis: {
data: state.chartDataXAxisData,
type: 'category',
boundaryGap: false,
axisLabel: {
formatter: '{value}',
color:
document.documentElement.getAttribute('data-theme') === 'dark'
? '#CACADA'
: '#333',
},
splitLine: {
show: true,
lineStyle: {
color: getSplitLineColor(),
},
},
},
series: state.chartDataYSeriesData as SeriesOption[],
},
{ replaceMerge: ['xAxis', 'series'] }
);
};
// 获网元指标据
const fetchKPITitle = async (type: AllChartType) => {
const language =
currentLocale.value.split('_')[0] === 'zh'
? 'cn'
: currentLocale.value.split('_')[0];
try {
const res = await getKPITitle(type.toUpperCase());
if (res.code === RESULT_CODE_SUCCESS) {
// 保存现有的颜色映
const existingColors = new Map(chartStates[type].seriesColors);
// 设置新的列
chartStates[type].tableColumns.value = [
{
title: t('views.perfManage.kpiKeyTarget.time'),
dataIndex: 'timeGroup',
key: 'timeGroup',
align: 'left',
width: 180,
fixed: 'left',
customRender: ({ text }: { text: string }) => {
return dayjs(Number(text)).format('YYYY-MM-DD HH:mm:ss');
},
},
...res.data.map((item: any) => {
const kpiId = item.kpiId;
// 如果没有现有的颜色,生成新的颜色
if (!existingColors.has(kpiId)) {
existingColors.set(kpiId, generateColorRGBA());
}
return {
title: item[`${language}Title`],
dataIndex: kpiId,
align: 'left',
key: kpiId,
resizable: true,
width: 100,
minWidth: 150,
maxWidth: 300,
};
}),
];
// 更新颜色映射
chartStates[type].seriesColors = existingColors;
}
} catch (error) {
console.error(error);
message.warning(t('common.getInfoFail'));
}
};
// 定义默认选择的网元类型
const DEFAULT_NE_TYPES: AllChartType[] = ['udm', 'amf', 'upf', 'ims'];
// 添加一个函数来获取当前主题下的网格线颜色
const getSplitLineColor = () => {
return document.documentElement.getAttribute('data-theme') === 'dark'
? '#333333'
: '#E8E8E8';
};
// 修改主题观察器
const themeObserver = new MutationObserver(() => {
const splitLineColor = getSplitLineColor();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
Object.values(chartStates).forEach(state => {
const chart = state.chart?.value;
if (!chart) return;
requestAnimationFrame(() => {
// 获取当前选中的指标IDs
const currentSelectedKpiIds = selectedRows.value[state.neType] || [];
// 重新生成所有指标的颜色并更新到 seriesColors
state.chartDataYSeriesData.forEach(series => {
const color = generateColorRGBA();
state.seriesColors.set(series.customKey as string, color);
});
// 重新计算图例选中状态
const legendSelected: Record<string, boolean> = {};
state.chartDataYSeriesData.forEach(series => {
const title =
state.tableColumns.value.find(col => col.key === series.customKey)
?.title || series.customKey;
if (typeof title === 'string') {
// 如果没有选中的指标或选中列表为空,显示所有指标
// 否则只显示选中的指标
legendSelected[title] =
!currentSelectedKpiIds.length ||
currentSelectedKpiIds.includes(series.customKey as string);
}
});
const option = {
tooltip: {
trigger: 'axis',
position: function (
point: number[],
_params: any,
_dom: HTMLElement,
_rect: any,
size: { viewSize: number[]; contentSize: number[] }
) {
const [x] = point;
const [viewWidth] = size.viewSize;
const [tooltipWidth] = size.contentSize;
// 计算距离右边界的距离
const rightSpace = viewWidth - x;
// 如果右侧空间不足以显示 tooltip加上20px的安全距离
if (rightSpace < tooltipWidth + 20) {
// 向左显示tooltip 右边界对齐鼠标位置
return [x - tooltipWidth - 10, point[1]];
}
// 默认向右显示tooltip 左边界对齐鼠标位置
return [x + 10, point[1]];
},
axisPointer: {
type: 'line',
z: 0,
},
className: `chart-tooltip-${state.neType}`,
z: 1000,
extraCssText: 'z-index: 1000; pointer-events: none;',
confine: true,
backgroundColor: isDark
? 'rgba(48, 48, 48, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
borderColor: isDark ? '#555' : '#ddd',
textStyle: {
color: isDark ? '#CACADA' : '#333',
},
},
xAxis: {
type: 'category',
boundaryGap: false,
data: state.chartDataXAxisData,
axisLabel: {
color: isDark ? '#CACADA' : '#333',
},
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
},
yAxis: {
type: 'value',
boundaryGap: [0, '100%'],
axisLabel: {
color: isDark ? '#CACADA' : '#333',
},
splitNumber: 5,
scale: true,
splitLine: {
show: true,
lineStyle: {
color: splitLineColor,
},
},
},
legend: {
show: false,
selected: legendSelected,
textStyle: {
color: isDark ? '#CACADA' : '#646A73',
},
backgroundColor: isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.9)',
borderColor: isDark ? '#555' : '#ddd',
},
series: state.chartDataYSeriesData.map(series => {
const color = state.seriesColors.get(
series.customKey as string
) as string;
return {
...series,
type: 'line',
symbol: 'none',
smooth: 0.6,
sampling: 'lttb',
itemStyle: { color },
lineStyle: { color },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace(')', ',0.8)') },
{ offset: 1, color: color.replace(')', ',0.3)') },
]),
},
data: series.data,
};
}),
};
chart.setOption(option, {
notMerge: false,
lazyUpdate: false,
});
// 强制重新渲染
chart.resize();
// 如果有选中的指标,确保图例状态正确
if (currentSelectedKpiIds.length > 0) {
nextTick(() => {
updateChartLegendSelect(state.neType, currentSelectedKpiIds);
});
}
// 强制更新表格以刷新图标颜色
nextTick(() => {
const stats = getKpiStats(state.neType);
if (stats.length > 0) {
// 触发表格重新渲染
state.tableState.loading = true;
nextTick(() => {
state.tableState.loading = false;
});
}
});
});
});
});
// 在 onMounted 中添加题观察器
onMounted(async () => {
ws.value = new WS();
// 从本地存储中读取选中的网元类型
const savedSelectedNeTypes = localStorage.getItem('selectedNeTypes');
if (savedSelectedNeTypes) {
const parsedSelectedNeTypes = JSON.parse(
savedSelectedNeTypes
) as AllChartType[];
selectedNeTypes.value = parsedSelectedNeTypes;
networkElementTypes.value = parsedSelectedNeTypes;
} else {
selectedNeTypes.value = [...DEFAULT_NE_TYPES];
networkElementTypes.value = [...DEFAULT_NE_TYPES];
localStorage.setItem('selectedNeTypes', JSON.stringify(DEFAULT_NE_TYPES));
}
// 添加主题观察器
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
await initCharts();
});
// 在 onUnmounted 中断开主题观察器
onUnmounted(() => {
if (ws.value && ws.value.state() === WebSocket.OPEN) {
ws.value.close();
}
Object.values(chartStates).forEach(state => {
if (state.chart.value) {
state.chart.value.dispose();
}
if (state.observer.value) {
state.observer.value.disconnect();
}
if (state.themeObserver.value) {
state.themeObserver.value.disconnect();
}
});
themeObserver.disconnect();
});
// 添加 KPIStats 接口定义
interface KPIStats {
kpiId: string;
title: string;
max: number;
min: number;
neType: AllChartType; // 添加网元类型字段
}
// 修改 getKpiStats 函数
const getKpiStats = (type: AllChartType) => {
const state = chartStates[type];
if (!state?.chartDataYSeriesData) return [];
return state.chartDataYSeriesData
.map(series => {
const kpiId = series.customKey as string;
const column = state.tableColumns.value.find(col => col.key === kpiId);
if (!column) return null;
// 直接从图表数据中获取值
const values = series.data.map(item => Number(item[1]));
return {
kpiId,
title: column.title as string,
max: values.length > 0 ? Math.max(...values) : 0,
min: values.length > 0 ? Math.min(...values) : 0,
neType: type,
};
})
.filter((item): item is KPIStats => item !== null);
};
// 修改选中行的状态改为数组存储个选中的指标ID
const selectedRows = ref<Record<AllChartType, string[]>>(
{} as Record<AllChartType, string[]>
);
// 修改处理行点击的方法,支持多选
const handleRowClick = (record: KPIStats, type: AllChartType) => {
const state = chartStates[type];
if (!state?.chart.value) return;
// 确保选中数组已初始化
if (!selectedRows.value[type]) {
selectedRows.value[type] = [];
}
const index = selectedRows.value[type].indexOf(record.kpiId);
if (index > -1) {
// 如果已选中,则取消选中
selectedRows.value[type].splice(index, 1);
} else {
// 如果未选中,则添加到选中列表
selectedRows.value[type].push(record.kpiId);
}
// 更新图表显示
updateChartLegendSelect(type, selectedRows.value[type]);
};
// 修改更新图表图例选中的方法,支持多选
const updateChartLegendSelect = (
type: AllChartType,
selectedKpiIds?: string[]
) => {
const state = chartStates[type];
const chart = state?.chart?.value;
if (!chart) return;
// 确保图表数据已经准备好
if (state.chartDataYSeriesData.length === 0) {
return;
}
const legendSelected: Record<string, boolean> = {};
state.chartDataYSeriesData.forEach(series => {
const title =
state.tableColumns.value.find(col => col.key === series.customKey)
?.title || series.customKey;
if (typeof title === 'string') {
// 如果没有选中的指标或选中列表为空,显示所有指标
// 否则只显示选中的指标
legendSelected[title] =
!selectedKpiIds?.length ||
selectedKpiIds.includes(series.customKey as string);
}
});
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
chart.setOption({
legend: {
show: false,
selected: legendSelected,
textStyle: {
color: isDark ? '#CACADA' : '#646A73',
},
backgroundColor: isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.9)',
borderColor: isDark ? '#555' : '#ddd',
},
});
};
// 修改表格行的样式配置
const statsColumns: TableColumnType<KPIStats>[] = [
{
title: '',
key: 'icon',
width: 80,
customRender: ({ record }: { record: KPIStats }) => {
const state = chartStates[record.neType];
// 从 seriesColors 中获取最新的颜色
const color = state?.seriesColors.get(record.kpiId);
return h(LineOutlined, {
style: {
color: color || '#000',
fontSize: '40px',
fontWeight: 'bold',
} as Record<string, string>,
});
},
},
{
title: t('views.perfManage.kpiOverView.kpiName'),
dataIndex: 'title',
key: 'title',
width: '40%',
},
{
title: t('views.perfManage.kpiOverView.maxValue'),
dataIndex: 'max',
key: 'max',
width: '30%',
sortDirections: ['ascend', 'descend'] as ('ascend' | 'descend')[],
},
{
title: t('views.perfManage.kpiOverView.minValue'),
dataIndex: 'min',
key: 'min',
width: '30%',
sortDirections: ['ascend', 'descend'] as ('ascend' | 'descend')[],
},
];
// 1. 添加 tab 切换处理函数
const handleTabChange = async (activeKey: string, type: AllChartType) => {
if (activeKey === 'stats') {
const state = chartStates[type];
if (!state?.chart.value) return;
// 制重新算统计信息
await nextTick();
const stats = getKpiStats(type);
if (stats.length === 0 && state.chartDataYSeriesData.length > 0) {
// 如果统计信为空图表有,重新渲染图表
await renderChart(type);
}
}
};
</script>
<template>
<PageContainer>
<a-card :bordered="false" class="control-card">
<a-form layout="horizontal">
<a-row :gutter="16">
<a-col :lg="4" :md="24" :xs="24">
<a-form-item
:label="
realTimeEnabled
? t('views.dashboard.cdr.realTimeDataStop')
: t('views.dashboard.cdr.realTimeDataStart')
"
>
<a-switch
v-model:checked="realTimeEnabled"
@change="handleRealTimeSwitch"
/>
</a-form-item>
</a-col>
<a-col :lg="20" :md="24" :xs="24">
<a-form-item
:label="t('views.ne.common.neType')"
class="ne-type-select"
>
<a-checkbox-group
v-model:value="selectedNeTypes"
:options="neTypeOptions"
:disabled="realTimeEnabled"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<div class="charts-container">
<a-card
v-for="type in networkElementTypes"
:key="type"
:bordered="false"
class="chart-card"
>
<template #title>
<div class="card-header">
<div class="card-title">
<span>{{ (type as string).toUpperCase() }}</span>
</div>
<a-range-picker
v-model:value="rangePicker[type]"
:allow-clear="false"
:disabled="realTimeEnabled"
bordered
:presets="ranges"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
value-format="x"
:placeholder="rangePicker.placeholder"
style="width: 360px"
@change="() => fetchData(type)"
class="no-drag"
></a-range-picker>
</div>
</template>
<div class="card-content">
<div class="chart-container">
<div
:ref="el => { if (el && chartStates[type]) chartStates[type].chartDom.value = el as HTMLElement }"
></div>
</div>
<div class="table-container">
<a-tabs
default-active-key="stats"
@change="(key:any) => handleTabChange(key, type)"
>
<a-tab-pane
key="stats"
:tab="t('views.perfManage.kpiKeyTarget.statistics')"
>
<a-table
:columns="statsColumns"
:data-source="getKpiStats(type)"
:pagination="false"
size="small"
:scroll="{ y: 'true' }"
:loading="chartStates[type].tableState.loading"
:custom-row="
(record:any) => ({
onClick: () => handleRowClick(record, type),
class: selectedRows[type]?.includes(record.kpiId)
? 'selected-row'
: '',
})
"
>
<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>
</a-tab-pane>
<a-tab-pane
key="raw"
:tab="t('views.perfManage.kpiKeyTarget.rawData')"
>
<a-table
:columns="chartStates[type].tableColumns.value"
:data-source="chartStates[type].tableState.data"
:loading="chartStates[type].tableState.loading"
:pagination="false"
size="small"
:scroll="{ y: '100%' }"
/>
</a-tab-pane>
</a-tabs>
</div>
</div>
</a-card>
</div>
</PageContainer>
</template>
<style lang="less" scoped>
.control-card {
margin-bottom: 16px;
}
.charts-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 1200px;
overflow-x: auto;
overflow-y: visible;
&::-webkit-scrollbar {
width: 0;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
&:hover {
background: #999;
}
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
}
.chart-card {
width: 100%;
height: 500px;
position: relative;
min-width: 1168px;
:deep(.ant-card-body) {
height: calc(100% - 57px);
padding: 16px !important;
display: flex;
flex-direction: column;
overflow: visible;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
}
.card-title {
display: flex;
align-items: center;
flex-shrink: 0;
span {
font-size: 16px;
font-weight: 500;
}
}
.card-content {
height: 100%;
display: flex;
gap: 16px;
}
.chart-container {
flex: 3;
min-width: 0;
height: 100%;
> div {
width: 100%;
height: 100%;
}
}
.table-container {
flex: 2;
min-width: 500px;
height: 100%;
display: flex;
flex-direction: column;
:deep(.ant-tabs) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.ant-tabs-content-holder) {
flex: 1;
overflow: hidden;
}
:deep(.ant-tabs-content) {
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.ant-table-wrapper) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.ant-spin-nested-loading) {
height: 100%;
}
:deep(.ant-spin-container) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.ant-table) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.ant-table-container) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.ant-table-header) {
flex-shrink: 0;
}
:deep(.ant-table-body) {
flex: 1;
overflow-y: auto !important;
min-height: 0;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
&:hover {
background: #999;
}
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
}
}
// 暗色主题下的滚动条样式
[data-theme='dark'] {
.charts-container {
&::-webkit-scrollbar-thumb {
background: #4c4c4c;
&:hover {
background: #666;
}
}
&::-webkit-scrollbar-track {
background: #2a2a2a;
}
}
:deep(.ant-table-body) {
&::-webkit-scrollbar-thumb {
background: #4c4c4c;
&:hover {
background: #666;
}
}
&::-webkit-scrollbar-track {
background: #2a2a2a;
}
}
}
// 表格交样式
:deep(.ant-table-tbody) {
tr {
cursor: pointer;
&:hover > td {
background-color: rgba(24, 144, 255, 0.1);
}
&.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(.ant-form-item) {
margin-bottom: 0;
&-label {
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
}
:deep(.ant-card-head) {
padding: 8px 16px;
&-title {
padding: 8px 0;
width: 100%;
}
}
:deep(.ant-range-picker) {
flex-shrink: 0;
max-width: 100%;
}
:deep(.ant-checkbox-group) {
&.ant-checkbox-group-disabled {
cursor: not-allowed;
.ant-checkbox-wrapper-disabled {
cursor: not-allowed;
&:hover {
.ant-checkbox-inner {
border-color: #d9d9d9;
}
}
}
}
}
[data-theme='dark'] {
:deep(.ant-checkbox-group) {
&.ant-checkbox-group-disabled {
.ant-checkbox-wrapper-disabled {
&:hover {
.ant-checkbox-inner {
border-color: #434343;
}
}
}
}
}
}
</style>