9 Commits

Author SHA1 Message Date
TsMask
33a8ce97d3 chore: 更新版本号 2.241102 2024-11-02 15:47:18 +08:00
TsMask
6ee9d464fb feat: PCF导出有取消操作 2024-11-02 15:46:26 +08:00
TsMask
df5072bae7 fix: CDR-IMS显示呼叫-挂断时间 2024-11-02 15:46:05 +08:00
zhongzm
e12dce1f0f feat:网元指标添加其他指标选项 优化样式 2024-10-31 18:35:26 +08:00
TsMask
d0457fc285 fix: UDM鉴权签约用户勾选导出 2024-10-31 16:32:14 +08:00
zhongzm
63d32f0a39 feat:自定义网元指标概览 2024-10-31 10:30:16 +08:00
TsMask
cf5d08aaab chore: 更新版本号 2.241028 2024-10-28 16:53:21 +08:00
TsMask
7ad566d74f fix: 网元总览接口变更 2024-10-28 16:52:41 +08:00
TsMask
c312186d91 fix: UDM签约数据参数类型转换字符串参数 2024-10-28 16:52:05 +08:00
10 changed files with 994 additions and 195 deletions

View File

@@ -11,7 +11,7 @@ VITE_APP_NAME = "Core Network OMC"
VITE_APP_CODE = "OMC"
# 应用版本
VITE_APP_VERSION = "2.241018"
VITE_APP_VERSION = "2.241102"
# 接口基础URL地址-不带/后缀
VITE_API_BASE_URL = "/omc-api"

View File

@@ -11,7 +11,7 @@ VITE_APP_NAME = "Core Network OMC"
VITE_APP_CODE = "OMC"
# 应用版本
VITE_APP_VERSION = "2.241018"
VITE_APP_VERSION = "2.241102"
# 接口基础URL地址-不带/后缀
VITE_API_BASE_URL = "/omc-api"

View File

