feat(拓扑图): 新增网络拓扑图功能模块

This commit is contained in:
zhang1106
2026-04-13 16:54:07 +08:00
parent 1d297fc81a
commit 01a05719dc
25 changed files with 2453 additions and 145 deletions
+4
View File
@@ -105,6 +105,10 @@ const routesConfig = [
file: 'dangerousOperations.js',
path: '/api/dangerous-operations',
},
{
file: 'topology.js',
path: '/api/topology',
},
];
module.exports = routesConfig;
+323
View File
@@ -0,0 +1,323 @@
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
const Cable = require('../models/Cable');
const Device = require('../models/Device');
const DevicePort = require('../models/DevicePort');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
router.get('/switch/:switchId', async (req, res) => {
try {
const { switchId } = req.params;
const { maxNodes = 100 } = req.query;
const centerDevice = await Device.findByPk(switchId, {
include: [
{
model: Rack,
as: 'Rack',
include: [{ model: Room, as: 'Room' }]
}
]
});
if (!centerDevice) {
return res.status(404).json({ success: false, error: '交换机不存在' });
}
if (centerDevice.type !== 'switch') {
return res.status(400).json({ success: false, error: '指定设备不是交换机' });
}
const cables = await Cable.findAll({
where: {
[Op.or]: [
{ sourceDeviceId: switchId },
{ targetDeviceId: switchId }
]
},
include: [
{
model: Device,
as: 'sourceDevice',
attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position']
},
{
model: Device,
as: 'targetDevice',
attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position']
}
]
});
const connectedDeviceIds = new Set();
cables.forEach(cable => {
if (cable.sourceDeviceId !== switchId) {
connectedDeviceIds.add(cable.sourceDeviceId);
}
if (cable.targetDeviceId !== switchId) {
connectedDeviceIds.add(cable.targetDeviceId);
}
});
if (connectedDeviceIds.size > parseInt(maxNodes)) {
return res.status(400).json({
success: false,
error: `连接设备数量(${connectedDeviceIds.size})超过限制(${maxNodes}),请使用更具体的筛选条件`
});
}
const connectedDevices = await Device.findAll({
where: { deviceId: { [Op.in]: Array.from(connectedDeviceIds) } },
include: [
{
model: Rack,
as: 'Rack',
include: [{ model: Room, as: 'Room' }]
}
]
});
const deviceMap = {};
connectedDevices.forEach(device => {
deviceMap[device.deviceId] = device.toJSON();
});
const centerRack = centerDevice.Rack;
const centerRoom = centerRack?.Room;
const nodes = [
{
id: centerDevice.deviceId,
deviceId: centerDevice.deviceId,
name: centerDevice.name,
type: centerDevice.type,
model: centerDevice.model,
status: centerDevice.status,
ipAddress: centerDevice.ipAddress,
rackId: centerDevice.rackId,
rackName: centerRack?.name,
roomId: centerRoom?.roomId,
roomName: centerRoom?.name,
position: centerDevice.position,
isCenter: true
},
...connectedDevices.map(device => {
const d = device.toJSON();
const rack = d.Rack;
const room = rack?.Room;
return {
id: d.deviceId,
deviceId: d.deviceId,
name: d.name,
type: d.type,
model: d.model,
status: d.status,
ipAddress: d.ipAddress,
rackId: d.rackId,
rackName: rack?.name,
roomId: room?.roomId,
roomName: room?.name,
position: d.position,
isCenter: false
};
})
];
const edges = cables.map(cable => {
const sourceId = cable.sourceDeviceId === switchId ? cable.targetDeviceId : cable.sourceDeviceId;
return {
id: cable.cableId,
source: cable.sourceDeviceId,
target: cable.targetDeviceId,
sourcePort: cable.sourcePort,
targetPort: cable.targetPort,
cableId: cable.cableId,
cableType: cable.cableType,
cableLength: cable.cableLength,
cableLabel: cable.cableLabel,
cableColor: cable.cableColor,
status: cable.status,
description: cable.description,
installedAt: cable.installedAt
};
});
const portStats = {};
const allDeviceIds = [switchId, ...Array.from(connectedDeviceIds)];
const ports = await DevicePort.findAll({
where: { deviceId: { [Op.in]: allDeviceIds } },
attributes: ['deviceId', 'status']
});
ports.forEach(port => {
if (!portStats[port.deviceId]) {
portStats[port.deviceId] = { total: 0, used: 0, free: 0, fault: 0 };
}
portStats[port.deviceId].total++;
if (port.status === 'occupied') portStats[port.deviceId].used++;
else if (port.status === 'free') portStats[port.deviceId].free++;
else if (port.status === 'fault') portStats[port.deviceId].fault++;
});
nodes.forEach(node => {
node.portCount = portStats[node.deviceId] || { total: 0, used: 0, free: 0, fault: 0 };
});
const statistics = {
totalDevices: nodes.length,
totalCables: edges.length,
normalCables: edges.filter(e => e.status === 'normal').length,
faultCables: edges.filter(e => e.status === 'fault').length,
disconnectedCables: edges.filter(e => e.status === 'disconnected').length,
byDeviceType: {},
byCableType: {}
};
nodes.forEach(node => {
statistics.byDeviceType[node.type] = (statistics.byDeviceType[node.type] || 0) + 1;
});
edges.forEach(edge => {
statistics.byCableType[edge.cableType] = (statistics.byCableType[edge.cableType] || 0) + 1;
});
res.json({
success: true,
data: {
centerDevice: nodes[0],
nodes: nodes.slice(1),
edges,
statistics
}
});
} catch (error) {
console.error('获取拓扑数据失败:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/rack/:rackId', async (req, res) => {
try {
const { rackId } = req.params;
const { maxNodes = 100 } = req.query;
const devices = await Device.findAll({
where: { rackId },
attributes: ['deviceId']
});
const deviceIds = devices.map(d => d.deviceId);
if (deviceIds.length === 0) {
return res.json({
success: true,
data: {
nodes: [],
edges: [],
statistics: {
totalDevices: 0,
totalCables: 0,
normalCables: 0,
faultCables: 0,
disconnectedCables: 0
}
}
});
}
const cables = await Cable.findAll({
where: {
[Op.or]: [
{ sourceDeviceId: { [Op.in]: deviceIds } },
{ targetDeviceId: { [Op.in]: deviceIds } }
]
},
include: [
{
model: Device,
as: 'sourceDevice',
attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position']
},
{
model: Device,
as: 'targetDevice',
attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position']
}
]
});
const relatedDeviceIds = new Set(deviceIds);
cables.forEach(cable => {
relatedDeviceIds.add(cable.sourceDeviceId);
relatedDeviceIds.add(cable.targetDeviceId);
});
if (relatedDeviceIds.size > parseInt(maxNodes)) {
return res.status(400).json({
success: false,
error: `设备数量(${relatedDeviceIds.size})超过限制(${maxNodes})`
});
}
const allDevices = await Device.findAll({
where: { deviceId: { [Op.in]: Array.from(relatedDeviceIds) } },
include: [
{
model: Rack,
as: 'Rack',
include: [{ model: Room, as: 'Room' }]
}
]
});
const nodes = allDevices.map(device => {
const d = device.toJSON();
const rack = d.Rack;
const room = rack?.Room;
return {
id: d.deviceId,
deviceId: d.deviceId,
name: d.name,
type: d.type,
model: d.model,
status: d.status,
ipAddress: d.ipAddress,
rackId: d.rackId,
rackName: rack?.name,
roomId: room?.roomId,
roomName: room?.name,
position: d.position,
isCenter: deviceIds.includes(d.deviceId)
};
});
const edges = cables.map(cable => ({
id: cable.cableId,
source: cable.sourceDeviceId,
target: cable.targetDeviceId,
sourcePort: cable.sourcePort,
targetPort: cable.targetPort,
cableId: cable.cableId,
cableType: cable.cableType,
cableLength: cable.cableLength,
status: cable.status
}));
const statistics = {
totalDevices: nodes.length,
totalCables: edges.length,
normalCables: edges.filter(e => e.status === 'normal').length,
faultCables: edges.filter(e => e.status === 'fault').length,
disconnectedCables: edges.filter(e => e.status === 'disconnected').length
};
res.json({ success: true, data: { nodes, edges, statistics } });
} catch (error) {
console.error('获取机柜拓扑数据失败:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+33
View File
@@ -11,8 +11,10 @@
"@ant-design/icons": "^6.1.0",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@types/dagre": "^0.7.54",
"antd": "^5.8.6",
"axios": "^1.13.6",
"dagre": "^0.8.5",
"dayjs": "^1.11.19",
"framer-motion": "^12.34.0",
"html2canvas": "^1.4.1",
@@ -2823,6 +2825,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -4172,6 +4180,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/data-urls": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
@@ -5328,6 +5346,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -6249,6 +6276,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+2
View File
@@ -16,8 +16,10 @@
"@ant-design/icons": "^6.1.0",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@types/dagre": "^0.7.54",
"antd": "^5.8.6",
"axios": "^1.13.6",
"dagre": "^0.8.5",
"dayjs": "^1.11.19",
"framer-motion": "^12.34.0",
"html2canvas": "^1.4.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+153 -144
View File
@@ -189,7 +189,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
useEffect(() => {
if (visible) {
setCurrentStep(0);
setSourceDevice(null);
setTargetDevice(null);
setSourcePort(null);
setTargetPort(null);
@@ -197,22 +196,30 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
setSelectedCableLength(3);
setCableLabel('');
setCableDescription('');
setSourcePorts([]);
setTargetPorts([]);
setConflicts([]);
setCompatibilityWarning(null);
setDevices([]);
setCablesData([]);
fetchDevices('', 'switch').then(deviceList => {
if (initialSourceDevice?.deviceId) {
const device = deviceList.find(d => d.deviceId === initialSourceDevice.deviceId);
if (device) {
setSourceDevice(device);
fetchDevicePorts(device.deviceId, 'source');
}
}
});
if (initialSourceDevice?.deviceId) {
const deviceWithId = {
deviceId: initialSourceDevice.deviceId,
name: initialSourceDevice.name || initialSourceDevice.deviceId,
type: initialSourceDevice.type || 'switch',
model: initialSourceDevice.model,
status: initialSourceDevice.status,
Rack: initialSourceDevice.Rack,
ipAddress: initialSourceDevice.ipAddress,
position: initialSourceDevice.position,
};
setSourceDevice(deviceWithId);
fetchDevicePorts(deviceWithId.deviceId, 'source');
fetchDevices('', 'switch');
} else {
setSourceDevice(null);
setSourcePorts([]);
fetchDevices('', 'switch');
}
fetchCables();
}
}, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]);
@@ -677,152 +684,165 @@ const Step1SourceDevice = ({
onPortSelect,
onDevicesChange,
}) => {
const isSourcePreSelected = !!sourceDevice;
return (
<div style={{ padding: '20px 0' }}>
<Card
title={
<Space>
<CloudServerOutlined style={{ color: '#1890ff' }} />
<span>步骤 1: 选择源设备</span>
<span>步骤 1: 选择源设备端口</span>
</Space>
}
size="small"
style={{ marginBottom: '20px' }}
>
<div style={{ marginBottom: '16px' }}>
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
选择接线的起点设备
</Typography.Text>
</div>
<div style={{ marginBottom: '16px' }}>
<FilterableDeviceSelect
filterType="switch"
onDeviceListChange={onDevicesChange}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
maxHeight: 300,
overflowY: 'auto',
padding: '8px',
background: designTokens.colors.background,
borderRadius: '8px',
}}
>
{fetchingDevices ? (
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
<Spin size="large" />
{!isSourcePreSelected && (
<>
<div style={{ marginBottom: '16px' }}>
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
选择接线的起点设备
</Typography.Text>
</div>
) : (
devices.map(device => (
<motion.div
key={device.deviceId}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={() => onDeviceSelect(device)}
hoverable
style={{
border: sourceDevice?.deviceId === device.deviceId
? `2px solid ${designTokens.colors.primary}`
: '1px solid #d9d9d9',
background: sourceDevice?.deviceId === device.deviceId
? 'rgba(24,144,255,0.08)'
: '#fff',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<div
<div style={{ marginBottom: '16px' }}>
<FilterableDeviceSelect
filterType="switch"
onDeviceListChange={onDevicesChange}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
maxHeight: 300,
overflowY: 'auto',
padding: '8px',
background: designTokens.colors.background,
borderRadius: '8px',
}}
>
{fetchingDevices ? (
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
<Spin size="large" />
</div>
) : (
devices.map(device => (
<motion.div
key={device.deviceId}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
onClick={() => onDeviceSelect(device)}
hoverable
style={{
width: '48px',
height: '48px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
flexShrink: 0,
border: sourceDevice?.deviceId === device.deviceId
? `2px solid ${designTokens.colors.primary}`
: '1px solid #d9d9d9',
background: sourceDevice?.deviceId === device.deviceId
? 'rgba(24,144,255,0.08)'
: '#fff',
}}
>
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
{device.name || device.deviceId}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
flexShrink: 0,
}}
>
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
</div>
{device.status && getStatusTag(device.status)}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
{device.deviceId}
</span>
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
</span>
</div>
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
{device.Rack?.Room?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
{device.Rack.Room.name}
</span>
)}
{device.Rack?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
{device.Rack.name}
</span>
)}
{device.position && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
U{device.position}
</span>
)}
{device.ipAddress && (
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
{device.ipAddress}
</span>
)}
</div>
{device.model && (
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
{device.model}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
{device.name || device.deviceId}
</div>
{device.status && getStatusTag(device.status)}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
{device.deviceId}
</span>
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
</span>
</div>
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
{device.Rack?.Room?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
{device.Rack.Room.name}
</span>
)}
{device.Rack?.name && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
{device.Rack.name}
</span>
)}
{device.position && (
<span style={{ marginRight: '12px' }}>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
U{device.position}
</span>
)}
{device.ipAddress && (
<span>
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
{device.ipAddress}
</span>
)}
</div>
{device.model && (
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
{device.model}
</div>
)}
</div>
)}
</div>
{sourceDevice?.deviceId === device.deviceId && (
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
)}
</div>
</Card>
</motion.div>
))
)}
</div>
{sourceDevice?.deviceId === device.deviceId && (
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
)}
</div>
</Card>
</motion.div>
))
)}
</div>
</>
)}
{sourceDevice && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
style={{ marginTop: '20px' }}
style={{ marginTop: isSourcePreSelected ? 0 : '20px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<DatabaseOutlined style={{ color: '#1890ff' }} />
<Typography.Text style={{ fontWeight: 600, color: '#262626' }}>
源设备已选择: {sourceDevice.name}
源设备: {sourceDevice.name}
</Typography.Text>
{isSourcePreSelected && (
<Tag color="blue" style={{ marginLeft: '8px' }}>已选择</Tag>
)}
<Tag color="blue" style={{ marginLeft: 'auto' }}>
{sourcePorts.filter(p => p.status === 'free').length} 个空闲端口
</Tag>
</div>
<div
@@ -833,14 +853,6 @@ const Step1SourceDevice = ({
border: '1px solid #d9d9d9',
}}
>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<DatabaseOutlined style={{ color: '#1890ff' }} />
<Typography.Text style={{ fontWeight: 600 }}>端口选择</Typography.Text>
<Tag color="blue" style={{ marginLeft: 'auto' }}>
{sourcePorts.filter(p => p.status === 'free').length} 个空闲端口
</Tag>
</div>
{sourcePorts.length === 0 ? (
<Empty description="该设备暂无端口数据" />
) : (
@@ -848,9 +860,6 @@ const Step1SourceDevice = ({
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
</div>
<div style={{ marginBottom: '8px', fontSize: '11px', color: '#bfbfbf' }}>
端口状态分布: {sourcePorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')}
</div>
{sourcePort && (
<div
style={{
+2 -1
View File
@@ -62,7 +62,8 @@ function parsePortRange(portName) {
return null;
}
const prefix = startPart.replace(startNumMatch[0], '');
const startIdx = startPart.length - startNumMatch[0].length;
const prefix = startPart.substring(0, startIdx);
const portCount = endNum - startNum + 1;
const ports = [];
@@ -0,0 +1,93 @@
import React from 'react';
import { Card, Select, Space, Button, Row, Col, Statistic, Tag, Typography } from 'antd';
import {
ReloadOutlined,
AppstoreOutlined
} from '@ant-design/icons';
const { Text } = Typography;
const { Option } = Select;
function TopologyControls({
switchDevices,
selectedSwitchId,
onSwitchChange,
loading,
onRefresh,
statistics
}) {
return (
<Card
size="small"
title={
<Space>
<AppstoreOutlined />
<span>选择交换机</span>
</Space>
}
extra={
<Button
type="text"
icon={<ReloadOutlined />}
onClick={onRefresh}
loading={loading}
size="small"
/>
}
style={{ marginBottom: 16, borderRadius: 8 }}
bodyStyle={{ padding: 16 }}
>
<Select
placeholder="搜索并选择交换机..."
value={selectedSwitchId || undefined}
onChange={onSwitchChange}
style={{ width: '100%', marginBottom: 16 }}
allowClear
showSearch
filterOption={(input, option) =>
option.label?.toLowerCase().includes(input.toLowerCase()) ?? true
}
>
{switchDevices.map(device => (
<Option key={device.deviceId} value={device.deviceId} label={device.name}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{device.name}</span>
<Text type="secondary" style={{ fontSize: 11 }}>{device.deviceId}</Text>
</div>
</Option>
))}
</Select>
{statistics && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #f0f0f0' }}>
<Row gutter={[8, 8]}>
<Col span={12}>
<Statistic
title="设备数"
value={statistics.totalDevices || 0}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={12}>
<Statistic
title="连接数"
value={statistics.totalCables || 0}
valueStyle={{ fontSize: 18 }}
/>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>连接状态</Text>
<Space size={4} style={{ marginTop: 4 }}>
<Tag color="success" style={{ fontSize: 11 }}>{statistics.normalCables || 0} 正常</Tag>
<Tag color="error" style={{ fontSize: 11 }}>{statistics.faultCables || 0} 故障</Tag>
<Tag style={{ fontSize: 11 }}>{statistics.disconnectedCables || 0} 未连接</Tag>
</Space>
</div>
</div>
)}
</Card>
);
}
export default TopologyControls;
@@ -0,0 +1,264 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
Handle,
Position
} from 'reactflow';
import 'reactflow/dist/style.css';
import { Card, Tag, Badge, Typography } from 'antd';
import {
CloudServerOutlined,
AppstoreOutlined,
DatabaseOutlined,
SwapOutlined,
SafetyOutlined
} from '@ant-design/icons';
import { SwitchNode, ServerNode, RouterNode, StorageNode, GenericNode, FirewallNode } from './nodes';
const { Text: AntText } = Typography;
const DEVICE_COLORS = {
switch: '#1890ff',
router: '#722ed1',
server: '#52c41a',
storage: '#fa8c16',
firewall: '#eb595a',
default: '#8c8c8c'
};
const DEVICE_ICONS = {
switch: AppstoreOutlined,
router: SwapOutlined,
server: CloudServerOutlined,
storage: DatabaseOutlined,
firewall: SafetyOutlined,
default: CloudServerOutlined
};
const CABLE_COLORS = {
ethernet: '#1890ff',
fiber: '#13c2c2',
copper: '#fa8c16'
};
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function DeviceNode({ data }) {
if (!data) {
return (
<Card size="small" style={{ width: 180 }}>
<AntText type="secondary">无数据</AntText>
</Card>
);
}
const nodeType = data.type || 'default';
return (
<>
<Handle type="target" position={Position.Left} style={{ background: '#555', width: 8, height: 8 }} />
{nodeType === 'switch' && <SwitchNode data={data} />}
{nodeType === 'server' && <ServerNode data={data} />}
{nodeType === 'router' && <RouterNode data={data} />}
{nodeType === 'storage' && <StorageNode data={data} />}
{nodeType === 'firewall' && <FirewallNode data={data} />}
{(nodeType === 'default' || !['switch', 'server', 'router', 'storage', 'firewall'].includes(nodeType)) && <GenericNode data={data} />}
<Handle type="source" position={Position.Right} style={{ background: '#555', width: 8, height: 8 }} />
</>
);
}
const nodeTypes = {
device: DeviceNode
};
function TopologyGraph({ nodes, edges, onNodeClick, onEdgeClick, selectedNode, selectedEdge }) {
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]);
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]);
const [hoveredNodeId, setHoveredNodeId] = useState(null);
const initializedRef = useRef(false);
useEffect(() => {
if (!nodes || nodes.length === 0) return;
setFlowNodes(prevNodes => {
const newNodes = nodes.map(node => {
const existingNode = prevNodes.find(n => n.id === node.id);
const position = existingNode?.position || node.position || { x: Math.random() * 400, y: Math.random() * 300 };
return {
id: node.id,
type: 'device',
position,
data: {
...node,
selected: selectedNode?.id === node.id,
hovered: hoveredNodeId === node.id
},
selected: selectedNode?.id === node.id
};
});
return newNodes;
});
initializedRef.current = true;
}, [nodes, selectedNode, hoveredNodeId]);
useEffect(() => {
if (!edges || edges.length === 0) return;
setFlowEdges(edges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: 'default',
animated: edge.status === 'fault',
style: {
stroke: edge.status === 'fault' ? '#ff4d4f' :
edge.status === 'disconnected' ? '#d9d9d9' :
CABLE_COLORS[edge.cableType] || '#8c8c8c',
strokeWidth: edge.status === 'fault' ? 3 : 2,
strokeDasharray: edge.status === 'disconnected' ? '5,5' : '0',
},
data: edge,
selected: selectedEdge?.id === edge.id
})));
}, [edges, selectedEdge]);
const onNodeClickHandler = useCallback((event, node) => {
if (onNodeClick) {
onNodeClick(node);
}
}, [onNodeClick]);
const onEdgeClickHandler = useCallback((event, edge) => {
if (onEdgeClick) {
onEdgeClick(edge);
}
}, [onEdgeClick]);
const onNodeMouseEnterHandler = useCallback((event, node) => {
setHoveredNodeId(node.id);
}, []);
const onNodeMouseLeaveHandler = useCallback(() => {
setHoveredNodeId(null);
}, []);
if (!nodes || nodes.length === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<AntText type="secondary">暂无拓扑数据</AntText>
</div>
);
}
return (
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClickHandler}
onEdgeClick={onEdgeClickHandler}
onNodeMouseEnter={onNodeMouseEnterHandler}
onNodeMouseLeave={onNodeMouseLeaveHandler}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-left"
style={{ background: '#fafafa' }}
defaultEdgeOptions={{
type: 'default',
animated: false
}}
>
<Background color="#e0e0e0" gap={20} />
<Controls
showZoom={true}
showFitView={true}
showInteractive={false}
/>
<MiniMap
nodeColor={(node) => {
const type = node.data?.type;
switch (type) {
case 'switch': return '#1890ff';
case 'router': return '#722ed1';
case 'server': return '#52c41a';
case 'storage': return '#fa8c16';
case 'firewall': return '#eb595a';
default: return '#8c8c8c';
}
}}
maskColor="rgba(0,0,0,0.1)"
style={{ border: '1px solid #e0e0e0', borderRadius: 8 }}
/>
<div style={{
position: 'absolute',
bottom: 20,
left: 20,
background: 'rgba(255,255,255,0.9)',
padding: '12px 16px',
borderRadius: 8,
border: '1px solid #e0e0e0',
fontSize: 12,
zIndex: 5
}}>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#262626' }}>设备类型</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 12, height: 12, background: '#1890ff', borderRadius: 2 }} />
<span>交换机</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 12, height: 12, background: '#722ed1', borderRadius: 2 }} />
<span>路由器</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 12, height: 12, background: '#52c41a', borderRadius: 2 }} />
<span>服务器</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 12, height: 12, background: '#fa8c16', borderRadius: 2 }} />
<span>存储</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 12, height: 12, background: '#eb595a', borderRadius: 2 }} />
<span>防火墙</span>
</div>
</div>
<div style={{ marginTop: 8, marginBottom: 4, fontWeight: 600, color: '#262626' }}>线缆类型</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 20, height: 2, background: '#1890ff' }} />
<span>网线</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 20, height: 2, background: '#13c2c2' }} />
<span>光纤</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 20, height: 2, background: '#fa8c16' }} />
<span>铜缆</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 20, height: 2, background: '#ff4d4f', borderStyle: 'dashed' }} />
<span>故障</span>
</div>
</div>
</div>
</ReactFlow>
);
}
export default TopologyGraph;
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Spin, message, Alert, Space } from 'antd';
import {
SwapOutlined,
CloudServerOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { motion } from 'framer-motion';
import { designTokens } from '../../config/theme';
import TopologyGraph from './TopologyGraph';
import TopologySidebar from './TopologySidebar';
import TopologyControls from './TopologyControls';
import { useTopologyLayout } from './hooks/useTopologyLayout';
function TopologyModal({ visible, onClose }) {
const [switchDevices, setSwitchDevices] = useState([]);
const [selectedSwitchId, setSelectedSwitchId] = useState(null);
const [topologyData, setTopologyData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
const [sidebarVisible, setSidebarVisible] = useState(false);
const { calculateLayout } = useTopologyLayout();
const fetchSwitchDevices = useCallback(async () => {
try {
const response = await axios.get('/api/devices/all', {
params: { pageSize: 50000, type: 'switch' }
});
setSwitchDevices(response.data.devices || []);
} catch (err) {
console.error('获取交换机列表失败:', err);
}
}, []);
useEffect(() => {
if (visible) {
fetchSwitchDevices();
}
}, [visible, fetchSwitchDevices]);
const handleSwitchChange = useCallback(async (switchId) => {
setSelectedSwitchId(switchId);
setSelectedNode(null);
setSelectedEdge(null);
setTopologyData(null);
setError(null);
if (!switchId) return;
try {
setLoading(true);
const response = await axios.get(`/api/topology/switch/${switchId}`, {
params: { maxNodes: 100 }
});
if (response.data.success) {
setTopologyData(response.data.data);
} else {
setError(response.data.error || '获取拓扑数据失败');
}
} catch (err) {
const errorMessage = err.response?.data?.error || '获取拓扑数据失败';
setError(errorMessage);
console.error('获取拓扑数据失败:', err);
} finally {
setLoading(false);
}
}, []);
const handleNodeClick = useCallback((node) => {
setSelectedNode(node.data || node);
setSelectedEdge(null);
setSidebarVisible(true);
}, []);
const handleEdgeClick = useCallback((edge) => {
setSelectedEdge(edge.data || edge);
setSelectedNode(null);
setSidebarVisible(true);
}, []);
const handleSidebarClose = useCallback(() => {
setSidebarVisible(false);
setSelectedNode(null);
setSelectedEdge(null);
}, []);
const layoutedElements = React.useMemo(() => {
if (!topologyData) return { nodes: [], edges: [] };
const centerDevice = topologyData.centerDevice;
const connectedNodes = topologyData.nodes || [];
const edges = topologyData.edges || [];
const allNodes = centerDevice ? [centerDevice, ...connectedNodes] : connectedNodes;
return calculateLayout(allNodes, edges, {
layoutType: 'TB',
centerNodeId: centerDevice?.deviceId
});
}, [topologyData, calculateLayout]);
const handleClose = useCallback(() => {
setSelectedSwitchId(null);
setTopologyData(null);
setError(null);
setSelectedNode(null);
setSelectedEdge(null);
onClose();
}, [onClose]);
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff'
}}
>
<SwapOutlined />
</div>
<span style={{ fontSize: 18, fontWeight: 600 }}>接线拓扑图</span>
</div>
}
open={visible}
onCancel={handleClose}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '16px 24px', height: 'calc(100vh - 180px)' }}
footer={null}
destroyOnClose
>
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
<div style={{ width: 280, flexShrink: 0 }}>
<TopologyControls
switchDevices={switchDevices}
selectedSwitchId={selectedSwitchId}
onSwitchChange={handleSwitchChange}
loading={loading}
onRefresh={fetchSwitchDevices}
statistics={topologyData?.statistics}
/>
{topologyData?.statistics && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', marginTop: 8 }}>
数据更新时间: {new Date().toLocaleTimeString()}
</div>
)}
</div>
<div style={{ flex: 1, position: 'relative', borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
{loading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10,
background: 'rgba(255,255,255,0.9)',
padding: 24,
borderRadius: 8
}}>
<Spin size="large" tip="加载拓扑数据..." />
</div>
)}
{error && (
<div style={{ padding: 16 }}>
<Alert
type="error"
message={error}
showIcon
style={{ marginBottom: 16 }}
/>
</div>
)}
{!loading && !error && !selectedSwitchId && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center'
}}>
<CloudServerOutlined style={{ fontSize: 48, color: '#ccc' }} />
<div style={{ marginTop: 16, color: '#999' }}>
请从左侧选择交换机以生成拓扑图
</div>
</div>
)}
<div style={{ width: '100%', height: '100%' }}>
<TopologyGraph
nodes={layoutedElements.nodes}
edges={layoutedElements.edges}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
/>
</div>
</div>
<TopologySidebar
visible={sidebarVisible}
onClose={handleSidebarClose}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
data={topologyData}
/>
</div>
</Modal>
);
}
export default TopologyModal;
@@ -0,0 +1,333 @@
import React from 'react';
import { Drawer, Card, Descriptions, Tag, Badge, Row, Col, Statistic, Typography, Space, Progress } from 'antd';
import {
CloudServerOutlined,
AppstoreOutlined,
DatabaseOutlined,
SwapOutlined
} from '@ant-design/icons';
const { Text, Title } = Typography;
const DEVICE_COLORS = {
switch: '#1890ff',
router: '#722ed1',
server: '#52c41a',
storage: '#fa8c16',
default: '#8c8c8c'
};
const DEVICE_ICONS = {
switch: AppstoreOutlined,
router: SwapOutlined,
server: CloudServerOutlined,
storage: DatabaseOutlined,
default: CloudServerOutlined
};
const CABLE_COLORS = {
ethernet: '#1890ff',
fiber: '#13c2c2',
copper: '#fa8c16'
};
function TopologySidebar({ visible, onClose, selectedNode, selectedEdge, data }) {
if (!selectedNode && !selectedEdge) {
return null;
}
const renderDeviceDetail = () => {
if (!selectedNode) return null;
const IconComponent = DEVICE_ICONS[selectedNode.type] || DEVICE_ICONS.default;
const nodeColor = DEVICE_COLORS[selectedNode.type] || DEVICE_COLORS.default;
const portCount = selectedNode.portCount || {};
const usedPercent = portCount.total > 0 ? Math.round((portCount.used / portCount.total) * 100) : 0;
return (
<div>
<div
style={{
textAlign: 'center',
padding: '24px 16px',
background: `linear-gradient(135deg, ${nodeColor}15 0%, ${nodeColor}05 100%)`,
borderRadius: 12,
marginBottom: 20,
border: `1px solid ${nodeColor}30`
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: 16,
background: `${nodeColor}20`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12
}}
>
<IconComponent style={{ fontSize: 32, color: nodeColor }} />
</div>
<Title level={4} style={{ margin: '8px 0 4px' }}>{selectedNode.name}</Title>
<Text type="secondary" style={{ fontSize: 12 }}>{selectedNode.deviceId}</Text>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center', gap: 8 }}>
<Tag color={nodeColor} style={{ borderRadius: 4, margin: 0 }}>
{selectedNode.type === 'switch' ? '交换机' :
selectedNode.type === 'router' ? '路由器' :
selectedNode.type === 'server' ? '服务器' :
selectedNode.type === 'storage' ? '存储' : '设备'}
</Tag>
{selectedNode.isCenter && (
<Tag color="gold" style={{ borderRadius: 4, margin: 0 }}>拓扑中心</Tag>
)}
</div>
</div>
<Card size="small" style={{ marginBottom: 16, borderRadius: 8 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label={<Text type="secondary">设备型号</Text>}>
<Text>{selectedNode.model || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">IP地址</Text>}>
<Text code>{selectedNode.ipAddress || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">所属机房</Text>}>
<Text>{selectedNode.roomName || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">所属机柜</Text>}>
<Text>{selectedNode.rackName || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">U位</Text>}>
<Text>{typeof selectedNode.position === 'string' || typeof selectedNode.position === 'number'
? selectedNode.position
: selectedNode.position?.x !== undefined
? `坐标(${selectedNode.position.x}, ${selectedNode.position.y})`
: '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">状态</Text>}>
<Badge
status={selectedNode.status === 'online' ? 'success' :
selectedNode.status === 'fault' ? 'error' : 'default'}
text={selectedNode.status === 'online' ? '在线' :
selectedNode.status === 'fault' ? '故障' : '离线'}
/>
</Descriptions.Item>
</Descriptions>
</Card>
{portCount.total > 0 && (
<Card size="small" title={<Text style={{ fontSize: 13 }}>端口统计</Text>} style={{ marginBottom: 16, borderRadius: 8 }}>
<Row gutter={[8, 12]}>
<Col span={24}>
<Progress
percent={usedPercent}
strokeColor={nodeColor}
trailColor="#f0f0f0"
size="small"
format={(percent) => (
<span style={{ fontSize: 11, color: '#8c8c8c' }}>
{portCount.used}/{portCount.total} 已用 ({percent}%)
</span>
)}
/>
</Col>
<Col span={8}>
<Statistic
title={<Text style={{ fontSize: 11 }}>总数</Text>}
value={portCount.total || 0}
valueStyle={{ fontSize: 18, color: '#262626' }}
/>
</Col>
<Col span={8}>
<Statistic
title={<Text style={{ fontSize: 11 }}>已用</Text>}
value={portCount.used || 0}
valueStyle={{ fontSize: 18, color: '#1890ff' }}
/>
</Col>
<Col span={8}>
<Statistic
title={<Text style={{ fontSize: 11 }}>空闲</Text>}
value={portCount.free || 0}
valueStyle={{ fontSize: 18, color: '#52c41a' }}
/>
</Col>
{portCount.fault > 0 && (
<Col span={8}>
<Statistic
title={<Text style={{ fontSize: 11 }}>故障</Text>}
value={portCount.fault || 0}
valueStyle={{ fontSize: 18, color: '#ff4d4f' }}
/>
</Col>
)}
</Row>
</Card>
)}
</div>
);
};
const renderEdgeDetail = () => {
if (!selectedEdge) return null;
const centerDevice = data?.centerDevice;
const sourceDevice = selectedEdge.source === centerDevice?.deviceId
? centerDevice
: data?.nodes?.find(n => n.id === selectedEdge.source);
const targetDevice = selectedEdge.target === centerDevice?.deviceId
? centerDevice
: data?.nodes?.find(n => n.id === selectedEdge.target);
const cableColor = CABLE_COLORS[selectedEdge.cableType] || '#8c8c8c';
return (
<div>
<div
style={{
padding: '24px 16px',
background: `linear-gradient(135deg, ${cableColor}15 0%, ${cableColor}05 100%)`,
borderRadius: 12,
marginBottom: 20,
border: `1px solid ${cableColor}30`
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div style={{ textAlign: 'center', flex: 1 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 8,
background: `${DEVICE_COLORS[sourceDevice?.type] || '#8c8c8c'}20`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8
}}
>
{React.createElement(DEVICE_ICONS[sourceDevice?.type] || DEVICE_ICONS.default, {
style: { fontSize: 24, color: DEVICE_COLORS[sourceDevice?.type] || '#8c8c8c' }
})}
</div>
<div style={{ fontWeight: 600, fontSize: 12 }}>{sourceDevice?.name || selectedEdge.source}</div>
<Tag size="small" style={{ marginTop: 4 }}>{selectedEdge.sourcePort}</Tag>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<div
style={{
width: 40,
height: 2,
background: cableColor,
position: 'relative'
}}
>
<div
style={{
position: 'absolute',
right: -4,
top: -3,
width: 0,
height: 0,
borderLeft: `8px solid ${cableColor}`,
borderTop: '4px solid transparent',
borderBottom: '4px solid transparent'
}}
/>
</div>
<Tag color={cableColor} style={{ fontSize: 10, borderRadius: 4 }}>
{selectedEdge.cableType === 'ethernet' ? '网线' :
selectedEdge.cableType === 'fiber' ? '光纤' :
selectedEdge.cableType === 'copper' ? '铜缆' : selectedEdge.cableType}
</Tag>
</div>
<div style={{ textAlign: 'center', flex: 1 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 8,
background: `${DEVICE_COLORS[targetDevice?.type] || '#8c8c8c'}20`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8
}}
>
{React.createElement(DEVICE_ICONS[targetDevice?.type] || DEVICE_ICONS.default, {
style: { fontSize: 24, color: DEVICE_COLORS[targetDevice?.type] || '#8c8c8c' }
})}
</div>
<div style={{ fontWeight: 600, fontSize: 12 }}>{targetDevice?.name || selectedEdge.target}</div>
<Tag size="small" style={{ marginTop: 4 }}>{selectedEdge.targetPort}</Tag>
</div>
</div>
</div>
<Card size="small" style={{ marginBottom: 16, borderRadius: 8 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label={<Text type="secondary">线缆ID</Text>}>
<Text code>{selectedEdge.cableId}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">线缆类型</Text>}>
<Tag color={cableColor}>
{selectedEdge.cableType === 'ethernet' ? '网线' :
selectedEdge.cableType === 'fiber' ? '光纤' :
selectedEdge.cableType === 'copper' ? '铜缆' : selectedEdge.cableType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">线缆长度</Text>}>
<Text>{selectedEdge.cableLength ? `${selectedEdge.cableLength}m` : '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">线缆标签</Text>}>
<Text>{selectedEdge.cableLabel || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">状态</Text>}>
<Badge
status={selectedEdge.status === 'normal' ? 'success' :
selectedEdge.status === 'fault' ? 'error' : 'default'}
text={selectedEdge.status === 'normal' ? '正常' :
selectedEdge.status === 'fault' ? '故障' : '未连接'}
/>
</Descriptions.Item>
<Descriptions.Item label={<Text type="secondary">安装时间</Text>}>
<Text>{selectedEdge.installedAt ? new Date(selectedEdge.installedAt).toLocaleDateString() : '-'}</Text>
</Descriptions.Item>
</Descriptions>
</Card>
{selectedEdge.description && (
<Card size="small" title={<Text style={{ fontSize: 13 }}>描述</Text>} style={{ borderRadius: 8 }}>
<Text>{selectedEdge.description}</Text>
</Card>
)}
</div>
);
};
return (
<Drawer
title={
<Space>
{selectedNode
? React.createElement(DEVICE_ICONS[selectedNode?.type] || DEVICE_ICONS.default, {})
: React.createElement(SwapOutlined, {})}
<span>{selectedNode ? '设备详情' : '连接详情'}</span>
</Space>
}
placement="right"
width={340}
open={visible}
onClose={onClose}
destroyOnClose
>
{selectedNode && renderDeviceDetail()}
{selectedEdge && renderEdgeDetail()}
</Drawer>
);
}
export default TopologySidebar;
@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
export function useTopologyData() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchTopologyBySwitch = useCallback(async (switchId, options = {}) => {
if (!switchId) {
setData(null);
return;
}
try {
setLoading(true);
setError(null);
const params = { maxNodes: options.maxNodes || 100 };
const response = await axios.get(`/api/topology/switch/${switchId}`, { params });
if (response.data.success) {
setData(response.data.data);
} else {
setError(response.data.error || '获取拓扑数据失败');
}
} catch (err) {
const errorMessage = err.response?.data?.error || err.message || '获取拓扑数据失败';
setError(errorMessage);
console.error('获取拓扑数据失败:', err);
} finally {
setLoading(false);
}
}, []);
const fetchTopologyByRack = useCallback(async (rackId, options = {}) => {
if (!rackId) {
setData(null);
return;
}
try {
setLoading(true);
setError(null);
const params = { maxNodes: options.maxNodes || 100 };
const response = await axios.get(`/api/topology/rack/${rackId}`, { params });
if (response.data.success) {
setData(response.data.data);
} else {
setError(response.data.error || '获取拓扑数据失败');
}
} catch (err) {
const errorMessage = err.response?.data?.error || err.message || '获取拓扑数据失败';
setError(errorMessage);
console.error('获取拓扑数据失败:', err);
} finally {
setLoading(false);
}
}, []);
const clearData = useCallback(() => {
setData(null);
setError(null);
}, []);
return {
data,
loading,
error,
fetchTopologyBySwitch,
fetchTopologyByRack,
clearData
};
}
@@ -0,0 +1,73 @@
import { useCallback } from 'react';
import dagre from 'dagre';
const NODE_WIDTH = 180;
const NODE_HEIGHT = 80;
export function useTopologyLayout() {
const calculateLayout = useCallback((nodes, edges, options = {}) => {
if (!nodes || nodes.length === 0) {
return { nodes: [], edges: [] };
}
const { layoutType = 'TB', centerNodeId = null } = options;
const g = new dagre.graphlib.Graph();
g.setGraph({
rankdir: layoutType,
nodesep: 60,
ranksep: 100,
marginx: 30,
marginy: 30
});
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach(node => {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach(edge => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
const positionedNodes = nodes.map(node => {
const nodeData = g.node(node.id);
if (!nodeData) return node;
let x = nodeData.x - NODE_WIDTH / 2;
let y = nodeData.y - NODE_HEIGHT / 2;
if (centerNodeId && node.id === centerNodeId) {
x = nodeData.x - NODE_WIDTH / 2;
y = nodeData.y - NODE_HEIGHT / 2;
}
return {
...node,
position: { x, y }
};
});
return {
nodes: positionedNodes,
edges: edges.map(edge => ({
...edge,
source: edge.source,
target: edge.target
}))
};
}, []);
const getLayoutedElements = useCallback((centerDevice, connectedNodes, edges, options = {}) => {
const allNodes = centerDevice ? [centerDevice, ...connectedNodes] : connectedNodes;
const layouted = calculateLayout(allNodes, edges, options);
return layouted;
}, [calculateLayout]);
return {
calculateLayout,
getLayoutedElements
};
}
@@ -0,0 +1,6 @@
export { default as TopologyGraph } from './TopologyGraph';
export { default as TopologySidebar } from './TopologySidebar';
export { default as TopologyControls } from './TopologyControls';
export { default as TopologyModal } from './TopologyModal';
export { useTopologyData } from './hooks/useTopologyData';
export { useTopologyLayout } from './hooks/useTopologyLayout';
@@ -0,0 +1,114 @@
import React from 'react';
import { Tooltip } from 'antd';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 100;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function RouterNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = '#eb595a';
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
<img
src="/png/防火墙.png"
alt="防火墙"
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div>
<div style={{
height: 32,
background: '#fff1f0',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Tooltip title={data.name || '防火墙'} placement="bottomLeft">
<div style={{
fontSize: 11,
fontWeight: 'bold',
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
cursor: 'pointer'
}}>
{data.name?.substring(0, 14) || '防火墙'}
</div>
</Tooltip>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColor,
display: 'inline-block'
}} />
{isCenter && (
<span style={{
fontSize: 9,
color: '#faad14',
fontWeight: 'bold'
}}>
</span>
)}
</div>
</div>
</div>
);
}
export default RouterNode;
@@ -0,0 +1,155 @@
import React from 'react';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 80;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
const TYPE_COLORS = {
switch: '#1890ff',
router: '#722ed1',
server: '#52c41a',
storage: '#fa8c16',
default: '#8c8c8c'
};
function GenericNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = TYPE_COLORS[data.type] || TYPE_COLORS.default;
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer'
}}
>
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
viewBox={`0 0 ${NODE_WIDTH} ${NODE_HEIGHT}`}
style={{ display: 'block' }}
>
<rect
x="10"
y="10"
width={NODE_WIDTH - 20}
height={NODE_HEIGHT - 20}
rx="4"
fill="#fafafa"
stroke={nodeColor}
strokeWidth="1"
/>
<rect
x="20"
y="20"
width="50"
height={NODE_HEIGHT - 40}
rx="4"
fill={nodeColor}
opacity="0.2"
/>
<rect
x="25"
y="25"
width="40"
height="30"
rx="2"
fill={nodeColor}
/>
<circle cx="35" cy="35" r="6" fill="#fff" />
<circle cx="35" cy="35" r="3" fill="#fff" opacity="0.5" />
<line x1="75" y1="20" x2="75" y2={NODE_HEIGHT - 20} stroke="#d9d9d9" strokeWidth="1" />
<text
x="85"
y="35"
fill="#262626"
fontSize="12"
fontWeight="bold"
>
<title>{data.name || '设备'}</title>
{data.name?.substring(0, 10) || '设备'}
</text>
<text
x="85"
y="50"
fill="#8c8c8c"
fontSize="10"
>
{data.type === 'switch' ? '交换机' :
data.type === 'router' ? '路由器' :
data.type === 'server' ? '服务器' :
data.type === 'storage' ? '存储' : '设备'}
</text>
{isCenter && (
<>
<circle cx={NODE_WIDTH - 20} cy="15" r="8" fill={nodeColor} />
<text
x={NODE_WIDTH - 20}
y="19"
fill="#fff"
fontSize="8"
fontWeight="bold"
textAnchor="middle"
>
</text>
</>
)}
<circle
cx={NODE_WIDTH - 15}
cy={NODE_HEIGHT - 15}
r="4"
fill={statusColor}
/>
</svg>
</div>
);
}
export default GenericNode;
@@ -0,0 +1,114 @@
import React from 'react';
import { Tooltip } from 'antd';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 100;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function RouterNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = '#722ed1';
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
<img
src="/png/路由器.png"
alt="路由器"
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div>
<div style={{
height: 32,
background: '#f9f0ff',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Tooltip title={data.name || '路由器'} placement="bottomLeft">
<div style={{
fontSize: 11,
fontWeight: 'bold',
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
cursor: 'pointer'
}}>
{data.name?.substring(0, 14) || '路由器'}
</div>
</Tooltip>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColor,
display: 'inline-block'
}} />
{isCenter && (
<span style={{
fontSize: 9,
color: '#faad14',
fontWeight: 'bold'
}}>
</span>
)}
</div>
</div>
</div>
);
}
export default RouterNode;
@@ -0,0 +1,114 @@
import React from 'react';
import { Tooltip } from 'antd';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 100;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function ServerNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = '#52c41a';
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
<img
src="/png/服务器.png"
alt="服务器"
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div>
<div style={{
height: 32,
background: '#f6ffed',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Tooltip title={data.name || '服务器'} placement="bottomLeft">
<div style={{
fontSize: 11,
fontWeight: 'bold',
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
cursor: 'pointer'
}}>
{data.name?.substring(0, 14) || '服务器'}
</div>
</Tooltip>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColor,
display: 'inline-block'
}} />
{isCenter && (
<span style={{
fontSize: 9,
color: '#faad14',
fontWeight: 'bold'
}}>
</span>
)}
</div>
</div>
</div>
);
}
export default ServerNode;
@@ -0,0 +1,226 @@
import React from 'react';
import { Tooltip } from 'antd';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 110;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function StorageNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = '#fa8c16';
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
const diskCount = 8;
const activeDisks = Math.floor((data.portCount?.used || 12) / 4);
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer'
}}
>
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
viewBox={`0 0 ${NODE_WIDTH} ${NODE_HEIGHT}`}
style={{ display: 'block' }}
>
<rect
x="10"
y="10"
width={NODE_WIDTH - 20}
height={NODE_HEIGHT - 20}
rx="4"
fill="#fff7e6"
stroke={nodeColor}
strokeWidth="1"
/>
<rect
x="15"
y="15"
width={NODE_WIDTH - 30}
height="55"
rx="3"
fill="#fff"
stroke={nodeColor}
strokeWidth="1"
/>
<rect
x="20"
y="20"
width="35"
height="45"
rx="2"
fill="#262626"
/>
<rect x="23" y="23" width="29" height="4" rx="1" fill="#52c41a" />
{[...Array(6)].map((_, i) => (
<rect
key={i}
x="23"
y={29 + i * 6}
width="29"
height="4"
rx="1"
fill={i < activeDisks ? nodeColor : '#595959'}
/>
))}
<rect
x="60"
y="20"
width="35"
height="45"
rx="2"
fill="#262626"
/>
<rect x="63" y="23" width="29" height="4" rx="1" fill="#52c41a" />
{[...Array(6)].map((_, i) => (
<rect
key={i}
x="63"
y={29 + i * 6}
width="29"
height="4"
rx="1"
fill={i < activeDisks - 2 ? nodeColor : '#595959'}
/>
))}
<rect
x="100"
y="20"
width="35"
height="45"
rx="2"
fill="#262626"
/>
<rect x="103" y="23" width="29" height="4" rx="1" fill="#52c41a" />
{[...Array(6)].map((_, i) => (
<rect
key={i}
x="103"
y={29 + i * 6}
width="29"
height="4"
rx="1"
fill={i < activeDisks - 4 ? nodeColor : '#595959'}
/>
))}
<rect
x="140"
y="20"
width="35"
height="45"
rx="2"
fill="#262626"
/>
<rect x="143" y="23" width="29" height="4" rx="1" fill="#52c41a" />
{[...Array(6)].map((_, i) => (
<rect
key={i}
x="143"
y={29 + i * 6}
width="29"
height="4"
rx="1"
fill={i < activeDisks - 6 ? nodeColor : '#595959'}
/>
))}
<line x1="15" y1="75" x2={NODE_WIDTH - 15} y2="75" stroke="#d9d9d9" strokeWidth="1" />
<rect x="15" y="80" width="80" height="5" rx="1" fill="#262626" />
<rect x="100" y="80" width="40" height="5" rx="1" fill="#52c41a" />
<text
x="15"
y="98"
fill="#262626"
fontSize="10"
fontWeight="bold"
>
<title>{data.name || '存储设备'}</title>
{data.name?.substring(0, 14) || '存储设备'}
</text>
<text
x={NODE_WIDTH - 15}
y="98"
fill="#8c8c8c"
fontSize="9"
textAnchor="end"
>
{data.model || 'Storage'}
</text>
{isCenter && (
<>
<circle cx={NODE_WIDTH - 20} cy="12" r="8" fill={nodeColor} />
<text
x={NODE_WIDTH - 20}
y="16"
fill="#fff"
fontSize="8"
fontWeight="bold"
textAnchor="middle"
>
</text>
</>
)}
<circle
cx={NODE_WIDTH - 15}
cy={NODE_HEIGHT - 12}
r="4"
fill={statusColor}
/>
</svg>
</div>
);
}
export default StorageNode;
@@ -0,0 +1,120 @@
import React from 'react';
import { Tooltip } from 'antd';
const NODE_WIDTH = 200;
const NODE_HEIGHT = 120;
const STATUS_COLORS = {
online: '#52c41a',
offline: '#d9d9d9',
fault: '#ff4d4f',
warning: '#faad14'
};
function SwitchNode({ data }) {
if (!data) {
return (
<div style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
无数据
</div>
);
}
const nodeColor = '#1890ff';
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
const isCenter = data.isCenter;
const isSelected = data.selected;
const portCount = data.portCount || { total: 24, used: 12, free: 12 };
return (
<div
style={{
width: NODE_WIDTH,
height: NODE_HEIGHT,
background: '#fff',
borderRadius: 8,
border: isSelected
? `2px solid ${nodeColor}`
: isCenter
? `2px solid ${nodeColor}`
: `1px solid #d9d9d9`,
boxShadow: isCenter
? `0 4px 16px ${nodeColor}40`
: isSelected
? `0 4px 12px ${nodeColor}30`
: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
<img
src="/png/交换机.png"
alt="交换机"
style={{ width: 'auto', height: '70px', objectFit: 'contain' }}
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div>
<div style={{
height: 36,
background: '#e6f7ff',
padding: '4px 8px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}>
<Tooltip title={data.name || '交换机'} placement="bottomLeft">
<div style={{
fontSize: 11,
fontWeight: 'bold',
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer'
}}>
{data.name?.substring(0, 16) || '交换机'}
</div>
</Tooltip>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2 }}>
<span style={{
fontSize: 9,
color: '#8c8c8c'
}}>
端口: {portCount.used}/{portCount.total}
</span>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: statusColor,
display: 'inline-block'
}} />
{isCenter && (
<span style={{
fontSize: 9,
color: '#faad14',
fontWeight: 'bold'
}}>
中心
</span>
)}
</div>
</div>
</div>
);
}
export default SwitchNode;
@@ -0,0 +1,6 @@
export { default as SwitchNode } from './SwitchNode';
export { default as ServerNode } from './ServerNode';
export { default as RouterNode } from './RouterNode';
export { default as StorageNode } from './StorageNode';
export { default as GenericNode } from './GenericNode';
export { default as FirewallNode } from './FirewallNode';
+17
View File
@@ -46,6 +46,7 @@ import {
FilterOutlined,
ClearOutlined,
MoreOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import * as XLSX from 'xlsx';
@@ -55,6 +56,7 @@ import { designTokens } from '../config/theme';
import { debounce } from '../utils/common';
import CloseButton from '../components/CloseButton';
import CableWizardModal from '../components/CableWizardModal';
import { TopologyModal } from '../components/topology';
const { Option } = Select;
const { Panel } = Collapse;
@@ -145,6 +147,8 @@ function CableManagement() {
const [expandedKeys, setExpandedKeys] = useState([]);
const [topologyModalVisible, setTopologyModalVisible] = useState(false);
const fetchCables = useCallback(async () => {
try {
setLoading(true);
@@ -1069,6 +1073,14 @@ function CableManagement() {
>
导出
</Button>
<Button
icon={<ShareAltOutlined />}
onClick={() => setTopologyModalVisible(true)}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
生成拓扑图
</Button>
</Space>
<Space>
<Tooltip title="刷新数据">
@@ -1866,6 +1878,11 @@ function CableManagement() {
</div>
)}
</Modal>
<TopologyModal
visible={topologyModalVisible}
onClose={() => setTopologyModalVisible(false)}
/>
</motion.div>
);
}