feat: 新增网卡、端口和接线管理功能
- 添加网卡(NetworkCard)模型及相关路由 - 实现端口(DevicePort)管理功能 - 新增接线(Cable)管理功能 - 添加前端网卡和端口管理界面 - 更新机柜可视化页面显示接线 - 添加设备详情抽屉展示端口和接线信息 - 更新部署文档包含数据库迁移指南 - 添加批量创建端口功能 - 设备删除时自动清理相关接线
This commit is contained in:
+277
@@ -1715,6 +1715,283 @@ echo "备份验证完成"
|
||||
|
||||
---
|
||||
|
||||
## 🚨 已部署项目更新指南
|
||||
|
||||
> **📋 版本更新说明**:
|
||||
> 本节介绍如何将已部署的生产环境更新到最新版本,包含网卡管理、批量创建端口等新功能。
|
||||
|
||||
### 更新前准备
|
||||
|
||||
#### 1. 确认当前版本
|
||||
```bash
|
||||
# 检查最后更新日期
|
||||
cd /var/www/idc_assest
|
||||
git log -1 --format="%cd" --date=short
|
||||
|
||||
# 查看当前分支
|
||||
git branch
|
||||
```
|
||||
|
||||
#### 2. 备份现有数据(强制操作)
|
||||
```bash
|
||||
# 创建备份目录
|
||||
BACKUP_DIR="/var/backups/idc_assest/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
echo "开始备份..."
|
||||
|
||||
# 备份数据库(SQLite)
|
||||
if [ -f "/var/www/idc_assest/backend/idc_management.db" ]; then
|
||||
cp /var/www/idc_assest/backend/idc_management.db "$BACKUP_DIR/idc_management.db"
|
||||
echo "✓ SQLite数据库已备份"
|
||||
fi
|
||||
|
||||
# 备份数据库(MySQL)
|
||||
read -p "是否需要备份MySQL数据库? (y/n): " need_mysql
|
||||
if [ "$need_mysql" = "y" ]; then
|
||||
read -p "MySQL用户名: " db_user
|
||||
read -s -p "MySQL密码: " db_pass
|
||||
echo
|
||||
mysqldump -u $db_user -p$db_pass idc_management > "$BACKUP_DIR/database.sql"
|
||||
echo "✓ MySQL数据库已备份"
|
||||
fi
|
||||
|
||||
# 备份配置文件
|
||||
cp /var/www/idc_assest/backend/.env "$BACKUP_DIR/.env"
|
||||
cp /var/www/idc_assest/frontend/.env* "$BACKUP_DIR/" 2>/dev/null || true
|
||||
echo "✓ 配置文件已备份"
|
||||
|
||||
# 备份上传文件
|
||||
cp -r /var/www/idc_assest/backend/uploads "$BACKUP_DIR/uploads" 2>/dev/null || true
|
||||
echo "✓ 上传文件已备份"
|
||||
|
||||
echo ""
|
||||
echo "备份完成,保存在: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
#### SQLite 数据库更新
|
||||
|
||||
```bash
|
||||
cd /var/www/idc_assest/backend
|
||||
|
||||
# 方式一:使用迁移脚本(推荐)
|
||||
node scripts/migrate-v2.js
|
||||
|
||||
# 预期输出:
|
||||
# ========================================
|
||||
# IDC管理系统 - 数据库迁移脚本 v2.0
|
||||
# ========================================
|
||||
# 🔍 检测数据库类型...
|
||||
# 数据库类型: sqlite
|
||||
# 📋 开始迁移...
|
||||
# ...
|
||||
# ✅ 迁移完成!
|
||||
# ========================================
|
||||
# 迁移成功完成!🎉
|
||||
# ========================================
|
||||
```
|
||||
|
||||
#### MySQL 数据库更新
|
||||
|
||||
```bash
|
||||
cd /var/www/idc_assest/backend
|
||||
|
||||
# 执行迁移脚本
|
||||
node scripts/migrate-v2.js
|
||||
|
||||
# 预期输出:
|
||||
# ========================================
|
||||
# IDC管理系统 - 数据库迁移脚本 v2.0
|
||||
# ========================================
|
||||
# 🔍 检测数据库类型...
|
||||
# 数据库类型: mysql
|
||||
# 📋 开始迁移...
|
||||
# 1. 创建 network_cards 表
|
||||
# 2. 为 device_ports 添加 nic_id 字段
|
||||
# 3. 创建相关索引
|
||||
# 🔄 执行 MySQL 迁移...
|
||||
# → 创建表: network_cards
|
||||
# → 检查 nic_id 字段是否存在...
|
||||
# → 添加 nic_id 字段...
|
||||
# → 创建 nic_id 索引...
|
||||
# ✅ 迁移完成!
|
||||
# ========================================
|
||||
# 迁移成功完成!🎉
|
||||
# ========================================
|
||||
```
|
||||
|
||||
> **💡 提示**:迁移脚本会自动检测数据库类型并执行相应的迁移操作。
|
||||
|
||||
### 更新后端代码
|
||||
|
||||
```bash
|
||||
cd /var/www/idc_assest
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin master
|
||||
|
||||
# 更新依赖
|
||||
cd backend
|
||||
npm install --only=production
|
||||
|
||||
# 检查新增的文件
|
||||
echo "新增的文件:"
|
||||
git diff --name-only --diff-filter=A HEAD
|
||||
|
||||
# 验证新模型文件
|
||||
ls -la models/NetworkCard.js
|
||||
ls -la routes/networkCards.js
|
||||
```
|
||||
|
||||
### 注册新路由
|
||||
|
||||
检查 `backend/server.js` 是否已包含新路由:
|
||||
|
||||
```javascript
|
||||
// 确认以下代码存在
|
||||
const networkCardsRouter = require('./routes/networkCards');
|
||||
app.use('/api/network-cards', networkCardsRouter);
|
||||
```
|
||||
|
||||
如果不存在,请手动添加:
|
||||
|
||||
```bash
|
||||
# 编辑 server.js
|
||||
nano /var/www/idc_assest/backend/server.js
|
||||
|
||||
# 在合适位置添加(通常在其他 app.use 语句附近)
|
||||
const networkCardsRouter = require('./routes/networkCards');
|
||||
app.use('/api/network-cards', networkCardsRouter);
|
||||
```
|
||||
|
||||
### 重启后端服务
|
||||
|
||||
```bash
|
||||
# 重启PM2进程
|
||||
pm2 restart idc-backend
|
||||
|
||||
# 验证服务状态
|
||||
pm2 status idc-backend
|
||||
|
||||
# 查看日志确认无错误
|
||||
pm2 logs idc-backend --lines 50
|
||||
```
|
||||
|
||||
### 更新前端代码
|
||||
|
||||
```bash
|
||||
cd /var/www/idc_assest/frontend
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin master
|
||||
|
||||
# 更新依赖
|
||||
npm install
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 验证构建结果
|
||||
ls -la dist/
|
||||
|
||||
# 部署到Web目录
|
||||
sudo rm -rf /var/www/idc-frontend/*
|
||||
sudo cp -r dist/* /var/www/idc-frontend/
|
||||
sudo chown -R www-data:www-data /var/www/idc-frontend
|
||||
```
|
||||
|
||||
### 验证更新
|
||||
|
||||
#### 1. API 接口验证
|
||||
|
||||
```bash
|
||||
# 测试新API接口
|
||||
curl http://localhost:8000/api/network-cards
|
||||
|
||||
# 预期返回:空数组或现有数据
|
||||
curl http://localhost:8000/api/device-ports
|
||||
|
||||
# 预期返回:端口数据(可能包含 nic_id 字段)
|
||||
```
|
||||
|
||||
#### 2. 前端功能验证
|
||||
|
||||
访问管理界面,验证以下功能:
|
||||
|
||||
- [ ] **网卡管理**
|
||||
- [ ] 设备详情中显示"端口与网卡"标签页
|
||||
- [ ] 可以创建新网卡
|
||||
- [ ] 网卡列表正确显示
|
||||
- [ ] 可以删除网卡(需无端口关联)
|
||||
|
||||
- [ ] **端口管理**
|
||||
- [ ] 可以创建端口时选择所属网卡
|
||||
- [ ] 端口按网卡分组显示
|
||||
- [ ] 显示未分组的端口
|
||||
|
||||
- [ ] **批量创建端口**
|
||||
- [ ] 输入格式如 `1/0/1-1/0/48` 可以创建多个端口
|
||||
- [ ] 预览功能正确显示待创建端口数量
|
||||
|
||||
#### 3. 数据库验证
|
||||
|
||||
```bash
|
||||
# 验证新表存在
|
||||
sqlite3 /var/www/idc_assest/backend/idc_management.db ".tables"
|
||||
# 应包含:network_cards
|
||||
|
||||
# 验证新字段
|
||||
sqlite3 /var/www/idc_assest/backend/idc_management.db ".schema device_ports" | grep nic_id
|
||||
# 应显示 nic_id 字段定义
|
||||
|
||||
# 检查数据
|
||||
sqlite3 /var/www/idc_assest/backend/idc_management.db "SELECT COUNT(*) FROM network_cards;"
|
||||
```
|
||||
|
||||
### 回滚操作(如果出现问题)
|
||||
|
||||
```bash
|
||||
cd /var/www/idc_assest
|
||||
|
||||
# 1. 停止服务
|
||||
pm2 stop idc-backend
|
||||
|
||||
# 2. 恢复数据库
|
||||
# SQLite
|
||||
cp /var/backups/idc_assest/最新备份目录/idc_management.db backend/idc_management.db
|
||||
|
||||
# 或 MySQL
|
||||
mysql -u idc_prod_user -p idc_management < /var/backups/idc_assest/最新备份目录/database.sql
|
||||
|
||||
# 3. 恢复代码
|
||||
git checkout HEAD@{1}
|
||||
|
||||
# 4. 重启服务
|
||||
pm2 start idc-backend
|
||||
|
||||
# 5. 验证回滚
|
||||
curl http://localhost:8000/api/health
|
||||
```
|
||||
|
||||
### 更新日志
|
||||
|
||||
**v2.x.x 新增功能**:
|
||||
- 网卡(NIC)管理功能,支持为设备添加多块网卡
|
||||
- 端口与网卡关联,支持按网卡分组管理端口
|
||||
- 批量创建端口,支持端口范围格式(如 `1/0/1-1/0/48`)
|
||||
- 新增 `network_cards` 数据库表
|
||||
- `device_ports` 表新增 `nic_id` 字段
|
||||
- 新增API端点:
|
||||
- `GET /api/network-cards` - 获取网卡列表
|
||||
- `POST /api/network-cards` - 创建网卡
|
||||
- `PUT /api/network-cards/:nicId` - 更新网卡
|
||||
- `DELETE /api/network-cards/:nicId` - 删除网卡
|
||||
- `GET /api/network-cards/device/:deviceId/with-ports` - 获取网卡及端口
|
||||
|
||||
---
|
||||
|
||||
## 🔐 安全加固清单
|
||||
|
||||
### 服务器安全
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const Device = require('./Device');
|
||||
|
||||
const Cable = sequelize.define('Cable', {
|
||||
cableId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
sourceDeviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'deviceId'
|
||||
}
|
||||
},
|
||||
sourcePort: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
targetDeviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'deviceId'
|
||||
}
|
||||
},
|
||||
targetPort: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
cableType: {
|
||||
type: DataTypes.ENUM('ethernet', 'fiber', 'copper'),
|
||||
defaultValue: 'ethernet',
|
||||
allowNull: false
|
||||
},
|
||||
cableLength: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('normal', 'fault', 'disconnected'),
|
||||
defaultValue: 'normal',
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'cables',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['sourceDeviceId'] },
|
||||
{ fields: ['targetDeviceId'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['cableType'] },
|
||||
{ fields: ['sourceDeviceId', 'targetDeviceId'] }
|
||||
]
|
||||
});
|
||||
|
||||
Cable.belongsTo(Device, { foreignKey: 'sourceDeviceId', as: 'sourceDevice' });
|
||||
Cable.belongsTo(Device, { foreignKey: 'targetDeviceId', as: 'targetDevice' });
|
||||
|
||||
module.exports = Cable;
|
||||
@@ -0,0 +1,76 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const Device = require('./Device');
|
||||
const NetworkCard = require('./NetworkCard');
|
||||
|
||||
const DevicePort = sequelize.define('DevicePort', {
|
||||
portId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
deviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'deviceId'
|
||||
}
|
||||
},
|
||||
nicId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'network_cards',
|
||||
key: 'nicId'
|
||||
},
|
||||
comment: '所属网卡ID,可为空(向后兼容)'
|
||||
},
|
||||
portName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
portType: {
|
||||
type: DataTypes.ENUM('RJ45', 'SFP', 'SFP+', 'SFP28', 'QSFP', 'QSFP28'),
|
||||
defaultValue: 'RJ45',
|
||||
allowNull: false
|
||||
},
|
||||
portSpeed: {
|
||||
type: DataTypes.ENUM('100M', '1G', '10G', '25G', '40G', '100G'),
|
||||
defaultValue: '1G',
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('free', 'occupied', 'fault'),
|
||||
defaultValue: 'free',
|
||||
allowNull: false
|
||||
},
|
||||
vlanId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'device_ports',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['deviceId'] },
|
||||
{ fields: ['nicId'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['portType'] },
|
||||
{ fields: ['portSpeed'] },
|
||||
{ unique: true, fields: ['deviceId', 'portName'] }
|
||||
]
|
||||
});
|
||||
|
||||
DevicePort.belongsTo(Device, { foreignKey: 'deviceId', as: 'device' });
|
||||
Device.hasMany(DevicePort, { foreignKey: 'deviceId', as: 'ports' });
|
||||
|
||||
DevicePort.belongsTo(NetworkCard, { foreignKey: 'nicId', as: 'networkCard' });
|
||||
NetworkCard.hasMany(DevicePort, { foreignKey: 'nicId', as: 'ports' });
|
||||
|
||||
module.exports = DevicePort;
|
||||
@@ -0,0 +1,69 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const Device = require('./Device');
|
||||
|
||||
const NetworkCard = sequelize.define('NetworkCard', {
|
||||
nicId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
deviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'deviceId'
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '网卡名称,如"网卡1"、"eth0"、"Primary NIC"'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: '网卡描述信息'
|
||||
},
|
||||
slotNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '插槽编号'
|
||||
},
|
||||
portCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '端口数量'
|
||||
},
|
||||
model: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: '网卡型号'
|
||||
},
|
||||
manufacturer: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: '制造商'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('normal', 'warning', 'fault', 'offline'),
|
||||
defaultValue: 'normal',
|
||||
allowNull: false,
|
||||
comment: '网卡状态'
|
||||
}
|
||||
}, {
|
||||
tableName: 'network_cards',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['deviceId'] },
|
||||
{ fields: ['slotNumber'] },
|
||||
{ unique: true, fields: ['deviceId', 'name'] }
|
||||
]
|
||||
});
|
||||
|
||||
NetworkCard.belongsTo(Device, { foreignKey: 'deviceId', as: 'device' });
|
||||
Device.hasMany(NetworkCard, { foreignKey: 'deviceId', as: 'networkCards' });
|
||||
|
||||
module.exports = NetworkCard;
|
||||
@@ -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');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { sourceDeviceId, targetDeviceId, status, cableType, page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = {};
|
||||
|
||||
if (sourceDeviceId) {
|
||||
where.sourceDeviceId = sourceDeviceId;
|
||||
}
|
||||
|
||||
if (targetDeviceId) {
|
||||
where.targetDeviceId = targetDeviceId;
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (cableType && cableType !== 'all') {
|
||||
where.cableType = cableType;
|
||||
}
|
||||
|
||||
const { count, rows } = await Cable.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'sourceDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'targetDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
],
|
||||
offset,
|
||||
limit: parseInt(pageSize),
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
cables: rows,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取接线列表失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const cables = await Cable.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ sourceDeviceId: deviceId },
|
||||
{ targetDeviceId: deviceId }
|
||||
]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'sourceDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'targetDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.json(cables);
|
||||
} catch (error) {
|
||||
console.error('获取设备接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { cableId, sourceDeviceId, sourcePort, targetDeviceId, targetPort, cableType, cableLength, status, description } = req.body;
|
||||
|
||||
if (!sourceDeviceId || !sourcePort || !targetDeviceId || !targetPort) {
|
||||
return res.status(400).json({ error: '缺少必填字段' });
|
||||
}
|
||||
|
||||
if (sourceDeviceId === targetDeviceId) {
|
||||
return res.status(400).json({ error: '源设备和目标设备不能相同' });
|
||||
}
|
||||
|
||||
const existingCable = await Cable.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ sourceDeviceId, sourcePort },
|
||||
{ targetDeviceId, targetPort }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingCable) {
|
||||
return res.status(400).json({ error: '端口已被占用' });
|
||||
}
|
||||
|
||||
const autoCableId = cableId || `CABLE-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const cable = await Cable.create({
|
||||
cableId: autoCableId,
|
||||
sourceDeviceId,
|
||||
sourcePort,
|
||||
targetDeviceId,
|
||||
targetPort,
|
||||
cableType: cableType || 'ethernet',
|
||||
cableLength,
|
||||
status: status || 'normal',
|
||||
description
|
||||
});
|
||||
|
||||
const createdCable = await Cable.findByPk(cable.cableId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'sourceDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'targetDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json(createdCable);
|
||||
} catch (error) {
|
||||
console.error('创建接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/batch', async (req, res) => {
|
||||
try {
|
||||
const { cables } = req.body;
|
||||
|
||||
if (!cables || !Array.isArray(cables) || cables.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的接线数据' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: cables.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < cables.length; i++) {
|
||||
const cableData = cables[i];
|
||||
|
||||
try {
|
||||
if (!cableData.cableId || !cableData.sourceDeviceId || !cableData.sourcePort ||
|
||||
!cableData.targetDeviceId || !cableData.targetPort) {
|
||||
throw new Error('缺少必填字段');
|
||||
}
|
||||
|
||||
if (cableData.sourceDeviceId === cableData.targetDeviceId) {
|
||||
throw new Error('源设备和目标设备不能相同');
|
||||
}
|
||||
|
||||
const existingCable = await Cable.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ sourceDeviceId: cableData.sourceDeviceId, sourcePort: cableData.sourcePort },
|
||||
{ targetDeviceId: cableData.targetDeviceId, targetPort: cableData.targetPort }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingCable) {
|
||||
throw new Error('端口已被占用');
|
||||
}
|
||||
|
||||
await Cable.create({
|
||||
cableId: cableData.cableId,
|
||||
sourceDeviceId: cableData.sourceDeviceId,
|
||||
sourcePort: cableData.sourcePort,
|
||||
targetDeviceId: cableData.targetDeviceId,
|
||||
targetPort: cableData.targetPort,
|
||||
cableType: cableData.cableType || 'ethernet',
|
||||
cableLength: cableData.cableLength,
|
||||
status: cableData.status || 'normal',
|
||||
description: cableData.description
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
index: i + 1,
|
||||
cableId: cableData.cableId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('批量创建接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:cableId', async (req, res) => {
|
||||
try {
|
||||
const [updated] = await Cable.update(req.body, {
|
||||
where: { cableId: req.params.cableId }
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
const cable = await Cable.findByPk(req.params.cableId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'sourceDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'targetDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
res.json(cable);
|
||||
} else {
|
||||
res.status(404).json({ error: '接线不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:cableId', async (req, res) => {
|
||||
try {
|
||||
const deleted = await Cable.destroy({
|
||||
where: { cableId: req.params.cableId }
|
||||
});
|
||||
|
||||
if (deleted) {
|
||||
res.status(204).json();
|
||||
} else {
|
||||
res.status(404).json({ error: '接线不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/batch', async (req, res) => {
|
||||
try {
|
||||
const { cableIds } = req.body;
|
||||
|
||||
if (!cableIds || !Array.isArray(cableIds) || cableIds.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的接线ID列表' });
|
||||
}
|
||||
|
||||
const deletedCount = await Cable.destroy({
|
||||
where: { cableId: { [Op.in]: cableIds } }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `批量删除成功,已删除 ${deletedCount} 条接线`,
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量删除接线失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:cableId', async (req, res) => {
|
||||
try {
|
||||
const cable = await Cable.findByPk(req.params.cableId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'sourceDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'targetDevice',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!cable) {
|
||||
return res.status(404).json({ error: '接线不存在' });
|
||||
}
|
||||
|
||||
res.json(cable);
|
||||
} catch (error) {
|
||||
console.error('获取接线详情失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,273 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Op } = require('sequelize');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
const Device = require('../models/Device');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { deviceId, status, portType, portSpeed, page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = {};
|
||||
|
||||
if (deviceId) {
|
||||
where.deviceId = deviceId;
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (portType && portType !== 'all') {
|
||||
where.portType = portType;
|
||||
}
|
||||
|
||||
if (portSpeed && portSpeed !== 'all') {
|
||||
where.portSpeed = portSpeed;
|
||||
}
|
||||
|
||||
const { count, rows } = await DevicePort.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
],
|
||||
offset,
|
||||
limit: parseInt(pageSize),
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
ports: rows,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取端口列表失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { deviceId },
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
],
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
res.json(ports);
|
||||
} catch (error) {
|
||||
console.error('获取设备端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { portId, deviceId, portName, portType, portSpeed, status, vlanId, description } = req.body;
|
||||
|
||||
if (!deviceId || !portName) {
|
||||
return res.status(400).json({ error: '缺少必填字段' });
|
||||
}
|
||||
|
||||
const existingPort = await DevicePort.findOne({
|
||||
where: { deviceId, portName }
|
||||
});
|
||||
|
||||
if (existingPort) {
|
||||
return res.status(400).json({ error: '该设备的端口名称已存在' });
|
||||
}
|
||||
|
||||
const autoPortId = portId || `PORT-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const port = await DevicePort.create({
|
||||
portId: autoPortId,
|
||||
deviceId,
|
||||
portName,
|
||||
portType: portType || 'RJ45',
|
||||
portSpeed: portSpeed || '1G',
|
||||
status: status || 'free',
|
||||
vlanId,
|
||||
description
|
||||
});
|
||||
|
||||
const createdPort = await DevicePort.findByPk(port.portId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json(createdPort);
|
||||
} catch (error) {
|
||||
console.error('创建端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/batch', async (req, res) => {
|
||||
try {
|
||||
const { ports } = req.body;
|
||||
|
||||
if (!ports || !Array.isArray(ports) || ports.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的端口数据' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: ports.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < ports.length; i++) {
|
||||
const portData = ports[i];
|
||||
|
||||
try {
|
||||
if (!portData.portId || !portData.deviceId || !portData.portName) {
|
||||
throw new Error('缺少必填字段');
|
||||
}
|
||||
|
||||
const existingPort = await DevicePort.findOne({
|
||||
where: { deviceId: portData.deviceId, portName: portData.portName }
|
||||
});
|
||||
|
||||
if (existingPort) {
|
||||
throw new Error('该设备的端口名称已存在');
|
||||
}
|
||||
|
||||
await DevicePort.create({
|
||||
portId: portData.portId,
|
||||
deviceId: portData.deviceId,
|
||||
portName: portData.portName,
|
||||
portType: portData.portType || 'RJ45',
|
||||
portSpeed: portData.portSpeed || '1G',
|
||||
status: portData.status || 'free',
|
||||
vlanId: portData.vlanId,
|
||||
description: portData.description
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
index: i + 1,
|
||||
portId: portData.portId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('批量创建端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:portId', async (req, res) => {
|
||||
try {
|
||||
const [updated] = await DevicePort.update(req.body, {
|
||||
where: { portId: req.params.portId }
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
const port = await DevicePort.findByPk(req.params.portId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
res.json(port);
|
||||
} else {
|
||||
res.status(404).json({ error: '端口不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:portId', async (req, res) => {
|
||||
try {
|
||||
const deleted = await DevicePort.destroy({
|
||||
where: { portId: req.params.portId }
|
||||
});
|
||||
|
||||
if (deleted) {
|
||||
res.status(204).json();
|
||||
} else {
|
||||
res.status(404).json({ error: '端口不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/batch', async (req, res) => {
|
||||
try {
|
||||
const { portIds } = req.body;
|
||||
|
||||
if (!portIds || !Array.isArray(portIds) || portIds.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的端口ID列表' });
|
||||
}
|
||||
|
||||
const deletedCount = await DevicePort.destroy({
|
||||
where: { portId: { [Op.in]: portIds } }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `批量删除成功,已删除 ${deletedCount} 个端口`,
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量删除端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:portId', async (req, res) => {
|
||||
try {
|
||||
const port = await DevicePort.findByPk(req.params.portId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type', 'rackId']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!port) {
|
||||
return res.status(404).json({ error: '端口不存在' });
|
||||
}
|
||||
|
||||
res.json(port);
|
||||
} catch (error) {
|
||||
console.error('获取端口详情失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -893,15 +893,35 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
});
|
||||
|
||||
if (deleted) {
|
||||
// 更新机柜当前功率
|
||||
const rack = await Rack.findByPk(device.rackId);
|
||||
if (rack) {
|
||||
await rack.update({
|
||||
currentPower: Math.max(0, rack.currentPower - device.powerConsumption)
|
||||
});
|
||||
const Cable = require('../models/Cable');
|
||||
|
||||
const deletedCables = await Cable.destroy({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ sourceDeviceId: req.params.deviceId },
|
||||
{ targetDeviceId: req.params.deviceId }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (deletedCables > 0) {
|
||||
console.log(`已删除 ${deletedCables} 条相关接线`);
|
||||
}
|
||||
|
||||
res.status(204).json();
|
||||
if (device.rackId) {
|
||||
const rack = await Rack.findByPk(device.rackId);
|
||||
if (rack) {
|
||||
await rack.update({
|
||||
currentPower: Math.max(0, rack.currentPower - device.powerConsumption)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
message: '删除成功',
|
||||
deviceId: req.params.deviceId,
|
||||
deletedCablesCount: deletedCables
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Op } = require('sequelize');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
const Device = require('../models/Device');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.query;
|
||||
const where = {};
|
||||
|
||||
if (deviceId) {
|
||||
where.deviceId = deviceId;
|
||||
}
|
||||
|
||||
const networkCards = await NetworkCard.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type']
|
||||
}
|
||||
],
|
||||
order: [['slotNumber', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
res.json(networkCards);
|
||||
} catch (error) {
|
||||
console.error('获取网卡列表失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const networkCards = await NetworkCard.findAll({
|
||||
where: { deviceId },
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type']
|
||||
}
|
||||
],
|
||||
order: [['slotNumber', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
res.json(networkCards);
|
||||
} catch (error) {
|
||||
console.error('获取设备网卡失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:nicId', async (req, res) => {
|
||||
try {
|
||||
const networkCard = await NetworkCard.findByPk(req.params.nicId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!networkCard) {
|
||||
return res.status(404).json({ error: '网卡不存在' });
|
||||
}
|
||||
|
||||
res.json(networkCard);
|
||||
} catch (error) {
|
||||
console.error('获取网卡详情失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:nicId/ports', async (req, res) => {
|
||||
try {
|
||||
const { nicId } = req.params;
|
||||
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { nicId },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
res.json(ports);
|
||||
} catch (error) {
|
||||
console.error('获取网卡端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { nicId, deviceId, name, description, slotNumber, model, manufacturer, status } = req.body;
|
||||
|
||||
if (!deviceId || !name) {
|
||||
return res.status(400).json({ error: '缺少必填字段' });
|
||||
}
|
||||
|
||||
const existingCard = await NetworkCard.findOne({
|
||||
where: { deviceId, name }
|
||||
});
|
||||
|
||||
if (existingCard) {
|
||||
return res.status(400).json({ error: '该设备已存在同名网卡' });
|
||||
}
|
||||
|
||||
const autoNicId = nicId || `NIC-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const networkCard = await NetworkCard.create({
|
||||
nicId: autoNicId,
|
||||
deviceId,
|
||||
name,
|
||||
description,
|
||||
slotNumber,
|
||||
model,
|
||||
manufacturer,
|
||||
status: status || 'normal',
|
||||
portCount: 0
|
||||
});
|
||||
|
||||
const createdCard = await NetworkCard.findByPk(networkCard.nicId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json(createdCard);
|
||||
} catch (error) {
|
||||
console.error('创建网卡失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:nicId', async (req, res) => {
|
||||
try {
|
||||
const [updated] = await NetworkCard.update(req.body, {
|
||||
where: { nicId: req.params.nicId }
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
const networkCard = await NetworkCard.findByPk(req.params.nicId, {
|
||||
include: [
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['deviceId', 'name', 'type']
|
||||
}
|
||||
]
|
||||
});
|
||||
res.json(networkCard);
|
||||
} else {
|
||||
res.status(404).json({ error: '网卡不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新网卡失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:nicId', async (req, res) => {
|
||||
try {
|
||||
const { nicId } = req.params;
|
||||
|
||||
const portCount = await DevicePort.count({ where: { nicId } });
|
||||
if (portCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: `该网卡下还有 ${portCount} 个端口,请先删除或转移端口后再删除网卡`
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await NetworkCard.destroy({
|
||||
where: { nicId }
|
||||
});
|
||||
|
||||
if (deleted) {
|
||||
res.status(204).json();
|
||||
} else {
|
||||
res.status(404).json({ error: '网卡不存在' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除网卡失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId/with-ports', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const networkCards = await NetworkCard.findAll({
|
||||
where: { deviceId },
|
||||
order: [['slotNumber', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
const cardsWithPorts = await Promise.all(
|
||||
networkCards.map(async (card) => {
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { nicId: card.nicId },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
const freeCount = ports.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = ports.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = ports.filter(p => p.status === 'fault').length;
|
||||
|
||||
return {
|
||||
...card.toJSON(),
|
||||
ports,
|
||||
stats: {
|
||||
total: ports.length,
|
||||
free: freeCount,
|
||||
occupied: occupiedCount,
|
||||
fault: faultCount
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const ungroupedPorts = await DevicePort.findAll({
|
||||
where: { deviceId, nicId: null },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
if (ungroupedPorts.length > 0) {
|
||||
cardsWithPorts.push({
|
||||
nicId: '_ungrouped',
|
||||
name: '未分组端口',
|
||||
description: '未分配到网卡的端口',
|
||||
portCount: ungroupedPorts.length,
|
||||
isUngrouped: true,
|
||||
ports: ungroupedPorts,
|
||||
stats: {
|
||||
total: ungroupedPorts.length,
|
||||
free: ungroupedPorts.filter(p => p.status === 'free').length,
|
||||
occupied: ungroupedPorts.filter(p => p.status === 'occupied').length,
|
||||
fault: ungroupedPorts.filter(p => p.status === 'fault').length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json(cardsWithPorts);
|
||||
} catch (error) {
|
||||
console.error('获取网卡及端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,170 @@
|
||||
const { sequelize } = require('../db');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
const Device = require('../models/Device');
|
||||
|
||||
async function migrate() {
|
||||
console.log('========================================');
|
||||
console.log(' IDC管理系统 - 数据库迁移脚本 v2.0 ');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
console.log('🔍 检测数据库类型...');
|
||||
const dbType = sequelize.getDialect();
|
||||
console.log(` 数据库类型: ${dbType}`);
|
||||
console.log('');
|
||||
|
||||
console.log('📋 开始迁移...');
|
||||
console.log(' 1. 创建 network_cards 表');
|
||||
console.log(' 2. 为 device_ports 添加 nic_id 字段');
|
||||
console.log(' 3. 创建相关索引');
|
||||
console.log('');
|
||||
|
||||
if (dbType === 'sqlite') {
|
||||
await migrateSQLite();
|
||||
} else if (dbType === 'mysql') {
|
||||
await migrateMySQL();
|
||||
} else {
|
||||
console.log(`⚠️ 不支持的数据库类型: ${dbType}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('✅ 迁移完成!');
|
||||
console.log('');
|
||||
console.log('📊 验证迁移结果...');
|
||||
|
||||
const [tables] = await sequelize.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||
console.log(` 数据库表: ${tables.map(t => t.name).join(', ')}`);
|
||||
|
||||
const portCount = await DevicePort.count();
|
||||
const cardCount = await NetworkCard.count();
|
||||
console.log(` device_ports: ${portCount} 条记录`);
|
||||
console.log(` network_cards: ${cardCount} 条记录`);
|
||||
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log(' 迁移成功完成!🎉');
|
||||
console.log('========================================');
|
||||
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ 迁移失败:', error.message);
|
||||
console.error('');
|
||||
console.error('错误详情:', error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateSQLite() {
|
||||
console.log('');
|
||||
console.log('🔄 执行 SQLite 迁移...');
|
||||
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
console.log(' → 删除旧的 device_ports 表...');
|
||||
await sequelize.query('DROP TABLE IF EXISTS `device_ports`');
|
||||
|
||||
console.log(' → 删除旧的 network_cards 表...');
|
||||
await sequelize.query('DROP TABLE IF EXISTS `network_cards`');
|
||||
|
||||
console.log(' → 同步 DevicePort 模型...');
|
||||
await DevicePort.sync({ force: false });
|
||||
|
||||
console.log(' → 同步 NetworkCard 模型...');
|
||||
await NetworkCard.sync({ force: false });
|
||||
|
||||
console.log(' → 同步 Device 模型(确保外键关系)...');
|
||||
await Device.sync({ force: false });
|
||||
|
||||
console.log(' → 重新同步 DevicePort 模型(含外键)...');
|
||||
await DevicePort.sync({ force: true });
|
||||
|
||||
console.log(' → 重新同步 NetworkCard 模型...');
|
||||
await NetworkCard.sync({ force: false });
|
||||
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
|
||||
} catch (error) {
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateMySQL() {
|
||||
console.log('');
|
||||
console.log('🔄 执行 MySQL 迁移...');
|
||||
|
||||
const tableName = 'network_cards';
|
||||
console.log(` → 创建表: ${tableName}`);
|
||||
|
||||
const createTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
||||
\`nic_id\` VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
\`device_id\` VARCHAR(255) NOT NULL,
|
||||
\`name\` VARCHAR(255) NOT NULL,
|
||||
\`description\` TEXT,
|
||||
\`slot_number\` INT,
|
||||
\`port_count\` INT DEFAULT 0,
|
||||
\`model\` VARCHAR(255),
|
||||
\`manufacturer\` VARCHAR(255),
|
||||
\`status\` ENUM('normal', 'warning', 'fault', 'offline') DEFAULT 'normal',
|
||||
\`created_at\` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX \`idx_device_id\` (\`device_id\`),
|
||||
INDEX \`idx_slot_number\` (\`slot_number\`),
|
||||
UNIQUE INDEX \`idx_device_name\` (\`device_id\`, \`name\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
|
||||
await sequelize.query(createTableSQL);
|
||||
|
||||
console.log(' → 检查 nic_id 字段是否存在...');
|
||||
const [columns] = await sequelize.query(
|
||||
"SHOW COLUMNS FROM `device_ports` LIKE 'nic_id'",
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (columns.length === 0) {
|
||||
console.log(' → 添加 nic_id 字段...');
|
||||
await sequelize.query(
|
||||
'ALTER TABLE `device_ports` ADD COLUMN `nic_id` VARCHAR(255) NULL AFTER `device_id`'
|
||||
);
|
||||
} else {
|
||||
console.log(' → nic_id 字段已存在,跳过');
|
||||
}
|
||||
|
||||
console.log(' → 创建 nic_id 索引...');
|
||||
try {
|
||||
await sequelize.query('CREATE INDEX `idx_port_nic_id` ON `device_ports`(`nic_id`)');
|
||||
} catch (error) {
|
||||
if (error.message.includes('Duplicate key name')) {
|
||||
console.log(' → 索引已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' → 同步模型以确保关系正确...');
|
||||
await NetworkCard.sync({ force: false });
|
||||
await DevicePort.sync({ force: false });
|
||||
|
||||
console.log(' → 添加外键约束...');
|
||||
try {
|
||||
await sequelize.query(
|
||||
'ALTER TABLE `device_ports` ADD CONSTRAINT `fk_port_nic` FOREIGN KEY (`nic_id`) REFERENCES `network_cards`(`nic_id`)'
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.message.includes('Duplicate key name') || error.message.includes('already exists')) {
|
||||
console.log(' → 外键约束已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -62,6 +62,8 @@ const ticketRoutes = require('./routes/tickets');
|
||||
const ticketCategoryRoutes = require('./routes/ticketCategories');
|
||||
const ticketFieldRoutes = require('./routes/ticketFields');
|
||||
const systemSettingsRoutes = require('./routes/systemSettings');
|
||||
const cableRoutes = require('./routes/cables');
|
||||
const devicePortRoutes = require('./routes/devicePorts');
|
||||
|
||||
// 使用路由
|
||||
app.use('/api/devices', deviceRoutes);
|
||||
@@ -79,6 +81,8 @@ app.use('/api/tickets', ticketRoutes);
|
||||
app.use('/api/ticket-categories', ticketCategoryRoutes);
|
||||
app.use('/api/ticket-fields', ticketFieldRoutes);
|
||||
app.use('/api/system-settings', systemSettingsRoutes);
|
||||
app.use('/api/cables', cableRoutes);
|
||||
app.use('/api/device-ports', devicePortRoutes);
|
||||
|
||||
// 静态文件服务
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
+16
-2
@@ -1,6 +1,6 @@
|
||||
import React, { useState, Suspense, lazy } from 'react';
|
||||
import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider, ConfigProvider as AntdConfigProvider } from 'antd';
|
||||
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined, ApiOutlined, PartitionOutlined } from '@ant-design/icons';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { ConfigProvider, useConfig } from './context/ConfigContext';
|
||||
@@ -23,6 +23,8 @@ const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManage
|
||||
const TicketStatistics = lazy(() => import('./pages/TicketStatistics'));
|
||||
const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement'));
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
||||
const CableManagement = lazy(() => import('./pages/CableManagement'));
|
||||
const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -144,7 +146,7 @@ const AppLayout = ({ children }) => {
|
||||
const path = location.pathname;
|
||||
if (path === '/') return 'dashboard';
|
||||
if (path.startsWith('/rooms') || path.startsWith('/racks') || path.startsWith('/visualization')) return 'room-management';
|
||||
if (path.startsWith('/devices') || path.startsWith('/fields')) return 'asset-management';
|
||||
if (path.startsWith('/devices') || path.startsWith('/fields') || path.startsWith('/cables') || path.startsWith('/ports')) return 'asset-management';
|
||||
if (path.startsWith('/consumables')) return 'consumables-management';
|
||||
if (path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || path.startsWith('/settings')) return 'system-management';
|
||||
if (path.startsWith('/tickets')) return 'ticket-management';
|
||||
@@ -244,6 +246,16 @@ const AppLayout = ({ children }) => {
|
||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/fields">字段管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'cables',
|
||||
icon: <ApiOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/cables">接线管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'ports',
|
||||
icon: <PartitionOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/ports">端口管理</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -578,6 +590,8 @@ const ThemeConfig = () => {
|
||||
<Route path="/ticket-statistics" element={<PrivateRoute><TicketStatistics /></PrivateRoute>} />
|
||||
<Route path="/ticket-fields" element={<PrivateRoute><TicketFieldManagement /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute><SystemSettings /></PrivateRoute>} />
|
||||
<Route path="/cables" element={<PrivateRoute><CableManagement /></PrivateRoute>} />
|
||||
<Route path="/ports" element={<PrivateRoute><PortManagement /></PrivateRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Drawer, Tabs, Tag, Space, Typography, Empty, Card, Tooltip } from 'antd';
|
||||
import { ApiOutlined, CloudServerOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import NetworkCardPanel from './NetworkCardPanel';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: '#667eea',
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
},
|
||||
spacing: {
|
||||
sm: 8,
|
||||
md: 16
|
||||
}
|
||||
};
|
||||
|
||||
function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables }) {
|
||||
const [activeTab, setActiveTab] = useState('ports');
|
||||
|
||||
const deviceCables = useMemo(() => {
|
||||
if (!device || !cables) return [];
|
||||
return cables.filter(c =>
|
||||
c.sourceDeviceId === device.deviceId || c.targetDeviceId === device.deviceId
|
||||
);
|
||||
}, [device, cables]);
|
||||
|
||||
const getStatusTag = useCallback((status) => {
|
||||
const config = {
|
||||
running: { color: 'success', text: '运行中' },
|
||||
normal: { color: 'success', text: '正常' },
|
||||
warning: { color: 'warning', text: '警告' },
|
||||
error: { color: 'error', text: '故障' },
|
||||
fault: { color: 'error', text: '故障' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
maintenance: { color: 'processing', text: '维护中' }
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}, []);
|
||||
|
||||
const getDeviceTypeName = useCallback((type) => {
|
||||
const typeMap = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
router: '路由器',
|
||||
storage: '存储设备',
|
||||
firewall: '防火墙',
|
||||
ups: 'UPS',
|
||||
pdu: 'PDU'
|
||||
};
|
||||
return typeMap[type?.toLowerCase()] || type || '未知设备';
|
||||
}, []);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'ports',
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined />
|
||||
端口与网卡
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<NetworkCardPanel
|
||||
deviceId={device?.deviceId}
|
||||
deviceName={device?.name}
|
||||
onRefresh={onRefreshCables}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'cables',
|
||||
label: (
|
||||
<span>
|
||||
<EnvironmentOutlined />
|
||||
接线 ({deviceCables.length})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="cable-panel">
|
||||
{deviceCables.length === 0 ? (
|
||||
<Empty description="该设备暂无接线" />
|
||||
) : (
|
||||
<Space direction="vertical" size={designTokens.spacing.md} style={{ width: '100%' }}>
|
||||
{deviceCables.map(cable => (
|
||||
<Card
|
||||
key={cable.cableId}
|
||||
size="small"
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
<div style={{ marginBottom: designTokens.spacing.sm }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>源设备:</Text>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{cable.sourceDevice?.name || '-'}
|
||||
<Tag color="blue" style={{ marginLeft: '8px' }}>{cable.sourcePort}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>目标设备:</Text>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{cable.targetDevice?.name || '-'}
|
||||
<Tag color="green" style={{ marginLeft: '8px' }}>{cable.targetPort}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Tag color={cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default'}>
|
||||
{cable.status === 'normal' ? '正常' : cable.status === 'fault' ? '故障' : '未连接'}
|
||||
</Tag>
|
||||
<Tag color="purple">
|
||||
{cable.cableType === 'ethernet' ? '网线' : cable.cableType === 'fiber' ? '光纤' : '铜缆'}
|
||||
</Tag>
|
||||
{cable.cableLength && (
|
||||
<Tag color="orange">
|
||||
{cable.cableLength}m
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{cable.description && (
|
||||
<div style={{ marginTop: designTokens.spacing.sm, fontSize: '12px', color: '#666' }}>
|
||||
{cable.description}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<CloudServerOutlined style={{ color: designTokens.colors.primary }} />
|
||||
<span>设备详情 - {device.name}</span>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
width={520}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
styles={{ body: { padding: '16px 20px', overflow: 'auto' } }}
|
||||
>
|
||||
<div className="device-info-section" style={{ marginBottom: '20px' }}>
|
||||
<Title level={5} style={{ margin: '0 0 12px 0', color: '#1e293b' }}>基本信息</Title>
|
||||
<div className="info-grid" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
background: '#f8fafc',
|
||||
padding: '16px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备ID</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.deviceId}</Text>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备类型</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{getDeviceTypeName(device.type)}</Text>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>设备状态</Text>
|
||||
{getStatusTag(device.status)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>位置</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
U{device.position} {device.height && `(${device.height}U)`}
|
||||
</Text>
|
||||
</div>
|
||||
{device.ipAddress && (
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>IP地址</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.ipAddress}</Text>
|
||||
</div>
|
||||
)}
|
||||
{device.brand && (
|
||||
<div className="info-item">
|
||||
<Text type="secondary" style={{ fontSize: '12px', display: 'block' }}>品牌</Text>
|
||||
<Text strong style={{ fontSize: '14px' }}>{device.brand}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(DeviceDetailDrawer);
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Select, message, Space, Tooltip } from 'antd';
|
||||
import { PlusOutlined, InfoCircleOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
medium: '10px'
|
||||
}
|
||||
};
|
||||
|
||||
function NetworkCardCreateModal({ device, visible, onClose, onSuccess }) {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
await axios.post('/api/network-cards', {
|
||||
deviceId: device.deviceId,
|
||||
name: values.name,
|
||||
slotNumber: values.slotNumber,
|
||||
description: values.description,
|
||||
model: values.model,
|
||||
manufacturer: values.manufacturer,
|
||||
status: values.status
|
||||
});
|
||||
|
||||
message.success('网卡创建成功');
|
||||
form.resetFields();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(error.response?.data?.error || '网卡创建失败');
|
||||
console.error('创建网卡失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [device, form, onClose, onSuccess]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CloudServerOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||
<span>新增网卡 - {device?.name || '设备'}</span>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={480}
|
||||
styles={{ body: { padding: '20px 24px' } }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
status: 'normal'
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={
|
||||
<Space>
|
||||
网卡名称
|
||||
<Tooltip title="如: 网卡1、eth0、Primary NIC、LAN1">
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入网卡名称' },
|
||||
{ max: 50, message: '名称不能超过50个字符' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如: 网卡1、eth0、LAN1" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="slotNumber"
|
||||
label="插槽编号"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="可选"
|
||||
min={1}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="请选择">
|
||||
<Option value="normal">正常</Option>
|
||||
<Option value="warning">警告</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="manufacturer"
|
||||
label="制造商"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="如: Intel、Realtek、Broadcom" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="model"
|
||||
label="型号"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="如: X520-DA2" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<TextArea rows={2} placeholder="请输入描述信息(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(NetworkCardCreateModal);
|
||||
@@ -0,0 +1,397 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge, Collapse, Card } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, ReloadOutlined, ApiOutlined, CloudServerOutlined, FolderOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import PortCreateModal from './PortCreateModal';
|
||||
import NetworkCardCreateModal from './NetworkCardCreateModal';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
},
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
}
|
||||
};
|
||||
|
||||
function NetworkCardPanel({ deviceId, deviceName, onRefresh }) {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [networkCards, setNetworkCards] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createPortModalVisible, setCreatePortModalVisible] = useState(false);
|
||||
const [createCardModalVisible, setCreateCardModalVisible] = useState(false);
|
||||
const [selectedCard, setSelectedCard] = useState(null);
|
||||
const [expandedCards, setExpandedCards] = useState([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [cardsResponse, networkCardsResponse] = await Promise.all([
|
||||
axios.get(`/api/network-cards/device/${deviceId}/with-ports`),
|
||||
axios.get(`/api/network-cards/device/${deviceId}`)
|
||||
]);
|
||||
|
||||
const cardsData = cardsResponse.data || [];
|
||||
setCards(cardsData);
|
||||
setNetworkCards(networkCardsResponse.data || []);
|
||||
|
||||
const initialExpanded = cardsData
|
||||
.filter(card => card.ports && card.ports.length > 0)
|
||||
.map(card => card.nicId);
|
||||
setExpandedCards(initialExpanded);
|
||||
} catch (error) {
|
||||
console.error('获取网卡数据失败:', error);
|
||||
setCards([]);
|
||||
setNetworkCards([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (card) => {
|
||||
try {
|
||||
await axios.delete(`/api/network-cards/${card.nicId}`);
|
||||
import('antd').then(({ message }) => message.success('网卡删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error(error.response?.data?.error || '网卡删除失败'));
|
||||
}
|
||||
}, [fetchData, onRefresh]);
|
||||
|
||||
const handleDeletePort = useCallback(async (port) => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
}, [fetchData, onRefresh]);
|
||||
|
||||
const handleCreateCardSuccess = useCallback(() => {
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
}, [fetchData, onRefresh]);
|
||||
|
||||
const handleCreatePortSuccess = useCallback(() => {
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
}, [fetchData, onRefresh]);
|
||||
|
||||
const handleExpand = (nicId) => {
|
||||
setExpandedCards(prev => {
|
||||
if (prev.includes(nicId)) {
|
||||
return prev.filter(id => id !== nicId);
|
||||
}
|
||||
return [...prev, nicId];
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const config = {
|
||||
free: { color: 'success', text: '空闲' },
|
||||
occupied: { color: 'processing', text: '占用' },
|
||||
fault: { color: 'error', text: '故障' },
|
||||
normal: { color: 'success', text: '正常' },
|
||||
warning: { color: 'warning', text: '警告' },
|
||||
offline: { color: 'default', text: '离线' }
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const getTypeTag = (type) => {
|
||||
const config = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
};
|
||||
const { color, text } = config[type] || { color: 'default', text: type };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const renderPortTable = (ports, nicId) => {
|
||||
const columns = [
|
||||
{
|
||||
title: '端口名称',
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120,
|
||||
render: (text) => <span style={{ fontWeight: 500 }}>{text}</span>
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 80,
|
||||
render: (type) => getTypeTag(type)
|
||||
},
|
||||
{
|
||||
title: '速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 70
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 70,
|
||||
render: (status) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: 'VLAN',
|
||||
dataIndex: 'vlanId',
|
||||
key: 'vlanId',
|
||||
width: 60,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Popconfirm
|
||||
title="确定要删除此端口吗?"
|
||||
onConfirm={() => handleDeletePort(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={ports}
|
||||
rowKey="portId"
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 500 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardHeader = (card) => {
|
||||
const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
background: card.isUngrouped
|
||||
? 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{card.isUngrouped ? <FolderOutlined /> : <CloudServerOutlined />}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
|
||||
{card.name}
|
||||
{card.slotNumber && <span style={{ color: '#94a3b8', marginLeft: 8 }}>插槽 {card.slotNumber}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>
|
||||
{card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={12}>
|
||||
<Badge count={stats.free} style={{ backgroundColor: designTokens.colors.success }} />
|
||||
<span style={{ fontSize: '12px', color: '#64748b' }}>空闲</span>
|
||||
<Badge count={stats.occupied} style={{ backgroundColor: '#1677ff' }} />
|
||||
<span style={{ fontSize: '12px', color: '#64748b' }}>占用</span>
|
||||
<Badge count={stats.fault} style={{ backgroundColor: designTokens.colors.error }} />
|
||||
<span style={{ fontSize: '12px', color: '#64748b' }}>故障</span>
|
||||
{!card.isUngrouped && (
|
||||
<Popconfirm
|
||||
title="确定要删除此网卡吗?"
|
||||
description="删除网卡前需确保其下无端口"
|
||||
onConfirm={() => handleDeleteCard(card)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除网卡
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedCard(card);
|
||||
setCreatePortModalVisible(true);
|
||||
}}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
添加端口
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" tip="加载网卡数据中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalStats = cards.reduce((acc, card) => {
|
||||
const stats = card.stats || {};
|
||||
acc.total += stats.total || 0;
|
||||
acc.free += stats.free || 0;
|
||||
acc.occupied += stats.occupied || 0;
|
||||
acc.fault += stats.fault || 0;
|
||||
return acc;
|
||||
}, { total: 0, free: 0, occupied: 0, fault: 0 });
|
||||
|
||||
return (
|
||||
<div className="network-card-panel">
|
||||
<div className="panel-header" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div className="stats" style={{ display: 'flex', gap: '24px' }}>
|
||||
<Space size={16}>
|
||||
<Badge count={networkCards.length} style={{ backgroundColor: designTokens.colors.primary.main }} />
|
||||
<span style={{ color: '#64748b', fontSize: '13px' }}>个网卡</span>
|
||||
<Badge count={totalStats.total} style={{ backgroundColor: '#667eea' }} />
|
||||
<span style={{ color: '#64748b', fontSize: '13px' }}>个端口</span>
|
||||
</Space>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData} size="small">
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateCardModalVisible(true)}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
新增网卡
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Empty
|
||||
description={
|
||||
<span>
|
||||
该设备暂无网卡和端口
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateCardModalVisible(true)}
|
||||
style={{ padding: 0, marginTop: 8 }}
|
||||
>
|
||||
立即添加网卡
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CloudServerOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={expandedCards}
|
||||
onChange={(keys) => setExpandedCards(keys)}
|
||||
expandIconPosition="end"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<Panel
|
||||
key={card.nicId}
|
||||
header={renderCardHeader(card)}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}
|
||||
>
|
||||
{card.ports && card.ports.length > 0 ? (
|
||||
renderPortTable(card.ports, card.nicId)
|
||||
) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#94a3b8' }}>
|
||||
该{card.isUngrouped ? '分组' : '网卡'}暂无端口
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedCard(card);
|
||||
setCreatePortModalVisible(true);
|
||||
}}
|
||||
style={{ padding: 0, marginTop: 8 }}
|
||||
>
|
||||
添加端口
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<NetworkCardCreateModal
|
||||
device={{ deviceId, name: deviceName }}
|
||||
visible={createCardModalVisible}
|
||||
onClose={() => setCreateCardModalVisible(false)}
|
||||
onSuccess={handleCreateCardSuccess}
|
||||
/>
|
||||
|
||||
<PortCreateModal
|
||||
device={{ deviceId, name: deviceName }}
|
||||
visible={createPortModalVisible}
|
||||
onClose={() => {
|
||||
setCreatePortModalVisible(false);
|
||||
setSelectedCard(null);
|
||||
}}
|
||||
onSuccess={handleCreatePortSuccess}
|
||||
defaultNicId={selectedCard?.nicId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(NetworkCardPanel);
|
||||
@@ -0,0 +1,400 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Modal, Form, Input, Select, InputNumber, message, Space, Button, Tooltip, Alert, Tag } from 'antd';
|
||||
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
medium: '10px'
|
||||
}
|
||||
};
|
||||
|
||||
function parsePortRange(portName) {
|
||||
if (!portName || typeof portName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = portName.trim();
|
||||
|
||||
if (!trimmed.includes('-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [startPart, endPart] = trimmed.split('-').map(s => s.trim());
|
||||
|
||||
if (!startPart || !endPart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNumMatch = startPart.match(/(\d+)$/);
|
||||
const endNumMatch = endPart.match(/(\d+)$/);
|
||||
|
||||
if (!startNumMatch || !endNumMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNum = parseInt(startNumMatch[1], 10);
|
||||
const endNum = parseInt(endNumMatch[1], 10);
|
||||
|
||||
if (startNum >= endNum || endNum - startNum > 1000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = startPart.replace(startNumMatch[0], '');
|
||||
const portCount = endNum - startNum + 1;
|
||||
|
||||
const ports = [];
|
||||
for (let i = 0; i < portCount; i++) {
|
||||
const num = startNum + i;
|
||||
ports.push(`${prefix}${num}`);
|
||||
}
|
||||
|
||||
return {
|
||||
isRange: true,
|
||||
prefix,
|
||||
startNum,
|
||||
endNum,
|
||||
portCount,
|
||||
ports
|
||||
};
|
||||
}
|
||||
|
||||
function generatePortNames(portName) {
|
||||
const result = parsePortRange(portName);
|
||||
|
||||
if (result && result.isRange) {
|
||||
return result.ports;
|
||||
}
|
||||
|
||||
return [portName];
|
||||
}
|
||||
|
||||
function PortCreateModal({ device, visible, onClose, onSuccess, defaultNicId, networkCards = [] }) {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewPorts, setPreviewPorts] = useState([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [nicList, setNicList] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
form.resetFields();
|
||||
|
||||
if (defaultNicId) {
|
||||
form.setFieldsValue({ nicId: defaultNicId });
|
||||
}
|
||||
|
||||
if (device?.deviceId && networkCards.length === 0) {
|
||||
fetchNetworkCards();
|
||||
} else if (networkCards.length > 0) {
|
||||
setNicList(networkCards);
|
||||
}
|
||||
}
|
||||
}, [visible, device, defaultNicId, networkCards, form]);
|
||||
|
||||
const fetchNetworkCards = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/network-cards/device/${device.deviceId}`);
|
||||
setNicList(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取网卡列表失败:', error);
|
||||
setNicList([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortNameChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
const ports = generatePortNames(value);
|
||||
|
||||
if (ports.length > 1) {
|
||||
setPreviewPorts(ports.slice(0, 20));
|
||||
setShowPreview(true);
|
||||
} else {
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const portNames = generatePortNames(values.portName);
|
||||
|
||||
if (portNames.length === 1) {
|
||||
await axios.post('/api/device-ports', {
|
||||
deviceId: device.deviceId,
|
||||
nicId: values.nicId || null,
|
||||
portName: portNames[0],
|
||||
portType: values.portType,
|
||||
portSpeed: values.portSpeed,
|
||||
vlanId: values.vlanId,
|
||||
status: values.status,
|
||||
description: values.description
|
||||
});
|
||||
message.success('端口创建成功');
|
||||
} else {
|
||||
const portsData = portNames.map((portName, index) => ({
|
||||
portId: `PORT-${Date.now()}-${index}`,
|
||||
deviceId: device.deviceId,
|
||||
nicId: values.nicId || null,
|
||||
portName,
|
||||
portType: values.portType,
|
||||
portSpeed: values.portSpeed,
|
||||
vlanId: values.vlanId,
|
||||
status: values.status,
|
||||
description: values.description
|
||||
}));
|
||||
|
||||
await axios.post('/api/device-ports/batch', { ports: portsData });
|
||||
message.success(`成功创建 ${portNames.length} 个端口`);
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(error.response?.data?.error || '端口创建失败');
|
||||
console.error('创建端口失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [device, form, onClose, onSuccess]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields();
|
||||
setPreviewPorts([]);
|
||||
setShowPreview(false);
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const portCount = previewPorts.length || (form.getFieldValue('portName') && !showPreview ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<PlusOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||
<span>新增端口 - {device?.name || '设备'}</span>
|
||||
{portCount > 1 && (
|
||||
<Tag color="blue">{portCount} 个端口</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
okText={portCount > 1 ? `创建 ${portCount} 个端口` : '创建'}
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
styles={{ body: { padding: '20px 24px' } }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
portType: 'RJ45',
|
||||
portSpeed: '1G',
|
||||
status: 'free'
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="deviceId"
|
||||
label="设备"
|
||||
>
|
||||
<Input
|
||||
value={device?.name}
|
||||
disabled
|
||||
placeholder={device?.deviceId}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nicId"
|
||||
label={
|
||||
<Space>
|
||||
所属网卡
|
||||
<Tooltip title="可选,不选择则端口不归属于任何网卡">
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择网卡(可选)"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{nicList.map(nic => (
|
||||
<Option key={nic.nicId} value={nic.nicId}>
|
||||
{nic.name}
|
||||
{nic.slotNumber && ` (插槽${nic.slotNumber})`}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="portName"
|
||||
label={
|
||||
<Space>
|
||||
端口名称
|
||||
<Tooltip title="支持单个端口(如 eth0/1)或端口范围(如 1/0/1-1/0/48)">
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入端口名称' },
|
||||
{
|
||||
pattern: /^[\w\/:\-]+$/,
|
||||
message: '端口名称格式不正确'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const ports = generatePortNames(value);
|
||||
if (ports.length > 1000) {
|
||||
return Promise.reject(new Error('单次最多创建1000个端口'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如: eth0/1 或 1/0/1-1/0/48"
|
||||
onChange={handlePortNameChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{showPreview && (
|
||||
<Alert
|
||||
message={`将创建 ${previewPorts.length} 个端口`}
|
||||
description={
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space wrap size={4}>
|
||||
{previewPorts.map((port, index) => (
|
||||
<Tag key={index} color="blue">{port}</Tag>
|
||||
))}
|
||||
{previewPorts.length < parsePortRange(form.getFieldValue('portName'))?.portCount && (
|
||||
<Tag color="default">...等</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="portType"
|
||||
label="端口类型"
|
||||
rules={[{ required: true, message: '请选择端口类型' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="请选择">
|
||||
<Option value="RJ45">RJ45</Option>
|
||||
<Option value="SFP">SFP</Option>
|
||||
<Option value="SFP+">SFP+</Option>
|
||||
<Option value="SFP28">SFP28</Option>
|
||||
<Option value="QSFP">QSFP</Option>
|
||||
<Option value="QSFP28">QSFP28</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="portSpeed"
|
||||
label="端口速率"
|
||||
rules={[{ required: true, message: '请选择端口速率' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="请选择">
|
||||
<Option value="100M">100M</Option>
|
||||
<Option value="1G">1G</Option>
|
||||
<Option value="10G">10G</Option>
|
||||
<Option value="25G">25G</Option>
|
||||
<Option value="40G">40G</Option>
|
||||
<Option value="100G">100G</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ display: 'flex', width: '100%' }}>
|
||||
<Form.Item
|
||||
name="vlanId"
|
||||
label="VLAN ID"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="1-4094"
|
||||
min={1}
|
||||
max={4094}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="请选择">
|
||||
<Option value="free">空闲</Option>
|
||||
<Option value="occupied">占用</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<TextArea rows={2} placeholder="请输入描述信息(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<strong>格式说明:</strong>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||
<li>单个端口:<code>eth0/1</code>、<code>gigabitethernet1/0/1</code></li>
|
||||
<li>端口范围:<code>1/0/1-1/0/48</code>(创建 1/0/1 到 1/0/48 共48个端口)</li>
|
||||
<li>简单范围:<code>eth1-eth24</code>(创建 eth1 到 eth24 共24个端口)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(PortCreateModal);
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Tag, Tooltip, Popconfirm, Empty, Spin, Badge } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, ApiOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import PortCreateModal from './PortCreateModal';
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea'
|
||||
},
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b'
|
||||
}
|
||||
};
|
||||
|
||||
function PortManagementPanel({ deviceId, deviceName, onRefresh }) {
|
||||
const [ports, setPorts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
|
||||
const fetchPorts = useCallback(async () => {
|
||||
if (!deviceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/device-ports/device/${deviceId}`);
|
||||
setPorts(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取端口列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPorts();
|
||||
}, [fetchPorts]);
|
||||
|
||||
const handleDelete = useCallback(async (port) => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchPorts();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
import('antd').then(({ message }) => message.error('端口删除失败'));
|
||||
}
|
||||
}, [fetchPorts, onRefresh]);
|
||||
|
||||
const handleCreateSuccess = useCallback(() => {
|
||||
fetchPorts();
|
||||
onRefresh?.();
|
||||
}, [fetchPorts, onRefresh]);
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const config = {
|
||||
free: { color: 'success', text: '空闲' },
|
||||
occupied: { color: 'processing', text: '占用' },
|
||||
fault: { color: 'error', text: '故障' }
|
||||
};
|
||||
const { color, text } = config[status] || { color: 'default', text: status };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const getTypeTag = (type) => {
|
||||
const config = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
};
|
||||
const { color, text } = config[type] || { color: 'default', text: type };
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '端口名称',
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<Tooltip title={text}>
|
||||
<span style={{ fontWeight: 500 }}>{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 90,
|
||||
render: (type) => getTypeTag(type)
|
||||
},
|
||||
{
|
||||
title: '速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (status) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: 'VLAN',
|
||||
dataIndex: 'vlanId',
|
||||
key: 'vlanId',
|
||||
width: 70,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Popconfirm
|
||||
title="确定要删除此端口吗?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const freeCount = ports.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = ports.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = ports.filter(p => p.status === 'fault').length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" tip="加载端口数据中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="port-management-panel">
|
||||
<div className="port-panel-header">
|
||||
<div className="port-stats">
|
||||
<Space size={16}>
|
||||
<Badge count={freeCount} style={{ backgroundColor: designTokens.colors.success }} />
|
||||
<span className="stat-label">空闲</span>
|
||||
<Badge count={occupiedCount} style={{ backgroundColor: '#1677ff' }} />
|
||||
<span className="stat-label">占用</span>
|
||||
<Badge count={faultCount} style={{ backgroundColor: designTokens.colors.error }} />
|
||||
<span className="stat-label">故障</span>
|
||||
</Space>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchPorts}
|
||||
size="small"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
style={{
|
||||
background: designTokens.colors.primary.gradient,
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
新增端口
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{ports.length === 0 ? (
|
||||
<div className="port-empty">
|
||||
<Empty
|
||||
description={
|
||||
<span>
|
||||
该设备暂无端口
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
style={{ padding: 0, marginTop: 8 }}
|
||||
>
|
||||
立即添加端口
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ApiOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={ports}
|
||||
rowKey="portId"
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PortCreateModal
|
||||
device={{ deviceId, name: deviceName }}
|
||||
visible={createModalVisible}
|
||||
onClose={() => setCreateModalVisible(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(PortManagementPanel);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,926 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
const { Option } = Select;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
light: '#8b9ff0',
|
||||
dark: '#4f5db8'
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
light: '#34d399',
|
||||
dark: '#047857'
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
light: '#fbbf24',
|
||||
dark: '#b45309'
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
light: '#f87171',
|
||||
dark: '#b91c1c'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
small: '6px',
|
||||
medium: '10px',
|
||||
large: '16px'
|
||||
},
|
||||
shadows: {
|
||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
};
|
||||
|
||||
function PortManagement() {
|
||||
const [ports, setPorts] = useState([]);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [groupedPorts, setGroupedPorts] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
deviceId: '',
|
||||
status: 'all',
|
||||
portType: 'all',
|
||||
portSpeed: 'all'
|
||||
});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingPort, setEditingPort] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
const [importFileList, setImportFileList] = useState([]);
|
||||
const [importPreview, setImportPreview] = useState([]);
|
||||
const [importProgress, setImportProgress] = useState({ current: 0, total: 0 });
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [skipExisting, setSkipExisting] = useState(false);
|
||||
const [updateExisting, setUpdateExisting] = useState(false);
|
||||
|
||||
const fetchPorts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = {};
|
||||
|
||||
if (filters.deviceId) params.deviceId = filters.deviceId;
|
||||
if (filters.status !== 'all') params.status = filters.status;
|
||||
if (filters.portType !== 'all') params.portType = filters.portType;
|
||||
if (filters.portSpeed !== 'all') params.portSpeed = filters.portSpeed;
|
||||
|
||||
const response = await axios.get('/api/device-ports', { params });
|
||||
setPorts(response.data.ports || response.data || []);
|
||||
} catch (error) {
|
||||
message.error('获取端口列表失败');
|
||||
console.error('获取端口列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/devices', { params: { pageSize: 1000 } });
|
||||
setDevices(response.data.devices || response.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPorts();
|
||||
fetchDevices();
|
||||
}, [fetchPorts, fetchDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const grouped = {};
|
||||
ports.forEach(port => {
|
||||
const deviceId = port.deviceId;
|
||||
if (!grouped[deviceId]) {
|
||||
grouped[deviceId] = {
|
||||
device: devices.find(d => d.deviceId === deviceId),
|
||||
ports: []
|
||||
};
|
||||
}
|
||||
grouped[deviceId].ports.push(port);
|
||||
});
|
||||
setGroupedPorts(grouped);
|
||||
}, [ports, devices]);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchPorts();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
deviceId: '',
|
||||
status: 'all',
|
||||
portType: 'all',
|
||||
portSpeed: 'all'
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingPort(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (port) => {
|
||||
setEditingPort(port);
|
||||
form.setFieldsValue({
|
||||
portId: port.portId,
|
||||
deviceId: port.deviceId,
|
||||
portName: port.portName,
|
||||
portType: port.portType,
|
||||
portSpeed: port.portSpeed,
|
||||
status: port.status,
|
||||
vlanId: port.vlanId,
|
||||
description: port.description
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (portId) => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${portId}`);
|
||||
message.success('删除成功');
|
||||
fetchPorts();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingPort) {
|
||||
await axios.put(`/api/device-ports/${editingPort.portId}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await axios.post('/api/device-ports', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchPorts();
|
||||
} catch (error) {
|
||||
message.error(editingPort ? '更新失败' : '创建失败');
|
||||
console.error('提交失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setImportModalVisible(true);
|
||||
setImportPreview([]);
|
||||
setImportProgress({ current: 0, total: 0 });
|
||||
};
|
||||
|
||||
const handleFileUpload = (info) => {
|
||||
const { file } = info;
|
||||
setImportFileList([file]);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = e.target.result;
|
||||
let parsedData = [];
|
||||
|
||||
if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
|
||||
const workbook = XLSX.read(data, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
parsedData = XLSX.utils.sheet_to_json(worksheet);
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
Papa.parse(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
parsedData = results.data;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
message.error('不支持的文件格式,请上传 .xlsx 或 .csv 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedData = await validateImportData(parsedData);
|
||||
setImportPreview(validatedData);
|
||||
setImportProgress({ current: 0, total: validatedData.length });
|
||||
} catch (error) {
|
||||
message.error('文件解析失败');
|
||||
console.error('文件解析失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
};
|
||||
|
||||
const validateImportData = async (data) => {
|
||||
const validatedData = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
const error = await validatePortRow(row, i);
|
||||
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
validatedData.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
message.warning(`发现 ${errors.length} 条数据错误,已跳过`);
|
||||
console.log('导入错误:', errors);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
};
|
||||
|
||||
const validatePortRow = async (row, index) => {
|
||||
const errors = [];
|
||||
|
||||
if (!row['设备ID'] || !row['端口名称']) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:缺少必填字段(设备ID或端口名称)` };
|
||||
}
|
||||
|
||||
const device = devices.find(d => d.deviceId === row['设备ID']);
|
||||
if (!device) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:设备不存在` };
|
||||
}
|
||||
|
||||
const validPortTypes = ['RJ45', 'SFP', 'SFP+', 'SFP28', 'QSFP', 'QSFP28'];
|
||||
if (!validPortTypes.includes(row['端口类型'])) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:无效的端口类型` };
|
||||
}
|
||||
|
||||
const validPortSpeeds = ['100M', '1G', '10G', '25G', '40G', '100G'];
|
||||
if (!validPortSpeeds.includes(row['端口速率'])) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:无效的端口速率` };
|
||||
}
|
||||
|
||||
const validStatuses = ['空闲', '占用', '故障'];
|
||||
if (!validStatuses.includes(row['状态'])) {
|
||||
return { valid: false, error: `第 ${index + 1} 行:无效的状态` };
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, error: errors.join('; ') };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
if (importPreview.length === 0) {
|
||||
message.warning('请先选择要导入的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setImportProgress({ current: 0, total: importPreview.length });
|
||||
|
||||
try {
|
||||
const statusMap = {
|
||||
'空闲': 'free',
|
||||
'占用': 'occupied',
|
||||
'故障': 'fault'
|
||||
};
|
||||
|
||||
const portsData = importPreview.map((row, index) => ({
|
||||
portId: `PORT-${Date.now()}-${index}`,
|
||||
deviceId: row['设备ID'],
|
||||
portName: row['端口名称'],
|
||||
portType: row['端口类型'],
|
||||
portSpeed: row['端口速率'],
|
||||
status: statusMap[row['状态']] || 'free',
|
||||
vlanId: row['VLAN ID'],
|
||||
description: row['描述']
|
||||
}));
|
||||
|
||||
const response = await axios.post('/api/device-ports/batch', { ports: portsData });
|
||||
|
||||
const { total, success, failed, errors } = response.data;
|
||||
|
||||
setImportProgress({ current: total, total: total });
|
||||
|
||||
if (failed > 0) {
|
||||
console.error('导入错误:', errors);
|
||||
message.warning(`导入完成!成功 ${success} 条,失败 ${failed} 条`);
|
||||
} else {
|
||||
message.success(`导入完成!成功 ${success} 条`);
|
||||
}
|
||||
|
||||
fetchPorts();
|
||||
setImportModalVisible(false);
|
||||
setImportPreview([]);
|
||||
} catch (error) {
|
||||
console.error('批量导入失败:', error);
|
||||
message.error('批量导入失败,请检查数据格式');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const templateData = [
|
||||
{
|
||||
'设备ID': 'DEV001',
|
||||
'端口名称': 'eth0/1',
|
||||
'端口类型': 'RJ45',
|
||||
'端口速率': '1G',
|
||||
'状态': '空闲',
|
||||
'VLAN ID': '100',
|
||||
'描述': '示例端口'
|
||||
}
|
||||
];
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(templateData);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '端口数据');
|
||||
XLSX.writeFile(workbook, '端口导入模板.xlsx');
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const statusMap = {
|
||||
'free': { color: 'success', text: '空闲' },
|
||||
'occupied': { color: 'processing', text: '占用' },
|
||||
'fault': { color: 'error', text: '故障' },
|
||||
'空闲': { color: 'success', text: '空闲' },
|
||||
'占用': { color: 'processing', text: '占用' },
|
||||
'故障': { color: 'error', text: '故障' }
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getPortTypeTag = (type) => {
|
||||
const typeMap = {
|
||||
'RJ45': { color: 'blue', text: 'RJ45' },
|
||||
'SFP': { color: 'green', text: 'SFP' },
|
||||
'SFP+': { color: 'cyan', text: 'SFP+' },
|
||||
'SFP28': { color: 'purple', text: 'SFP28' },
|
||||
'QSFP': { color: 'orange', text: 'QSFP' },
|
||||
'QSFP28': { color: 'red', text: 'QSFP28' }
|
||||
};
|
||||
const config = typeMap[type] || { color: 'default', text: type };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const portColumns = [
|
||||
{
|
||||
title: '端口名称',
|
||||
dataIndex: 'portName',
|
||||
key: 'portName',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '端口类型',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
width: 100,
|
||||
render: (type) => getPortTypeTag(type)
|
||||
},
|
||||
{
|
||||
title: '端口速率',
|
||||
dataIndex: 'portSpeed',
|
||||
key: 'portSpeed',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: 'VLAN ID',
|
||||
dataIndex: 'vlanId',
|
||||
key: 'vlanId',
|
||||
width: 100,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<Tooltip title={text}>
|
||||
<span>{text || '-'}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个端口吗?"
|
||||
onConfirm={() => handleDelete(record.portId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', background: '#f5f5f5', minHeight: '100vh' }}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="选择设备"
|
||||
style={{ width: 200 }}
|
||||
value={filters.deviceId || undefined}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, deviceId: value }))}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}
|
||||
>
|
||||
{devices.map(device => (
|
||||
<Option key={device.deviceId} value={device.deviceId}>
|
||||
{device.name} ({device.deviceId})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="端口类型"
|
||||
style={{ width: 120 }}
|
||||
value={filters.portType}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, portType: value }))}
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="RJ45">RJ45</Option>
|
||||
<Option value="SFP">SFP</Option>
|
||||
<Option value="SFP+">SFP+</Option>
|
||||
<Option value="SFP28">SFP28</Option>
|
||||
<Option value="QSFP">QSFP</Option>
|
||||
<Option value="QSFP28">QSFP28</Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="端口速率"
|
||||
style={{ width: 120 }}
|
||||
value={filters.portSpeed}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, portSpeed: value }))}
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="100M">100M</Option>
|
||||
<Option value="1G">1G</Option>
|
||||
<Option value="10G">10G</Option>
|
||||
<Option value="25G">25G</Option>
|
||||
<Option value="40G">40G</Option>
|
||||
<Option value="100G">100G</Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="状态"
|
||||
style={{ width: 120 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="free">空闲</Option>
|
||||
<Option value="occupied">占用</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
新增端口
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
onClick={handleImport}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
|
||||
<Button icon={<ExportOutlined />}>
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" tip="加载端口数据中..." />
|
||||
</div>
|
||||
) : Object.keys(groupedPorts).length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px' }}>
|
||||
<Empty description="暂无端口数据" />
|
||||
</div>
|
||||
) : (
|
||||
<Collapse
|
||||
defaultActiveKey={Object.keys(groupedPorts).slice(0, 5)}
|
||||
style={{ background: '#f5f5f5' }}
|
||||
>
|
||||
{Object.entries(groupedPorts).map(([deviceId, data]) => {
|
||||
const device = data.device;
|
||||
const devicePorts = data.ports || [];
|
||||
const freeCount = devicePorts.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
key={deviceId}
|
||||
header={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
{device?.type?.toLowerCase()?.includes('server') ? '🖥️' :
|
||||
device?.type?.toLowerCase()?.includes('switch') ? '🔀' :
|
||||
device?.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '16px', color: '#1e293b' }}>
|
||||
{device?.name || '未知设备'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b' }}>
|
||||
{device?.deviceId || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space size="small">
|
||||
<Tag color="success">空闲: {freeCount}</Tag>
|
||||
<Tag color="processing">占用: {occupiedCount}</Tag>
|
||||
<Tag color="error">故障: {faultCount}</Tag>
|
||||
<Tag color="blue">总计: {devicePorts.length}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={portColumns}
|
||||
dataSource={devicePorts}
|
||||
rowKey="portId"
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</Collapse>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingPort ? '编辑端口' : '新增端口'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
width={600}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="deviceId"
|
||||
label="设备"
|
||||
rules={[{ required: true, message: '请选择设备' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择设备"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}
|
||||
>
|
||||
{devices.map(device => (
|
||||
<Option key={device.deviceId} value={device.deviceId}>
|
||||
{device.name} ({device.deviceId})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="portName"
|
||||
label="端口名称"
|
||||
rules={[{ required: true, message: '请输入端口名称' }]}
|
||||
>
|
||||
<Input placeholder="例如: eth0/1" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="portType"
|
||||
label="端口类型"
|
||||
rules={[{ required: true, message: '请选择端口类型' }]}
|
||||
initialValue="RJ45"
|
||||
>
|
||||
<Select placeholder="请选择端口类型">
|
||||
<Option value="RJ45">RJ45</Option>
|
||||
<Option value="SFP">SFP</Option>
|
||||
<Option value="SFP+">SFP+</Option>
|
||||
<Option value="SFP28">SFP28</Option>
|
||||
<Option value="QSFP">QSFP</Option>
|
||||
<Option value="QSFP28">QSFP28</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="portSpeed"
|
||||
label="端口速率"
|
||||
rules={[{ required: true, message: '请选择端口速率' }]}
|
||||
initialValue="1G"
|
||||
>
|
||||
<Select placeholder="请选择端口速率">
|
||||
<Option value="100M">100M</Option>
|
||||
<Option value="1G">1G</Option>
|
||||
<Option value="10G">10G</Option>
|
||||
<Option value="25G">25G</Option>
|
||||
<Option value="40G">40G</Option>
|
||||
<Option value="100G">100G</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
initialValue="free"
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="free">空闲</Option>
|
||||
<Option value="occupied">占用</Option>
|
||||
<Option value="fault">故障</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="vlanId"
|
||||
label="VLAN ID"
|
||||
>
|
||||
<InputNumber placeholder="请输入VLAN ID" min={1} max={4094} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="请输入描述" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量导入端口"
|
||||
open={importModalVisible}
|
||||
onCancel={() => {
|
||||
setImportModalVisible(false);
|
||||
setImportPreview([]);
|
||||
setImportProgress({ current: 0, total: 0 });
|
||||
}}
|
||||
width={900}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setImportModalVisible(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</Button>,
|
||||
<Button
|
||||
key="import"
|
||||
type="primary"
|
||||
icon={<ImportOutlined />}
|
||||
onClick={handleBatchImport}
|
||||
loading={importing}
|
||||
disabled={importPreview.length === 0}
|
||||
style={{ background: designTokens.colors.primary.gradient, border: 'none' }}
|
||||
>
|
||||
开始导入
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: 16 }}>
|
||||
<Upload.Dragger
|
||||
name="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
beforeUpload={() => false}
|
||||
customRequest={({ file, onSuccess }) => {
|
||||
handleFileUpload({ file, onSuccess });
|
||||
}}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadIcon />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx, .xls, .csv 格式</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}>
|
||||
<Checkbox checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)}>
|
||||
跳过已存在的端口
|
||||
</Checkbox>
|
||||
<Checkbox checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)}>
|
||||
更新已存在的端口
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
{importPreview.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>数据预览(前10条)</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: '设备ID',
|
||||
dataIndex: '设备ID',
|
||||
key: 'deviceId',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '端口名称',
|
||||
dataIndex: '端口名称',
|
||||
key: 'portName',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '端口类型',
|
||||
dataIndex: '端口类型',
|
||||
key: 'portType',
|
||||
width: 100,
|
||||
render: (type) => getPortTypeTag(type)
|
||||
},
|
||||
{
|
||||
title: '端口速率',
|
||||
dataIndex: '端口速率',
|
||||
key: 'portSpeed',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: 'VLAN ID',
|
||||
dataIndex: 'VLAN ID',
|
||||
key: 'vlanId',
|
||||
width: 100,
|
||||
render: (vlanId) => vlanId || '-'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: '描述',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<Tooltip title={text}>
|
||||
<span>{text || '-'}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
]}
|
||||
dataSource={importPreview.slice(0, 10)}
|
||||
rowKey={(record, index) => index}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importPreview.length > 10 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Text type="secondary">仅显示前10条数据,共 {importPreview.length} 条</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{importing && (
|
||||
<div style={{ textAlign: 'center', padding: '24px' }}>
|
||||
<Spin size="large" tip="导入中..." />
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Progress
|
||||
percent={Math.round((importProgress.current / importProgress.total) * 100)}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': designTokens.colors.primary.main,
|
||||
'100%': designTokens.colors.success.main
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text>
|
||||
正在导入 {importProgress.current} / {importProgress.total} 条数据...
|
||||
</Text>
|
||||
{importProgress.current > 0 && (
|
||||
<Text type="secondary">
|
||||
预计剩余时间:{Math.ceil((importProgress.total - importProgress.current) / 5)} 秒
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortManagement;
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
MobileOutlined, PrinterOutlined, SettingOutlined,
|
||||
SearchOutlined, ClearOutlined, EnvironmentOutlined,
|
||||
FilterOutlined, AppstoreOutlined, UnorderedListOutlined,
|
||||
FullscreenOutlined, CompressOutlined, EyeOutlined
|
||||
FullscreenOutlined, CompressOutlined, EyeOutlined, ApiOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import DeviceComponent from '../components/DeviceComponent';
|
||||
import DeviceDetailDrawer from '../components/DeviceDetailDrawer';
|
||||
import './RackVisualization.css';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -393,6 +394,14 @@ function RackVisualization() {
|
||||
const [savingTooltipConfig, setSavingTooltipConfig] = useState(false); // 保存配置状态
|
||||
const [deviceCache, setDeviceCache] = useState({}); // 设备数据缓存,键为rackId,值为设备数据
|
||||
|
||||
// 接线管理相关状态
|
||||
const [cables, setCables] = useState([]); // 接线列表
|
||||
const [showCables, setShowCables] = useState(true); // 是否显示接线
|
||||
|
||||
// 设备详情抽屉状态
|
||||
const [selectedDevice, setSelectedDevice] = useState(null); // 当前选中设备
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); // 详情抽屉显示状态
|
||||
|
||||
// 设备搜索功能
|
||||
const [searchKeyword, setSearchKeyword] = useState(''); // 搜索关键词
|
||||
const [searchResults, setSearchResults] = useState([]); // 搜索结果
|
||||
@@ -815,11 +824,26 @@ function RackVisualization() {
|
||||
useEffect(() => {
|
||||
if (selectedRack?.rackId) {
|
||||
fetchDevices(selectedRack.rackId);
|
||||
fetchCablesForRack(selectedRack.rackId);
|
||||
} else {
|
||||
setDevices([]);
|
||||
setCables([]);
|
||||
}
|
||||
}, [selectedRack, fetchDevices]);
|
||||
|
||||
// 获取机柜的接线数据
|
||||
const fetchCablesForRack = useCallback(async (rackId) => {
|
||||
try {
|
||||
const response = await axios.get('/api/cables');
|
||||
const rackCables = (response.data.cables || []).filter(cable =>
|
||||
cable.sourceDevice?.rackId === rackId || cable.targetDevice?.rackId === rackId
|
||||
);
|
||||
setCables(rackCables);
|
||||
} catch (error) {
|
||||
console.error('获取接线数据失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开字段配置模态框时获取数据
|
||||
const handleOpenTooltipConfig = () => {
|
||||
if (Object.keys(tooltipFields).length === 0) {
|
||||
@@ -1208,6 +1232,14 @@ function RackVisualization() {
|
||||
>
|
||||
字段配置
|
||||
</Button>
|
||||
<Button
|
||||
className={showCables ? 'primary-button' : 'secondary-button'}
|
||||
icon={<ApiOutlined />}
|
||||
onClick={() => setShowCables(!showCables)}
|
||||
type={showCables ? 'primary' : 'default'}
|
||||
>
|
||||
{showCables ? '隐藏接线' : '显示接线'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1510,11 +1542,15 @@ function RackVisualization() {
|
||||
borderTop: isHighlighted
|
||||
? `2px solid ${statusTheme.topBorderColor}`
|
||||
: `1px solid ${statusTheme.topBorderColor}`
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const isOneU = (device?.height || 1) === 1;
|
||||
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
|
||||
if (isOneU && !isFaultStatus) {
|
||||
}}
|
||||
onClick={(e) => {
|
||||
setSelectedDevice(device);
|
||||
setDetailDrawerVisible(true);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const isOneU = (device?.height || 1) === 1;
|
||||
const isFaultStatus = device?.status === 'error' || device?.status === 'fault';
|
||||
if (isOneU && !isFaultStatus) {
|
||||
e.currentTarget.style.height = '33px';
|
||||
e.currentTarget.style.zIndex = '150';
|
||||
}
|
||||
@@ -1687,6 +1723,71 @@ function RackVisualization() {
|
||||
{/* 机柜顶部 */}
|
||||
<div className="rack-top" />
|
||||
|
||||
{/* 接线连线层 */}
|
||||
{showCables && cables.length > 0 && (
|
||||
<svg
|
||||
className="cable-connections"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 50
|
||||
}}
|
||||
>
|
||||
{cables.map((cable, index) => {
|
||||
const sourceDevice = devices.find(d => d.deviceId === cable.sourceDeviceId);
|
||||
const targetDevice = devices.find(d => d.deviceId === cable.targetDeviceId);
|
||||
|
||||
if (!sourceDevice || !targetDevice) return null;
|
||||
|
||||
const sourceStyle = getDeviceStyle(sourceDevice, selectedRack.height);
|
||||
const targetStyle = getDeviceStyle(targetDevice, selectedRack.height);
|
||||
|
||||
const sourceTop = parseInt(sourceStyle.top);
|
||||
const sourceHeight = parseInt(sourceStyle.height);
|
||||
const targetTop = parseInt(targetStyle.top);
|
||||
const targetHeight = parseInt(targetStyle.height);
|
||||
|
||||
const sourceX = 50;
|
||||
const sourceY = sourceTop + sourceHeight / 2;
|
||||
const targetX = 50;
|
||||
const targetY = targetTop + targetHeight / 2;
|
||||
|
||||
const cableColor = cable.status === 'normal' ? '#10b981' :
|
||||
cable.status === 'fault' ? '#ef4444' : '#6b7280';
|
||||
const cableDash = cable.cableType === 'fiber' ? '5,5' : 'none';
|
||||
|
||||
return (
|
||||
<g key={cable.cableId}>
|
||||
<path
|
||||
d={`M ${sourceX} ${sourceY} Q ${sourceX + 30} ${(sourceY + targetY) / 2} ${targetX} ${targetY}`}
|
||||
stroke={cableColor}
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeDasharray={cableDash}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<circle
|
||||
cx={sourceX}
|
||||
cy={sourceY}
|
||||
r="4"
|
||||
fill={cableColor}
|
||||
/>
|
||||
<circle
|
||||
cx={targetX}
|
||||
cy={targetY}
|
||||
r="4"
|
||||
fill={cableColor}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* 机柜名称和设备数量 */}
|
||||
<div className="rack-header">
|
||||
{/* 机柜标题 */}
|
||||
@@ -1897,6 +1998,18 @@ function RackVisualization() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设备详情抽屉 */}
|
||||
<DeviceDetailDrawer
|
||||
device={selectedDevice}
|
||||
visible={detailDrawerVisible}
|
||||
onClose={() => {
|
||||
setDetailDrawerVisible(false);
|
||||
setSelectedDevice(null);
|
||||
}}
|
||||
cables={cables}
|
||||
onRefreshCables={() => selectedRack?.rackId && fetchCablesForRack(selectedRack.rackId)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
Generated
+9
@@ -8,6 +8,9 @@
|
||||
"name": "idc-device-management",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"papaparse": "^5.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
@@ -175,6 +178,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"papaparse": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user