1016 lines
29 KiB
Vue
1016 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { GridLayout, GridItem } from 'grid-layout-plus'
|
||
import * as echarts from 'echarts';
|
||
import { PageContainer } from 'antdv-pro-layout';
|
||
import { onMounted, reactive, ref, markRaw, nextTick, onUnmounted, watch } 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, { Dayjs } from 'dayjs';
|
||
import useNeInfoStore from '@/store/modules/neinfo';
|
||
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 { DownOutlined } from '@ant-design/icons-vue';
|
||
import { MenuInfo } from 'ant-design-vue/es/menu/src/interface';
|
||
const { t, currentLocale } = useI18n();
|
||
|
||
const neInfoStore = useNeInfoStore();
|
||
|
||
//WebSocket连接
|
||
const ws = ref<WS | null>(null);
|
||
|
||
//添加实时数据状态
|
||
const realTimeEnabled = ref(false);
|
||
//实时数据开关
|
||
const handleRealTimeSwitch = (checked: any) => {
|
||
fnRealTimeSwitch(!!checked);
|
||
};
|
||
|
||
// 定义所有网元类型
|
||
const ALL_NE_TYPES = ['ims', 'amf', 'udm', 'smf', 'pcf','upf','mme','mocngw','smsc','cbc','ausf'] as const;
|
||
type AllChartType = typeof ALL_NE_TYPES[number];
|
||
|
||
// 在 ALL_NE_TYPES 定义之后添加 小写转大写
|
||
const neTypeOptions = ALL_NE_TYPES.map(type => ({
|
||
label: type.toUpperCase(),
|
||
value: type
|
||
}));
|
||
|
||
// 使用 ref 来使 networkElementTypes 变为响应式,并使用 ALL_NE_TYPES 初始化
|
||
const networkElementTypes = ref<AllChartType[]>([...ALL_NE_TYPES]);
|
||
|
||
// 选择的网元类型
|
||
const selectedNeTypes = ref<AllChartType[]>([]);
|
||
|
||
// 临时状态 存储最新的选择
|
||
const latestSelectedTypes = ref<AllChartType[]>([]);
|
||
//手动更新跟踪
|
||
const isManuallyUpdating = ref(false);
|
||
|
||
// watch 监控函数
|
||
watch(selectedNeTypes, (newTypes) => {
|
||
//if (isManuallyUpdating.value) return;
|
||
networkElementTypes.value = newTypes;
|
||
latestSelectedTypes.value = newTypes;
|
||
debouncedUpdateCharts();
|
||
|
||
// 更新 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);
|
||
}
|
||
}, { deep: true });
|
||
|
||
// 防抖函数
|
||
const debouncedUpdateCharts = useDebounceFn(() => {
|
||
// 比较当前选择和最新选择
|
||
if (JSON.stringify(latestSelectedTypes.value) !== JSON.stringify(selectedNeTypes.value)) {
|
||
// 如果不一致,以最新选择为准
|
||
selectedNeTypes.value = latestSelectedTypes.value;
|
||
}
|
||
|
||
const newTypes = selectedNeTypes.value;
|
||
|
||
// 更新 chartOrder
|
||
chartOrder.value = chartOrder.value.filter(item => newTypes.includes(item.i));
|
||
|
||
newTypes.forEach((type) => {
|
||
if (!chartOrder.value.some(item => item.i === type)) {
|
||
chartOrder.value.push({
|
||
x: (chartOrder.value.length % 2) * 6,
|
||
y: Math.floor(chartOrder.value.length / 2) * 4,
|
||
w: 6,
|
||
h: 4,
|
||
i: type,
|
||
});
|
||
}
|
||
// 确保 chartStates 包含新的网元类型
|
||
if (!chartStates[type]) {
|
||
chartStates[type] = createChartState();
|
||
}
|
||
});
|
||
|
||
// 保存选中的网元类型到本地存储
|
||
localStorage.setItem('selectedNeTypes', JSON.stringify(newTypes));
|
||
|
||
// 重新初始化图表
|
||
nextTick(() => {
|
||
initCharts();
|
||
});
|
||
}, 300);
|
||
|
||
// 改变状态时重新初始化图表 数
|
||
const initCharts = async () => {
|
||
// 清除不再需要的图表
|
||
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();
|
||
}
|
||
delete chartStates[key as AllChartType];
|
||
}
|
||
});
|
||
|
||
// 初始化或更新需要的图表
|
||
for (const type of networkElementTypes.value) {
|
||
if (!chartStates[type]) {
|
||
chartStates[type] = createChartState();
|
||
}
|
||
try {
|
||
await fetchKPITitle(type);
|
||
await nextTick();
|
||
initChart(type);
|
||
await fetchData(type);
|
||
} catch (error) {
|
||
console.error(`Error initializing chart for ${type}:`, error);
|
||
}
|
||
}
|
||
|
||
// 不要在这里保存布局,因为这可能会覆盖我们刚刚设置的布局
|
||
// localStorage.setItem('chartOrder', JSON.stringify(chartOrder.value));
|
||
};
|
||
|
||
// 位置类型定义(记录布局)
|
||
interface LayoutItem {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
i: AllChartType;
|
||
}
|
||
|
||
type Layout = LayoutItem[];
|
||
|
||
//构建响应式数组储存图表类型数据
|
||
const chartOrder = ref<Layout>(
|
||
JSON.parse(localStorage.getItem('chartOrder') || 'null') ||
|
||
networkElementTypes.value.map((type, index) => ({//系统默认布局
|
||
x: index % 2 * 6, // 每行两个图表,宽度为6
|
||
y: Math.floor(index / 2) * 4, // 每个图表据 4 个单位高度
|
||
w: 6, // 宽度为6单位
|
||
h: 4, // 高度为4个单位
|
||
i: type, // 使用网元类型作为唯一标识
|
||
}))
|
||
);
|
||
|
||
// 监听带防抖
|
||
const handleLayoutUpdated = useDebounceFn((newLayout: Layout) => {
|
||
if (isManuallyUpdating.value) {
|
||
return;
|
||
}
|
||
const filteredLayout = newLayout.filter(item => networkElementTypes.value.includes(item.i));
|
||
if (JSON.stringify(filteredLayout) !== JSON.stringify(chartOrder.value)) {
|
||
chartOrder.value = filteredLayout;
|
||
localStorage.setItem('chartOrder', JSON.stringify(chartOrder.value));
|
||
nextTick(() => {
|
||
chartOrder.value.forEach((item) => {
|
||
const state = chartStates[item.i];
|
||
if (state?.chart.value) {
|
||
state.chart.value.resize();
|
||
renderChart(item.i);
|
||
}
|
||
});
|
||
});
|
||
} else {
|
||
console.log('No change in layout, skipping update');
|
||
}
|
||
}, 200); // 200ms 的防抖时间
|
||
|
||
// 监听 chartOrder 的变化
|
||
watch(chartOrder, (newOrder, oldOrder) => {
|
||
if (JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) {
|
||
nextTick(() => {
|
||
Object.values(chartStates).forEach(state => {
|
||
if (state.chart.value) {
|
||
state.chart.value.resize();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}, { deep: true });
|
||
|
||
// 定义表格状态类型
|
||
type TableStateType = {
|
||
loading: boolean;
|
||
size: SizeType;
|
||
seached: boolean;
|
||
data: Record<string, any>[];
|
||
selectedRowKeys: (string | number)[];
|
||
};
|
||
|
||
// 创建可复用的状态
|
||
const createChartState = () => {
|
||
const chartDom = ref<HTMLElement | null>(null);
|
||
const chart = ref<echarts.ECharts | null>(null);
|
||
const observer = ref<ResizeObserver | 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[],
|
||
};
|
||
};
|
||
|
||
// 图表类型状态
|
||
const chartStates: Record<AllChartType, ReturnType<typeof createChartState>> = Object.fromEntries(
|
||
networkElementTypes.value.map(type => [type, createChartState()])
|
||
) as Record<AllChartType, ReturnType<typeof createChartState>>;
|
||
|
||
//日期选择器
|
||
interface RangePicker extends Record<AllChartType, [string, string]> {
|
||
placeholder: [string, string];
|
||
}
|
||
|
||
// 日期选择器状态
|
||
const rangePicker = reactive<RangePicker>({
|
||
...Object.fromEntries(networkElementTypes.value.map(type => [
|
||
type,
|
||
[
|
||
dayjs().startOf('hour').valueOf().toString(), // 当前小时内
|
||
dayjs().endOf('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(() => {
|
||
const state = chartStates[type];
|
||
if (!state) {
|
||
console.warn(`Chart state for ${type} not found`);
|
||
return;
|
||
}
|
||
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 = markRaw(echarts.init(container));
|
||
const option: echarts.EChartsOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
position: function(pt: any) {
|
||
return [pt[0], '10%'];
|
||
},
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: state.chartDataXAxisData,
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
boundaryGap: [0, '100%'],
|
||
},
|
||
legend: {
|
||
type: 'scroll',
|
||
orient: 'horizontal',
|
||
top: -5,
|
||
itemWidth: 20,
|
||
textStyle: {
|
||
color: '#646A73',
|
||
},
|
||
icon: 'circle',
|
||
selected: state.chartLegendSelected,
|
||
},
|
||
grid: {
|
||
left: '10%',
|
||
right: '10%',
|
||
bottom: '15%',
|
||
},
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
],
|
||
series: state.chartDataYSeriesData as SeriesOption[],
|
||
};
|
||
state.chart.value.setOption(option);
|
||
state.chart.value.resize();
|
||
|
||
// 创建 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]; // 直使用 type
|
||
const neId = '001';
|
||
state.tableState.loading = true;
|
||
try {
|
||
const dateRange = rangePicker[type] as [string, string];
|
||
const [startTime, endTime] = dateRange;
|
||
const res = await listKPIData({
|
||
neType: type.toUpperCase(),
|
||
neId,
|
||
startTime,
|
||
endTime,
|
||
sortField: 'timeGroup',
|
||
sortOrder: 'desc',
|
||
interval: 5,
|
||
});
|
||
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
|
||
state.tableState.data = res.data;
|
||
await nextTick(() => {
|
||
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();
|
||
}
|
||
// 清空所有图表的现有数据
|
||
Object.values(chartStates).forEach(state => {
|
||
state.tableState.seached = false;
|
||
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;
|
||
});
|
||
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) {
|
||
console.warn(res.msg);
|
||
return;
|
||
}
|
||
|
||
if (!data?.groupId) {
|
||
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) {
|
||
// 更新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[]
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
interface CustomSeriesOption extends Omit<LineSeriesOption, 'data'> {
|
||
customKey?: string;
|
||
data: Array<[string, number]>;
|
||
}
|
||
// 创建可复用的图表渲染函数
|
||
const renderChart = (type: AllChartType) => {
|
||
const state = chartStates[type];
|
||
if (state.chart.value == null) {
|
||
return;
|
||
}
|
||
// 置数据
|
||
state.chartLegendSelected = {};
|
||
state.chartDataXAxisData = [];
|
||
state.chartDataYSeriesData = [];
|
||
|
||
// 处理数据
|
||
for (const column of state.tableColumns.value) {
|
||
if (['neName', 'startIndex', 'timeGroup'].includes(column.key as string)) continue;
|
||
const color = generateColorRGBA();
|
||
state.chartDataYSeriesData.push({
|
||
name: column.title as string,
|
||
customKey: column.key as string,
|
||
type: 'line',
|
||
symbol: 'none',
|
||
sampling: 'lttb',
|
||
itemStyle: { 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.chartLegendSelected[column.title as string] = true;
|
||
}
|
||
|
||
const orgData = [...state.tableState.data].reverse();
|
||
for (const item of orgData) {
|
||
state.chartDataXAxisData.push(parseDateToStr(+item.timeGroup));
|
||
for (const series of state.chartDataYSeriesData) {
|
||
series.data.push([parseDateToStr(+item.timeGroup), +item[series.customKey as string]]);
|
||
}
|
||
}
|
||
|
||
// 更新图表
|
||
state.chart.value.setOption(
|
||
{
|
||
legend: { selected: state.chartLegendSelected },
|
||
xAxis: {
|
||
data: state.chartDataXAxisData,
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
},
|
||
series: state.chartDataYSeriesData as SeriesOption[],
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
],
|
||
},
|
||
{ 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 && Array.isArray(res.data)) {
|
||
chartStates[type].tableColumns.value = res.data.map(item => ({
|
||
title: item[`${language}Title`],
|
||
dataIndex: item.kpiId,
|
||
align: 'left',
|
||
key: item.kpiId,
|
||
resizable: true,
|
||
width: 100,
|
||
minWidth: 150,
|
||
maxWidth: 300,
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
message.warning(t('common.getInfoFail'));
|
||
}
|
||
};
|
||
|
||
// 定义默认选择的网元类型
|
||
const DEFAULT_NE_TYPES: AllChartType[] = ['udm', 'amf', 'upf', 'ims'];
|
||
|
||
// 在 onMounted 钩子中
|
||
onMounted(async () => {
|
||
ws.value = new WS();
|
||
await neInfoStore.fnNelist();
|
||
|
||
// 从本地存储中读取选中的网元类型
|
||
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));
|
||
}
|
||
|
||
// 初始化或更新 chartOrder
|
||
const savedLayout = localStorage.getItem('chartOrder');
|
||
if (savedLayout) {
|
||
chartOrder.value = JSON.parse(savedLayout).filter((item: LayoutItem) =>
|
||
networkElementTypes.value.includes(item.i)
|
||
);
|
||
}
|
||
|
||
// 如果 chartOrder 为空或者不包含所有选中的网元,添加缺失的网元
|
||
const missingTypes = networkElementTypes.value.filter(type => !chartOrder.value.some(item => item.i === type));
|
||
missingTypes.forEach((type) => {
|
||
chartOrder.value.push({
|
||
x: (chartOrder.value.length % 2) * 6,
|
||
y: Math.floor(chartOrder.value.length / 2) * 4,
|
||
w: 6,
|
||
h: 4,
|
||
i: type,
|
||
});
|
||
});
|
||
|
||
await initCharts();
|
||
});
|
||
|
||
// 在组件卸载时销毁图表实例
|
||
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();
|
||
}
|
||
});
|
||
});
|
||
|
||
const isSaving = ref(false);
|
||
const isRestoring = ref(false);
|
||
//保存布局按钮
|
||
const handleSaveLayout = async (info: MenuInfo) => {
|
||
if (typeof info.key === 'string' && !isSaving.value) {
|
||
isSaving.value = true;
|
||
isRestoring.value = true;
|
||
try {
|
||
await saveCurrentLayout(info.key);
|
||
} finally {
|
||
setTimeout(()=>{
|
||
isSaving.value = false;
|
||
isRestoring.value = false;
|
||
},700)
|
||
}
|
||
} else {
|
||
console.error('Invalid layout key or operation in progress');
|
||
}
|
||
};
|
||
//恢复布局按钮
|
||
const handleRestoreLayout = async (info: MenuInfo) => {
|
||
if (typeof info.key === 'string' && !isRestoring.value) {
|
||
isRestoring.value = true;
|
||
isSaving.value = true;
|
||
try {
|
||
await restoreSavedLayout(info.key);
|
||
} finally {
|
||
setTimeout(()=>{
|
||
isSaving.value = false;
|
||
isRestoring.value = false;
|
||
},700)
|
||
}
|
||
} else {
|
||
console.error('Invalid layout key or operation in progress');
|
||
}
|
||
};
|
||
|
||
// 保存当前布局
|
||
const saveCurrentLayout = async (layoutName: string) => {
|
||
const savedLayouts = JSON.parse(localStorage.getItem('savedLayouts') || '{}');
|
||
savedLayouts[layoutName] = {
|
||
layout: chartOrder.value,
|
||
selectedTypes: selectedNeTypes.value
|
||
};
|
||
localStorage.setItem('savedLayouts', JSON.stringify(savedLayouts));
|
||
message.success(t('views.perfManage.kpiKeyTarget.saveSuccess', { name: t(`views.perfManage.kpiKeyTarget.${layoutName}`)}));
|
||
};
|
||
//恢复已保存的布局
|
||
const restoreSavedLayout = async (layoutName: string) => {
|
||
const savedLayouts = JSON.parse(localStorage.getItem('savedLayouts') || '{}');
|
||
const savedLayout = savedLayouts[layoutName];
|
||
if (savedLayout && Array.isArray(savedLayout.selectedTypes) && Array.isArray(savedLayout.layout)) {
|
||
selectedNeTypes.value = savedLayout.selectedTypes;
|
||
networkElementTypes.value = savedLayout.selectedTypes;
|
||
|
||
// 更新布局
|
||
chartOrder.value = savedLayout.layout.filter((item: LayoutItem) =>
|
||
savedLayout.selectedTypes.includes(item.i)
|
||
);
|
||
|
||
// 如果有当前选中的网元类型不在保存的布局中,添加它们
|
||
const missingTypes = savedLayout.selectedTypes.filter((type: AllChartType) =>
|
||
!chartOrder.value.some(item => item.i === type)
|
||
);
|
||
missingTypes.forEach((type: AllChartType) => {
|
||
chartOrder.value.push({
|
||
x: (chartOrder.value.length % 2) * 6,
|
||
y: Math.floor(chartOrder.value.length / 2) * 4,
|
||
w: 6,
|
||
h: 4,
|
||
i: type,
|
||
});
|
||
});
|
||
|
||
localStorage.setItem('chartOrder', JSON.stringify(chartOrder.value));
|
||
localStorage.setItem('selectedNeTypes', JSON.stringify(selectedNeTypes.value));
|
||
|
||
await nextTick();
|
||
await initCharts();
|
||
|
||
message.success(t('views.perfManage.kpiKeyTarget.restoreSavedSuccess', { name: t(`views.perfManage.kpiKeyTarget.${layoutName}`)}));
|
||
} else {
|
||
message.warning(t('views.perfManage.kpiKeyTarget.noSavedLayout', { name: t(`views.perfManage.kpiKeyTarget.${layoutName}`)}));
|
||
}
|
||
};
|
||
|
||
// 应用全宽布局
|
||
const applyFullWidthLayout = () => {
|
||
isManuallyUpdating.value = true;
|
||
const newLayout = selectedNeTypes.value.map((type, index) => ({
|
||
x: 0,
|
||
y: index * 8,
|
||
w: 12,
|
||
h: 8,
|
||
i: type,
|
||
}));
|
||
|
||
nextTick(() => {
|
||
Object.assign(chartOrder.value, newLayout)
|
||
// chartOrder.value = newLayout;
|
||
localStorage.setItem('chartOrder', JSON.stringify(newLayout));
|
||
initCharts();
|
||
// 直接更新图表,不调用 handleLayoutUpdated
|
||
chartOrder.value.forEach((item) => {
|
||
const state = chartStates[item.i];
|
||
if (state?.chart.value) {
|
||
state.chart.value.resize();
|
||
renderChart(item.i);
|
||
}
|
||
});
|
||
isManuallyUpdating.value = false;
|
||
});
|
||
};
|
||
|
||
// 应用两列布局
|
||
const applyTwoColumnLayout = () => {
|
||
isManuallyUpdating.value = true;
|
||
const newLayout = selectedNeTypes.value.map((type, index) => ({
|
||
x: (index % 2) * 6,
|
||
y: Math.floor(index / 2) * 4,
|
||
w: 6,
|
||
h: 4,
|
||
i: type,
|
||
}));
|
||
nextTick(() => {
|
||
// chartOrder.value = newLayout;
|
||
Object.assign(chartOrder.value, newLayout)
|
||
localStorage.setItem('chartOrder', JSON.stringify(newLayout));
|
||
initCharts();
|
||
// 直接更新图表,不调用 handleLayoutUpdated
|
||
chartOrder.value.forEach((item) => {
|
||
const state = chartStates[item.i];
|
||
if (state?.chart.value) {
|
||
state.chart.value.resize();
|
||
renderChart(item.i);
|
||
}
|
||
});
|
||
isManuallyUpdating.value = false;
|
||
});
|
||
};
|
||
</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.realTimeDataStart') : t('views.dashboard.cdr.realTimeDataStop')">
|
||
<a-switch
|
||
v-model:checked="realTimeEnabled"
|
||
@change="handleRealTimeSwitch"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :lg="12" :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" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :lg="6" :md="24" :xs="24">
|
||
|
||
<a-form-item>
|
||
<a-dropdown :disabled="isSaving">
|
||
<a-button :loading="isSaving">
|
||
{{ t('views.perfManage.kpiKeyTarget.saveLayout') }}
|
||
<DownOutlined />
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu @click="handleSaveLayout">
|
||
<a-menu-item key="layout1">{{ t('views.perfManage.kpiKeyTarget.layout1') }}</a-menu-item>
|
||
<a-menu-item key="layout2">{{ t('views.perfManage.kpiKeyTarget.layout2') }}</a-menu-item>
|
||
<a-menu-item key="layout3">{{ t('views.perfManage.kpiKeyTarget.layout3') }}</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
<a-dropdown :disabled="isRestoring">
|
||
<a-button :loading="isRestoring">
|
||
{{ t('views.perfManage.kpiKeyTarget.restoreSaved') }}
|
||
<DownOutlined />
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu @click="handleRestoreLayout">
|
||
<a-menu-item key="layout1">{{ t('views.perfManage.kpiKeyTarget.layout1') }}</a-menu-item>
|
||
<a-menu-item key="layout2">{{ t('views.perfManage.kpiKeyTarget.layout2') }}</a-menu-item>
|
||
<a-menu-item key="layout3">{{ t('views.perfManage.kpiKeyTarget.layout3') }}</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
<a-button @click="applyFullWidthLayout">
|
||
{{ t('views.perfManage.kpiKeyTarget.fullWidthLayout') }}
|
||
</a-button>
|
||
<a-button @click="applyTwoColumnLayout">
|
||
{{ t('views.perfManage.kpiKeyTarget.twoColumnLayout') }}
|
||
</a-button>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</a-card>
|
||
|
||
<GridLayout
|
||
v-model:layout="chartOrder"
|
||
:col-num="12"
|
||
:row-height="100"
|
||
:margin="[10, 10]"
|
||
:is-draggable="true"
|
||
:is-resizable="true"
|
||
:vertical-compact="true"
|
||
:use-css-transforms="true"
|
||
:responsive="true"
|
||
:prevent-collision="false"
|
||
@layout-updated="handleLayoutUpdated"
|
||
class="charts-container"
|
||
>
|
||
<GridItem
|
||
v-for="item in chartOrder.filter(i => networkElementTypes.includes(i.i))"
|
||
:key="item.i"
|
||
:x="item.x"
|
||
:y="item.y"
|
||
:w="item.w"
|
||
:h="item.h"
|
||
:i="item.i"
|
||
:min-w="4"
|
||
:min-h="3"
|
||
:is-draggable="true"
|
||
:is-resizable="true"
|
||
:resizable-handles="['se']"
|
||
drag-allow-from=".ant-card-head"
|
||
drag-ignore-from=".no-drag"
|
||
class="grid-item"
|
||
>
|
||
<a-card :bordered="false" class="card-container">
|
||
<template #title>
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
<span>{{ (item.i as string).toUpperCase() }}</span>
|
||
</div>
|
||
<a-range-picker
|
||
v-model:value="rangePicker[item.i]"
|
||
:allow-clear="false"
|
||
bordered
|
||
:show-time="{ format: 'HH:mm:ss' }"
|
||
format="YYYY-MM-DD HH:mm:ss"
|
||
value-format="x"
|
||
:placeholder="rangePicker.placeholder"
|
||
style="width: 360px"
|
||
@change="() => fetchData(item.i)"
|
||
class="no-drag"
|
||
></a-range-picker>
|
||
</div>
|
||
</template>
|
||
<div class='chart'>
|
||
<div :ref="el => { if (el && chartStates[item.i]) chartStates[item.i].chartDom.value = el as HTMLElement }"></div>
|
||
</div>
|
||
</a-card>
|
||
</GridItem>
|
||
</GridLayout>
|
||
</PageContainer>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.control-card {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.charts-container {
|
||
width: 100%;
|
||
min-height: 600px;
|
||
}
|
||
|
||
.grid-item {
|
||
overflow: visible;
|
||
position: relative;
|
||
}
|
||
|
||
.date-picker-wrapper {
|
||
position: absolute;
|
||
top: -40px;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
.card-container {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
cursor: move;
|
||
flex-wrap: wrap; /* 添加这行,允许内容换行 */
|
||
gap: 8px; /* 添加这行,为换行时的元素之间添加间隔 */
|
||
}
|
||
|
||
.card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
|
||
span {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.chart {
|
||
flex: 1;
|
||
min-height: 0;
|
||
|
||
> div {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.switch-label {
|
||
margin-left: 8px;
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.65);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
|
||
|
||
:deep(.ant-card-body) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.ant-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(.ant-form-item-label) {
|
||
font-weight: 500;
|
||
color: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
:deep(.anticon) {
|
||
font-size: 16px;
|
||
color: #1890ff;
|
||
}
|
||
|
||
:deep(.ant-select) {
|
||
min-width: 200px;
|
||
}
|
||
|
||
:deep(.ant-card-head) {
|
||
padding: 8px 16px; /* 减小上下内边距 */
|
||
}
|
||
|
||
:deep(.ant-card-head-title) {
|
||
padding: 8px 0; /* 减小上下内边距 */
|
||
width: 100%; // 确保标题占据全宽
|
||
}
|
||
|
||
:deep(.ant-range-picker) {
|
||
flex-shrink: 0; // 防止日期选择被压缩
|
||
max-width: 100%; // 确保在小屏幕上不会溢出
|
||
}
|
||
</style>
|