@@ -567,6 +567,8 @@ export default {
rowInfo: "Info",
type: "Type",
duration: "Duration",
seizureTime: "Call Start Time",
releaseTime: "Hangup Time",
caller: "Caller",
called: "Called",
result: "Result",
@@ -1085,6 +1087,13 @@ export default {
"layout2": "Layout 2",
"layout3": "Layout 3"
},
kpiOverView:{
"kpiChartTitle":"Overview of NE metrics",
"changeLine":"Change to Line Charts",
"changeBar":"Change to Bar Charts",
"chooseShowMetrics":"Select the metric you want to display",
"chooseMetrics":"Select an indicator",
},
},
traceManage: {
analysis: {

View File

@@ -567,6 +567,8 @@ export default {
rowInfo: "记录信息",
type: "记录类型",
duration: "通话时长",
seizureTime: "呼叫开始时间",
releaseTime: "挂断结束时间",
caller: "主叫",
called: "被叫",
result: "结果",
@@ -1085,6 +1087,14 @@ export default {
"layout2": "布局2",
"layout3": "布局3"
},
kpiOverView:{
"kpiChartTitle":"网元指标概览",
"changeLine":"切换为折线图",
"changeBar":"切换为柱状图",
"chooseShowMetrics":"选择需要显示的指标",
"chooseMetrics":"选择指标",
},
},
traceManage: {
analysis: {

View File

@@ -2,8 +2,7 @@
import { PageContainer } from 'antdv-pro-layout';
import { ColumnsType } from 'ant-design-vue/lib/table';
import { message } from 'ant-design-vue/lib';
import { reactive, toRaw, ref, onMounted, onBeforeUnmount, markRaw } from 'vue';
import { listMain } from '@/api/index';
import { reactive, ref, onMounted, onBeforeUnmount, markRaw } from 'vue';
import useI18n from '@/hooks/useI18n';
import { TooltipComponent } from 'echarts/components';
import { GaugeChart } from 'echarts/charts';
@@ -15,6 +14,8 @@ import { LabelLayout } from 'echarts/features';
import { useRoute } from 'vue-router';
import useAppStore from '@/store/modules/app';
import useDictStore from '@/store/modules/dict';
import { listAllNeInfo } from '@/api/ne/neInfo';
import { parseDateToStr } from '@/utils/date-utils';
const { getDict } = useDictStore();
const appStore = useAppStore();
const route = useRoute();
@@ -52,44 +53,55 @@ let indexColor = ref<DictType[]>([
let tableColumns: ColumnsType = [
{
title: t('views.index.object'),
dataIndex: 'name',
align: 'center',
key: 'status',
dataIndex: 'neName',
align: 'left',
},
{
title: t('views.index.realNeStatus'),
dataIndex: 'status',
align: 'center',
customRender(opt) {
if (opt.value == 'Normal') return t('views.index.normal');
return t('views.index.abnormal');
},
dataIndex: 'serverState',
align: 'left',
key: 'status',
},
{
title: t('views.index.reloadTime'),
dataIndex: 'refresh',
align: 'center',
dataIndex: 'serverState',
align: 'left',
customRender(opt) {
if (opt.value?.refreshTime) return parseDateToStr(opt.value?.refreshTime);
return '-';
},
},
{
title: t('views.index.version'),
dataIndex: 'version',
align: 'center',
dataIndex: 'serverState',
align: 'left',
customRender(opt) {
return opt.value?.version || '-';
},
},
{
title: t('views.index.serialNum'),
dataIndex: 'serialNum',
align: 'center',
dataIndex: 'serverState',
align: 'left',
customRender(opt) {
return opt.value?.sn || '-';
},
},
{
title: t('views.index.expiryDate'),
dataIndex: 'expiryDate',
align: 'center',
dataIndex: 'serverState',
align: 'left',
customRender(opt) {
return opt.value?.expire || '-';
},
},
{
title: t('views.index.ipAddress'),
dataIndex: 'ipAddress',
key: 'groupName',
align: 'center',
dataIndex: 'serverState',
align: 'left',
customRender(opt) {
return opt.value?.neIP || '-';
},
},
];
/**表格状态类型 */
@@ -130,12 +142,8 @@ type nfStateType = {
hostName: string;
/**操作系统信息 */
osInfo: string;
/**数据库信息 */
dbInfo: string;
/**IP地址 */
ipAddress: string;
/**端口 */
port: number;
/**版本 */
version: string;
/**CPU利用率 */
@@ -154,9 +162,7 @@ type nfStateType = {
let pronInfo: nfStateType = reactive({
hostName: '5gc',
osInfo: 'Linux 5gc 4.15.0-112-generic 2020 x86_64 GNU/Linux',
dbInfo: 'db v9.9.9',
ipAddress: '-',
port: 33030,
version: '-',
cpuUse: '-',
memoryUse: '-',
@@ -169,8 +175,8 @@ let pronInfo: nfStateType = reactive({
function fnGetList(one: boolean) {
if (tableState.loading) return;
one && (tableState.loading = true);
listMain().then(res => {
tableState.data = res;
listAllNeInfo({ bandStatus: true }).then(res => {
tableState.data = res.data;
tableState.loading = false;
var rightNum = 0;
var errorNum = 0;
@@ -253,17 +259,14 @@ const closeDrawer = () => {
function rowClick(record: any, index: any) {
return {
onClick: (event: any) => {
if (
toRaw(record).status == '异常' ||
toRaw(record).status == 'Abnormal'
) {
let pronData = JSON.parse(JSON.stringify(record.serverState));
if (!pronData.online) {
message.error(t('views.index.neStatus'), 2);
return false;
} else {
let pronData = toRaw(record);
const totalMemInKB = pronData.memUsage?.totalMem;
const nfUsedMemInKB = pronData.memUsage?.nfUsedMem;
const sysMemUsageInKB = pronData.memUsage?.sysMemUsage;
const totalMemInKB = pronData.mem?.totalMem;
const nfUsedMemInKB = pronData.mem?.nfUsedMem;
const sysMemUsageInKB = pronData.mem?.sysMemUsage;
// 将KB转换为MB
const totalMemInMB = Math.round((totalMemInKB / 1024) * 100) / 100;
@@ -273,19 +276,17 @@ function rowClick(record: any, index: any) {
//渲染详细信息
pronInfo = {
hostName: pronData.hostName,
osInfo: pronData.osInfo,
dbInfo: pronData.dbInfo,
ipAddress: pronData.ipAddress,
port: pronData.port,
hostName: pronData.hostname,
osInfo: pronData.os,
ipAddress: pronData.neIP,
version: pronData.version,
cpuUse:
pronData.name +
pronData.neName +
':' +
pronData.cpuUsage?.nfCpuUsage / 100 +
pronData.cpu?.nfCpuUsage / 100 +
'%; ' +
'SYS:' +
pronData.cpuUsage?.sysCpuUsage / 100 +
pronData.cpu?.sysCpuUsage / 100 +
'%',
memoryUse:
'Total:' +
@@ -298,8 +299,8 @@ function rowClick(record: any, index: any) {
sysMemUsageInMB +
'MB',
capability: pronData.capability,
serialNum: pronData.serialNum,
expiryDate: pronData.expiryDate,
serialNum: pronData.sn,
expiryDate: pronData.expire,
};
}
visible.value = true;
@@ -388,11 +389,11 @@ onBeforeUnmount(() => {
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<div v-if="record.status == '正常' || record.status == 'Normal'">
<a-tag color="blue">{{ record.name }}</a-tag>
<div v-if="record.serverState.online">
<a-tag color="blue">{{ t('views.index.normal') }}</a-tag>
</div>
<div v-else>
<a-tag color="pink">{{ record.name }}</a-tag>
<a-tag color="pink">{{ t('views.index.abnormal') }}</a-tag>
</div>
</template>
</template>

View File

@@ -161,6 +161,13 @@ let tableColumns: ColumnsType = [
return cdrJSON.calledParty;
},
},
{
title: t('views.dashboard.cdr.result'),
dataIndex: 'cdrJSON',
key: 'cause',
align: 'left',
width: 150,
},
{
title: t('views.dashboard.cdr.duration'),
dataIndex: 'cdrJSON',
@@ -175,20 +182,29 @@ let tableColumns: ColumnsType = [
},
},
{
title: t('views.dashboard.cdr.result'),
title: t('views.dashboard.cdr.seizureTime'),
dataIndex: 'cdrJSON',
key: 'cause',
align: 'left',
width: 150,
},
{
title: t('views.dashboard.cdr.time'),
dataIndex: 'cdrJSON',
align: 'center',
width: 150,
width: 200,
customRender(opt) {
const cdrJSON = opt.value;
return parseDateToStr(+cdrJSON.releaseTime * 1000);
if (typeof cdrJSON.seizureTime === 'number') {
return parseDateToStr(+cdrJSON.seizureTime * 1000);
}
return cdrJSON.seizureTime;
},
},
{
title: t('views.dashboard.cdr.releaseTime'),
dataIndex: 'cdrJSON',
align: 'left',
width: 200,
customRender(opt) {
const cdrJSON = opt.value;
if (typeof cdrJSON.releaseTime === 'number') {
return parseDateToStr(+cdrJSON.releaseTime * 1000);
}
return cdrJSON.releaseTime;
},
},
{
@@ -725,61 +741,73 @@ onBeforeUnmount(() => {
</template>
</template>
<template #expandedRowRender="{ record }">
<div style="width: 46%; padding-left: 32px; padding-bottom: 16px">
<a-divider orientation="left">
{{ t('views.dashboard.cdr.cdrInfo') }}
</a-divider>
<div>
<span>{{ t('views.ne.common.neName') }}: </span>
<span>{{ record.neName }}</span>
</div>
<div>
<span>{{ t('views.ne.common.rmUid') }}: </span>
<span>{{ record.rmUID }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.time') }}: </span>
<span>{{ parseDateToStr(+record.timestamp * 1000) }}</span>
</div>
<a-divider orientation="left">
{{ t('views.dashboard.cdr.rowInfo') }}
</a-divider>
<div>
<span>{{ t('views.dashboard.cdr.type') }}: </span>
<DictTag
:options="dict.cdrCallType"
:value="record.cdrJSON.callType"
/>
</div>
<div>
<span>{{ t('views.dashboard.cdr.duration') }}: </span>
<span v-if="record.cdrJSON.callType !== 'sms'">
{{ parseDuration(record.cdrJSON.callDuration) }}
</span>
<span v-else> - </span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.caller') }}: </span>
<span>{{ record.cdrJSON.callerParty }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.called') }}: </span>
<span>{{ record.cdrJSON.calledParty }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.result') }}: </span>
<span v-if="record.cdrJSON.callType !== 'sms'">
<a-row :gutter="16">
<a-col :lg="5" :md="12" :xs="24">
<a-divider orientation="left">
{{ t('views.dashboard.cdr.cdrInfo') }}
</a-divider>
<div>
<span>{{ t('views.ne.common.neName') }}: </span>
<span>{{ record.neName }}</span>
</div>
<div>
<span>{{ t('views.ne.common.rmUid') }}: </span>
<span>{{ record.rmUID }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.time') }}: </span>
<span>{{ parseDateToStr(+record.timestamp * 1000) }}</span>
</div>
</a-col>
<a-col :lg="6" :md="12" :xs="24">
<a-divider orientation="left">
{{ t('views.dashboard.cdr.rowInfo') }}
</a-divider>
<div>
<span>{{ t('views.dashboard.cdr.type') }}: </span>
<DictTag
:options="dict.cdrSipCode"
:value="record.cdrJSON.cause"
value-default="0"
:options="dict.cdrCallType"
:value="record.cdrJSON.callType"
/>
</span>
<span v-else>
{{ t('views.dashboard.cdr.resultOk') }}
</span>
</div>
</div>
</div>
<div>
<span>{{ t('views.dashboard.cdr.duration') }}: </span>
<span v-if="record.cdrJSON.callType !== 'sms'">
{{ parseDuration(record.cdrJSON.callDuration) }}
</span>
<span v-else> - </span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.caller') }}: </span>
<span>{{ record.cdrJSON.callerParty }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.called') }}: </span>
<span>{{ record.cdrJSON.calledParty }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.result') }}: </span>
<span v-if="record.cdrJSON.callType !== 'sms'">
<DictTag
:options="dict.cdrSipCode"
:value="record.cdrJSON.cause"
value-default="0"
/>
</span>
<span v-else>
{{ t('views.dashboard.cdr.resultOk') }}
</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.seizureTime') }}: </span>
<span>{{ record.cdrJSON.seizureTime }}</span>
</div>
<div>
<span>{{ t('views.dashboard.cdr.releaseTime') }}: </span>
<span>{{ record.cdrJSON.releaseTime }}</span>
</div>
</a-col>
</a-row>
</template>
</a-table>
</a-card>

View File

@@ -316,8 +316,8 @@ function fnModalOk() {
.then(e => {
modalState.confirmLoading = true;
const from = toRaw(modalState.from);
from.neId = queryParams.neId || '-';
from.algoIndex = `${from.algoIndex}`;
from.neId = queryParams.neId || '-';
const result = from.id
? updateUDMAuth(from)
: from.num === 1
@@ -484,33 +484,27 @@ function fnRecordDelete(imsi: string) {
/**
* UDM鉴权用户勾选导出
*/
function fnRecordExport(type: string = 'txt') {
function fnRecordExport(type: string = 'txt') {
const selectLen = tableState.selectedRowKeys.length;
if (selectLen <= 0) return;
const rows: Record<string, any>[] = tableState.data.filter(
(row: Record<string, any>) =>
tableState.selectedRowKeys.indexOf(row.imsi) >= 0
);
let content = '';
if (type == 'txt') {
for (const row of rows) {
const opc = row.opc === '-' ? '' : `,${row.opc}`;
content += `${row.imsi},${row.ki},${row.algoIndex},${row.amf}${opc}\r\n`;
}
}
if (type == 'csv') {
content = `IMSI,ki,Algo Index,AMF,OPC\r\n`;
for (const row of rows) {
const opc = row.opc === '-' ? '' : `,${row.opc}`;
content += `${row.imsi},${row.ki},${row.algoIndex},${row.amf}${opc}\r\n`;
}
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
saveAs(blob, `UDMAuth_${Date.now()}.${type}`);
const neId = queryParams.neId;
if (!neId) return;
const hide = message.loading(t('common.loading'), 0);
exportUDMAuth({ type: type, neId: neId, imsis: tableState.selectedRowKeys })
.then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
message.success(t('common.msgSuccess', { msg: t('common.export') }), 3);
saveAs(res.data, `UDMAuth_select_${Date.now()}.${type}`);
} else {
message.error(`${res.msg}`, 3);
}
})
.finally(() => {
hide();
});
}
/**列表导出全部数据 */
function fnExportList(type: string) {
const neId = queryParams.neId;

View File

@@ -780,9 +780,6 @@ onMounted(() => {
ok-text="TXT"
ok-type="default"
@confirm="fnExportList('txt')"
:show-cancel="false"
cancel-text="CSV"
@cancel="fnExportList('csv')"
>
<a-button type="dashed">
<template #icon><ExportOutlined /></template>

View File

@@ -625,6 +625,10 @@ function fnModalOk() {
.map((item: number) => `${item}`.padStart(2, '0'))
.join('');
from.activeTime = `${from.activeTime}`;
from.rfspIndex = `${from.rfspIndex}`;
from.regTimer = `${from.regTimer}`;
from.ueUsageType = `${from.ueUsageType}`;
from.neId = queryParams.neId || '-';
const result = from.id
? updateUDMSub(from)
@@ -827,49 +831,30 @@ function fnRecordDelete(imsi: string) {
/**
* UDM签约用户导出
*/
function fnRecordExport(type: string = 'txt') {
function fnRecordExport(type: string = 'txt') {
const selectLen = tableState.selectedRowKeys.length;
if (selectLen <= 0) return;
const rows: Record<string, any>[] = tableState.data.filter(
(row: Record<string, any>) =>
tableState.selectedRowKeys.indexOf(row.imsi) >= 0
);
let content = '';
if (type == 'txt') {
for (const row of rows) {
const epsDat = [
row.epsFlag,
row.epsOdb,
row.hplmnOdb,
row.ard,
row.epstpl,
row.contextId,
row.apnContext,
row.staticIp,
].join(',');
content += `${row.imsi},${row.msisdn},${row.ambr},${row.nssai},${row.arfb},${row.sar},${row.rat},${row.cn},${row.smfSel},${row.smData},${epsDat}\r\n`;
}
}
if (type == 'csv') {
content = `imsi,msisdn,ambr,nssai,arfb,sar,rat,cn,smf_sel,sm_dat,eps_dat\r\n`;
for (const row of rows) {
const epsDat = [
row.epsFlag,
row.epsOdb,
row.hplmnOdb,
row.ard,
row.epstpl,
row.contextId,
row.apnContext,
row.staticIp,
].join(',');
content += `${row.imsi},${row.msisdn},${row.ambr},${row.nssai},${row.arfb},${row.sar},${row.rat},${row.cn},${row.smfSel},${row.smData},${epsDat}\r\n`;
}
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
saveAs(blob, `UDMSub_${Date.now()}.${type}`);
const neId = queryParams.neId;
if (!neId) return;
const hide = message.loading(t('common.loading'), 0);
exportUDMSub({ type: type, neId: neId, imsis: tableState.selectedRowKeys })
.then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
message.success({
content: t('common.msgSuccess', { msg: t('common.export') }),
duration: 2,
});
saveAs(res.data, `UDMSub_select_${Date.now()}.${type}`);
} else {
message.error({
content: `${res.msg}`,
duration: 2,
});
}
})
.finally(() => {
hide();
});
}
/**列表导出 */

View File

@@ -1,16 +1,791 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { PageContainer } from 'antdv-pro-layout';
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue';
import * as echarts from 'echarts/core';
import { LegendComponent } from 'echarts/components';
import { LineChart, BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, TitleComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { getKPITitle, listKPIData } from '@/api/perfManage/goldTarget';
import useI18n from '@/hooks/useI18n';
import { message, Modal } from 'ant-design-vue';
import { RESULT_CODE_ERROR, RESULT_CODE_SUCCESS } from '@/constants/result-constants';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { OptionsType, WS } from '@/plugins/ws-websocket';
import { generateColorRGBA } from '@/utils/generate-utils';
import { BarChartOutlined, LineChartOutlined, UnorderedListOutlined, DownOutlined, MoreOutlined } from '@ant-design/icons-vue';
// 在这里定义 ChartDataItem 接口
interface ChartDataItem {
date: string; // 将存储完整的时间字符串,包含时分秒
[kpiId: string]: string | number; // 动态指标
}
echarts.use([
LineChart,
BarChart,
GridComponent,
TooltipComponent,
TitleComponent,
CanvasRenderer,
LegendComponent
]);
// WebSocket连接
const ws = ref<WS | null>(null);
//日期范围响应式变量
const dateRange = ref<[string, string]>([
dayjs().startOf('day').valueOf().toString(),
dayjs().valueOf().toString()
]);
//实时数据状态
const isRealtime = ref(false);
//图表数据响应式数组
const chartData = ref<ChartDataItem[]>([]);
//储存Echarts的实例的变量
let chart: echarts.ECharts | null = null;
//observer 变量 监听图表容器大小
let observer: ResizeObserver | null = null;
//日期变化时更新图表数据
const handleDateChange = (
value: [string, string] | [Dayjs, Dayjs],
dateStrings: [string, string]
) => {
if (!dateStrings[0] || !dateStrings[1]) {
console.warn('Invalid date strings:', dateStrings);
return;
}
dateRange.value = [
dayjs(dateStrings[0]).valueOf().toString(),
dayjs(dateStrings[1]).valueOf().toString()
];
fetchChartData();
};
//切换实时数据
const toggleRealtime = () => {
fnRealTimeSwitch(isRealtime.value);
};
// 定义所有网元类型
const ALL_NE_TYPES = ['AMF', 'SMF', 'UPF', 'MME', 'IMS', 'SMSC'] as const;
type NeType = typeof ALL_NE_TYPES[number];
// 定义要筛选的指标 ID按网元类型组织
const TARGET_KPI_IDS: Record<NeType, string[]> = {
AMF: ['AMF.02', 'AMF.03', 'AMF.A.07', 'AMF.A.08'],
SMF: ['SMF.02', 'SMF.03', 'SMF.04', 'SMF.05'],
UPF: ['UPF.03', 'UPF.04', 'UPF.05', 'UPF.06'],
MME: ['MME.A.01', 'MME.A.02', 'MME.A.03'],
IMS: ['SCSCF.01', 'SCSCF.02', 'SCSCF.05', 'SCSCF.06'],
SMSC: ['SMSC.A.01', 'SMSC.A.02', 'SMSC.A.03']
};
// 实时数据开关函数
const fnRealTimeSwitch = (bool: boolean) => {
if (bool) {
if(!ws.value) {
ws.value = new WS();
}
chartData.value = [];
const options: OptionsType = {
url: '/ws',
params: {
subGroupID: ALL_NE_TYPES.map(type => `10_${type}_001`).join(','),
},
onmessage: wsMessage,
onerror: wsError,
};
ws.value.connect(options);
} else if(ws.value) {
ws.value.close();//断开链接
ws.value = null;//清空链接
}
}
// 接收数据后错误回调
const wsError = () => {
message.error(t('common.websocketError'));
}
// 接收数据后回调
const wsMessage = (res: Record<string, any>) => {
const { code, data } = res;
if (code === RESULT_CODE_ERROR) {
console.warn(res.msg);
return;
}
if (!data?.groupId) {
return;
}
const kpiEvent = data.data;
if (!kpiEvent) {
return;
}
// 构造新的数据点
const newData: ChartDataItem = {
date: kpiEvent.timeGroup
? kpiEvent.timeGroup.toString()
: Date.now().toString()
};
// 只添加已选中的指标的数据
selectedKPIs.value.forEach(kpiId => {
if (kpiEvent[kpiId] !== undefined) {
newData[kpiId] = Number(kpiEvent[kpiId]);
} else {
newData[kpiId] = 0;
}
});
// 更新数据
updateChartData(newData);
};
// 获取图表数据方法
const fetchChartData = async () => {
if (kpiColumns.value.length === 0) {
console.warn('No KPI columns available');
updateChart();
return;
}
try {
const [startTime, endTime] = dateRange.value;
if (!startTime || !endTime) {
console.warn('Invalid date range:', dateRange.value);
return;
}
const allData: any[] = [];
// 使用 ALL_NE_TYPES 遍历网元类型
for (const neType of ALL_NE_TYPES) {
const params = {
neType,
neId: '001',
startTime: String(startTime),
endTime: String(endTime),
sortField: 'timeGroup',
sortOrder: 'asc',
interval: 5,
kpiIds: TARGET_KPI_IDS[neType].join(',')
};
const res = await listKPIData(params);
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
allData.push(...res.data);
}
}
// 按时间分组合数据
const groupedData = new Map<string, any>();
allData.forEach(item => {
const timeKey = item.timeGroup;
if (!groupedData.has(timeKey)) {
groupedData.set(timeKey, { timeGroup: timeKey });
}
const existingData = groupedData.get(timeKey);
Object.assign(existingData, item);
});
// 直接将处理后的数据赋值给 chartData.value
chartData.value = Array.from(groupedData.values())
.sort((a, b) => Number(a.timeGroup) - Number(b.timeGroup))
.map(item => {
const dataItem: ChartDataItem = {
date: item.timeGroup.toString(),
};
kpiColumns.value.forEach(kpi => {
dataItem[kpi.kpiId] = Number(item[kpi.kpiId]) || 0;
});
return dataItem;
});
updateChart();
} catch (error) {
console.error('Failed to fetch chart data:', error);
message.error(t('common.getInfoFail'));
}
};
// 添加一个 Map 来存储每个指标的临时固定颜色
const kpiColors = new Map<string, string>();
// 定义图表类型的响应式变量
const chartType = ref<'line' | 'bar'>('line');
// 添加切换图表类型的方法
const toggleChartType = () => {
chartType.value = chartType.value === 'line' ? 'bar' : 'line';
updateChart();
};
// 更新图表
const updateChart = () => {
if (!chart || !kpiColumns.value.length) return;//首先检查图表实例和指标是否存在
//过滤出已选择的指标列
const filteredColumns = kpiColumns.value.filter(col => selectedKPIs.value.includes(col.kpiId));
const legendData = filteredColumns.map(item => item.title);//创建图例数据数组,包含所有选中的指标的标题
//为每个选中的指标创建一个系列配置
const series = filteredColumns.map(item => {
const color = kpiColors.get(item.kpiId) || generateColorRGBA();
if (!kpiColors.has(item.kpiId)) {
kpiColors.set(item.kpiId, color);//保持指标颜色的临时一致性
}
return {
name: item.title,
type: chartType.value, // 使用当前选择的图表类型
data: chartData.value.length > 0
? chartData.value.map(dataItem => dataItem[item.kpiId] || 0)
: [0],
smooth: chartType.value === 'line', // 只在折线图时使用平滑
symbol: chartType.value === 'line' ? 'circle' : undefined, // 只在折线图时显示标记
symbolSize: chartType.value === 'line' ? 6 : undefined,
showSymbol: chartType.value === 'line',
itemStyle: { color }
};
});
//图表配置对象
const option = {
title: {
text: t('views.perfManage.kpiOverView.kpiChartTitle'),
left: 'center'
},
tooltip: {
trigger: 'axis',
position: function(pt: any) {
return [pt[0], '10%'];
},
},
legend: {//图例配置
data: legendData,
type: 'scroll',
orient: 'horizontal',
top: 25,
textStyle: {
fontSize: 12
},
selected: Object.fromEntries(legendData.map(name => [name, true])),
show: true,
left: 'center',
width: '80%',
height: 50,
padding: [5, 10],
},
grid: { //网格配置
left: '3%',
right: '4%',
bottom: '3%',
top: 100,
containLabel: true
},
xAxis: { //x轴配置
type: 'category',
boundaryGap: false,
data: chartData.value.length > 0
? chartData.value.map(item => {
// 将时间戳转换为包含时分秒的格式
return dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss');
})
: [''],
axisLabel: {
formatter: (value: string) => {
// 自定义 x 轴标签的显示格式
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
},
rotate: 0,
interval: 'auto', // 自动计算显示间隔
align: 'right'
}
},
yAxis: { // y轴配置
type: 'value',
axisLabel: {
formatter: '{value}'
},
// 添加自动计算的分割段数
splitNumber: 5,
// 添加自动计算的最小/最大值围
scale: true
},
series: series //配置数据
};
chart.setOption(option);//使用新的配置更新图表
chart.resize();//调整图表大小以适应容器
// 如果已经有 observer先断开连接
if (observer) {
observer.disconnect();
}
// 创建新的 ResizeObserver
observer = new ResizeObserver(() => {
if (chart) {
chart.resize();
}
});
// 观察图表容器
const container = document.getElementById('chartContainer');
if (container) {
observer.observe(container);
}
};
//钩子函数
onMounted(async () => {
try {
// 获取所有网元的指标
await fetchSpecificKPI();
await nextTick();
const container = document.getElementById('chartContainer');
if (container && !chart) {
chart = echarts.init(container);
if (kpiColumns.value.length > 0) {
updateChart();
await fetchChartData();
} else {
console.warn('No KPI columns available after fetching');
}
} else if (chart) {
console.warn('Chart already initialized, skipping initialization');
} else {
console.error('Chart container not found');
}
} catch (error) {
console.error('Failed to initialize:', error);
message.error(t('common.initFail'));
}
});
// 定义指列类型
interface KPIColumn {
title: string;
dataIndex: string;
key: string;
kpiId: string;
neType: string; // 添加网元类型字段
}
// 存储指标列信
const kpiColumns = ref<KPIColumn[]>([]);
// 添加选中指标的的状态
const selectedKPIs = ref<string[]>([]);
// 添加对话框可见性状态
const isModalVisible = ref(false);
// 添加临时存储下拉框选择的数组
const tempSelectedKPIs = ref<string[]>([]);
// 添加一个变量保存打开对话框时的选择状态
const originalSelectedKPIs = ref<string[]>([]);
// 打开对话框的方法
const showKPISelector = () => {
// 保存当前的选择状态
originalSelectedKPIs.value = [...selectedKPIs.value];
// 初始化临时选择为当前已选择的其他指标
const primaryKPIs = Object.values(TARGET_KPI_IDS).flat();
tempSelectedKPIs.value = selectedKPIs.value.filter(kpiId =>
!primaryKPIs.includes(kpiId)
);
isModalVisible.value = true;
};
// 保存选中指标到 localStorage 的方法
const saveSelectedKPIs = () => {
localStorage.setItem('selectedKPIs', JSON.stringify(selectedKPIs.value));
};
// 取消按钮的处理方法
const handleModalCancel = () => {
// 恢复到打开对话框时的选择状态
selectedKPIs.value = [...originalSelectedKPIs.value];
// 清空临时选择
tempSelectedKPIs.value = [];
isModalVisible.value = false;
};
// 确认按钮的处理方法
const handleModalOk = () => {
// 获取主要指标列表
const primaryKPIs = Object.values(TARGET_KPI_IDS).flat();
// 获取当前在主界面选中的主要指标
const selectedPrimaryKPIs = selectedKPIs.value.filter(kpiId =>
primaryKPIs.includes(kpiId)
);
// 合并选中的主要指标和临时选中的其他指标
selectedKPIs.value = Array.from(new Set([
...selectedPrimaryKPIs, // 只包含已选中的主要指标
...tempSelectedKPIs.value // 临时选中的其他指标
]));
// 清空临时选择和原始选择
tempSelectedKPIs.value = [];
originalSelectedKPIs.value = [];
// 保存选择并更新图表
saveSelectedKPIs();
updateChart();
isModalVisible.value = false;
};
// 获取网元指标
const fetchSpecificKPI = async () => {
const language = currentLocale.value.split('_')[0] === 'zh' ? 'cn' : currentLocale.value.split('_')[0];
try {
let allKPIs: KPIColumn[] = [];
// 1. 获取所有网元的全部指标
for (const neType of ALL_NE_TYPES) {
const res = await getKPITitle(neType.toUpperCase());
if (res.code === RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
// 转换指标格式
const formattedKPIs = res.data.map(item => ({
title: item[`${language}Title`],
dataIndex: item.kpiId,
key: item.kpiId,
kpiId: item.kpiId,
neType: neType // 添加网元类型信息
}));
// 添加到所有指标数组
allKPIs = [...allKPIs, ...formattedKPIs];
}
}
// 2. 更新所有指标到 kpiColumns
kpiColumns.value = allKPIs;
// 3. 尝试加载保存的选择
const savedKPIs = localStorage.getItem('selectedKPIs');
if (savedKPIs) {
// 确保保存的选择仍然存在于当前指标中
const validSavedKPIs = JSON.parse(savedKPIs).filter(
(kpiId: string) => kpiColumns.value.some(col => col.kpiId === kpiId)
);
if (validSavedKPIs.length > 0) {
selectedKPIs.value = validSavedKPIs;
} else {
// 如果没有有效的保存选择则默认选择<E98089><E68BA9>要指标
selectedKPIs.value = Object.values(TARGET_KPI_IDS).flat();
}
} else {
// 如果没有保存的选择,则默认选择重要指标
selectedKPIs.value = Object.values(TARGET_KPI_IDS).flat();
}
if (kpiColumns.value.length === 0) {
console.warn('No KPIs found');
} else {
console.log(`Found ${kpiColumns.value.length} total KPIs`);
}
return kpiColumns.value;
} catch (error) {
console.error('Failed to fetch KPI titles:', error);
message.error(t('common.getInfoFail'));
return [];
}
};
// onUnmounted 钩子
onUnmounted(() => {
if(ws.value && ws.value.state() === WebSocket.OPEN) {
ws.value.close();
}
if (observer) {
observer.disconnect();
observer = null;
}
if (chart) {
chart.dispose();
chart = null;
}
// 可选:在组件卸载时保存选择
saveSelectedKPIs();
});
const { t, currentLocale } = useI18n();
// 更新图表数据方法
const updateChartData = (newData: ChartDataItem) => {
chartData.value.push(newData);
if (chartData.value.length > 100) {
chartData.value.shift();
}
if (chart) {
chart.setOption({
xAxis: {
data: chartData.value.map(item =>
dayjs(Number(item.date)).format('YYYY-MM-DD HH:mm:ss')
)
},
series: selectedKPIs.value.map(kpiId => ({
type: chartType.value, // 使用当前选择的图表类型
data: chartData.value.map(item => item[kpiId] || 0)
}))
});
}
};
// groupedKPIs 计算属性,使用 TARGET_KPI_IDS 来分组过滤
const groupedKPIs = computed(() => {
const groups: Record<string, KPIColumn[]> = {};
ALL_NE_TYPES.forEach(neType => {
// 使用 TARGET_KPI_IDS 中定义的指标 ID 来过滤
const targetIds = TARGET_KPI_IDS[neType];
groups[neType] = kpiColumns.value.filter(kpi =>
targetIds.includes(kpi.kpiId)
);
});
return groups;
});
// 计算其他指标
const secondaryKPIs = computed(() => {
const groups: Record<string, KPIColumn[]> = {};
if (kpiColumns.value.length === 0) {
console.warn('No KPI columns available');
return groups;
}
ALL_NE_TYPES.forEach(neType => {
// 获取当前网元类型的主要指标 ID
const primaryIds = TARGET_KPI_IDS[neType];
// 从所有指标中筛选出当前网元其他指标
groups[neType] = kpiColumns.value.filter(kpi => {
// 检查是否不在主要指标列表中
const isNotPrimary = !primaryIds.includes(kpi.kpiId);
// 检查是否属于当前网元类型
// 使用 getKPITitle API 返回的原始数据中的网元类型信息
const isCurrentNeType = kpi.neType === neType;
return isCurrentNeType && isNotPrimary;
});
});
return groups;
});
// 添加处理其他指标选择变化的方法
const handleSecondaryKPIChange = (kpiId: string, checked: boolean) => {
if (checked) {
// 如果选中,将指标 ID 添加到临时列表
if (!tempSelectedKPIs.value.includes(kpiId)) {
tempSelectedKPIs.value = [...tempSelectedKPIs.value, kpiId];
}
} else {
// 如果取消选中,从临时列表中移除指标 ID
tempSelectedKPIs.value = tempSelectedKPIs.value.filter(id => id !== kpiId);
}
};
onMounted(() => {});
</script>
<template>
<PageContainer>
<a-card :bordered="false" :body-style="{ padding: '0px' }">
<h1>kpiOverView</h1>
</a-card>
</PageContainer>
<div class="kpi-overview">
<div class="controls">
<a-range-picker
v-model:value="dateRange"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:value-format="'x'"
:disabled="isRealtime"
@change="handleDateChange"
/>
<a-button @click="showKPISelector">
<template #icon>
<unordered-list-outlined />
</template>
{{t('views.perfManage.kpiOverView.chooseMetrics')}}
</a-button>
<a-button @click="toggleChartType">
<template #icon>
<bar-chart-outlined v-if="chartType === 'line'" />
<line-chart-outlined v-else />
</template>
{{ chartType === 'line' ? t('views.perfManage.kpiOverView.changeLine') : t('views.perfManage.kpiOverView.changeBar') }}
</a-button>
<a-form-item :label="isRealtime ? t('views.dashboard.cdr.realTimeDataStart') : t('views.dashboard.cdr.realTimeDataStop')">
<a-switch
v-model:checked="isRealtime"
@change="toggleRealtime"
/>
</a-form-item>
</div>
<div id="chartContainer" class="chart-container"></div>
<!-- 修改指标选择对话框 -->
<a-modal
v-model:visible="isModalVisible"
title="选择要显示的指标"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="800px"
:bodyStyle="{ maxHeight: '600px', overflow: 'auto' }"
>
<a-checkbox-group v-model:value="selectedKPIs">
<div class="kpi-checkbox-list">
<a-card
v-for="neType in ALL_NE_TYPES"
:key="neType"
class="ne-type-card"
:bordered="false"
>
<template #title>
<span class="card-title">{{ neType.toUpperCase() }}</span>
</template>
<template #extra>
<a-dropdown v-if="secondaryKPIs[neType]?.length" trigger="click">
<a-button type="link" size="small">
<more-outlined />
<down-outlined />
<span class="secondary-count">({{ secondaryKPIs[neType].length }})</span>
</a-button>
<template >
<div class="secondary-kpi-menu" @click.stop>
<div
v-for="kpi in secondaryKPIs[neType]"
:key="kpi.kpiId"
class="secondary-kpi-item"
@click.stop
>
<a-checkbox
:value="kpi.kpiId"
:checked="tempSelectedKPIs.includes(kpi.kpiId)"
@change="(e) => handleSecondaryKPIChange(kpi.kpiId, e.target.checked)"
@click.stop
>
{{ kpi.title }}
</a-checkbox>
</div>
</div>
</template>
</a-dropdown>
</template>
<div class="ne-type-items">
<div
v-for="kpi in groupedKPIs[neType]"
:key="kpi.kpiId"
class="kpi-checkbox-item"
>
<a-checkbox :value="kpi.kpiId">
{{ kpi.title }}
</a-checkbox>
</div>
</div>
</a-card>
</div>
</a-checkbox-group>
</a-modal>
</div>
</template>
<style lang="less" scoped></style>
<style scoped>
/* 基础布局样式 */
.kpi-overview {
padding: 20px;
}
.controls {
display: flex;
gap: 16px;
margin-bottom: 20px;
align-items: center;
}
.chart-container {
height: calc(100vh - 160px);
width: 100%;
}
/* 指标选择对话框样式 */
.kpi-checkbox-list {
padding: 8px;
width: 100%;
}
/* 网元指标列表样式 */
.ne-type-items {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.kpi-checkbox-item {
padding: 8px 12px;
border-radius: 4px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 其他指标下拉菜单样式 */
.secondary-kpi-menu {
background: #fff;
padding: 4px 0;
border-radius: 2px;
box-shadow: 0 3px 6px -4px rgba(0,0,0,.12), 0 6px 16px 0 rgba(0,0,0,.08);
max-height: 300px;
overflow-y: auto;
min-width: 200px;
}
.secondary-kpi-item {
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
/* 组件统一样式 */
:deep(.ant-form-item),
:deep(.ant-picker),
:deep(.ant-btn) {
height: 32px;
}
:deep(.ant-form-item) {
margin-bottom: 0;
display: flex;
align-items: center;
}
:deep(.ant-checkbox-wrapper) {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 添加次要指标数量的样式 */
.secondary-count {
margin-left: 4px;
font-size: 12px;
}
</style>