diff --git a/backend/models/Cable.js b/backend/models/Cable.js index 3166171..5138d4e 100644 --- a/backend/models/Cable.js +++ b/backend/models/Cable.js @@ -53,6 +53,31 @@ const Cable = sequelize.define( type: DataTypes.TEXT, allowNull: true, }, + cableLabel: { + type: DataTypes.STRING, + allowNull: true, + comment: '线缆标签/编号', + }, + cableColor: { + type: DataTypes.STRING, + allowNull: true, + comment: '线缆颜色(便于识别)', + }, + installedBy: { + type: DataTypes.STRING, + allowNull: true, + comment: '安装人', + }, + installedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '安装时间', + }, + lastTestedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '上次测试时间', + }, }, { tableName: 'cables', @@ -63,6 +88,7 @@ const Cable = sequelize.define( { fields: ['status'] }, { fields: ['cableType'] }, { fields: ['sourceDeviceId', 'targetDeviceId'] }, + { fields: ['cableLabel'] }, ], } ); diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js index be3d0a8..cd39157 100644 --- a/backend/models/ConsumableLog.js +++ b/backend/models/ConsumableLog.js @@ -96,6 +96,36 @@ const ConsumableLog = sequelize.define( defaultValue: [], comment: '本次操作的SN序列号列表', }, + deviceId: { + type: DataTypes.STRING, + allowNull: true, + comment: '目标设备ID(出库时关联)', + }, + deviceName: { + type: DataTypes.STRING, + allowNull: true, + comment: '目标设备名称', + }, + rackId: { + type: DataTypes.STRING, + allowNull: true, + comment: '机柜ID', + }, + rackName: { + type: DataTypes.STRING, + allowNull: true, + comment: '机柜名称', + }, + roomId: { + type: DataTypes.STRING, + allowNull: true, + comment: '机房ID', + }, + roomName: { + type: DataTypes.STRING, + allowNull: true, + comment: '机房名称', + }, }, { tableName: 'consumable_logs', @@ -109,6 +139,7 @@ const ConsumableLog = sequelize.define( { fields: ['originalLogId'] }, { fields: ['isEditable'] }, { fields: ['isConsumableDeleted'] }, + { fields: ['deviceId'] }, ], } ); diff --git a/backend/routes/cables.js b/backend/routes/cables.js index 86807ba..3ebe75c 100644 --- a/backend/routes/cables.js +++ b/backend/routes/cables.js @@ -257,6 +257,11 @@ router.post('/', async (req, res) => { cableLength, status, description, + cableLabel, + cableColor, + installedBy, + installedAt, + lastTestedAt, force, } = req.body; @@ -321,6 +326,11 @@ router.post('/', async (req, res) => { cableLength, status: status || 'normal', description, + cableLabel: cableLabel || null, + cableColor: cableColor || null, + installedBy: installedBy || null, + installedAt: installedAt || null, + lastTestedAt: lastTestedAt || null, }); const createdCable = await Cable.findByPk(cable.cableId, { diff --git a/backend/routes/consumableImport.js b/backend/routes/consumableImport.js new file mode 100644 index 0000000..5b8f995 --- /dev/null +++ b/backend/routes/consumableImport.js @@ -0,0 +1,328 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const { sequelize } = require('../db'); +const Consumable = require('../models/Consumable'); +const ConsumableLog = require('../models/ConsumableLog'); +const { importJobManager } = require('../utils/importJobManager'); + +const SUPPORTED_FIELDS = [ + 'consumableId', + 'name', + 'category', + 'unit', + 'currentStock', + 'minStock', + 'maxStock', + 'unitPrice', + 'supplier', + 'location', + 'description', + 'snList', + 'status', +]; + +const FIELD_ALIASES = { + 耗材ID: 'consumableId', + 名称: 'name', + 分类: 'category', + 单位: 'unit', + 当前库存: 'currentStock', + 最小库存: 'minStock', + 最大库存: 'maxStock', + 单价: 'unitPrice', + 供应商: 'supplier', + 存放位置: 'location', + 描述: 'description', + SN序列号: 'snList', + 状态: 'status', +}; + +const normalizeFieldName = fieldName => { + if (!fieldName) return null; + const trimmed = String(fieldName).trim(); + if (FIELD_ALIASES[trimmed]) { + return FIELD_ALIASES[trimmed]; + } + if (SUPPORTED_FIELDS.includes(trimmed)) { + return trimmed; + } + return null; +}; + +const parseSnList = snStr => { + if (!snStr) return []; + if (Array.isArray(snStr)) return snStr; + if (typeof snStr === 'string') { + return snStr.split(/[,,;;\n]/).map(s => s.trim()).filter(Boolean); + } + return []; +}; + +router.post('/consumables/background', async (req, res) => { + const { items, operator = '系统', mode = 'create', fieldMapping = {} } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ error: '没有导入数据' }); + } + + const jobId = `IMP_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const job = importJobManager.createJob(jobId, 'consumable_import', items.length); + + importJobManager.startJob(jobId); + + setImmediate(async () => { + const transaction = await sequelize.transaction(); + try { + const results = { + success: 0, + failed: 0, + updated: 0, + skipped: 0, + errors: [], + details: [], + }; + + for (let i = 0; i < items.length; i++) { + const currentJob = importJobManager.getJob(jobId); + if (currentJob && currentJob.status === 'cancelled') { + await transaction.rollback(); + return; + } + + const item = items[i]; + const rowNumber = i + 1; + + try { + const mappedItem = {}; + for (const [sourceField, targetField] of Object.entries(fieldMapping)) { + if (sourceField && targetField && item[sourceField] !== undefined) { + mappedItem[targetField] = item[sourceField]; + } + } + + for (const [key, value] of Object.entries(item)) { + const normalizedField = normalizeFieldName(key); + if (normalizedField && mappedItem[normalizedField] === undefined) { + mappedItem[normalizedField] = value; + } + } + + const consumableId = + mappedItem.consumableId || mappedItem.name + `_${Date.now()}`; + const name = mappedItem.name; + const category = mappedItem.category; + + if (!name || !category) { + throw new Error('名称和分类为必填项'); + } + + let snList = []; + if (mappedItem.snList) { + snList = parseSnList(mappedItem.snList); + } + + const consumableData = { + consumableId: consumableId || `CON${Date.now()}${i}`, + name, + category, + unit: mappedItem.unit || '个', + currentStock: + snList.length > 0 + ? snList.length + : parseInt(mappedItem.currentStock) || 0, + minStock: parseInt(mappedItem.minStock) || 10, + maxStock: parseInt(mappedItem.maxStock) || 0, + unitPrice: parseFloat(mappedItem.unitPrice) || 0, + supplier: mappedItem.supplier || '', + location: mappedItem.location || '', + description: mappedItem.description || '', + status: mappedItem.status || 'active', + snList, + }; + + let existingConsumable = null; + if (consumableData.consumableId) { + existingConsumable = await Consumable.findByPk(consumableData.consumableId, { + transaction, + }); + } + + let operationType; + let previousStock = 0; + + if (existingConsumable) { + if (mode === 'update') { + previousStock = existingConsumable.currentStock; + await existingConsumable.update(consumableData, { transaction }); + operationType = 'import_update'; + results.updated++; + results.details.push({ + row: rowNumber, + status: 'updated', + consumableId: existingConsumable.consumableId, + name: existingConsumable.name, + }); + } else { + results.skipped++; + results.details.push({ + row: rowNumber, + status: 'skipped', + reason: '耗材已存在', + consumableId: consumableData.consumableId, + }); + importJobManager.incrementProgress(jobId, 0, 0, 1, 0); + continue; + } + } else { + await Consumable.create(consumableData, { transaction }); + operationType = 'import'; + results.success++; + results.details.push({ + row: rowNumber, + status: 'created', + consumableId: consumableData.consumableId, + name: consumableData.name, + }); + } + + await ConsumableLog.create( + { + consumableId: consumableData.consumableId, + consumableName: consumableData.name, + operationType, + quantity: consumableData.currentStock, + previousStock, + currentStock: consumableData.currentStock, + operator, + reason: '后台批量导入', + notes: existingConsumable ? '更新现有耗材' : '', + consumableSnapshot: { + category: consumableData.category, + unit: consumableData.unit, + unitPrice: consumableData.unitPrice, + supplier: consumableData.supplier, + location: consumableData.location, + minStock: consumableData.minStock, + maxStock: consumableData.maxStock, + }, + }, + { transaction } + ); + + importJobManager.incrementProgress(jobId, existingConsumable && mode === 'update' ? 0 : 1, 0, 0, existingConsumable ? 1 : 0); + } catch (error) { + results.failed++; + results.errors.push(`第 ${rowNumber} 行: ${error.message}`); + results.details.push({ + row: rowNumber, + status: 'failed', + error: error.message, + }); + importJobManager.incrementProgress(jobId, 0, 1, 0, 0); + } + } + + await transaction.commit(); + importJobManager.completeJob(jobId, results); + } catch (error) { + await transaction.rollback(); + importJobManager.failJob(jobId, error.message); + } + }); + + res.json({ + jobId, + message: '导入任务已创建,正在后台执行', + totalItems: items.length, + }); +}); + +router.get('/consumables/progress/:jobId', async (req, res) => { + const { jobId } = req.params; + + const progress = importJobManager.getJobProgress(jobId); + if (!progress) { + return res.status(404).json({ error: '任务不存在' }); + } + + res.json(progress); +}); + +router.post('/consumables/cancel/:jobId', async (req, res) => { + const { jobId } = req.params; + + const job = importJobManager.getJob(jobId); + if (!job) { + return res.status(404).json({ error: '任务不存在' }); + } + + if (!job.canCancel) { + return res.status(400).json({ error: '该任务无法取消' }); + } + + importJobManager.cancelJob(jobId); + res.json({ message: '任务已取消' }); +}); + +router.get('/consumables/result/:jobId', async (req, res) => { + const { jobId } = req.params; + + const job = importJobManager.getJob(jobId); + if (!job) { + return res.status(404).json({ error: '任务不存在' }); + } + + if (job.status !== 'completed' && job.status !== 'failed') { + return res.status(400).json({ error: '任务尚未完成' }); + } + + res.json({ + jobId: job.jobId, + status: job.status, + result: job.result, + error: job.error, + completedAt: job.endTime, + }); +}); + +router.get('/consumables/field-mappings', async (req, res) => { + const mappings = [ + { source: '耗材ID', target: 'consumableId', required: false, description: '耗材唯一标识符' }, + { source: '名称', target: 'name', required: true, description: '耗材名称' }, + { source: '分类', target: 'category', required: true, description: '耗材分类' }, + { source: '单位', target: 'unit', required: false, description: '计量单位,默认"个"' }, + { source: '当前库存', target: 'currentStock', required: false, description: '当前库存数量' }, + { source: '最小库存', target: 'minStock', required: false, description: '安全库存预警值' }, + { source: '最大库存', target: 'maxStock', required: false, description: '最大库存限制,0表示无限制' }, + { source: '单价', target: 'unitPrice', required: false, description: '耗材单价' }, + { source: '供应商', target: 'supplier', required: false, description: '供应商名称' }, + { source: '存放位置', target: 'location', required: false, description: '仓库内存放位置' }, + { source: '描述', target: 'description', required: false, description: '耗材详细描述' }, + { source: 'SN序列号', target: 'snList', required: false, description: '序列号列表,用逗号分隔' }, + { source: '状态', target: 'status', required: false, description: '状态:active启用,inactive停用' }, + ]; + + const systemFields = [ + { name: 'consumableId', type: 'string', description: '耗材唯一标识符' }, + { name: 'name', type: 'string', description: '耗材名称' }, + { name: 'category', type: 'string', description: '耗材分类' }, + { name: 'unit', type: 'string', description: '计量单位' }, + { name: 'currentStock', type: 'number', description: '当前库存数量' }, + { name: 'minStock', type: 'number', description: '最小库存(预警线)' }, + { name: 'maxStock', type: 'number', description: '最大库存限制' }, + { name: 'unitPrice', type: 'number', description: '单价' }, + { name: 'supplier', type: 'string', description: '供应商' }, + { name: 'location', type: 'string', description: '存放位置' }, + { name: 'description', type: 'text', description: '描述' }, + { name: 'snList', type: 'array', description: 'SN序列号数组' }, + { name: 'status', type: 'string', description: '状态' }, + ]; + + res.json({ + aliases: mappings, + systemFields, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index d6a6392..132fc52 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -459,7 +459,16 @@ router.post('/quick-inout', async (req, res) => { while (attempt < RETRY.MAX_RETRIES) { const transaction = await sequelize.transaction(); try { - const { consumableId, type, quantity, operator, reason, notes, snList } = req.body; + const { + consumableId, + type, + quantity, + operator, + reason, + notes, + snList, + deviceId, + } = req.body; const consumable = await Consumable.findByPk(consumableId, { transaction }); if (!consumable) { @@ -467,6 +476,39 @@ router.post('/quick-inout', async (req, res) => { return res.status(404).json({ error: '耗材不存在' }); } + let deviceInfo = {}; + if (deviceId && type === 'out') { + const Device = require('../models/Device'); + const Rack = require('../models/Rack'); + const Room = require('../models/Room'); + + const device = await Device.findByPk(deviceId, { transaction }); + if (device) { + deviceInfo = { + deviceId: device.deviceId, + deviceName: device.name, + rackId: device.rackId, + rackName: null, + roomId: null, + roomName: null, + }; + + if (device.rackId) { + const rack = await Rack.findByPk(device.rackId, { transaction }); + if (rack) { + deviceInfo.rackName = rack.name; + if (rack.roomId) { + const room = await Room.findByPk(rack.roomId, { transaction }); + if (room) { + deviceInfo.roomId = room.roomId; + deviceInfo.roomName = room.name; + } + } + } + } + } + } + const previousStock = parseFloat(consumable.currentStock); let newStock; const currentSnList = consumable.snList || []; @@ -554,6 +596,12 @@ router.post('/quick-inout', async (req, res) => { notes, isEditable: false, snList: operationSnList, + deviceId: deviceInfo.deviceId || null, + deviceName: deviceInfo.deviceName || null, + rackId: deviceInfo.rackId || null, + rackName: deviceInfo.rackName || null, + roomId: deviceInfo.roomId || null, + roomName: deviceInfo.roomName || null, consumableSnapshot: { category: consumable.category, unit: consumable.unit, @@ -571,6 +619,7 @@ router.post('/quick-inout', async (req, res) => { message: '操作成功', record, consumable: await Consumable.findByPk(consumableId), + deviceInfo: Object.keys(deviceInfo).length > 0 ? deviceInfo : null, }); return; } catch (error) { @@ -589,7 +638,18 @@ router.post('/inout', async (req, res) => { while (attempt < RETRY.MAX_RETRIES) { const transaction = await sequelize.transaction(); try { - const { consumableId, type, quantity, operator, reason, recipient, notes, snList } = req.body; + const { + consumableId, + type, + quantity, + operator, + reason, + recipient, + notes, + snList, + deviceId, + deviceName, + } = req.body; const consumable = await Consumable.findByPk(consumableId, { transaction }); if (!consumable) { @@ -597,6 +657,39 @@ router.post('/inout', async (req, res) => { return res.status(404).json({ error: '耗材不存在' }); } + let deviceInfo = {}; + if (deviceId && type === 'out') { + const Device = require('../models/Device'); + const Rack = require('../models/Rack'); + const Room = require('../models/Room'); + + const device = await Device.findByPk(deviceId, { transaction }); + if (device) { + deviceInfo = { + deviceId: device.deviceId, + deviceName: device.name, + rackId: device.rackId, + rackName: null, + roomId: null, + roomName: null, + }; + + if (device.rackId) { + const rack = await Rack.findByPk(device.rackId, { transaction }); + if (rack) { + deviceInfo.rackName = rack.name; + if (rack.roomId) { + const room = await Room.findByPk(rack.roomId, { transaction }); + if (room) { + deviceInfo.roomId = room.roomId; + deviceInfo.roomName = room.name; + } + } + } + } + } + } + const previousStock = parseFloat(consumable.currentStock); let newStock; const currentSnList = consumable.snList || []; @@ -682,6 +775,12 @@ router.post('/inout', async (req, res) => { notes, isEditable: false, snList: operationSnList, + deviceId: deviceInfo.deviceId || null, + deviceName: deviceInfo.deviceName || null, + rackId: deviceInfo.rackId || null, + rackName: deviceInfo.rackName || null, + roomId: deviceInfo.roomId || null, + roomName: deviceInfo.roomName || null, consumableSnapshot: { category: consumable.category, unit: consumable.unit, @@ -699,6 +798,7 @@ router.post('/inout', async (req, res) => { message: '操作成功', record, consumable: await Consumable.findByPk(consumableId), + deviceInfo: Object.keys(deviceInfo).length > 0 ? deviceInfo : null, }); return; } catch (error) { @@ -1314,4 +1414,97 @@ router.get('/logs/:id/history', async (req, res) => { } }); +router.get('/devices/search', async (req, res) => { + try { + const { keyword, limit = 20 } = req.query; + + if (!keyword) { + return res.json({ devices: [] }); + } + + const Device = require('../models/Device'); + const Rack = require('../models/Rack'); + const Room = require('../models/Room'); + + const escapedKeyword = keyword.replace(/'/g, "''"); + + const devices = await Device.findAll({ + where: { + [Op.or]: [ + { deviceId: { [Op.like]: `%${escapedKeyword}%` } }, + { name: { [Op.like]: `%${escapedKeyword}%` } }, + { serialNumber: { [Op.like]: `%${escapedKeyword}%` } }, + ], + }, + include: [ + { + model: Rack, + as: 'rack', + include: [{ model: Room, as: 'room' }], + }, + ], + limit: parseInt(limit), + order: [['name', 'ASC']], + }); + + const result = devices.map(device => ({ + deviceId: device.deviceId, + name: device.name, + type: device.type, + model: device.model, + serialNumber: device.serialNumber, + status: device.status, + location: device.rack + ? { + rackId: device.rack.rackId, + rackName: device.rack.name, + roomId: device.rack.room ? device.rack.room.roomId : null, + roomName: device.rack.room ? device.rack.room.name : null, + } + : null, + })); + + res.json({ devices: result }); + } catch (error) { + console.error('搜索设备失败:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/devices/by-sn/:sn', async (req, res) => { + try { + const { sn } = req.params; + const Device = require('../models/Device'); + + const device = await Device.findOne({ + where: { + [Op.or]: [ + { deviceId: { [Op.like]: `%${sn}%` } }, + { serialNumber: { [Op.like]: `%${sn}%` } }, + { name: { [Op.like]: `%${sn}%` } }, + ], + }, + }); + + if (!device) { + return res.json({ found: false, device: null }); + } + + res.json({ + found: true, + device: { + deviceId: device.deviceId, + name: device.name, + type: device.type, + model: device.model, + serialNumber: device.serialNumber, + status: device.status, + }, + }); + } catch (error) { + console.error('查询设备失败:', error); + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 5817ce3..9bfa221 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -100,6 +100,11 @@ const migrations = [ description: '为 devices 表添加复合索引,优化位置冲突检测和悲观锁性能', migrate: migrateDevicePositionIndexes, }, + { + name: '耗材日志设备关联', + description: '为 consumable_logs 表添加 deviceId、deviceName、rackId、rackName、roomId、roomName 字段', + migrate: migrateConsumableLogDeviceAssociation, + }, ]; async function runMigrations() { @@ -738,37 +743,68 @@ async function migrateDevicePositionIndexes() { return; } - console.log(` → 为 ${tableName} 表添加复合索引 rackId_position...`); - try { - if (dialect === 'sqlite') { - await sequelize.query(`CREATE INDEX IF NOT EXISTS devices_rackId_position ON ${tableName}(rackId, position)`); - } else { - await sequelize.query(`CREATE INDEX IF NOT EXISTS \`devices_rackId_position\` ON \`${tableName}\`(\`rackId\`, \`position\`)`); - } - console.log(' ✓ 索引创建成功'); - } catch (error) { - if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) { - console.log(' → 索引已存在,跳过'); - } else { - throw error; + const indexesToCreate = [ + { name: 'devices_rackId_position', fields: ['rackId', 'position'] }, + { name: 'devices_rackId_position_isIdle', fields: ['rackId', 'position', 'isIdle'] }, + ]; + + for (const idx of indexesToCreate) { + console.log(` → 为 ${tableName} 表添加复合索引 ${idx.name}...`); + try { + const existingIndexes = await sequelize.query(`SHOW INDEX FROM ${tableName}`, { + type: sequelize.QueryTypes.SELECT, + }); + const indexExists = existingIndexes.some( + existing => existing.Key_name === idx.name + ); + + if (indexExists) { + console.log(' → 索引已存在,跳过'); + } else { + if (dialect === 'sqlite') { + await sequelize.query( + `CREATE INDEX IF NOT EXISTS ${idx.name} ON ${tableName}(${idx.fields.join(', ')})` + ); + } else { + await sequelize.query( + `CREATE INDEX \`${idx.name}\` ON \`${tableName}\`(\`${idx.fields.join('`, `')}\`)` + ); + } + console.log(' ✓ 索引创建成功'); + } + } catch (error) { + if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) { + console.log(' → 索引已存在,跳过'); + } else { + throw error; + } } } +} + +async function migrateConsumableLogDeviceAssociation() { + const tableName = 'consumable_logs'; + + if (!(await tableExists(tableName))) { + console.log(` ${tableName} 表不存在,跳过`); + return; + } - console.log(` → 为 ${tableName} 表添加复合索引 rackId_position_isIdle...`); - try { - if (dialect === 'sqlite') { - await sequelize.query(`CREATE INDEX IF NOT EXISTS devices_rackId_position_isIdle ON ${tableName}(rackId, position, isIdle)`); - } else { - await sequelize.query(`CREATE INDEX IF NOT EXISTS \`devices_rackId_position_isIdle\` ON \`${tableName}\`(\`rackId\`, \`position\`, \`isIdle\`)`); - } - console.log(' ✓ 索引创建成功'); - } catch (error) { - if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) { - console.log(' → 索引已存在,跳过'); - } else { - throw error; - } + const columns = await getTableColumns(tableName); + const newColumns = [ + { name: 'deviceId', def: 'VARCHAR(255)' }, + { name: 'deviceName', def: 'VARCHAR(255)' }, + { name: 'rackId', def: 'VARCHAR(255)' }, + { name: 'rackName', def: 'VARCHAR(255)' }, + { name: 'roomId', def: 'VARCHAR(255)' }, + { name: 'roomName', def: 'VARCHAR(255)' }, + ]; + + for (const col of newColumns) { + await addColumnIfNotExists(tableName, col.name, col.def); } + + console.log(' 耗材日志设备关联迁移完成'); } // 执行迁移 diff --git a/backend/scripts/migrate-cable-fields.js b/backend/scripts/migrate-cable-fields.js new file mode 100644 index 0000000..dde107c --- /dev/null +++ b/backend/scripts/migrate-cable-fields.js @@ -0,0 +1,42 @@ +const { sequelize } = require('../db'); +const Cable = require('../models/Cable'); + +async function migrateCableFields() { + try { + console.log('开始迁移 Cable 模型字段...'); + + // 检查字段是否已存在 + const [results] = await sequelize.query('PRAGMA table_info(cables)'); + const existingColumns = results.map(row => row.name); + + const newColumns = [ + { name: 'cableLabel', type: 'VARCHAR(255)' }, + { name: 'cableColor', type: 'VARCHAR(50)' }, + { name: 'installedBy', type: 'VARCHAR(100)' }, + { name: 'installedAt', type: 'DATETIME' }, + { name: 'lastTestedAt', type: 'DATETIME' }, + ]; + + for (const column of newColumns) { + if (!existingColumns.includes(column.name)) { + console.log(`添加字段: ${column.name}`); + await sequelize.query(`ALTER TABLE cables ADD COLUMN ${column.name} ${column.type}`); + } else { + console.log(`字段已存在: ${column.name}`); + } + } + + console.log('迁移完成!'); + console.log('\n新增字段:'); + console.log(' - cableLabel: 线缆标签/编号'); + console.log(' - cableColor: 线缆颜色(便于识别)'); + console.log(' - installedBy: 安装人'); + console.log(' - installedAt: 安装时间'); + console.log(' - lastTestedAt: 上次测试时间'); + } catch (error) { + console.error('迁移失败:', error); + process.exit(1); + } +} + +migrateCableFields(); diff --git a/backend/server.js b/backend/server.js index 778eca4..7068a12 100644 --- a/backend/server.js +++ b/backend/server.js @@ -251,6 +251,9 @@ const operationLogsRoutes = require('./routes/operationLogs'); const idleDeviceRoutes = require('./routes/idleDevices'); const warehouseRoutes = require('./routes/warehouses'); const dangerousOperationsRoutes = require('./routes/dangerousOperations'); +const consumableImportRoutes = require('./routes/consumableImport'); + +app.use('/api', consumableImportRoutes); app.use('/api/devices', deviceRoutes); app.use('/api/racks', rackRoutes); diff --git a/backend/utils/importJobManager.js b/backend/utils/importJobManager.js new file mode 100644 index 0000000..449a65b --- /dev/null +++ b/backend/utils/importJobManager.js @@ -0,0 +1,174 @@ +const { EventEmitter } = require('events'); +const path = require('path'); +const fs = require('fs'); + +class ImportJobManager extends EventEmitter { + constructor() { + super(); + this.jobs = new Map(); + this.JOB_STATUS = { + PENDING: 'pending', + PROCESSING: 'processing', + COMPLETED: 'completed', + FAILED: 'failed', + CANCELLED: 'cancelled', + }; + } + + createJob(jobId, type, totalItems) { + const job = { + jobId, + type, + status: this.JOB_STATUS.PENDING, + totalItems, + processedItems: 0, + successCount: 0, + failedCount: 0, + skippedCount: 0, + updatedCount: 0, + startTime: null, + endTime: null, + error: null, + canCancel: true, + result: null, + createdAt: new Date(), + }; + this.jobs.set(jobId, job); + return job; + } + + getJob(jobId) { + return this.jobs.get(jobId); + } + + updateJob(jobId, updates) { + const job = this.jobs.get(jobId); + if (!job) return null; + + Object.assign(job, updates); + this.emit('jobUpdate', job); + return job; + } + + startJob(jobId) { + const job = this.jobs.get(jobId); + if (!job) return null; + + job.status = this.JOB_STATUS.PROCESSING; + job.startTime = new Date(); + this.emit('jobStart', job); + return job; + } + + incrementProgress(jobId, success = 0, failed = 0, skipped = 0, updated = 0) { + const job = this.jobs.get(jobId); + if (!job) return null; + + job.processedItems += success + failed + skipped + updated; + job.successCount += success; + job.failedCount += failed; + job.skippedCount += skipped; + job.updatedCount += updated; + this.emit('jobProgress', job); + return job; + } + + completeJob(jobId, result = null) { + const job = this.jobs.get(jobId); + if (!job) return null; + + job.status = this.JOB_STATUS.COMPLETED; + job.endTime = new Date(); + job.canCancel = false; + job.result = result; + this.emit('jobComplete', job); + return job; + } + + failJob(jobId, error) { + const job = this.jobs.get(jobId); + if (!job) return null; + + job.status = this.JOB_STATUS.FAILED; + job.endTime = new Date(); + job.canCancel = false; + job.error = error; + this.emit('jobFailed', job); + return job; + } + + cancelJob(jobId) { + const job = this.jobs.get(jobId); + if (!job || !job.canCancel) return null; + + job.status = this.JOB_STATUS.CANCELLED; + job.endTime = new Date(); + job.canCancel = false; + this.emit('jobCancelled', job); + return job; + } + + getJobProgress(jobId) { + const job = this.jobs.get(jobId); + if (!job) return null; + + const progress = { + jobId: job.jobId, + status: job.status, + totalItems: job.totalItems, + processedItems: job.processedItems, + progressPercent: + job.totalItems > 0 ? Math.round((job.processedItems / job.totalItems) * 100) : 0, + successCount: job.successCount, + failedCount: job.failedCount, + skippedCount: job.skippedCount, + updatedCount: job.updatedCount, + canCancel: job.canCancel, + error: job.error, + startTime: job.startTime, + endTime: job.endTime, + elapsedTime: job.startTime + ? Date.now() - new Date(job.startTime).getTime() + : null, + }; + + return progress; + } + + listJobs() { + return Array.from(this.jobs.values()).map(job => ({ + jobId: job.jobId, + type: job.type, + status: job.status, + totalItems: job.totalItems, + processedItems: job.processedItems, + progressPercent: + job.totalItems > 0 ? Math.round((job.processedItems / job.totalItems) * 100) : 0, + canCancel: job.canCancel, + startTime: job.startTime, + endTime: job.endTime, + createdAt: job.createdAt, + })); + } + + cleanupOldJobs(maxAgeMs = 24 * 60 * 60 * 1000) { + const now = Date.now(); + for (const [jobId, job] of this.jobs.entries()) { + const jobEndTime = job.endTime || job.createdAt; + if (now - new Date(jobEndTime).getTime() > maxAgeMs) { + this.jobs.delete(jobId); + } + } + } +} + +const importJobManager = new ImportJobManager(); + +setInterval(() => { + importJobManager.cleanupOldJobs(); +}, 60 * 60 * 1000); + +module.exports = { + importJobManager, + ImportJobManager, +}; \ No newline at end of file diff --git a/docs/CABLE_WIZARD_GUIDE.md b/docs/CABLE_WIZARD_GUIDE.md new file mode 100644 index 0000000..e1dea88 --- /dev/null +++ b/docs/CABLE_WIZARD_GUIDE.md @@ -0,0 +1,186 @@ +# 向导式接线创建功能使用指南 + +## 功能概述 + +新的向导式接线创建功能提供了一种直观、交互式的接线创建流程,帮助用户更高效地管理设备间的物理连接。 + +## 核心特性 + +### 1. 四步向导流程 + +#### 步骤 1: 选择源设备 +- 搜索并选择接线的起点设备 +- 支持设备类型过滤(服务器、交换机、存储设备) +- 点击设备卡片快速选择 + +#### 步骤 2: 选择目标设备 +- 搜索并选择接线的终点设备 +- 实时端口冲突检测 +- 高亮显示已占用端口 + +#### 步骤 3: 线缆配置 +- **线缆类型选择**: + - 🌐 以太网线(适用于1G/10G短距离连接) + - 🔦 光纤(适用于长距离或高带宽需求) + - 🔌 铜缆(适用于电源或特殊设备连接) + +- **线缆长度选择**: + - 提供常用长度选项(1m, 2m, 3m, 5m, 7m, 10m, 15m, 20m, 30m, 50m) + - **自动计算建议长度**:根据源/目标设备的机柜位置自动估算所需长度 + +- **线缆属性设置**: + - 自定义线缆标签(支持自动生成或手动输入) + - 添加备注说明 + +#### 步骤 4: 预览确认 +- 全面展示接线信息 +- 可视化连接预览 +- 确认创建接线 + +### 2. 端口可视化面板 + +- **端口状态颜色编码**: + - 🟦 灰色:空闲端口 + - 🟩 绿色:已连接端口 + - 🟥 红色:故障端口 + +- **端口信息提示**: + - 悬停显示端口详细信息 + - 显示端口类型、速率、VLAN + - 显示已连接的对端设备信息 + +- **智能端口选择**: + - 仅显示可用端口 + - 自动过滤已占用端口 + - 冲突端口高亮显示 + +### 3. 冲突检测 + +- 实时检测端口占用情况 +- 显示冲突的详细信息 +- 提供解决方案建议 + +## 使用方法 + +### 从设备管理页面创建接线 + +1. 进入**设备管理**页面 +2. 在交换机设备卡片上找到 **"添加接线"** 按钮 +3. 点击按钮,打开向导式创建界面 +4. 按照四步向导完成接线创建 + +### 从机柜3D视图创建接线 + +1. 进入**机柜3D可视化**页面 +2. 在3D场景中选择源设备 +3. 点击设备上的端口 +4. 选择目标设备的端口 +5. 确认接线配置 + +## 新增字段说明 + +### Cable 模型新增字段 + +| 字段名 | 类型 | 说明 | 示例 | +|--------|------|------|------| +| `cableLabel` | VARCHAR(255) | 线缆标签/编号 | CABLE-001-002-1234 | +| `cableColor` | VARCHAR(50) | 线缆颜色 | "红色", "蓝色", "黄色" | +| `installedBy` | VARCHAR(100) | 安装人 | "张三" | +| `installedAt` | DATETIME | 安装时间 | "2024-01-15 10:30:00" | +| `lastTestedAt` | DATETIME | 上次测试时间 | "2024-01-15 11:00:00" | + +## 技术实现 + +### 前端组件 + +- **CableWizardModal**: 向导式接线创建主组件 +- **PortPanel**: 端口可视化面板 +- **framer-motion**: 动画效果 + +### 后端 API + +- `POST /api/cables`: 创建接线(支持新字段) +- `POST /api/cables/check-conflict`: 检查端口冲突 +- `GET /api/device-ports/device/:deviceId`: 获取设备端口列表 + +### 数据库迁移 + +运行以下命令添加新字段: + +```bash +cd backend +node scripts/migrate-cable-fields.js +``` + +## 最佳实践 + +### 1. 线缆标签规范 + +建议使用以下格式生成线缆标签: + +``` +CABLE-{源设备ID后4位}-{目标设备ID后4位}-{时间戳后4位} +``` + +示例: +- `CABLE-0001-0002-1234` +- `CABLE-SRV01-SW01-5678` + +### 2. 线缆长度估算 + +系统会根据设备所在机柜的位置自动估算所需长度: + +``` +建议长度 = |源机柜ID - 目标机柜ID| × 0.5 + 3 +``` + +最小长度:3m +最大长度:50m + +### 3. 端口选择建议 + +- 优先选择相邻的端口(便于管理) +- 同一设备的端口尽量连续分配 +- 考虑线缆走线路径,避免交叉 + +## 常见问题 + +### Q1: 如何查看已创建的接线? + +**A**: 在接线管理页面可以查看所有接线,支持按状态、类型、设备等条件筛选。 + +### Q2: 如何编辑已创建的接线? + +**A**: 点击接线记录的"编辑"按钮,修改后保存即可。 + +### Q3: 如何删除接线? + +**A**: 点击接线记录的"删除"按钮,确认后删除。 + +### Q4: 端口冲突如何解决? + +**A**: 系统会高亮显示冲突端口,建议选择其他空闲端口或先删除原有连接。 + +## 未来扩展 + +### 计划功能 + +1. **批量接线创建** + - 支持 Excel 批量导入 + - 支持模板下载 + +2. **3D 线缆渲染** + - 在 3D 场景中显示线缆走向 + - 支持线缆路径追踪 + +3. **接线统计报表** + - 按类型/长度/状态统计 + - 导出 Excel 报表 + +4. **工单联动** + - 接线操作与工单系统集成 + - 支持变更审批流程 + +## 技术支持 + +如有问题,请联系系统管理员或查看项目文档。 diff --git a/frontend/src/components/CableWizardModal.jsx b/frontend/src/components/CableWizardModal.jsx new file mode 100644 index 0000000..4db78e7 --- /dev/null +++ b/frontend/src/components/CableWizardModal.jsx @@ -0,0 +1,1498 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Modal, + Steps, + Button, + Space, + Spin, + Card, + Tag, + Tooltip, + message, + Typography, + Divider, + Badge, + Empty, + Input, +} from 'antd'; +import { + SwapOutlined, + CloudServerOutlined, + DatabaseOutlined, + SettingOutlined, + CheckCircleOutlined, + ArrowRightOutlined, + PlusOutlined, + SearchOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import axios from 'axios'; +import CloseButton from './CloseButton'; +import PortPanel from './PortPanel'; +import { designTokens } from '../config/theme'; + +const { Title, Text } = Typography; + +const CABLE_TYPES = [ + { value: 'ethernet', label: '以太网线', color: '#52c41a', icon: '🌐' }, + { value: 'fiber', label: '光纤', color: '#1890ff', icon: '🔦' }, + { value: 'copper', label: '铜缆', color: '#faad14', icon: '🔌' }, +]; + +const CABLE_LENGTHS = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50]; + +const getStatusTag = (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 CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, editingCable }) => { + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [devices, setDevices] = useState([]); + const [sourceDevice, setSourceDevice] = useState(null); + const [targetDevice, setTargetDevice] = useState(null); + const [sourcePort, setSourcePort] = useState(null); + const [targetPort, setTargetPort] = useState(null); + const [selectedCableType, setSelectedCableType] = useState('ethernet'); + const [selectedCableLength, setSelectedCableLength] = useState(3); + const [cableLabel, setCableLabel] = useState(''); + const [cableDescription, setCableDescription] = useState(''); + const [fetchingDevices, setFetchingDevices] = useState(false); + const [sourcePorts, setSourcePorts] = useState([]); + const [targetPorts, setTargetPorts] = useState([]); + const [conflicts, setConflicts] = useState([]); + const [cablesData, setCablesData] = useState([]); + + // 初始化编辑模式的数据 + useEffect(() => { + if (editingCable && visible) { + setSourceDevice({ deviceId: editingCable.sourceDeviceId, name: editingCable.sourceDeviceId }); + setTargetDevice({ deviceId: editingCable.targetDeviceId, name: editingCable.targetDeviceId }); + setSourcePort({ portName: editingCable.sourcePort }); + setTargetPort({ portName: editingCable.targetPort }); + setSelectedCableType(editingCable.cableType || 'ethernet'); + setSelectedCableLength(editingCable.cableLength || 3); + setCableLabel(editingCable.cableLabel || ''); + setCableDescription(editingCable.description || ''); + setCurrentStep(3); // 直接跳转到预览步骤 + } + }, [editingCable, visible]); + + const fetchDevices = useCallback(async (keyword = '', type = '') => { + try { + setFetchingDevices(true); + const params = { pageSize: 100 }; + if (keyword && keyword.trim()) { + params.keyword = keyword.trim(); + } + if (type && type.trim()) { + params.type = type.trim(); + } + const response = await axios.get('/api/devices', { params }); + const deviceList = response.data.devices || []; + setDevices(deviceList); + return deviceList; + } catch (error) { + console.error('获取设备列表失败:', error); + message.error('获取设备列表失败'); + return []; + } finally { + setFetchingDevices(false); + } + }, []); + + const fetchDevicePorts = useCallback(async (deviceId, type) => { + console.log('fetchDevicePorts called for:', deviceId, type); + if (!deviceId) return; + + try { + const response = await axios.get(`/api/device-ports/device/${deviceId}`); + const ports = response.data || []; + console.log('Fetched ports:', ports); + if (type === 'source') { + setSourcePorts(ports); + } else { + setTargetPorts(ports); + } + } catch (error) { + console.error(`获取端口列表失败: ${deviceId}`, error); + message.error('获取端口列表失败'); + } + }, []); + + const fetchCables = useCallback(async () => { + try { + const response = await axios.get('/api/cables'); + setCablesData(response.data.cables || []); + } catch (error) { + console.error('获取接线数据失败:', error); + } + }, []); + + const checkPortConflict = useCallback(async (deviceId, portName, excludeCableId = null) => { + try { + const response = await axios.post('/api/cables/check-conflict', { + sourceDeviceId: deviceId, + sourcePort: portName, + excludeCableId, + }); + return response.data; + } catch (error) { + console.error('检查端口冲突失败:', error); + return { hasConflict: false, conflicts: [] }; + } + }, []); + + const debounce = (fn, delay) => { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; + }; + + const handleDeviceSearch = useCallback( + debounce((value) => { + fetchDevices(value, 'switch'); + }, 300), + [fetchDevices] + ); + + useEffect(() => { + if (visible) { + setCurrentStep(0); + setSourceDevice(null); + setTargetDevice(null); + setSourcePort(null); + setTargetPort(null); + setSelectedCableType('ethernet'); + setSelectedCableLength(3); + setCableLabel(''); + setCableDescription(''); + setSourcePorts([]); + setTargetPorts([]); + setConflicts([]); + setDevices([]); + setCablesData([]); + + fetchDevices('', 'switch').then(deviceList => { + if (initialSourceDevice?.deviceId) { + const device = deviceList.find(d => d.deviceId === initialSourceDevice.deviceId); + if (device) { + setSourceDevice(device); + fetchDevicePorts(device.deviceId, 'source'); + } + } + }); + fetchCables(); + } + }, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]); + + const handleSourceDeviceSelect = useCallback(async device => { + setSourceDevice(device); + setSourcePort(null); + await fetchDevicePorts(device.deviceId, 'source'); + }, [fetchDevicePorts]); + + const handleTargetDeviceSelect = useCallback(async device => { + setTargetDevice(device); + setTargetPort(null); + setConflicts([]); + await fetchDevicePorts(device.deviceId, 'target'); + }, [fetchDevicePorts]); + + const handleSourcePortSelect = useCallback(async port => { + console.log('handleSourcePortSelect called with port:', port); + + // 如果点击的是已选中的端口,则取消选择 + const isAlreadySelected = + sourcePort?.portId === port.portId || sourcePort?.portName === port.portName; + + if (isAlreadySelected) { + console.log('Deselecting source port:', port); + setSourcePort(null); + setConflicts([]); + } else { + // 只有在选择新端口时才进行设备相同性检查 + if (sourceDevice?.deviceId === targetDevice?.deviceId) { + message.warning('源设备和目标设备不能相同'); + return; + } + + setSourcePort(port); + console.log('Source port set to:', port); + + if (targetDevice) { + await checkPortConflict(targetDevice.deviceId, port.portName); + } + } + }, [sourceDevice, targetDevice, sourcePort, checkPortConflict]); + + const handleTargetPortSelect = useCallback(async port => { + console.log('handleTargetPortSelect called with port:', port); + + // 如果点击的是已选中的端口,则取消选择 + const isAlreadySelected = + targetPort?.portId === port.portId || targetPort?.portName === port.portName; + + if (isAlreadySelected) { + console.log('Deselecting target port:', port); + setTargetPort(null); + setConflicts([]); + } else { + // 只有在选择新端口时才进行设备相同性检查 + if (sourceDevice?.deviceId === targetDevice?.deviceId) { + message.warning('源设备和目标设备不能相同'); + return; + } + + setTargetPort(port); + console.log('Target port set to:', port); + await checkPortConflict(sourceDevice.deviceId, port.portName); + } + }, [sourceDevice, targetDevice, targetPort, checkPortConflict]); + + const handleNextStep = useCallback(async () => { + if (currentStep === 0) { + if (!sourceDevice) { + message.warning('请先选择源设备'); + return; + } + if (!sourcePort) { + message.warning('请先选择源设备端口'); + return; + } + if (sourcePorts.length === 0) { + message.warning('源设备没有可用端口'); + return; + } + } else if (currentStep === 1) { + if (!targetDevice) { + message.warning('请先选择目标设备'); + return; + } + if (!targetPort) { + message.warning('请先选择目标设备端口'); + return; + } + if (targetPorts.length === 0) { + message.warning('目标设备没有可用端口'); + return; + } + } else if (currentStep === 2) { + if (!sourcePort || !targetPort) { + message.warning('请先选择源端口和目标端口'); + return; + } + if (sourcePort.portName === targetPort.portName && sourceDevice?.deviceId === targetDevice?.deviceId) { + message.warning('源端口和目标端口不能相同'); + return; + } + } + + setCurrentStep(prev => prev + 1); + }, [currentStep, sourceDevice, targetDevice, sourcePort, targetPort, sourcePorts, targetPorts]); + + const handlePrevStep = useCallback(() => { + setCurrentStep(prev => Math.max(0, prev - 1)); + }, []); + + const handleSubmit = useCallback(async () => { + try { + setLoading(true); + + const payload = { + cableId: cableLabel || `CABLE-${Date.now()}`, + sourceDeviceId: sourceDevice.deviceId, + sourcePort: sourcePort.portName, + targetDeviceId: targetDevice.deviceId, + targetPort: targetPort.portName, + cableType: selectedCableType, + cableLength: selectedCableLength, + description: cableDescription, + }; + + // 如果是编辑模式,使用 PUT 请求 + if (editingCable) { + await axios.put(`/api/cables/${editingCable.cableId}`, payload); + message.success('接线更新成功'); + } else { + await axios.post('/api/cables', payload); + message.success('接线创建成功'); + } + + onSuccess?.(); + onClose(); + } catch (error) { + console.error('操作接线失败:', error); + if (error.response?.data?.conflict) { + message.error('端口已被占用,请选择其他端口'); + } else { + message.error(editingCable ? '接线更新失败' : '接线创建失败'); + } + } finally { + setLoading(false); + } + }, [ + sourceDevice, + sourcePort, + targetDevice, + targetPort, + selectedCableType, + selectedCableLength, + cableLabel, + cableDescription, + editingCable, + onSuccess, + onClose, + ]); + + const getAvailablePorts = useCallback( + (ports, type) => { + return ports.filter(port => { + if (port.status !== 'free') return false; + + if (type === 'source') { + return !conflicts.some(c => c.type === 'target' && c.port === port.portName); + } else { + return !conflicts.some(c => c.type === 'source' && c.port === port.portName); + } + }); + }, + [conflicts] + ); + + const getRecommendedPorts = useCallback( + (availablePorts, type) => { + if (availablePorts.length === 0) return []; + + const sortedPorts = [...availablePorts].sort((a, b) => { + const extractNumbers = str => { + const matches = str.match(/\d+/g); + return matches ? matches.map(Number) : []; + }; + + const numsA = extractNumbers(a.portName); + const numsB = extractNumbers(b.portName); + + for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) { + if (numsA[i] !== numsB[i]) { + return numsA[i] - numsB[i]; + } + } + + return a.portName.localeCompare(b.portName); + }); + + return sortedPorts.slice(0, 5); + }, + [] + ); + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + + ); + + case 1: + return ( + + ); + + case 2: + return ( + + ); + + case 3: + return ( + c.value === selectedCableType)} + cableLength={selectedCableLength} + cableLabel={cableLabel} + cableDescription={cableDescription} + /> + ); + + default: + return null; + } + }; + + const steps = [ + { + title: '源设备', + icon: , + description: '选择连接起点', + }, + { + title: '目标设备', + icon: , + description: '选择连接终点', + }, + { + title: '线缆配置', + icon: , + description: '设置线缆参数', + }, + { + title: '预览确认', + icon: , + description: '确认创建接线', + }, + ]; + + return ( + + +
+ + 新增接线 + + + 向导式创建流程 + +
+ + } + open={visible} + closeIcon={} + onCancel={onClose} + width={1000} + maskClosable={false} + footer={null} + > +
+ +
+ +
+ + + {renderStepContent()} + + +
+ +
+ + + + {currentStep > 0 && ( + + )} + + {currentStep < steps.length - 1 ? ( + + ) : ( + + )} + +
+
+ ); +}; + +const Step1SourceDevice = ({ + devices, + fetchingDevices, + onDeviceSearch, + onDeviceSelect, + sourceDevice, + sourcePorts, + sourcePort, + onPortSelect, +}) => { + const [searchKeyword, setSearchKeyword] = useState(''); + + const handleSearch = (e) => { + const value = e.target.value; + setSearchKeyword(value); + onDeviceSearch?.(value); + }; + + return ( +
+ + + 步骤 1: 选择源设备 + + } + size="small" + style={{ marginBottom: '20px' }} + > +
+ + 选择接线的起点设备 + +
+ +
+ } + value={searchKeyword} + onChange={handleSearch} + style={{ width: '100%' }} + /> +
+ +
+ {fetchingDevices ? ( +
+ +
+ ) : ( + devices.map(device => ( + + onDeviceSelect(device)} + hoverable + style={{ + border: sourceDevice?.deviceId === device.deviceId + ? `2px solid ${designTokens.colors.primary}` + : '1px solid #d9d9d9', + background: sourceDevice?.deviceId === device.deviceId + ? 'rgba(24,144,255,0.08)' + : '#fff', + }} + > +
+
+ {device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'} +
+
+
+
+ {device.name || device.deviceId} +
+ {device.status && getStatusTag(device.status)} +
+
+ + ID: + {device.deviceId} + + + 类型: + {device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type} + +
+
+ {device.Rack?.Room?.name && ( + + 机房: + {device.Rack.Room.name} + + )} + {device.Rack?.name && ( + + 机柜: + {device.Rack.name} + + )} + {device.position && ( + + U位: + U{device.position} + + )} + {device.ipAddress && ( + + IP: + {device.ipAddress} + + )} +
+ {device.model && ( +
+ 型号: + {device.model} +
+ )} +
+ {sourceDevice?.deviceId === device.deviceId && ( + + )} +
+
+
+ )) + )} +
+ + {sourceDevice && ( + +
+ + 源设备已选择: {sourceDevice.name} + +
+ +
+
+ + 端口选择 + + {sourcePorts.filter(p => p.status === 'free').length} 个空闲端口 + +
+ + {sourcePorts.length === 0 ? ( + + ) : ( + <> +
+ 端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length} +
+
+ 端口状态分布: {sourcePorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')} +
+ {sourcePort && ( +
+ +
+
+ ✓ 已选择端口: {sourcePort.portName} +
+ {sourcePort.portType && ( +
+ 端口类型: {sourcePort.portType} +
+ )} + {sourcePort.portSpeed && ( +
+ 端口速率: {sourcePort.portSpeed} +
+ )} +
+
+ )} + + + )} +
+
+ )} +
+
+ ); +}; + +const Step2TargetDevice = ({ + devices, + fetchingDevices, + onDeviceSearch, + onDeviceSelect, + targetDevice, + targetPorts, + targetPort, + onPortSelect, + conflicts, +}) => { + const [searchKeyword, setSearchKeyword] = useState(''); + + const handleSearch = (e) => { + const value = e.target.value; + setSearchKeyword(value); + onDeviceSearch?.(value); + }; + + return ( +
+ + + 步骤 2: 选择目标设备 + + } + size="small" + style={{ marginBottom: '20px' }} + > +
+ + 选择接线的终点设备 + +
+ +
+ } + value={searchKeyword} + onChange={handleSearch} + style={{ width: '100%' }} + /> +
+ +
+ {fetchingDevices ? ( +
+ +
+ ) : ( + devices.map(device => ( + + onDeviceSelect(device)} + hoverable + style={{ + border: targetDevice?.deviceId === device.deviceId + ? `2px solid ${designTokens.colors.primary}` + : '1px solid #d9d9d9', + background: targetDevice?.deviceId === device.deviceId + ? 'rgba(24,144,255,0.08)' + : '#fff', + }} + > +
+
+ {device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'} +
+
+
+
+ {device.name || device.deviceId} +
+ {device.status && getStatusTag(device.status)} +
+
+ + ID: + {device.deviceId} + + + 类型: + {device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type} + +
+
+ {device.Rack?.Room?.name && ( + + 机房: + {device.Rack.Room.name} + + )} + {device.Rack?.name && ( + + 机柜: + {device.Rack.name} + + )} + {device.position && ( + + U位: + U{device.position} + + )} + {device.ipAddress && ( + + IP: + {device.ipAddress} + + )} +
+ {device.model && ( +
+ 型号: + {device.model} +
+ )} +
+ {targetDevice?.deviceId === device.deviceId && ( + + )} +
+
+
+ )) + )} +
+ + {targetDevice && ( + +
+ + 目标设备已选择: {targetDevice.name} + +
+ +
+
+ + 端口选择 + + {targetPorts.filter(p => p.status === 'free').length} 个空闲端口 + +
+ + {targetPorts.length === 0 ? ( + + ) : ( + <> +
+ 端口总数: {targetPorts.length} | 空闲端口: {targetPorts.filter(p => p.status === 'free').length} +
+
+ 端口状态分布: {targetPorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')} +
+ {targetPort && ( +
+ +
+
+ ✓ 已选择端口: {targetPort.portName} +
+ {targetPort.portType && ( +
+ 端口类型: {targetPort.portType} +
+ )} + {targetPort.portSpeed && ( +
+ 端口速率: {targetPort.portSpeed} +
+ )} +
+
+ )} + + + )} + + {conflicts.length > 0 && ( +
+ + ⚠️ 端口冲突检测 + +
+ {conflicts.map((conflict, index) => ( +
+ + {conflict.type === 'source' ? '源端口' : '目标端口'} + + {conflict.port} + + 已被 {conflict.existingCable?.sourceDevice?.name} →{' '} + {conflict.existingCable?.targetDevice?.name} 占用 + +
+ ))} +
+
+ )} +
+
+ )} +
+
+ ); +}; + +const Step3CableConfig = ({ + sourceDevice, + sourcePort, + targetDevice, + targetPort, + cableTypes, + cableLengths, + selectedCableType, + selectedCableLength, + onCableTypeChange, + onCableLengthChange, + cableLabel, + cableDescription, + setCableLabel, + setCableDescription, +}) => { + const estimatedLength = useMemo(() => { + if (!sourceDevice?.rackId || !targetDevice?.rackId) return 3; + + const distance = Math.abs(sourceDevice.rackId - targetDevice.rackId) * 0.5; + return Math.max(3, Math.min(50, Math.round(distance + 3))); + }, [sourceDevice, targetDevice]); + + return ( +
+ + + 步骤 3: 线缆配置 + + } + size="small" + style={{ marginBottom: '20px' }} + > +
+
+ + 连接信息 + + +
+
+
+
源设备
+
{sourceDevice?.name}
+
+ 端口: {sourcePort?.portName} +
+
+ + + +
+
目标设备
+
{targetDevice?.name}
+
+ 端口: {targetPort?.portName} +
+
+
+ + + +
+
+
线缆类型
+
+ {cableTypes.map(type => ( + onCableTypeChange(type.value)} + > + {type.icon} {type.label} + + ))} +
+
+ +
+
线缆长度
+
+ {cableLengths.map(length => ( + onCableLengthChange(length)} + > + {length}m + + ))} +
+
+
+
+ +
+ + 建议长度: {estimatedLength}m +
+
+ +
+ + 线缆属性 + + +
+
+ + 线缆标签 + + setCableLabel(e.target.value)} + style={{ width: '100%' }} + /> +
+ 示例: CABLE-{sourceDevice?.deviceId.slice(-4)}-{targetDevice?.deviceId.slice(-4)}-{Date.now().toString().slice(-4)} +
+
+ +
+ + 备注说明 + + setCableDescription(e.target.value)} + rows={6} + style={{ width: '100%' }} + /> +
+
+ +
+ + 💡 提示 + +
+ • 以太网线(Cat6): 适用于1G/10G短距离连接 +
+ • 光纤(SMF/MMF): 适用于长距离或高带宽需求 +
+ • 铜缆: 适用于电源或特殊设备连接 +
+
+
+
+
+
+ ); +}; + +const Step4Preview = ({ + sourceDevice, + sourcePort, + targetDevice, + targetPort, + cableType, + cableLength, + cableLabel, + cableDescription, +}) => { + return ( +
+ + + 步骤 4: 预览确认 + + } + size="small" + style={{ marginBottom: '20px' }} + > +
+
+
+
+ {sourceDevice?.type === 'server' ? '🖥️' : sourceDevice?.type === 'switch' ? '📡' : '💾'} +
+ +
+
+ {cableType?.icon} +
+
+
+ {cableType?.label} {cableLength}m +
+
+ {cableLabel || '自动生成'} +
+
+
+ +
+ {targetDevice?.type === 'server' ? '🖥️' : targetDevice?.type === 'switch' ? '📡' : '💾'} +
+
+ +
+
+
源端口
+
{sourcePort?.portName}
+
+ {sourceDevice?.name} +
+
+ + + +
+
目标端口
+
{targetPort?.portName}
+
+ {targetDevice?.name} +
+
+
+
+ + {cableDescription && ( +
+ + 备注说明 + +
+ {cableDescription} +
+
+ )} + +
+ + + ✅ 所有信息已确认,可以创建接线 + +
+
+
+
+ ); +}; + +export default CableWizardModal; diff --git a/frontend/src/components/PortPanel.jsx b/frontend/src/components/PortPanel.jsx index a32f249..e2b9ee3 100644 --- a/frontend/src/components/PortPanel.jsx +++ b/frontend/src/components/PortPanel.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Tooltip, Badge, Divider, Pagination } from 'antd'; -import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined } from '@ant-design/icons'; +import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined, CheckCircleOutlined } from '@ant-design/icons'; const PortPanel = ({ ports, @@ -10,6 +10,7 @@ const PortPanel = ({ devices = [], onPortClick, compact = false, + selectedPort = null, }) => { const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(48); // 默认每页48个端口 @@ -420,6 +421,7 @@ const PortPanel = ({ const statusColor = getPortStatusColor(port.status); const isClickable = onPortClick && port.status !== 'disabled'; const cable = findPortCable(port); + const isSelected = selectedPort?.portId === port.portId || selectedPort?.portName === port.portName; return (
isClickable && onPortClick(port)} + onClick={() => { + console.log('Port clicked:', port); + if (isClickable) { + console.log('Port is clickable, calling onPortClick'); + onPortClick(port); + } + }} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', - padding: '4px', + padding: '6px 4px', cursor: isClickable ? 'pointer' : 'not-allowed', - transition: 'all 0.2s ease', + transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', position: 'relative', minWidth: '0', + pointerEvents: isClickable ? 'auto' : 'none', + transform: isSelected ? 'scale(1.08)' : 'scale(1)', + boxShadow: isSelected + ? '0 0 0 4px rgba(24,144,255,0.15), 0 8px 25px rgba(24,144,255,0.25)' + : isClickable + ? '0 0 0 0 rgba(24,144,255,0)' + : 'none', }} > + {/* 选中状态标记 - 更明显的视觉反馈 */} + {isSelected && ( +
+ +
+ )} {/* LED 指示灯 - 在端口上方 */}
@@ -463,22 +509,28 @@ const PortPanel = ({ style={{ width: '100%', aspectRatio: '1 / 1.2', - background: 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)', - border: `2px solid ${statusColor}`, - borderRadius: '2px', + background: isSelected + ? 'linear-gradient(180deg, #e6f7ff 0%, #bae7ff 100%)' + : 'linear-gradient(180deg, #2a3441 0%, #1e2530 100%)', + border: `2px solid ${isSelected ? '#1890ff' : statusColor}`, + borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', - boxShadow: `inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)`, + boxShadow: isSelected + ? 'inset 0 2px 4px rgba(24,144,255,0.2), 0 4px 12px rgba(24,144,255,0.15)' + : 'inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 4px rgba(0,0,0,0.3)', + transition: 'all 0.2s ease', }} > {/* 端口内部图标 */}
{getPortTypeIcon(port.portType)} @@ -504,15 +556,16 @@ const PortPanel = ({ {/* 端口名称 - 在端口下方 */}
{getPortDisplayName(port.portName)} diff --git a/frontend/src/pages/CableManagement.jsx b/frontend/src/pages/CableManagement.jsx index b219def..bae3b9e 100644 --- a/frontend/src/pages/CableManagement.jsx +++ b/frontend/src/pages/CableManagement.jsx @@ -54,6 +54,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { designTokens } from '../config/theme'; import { debounce } from '../utils/common'; import CloseButton from '../components/CloseButton'; +import CableWizardModal from '../components/CableWizardModal'; const { Option } = Select; const { Panel } = Collapse; @@ -127,6 +128,9 @@ function CableManagement() { const [editingCable, setEditingCable] = useState(null); const [form] = Form.useForm(); + const [wizardVisible, setWizardVisible] = useState(false); + const [wizardInitialSourceDevice, setWizardInitialSourceDevice] = useState(null); + const [importModalVisible, setImportModalVisible] = useState(false); const [importFileList, setImportFileList] = useState([]); const [importPreview, setImportPreview] = useState([]); @@ -284,24 +288,14 @@ function CableManagement() { }; const handleAdd = () => { - setEditingCable(null); - form.resetFields(); - setModalVisible(true); + setWizardInitialSourceDevice(null); + setWizardVisible(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); + setWizardInitialSourceDevice(null); + setWizardVisible(true); }; const handleDelete = async cableId => { @@ -1243,9 +1237,8 @@ function CableManagement() { type="text" icon={} onClick={() => { - setEditingCable(null); - form.setFieldsValue({ sourceDeviceId: switchId }); - setModalVisible(true); + setWizardInitialSourceDevice(switchData.switch); + setWizardVisible(true); }} style={{ color: designTokens.colors.primary.main }} /> @@ -1510,6 +1503,22 @@ function CableManagement() { + {/* 向导式接线创建弹窗 */} + { + setWizardVisible(false); + setWizardInitialSourceDevice(null); + setEditingCable(null); + }} + onSuccess={() => { + setEditingCable(null); + fetchCables(); + }} + initialSourceDevice={wizardInitialSourceDevice} + editingCable={editingCable} + /> + {/* 批量导入弹窗 */} { @@ -373,7 +404,7 @@ function ConsumableManagement() { const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }); if (jsonData.length < 2) { - resolve([]); + resolve({ data: [], headers: [] }); return; } @@ -391,7 +422,7 @@ function ConsumableManagement() { } } - resolve(result); + resolve({ data: result, headers }); } catch (error) { reject(error); } @@ -401,6 +432,31 @@ function ConsumableManagement() { }); }; + const detectFieldMappings = (headers, availableFields) => { + const mappings = {}; + const normalizedAvailableFields = availableFields.map(f => ({ + source: f.source, + target: f.target, + normalizedSource: f.source.toLowerCase(), + normalizedTarget: f.target.toLowerCase(), + })); + + headers.forEach(header => { + const normalizedHeader = header.toLowerCase().replace(/[_\s]/g, ''); + const match = normalizedAvailableFields.find( + f => + f.normalizedSource.replace(/[_\s]/g, '') === normalizedHeader || + f.normalizedTarget.replace(/[_\s]/g, '') === normalizedHeader || + f.source === header + ); + if (match) { + mappings[header] = match.target; + } + }); + + return mappings; + }; + const validateImportData = (data, validCategories) => { const errors = []; const validCategoryNames = validCategories.map(c => c.name); @@ -425,6 +481,78 @@ function ConsumableManagement() { return errors; }; + const fetchFieldMappings = async () => { + try { + const response = await axios.get('/api/consumables/field-mappings'); + if (response.data && response.data.aliases) { + setAvailableFields(response.data.aliases); + } + } catch (error) { + console.error('获取字段映射信息失败:', error); + } + }; + + const pollImportProgress = async jobId => { + try { + const response = await axios.get(`/api/consumables/progress/${jobId}`); + const progress = response.data; + setImportJobStatus(progress); + setImportProgress(progress.progressPercent || 0); + + if (progress.status === 'completed') { + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + setImportPhase('导入完成'); + fetchImportResult(jobId); + } else if (progress.status === 'failed') { + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + setImportPhase('导入失败'); + setImporting(false); + message.error(progress.error || '导入失败'); + } else if (progress.status === 'cancelled') { + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + setImportPhase('导入已取消'); + setImporting(false); + } + } catch (error) { + console.error('获取导入进度失败:', error); + } + }; + + const fetchImportResult = async jobId => { + try { + const response = await axios.get(`/api/consumables/result/${jobId}`); + if (response.data && response.data.result) { + setImportResult(response.data.result); + setImportStep('result'); + } + setImporting(false); + fetchConsumables(); + } catch (error) { + console.error('获取导入结果失败:', error); + setImporting(false); + } + }; + + const handleCancelImport = async () => { + if (!importJobId) return; + try { + await axios.post(`/api/consumables/cancel/${importJobId}`); + message.info('正在取消导入任务...'); + } catch (error) { + console.error('取消导入失败:', error); + message.error('取消导入失败'); + } + }; + const handleFileChange = async info => { const file = info.fileList[info.fileList.length - 1]; if (file && file.originFileObj) { @@ -432,7 +560,7 @@ function ConsumableManagement() { setImporting(true); setImportPhase('正在解析文件...'); - const parsedData = await parseFile(file.originFileObj); + const { data: parsedData, headers: detectedHeadersList } = await parseFile(file.originFileObj); if (parsedData.length === 0) { message.warning('文件中没有有效数据'); @@ -440,6 +568,13 @@ function ConsumableManagement() { return; } + setDetectedHeaders(detectedHeadersList || []); + + if (availableFields.length > 0 && detectedHeadersList.length > 0) { + const autoMappings = detectFieldMappings(detectedHeadersList, availableFields); + setFieldMappings(autoMappings); + } + const validationErrors = validateImportData(parsedData, categories); setImportValidationErrors(validationErrors); setImportPreview(parsedData); @@ -469,6 +604,17 @@ function ConsumableManagement() { setImportMode('create'); setImportValidationErrors([]); setImportResult(null); + setFieldMappings({}); + setDetectedHeaders([]); + setImportJobId(null); + setImportJobStatus(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + if (availableFields.length === 0) { + fetchFieldMappings(); + } }; const handleImportCancel = () => { @@ -480,6 +626,14 @@ function ConsumableManagement() { setImportResult(null); setImportStep('upload'); setImportValidationErrors([]); + setFieldMappings({}); + setDetectedHeaders([]); + setImportJobId(null); + setImportJobStatus(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } }; const handleImport = async () => { @@ -489,50 +643,74 @@ function ConsumableManagement() { } setImporting(true); - setImportProgress(10); + setImportProgress(0); setImportPhase('准备导入数据...'); setImportResult(null); + setImportStep('importing'); + + const useBackgroundMode = importPreview.length > 500; try { - setImportProgress(30); - setImportPhase('正在提交到服务器...'); + if (useBackgroundMode) { + setImportPhase('正在创建后台任务...'); - const response = await axios.post('/api/consumables/import', { - items: importPreview, - mode: importMode, - }); - - setImportProgress(70); - setImportPhase('处理导入结果...'); - - const results = response.data.results; - setImportResult(results); - setImportStep('result'); - setImportProgress(100); - setImportPhase('导入完成'); - - if (results.failed > 0) { - message.warning(response.data.message); - } else { - message.success({ - content: response.data.message, - icon: , + const response = await axios.post('/api/consumables/background', { + items: importPreview, + mode: importMode, + fieldMapping: fieldMappings, }); - } - fetchConsumables(); + const { jobId } = response.data; + setImportJobId(jobId); + setImportProgress(5); + setImportPhase('后台任务已创建,正在导入...'); + + const interval = setInterval(() => { + pollImportProgress(jobId); + }, 1000); + setPollingInterval(interval); + } else { + setImportProgress(30); + setImportPhase('正在提交到服务器...'); + + const response = await axios.post('/api/consumables/import', { + items: importPreview, + mode: importMode, + }); + + setImportProgress(70); + setImportPhase('处理导入结果...'); + + const results = response.data.results; + setImportResult(results); + setImportStep('result'); + setImportProgress(100); + setImportPhase('导入完成'); + + if (results.failed > 0) { + message.warning(response.data.message); + } else { + message.success({ + content: response.data.message, + icon: , + }); + } + + fetchConsumables(); + setImporting(false); + } } catch (error) { message.error('导入失败,请检查网络连接或服务器状态'); console.error('导入耗材失败:', error); - } finally { setImporting(false); + setImportStep('preview'); } }; const downloadTemplate = () => { const template = [ { - 耗材ID: '', + 耗材ID: 'CON001', 名称: '示例耗材-网络模块', 分类: '光模块', 单位: '个', @@ -548,12 +726,117 @@ function ConsumableManagement() { }, ]; - const ws = XLSX.utils.json_to_sheet(template); + const fieldDescription = [ + { + 字段名: '耗材ID', + 系统字段: 'consumableId', + 必填: '否', + 说明: '耗材唯一标识符,留空自动生成;填写后可识别并更新现有耗材', + 示例: 'CON001', + }, + { + 字段名: '名称', + 系统字段: 'name', + 必填: '是', + 说明: '耗材名称', + 示例: '光纤跳线', + }, + { + 字段名: '分类', + 系统字段: 'category', + 必填: '是', + 说明: '耗材分类,如"光模块"或"光纤跳线",需先在系统中创建该分类', + 示例: '光模块', + }, + { + 字段名: '单位', + 系统字段: 'unit', + 必填: '否', + 说明: '计量单位,如"个"、"根"、"箱",默认"个"', + 示例: '个', + }, + { + 字段名: '当前库存', + 系统字段: 'currentStock', + 必填: '否', + 说明: '当前库存数量,数字类型', + 示例: '100', + }, + { + 字段名: '最小库存', + 系统字段: 'minStock', + 必填: '否', + 说明: '安全库存阈值,低于此值会触发预警', + 示例: '10', + }, + { + 字段名: '最大库存', + 系统字段: 'maxStock', + 必填: '否', + 说明: '最大库存限制,0表示无限制', + 示例: '500', + }, + { + 字段名: '单价', + 系统字段: 'unitPrice', + 必填: '否', + 说明: '耗材单价,数字类型', + 示例: '5.00', + }, + { + 字段名: '供应商', + 系统字段: 'supplier', + 必填: '否', + 说明: '耗材供应商名称', + 示例: 'XX科技有限公司', + }, + { + 字段名: '存放位置', + 系统字段: 'location', + 必填: '否', + 说明: '仓库内存放位置,如"A柜-01层"', + 示例: 'A柜-01层', + }, + { + 字段名: '描述', + 系统字段: 'description', + 必填: '否', + 说明: '耗材的详细描述或备注', + 示例: '这是一条测试数据', + }, + { + 字段名: 'SN序列号', + 系统字段: 'snList', + 必填: '否', + 说明: '多个SN用逗号、分号或换行分隔,如"SN001,SN002"或"SN001\\nSN002"', + 示例: 'SN001,SN002,SN003', + }, + { + 字段名: '状态', + 系统字段: 'status', + 必填: '否', + 说明: '"active"启用,"inactive"停用,默认启用', + 示例: 'active', + }, + ]; + const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '耗材导入模板'); + + const ws1 = XLSX.utils.json_to_sheet(template); + XLSX.utils.book_append_sheet(wb, ws1, '耗材导入模板'); + + const ws2 = XLSX.utils.json_to_sheet(fieldDescription); + XLSX.utils.book_append_sheet(wb, ws2, '字段说明'); + + const ws3 = XLSX.utils.json_to_sheet([{ 注意: '请删除示例数据后填写您的实际数据' }]); + XLSX.utils.book_append_sheet(wb, ws3, '使用说明'); + XLSX.writeFile(wb, '耗材导入模板.xlsx'); - message.success('模板下载成功'); + message.success({ + content: '模板下载成功(包含3个工作表)', + icon: , + }); }; const downloadFailedRecords = () => { @@ -655,6 +938,37 @@ function ConsumableManagement() { setScanChecking(false); }, []); + const addToPendingOut = (consumable, snList = []) => { + const existingIndex = pendingOutItems.findIndex( + item => item.consumable.consumableId === consumable.consumableId + ); + + if (existingIndex >= 0) { + const updated = [...pendingOutItems]; + const existing = updated[existingIndex]; + const newSnList = [...new Set([...existing.snList, ...snList])]; + updated[existingIndex] = { + ...existing, + quantity: newSnList.length > 0 ? newSnList.length : existing.quantity + 1, + snList: newSnList, + }; + setPendingOutItems(updated); + } else { + setPendingOutItems([ + ...pendingOutItems, + { + consumable, + quantity: snList.length > 0 ? snList.length : 1, + snList, + }, + ]); + } + }; + + const removeFromPendingOut = consumableId => { + setPendingOutItems(pendingOutItems.filter(item => item.consumable.consumableId !== consumableId)); + }; + const handleScanKeyDown = useCallback( async e => { if (e.key === 'Enter' && scanValue.trim()) { @@ -727,10 +1041,38 @@ function ConsumableManagement() { try { const res = await axios.get(`/api/consumables/by-sn/${encodeURIComponent(code)}`); if (res.data.found) { - handleScanCancel(); - showStockModal(res.data.consumable, 'out'); - setSelectedSnList([code]); - stockForm.setFieldsValue({ quantity: 1 }); + const consumable = res.data.consumable; + const existingItem = pendingOutItems.find( + item => item.consumable.consumableId === consumable.consumableId + ); + if (existingItem) { + if (!existingItem.snList.includes(code)) { + const updated = [...pendingOutItems]; + const index = updated.findIndex( + item => item.consumable.consumableId === consumable.consumableId + ); + updated[index] = { + ...updated[index], + quantity: updated[index].quantity + 1, + snList: [...updated[index].snList, code], + }; + setPendingOutItems(updated); + message.success({ + content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`, + icon: , + }); + } else { + message.warning(`SN已在列表中: ${code}`); + } + } else { + addToPendingOut(consumable, [code]); + message.success({ + content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`, + icon: , + }); + } + setScanValue(''); + scanInputRef.current?.focus(); } else { message.warning('未找到该SN对应的耗材'); setScanValue(''); @@ -754,6 +1096,8 @@ function ConsumableManagement() { form, showStockModal, stockForm, + pendingOutItems, + addToPendingOut, ] ); @@ -786,6 +1130,178 @@ function ConsumableManagement() { [scannedSnList, handleScanCancel, fetchConsumables] ); + const searchDevices = useCallback(async keyword => { + if (!keyword || keyword.length < 1) { + setQuickOutDeviceList([]); + return; + } + setQuickOutDeviceLoading(true); + try { + const response = await axios.get('/api/consumables/devices/search', { + params: { keyword, limit: 20 }, + }); + setQuickOutDeviceList(response.data.devices || []); + } catch (error) { + console.error('搜索设备失败:', error); + setQuickOutDeviceList([]); + } finally { + setQuickOutDeviceLoading(false); + } + }, []); + + const debouncedDeviceSearch = useDebouncedCallback(searchDevices, 300); + + const handleQuickOutDeviceSearch = value => { + setQuickOutDeviceSearch(value); + debouncedDeviceSearch(value); + }; + + const searchBatchDevices = useCallback(async keyword => { + if (!keyword || keyword.length < 1) { + setBatchOutDeviceList([]); + return; + } + setBatchOutDeviceLoading(true); + try { + const response = await axios.get('/api/consumables/devices/search', { + params: { keyword, limit: 20 }, + }); + setBatchOutDeviceList(response.data.devices || []); + } catch (error) { + console.error('搜索设备失败:', error); + setBatchOutDeviceList([]); + } finally { + setBatchOutDeviceLoading(false); + } + }, []); + + const debouncedBatchDeviceSearch = useDebouncedCallback(searchBatchDevices, 300); + + const handleBatchOutDeviceSearch = value => { + setBatchOutDeviceSearch(value); + debouncedBatchDeviceSearch(value); + }; + + const openQuickOutModal = (consumable, sn = null) => { + setQuickOutConsumable(consumable); + setQuickOutDevice(null); + setQuickOutDeviceSearch(''); + setQuickOutDeviceList([]); + setQuickOutQuantity(1); + setQuickOutReason(''); + setQuickOutSnList(sn ? [sn] : []); + setQuickOutModalVisible(true); + }; + + const handleQuickOutSubmit = async () => { + if (!quickOutConsumable) { + message.warning('请选择耗材'); + return; + } + if (quickOutQuantity < 1) { + message.warning('出库数量必须大于0'); + return; + } + if (quickOutQuantity > quickOutConsumable.currentStock) { + message.warning('出库数量不能超过当前库存'); + return; + } + + setQuickOutSubmitting(true); + try { + const response = await axios.post('/api/consumables/quick-inout', { + consumableId: quickOutConsumable.consumableId, + type: 'out', + quantity: quickOutQuantity, + operator: '系统管理员', + reason: quickOutReason, + notes: quickOutDevice ? `出库至设备: ${quickOutDevice.name}` : '', + snList: quickOutSnList, + deviceId: quickOutDevice?.deviceId || null, + }); + + message.success({ + content: `成功出库 ${quickOutQuantity} 个${quickOutDevice ? `至设备 ${quickOutDevice.name}` : ''}`, + icon: , + }); + + setQuickOutModalVisible(false); + fetchConsumables(); + } catch (error) { + message.error(error.response?.data?.error || '出库操作失败'); + console.error('出库失败:', error); + } finally { + setQuickOutSubmitting(false); + } + }; + + const openBatchOutModal = () => { + if (pendingOutItems.length === 0) { + message.warning('没有待出库的耗材'); + return; + } + setBatchOutDevice(null); + setBatchOutDeviceSearch(''); + setBatchOutDeviceList([]); + setBatchOutReason(''); + setBatchOutModalVisible(true); + }; + + const handleBatchOutSubmit = async () => { + if (pendingOutItems.length === 0) { + message.warning('没有待出库的耗材'); + return; + } + + setBatchOutSubmitting(true); + let successCount = 0; + let failCount = 0; + const errors = []; + + for (const item of pendingOutItems) { + try { + await axios.post('/api/consumables/quick-inout', { + consumableId: item.consumable.consumableId, + type: 'out', + quantity: item.quantity, + operator: '系统管理员', + reason: batchOutReason || '批量出库', + notes: batchOutDevice ? `出库至设备: ${batchOutDevice.name}` : '', + snList: item.snList, + deviceId: batchOutDevice?.deviceId || null, + }); + successCount++; + } catch (error) { + failCount++; + errors.push(`${item.consumable.name}: ${error.response?.data?.error || error.message}`); + } + } + + setBatchOutSubmitting(false); + setBatchOutModalVisible(false); + setPendingOutItems([]); + setBatchOutDevice(null); + setBatchOutDeviceSearch(''); + setBatchOutReason(''); + + if (failCount === 0) { + message.success({ + content: `成功出库 ${successCount} 项${batchOutDevice ? `至设备 ${batchOutDevice.name}` : ''}`, + icon: , + }); + } else { + message.warning({ + content: `出库完成: 成功 ${successCount} 项, 失败 ${failCount} 项`, + icon: , + }); + if (errors.length > 0) { + console.error('出库失败详情:', errors); + } + } + + fetchConsumables(); + }; + const columns = useMemo( () => [ { @@ -2344,7 +2860,7 @@ function ConsumableManagement() { 点击或拖拽文件到此处上传 - 支持 .xlsx、.xls、.csv 格式,文件大小不超过 10MB + 支持 Excel (.xlsx/.xls)、CSV (.csv) 格式,文件大小不超过 10MB
)} + {/* 导入中状态 */} + {importStep === 'importing' && ( + +
+ +
+ + {importJobStatus?.status === 'processing' ? '正在导入中...' : '准备导入...'} + + + {importPhase} + + + {importJobStatus && ( +
+ + + +
+ {importJobStatus.successCount || 0} +
+
成功
+
+ + + +
+ {importJobStatus.skippedCount || 0} +
+
跳过
+
+ + + +
+ {importJobStatus.failedCount || 0} +
+
失败
+
+ +
+
+ 已处理 {importJobStatus.processedItems || 0} / {importJobStatus.totalItems || 0} 条 +
+
+ )} + + + + + + + + + +
+ )} + {/* 步骤3: 完成 */} {importStep === 'result' && importResult && ( )} + {scanMode === 'out' && pendingOutItems.length > 0 && ( +
+
+ 待出库列表 ({pendingOutItems.length} 项) +
+
+ {pendingOutItems.map(item => ( + removeFromPendingOut(item.consumable.consumableId)} + color="red" + style={{ marginBottom: '4px', marginRight: '8px' }} + > + {item.consumable.name} × {item.quantity} + {item.snList.length > 0 && ` (${item.snList.length} SN)`} + + ))} +
+ +
+ )} + 💡 提示:也可手动输入条码后按回车键确认
+ + {/* 扫码快速出库到设备弹窗 */} + + + 扫码出库 + + } + open={quickOutModalVisible} + closeIcon={} + onCancel={() => setQuickOutModalVisible(false)} + footer={null} + width={520} + destroyOnClose + > + {quickOutConsumable && ( +
+ + + + + {quickOutConsumable.category?.charAt(0) || '耗'} + + + +
+ {quickOutConsumable.name} +
+
+ {quickOutConsumable.category} · 库存: {quickOutConsumable.currentStock} {quickOutConsumable.unit} +
+ {quickOutConsumable.location && ( +
+ 📍 {quickOutConsumable.location} +
+ )} + +
+
+ +
+ + +
+ +
+ + ({ + value: d.name, + label: ( +
+
{d.name}
+
+ {d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'} +
+
+ ), + }))} + onSearch={handleQuickOutDeviceSearch} + onSelect={(value, option) => { + const device = quickOutDeviceList.find(d => d.name === value); + setQuickOutDevice(device); + setQuickOutDeviceSearch(device?.name || ''); + }} + onChange={value => { + setQuickOutDeviceSearch(value); + if (!value) { + setQuickOutDevice(null); + } + }} + placeholder="搜索设备名称/ID/序列号" + style={{ width: '100%' }} + loading={quickOutDeviceLoading} + /> + {quickOutDevice && ( +
+ + + + 已选择: {quickOutDevice.name} + + + {quickOutDevice.location && ( +
+ 📍 {quickOutDevice.location.roomName} · {quickOutDevice.location.rackName} +
+ )} +
+ )} +
+ +
+ + setQuickOutReason(e.target.value)} + placeholder="请输入出库原因" + autoSize={{ minRows: 2, maxRows: 4 }} + style={{ borderRadius: '8px' }} + /> +
+ + {quickOutSnList.length > 0 && ( +
+ +
+ {quickOutSnList.map((sn, index) => ( + + {sn} + + ))} +
+
+ )} + +
+ + + + + + + + + + + +
+ + + + + +
+ )} +
+ + {/* 批量出库确认弹窗 */} + + + 批量出库确认 ({pendingOutItems.length} 项) + + } + open={batchOutModalVisible} + closeIcon={} + onCancel={() => setBatchOutModalVisible(false)} + footer={null} + width={600} + destroyOnClose + > +
+ + +
+ +
+ {pendingOutItems.map((item, index) => ( + + + +
{item.consumable.name}
+
+ {item.consumable.category} · 库存: {item.consumable.currentStock} + {item.snList.length > 0 && ` · SN: ${item.snList.length}个`} +
+ {item.snList.length > 0 && ( +
+ {item.snList.slice(0, 5).map((sn, i) => ( + + {sn} + + ))} + {item.snList.length > 5 && ( + +{item.snList.length - 5} 更多 + )} +
+ )} + + + + × {item.quantity} + + + +
+
+ +
+ + ({ + value: d.name, + label: ( +
+
{d.name}
+
+ {d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'} +
+
+ ), + }))} + onSearch={handleBatchOutDeviceSearch} + onSelect={(value, option) => { + const device = batchOutDeviceList.find(d => d.name === value); + setBatchOutDevice(device); + setBatchOutDeviceSearch(device?.name || ''); + }} + onChange={value => { + setBatchOutDeviceSearch(value); + if (!value) { + setBatchOutDevice(null); + } + }} + placeholder="搜索设备名称/ID/序列号" + style={{ width: '100%' }} + loading={batchOutDeviceLoading} + /> + {batchOutDevice && ( +
+ + + + 已选择: {batchOutDevice.name} + + + {batchOutDevice.location && ( +
+ 📍 {batchOutDevice.location.roomName} · {batchOutDevice.location.rackName} +
+ )} +
+ )} +
+ +
+ + setBatchOutReason(e.target.value)} + placeholder="请输入出库原因" + autoSize={{ minRows: 2, maxRows: 4 }} + style={{ borderRadius: '8px' }} + /> +
+ +
+ + + + + + sum + item.quantity, 0)} + valueStyle={{ fontSize: '20px', color: designTokens.colors.error.main }} + /> + + + sum + item.snList.length, 0)} + valueStyle={{ fontSize: '20px', color: designTokens.colors.primary.main }} + /> + + +
+ + + + + +
+
); } diff --git a/package-lock.json b/package-lock.json index 602f5a5..0cfbc0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,67 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@ant-design/cssinjs": "^2.1.2", "papaparse": "^5.5.3" }, "devDependencies": { "concurrently": "^9.2.1" } }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -86,6 +141,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -131,6 +195,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -178,12 +248,47 @@ "node": ">=8" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "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/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -204,6 +309,12 @@ "tslib": "^2.1.0" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -245,6 +356,12 @@ "node": ">=8" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", diff --git a/package.json b/package.json index 48ba552..4fc426e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "concurrently": "^9.2.1" }, "dependencies": { + "@ant-design/cssinjs": "^2.1.2", "papaparse": "^5.5.3" } }