diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 20fce0e..30fe6d5 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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` - 获取网卡及端口 + +--- + ## 🔐 安全加固清单 ### 服务器安全 diff --git a/backend/models/Cable.js b/backend/models/Cable.js new file mode 100644 index 0000000..c02e027 --- /dev/null +++ b/backend/models/Cable.js @@ -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; diff --git a/backend/models/DevicePort.js b/backend/models/DevicePort.js new file mode 100644 index 0000000..6680745 --- /dev/null +++ b/backend/models/DevicePort.js @@ -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; diff --git a/backend/models/NetworkCard.js b/backend/models/NetworkCard.js new file mode 100644 index 0000000..4b47b0a --- /dev/null +++ b/backend/models/NetworkCard.js @@ -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; diff --git a/backend/routes/cables.js b/backend/routes/cables.js new file mode 100644 index 0000000..073f3b8 --- /dev/null +++ b/backend/routes/cables.js @@ -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; diff --git a/backend/routes/devicePorts.js b/backend/routes/devicePorts.js new file mode 100644 index 0000000..a91b5df --- /dev/null +++ b/backend/routes/devicePorts.js @@ -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; diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 94e5e40..3fe122e 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -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: '设备不存在' }); } diff --git a/backend/routes/networkCards.js b/backend/routes/networkCards.js new file mode 100644 index 0000000..32aa1cb --- /dev/null +++ b/backend/routes/networkCards.js @@ -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; diff --git a/backend/scripts/migrate-v2.js b/backend/scripts/migrate-v2.js new file mode 100644 index 0000000..bfc7133 --- /dev/null +++ b/backend/scripts/migrate-v2.js @@ -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(); diff --git a/backend/server.js b/backend/server.js index 772181d..5be5de4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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')); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b0c7ca..bd3fe37 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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: , label: 字段管理, }, + { + key: 'cables', + icon: , + label: 接线管理, + }, + { + key: 'ports', + icon: , + label: 端口管理, + }, ], }, { @@ -578,6 +590,8 @@ const ThemeConfig = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/components/DeviceDetailDrawer.jsx b/frontend/src/components/DeviceDetailDrawer.jsx new file mode 100644 index 0000000..d5b11ab --- /dev/null +++ b/frontend/src/components/DeviceDetailDrawer.jsx @@ -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 {text}; + }, []); + + 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: ( + + + 端口与网卡 + + ), + children: ( + + ) + }, + { + key: 'cables', + label: ( + + + 接线 ({deviceCables.length}) + + ), + children: ( + + {deviceCables.length === 0 ? ( + + ) : ( + + {deviceCables.map(cable => ( + + + + + 源设备: + + {cable.sourceDevice?.name || '-'} + {cable.sourcePort} + + + + 目标设备: + + {cable.targetDevice?.name || '-'} + {cable.targetPort} + + + + + + + + {cable.status === 'normal' ? '正常' : cable.status === 'fault' ? '故障' : '未连接'} + + + {cable.cableType === 'ethernet' ? '网线' : cable.cableType === 'fiber' ? '光纤' : '铜缆'} + + {cable.cableLength && ( + + {cable.cableLength}m + + )} + + + {cable.description && ( + + {cable.description} + + )} + + ))} + + )} + + ) + } + ]; + + if (!device) return null; + + return ( + + + 设备详情 - {device.name} + + } + placement="right" + width={520} + open={visible} + onClose={onClose} + styles={{ body: { padding: '16px 20px', overflow: 'auto' } }} + > + + 基本信息 + + + 设备ID + {device.deviceId} + + + 设备类型 + {getDeviceTypeName(device.type)} + + + 设备状态 + {getStatusTag(device.status)} + + + 位置 + + U{device.position} {device.height && `(${device.height}U)`} + + + {device.ipAddress && ( + + IP地址 + {device.ipAddress} + + )} + {device.brand && ( + + 品牌 + {device.brand} + + )} + + + + + + ); +} + +export default React.memo(DeviceDetailDrawer); diff --git a/frontend/src/components/NetworkCardCreateModal.jsx b/frontend/src/components/NetworkCardCreateModal.jsx new file mode 100644 index 0000000..b71091f --- /dev/null +++ b/frontend/src/components/NetworkCardCreateModal.jsx @@ -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 ( + + + 新增网卡 - {device?.name || '设备'} + + } + open={visible} + onOk={handleSubmit} + onCancel={handleCancel} + confirmLoading={loading} + okText="创建" + cancelText="取消" + width={480} + styles={{ body: { padding: '20px 24px' } }} + > + + + 网卡名称 + + + + + } + rules={[ + { required: true, message: '请输入网卡名称' }, + { max: 50, message: '名称不能超过50个字符' } + ]} + > + + + + + + + + + + + 正常 + 警告 + 故障 + 离线 + + + + + + + + + + + + + + + + + + + + ); +} + +export default React.memo(NetworkCardCreateModal); diff --git a/frontend/src/components/NetworkCardPanel.jsx b/frontend/src/components/NetworkCardPanel.jsx new file mode 100644 index 0000000..3c0a338 --- /dev/null +++ b/frontend/src/components/NetworkCardPanel.jsx @@ -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 {text}; + }; + + 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 {text}; + }; + + const renderPortTable = (ports, nicId) => { + const columns = [ + { + title: '端口名称', + dataIndex: 'portName', + key: 'portName', + width: 120, + render: (text) => {text} + }, + { + 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) => ( + + handleDeletePort(record)} + okText="确定" + cancelText="取消" + > + }> + 删除 + + + + ) + } + ]; + + return ( + + ); + }; + + const renderCardHeader = (card) => { + const stats = card.stats || { free: 0, occupied: 0, fault: 0, total: 0 }; + + return ( + + + + {card.isUngrouped ? : } + + + + {card.name} + {card.slotNumber && 插槽 {card.slotNumber}} + + + {card.description || (card.isUngrouped ? '未分配到网卡的端口' : '网卡')} + + + + + + 空闲 + + 占用 + + 故障 + {!card.isUngrouped && ( + handleDeleteCard(card)} + okText="确定" + cancelText="取消" + > + }> + 删除网卡 + + + )} + } + onClick={(e) => { + e.stopPropagation(); + setSelectedCard(card); + setCreatePortModalVisible(true); + }} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 添加端口 + + + + ); + }; + + if (loading) { + return ( + + + + ); + } + + 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 ( + + + + + + 个网卡 + + 个端口 + + + + } onClick={fetchData} size="small"> + 刷新 + + } + onClick={() => setCreateCardModalVisible(true)} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 新增网卡 + + + + + {cards.length === 0 ? ( + + + 该设备暂无网卡和端口 + + } + onClick={() => setCreateCardModalVisible(true)} + style={{ padding: 0, marginTop: 8 }} + > + 立即添加网卡 + + + } + > + + + + ) : ( + setExpandedCards(keys)} + expandIconPosition="end" + style={{ background: 'transparent' }} + > + {cards.map((card) => ( + + {card.ports && card.ports.length > 0 ? ( + renderPortTable(card.ports, card.nicId) + ) : ( + + 该{card.isUngrouped ? '分组' : '网卡'}暂无端口 + + } + onClick={() => { + setSelectedCard(card); + setCreatePortModalVisible(true); + }} + style={{ padding: 0, marginTop: 8 }} + > + 添加端口 + + + )} + + ))} + + )} + + setCreateCardModalVisible(false)} + onSuccess={handleCreateCardSuccess} + /> + + { + setCreatePortModalVisible(false); + setSelectedCard(null); + }} + onSuccess={handleCreatePortSuccess} + defaultNicId={selectedCard?.nicId} + /> + + ); +} + +export default React.memo(NetworkCardPanel); diff --git a/frontend/src/components/PortCreateModal.jsx b/frontend/src/components/PortCreateModal.jsx new file mode 100644 index 0000000..7323735 --- /dev/null +++ b/frontend/src/components/PortCreateModal.jsx @@ -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 ( + + + 新增端口 - {device?.name || '设备'} + {portCount > 1 && ( + {portCount} 个端口 + )} + + } + open={visible} + onOk={handleSubmit} + onCancel={handleCancel} + confirmLoading={loading} + okText={portCount > 1 ? `创建 ${portCount} 个端口` : '创建'} + cancelText="取消" + width={560} + styles={{ body: { padding: '20px 24px' } }} + > + + + + + + + 所属网卡 + + + + + } + > + + {nicList.map(nic => ( + + {nic.name} + {nic.slotNumber && ` (插槽${nic.slotNumber})`} + + ))} + + + + + 端口名称 + + + + + } + 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(); + } + } + ]} + > + + + + {showPreview && ( + + + {previewPorts.map((port, index) => ( + {port} + ))} + {previewPorts.length < parsePortRange(form.getFieldValue('portName'))?.portCount && ( + ...等 + )} + + + } + type="info" + showIcon + style={{ marginBottom: 16 }} + /> + )} + + + + + RJ45 + SFP + SFP+ + SFP28 + QSFP + QSFP28 + + + + + + 100M + 1G + 10G + 25G + 40G + 100G + + + + + + + + + + + + 空闲 + 占用 + 故障 + + + + + + + + + + 格式说明: + + 单个端口:eth0/1、gigabitethernet1/0/1 + 端口范围:1/0/1-1/0/48(创建 1/0/1 到 1/0/48 共48个端口) + 简单范围:eth1-eth24(创建 eth1 到 eth24 共24个端口) + + + + + ); +} + +export default React.memo(PortCreateModal); diff --git a/frontend/src/components/PortManagementPanel.jsx b/frontend/src/components/PortManagementPanel.jsx new file mode 100644 index 0000000..029f9f1 --- /dev/null +++ b/frontend/src/components/PortManagementPanel.jsx @@ -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 {text}; + }; + + 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 {text}; + }; + + const columns = [ + { + title: '端口名称', + dataIndex: 'portName', + key: 'portName', + width: 120, + render: (text) => ( + + {text} + + ) + }, + { + 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) => ( + + handleDelete(record)} + okText="确定" + cancelText="取消" + > + } + > + 删除 + + + + ) + } + ]; + + 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 ( + + + + ); + } + + return ( + + + + + + 空闲 + + 占用 + + 故障 + + + + } + onClick={fetchPorts} + size="small" + > + 刷新 + + } + onClick={() => setCreateModalVisible(true)} + style={{ + background: designTokens.colors.primary.gradient, + border: 'none' + }} + > + 新增端口 + + + + + {ports.length === 0 ? ( + + + 该设备暂无端口 + + } + onClick={() => setCreateModalVisible(true)} + style={{ padding: 0, marginTop: 8 }} + > + 立即添加端口 + + + } + > + + + + ) : ( + + )} + + setCreateModalVisible(false)} + onSuccess={handleCreateSuccess} + /> + + ); +} + +export default React.memo(PortManagementPanel); diff --git a/frontend/src/pages/CableManagement.jsx b/frontend/src/pages/CableManagement.jsx new file mode 100644 index 0000000..cda4377 --- /dev/null +++ b/frontend/src/pages/CableManagement.jsx @@ -0,0 +1,1059 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, 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 CableManagement() { + const [cables, setCables] = useState([]); + const [devices, setDevices] = useState([]); + const [switchDevices, setSwitchDevices] = useState([]); + const [groupedCables, setGroupedCables] = useState({}); + const [devicePorts, setDevicePorts] = useState({}); + const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({ + switchDeviceId: '', + status: 'all', + cableType: 'all' + }); + const [modalVisible, setModalVisible] = useState(false); + const [editingCable, setEditingCable] = 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 fetchCables = useCallback(async () => { + try { + setLoading(true); + const params = {}; + + if (filters.switchDeviceId) params.sourceDeviceId = filters.switchDeviceId; + if (filters.status !== 'all') params.status = filters.status; + if (filters.cableType !== 'all') params.cableType = filters.cableType; + + const response = await axios.get('/api/cables', { params }); + setCables(response.data.cables || []); + + const grouped = {}; + response.data.cables.forEach(cable => { + const switchId = cable.sourceDeviceId; + if (!grouped[switchId]) { + grouped[switchId] = { + switch: cable.sourceDevice, + cables: [] + }; + } + grouped[switchId].cables.push(cable); + }); + setGroupedCables(grouped); + } 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 } }); + const allDevices = response.data.devices || []; + const switches = allDevices.filter(device => device.type === 'switch'); + setDevices(allDevices); + setSwitchDevices(switches); + } catch (error) { + console.error('获取设备列表失败:', error); + } + }, []); + + const fetchDevicePorts = useCallback(async (deviceId) => { + if (!deviceId) { + setDevicePorts(prev => ({ ...prev, [deviceId]: [] })); + return; + } + + try { + const response = await axios.get(`/api/device-ports/device/${deviceId}`); + setDevicePorts(prev => ({ ...prev, [deviceId]: response.data || [] })); + } catch (error) { + console.error('获取设备端口失败:', error); + setDevicePorts(prev => ({ ...prev, [deviceId]: [] })); + } + }, []); + + useEffect(() => { + fetchCables(); + fetchDevices(); + }, [fetchCables, fetchDevices]); + + const handleSearch = () => { + fetchCables(); + }; + + const handleReset = () => { + setFilters({ + switchDeviceId: '', + status: 'all', + cableType: 'all' + }); + }; + + const handleAdd = () => { + setEditingCable(null); + form.resetFields(); + setModalVisible(true); + }; + + const handleEdit = (cable) => { + setEditingCable(cable); + form.setFieldsValue({ + sourceDeviceId: cable.sourceDeviceId, + sourcePort: cable.sourcePort, + targetDeviceId: cable.targetDeviceId, + targetPort: cable.targetPort, + cableType: cable.cableType, + cableLength: cable.cableLength, + status: cable.status, + description: cable.description + }); + setModalVisible(true); + }; + + const handleDelete = async (cableId) => { + try { + await axios.delete(`/api/cables/${cableId}`); + message.success('删除成功'); + fetchCables(); + } catch (error) { + message.error('删除失败'); + console.error('删除失败:', error); + } + }; + + const handleDeleteSwitch = async (switchId) => { + try { + await axios.delete(`/api/devices/${switchId}`); + message.success('删除设备成功'); + fetchDevices(); + fetchCables(); + } catch (error) { + message.error('删除设备失败'); + console.error('删除设备失败:', error); + } + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + if (editingCable) { + await axios.put(`/api/cables/${editingCable.cableId}`, values); + message.success('更新成功'); + } else { + await axios.post('/api/cables', values); + message.success('创建成功'); + } + + setModalVisible(false); + form.resetFields(); + fetchCables(); + } catch (error) { + message.error(editingCable ? '更新失败' : '创建失败'); + 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 validateCableRow(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 validateCableRow = async (row, index) => { + const errors = []; + + if (!row['源设备ID'] || !row['源设备端口']) { + return { valid: false, error: `第 ${index + 1} 行:缺少必填字段(源设备ID或源设备端口)` }; + } + + const sourceDevice = devices.find(d => d.deviceId === row['源设备ID']); + if (!sourceDevice) { + return { valid: false, error: `第 ${index + 1} 行:源设备不存在` }; + } + + const targetDevice = devices.find(d => d.deviceId === row['目标设备ID']); + if (!targetDevice) { + return { valid: false, error: `第 ${index + 1} 行:目标设备不存在` }; + } + + const validCableTypes = ['网线', '光纤', '铜缆']; + if (!validCableTypes.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 cableTypeMap = { + '网线': 'ethernet', + '光纤': 'fiber', + '铜缆': 'copper' + }; + + const statusMap = { + '正常': 'normal', + '故障': 'fault', + '未连接': 'disconnected' + }; + + const cablesData = importPreview.map((row, index) => ({ + cableId: `CABLE-${Date.now()}-${index}`, + sourceDeviceId: row['源设备ID'], + sourcePort: row['源设备端口'], + targetDeviceId: row['目标设备ID'], + targetPort: row['目标设备端口'], + cableType: cableTypeMap[row['线缆类型']] || 'ethernet', + cableLength: row['线缆长度(米)'], + status: statusMap[row['状态']] || 'normal', + description: row['描述'] + })); + + const response = await axios.post('/api/cables/batch', { cables: cablesData }); + + 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} 条`); + } + + fetchCables(); + setImportModalVisible(false); + setImportPreview([]); + } catch (error) { + console.error('批量导入失败:', error); + message.error('批量导入失败,请检查数据格式'); + } finally { + setImporting(false); + } + }; + + const handleDownloadTemplate = () => { + const templateData = [ + { + '源设备ID': 'DEV001', + '源设备端口': 'eth0/1', + '目标设备ID': 'DEV002', + '目标设备端口': 'eth0', + '线缆类型': '网线', + '线缆长度(米)': '5', + '状态': '正常', + '描述': '示例接线' + } + ]; + + 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 = { + normal: { color: 'success', text: '正常' }, + fault: { color: 'error', text: '故障' }, + disconnected: { color: 'default', text: '未连接' } + }; + const config = statusMap[status] || { color: 'default', text: status }; + return {config.text}; + }; + + const getCableTypeTag = (type) => { + const typeMap = { + '网线': { color: 'blue', text: '网线' }, + '光纤': { color: 'green', text: '光纤' }, + '铜缆': { color: 'orange', text: '铜缆' } + }; + const config = typeMap[type] || { color: 'default', text: type }; + return {config.text}; + }; + + const getPortConnectionStatus = (portName, switchData) => { + const cable = switchData.cables.find(c => c.sourcePort === portName); + if (!cable) { + return { status: 'disconnected', text: '未连接', color: 'default' }; + } + return { + status: cable.status, + text: cable.status === 'normal' ? '已连接' : cable.status === 'fault' ? '故障' : '未连接', + color: cable.status === 'normal' ? 'success' : cable.status === 'fault' ? 'error' : 'default' + }; + }; + + const portColumns = [ + { + title: '端口名称', + dataIndex: 'portName', + key: 'portName', + width: 120 + }, + { + title: '端口类型', + dataIndex: 'portType', + key: 'portType', + width: 100, + render: (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 {config.text}; + } + }, + { + title: '端口速率', + dataIndex: 'portSpeed', + key: 'portSpeed', + width: 100 + }, + { + title: '连接状态', + dataIndex: 'connectionStatus', + key: 'connectionStatus', + width: 100, + render: (_, record) => { + const status = getPortConnectionStatus(record.portName, record.switchData); + return {status.text}; + } + }, + { + title: '目标设备', + dataIndex: 'targetDevice', + key: 'targetDevice', + width: 200, + render: (_, record) => { + const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); + if (!cable) return '-'; + return ( + + {cable.targetDevice?.name || '-'} + {cable.targetPort} + + ); + } + }, + { + title: '线缆类型', + dataIndex: 'cableType', + key: 'cableType', + width: 100, + render: (_, record) => { + const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); + if (!cable) return '-'; + return getCableTypeTag(cable.cableType); + } + }, + { + title: '长度(米)', + dataIndex: 'cableLength', + key: 'cableLength', + width: 100, + render: (_, record) => { + const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); + if (!cable) return '-'; + return cable.cableLength ? `${cable.cableLength}m` : '-'; + } + }, + { + title: '操作', + key: 'action', + width: 150, + fixed: 'right', + render: (_, record) => { + const cable = record.switchData.cables.find(c => c.sourcePort === record.portName); + return ( + + {cable && ( + <> + } + onClick={() => handleEdit(cable)} + > + 编辑 + + handleDelete(cable.cableId)} + okText="确定" + cancelText="取消" + > + } + > + 删除 + + + > + )} + + ); + } + } + ]; + + return ( + + + + + setFilters(prev => ({ ...prev, switchDeviceId: value }))} + allowClear + showSearch + filterOption={(input, option) => + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + > + {switchDevices.map(device => ( + + {device.name} ({device.deviceId}) + + ))} + + + setFilters(prev => ({ ...prev, cableType: value }))} + > + 全部 + 网线 + 光纤 + 铜缆 + + + setFilters(prev => ({ ...prev, status: value }))} + > + 全部 + 已连接 + 故障 + 未连接 + + + } + onClick={handleSearch} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 搜索 + + + } onClick={handleReset}> + 重置 + + + + + + + } + onClick={handleAdd} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 新增接线 + + + } + onClick={handleImport} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 批量导入 + + + }> + 导出 + + + + + {loading ? ( + + + + ) : Object.keys(groupedCables).length === 0 ? ( + + + + ) : ( + + {Object.entries(groupedCables).map(([switchId, switchData]) => { + const switchDevice = switchData.switch; + const switchPorts = devicePorts[switchId] || []; + const connectedCount = switchData.cables.filter(c => c.status === 'normal').length; + const disconnectedCount = switchData.cables.filter(c => c.status === 'disconnected').length; + const faultCount = switchData.cables.filter(c => c.status === 'fault').length; + + return ( + + + + 🔀 + + + + {switchDevice?.name || '未知设备'} + + + {switchDevice?.deviceId || '-'} + + + + + 已连接: {connectedCount} + 未连接: {disconnectedCount} + {faultCount > 0 && 故障: {faultCount}} + 总计: {switchData.cables.length} + + + } + extra={ + + } + onClick={() => { + setEditingCable(null); + form.setFieldsValue({ sourceDeviceId: switchId }); + setModalVisible(true); + }} + > + 添加接线 + + handleDeleteSwitch(switchId)} + okText="确定" + cancelText="取消" + > + } + > + 删除设备 + + + + } + > + ({ + ...port, + switchData: switchData + }))} + rowKey="portId" + pagination={false} + size="small" + scroll={{ x: 1200 }} + /> + + ); + })} + + )} + + + { + setModalVisible(false); + form.resetFields(); + }} + width={600} + okText="确定" + cancelText="取消" + > + + + + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + onChange={(value) => { + fetchDevicePorts(value); + form.setFieldsValue({ sourcePort: undefined }); + }} + > + {switchDevices.map(device => ( + + {device.name} ({device.deviceId}) + + ))} + + + + + + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + disabled={!form.getFieldValue('sourceDeviceId')} + > + {(devicePorts[form.getFieldValue('sourceDeviceId')] || []).map(port => ( + + {port.portName} ({port.portType} - {port.portSpeed}) + + ))} + + + + + + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + onChange={(value) => { + fetchDevicePorts(value); + form.setFieldsValue({ targetPort: undefined }); + }} + > + {devices.map(device => ( + + {device.name} ({device.deviceId}) + + ))} + + + + + + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + disabled={!form.getFieldValue('targetDeviceId')} + > + {(devicePorts[form.getFieldValue('targetDeviceId')] || []).map(port => ( + + {port.portName} ({port.portType} - {port.portSpeed}) + + ))} + + + + + + 网线 + 光纤 + 铜缆 + + + + + + + + + + 正常 + 故障 + 未连接 + + + + + + + + + + { + setImportModalVisible(false); + setImportPreview([]); + setImportProgress({ current: 0, total: 0 }); + }} + width={800} + footer={[ + setImportModalVisible(false)}> + 取消 + , + } + onClick={handleDownloadTemplate} + > + 下载模板 + , + } + onClick={handleBatchImport} + loading={importing} + disabled={importPreview.length === 0} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 开始导入 + + ]} + > + + + false} + customRequest={({ file, onSuccess }) => { + handleFileUpload({ file, onSuccess }); + }} + > + + + + 点击或拖拽文件到此处上传 + 支持 .xlsx, .xls, .csv 格式 + + + + + setSkipExisting(e.target.checked)}> + 跳过已存在的接线 + + setUpdateExisting(e.target.checked)}> + 更新已存在的接线 + + + + {importPreview.length > 0 && ( + <> + + + 数据预览(前10条) + } + onClick={handleDownloadTemplate} + > + 下载模板 + + + getCableTypeTag(type) + }, + { + title: '状态', + dataIndex: '状态', + key: 'status', + width: 100, + render: (status) => getStatusTag(status) + }, + { + title: '描述', + dataIndex: '描述', + key: 'description', + ellipsis: true + } + ]} + dataSource={importPreview.slice(0, 10)} + rowKey={(record, index) => index} + pagination={false} + size="small" + scroll={{ x: 1000 }} + /> + + + {importPreview.length > 10 && ( + + 仅显示前10条数据,共 {importPreview.length} 条 + + )} + > + )} + + {importing && ( + + + + + + + 正在导入 {importProgress.current} / {importProgress.total} 条数据... + + {importProgress.current > 0 && ( + + 预计剩余时间:{Math.ceil((importProgress.total - importProgress.current) / 5)} 秒 + + )} + + + + )} + + + + ); +} + +export default CableManagement; diff --git a/frontend/src/pages/PortManagement.jsx b/frontend/src/pages/PortManagement.jsx new file mode 100644 index 0000000..f70d5d3 --- /dev/null +++ b/frontend/src/pages/PortManagement.jsx @@ -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 {config.text}; + }; + + 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 {config.text}; + }; + + 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) => ( + + {text || '-'} + + ) + }, + { + title: '操作', + key: 'action', + width: 150, + fixed: 'right', + render: (_, record) => ( + + } + onClick={() => handleEdit(record)} + > + 编辑 + + handleDelete(record.portId)} + okText="确定" + cancelText="取消" + > + } + > + 删除 + + + + ) + } + ]; + + return ( + + + + + setFilters(prev => ({ ...prev, deviceId: value }))} + allowClear + showSearch + filterOption={(input, option) => + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + > + {devices.map(device => ( + + {device.name} ({device.deviceId}) + + ))} + + + setFilters(prev => ({ ...prev, portType: value }))} + > + 全部 + RJ45 + SFP + SFP+ + SFP28 + QSFP + QSFP28 + + + setFilters(prev => ({ ...prev, portSpeed: value }))} + > + 全部 + 100M + 1G + 10G + 25G + 40G + 100G + + + setFilters(prev => ({ ...prev, status: value }))} + > + 全部 + 空闲 + 占用 + 故障 + + + } + onClick={handleSearch} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 搜索 + + + } onClick={handleReset}> + 重置 + + + + + + + } + onClick={handleAdd} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 新增端口 + + + } + onClick={handleImport} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 批量导入 + + + }> + 导出 + + + + + {loading ? ( + + + + ) : Object.keys(groupedPorts).length === 0 ? ( + + + + ) : ( + + {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 ( + + + + {device?.type?.toLowerCase()?.includes('server') ? '🖥️' : + device?.type?.toLowerCase()?.includes('switch') ? '🔀' : + device?.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'} + + + + {device?.name || '未知设备'} + + + {device?.deviceId || '-'} + + + + + 空闲: {freeCount} + 占用: {occupiedCount} + 故障: {faultCount} + 总计: {devicePorts.length} + + + } + > + + + ); + })} + + )} + + + { + setModalVisible(false); + form.resetFields(); + }} + width={600} + okText="确定" + cancelText="取消" + > + + + + option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 + } + > + {devices.map(device => ( + + {device.name} ({device.deviceId}) + + ))} + + + + + + + + + + RJ45 + SFP + SFP+ + SFP28 + QSFP + QSFP28 + + + + + + 100M + 1G + 10G + 25G + 40G + 100G + + + + + + 空闲 + 占用 + 故障 + + + + + + + + + + + + + + { + setImportModalVisible(false); + setImportPreview([]); + setImportProgress({ current: 0, total: 0 }); + }} + width={900} + footer={[ + setImportModalVisible(false)}> + 取消 + , + } + onClick={handleDownloadTemplate} + > + 下载模板 + , + } + onClick={handleBatchImport} + loading={importing} + disabled={importPreview.length === 0} + style={{ background: designTokens.colors.primary.gradient, border: 'none' }} + > + 开始导入 + + ]} + > + + + false} + customRequest={({ file, onSuccess }) => { + handleFileUpload({ file, onSuccess }); + }} + > + + + + 点击或拖拽文件到此处上传 + 支持 .xlsx, .xls, .csv 格式 + + + + + setSkipExisting(e.target.checked)}> + 跳过已存在的端口 + + setUpdateExisting(e.target.checked)}> + 更新已存在的端口 + + + + {importPreview.length > 0 && ( + <> + + + 数据预览(前10条) + } + onClick={handleDownloadTemplate} + > + 下载模板 + + + 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) => ( + + {text || '-'} + + ) + } + ]} + dataSource={importPreview.slice(0, 10)} + rowKey={(record, index) => index} + pagination={false} + size="small" + scroll={{ x: 1000 }} + /> + + + {importPreview.length > 10 && ( + + 仅显示前10条数据,共 {importPreview.length} 条 + + )} + > + )} + + {importing && ( + + + + + + + 正在导入 {importProgress.current} / {importProgress.total} 条数据... + + {importProgress.current > 0 && ( + + 预计剩余时间:{Math.ceil((importProgress.total - importProgress.current) / 5)} 秒 + + )} + + + + )} + + + + ); +} + +export default PortManagement; diff --git a/frontend/src/pages/RackVisualization.jsx b/frontend/src/pages/RackVisualization.jsx index 6e381e6..26d06ed 100644 --- a/frontend/src/pages/RackVisualization.jsx +++ b/frontend/src/pages/RackVisualization.jsx @@ -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() { > 字段配置 + } + onClick={() => setShowCables(!showCables)} + type={showCables ? 'primary' : 'default'} + > + {showCables ? '隐藏接线' : '显示接线'} + @@ -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() { {/* 机柜顶部 */} + {/* 接线连线层 */} + {showCables && cables.length > 0 && ( + + {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 ( + + + + + + ); + })} + + )} + {/* 机柜名称和设备数量 */} {/* 机柜标题 */} @@ -1897,6 +1998,18 @@ function RackVisualization() { )} + + {/* 设备详情抽屉 */} + { + setDetailDrawerVisible(false); + setSelectedDevice(null); + }} + cables={cables} + onRefreshCables={() => selectedRack?.rackId && fetchCablesForRack(selectedRack.rackId)} + /> ); diff --git a/package-lock.json b/package-lock.json index a4be28e..602f5a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8e0c174..956f197 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,8 @@ "license": "MIT", "devDependencies": { "concurrently": "^9.2.1" + }, + "dependencies": { + "papaparse": "^5.5.3" } }
eth0/1
gigabitethernet1/0/1
1/0/1-1/0/48
eth1-eth24
+ +
点击或拖拽文件到此处上传
支持 .xlsx, .xls, .csv 格式