Files
fe.ems.vue3/src/views/neData/base-station/topology.vue
2025-10-23 14:15:46 +08:00

642 lines
18 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 { reactive, onMounted, ref, onBeforeUnmount, useTemplateRef } from 'vue';
import { Graph, GraphData, Menu, Tooltip, Util } from '@antv/g6';
import { listAMFNbStatelist } from '@/api/neData/amf';
import { parseBasePath } from '@/plugins/file-static-url';
import { edgeLineAnimateState } from '@/views/monitor/topologyBuild/hooks/registerEdge';
import { nodeImageAnimateState } from '@/views/monitor/topologyBuild/hooks/registerNode';
import useNeListStore from '@/store/modules/ne_list';
import useI18n from '@/hooks/useI18n';
import { RESULT_CODE_SUCCESS } from '@/constants/result-constants';
import { stateNeInfo } from '@/api/ne/neInfo';
import { parseDateToStr } from '@/utils/date-utils';
import { useFullscreen } from '@vueuse/core';
import { listMMENbStatelist } from '@/api/neData/mme';
const { t } = useI18n();
const neListStore = useNeListStore();
/**图DOM节点实例对象 */
const graphG6Dom = useTemplateRef('graphG6Dom');
/**图数据 */
const graphData = reactive<Record<string, any>>({
nodes: [
{
id: 'omc',
label: 'OMC',
img: parseBasePath('/svg/service_db.svg'),
},
{
id: 'amf1',
label: 'amf1',
img: parseBasePath('/svg/service.svg'),
},
{
id: 'amf2',
label: 'amf2',
img: parseBasePath('/svg/service.svg'),
},
{
id: 'base1',
label: 'base1',
img: parseBasePath('/svg/base.svg'),
},
{
id: 'base2',
label: 'base2',
img: parseBasePath('/svg/base.svg'),
},
],
edges: [
{
source: 'omc',
target: 'amf1',
},
{
source: 'omc',
target: 'amf2',
},
{
source: 'amf1',
target: 'base1',
},
{
source: 'amf2',
target: 'base1',
},
{
source: 'amf2',
target: 'base2',
},
],
});
/**图实例对象 */
const graphG6 = ref<any>(null);
/**图节点右击菜单 */
const graphNodeMenu = new Menu({
offsetX: 6,
offseY: 10,
itemTypes: ['node'],
getContent(evt) {
if (!evt) return t('views.monitor.topologyBuild.graphNotInfo');
const { id, label, nType, nInfo }: any = evt.item?.getModel();
if (['GNB', 'ENB'].includes(nType)) {
return `
<div
style="
display: flex;
flex-direction: column;
width: 140px;
"
>
<span>
${t('views.neData.baseStation.name')}:
${label ?? '--'}
</span>
</div>
`;
}
return `
<div
style="
display: flex;
flex-direction: column;
width: 140px;
"
>
<span>
${t('views.monitor.topology.name')}:
${label ?? '--'}
</span>
</div>
`;
},
});
/**图节点展示 */
const graphNodeTooltip = new Tooltip({
offsetX: 10,
offsetY: 20,
getContent(evt) {
if (!evt) return t('views.monitor.topologyBuild.graphNotInfo');
const { id, label, nType, nInfo }: any = evt.item?.getModel();
if (['GNB', 'ENB'].includes(nType)) {
return `
<div
style="
display: flex;
flex-direction: column;
width: 256px;
"
>
<div><strong>${t('views.neData.baseStation.state')}</strong><span>
${nInfo.state === 'ON'
? t('views.neData.baseStation.online')
: t('views.neData.baseStation.offline')
}
</span></div>
<div><strong>${t('views.neData.baseStation.time')}</strong><span>
${nInfo.state === 'ON' ? nInfo.onTime ?? '--' : nInfo.offTime ?? '--'}
</span></div>
<div>==============================</div>
<div><strong>ID</strong><span>${nInfo.index}</span></div>
<div><strong>${t('views.neData.baseStation.address')}</strong><span>
${nInfo.address ?? '--'}</span></div>
<div><strong>${t('views.neData.baseStation.nbName')}</strong><span>
${nInfo.nbName ?? '--'}</span></div>
<div><strong>${t('views.neData.baseStation.nbId')}</strong><span>
${nInfo.ranId ?? '--'}</span></div>
<div><strong>${t('views.neData.baseStation.ueNum')}</strong><span>
${nInfo.ueNum ?? '--'}</span></div>
<div><strong>${t('views.neData.baseStation.name')}</strong><span>
${nInfo.name ?? '--'}
</span></div>
<div><strong>${t(
'views.neData.baseStation.position'
)}</strong><span style="word-wrap: break-word;">
${nInfo.position}
</span></div>
</div>
`;
}
return `
<div
style="
display: flex;
flex-direction: column;
width: 200px;
"
>
<div><strong>${t('views.monitor.topology.state')}</strong><span>
${nInfo.online
? t('views.monitor.topology.normalcy')
: t('views.monitor.topology.exceptions')
}
</span></div>
<div><strong>${t('views.monitor.topology.refreshTime')}</strong><span>
${nInfo.refreshTime ?? '--'}
</span></div>
<div>========================</div>
<div><strong>ID</strong><span>${nInfo.neId}</span></div>
<div><strong>${t('views.monitor.topology.name')}</strong><span>
${nInfo.neName ?? '--'}
</span></div>
<div><strong>IP</strong><span>${nInfo.neIP}</span></div>
<div><strong>${t('views.monitor.topology.version')}</strong><span>
${nInfo.version ?? '--'}
</span></div>
<div><strong>${t('views.monitor.topology.serialNum')}</strong><span>
${nInfo.sn ?? '--'}
</span></div>
<div><strong>${t('views.monitor.topology.expiryDate')}</strong><span>
${nInfo.expire ?? '--'}
</span></div>
</div>
`;
},
itemTypes: ['node'],
});
/**注册自定义边或节点 */
function registerEdgeNode() {
// 边
edgeLineAnimateState();
// 节点
nodeImageAnimateState();
}
/**
* format the string
* @param {string} str The origin string
* @param {number} maxWidth max width
* @param {number} fontSize font size
* @return {string} the processed result
*/
function fittingString(str: string, maxWidth: number, fontSize: number) {
let currentWidth = 0;
let res = str;
const pattern = new RegExp('[\u4E00-\u9FA5]+'); // distinguish the Chinese charactors and letters
str.split('').forEach((letter, i) => {
if (currentWidth > maxWidth) return;
if (pattern.test(letter)) {
// Chinese charactors
currentWidth += fontSize;
} else {
// get the width of single letter according to the fontSize
currentWidth += Util.getLetterWidth(letter, fontSize);
}
if (currentWidth > maxWidth) {
res = `${str.substring(0, i)}\n${str.substring(i)}`;
}
});
return res;
}
/**图事件 */
function graphEvent(graph: Graph) {
graph.on('edge:mouseenter', evt => {
const { item } = evt;
if (!item) return;
graph.setItemState(item, 'circle-move', '#b5d6fb');
});
graph.on('edge:mouseleave', evt => {
const { item } = evt;
if (!item) return;
graph.setItemState(item, 'circle-move', false);
graph.setItemState(item, 'circle-move:#b5d6fb', false);
});
}
/**图数据渲染 */
function handleRanderGraph(container: HTMLElement | null, data: GraphData) {
if (!container) return;
const { clientHeight, clientWidth } = container;
// 注册自定义边或节点
registerEdgeNode();
const graph = new Graph({
container: container,
width: clientWidth,
height: clientHeight,
fitCenter: false,
fitView: true,
fitViewPadding: [40, 40, 40, 40],
modes: {
// default: ['drag-canvas', 'drag-node', 'zoom-canvas'],
default: [
'zoom-canvas',
'drag-canvas',
'drag-node',
{
type: 'drag-combo',
onlyChangeComboSize: true, // 不改变层级关系
},
{
type: 'collapse-expand-combo',
trigger: 'dblclick',
relayout: true, // 收缩展开后,不重新布局
},
],
},
groupByTypes: false,
plugins: [graphNodeMenu, graphNodeTooltip],
layout: {
type: 'dagre',
rankdir: 'TB', // 布局的方向TB从上到下BT从下到上LR从左到右RL从右到左
//align: 'UL', // 节点对齐方式 UL、UR、DL、DR
controlPoints: true,
nodesep: 20,
ranksep: 40,
},
animate: true,
defaultNode: {
type: 'image-animate-state',
labelCfg: {
offset: 8,
position: 'bottom',
style: { fill: '#ffffff', fontSize: 14, fontWeight: 500 },
},
size: 48,
img: parseBasePath('/svg/cloud.svg'),
width: 48,
height: 48,
},
defaultEdge: {
type: 'line-animate-state',
labelCfg: {
autoRotate: true,
refY: 10,
refX: 40,
},
style: {
stroke: '#fafafa',
lineWidth: 1.5,
},
},
defaultCombo: {
labelCfg: {
offset: 16,
position: 'bottom',
style: { fill: '#ffffff', fontSize: 14, fontWeight: 500 },
},
style: {
stroke: '#BDEFDB',
fill: '#BDEFDB',
opacity: 0.25,
},
collapsedSubstituteIcon: {
show: true,
img: parseBasePath('/svg/service.svg'),
width: 48,
height: 48,
},
},
});
graph.data(data);
graph.render();
graphEvent(graph);
// 创建 ResizeObserver 实例
const observer = new ResizeObserver(function (entries) {
// 当元素大小发生变化时触发回调函数
entries.forEach(function (entry) {
if (!graph) {
return;
}
graph.changeSize(entry.contentRect.width, entry.contentRect.height);
graph.fitCenter();
});
});
// 监听元素大小变化
observer.observe(container);
return graph;
}
/**
* 获取图组数据渲染到画布
*/
async function fnGraphDataLoad() {
const dataNe = await fnGraphDataBase();
Object.assign(graphData, dataNe);
graphG6.value = handleRanderGraph(graphG6Dom.value, dataNe);
// 添加基站
const dataNb = await fnGraphDataNb(dataNe);
Object.assign(graphData, dataNb);
// graphG6.value.clear();
graphG6.value.read(dataNb);
// 添加状态
interval.value = true;
repeatFn();
}
/**图数据网元构建 */
async function fnGraphDataBase() {
const data: GraphData = {
nodes: [],
edges: [],
};
// 添加基础网元
for (const item of neListStore.getNeCascaderOptions) {
if ('OMC' === item.value) {
if (item.children?.length === 0) continue;
// 是否存在OMC保证唯一
const hasOMC = data.nodes?.findIndex(v => v.neType === 'OMC');
if (hasOMC !== -1) continue;
// 根网元
const omcInfo = item.children[0];
const node = {
id: 'OMC',
label: omcInfo.neName,
img: parseBasePath('/svg/service_db.svg'),
nInfo: { online: false, neId: omcInfo.neId, neType: omcInfo.neType },
nType: 'OMC',
};
// 添加OMC节点
data.nodes?.push(node);
continue;
}
if (['AMF', 'MME'].includes(item.value)) {
if (item.children?.length === 0) continue;
for (const child of item.children) {
const id = `${child.neType}_${child.neId}`;
const node = {
id: id,
label: child.neName,
img: parseBasePath('/svg/service.svg'),
nInfo: { online: false, neId: child.neId, neType: child.neType },
nType: item.value,
};
// 添加节点
data.nodes?.push(node);
data.edges?.push({
source: 'OMC',
target: id,
});
}
item.children.forEach((v: any) => { });
continue;
}
}
return data;
}
/**图数据基站构建 */
async function fnGraphDataNb(data: GraphData) {
const arr = data.nodes?.filter((v: any) => ['AMF', 'MME'].includes(v.nType));
if (arr === undefined || arr.length === 0) return data;
for (const item of arr) {
if (item.nType === 'AMF') {
const neId = (item.nInfo as any).neId;
const res = await listAMFNbStatelist({ neId });
if (res.code !== RESULT_CODE_SUCCESS || !Array.isArray(res.data)) {
continue;
}
for (const nb of res.data) {
const id = `${item.id}_${nb.index}`;
data.nodes?.push({
id: id,
label: fittingString(`${nb.name}`, 80, 14),
img: parseBasePath('/svg/base5G.svg'),
nInfo: nb,
nType: 'GNB',
});
data.edges?.push({
source: item.id,
target: id,
});
}
}
if (item.nType === 'MME') {
const neId = (item.nInfo as any).neId;
const res = await listMMENbStatelist({ neId });
if (res.code !== RESULT_CODE_SUCCESS || !Array.isArray(res.data)) {
continue;
}
for (const nb of res.data) {
const id = `${item.id}_${nb.index}`;
data.nodes?.push({
id: id,
label: fittingString(`${nb.name}`, 80, 14),
img: parseBasePath('/svg/base4G.svg'),
nInfo: nb,
nType: 'ENB',
});
data.edges?.push({
source: item.id,
target: id,
});
}
}
}
return data;
}
/**
* 图状态构建
* @param reload 是否重载状态
*/
async function fnGraphState(reload: boolean = false) {
// 节点状态
if (!Array.isArray(graphData.nodes)) return;
const onc = graphData.nodes.find((v: any) => v.nType === 'OMC');
if (onc) {
const { id, nInfo } = onc as any;
if (!nInfo) return;
const res = await stateNeInfo(nInfo.neType, nInfo.neId);
if (res.code === RESULT_CODE_SUCCESS) {
Object.assign(nInfo, res.data, {
refreshTime: parseDateToStr(res.data.refreshTime, 'HH:mm:ss'),
});
}
const stateColor = nInfo.online ? '#52c41a' : '#f5222d'; // 状态颜色
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
}
graphData.nodes
.filter((v: any) => ['AMF', 'MME'].includes(v.nType))
.forEach(async (v: any) => {
const { id, nInfo } = v;
if (!nInfo) return;
const res = await stateNeInfo(nInfo.neType, nInfo.neId);
if (res.code === RESULT_CODE_SUCCESS) {
Object.assign(nInfo, res.data, {
refreshTime: parseDateToStr(res.data.refreshTime, 'HH:mm:ss'),
});
}
const stateColor = nInfo.online ? '#52c41a' : '#f5222d'; // 状态颜色
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
// 重载时更新下级基站状态
if (reload && nInfo.neType === 'AMF') {
const res = await listAMFNbStatelist({ neId: nInfo.neId });
if (res.code == RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
for (const nb of res.data) {
const nbItem = graphData.nodes.find(
(v: any) => v.id === `${id}_${nb.index}`
);
if (nbItem) {
Object.assign(nbItem.nInfo, nb);
const stateColor = nb.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
graphG6.value.setItemState(
nbItem.id,
'top-right-dot',
stateColor
);
}
}
}
}
if (reload && nInfo.neType === 'MME') {
const res = await listMMENbStatelist({ neId: nInfo.neId });
if (res.code == RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
for (const nb of res.data) {
const nbItem = graphData.nodes.find(
(v: any) => v.id === `${id}_${nb.index}`
);
if (nbItem) {
Object.assign(nbItem.nInfo, nb);
const stateColor = nb.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
graphG6.value.setItemState(
nbItem.id,
'top-right-dot',
stateColor
);
}
}
}
}
});
if (reload) {
await new Promise(resolve => setTimeout(resolve, 15_000));
return;
}
// 非重载时使用初始获取的状态
graphData.nodes
.filter((v: any) => ['GNB', 'ENB'].includes(v.nType))
.forEach(async (v: any) => {
const { id, nInfo } = v;
if (!nInfo) return;
const stateColor = nInfo.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
});
}
/**递归调度器 */
const interval = ref<boolean>(false);
/**递归刷新图状态 */
function repeatFn(reload: boolean = false) {
if (!interval.value || !graphG6Dom.value) {
return;
}
fnGraphState(reload)
.finally(() => {
repeatFn(true); // 递归调用自己
})
.catch(error => {
console.error(error);
});
}
const viewportDom = ref<HTMLElement | null>(null);
const { isFullscreen, toggle } = useFullscreen(viewportDom);
function fullscreen() {
toggle();
if (!graphG6Dom.value) return;
if (isFullscreen.value) {
graphG6Dom.value.style.height = 'calc(100vh - 300px)';
} else {
graphG6Dom.value.style.height = '100vh';
}
const { clientHeight, clientWidth } = graphG6Dom.value;
graphG6.value.changeSize(clientHeight, clientWidth);
graphG6.value.fitView(40);
}
onMounted(() => {
fnGraphDataLoad();
});
onBeforeUnmount(() => {
interval.value = false;
});
</script>
<template>
<a-card :bordered="false" :body-style="{ padding: '0' }" ref="viewportDom">
<!-- 插槽-卡片左侧侧 -->
<template #title>
<a-space :size="8" align="center">
{{ t('views.neData.baseStation.topologyTitle') }}
</a-space>
</template>
<!-- 插槽-卡片右侧 -->
<template #extra>
<a-button type="default" @click.prevent="fullscreen()">
<template #icon>
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</template>
{{ t('loayouts.rightContent.fullscreen') }}
</a-button>
</template>
<div ref="graphG6Dom" class="chart"></div>
</a-card>
</template>
<style lang="less" scoped>
.chart {
width: 100%;
height: calc(100vh - 300px);
background-color: rgb(43, 47, 51);
}
</style>