630 lines
16 KiB
Vue
630 lines
16 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, Switch, Form } from 'ant-design-vue';
|
||
import { ColumnsType } from 'ant-design-vue/es/table';
|
||
import { generateColorRGBA } from '@/utils/generate-utils';
|
||
import { LineSeriesOption } from 'echarts/charts';
|
||
import { OptionsType, WS } from '@/plugins/ws-websocket';
|
||
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 networkElementTypes = ['udm', 'upf', 'amf', 'smf', 'ims','ausf'] as const;
|
||
// 定义 ChartType
|
||
type ChartType = typeof networkElementTypes[number]; // 将 i 的类型改为 ChartType
|
||
|
||
// 添加类型定义
|
||
interface LayoutItem {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
i: ChartType; // 将 i 的类型改为 ChartType
|
||
}
|
||
|
||
type Layout = LayoutItem[];
|
||
|
||
//构建响应式数组储存图表类型数据
|
||
const chartOrder = ref<Layout>(
|
||
networkElementTypes.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 = (newLayout: Layout) => {
|
||
chartOrder.value = newLayout;
|
||
nextTick(() => {
|
||
newLayout.forEach((item) => {
|
||
const state = chartStates[item.i];
|
||
if (state.chart.value) {
|
||
state.chart.value.resize();
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// 监听 chartOrder 的变化
|
||
watch(chartOrder, () => {
|
||
nextTick(() => {
|
||
Object.values(chartStates).forEach(state => {
|
||
if (state.chart.value) {
|
||
state.chart.value.resize();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// 定义表格状态类型
|
||
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<ColumnsType>([]),
|
||
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<ChartType, ReturnType<typeof createChartState>> = Object.fromEntries(
|
||
networkElementTypes.map(type => [type, createChartState()])
|
||
) as Record<ChartType, ReturnType<typeof createChartState>>;
|
||
|
||
//日期选择器
|
||
interface RangePicker extends Record<ChartType, [string, string]> {
|
||
placeholder: [string, string];
|
||
ranges: Record<string, [Dayjs, Dayjs]>;
|
||
}
|
||
|
||
// 创建日期选择器状态
|
||
const rangePicker = reactive<RangePicker>({
|
||
...Object.fromEntries(networkElementTypes.map(type => [
|
||
type,
|
||
[
|
||
dayjs('2024-09-20 00:00:00').valueOf().toString(),//模拟数据的日期设为默认日期
|
||
dayjs('2024-09-20 23:59:59').valueOf().toString()
|
||
]
|
||
])) as Record<ChartType, [string, string]>,
|
||
placeholder: [t('views.monitor.monitor.startTime'), t('views.monitor.monitor.endTime')] as [string, string],
|
||
ranges: {
|
||
[t('views.monitor.monitor.yesterday')]: [dayjs().subtract(1, 'day').startOf('day'), dayjs().subtract(1, 'day').endOf('day')],
|
||
[t('views.monitor.monitor.today')]: [dayjs().startOf('day'), dayjs()],
|
||
[t('views.monitor.monitor.week')]: [dayjs().startOf('week'), dayjs().endOf('week')],
|
||
[t('views.monitor.monitor.month')]: [dayjs().startOf('month'), dayjs().endOf('month')],
|
||
} as Record<string, [Dayjs, Dayjs]>,
|
||
});
|
||
|
||
// 创建可复用的图表初始化函数
|
||
const initChart = (type: ChartType) => {
|
||
nextTick(() => {
|
||
const state = chartStates[type];
|
||
const container = state.chartDom.value;
|
||
if (!container) return;
|
||
|
||
state.chart.value = markRaw(echarts.init(container, 'light'));
|
||
const option: echarts.EChartsOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
position: function(pt: any) {
|
||
return [pt[0], '10%'];
|
||
},
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: [],
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
boundaryGap: [0, '100%'],
|
||
},
|
||
legend: {
|
||
type: 'scroll',
|
||
orient: 'horizontal',
|
||
top: -5,
|
||
itemWidth: 20,
|
||
textStyle: {
|
||
color: '#646A73',
|
||
},
|
||
icon: 'circle',
|
||
selected: {},
|
||
},
|
||
grid: {
|
||
left: '10%',
|
||
right: '10%',
|
||
bottom: '15%',
|
||
},
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
],
|
||
series: [],
|
||
};
|
||
state.chart.value.setOption(option);
|
||
state.chart.value.resize(); // 确保图表正确调整大小
|
||
|
||
// 创建 ResizeObserver 实例
|
||
state.observer.value = new ResizeObserver(() => {
|
||
if (state.chart.value) {
|
||
state.chart.value.resize();
|
||
}
|
||
});
|
||
|
||
// 开始观察图表容器
|
||
state.observer.value.observe(container);
|
||
});
|
||
};
|
||
|
||
//结束拖拽事件
|
||
const onDragEnd = () => {
|
||
nextTick(() => {
|
||
chartOrder.value.forEach((type:any) => {
|
||
const state = chartStates[type as ChartType];
|
||
if (state.chart.value) {
|
||
state.chart.value.resize(); // 调整图表大小
|
||
// 重新设置图表选项,保留原有数
|
||
state.chart.value.setOption({
|
||
xAxis: { data: state.chartDataXAxisData },
|
||
series: state.chartDataYSeriesData,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
|
||
// 可复用的数据获取函数
|
||
const fetchData = async (type: ChartType) => {
|
||
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;
|
||
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;
|
||
});
|
||
// 建立连接
|
||
const options: OptionsType = {
|
||
url: '/ws',
|
||
params: {
|
||
subGroupID: networkElementTypes.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 res = JSON.parse(event.data);
|
||
const { code, data } = res;
|
||
if (code === RESULT_CODE_ERROR) {
|
||
console.warn(res.msg);
|
||
return;
|
||
}
|
||
|
||
// 订阅组信息
|
||
if (!data?.groupId) {
|
||
return;
|
||
}
|
||
|
||
// 处理四个图表的数据
|
||
networkElementTypes.forEach((type) => {
|
||
const state = chartStates[type];
|
||
const kpiEvent:any = data.data[type.toUpperCase()];
|
||
|
||
if (kpiEvent) {
|
||
// 更新 X 轴数据
|
||
if (kpiEvent.timeGroup) {
|
||
state.chartDataXAxisData.push(parseDateToStr(+kpiEvent.timeGroup));
|
||
if (state.chartDataXAxisData.length > 100) {
|
||
state.chartDataXAxisData.shift();
|
||
}
|
||
}
|
||
|
||
// 更新 Y 轴数据
|
||
state.chartDataYSeriesData.forEach(series => {
|
||
if (kpiEvent[series.customKey as string] !== undefined) {
|
||
series.data.push(+kpiEvent[series.customKey as string]);
|
||
if (series.data.length > 100) {
|
||
series.data.shift();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 更新图表
|
||
if (state.chart.value) {
|
||
state.chart.value.setOption({
|
||
xAxis: { data: state.chartDataXAxisData },
|
||
series: state.chartDataYSeriesData,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
type LineDataItem = {
|
||
value: number;
|
||
[key: string]: any;
|
||
};
|
||
interface CustomSeriesOption extends Omit<LineSeriesOption, 'data'> {
|
||
customKey?: string;
|
||
data: (number | LineDataItem)[];
|
||
}
|
||
// 创建可复用的图表渲染函数
|
||
const renderChart = (type: ChartType) => {
|
||
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(+item[series.customKey as string]);
|
||
}
|
||
}
|
||
|
||
// 更新图表
|
||
state.chart.value.setOption(
|
||
{
|
||
legend: { selected: state.chartLegendSelected },
|
||
xAxis: {
|
||
data: state.chartDataXAxisData,
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
},
|
||
series: state.chartDataYSeriesData,
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
],
|
||
},
|
||
{ replaceMerge: ['xAxis', 'series'] }
|
||
);
|
||
};
|
||
|
||
|
||
// 获取表头数据
|
||
const fetchKPITitle = async (type: ChartType) => {
|
||
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'));
|
||
}
|
||
};
|
||
|
||
// 初始化所有图表
|
||
onMounted(async () => {
|
||
ws.value = new WS();
|
||
await neInfoStore.fnNelist();
|
||
for (const type of networkElementTypes) {
|
||
await fetchKPITitle(type);
|
||
initChart(type);
|
||
fetchData(type); // 确保这行存在
|
||
}
|
||
|
||
});
|
||
|
||
// 在组件卸载时销毁图表实例
|
||
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();
|
||
}
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<PageContainer>
|
||
<div class="control-row">
|
||
<a-form layout="inline">
|
||
<a-form-item>
|
||
<a-switch
|
||
v-model:checked="realTimeEnabled"
|
||
@change="handleRealTimeSwitch"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<span class="switch-label">{{ realTimeEnabled ? t('views.dashboard.cdr.realTimeDataStart') : t('views.dashboard.cdr.realTimeDataStop') }}</span>
|
||
</a-form-item>
|
||
<!-- 这里可以添加更多的表单项 -->
|
||
</a-form>
|
||
</div>
|
||
<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"
|
||
: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="['ne']"
|
||
drag-allow-from=".drag-handle"
|
||
drag-ignore-from=".no-drag"
|
||
class="grid-item"
|
||
>
|
||
<div class="drag-handle"></div>
|
||
<a-card :bordered="false" class="card-container">
|
||
<template #title>
|
||
<span class="no-drag">{{ item.i.toUpperCase() }}</span>
|
||
</template>
|
||
<template #extra>
|
||
<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"
|
||
:ranges="rangePicker.ranges"
|
||
style="width: 100%"
|
||
@change="() => fetchData(item.i)"
|
||
></a-range-picker>
|
||
</template>
|
||
<div class='chart'>
|
||
<div :ref="el => { if (el) chartStates[item.i].chartDom.value = el as HTMLElement }"></div>
|
||
</div>
|
||
</a-card>
|
||
</GridItem>
|
||
</GridLayout>
|
||
</PageContainer>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.charts-container {
|
||
width: 100%;
|
||
min-height: 600px; // 减小最小高度
|
||
}
|
||
|
||
.grid-item {
|
||
overflow: visible; // 改为 visible 以确保拖拽手柄可见
|
||
position: relative;
|
||
}
|
||
|
||
.card-container {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
:deep(.ant-card-body) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 12px !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chart {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.chart > div {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.control-row {
|
||
margin-bottom: 16px;
|
||
background-color: #ffffff; // 改为白色背景
|
||
border-radius: 4px;
|
||
padding: 16px;
|
||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03); // 添加轻微阴影
|
||
border: 1px solid #f0f0f0; // 添加浅色边框
|
||
}
|
||
|
||
:deep(.ant-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(.ant-form-item-label) {
|
||
font-weight: 500; // 加粗标签文字
|
||
color: rgba(0, 0, 0, 0.85); // 调整标签颜色
|
||
}
|
||
|
||
.switch-label {
|
||
margin-left: 8px;
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.65); // 调整文字颜色
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.drag-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 20px;
|
||
height: 20px;
|
||
background-color: #1890ff;
|
||
cursor: move;
|
||
z-index: 100; // 增加 z-index 确保在最上层
|
||
}
|
||
|
||
/* 可以根据需要调整卡片标题的样式 */
|
||
:deep(.ant-card-head-title) {
|
||
padding-right: 25px; /* 为拖拽手柄留出空间 */
|
||
}
|
||
</style>
|