Files
fe.ems.vue3/src/views/perfManage/kpiKeyTarget/index.vue
2024-10-15 17:48:35 +08:00

630 lines
16 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 { 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>