1697 lines
46 KiB
Vue
1697 lines
46 KiB
Vue
<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>
|