From 686089c21a04c6199a97232e7f3eac13652ec688 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Tue, 10 Mar 2026 09:20:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9A=82=E5=AD=98=E8=AE=BE=E5=A4=87=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migratePendingDevice.js | 58 + backend/models/PendingDevice.js | 179 +++ backend/routes/inventory.js | 400 ++++++- backend/scripts/migrate-all.js | 15 + docs/USER_GUIDE.md | 816 +++++++++++++ frontend/src/App.jsx | 16 + frontend/src/pages/DeviceManagement.jsx | 32 +- frontend/src/pages/InventoryTaskExecution.jsx | 1010 +++++++++++++---- .../src/pages/PendingDeviceManagement.jsx | 881 ++++++++++++++ 9 files changed, 3125 insertions(+), 282 deletions(-) create mode 100644 backend/migratePendingDevice.js create mode 100644 backend/models/PendingDevice.js create mode 100644 docs/USER_GUIDE.md create mode 100644 frontend/src/pages/PendingDeviceManagement.jsx diff --git a/backend/migratePendingDevice.js b/backend/migratePendingDevice.js new file mode 100644 index 0000000..f87eda9 --- /dev/null +++ b/backend/migratePendingDevice.js @@ -0,0 +1,58 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = process.env.DB_PATH || path.join(__dirname, 'idc_management.db'); + +const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('无法连接到数据库:', err); + process.exit(1); + } + console.log('已连接到数据库:', dbPath); +}); + +const columnsToAdd = [ + { table: 'pending_devices', column: 'height', type: 'INTEGER', defaultValue: 1 }, + { table: 'pending_devices', column: 'powerConsumption', type: 'FLOAT', defaultValue: 0 }, + { table: 'pending_devices', column: 'brand', type: 'VARCHAR(255)', defaultValue: null }, + { table: 'pending_devices', column: 'purchaseDate', type: 'DATE', defaultValue: null }, + { table: 'pending_devices', column: 'warrantyExpiry', type: 'DATE', defaultValue: null }, +]; + +db.serialize(() => { + columnsToAdd.forEach(({ table, column, type, defaultValue }) => { + db.all(`PRAGMA table_info(${table})`, (err, rows) => { + if (err) { + console.error(`获取表 ${table} 信息失败:`, err); + return; + } + + const columnExists = rows && rows.some(row => row.name === column); + + if (!columnExists) { + const defaultClause = defaultValue !== null ? ` DEFAULT ${typeof defaultValue === 'string' ? `'${defaultValue}'` : defaultValue}` : ''; + const sql = `ALTER TABLE ${table} ADD COLUMN ${column} ${type}${defaultClause}`; + + db.run(sql, (err) => { + if (err) { + console.error(`添加列 ${table}.${column} 失败:`, err.message); + } else { + console.log(`成功添加列 ${table}.${column}`); + } + }); + } else { + console.log(`列 ${table}.${column} 已存在,跳过`); + } + }); + }); +}); + +setTimeout(() => { + db.close((err) => { + if (err) { + console.error('关闭数据库失败:', err); + } else { + console.log('数据库迁移完成,连接已关闭'); + } + }); +}, 2000); diff --git a/backend/models/PendingDevice.js b/backend/models/PendingDevice.js new file mode 100644 index 0000000..5f1de39 --- /dev/null +++ b/backend/models/PendingDevice.js @@ -0,0 +1,179 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); +const User = require('./User'); +const InventoryPlan = require('./InventoryPlan'); +const InventoryTask = require('./InventoryTask'); +const Room = require('./Room'); +const Rack = require('./Rack'); + +const PendingDevice = sequelize.define('PendingDevice', { + pendingId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + serialNumber: { + type: DataTypes.STRING, + allowNull: false, + comment: '设备序列号' + }, + deviceName: { + type: DataTypes.STRING, + allowNull: true, + comment: '设备名称' + }, + deviceType: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: 'other', + comment: '设备类型: server, switch, router, storage, other' + }, + roomId: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: Room, + key: 'roomId' + }, + comment: '所属机房ID' + }, + rackId: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: Rack, + key: 'rackId' + }, + comment: '所属机柜ID' + }, + position: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'U位' + }, + height: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: 1, + comment: '高度(U)' + }, + powerConsumption: { + type: DataTypes.FLOAT, + allowNull: true, + defaultValue: 0, + comment: '功率(W)' + }, + model: { + type: DataTypes.STRING, + allowNull: true, + comment: '设备型号' + }, + brand: { + type: DataTypes.STRING, + allowNull: true, + comment: '品牌' + }, + ipAddress: { + type: DataTypes.STRING, + allowNull: true, + comment: 'IP地址' + }, + purchaseDate: { + type: DataTypes.DATE, + allowNull: true, + comment: '购买日期' + }, + warrantyExpiry: { + type: DataTypes.DATE, + allowNull: true, + comment: '保修到期' + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + comment: '描述' + }, + customFields: { + type: DataTypes.JSON, + defaultValue: {}, + allowNull: true + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending', + comment: 'pending: 待同步, synced: 已同步, deleted: 已删除' + }, + planId: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: InventoryPlan, + key: 'planId' + }, + comment: '关联的盘点计划ID' + }, + taskId: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: InventoryTask, + key: 'taskId' + }, + comment: '关联的盘点任务ID' + }, + createdBy: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: User, + key: 'userId' + }, + comment: '创建人' + }, + syncedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '同步时间' + }, + syncedBy: { + type: DataTypes.STRING, + allowNull: true, + references: { + model: User, + key: 'userId' + }, + comment: '同步人' + }, + syncedDeviceId: { + type: DataTypes.STRING, + allowNull: true, + comment: '同步后生成的设备ID' + }, + remark: { + type: DataTypes.TEXT, + allowNull: true, + comment: '备注' + } +}, { + tableName: 'pending_devices', + timestamps: true, + indexes: [ + { fields: ['serialNumber'] }, + { fields: ['status'] }, + { fields: ['planId'] }, + { fields: ['taskId'] }, + { fields: ['createdBy'] }, + { fields: ['roomId'] }, + { fields: ['rackId'] } + ] +}); + +PendingDevice.belongsTo(User, { foreignKey: 'createdBy', as: 'Creator' }); +PendingDevice.belongsTo(User, { foreignKey: 'syncedBy', as: 'Syncer' }); +PendingDevice.belongsTo(InventoryPlan, { foreignKey: 'planId', as: 'Plan' }); +PendingDevice.belongsTo(InventoryTask, { foreignKey: 'taskId', as: 'Task' }); +PendingDevice.belongsTo(Room, { foreignKey: 'roomId', as: 'Room' }); +PendingDevice.belongsTo(Rack, { foreignKey: 'rackId', as: 'Rack' }); + +module.exports = PendingDevice; diff --git a/backend/routes/inventory.js b/backend/routes/inventory.js index d09ea56..c899692 100644 --- a/backend/routes/inventory.js +++ b/backend/routes/inventory.js @@ -9,6 +9,7 @@ const Device = require('../models/Device'); const Rack = require('../models/Rack'); const Room = require('../models/Room'); const User = require('../models/User'); +const PendingDevice = require('../models/PendingDevice'); const { authMiddleware, authorize } = require('../middleware/auth'); const { PAGINATION } = require('../config'); @@ -522,15 +523,37 @@ router.get('/stats/dashboard', async (req, res) => { router.post('/quick-add-device', async (req, res) => { try { - const { taskId, planId, serialNumber, deviceName, deviceType, rackId, position, remark } = req.body; + const { + taskId, + planId, + serialNumber, + SN, + deviceName, + name, + deviceType, + type, + roomId, + rackId, + position, + model, + brand, + height, + powerConsumption, + ipAddress, + purchaseDate, + warrantyExpiry, + description, + remark, + ...restFields + } = req.body; - if (!taskId || !planId || !serialNumber) { - return res.status(400).json({ error: '缺少必要参数:taskId, planId, serialNumber' }); - } + // 字段名映射:优先使用数据库字段名,其次使用默认字段名 + const finalSerialNumber = serialNumber || SN; + const finalDeviceName = name || deviceName; + const finalDeviceType = type || deviceType; - const task = await InventoryTask.findByPk(taskId); - if (!task) { - return res.status(404).json({ error: '盘点任务不存在' }); + if (!planId || !finalSerialNumber) { + return res.status(400).json({ error: '缺少必要参数:planId, serialNumber' }); } const plan = await InventoryPlan.findByPk(planId); @@ -538,15 +561,215 @@ router.post('/quick-add-device', async (req, res) => { return res.status(404).json({ error: '盘点计划不存在' }); } - const existingDevice = await Device.findOne({ where: { serialNumber } }); + const existingDevice = await Device.findOne({ where: { serialNumber: finalSerialNumber } }); if (existingDevice) { - return res.status(400).json({ error: '该序列号的设备已存在', deviceId: existingDevice.deviceId }); + return res.status(400).json({ error: '该序列号的设备已存在于设备管理中', deviceId: existingDevice.deviceId }); } - let deviceId; - const devices = await Device.findAll({ - where: { deviceId: { [Op.like]: 'DEV%' } } + const existingPending = await PendingDevice.findOne({ + where: { serialNumber: finalSerialNumber, status: 'pending' } }); + if (existingPending) { + return res.status(400).json({ error: '该序列号的设备已在暂存列表中', pendingId: existingPending.pendingId }); + } + + const pendingId = `PEND${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; + + // 只有当用户没有填写设备名称时,才使用默认名称 + const finalName = finalDeviceName && finalDeviceName.trim() !== '' + ? finalDeviceName.trim() + : `新设备-${finalSerialNumber.slice(-6)}`; + + const pendingDevice = await PendingDevice.create({ + pendingId, + serialNumber: finalSerialNumber, + deviceName: finalName, + deviceType: finalDeviceType || 'other', + roomId: roomId || null, + rackId: rackId || null, + position: position || null, + model: model || null, + brand: brand || null, + height: height || 1, + powerConsumption: powerConsumption || 0, + ipAddress: ipAddress || null, + purchaseDate: purchaseDate ? new Date(purchaseDate) : null, + warrantyExpiry: warrantyExpiry ? new Date(warrantyExpiry) : null, + description: description || null, + customFields: Object.keys(restFields).length > 0 ? restFields : null, + planId, + taskId: taskId || null, + createdBy: req.user?.userId, + status: 'pending', + remark: remark || '盘点时快速添加' + }); + + res.status(201).json({ + message: '设备已暂存,请前往暂存设备页面完善信息后同步', + pendingDevice + }); + } catch (error) { + console.error('快速添加设备错误:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/pending-devices', async (req, res) => { + try { + const { status, planId, roomId, keyword, page = 1, pageSize = PAGINATION.DEFAULT_PAGE_SIZE } = req.query; + const offset = (page - 1) * pageSize; + const where = {}; + + if (status) { + where.status = status; + } + if (planId) { + where.planId = planId; + } + if (roomId) { + where.roomId = roomId; + } + if (keyword) { + where[Op.or] = [ + { serialNumber: { [Op.like]: `%${keyword}%` } }, + { deviceName: { [Op.like]: `%${keyword}%` } } + ]; + } + + const { count, rows } = await PendingDevice.findAndCountAll({ + where, + include: [ + { model: User, as: 'Creator', attributes: ['userId', 'username', 'realName'] }, + { model: User, as: 'Syncer', attributes: ['userId', 'username', 'realName'] }, + { model: InventoryPlan, as: 'Plan', attributes: ['planId', 'name'] }, + { model: Room, as: 'Room', attributes: ['roomId', 'name'] }, + { model: Rack, as: 'Rack', attributes: ['rackId', 'name'] } + ], + order: [['createdAt', 'DESC']], + limit: parseInt(pageSize), + offset: parseInt(offset) + }); + + res.json({ + pendingDevices: rows, + total: count, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + console.error('获取暂存设备列表错误:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/pending-devices/stats', async (req, res) => { + try { + const total = await PendingDevice.count(); + const pending = await PendingDevice.count({ where: { status: 'pending' } }); + const synced = await PendingDevice.count({ where: { status: 'synced' } }); + + res.json({ total, pending, synced }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/pending-devices/:pendingId', async (req, res) => { + try { + const pendingDevice = await PendingDevice.findByPk(req.params.pendingId, { + include: [ + { model: User, as: 'Creator', attributes: ['userId', 'username', 'realName'] }, + { model: User, as: 'Syncer', attributes: ['userId', 'username', 'realName'] }, + { model: InventoryPlan, as: 'Plan', attributes: ['planId', 'name'] }, + { model: Room, as: 'Room', attributes: ['roomId', 'name'] }, + { model: Rack, as: 'Rack', attributes: ['rackId', 'name'] } + ] + }); + + if (!pendingDevice) { + return res.status(404).json({ error: '暂存设备不存在' }); + } + + res.json(pendingDevice); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/pending-devices/:pendingId', async (req, res) => { + try { + const pendingDevice = await PendingDevice.findByPk(req.params.pendingId); + if (!pendingDevice) { + return res.status(404).json({ error: '暂存设备不存在' }); + } + + if (pendingDevice.status === 'synced') { + return res.status(400).json({ error: '已同步的设备无法修改' }); + } + + const { deviceName, deviceType, roomId, rackId, position, model, brand, height, powerConsumption, ipAddress, purchaseDate, warrantyExpiry, description, remark, ...restFields } = req.body; + + const updateData = { + deviceName: deviceName !== undefined ? deviceName : pendingDevice.deviceName, + deviceType: deviceType !== undefined ? deviceType : pendingDevice.deviceType, + roomId: roomId !== undefined ? roomId : pendingDevice.roomId, + rackId: rackId !== undefined ? rackId : pendingDevice.rackId, + position: position !== undefined ? position : pendingDevice.position, + model: model !== undefined ? model : pendingDevice.model, + brand: brand !== undefined ? brand : pendingDevice.brand, + height: height !== undefined ? height : pendingDevice.height, + powerConsumption: powerConsumption !== undefined ? powerConsumption : pendingDevice.powerConsumption, + ipAddress: ipAddress !== undefined ? ipAddress : pendingDevice.ipAddress, + purchaseDate: purchaseDate !== undefined ? (purchaseDate ? new Date(purchaseDate) : null) : pendingDevice.purchaseDate, + warrantyExpiry: warrantyExpiry !== undefined ? (warrantyExpiry ? new Date(warrantyExpiry) : null) : pendingDevice.warrantyExpiry, + description: description !== undefined ? description : pendingDevice.description, + remark: remark !== undefined ? remark : pendingDevice.remark + }; + + if (Object.keys(restFields).length > 0) { + const existingCustomFields = pendingDevice.customFields || {}; + updateData.customFields = { ...existingCustomFields, ...restFields }; + } + + await pendingDevice.update(updateData); + + res.json(pendingDevice); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/pending-devices/:pendingId', async (req, res) => { + try { + const pendingDevice = await PendingDevice.findByPk(req.params.pendingId); + if (!pendingDevice) { + return res.status(404).json({ error: '暂存设备不存在' }); + } + + await pendingDevice.destroy(); + res.json({ message: '删除成功' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/pending-devices/:pendingId/sync', async (req, res) => { + try { + const pendingDevice = await PendingDevice.findByPk(req.params.pendingId); + if (!pendingDevice) { + return res.status(404).json({ error: '暂存设备不存在' }); + } + + if (pendingDevice.status === 'synced') { + return res.status(400).json({ error: '该设备已同步' }); + } + + const existingDevice = await Device.findOne({ where: { serialNumber: pendingDevice.serialNumber } }); + if (existingDevice) { + return res.status(400).json({ error: '该序列号的设备已存在于设备管理中' }); + } + + const devices = await Device.findAll({ where: { deviceId: { [Op.like]: 'DEV%' } } }); let maxNumber = 0; devices.forEach(device => { const match = device.deviceId.match(/^DEV(\d+)$/); @@ -555,61 +778,126 @@ router.post('/quick-add-device', async (req, res) => { if (num > maxNumber) maxNumber = num; } }); - deviceId = `DEV${String(maxNumber + 1).padStart(3, '0')}`; + const deviceId = `DEV${String(maxNumber + 1).padStart(3, '0')}`; const newDevice = await Device.create({ deviceId, - name: deviceName || `新设备-${serialNumber.slice(-6)}`, - type: deviceType || 'other', - serialNumber, - rackId: rackId || null, - position: position || null, + name: pendingDevice.deviceName, + type: pendingDevice.deviceType, + serialNumber: pendingDevice.serialNumber, + rackId: pendingDevice.rackId, + position: pendingDevice.position, + height: pendingDevice.height || 1, + powerConsumption: pendingDevice.powerConsumption || 0, + model: pendingDevice.model, + ipAddress: pendingDevice.ipAddress, + description: pendingDevice.description, + purchaseDate: pendingDevice.purchaseDate, + warrantyExpiry: pendingDevice.warrantyExpiry, + customFields: pendingDevice.customFields, status: 'running' }); - const record = await InventoryRecord.create({ - recordId: generateRecordId(), - taskId, - planId, - deviceId: newDevice.deviceId, - deviceName: newDevice.name, - deviceType: newDevice.type, - serialNumber: newDevice.serialNumber, - rackId: newDevice.rackId, - position: newDevice.position, - status: 'normal', - abnormalType: 'extra_device', - checkedBy: req.user?.userId, - checkedAt: new Date(), - remark: remark || '盘点时新增设备' + await pendingDevice.update({ + status: 'synced', + syncedAt: new Date(), + syncedBy: req.user?.userId, + syncedDeviceId: newDevice.deviceId }); - const taskRecords = await InventoryRecord.findAll({ where: { taskId: task.taskId } }); - const taskStats = { - totalDevices: taskRecords.length, - checkedDevices: taskRecords.filter(r => r.status !== 'pending').length, - normalDevices: taskRecords.filter(r => r.status === 'normal').length, - abnormalDevices: taskRecords.filter(r => r.status === 'abnormal').length - }; - await task.update(taskStats); - - const planRecords = await InventoryRecord.findAll({ where: { planId: plan.planId } }); - const planStats = { - totalDevices: planRecords.length, - checkedDevices: planRecords.filter(r => r.status !== 'pending').length, - normalDevices: planRecords.filter(r => r.status === 'normal').length, - abnormalDevices: planRecords.filter(r => r.status === 'abnormal').length, - missedDevices: planRecords.filter(r => r.status === 'pending').length - }; - await plan.update(planStats); - - res.status(201).json({ - message: '设备添加成功', + res.json({ + message: '同步成功', device: newDevice, - record + pendingDevice }); } catch (error) { - console.error('快速添加设备错误:', error); + console.error('同步设备错误:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/pending-devices/batch-sync', async (req, res) => { + try { + const { pendingIds } = req.body; + if (!pendingIds || pendingIds.length === 0) { + return res.status(400).json({ error: '请选择要同步的设备' }); + } + + const pendingDevices = await PendingDevice.findAll({ + where: { + pendingId: { [Op.in]: pendingIds }, + status: 'pending' + } + }); + + if (pendingDevices.length === 0) { + return res.status(400).json({ error: '没有可同步的设备' }); + } + + const devices = await Device.findAll({ where: { deviceId: { [Op.like]: 'DEV%' } } }); + let maxNumber = 0; + devices.forEach(device => { + const match = device.deviceId.match(/^DEV(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNumber) maxNumber = num; + } + }); + + const results = []; + const errors = []; + + for (const pending of pendingDevices) { + try { + const existingDevice = await Device.findOne({ where: { serialNumber: pending.serialNumber } }); + if (existingDevice) { + errors.push({ pendingId: pending.pendingId, serialNumber: pending.serialNumber, error: '序列号已存在' }); + continue; + } + + maxNumber++; + const deviceId = `DEV${String(maxNumber).padStart(3, '0')}`; + + const newDevice = await Device.create({ + deviceId, + name: pending.deviceName, + type: pending.deviceType, + serialNumber: pending.serialNumber, + rackId: pending.rackId, + position: pending.position, + height: pending.height || 1, + powerConsumption: pending.powerConsumption || 0, + model: pending.model, + ipAddress: pending.ipAddress, + description: pending.description, + purchaseDate: pending.purchaseDate, + warrantyExpiry: pending.warrantyExpiry, + customFields: pending.customFields, + status: 'running' + }); + + await pending.update({ + status: 'synced', + syncedAt: new Date(), + syncedBy: req.user?.userId, + syncedDeviceId: newDevice.deviceId + }); + + results.push({ pendingId: pending.pendingId, deviceId: newDevice.deviceId }); + } catch (err) { + errors.push({ pendingId: pending.pendingId, error: err.message }); + } + } + + res.json({ + message: `成功同步 ${results.length} 台设备`, + successCount: results.length, + errorCount: errors.length, + results, + errors + }); + } catch (error) { + console.error('批量同步设备错误:', error); res.status(500).json({ error: error.message }); } }); diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 795e8ee..5c40ed0 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -79,6 +79,11 @@ const migrations = [ name: '设备表字段可空', description: '将设备表所有字段改为可空,由应用层验证控制', migrate: migrateDeviceFieldsNullable + }, + { + name: '暂存设备自定义字段', + description: '为 pending_devices 表添加 customFields 字段,支持自定义字段存储', + migrate: migratePendingDeviceCustomFields } ]; @@ -550,6 +555,16 @@ async function migrateDeviceFieldsNullable() { } } +async function migratePendingDeviceCustomFields() { + if (!(await tableExists('pending_devices'))) { + console.log(' pending_devices 表不存在,跳过'); + return; + } + + const columnDef = dbDialect === 'sqlite' ? "JSON DEFAULT '{}'" : "JSON"; + await addColumnIfNotExists('pending_devices', 'customFields', columnDef); +} + // 执行迁移 runMigrations().catch(error => { console.error('迁移执行失败:', error); diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..31c11b6 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,816 @@ +# IDC设备资产系统 - 用户使用指南 + +## 目录 + +- [系统概述](#系统概述) +- [快速入门](#快速入门) +- [功能模块详解](#功能模块详解) + - [仪表盘](#仪表盘) + - [机房管理](#机房管理) + - [资产管理](#资产管理) + - [耗材管理](#耗材管理) + - [工单管理](#工单管理) + - [资产盘点](#资产盘点) + - [系统管理](#系统管理) +- [常见问题](#常见问题) +- [快捷操作参考](#快捷操作参考) + +--- + +## 系统概述 + +### 系统简介 + +IDC设备资产系统是一款现代化的数据中心(IDC)设备管理平台,提供机房、机柜、设备的全生命周期管理,具备3D可视化展示功能。系统采用前后端分离架构,前端基于React + Ant Design构建,后端使用Node.js + Express开发。 + +### 核心功能 + +| 功能模块 | 主要功能 | +|---------|---------| +| **仪表盘** | 数据中心运行状态实时监控,关键指标可视化展示 | +| **机房管理** | 机房信息管理、机柜管理、3D可视化展示 | +| **资产管理** | 设备管理、端口管理、线缆管理、字段配置 | +| **耗材管理** | 耗材库存管理、分类管理、操作日志、统计报表 | +| **工单管理** | 故障报修、维护工单、工单分类、统计报表 | +| **资产盘点** | 盘点计划、盘点任务、暂存设备管理 | +| **系统管理** | 用户管理、角色权限、系统设置 | + +### 系统特点 + +- **全生命周期管理**:设备从采购、安装、运行、维护到报废的全流程跟踪 +- **3D可视化**:三维机柜展示,直观呈现设备布局 +- **灵活扩展**:支持自定义设备字段和工单字段 +- **批量操作**:支持Excel/CSV批量导入导出设备 +- **权限控制**:基于角色的访问控制(RBAC) + +--- + +## 快速入门 + +### 首次登录 + +系统采用**首次注册自动成为管理员**的机制: + +1. 打开浏览器,访问系统地址(如:`http://localhost:3000`) +2. 在登录页面点击「注册账号」链接 +3. 填写注册信息: + - 用户名(必填,用于登录) + - 密码(必填,建议8位以上) + - 邮箱(选填) +4. 点击「注册」按钮完成注册 +5. 系统自动登录,第一个注册的用户自动获得管理员权限 + +> ⚠️ **安全提示**:首次登录后请立即修改密码,确保账号安全。 + +### 界面导航 + +系统采用左侧菜单导航结构: + +``` +├── 仪表盘 # 数据概览 +├── 机房管理 +│ ├── 机房管理 # 机房信息管理 +│ ├── 机柜管理 # 机柜信息管理 +│ └── 3D机柜可视化 # 三维展示 +├── 资产管理 +│ ├── 设备管理 # 设备CRUD操作 +│ ├── 字段管理 # 自定义字段配置 +│ ├── 接线管理 # 线缆连接管理 +│ └── 端口管理 # 设备端口配置 +├── 耗材管理 +│ ├── 耗材统计 # 库存统计报表 +│ ├── 耗材列表 # 耗材库存管理 +│ ├── 分类管理 # 耗材分类配置 +│ └── 操作日志 # 领用记录追踪 +├── 工单管理 +│ ├── 工单列表 # 工单处理 +│ ├── 故障分类 # 故障类型配置 +│ ├── 统计报表 # 工单统计分析 +│ └── 字段管理 # 自定义字段 +├── 资产盘点 +│ ├── 盘点计划 # 盘点任务管理 +│ └── 暂存设备 # 待确认设备 +└── 系统管理 + ├── 用户管理 # 用户账号管理 + └── 系统设置 # 系统参数配置 +``` + +### 基本操作流程 + +``` +1. 创建机房 → 2. 添加机柜 → 3. 录入设备 → 4. 配置端口/线缆 → 5. 日常维护 +``` + +--- + +## 功能模块详解 + +### 仪表盘 + +仪表盘是系统的首页,提供数据中心整体运行状态的可视化展示。 + +#### 功能说明 + +| 区域 | 内容 | +|------|------| +| **统计卡片** | 机房数量、机柜数量、设备总数、运行中设备、维护中设备、已报废设备 | +| **设备状态分布** | 各状态设备占比图表 | +| **机柜容量概览** | 各机房机柜使用率 | +| **待处理工单** | 未完成工单列表 | +| **库存预警** | 低库存耗材提醒 | + +#### 操作说明 + +- **刷新数据**:点击右上角「刷新」按钮获取最新数据 +- **快速跳转**:点击统计卡片可跳转到对应管理页面 + +--- + +### 机房管理 + +#### 机房管理 + +机房管理用于维护数据中心的基础信息。 + +##### 新增机房 + +1. 进入「机房管理」页面 +2. 点击「新增机房」按钮 +3. 填写机房信息: + - **机房名称**(必填):如「北京一号机房」 + - **机房位置**:详细地址 + - **机房面积**:单位平方米 + - **负责人**:机房管理员 + - **联系电话**:紧急联系方式 + - **备注**:其他说明 + - **机房图片**:上传机房照片 +4. 点击「确定」保存 + +##### 编辑机房 + +1. 在机房列表中找到目标机房 +2. 点击操作列的「编辑」按钮 +3. 修改需要更新的信息 +4. 点击「确定」保存 + +##### 删除机房 + +> ⚠️ **注意**:删除机房前需确保该机房下没有机柜和设备。 + +1. 点击操作列的「删除」按钮 +2. 确认删除操作 + +#### 机柜管理 + +机柜管理用于维护机柜信息和查看容量状态。 + +##### 新增机柜 + +1. 进入「机柜管理」页面 +2. 点击「新增机柜」按钮 +3. 填写机柜信息: + - **机柜名称**(必填):如「A01」 + - **所属机房**(必填):选择已有机房 + - **机柜高度**:U数(如42U) + - **机柜位置**:在机房中的位置 + - **备注**:其他说明 +4. 点击「确定」保存 + +##### 容量查看 + +机柜列表显示: +- **总U数**:机柜总容量 +- **已用U数**:已安装设备占用 +- **剩余U数**:可用空间 +- **使用率**:可视化进度条 + +#### 3D机柜可视化 + +3D可视化提供直观的机柜设备布局展示。 + +##### 操作方式 + +| 操作 | 方法 | +|------|------| +| **旋转视角** | 鼠标左键拖拽 | +| **缩放视图** | 鼠标滚轮 | +| **平移视图** | 鼠标右键拖拽 | +| **查看设备** | 鼠标悬停显示设备信息 | +| **设备详情** | 点击设备弹出详情面板 | + +##### 功能说明 + +- **机柜选择**:左侧列表选择要查看的机柜 +- **设备状态**:不同颜色表示不同状态 + - 🟢 绿色:运行中 + - 🟡 黄色:维护中 + - 🔴 红色:故障 + - ⚪ 灰色:已停用 + +--- + +### 资产管理 + +#### 设备管理 + +设备管理是系统的核心功能,提供设备全生命周期管理。 + +##### 设备状态流转 + +``` +采购中 → 待安装 → 运行中 ⇄ 维护中 → 已报废 +``` + +| 状态 | 说明 | +|------|------| +| **采购中** | 设备已采购,尚未到货 | +| **待安装** | 设备到货,等待安装 | +| **运行中** | 设备正常运行 | +| **维护中** | 设备正在维护 | +| **已报废** | 设备已报废处理 | + +##### 新增设备 + +1. 进入「设备管理」页面 +2. 点击「新增设备」按钮 +3. 填写设备信息: + - **基本信息**: + - 设备名称(必填) + - 设备类型:服务器/网络设备/存储设备/其他 + - 设备型号 + - 序列号 + - 品牌 + - **位置信息**: + - 所属机房 + - 所属机柜 + - 起始U位 + - 占用U数 + - **状态信息**: + - 设备状态 + - 购买日期 + - 过保日期 + - **自定义字段**:根据系统配置显示 +4. 点击「确定」保存 + +##### 批量导入设备 + +1. 点击「导入」按钮 +2. 下载导入模板(Excel/CSV格式) +3. 按模板格式填写设备信息 +4. 选择填写好的文件上传 +5. 系统自动解析并导入 + +> 💡 **提示**:导入前请确保机房和机柜信息已创建。 + +##### 设备导出 + +1. 选择需要导出的设备(可多选) +2. 点击「导出」按钮 +3. 选择导出格式(Excel/CSV) +4. 文件自动下载 + +##### 设备详情 + +点击设备名称或「详情」按钮,可查看: +- 设备完整信息 +- 关联端口 +- 网卡信息 +- 操作历史 + +#### 端口管理 + +端口管理用于配置设备的网络端口信息。 + +##### 端口类型 + +| 类型 | 说明 | +|------|------| +| **网口** | 以太网接口(RJ45) | +| **光口** | 光纤接口(SFP/SFP+) | +| **管理口** | 带外管理接口 | +| **其他** | 其他类型接口 | + +##### 配置端口 + +1. 进入「端口管理」页面 +2. 选择设备 +3. 点击「添加端口」 +4. 填写端口信息: + - 端口名称(如 eth0、ens192) + - 端口类型 + - 端口速率(如 1Gbps、10Gbps) + - IP地址 + - MAC地址 + - 关联网卡 + - 状态 + +#### 接线管理 + +接线管理用于记录机柜间的线缆连接关系。 + +##### 新增线缆 + +1. 进入「接线管理」页面 +2. 点击「新增线缆」 +3. 填写线缆信息: + - **线缆名称**:如「A01-B01-光纤-01」 + - **线缆类型**:光纤/网线/电源线 + - **起始端**: + - 源机房/机柜 + - 源设备 + - 源端口 + - **终止端**: + - 目标机房/机柜 + - 目标设备 + - 目标端口 + - **线缆长度**:单位米 + - **备注** + +##### 线缆追踪 + +通过线缆列表可以: +- 查看线缆连接路径 +- 追踪设备间连接关系 +- 导出线缆清单 + +#### 字段管理 + +字段管理用于自定义设备属性,满足不同业务需求。 + +##### 字段类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| **文本** | 单行文本输入 | 资产编号 | +| **数字** | 数值输入 | 功率(W) | +| **日期** | 日期选择 | 维保到期日 | +| **下拉选择** | 固定选项 | 设备等级 | +| **多行文本** | 长文本输入 | 备注说明 | + +##### 添加字段 + +1. 进入「字段管理」页面 +2. 点击「新增字段」 +3. 配置字段属性: + - 字段名称 + - 字段标识(英文,用于系统识别) + - 字段类型 + - 是否必填 + - 默认值 + - 排序权重 +4. 保存后,设备表单将自动显示该字段 + +--- + +### 耗材管理 + +#### 耗材统计 + +耗材统计页面提供库存数据的可视化分析。 + +##### 统计内容 + +- **库存总览**:总数量、总种类、预警数量 +- **分类统计**:各分类耗材数量分布 +- **库存预警**:低于安全库存的耗材列表 +- **领用趋势**:近期耗材领用统计 + +#### 耗材列表 + +耗材列表用于管理耗材库存信息。 + +##### 新增耗材 + +1. 进入「耗材列表」页面 +2. 点击「新增耗材」 +3. 填写耗材信息: + - **耗材名称**(必填) + - **耗材分类** + - **规格型号** + - **计量单位**:个/条/盒等 + - **当前库存** + - **安全库存**:低于此值触发预警 + - **最大库存** + - **存放位置** + - **备注** +4. 保存 + +##### 库存操作 + +| 操作 | 说明 | +|------|------| +| **入库** | 增加库存数量 | +| **出库/领用** | 减少库存数量,记录领用人 | +| **盘点** | 更新实际库存数量 | + +##### 入库操作 + +1. 点击耗材的「入库」按钮 +2. 填写入库信息: + - 入库数量 + - 入库类型(采购入库/归还入库) + - 备注 +3. 确认入库 + +##### 出库操作 + +1. 点击耗材的「出库」按钮 +2. 填写出库信息: + - 出库数量 + - 领用人 + - 用途 + - 备注 +3. 确认出库 + +#### 分类管理 + +分类管理用于维护耗材的分类体系。 + +##### 新增分类 + +1. 进入「分类管理」页面 +2. 点击「新增分类」 +3. 填写分类名称和描述 +4. 保存 + +##### 分类示例 + +``` +├── 网络耗材 +│ ├── 网线 +│ ├── 光纤 +│ └── 跳线 +├── 存储耗材 +│ ├── 硬盘 +│ └── 磁带 +└── 通用耗材 + ├── 扎带 + └── 标签 +``` + +#### 操作日志 + +操作日志记录所有耗材的出入库操作历史。 + +##### 日志内容 + +- 操作时间 +- 操作类型(入库/出库/盘点) +- 耗材名称 +- 操作数量 +- 操作人 +- 领用人(出库时) +- 备注 + +##### 日志查询 + +- 按时间范围筛选 +- 按耗材名称搜索 +- 按操作类型筛选 + +--- + +### 工单管理 + +#### 工单列表 + +工单列表用于处理故障报修和维护工单。 + +##### 工单状态 + +``` +待处理 → 处理中 → 已完成 → 已关闭 +``` + +| 状态 | 说明 | +|------|------| +| **待处理** | 新建工单,等待分配 | +| **处理中** | 正在处理 | +| **已完成** | 问题已解决 | +| **已关闭** | 工单关闭 | + +##### 工单优先级 + +| 优先级 | 说明 | 响应时间 | +|--------|------|----------| +| **紧急** | 严重影响业务 | 立即响应 | +| **高** | 重要问题 | 2小时内 | +| **中** | 一般问题 | 24小时内 | +| **低** | 优化建议 | 一周内 | + +##### 创建工单 + +1. 进入「工单列表」页面 +2. 点击「新建工单」 +3. 填写工单信息: + - **工单标题**:简要描述问题 + - **故障分类**:选择故障类型 + - **优先级**:紧急/高/中/低 + - **关联设备**:选择相关设备(可选) + - **问题描述**:详细说明问题 + - **附件**:上传截图或日志 + - **自定义字段**:根据系统配置填写 +4. 提交工单 + +##### 处理工单 + +1. 点击工单进入详情页 +2. 点击「接单」分配给自己 +3. 更新工单状态为「处理中」 +4. 记录处理过程: + - 点击「添加记录」 + - 填写处理内容 + - 更新工单状态 +5. 问题解决后,点击「完成」 +6. 填写解决方案 + +#### 故障分类 + +故障分类用于标准化故障类型,便于统计分析。 + +##### 新增分类 + +1. 进入「故障分类」页面 +2. 点击「新增分类」 +3. 填写分类名称和描述 +4. 保存 + +##### 分类示例 + +``` +├── 硬件故障 +│ ├── 硬盘故障 +│ ├── 电源故障 +│ └── 风扇故障 +├── 网络故障 +│ ├── 网络中断 +│ └── 配置错误 +└── 系统故障 + ├── 系统崩溃 + └── 服务异常 +``` + +#### 统计报表 + +统计报表提供工单数据的分析视图。 + +##### 报表内容 + +- **工单数量趋势**:按时间统计工单数量 +- **分类统计**:各类型工单占比 +- **处理效率**:平均响应时间、解决时间 +- **人员统计**:各处理人工单数量 + +#### 字段管理 + +工单字段管理用于自定义工单属性。 + +操作方式与设备字段管理相同,参见[字段管理](#字段管理)。 + +--- + +### 资产盘点 + +#### 盘点计划 + +盘点计划用于制定和管理资产盘点任务。 + +##### 创建盘点计划 + +1. 进入「盘点计划」页面 +2. 点击「新建计划」 +3. 填写计划信息: + - **计划名称**:如「2024年第一季度盘点」 + - **盘点范围**: + - 全部资产 + - 指定机房 + - 指定机柜 + - **计划时间**:盘点开始和结束时间 + - **负责人**:盘点负责人 + - **参与人员**:选择盘点人员 + - **备注** +4. 保存计划 + +##### 盘点任务状态 + +| 状态 | 说明 | +|------|------| +| **待执行** | 计划已创建,等待开始 | +| **执行中** | 盘点进行中 | +| **已完成** | 盘点结束 | + +##### 执行盘点 + +1. 在盘点计划列表点击「执行」 +2. 进入盘点执行页面 +3. 逐个确认设备: + - 确认设备存在 + - 核对设备信息 + - 标记异常设备 +4. 提交盘点结果 + +##### 盘点结果 + +- 正常设备数量 +- 异常设备列表 +- 盘点完成率 +- 差异报告 + +#### 暂存设备 + +暂存设备用于管理盘点中发现的新设备或信息不完整的设备。 + +##### 功能说明 + +- 存放未在系统中登记的设备 +- 记录设备基本信息 +- 等待确认后转为正式设备 + +##### 操作流程 + +1. 盘点时发现新设备 +2. 将设备添加到暂存区 +3. 核实设备信息 +4. 确认后转为正式设备 + +--- + +### 系统管理 + +#### 用户管理 + +用户管理用于管理系统用户账号和权限。 + +##### 用户状态 + +| 状态 | 说明 | +|------|------| +| **正常** | 用户可正常登录 | +| **待审核** | 新注册用户,等待管理员审核 | +| **已禁用** | 账号被禁用,无法登录 | + +##### 新增用户 + +1. 进入「用户管理」页面 +2. 点击「新增用户」 +3. 填写用户信息: + - 用户名(必填,唯一) + - 密码 + - 邮箱 + - 手机号 + - 角色 + - 状态 +4. 保存 + +##### 用户审核 + +新注册用户默认为「待审核」状态: + +1. 在用户列表筛选「待审核」用户 +2. 点击「审核」按钮 +3. 选择「通过」或「拒绝」 +4. 通过后用户状态变为「正常」 + +##### 角色权限 + +系统采用RBAC权限模型: + +| 角色 | 权限范围 | +|------|----------| +| **管理员** | 全部功能 | +| **运维人员** | 设备管理、工单处理 | +| **普通用户** | 查看权限 | + +#### 系统设置 + +系统设置用于配置系统参数。 + +##### 可配置项 + +| 设置项 | 说明 | +|--------|------| +| **站点名称** | 系统显示名称 | +| **Logo** | 系统Logo图片 | +| **空闲超时** | 无操作自动退出时间 | +| **背景图片** | 登录页背景 | + +##### 修改设置 + +1. 进入「系统设置」页面 +2. 修改需要更新的配置 +3. 点击「保存」 + +--- + +## 常见问题 + +### 登录相关 + +**Q: 忘记密码怎么办?** + +A: 请联系管理员重置密码。管理员可在「用户管理」中为用户重置密码。 + +**Q: 账号被锁定怎么办?** + +A: 多次密码错误会导致账号临时锁定,请等待30分钟后重试,或联系管理员解锁。 + +**Q: 为什么注册后无法登录?** + +A: 新注册用户需要管理员审核后才能激活。请联系管理员进行审核。 + +### 设备管理 + +**Q: 如何批量修改设备信息?** + +A: 目前支持批量导入更新。导出设备列表,修改后重新导入即可。 + +**Q: 设备删除后能恢复吗?** + +A: 设备删除是软删除,数据仍保留在数据库中。如需恢复,请联系管理员。 + +**Q: 为什么导入失败?** + +A: 请检查: +1. 文件格式是否正确(Excel/CSV) +2. 必填字段是否完整 +3. 机房/机柜名称是否匹配 +4. 数据格式是否符合要求 + +### 3D可视化 + +**Q: 3D页面加载很慢怎么办?** + +A: 可能原因: +1. 设备数量较多,建议分机房查看 +2. 浏览器性能限制,建议使用Chrome最新版 +3. 网络带宽限制 + +**Q: 设备位置显示不正确?** + +A: 请检查设备的起始U位和占用U数设置是否正确。 + +### 耗材管理 + +**Q: 库存预警如何设置?** + +A: 在耗材编辑页面设置「安全库存」值,当库存低于此值时系统会自动预警。 + +**Q: 出库后可以撤销吗?** + +A: 出库操作记录在日志中,如需撤销可执行入库操作,并在备注中说明原因。 + +### 工单管理 + +**Q: 如何分配工单?** + +A: 工单创建后,处理人可以在工单详情页点击「接单」自行分配,或由管理员指定。 + +**Q: 工单关闭后还能重新打开吗?** + +A: 已关闭的工单无法重新打开,如有新问题请新建工单。 + +--- + +## 快捷操作参考 + +### 键盘快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Ctrl + S` | 保存当前表单 | +| `Esc` | 关闭弹窗/抽屉 | +| `Enter` | 确认操作 | + +### 表格操作 + +| 操作 | 方法 | +|------|------| +| **多选** | 勾选复选框 | +| **全选** | 勾选表头复选框 | +| **排序** | 点击列标题 | +| **筛选** | 使用列筛选器 | +| **分页** | 底部分页器 | + +### 搜索技巧 + +- 支持模糊搜索 +- 可组合多个条件筛选 +- 时间范围选择器快速筛选 + +--- + +## 技术支持 + +如遇到问题,请通过以下方式获取帮助: + +- **问题反馈**:提交 Issue 到代码仓库 +- **功能建议**:提交 Issue 并标注 `feature-request` + +**代码仓库:** +- Gitee: https://gitee.com/zhang96110/idc_assest +- GitHub: https://github.com/gituib/idc_assest + +--- + +**文档版本**:v1.0.0 +**最后更新**:2026年3月 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e1b612c..cf3b870 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,6 +34,7 @@ import { ApiOutlined, PartitionOutlined, CodepenOutlined, + CloudUploadOutlined, } from '@ant-design/icons'; import { BrowserRouter as Router, @@ -73,6 +74,7 @@ const CableManagement = lazy(() => import('./pages/CableManagement')); const PortManagement = lazy(() => import('./pages/PortManagement')); const InventoryManagement = lazy(() => import('./pages/InventoryManagement')); const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution')); +const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement')); const { Header, Content, Sider } = Layout; @@ -199,6 +201,7 @@ const AppLayout = ({ children }) => { ) return 'system-management'; if (path.startsWith('/tickets')) return 'ticket-management'; + if (path.startsWith('/inventory') || path.startsWith('/pending-devices')) return 'inventory-management'; return 'dashboard'; }; @@ -321,6 +324,11 @@ const AppLayout = ({ children }) => { icon: , label: 盘点计划, }, + { + key: 'pending-devices', + icon: , + label: 暂存设备, + }, ], }, { @@ -721,6 +729,14 @@ const ThemeConfig = () => { } /> + + + + } + /> { function DeviceManagement() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const [devices, setDevices] = useState([]); const [allDevices, setAllDevices] = useState([]); const [racks, setRacks] = useState([]); @@ -517,6 +518,35 @@ function DeviceManagement() { fetchDeviceFields(); }, [fetchDevices]); + // 处理URL参数中的设备ID,自动打开设备详情 + useEffect(() => { + const deviceIdFromUrl = searchParams.get('deviceId'); + if (deviceIdFromUrl) { + const fetchDeviceDetail = async () => { + try { + const response = await axios.get(`/api/devices/${deviceIdFromUrl}`); + if (response.data) { + setSelectedDevice(response.data); + setDetailModalVisible(true); + setSearchParams({}); + } + } catch (error) { + console.error('获取设备详情失败:', error); + const errorStatus = error.response?.status; + const errorMessage = error.response?.data?.error; + + if (errorStatus === 404 || errorMessage === '设备不存在') { + message.warning('该设备已被删除,无法查看详情'); + } else { + message.error(errorMessage || '获取设备详情失败'); + } + setSearchParams({}); + } + }; + fetchDeviceDetail(); + } + }, [searchParams, setSearchParams]); + // 同步当前页设备数据 useEffect(() => { if (filteredDevicesMemo.length > 0) { diff --git a/frontend/src/pages/InventoryTaskExecution.jsx b/frontend/src/pages/InventoryTaskExecution.jsx index e88612c..be2c415 100644 --- a/frontend/src/pages/InventoryTaskExecution.jsx +++ b/frontend/src/pages/InventoryTaskExecution.jsx @@ -6,6 +6,8 @@ import { Form, Input, Select, + InputNumber, + DatePicker, message, Card, Space, @@ -34,7 +36,7 @@ import { } from '@ant-design/icons'; import axios from 'axios'; import dayjs from 'dayjs'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import { designTokens } from '../config/theme'; const api = axios.create({ @@ -51,6 +53,7 @@ api.interceptors.request.use((config) => { const InventoryTaskExecution = () => { const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); const [plan, setPlan] = useState(null); const [tasks, setTasks] = useState([]); const [currentTask, setCurrentTask] = useState(null); @@ -73,6 +76,7 @@ const InventoryTaskExecution = () => { const [quickAddLoading, setQuickAddLoading] = useState(false); const [rooms, setRooms] = useState([]); const [racks, setRacks] = useState([]); + const [deviceFields, setDeviceFields] = useState(null); // 初始值改为 null,表示未加载 const [selectedRoomId, setSelectedRoomId] = useState(null); const planId = searchParams.get('planId'); @@ -252,6 +256,41 @@ const InventoryTaskExecution = () => { fetchPlan(); }, [fetchPlan]); + const defaultDeviceFields = [ + { fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: true, visible: true, order: 1 }, + { fieldName: 'deviceType', displayName: '设备类型', fieldType: 'select', required: true, visible: true, order: 2, options: [ + { value: 'server', label: '服务器' }, + { value: 'switch', label: '交换机' }, + { value: 'router', label: '路由器' }, + { value: 'storage', label: '存储设备' }, + { value: 'other', label: '其他' }, + ]}, + { fieldName: 'model', displayName: '型号', fieldType: 'string', required: false, visible: true, order: 3 }, + { fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, visible: true, order: 4 }, + { fieldName: 'roomId', displayName: '所属机房', fieldType: 'select', required: false, visible: true, order: 5 }, + { fieldName: 'rackId', displayName: '所属机柜', fieldType: 'select', required: false, visible: true, order: 6 }, + { fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: false, visible: true, order: 7 }, + { fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: false, visible: true, order: 8 }, + { fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: false, visible: true, order: 9 }, + { fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: false, visible: true, order: 10 }, + { fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: false, visible: true, order: 11 }, + { fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, visible: true, order: 12 }, + { fieldName: 'brand', displayName: '品牌', fieldType: 'string', required: false, visible: true, order: 13 }, + { fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, visible: true, order: 14 }, + { fieldName: 'remark', displayName: '备注', fieldType: 'textarea', required: false, visible: true, order: 15 }, + ]; + + const fetchDeviceFields = async () => { + try { + const res = await api.get('/deviceFields'); + const sortedFields = res.data.sort((a, b) => a.order - b.order); + setDeviceFields(sortedFields); + } catch (error) { + console.error('获取字段配置失败:', error); + setDeviceFields(defaultDeviceFields); + } + }; + const fetchRooms = async () => { try { const res = await api.get('/rooms'); @@ -263,7 +302,7 @@ const InventoryTaskExecution = () => { const fetchRacks = async () => { try { - const res = await api.get('/racks'); + const res = await api.get('/racks', { params: { pageSize: 1000 } }); setRacks(res.data.racks || res.data || []); } catch (error) { console.error('获取机柜列表失败', error); @@ -273,44 +312,260 @@ const InventoryTaskExecution = () => { useEffect(() => { fetchRooms(); fetchRacks(); + fetchDeviceFields(); }, []); const filteredRacks = selectedRoomId ? racks.filter(rack => rack.roomId === selectedRoomId) : racks; + const renderQuickAddFormField = (field) => { + const { fieldName, displayName, fieldType, required, options } = field; + + // 统一的表单项样式 + const formItemStyle = { + marginBottom: 16, + }; + + // 统一的输入框样式 + const inputStyle = { + borderRadius: designTokens.borderRadius.medium, + }; + + if (fieldName === 'roomId' || fieldName === 'rackId') { + // 机房和机柜字段已在Modal中单独处理 + return null; + } + + if (fieldName === 'deviceType') { + return ( + + + + ); + } + + if (fieldType === 'select') { + return ( + + + + ); + } + + if (fieldType === 'number') { + return ( + + + + ); + } + + if (fieldType === 'date') { + return ( + + + + ); + } + + if (fieldType === 'text' || fieldType === 'textarea') { + return ( + + + + ); + } + + return ( + + + + ); + }; + + const renderQuickAddFormFields = () => { + // 如果字段配置未加载,使用默认配置 + const fields = deviceFields || defaultDeviceFields; + + // 排除机房和机柜字段(已在Modal中单独渲染) + const visibleFields = fields.filter(f => + f.visible !== false && + f.fieldName !== 'serialNumber' && + f.fieldName !== 'roomId' && + f.fieldName !== 'rackId' + ); + const sortedFields = visibleFields.sort((a, b) => a.order - b.order); + + // 将字段分成两列 + const leftFields = []; + const rightFields = []; + const fullFields = []; + + sortedFields.forEach((field, index) => { + // 文本域类型独占一行 + if (field.fieldType === 'text') { + fullFields.push(field); + } else { + // 其他字段交替排列 + if (index % 2 === 0) { + leftFields.push(field); + } else { + rightFields.push(field); + } + } + }); + + return ( + <> + {/* 两列布局的字段 */} + + + {leftFields.map(renderQuickAddFormField)} + + + {rightFields.map(renderQuickAddFormField)} + + + + {/* 独占一行的字段(如备注) */} + {fullFields.map(renderQuickAddFormField)} + + ); + }; + const handleQuickAddDevice = async (values) => { - if (!currentTask || !plan) { - message.error('请先选择盘点任务'); + if (!plan) { + message.error('盘点计划不存在'); return; } setQuickAddLoading(true); try { - const res = await api.post('/inventory/quick-add-device', { - taskId: currentTask.taskId, + const { + purchaseDate, + warrantyExpiry, + ...otherValues + } = values; + + const payload = { + taskId: currentTask?.taskId || null, planId: plan.planId, serialNumber: scanResult?.sn || values.serialNumber, - deviceName: values.deviceName, - deviceType: values.deviceType, - rackId: values.rackId, - position: values.position, - remark: values.remark - }); - message.success('设备添加成功!'); + ...otherValues, + purchaseDate: purchaseDate ? dayjs(purchaseDate).toISOString() : null, + warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null + }; + + const res = await api.post('/inventory/quick-add-device', payload); + message.success('设备已暂存!'); setQuickAddModalVisible(false); + setSelectedRoomId(null); quickAddForm.resetFields(); setScanResult({ success: true, - message: `新设备 "${res.data.device.name}" 已添加并完成盘点!`, - record: res.data.record, - sn: res.data.device.serialNumber + message: `设备 "${res.data.pendingDevice.deviceName}" 已暂存,请前往「暂存设备」页面完善信息后同步到设备管理`, + pendingDevice: res.data.pendingDevice, + sn: res.data.pendingDevice.serialNumber }); - if (currentTask) { - fetchTaskRecords(currentTask.taskId); - } - fetchPlan(); } catch (error) { - message.error(error.response?.data?.error || '添加设备失败'); + message.error(error.response?.data?.error || '暂存设备失败'); } finally { setQuickAddLoading(false); } @@ -887,129 +1142,308 @@ const InventoryTaskExecution = () => {
- setScanInput(e.target.value)} - onKeyDown={handleScanKeyPress} - variant="outlined" - style={{ - fontSize: 16, - textAlign: 'center', - height: 40, - borderRadius: 8, - boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)' - }} - suffix={ - - } - autoFocus - /> +
+ setScanInput(e.target.value)} + onKeyDown={handleScanKeyPress} + variant="outlined" + style={{ + fontSize: 16, + textAlign: 'center', + height: 40, + borderRadius: 8, + boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)', + width: 400 + }} + suffix={ + + } + autoFocus + /> +
扫码枪扫描或手动输入序列号后按回车 / 点击盘点
{scanResult && ( -
-
- {scanResult.success ? ( -
- -
- ) : ( -
- -
- )} -
-
+ {/* 顶部状态条 */} +
- {scanResult.message} -
- {scanResult.record && ( -
- - -
- 设备ID - {scanResult.record.deviceId} -
-
- 设备名称 - {scanResult.record.deviceName} + {scanResult.success ? ( + + ) : ( + + )} +
+
+
+ {scanResult.success ? '盘点成功' : '未找到设备'} +
+
+ {scanResult.message} +
+
+
+ + {/* 设备信息卡片 */} + {scanResult.record && ( +
+
+ 设备信息 +
+ + +
+
+ 设备ID +
+
+ {scanResult.record.deviceId} +
- -
- 序列号 - {scanResult.record.serialNumber} + +
+
+ 设备名称 +
+
+ {scanResult.record.deviceName} +
-
- 当前位置 - {scanResult.record.displayLocation || `${scanResult.record.rackId} - U${scanResult.record.position}`} + + +
+
+ 序列号 +
+
+ {scanResult.record.serialNumber} +
+
+ + +
+
+ 当前位置 +
+
+ + {scanResult.record.displayLocation || `${scanResult.record.rackId}`} + + U{scanResult.record.position} +
)} + + {/* 暂存设备信息 */} + {scanResult.pendingDevice && ( +
+
+ 暂存设备信息 +
+ + +
+
+ 暂存ID +
+
+ {scanResult.pendingDevice.pendingId} +
+
+ + +
+
+ 设备名称 +
+
+ {scanResult.pendingDevice.deviceName} +
+
+ + +
+
+ 序列号 +
+
+ {scanResult.pendingDevice.serialNumber} +
+
+ + +
+
+ 设备类型 +
+
+ + {scanResult.pendingDevice.deviceType} + +
+
+ +
+
+ +
+
+ )} + + {/* 快速添加按钮 */} {!scanResult.success && scanResult.sn && ( -
+
)} -
+ )} {scanResult && ( @@ -1136,127 +1570,253 @@ const InventoryTaskExecution = () => { - - 快速添加设备 -
- } + title={null} open={quickAddModalVisible} onCancel={() => { setQuickAddModalVisible(false); setSelectedRoomId(null); }} footer={null} - width={500} + width={640} centered + styles={{ + body: { padding: 0 }, + }} > -
- - - +
+
+
+ +
+
+
+ 快速添加设备 +
+
+ 将扫描到的设备添加到系统 +
+
+
+
- + - - + {/* 序列号显示 - 突出显示 */} + + 设备序列号 + + } + rules={[{ required: true, message: '请输入序列号' }]} + > + + - - - + {/* 位置信息区域 */} +
+
+ + 位置信息(可选) +
+ + + + + + + + + + + + + +
- - - +
+ + 设备详细信息 +
+ + {renderQuickAddFormFields()} +
+ + {/* 操作按钮 */} +
+ - - - - + +
+ +
); diff --git a/frontend/src/pages/PendingDeviceManagement.jsx b/frontend/src/pages/PendingDeviceManagement.jsx new file mode 100644 index 0000000..1daaf64 --- /dev/null +++ b/frontend/src/pages/PendingDeviceManagement.jsx @@ -0,0 +1,881 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Table, + Button, + Modal, + Form, + Input, + Select, + InputNumber, + DatePicker, + message, + Card, + Space, + Tag, + Row, + Col, + Statistic, + Progress, + Descriptions, + Empty, + Tooltip, + Popconfirm, +} from 'antd'; +import { + SyncOutlined, + EditOutlined, + DeleteOutlined, + SearchOutlined, + ReloadOutlined, + InboxOutlined, + CheckCircleOutlined, + CloudUploadOutlined, + CloudServerOutlined, +} from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; +import { designTokens } from '../config/theme'; + +const api = axios.create({ + baseURL: '/api', +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +const PendingDeviceManagement = () => { + const navigate = useNavigate(); + const [pendingDevices, setPendingDevices] = useState([]); + const [rooms, setRooms] = useState([]); + const [racks, setRacks] = useState([]); + const [plans, setPlans] = useState([]); + const [deviceFields, setDeviceFields] = useState(null); // 初始值改为 null,表示未加载 + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState({ total: 0, pending: 0, synced: 0 }); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [editModalVisible, setEditModalVisible] = useState(false); + const [currentDevice, setCurrentDevice] = useState(null); + const [form] = Form.useForm(); + const [selectedRoomId, setSelectedRoomId] = useState(null); + + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 20, + total: 0, + }); + + const [filters, setFilters] = useState({ + status: null, + planId: null, + roomId: null, + keyword: '', + }); + + const defaultDeviceFields = [ + { fieldName: 'deviceName', displayName: '设备名称', fieldType: 'string', required: true, visible: true, order: 1 }, + { fieldName: 'deviceType', displayName: '设备类型', fieldType: 'select', required: true, visible: true, order: 2, options: [ + { value: 'server', label: '服务器' }, + { value: 'switch', label: '交换机' }, + { value: 'router', label: '路由器' }, + { value: 'storage', label: '存储设备' }, + { value: 'other', label: '其他' }, + ]}, + { fieldName: 'model', displayName: '型号', fieldType: 'string', required: false, visible: true, order: 3 }, + { fieldName: 'serialNumber', displayName: '序列号', fieldType: 'string', required: true, visible: true, order: 4 }, + { fieldName: 'roomId', displayName: '所属机房', fieldType: 'select', required: false, visible: true, order: 5 }, + { fieldName: 'rackId', displayName: '所属机柜', fieldType: 'select', required: false, visible: true, order: 6 }, + { fieldName: 'position', displayName: '位置(U)', fieldType: 'number', required: false, visible: true, order: 7 }, + { fieldName: 'height', displayName: '高度(U)', fieldType: 'number', required: false, visible: true, order: 8 }, + { fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', required: false, visible: true, order: 9 }, + { fieldName: 'purchaseDate', displayName: '购买日期', fieldType: 'date', required: false, visible: true, order: 10 }, + { fieldName: 'warrantyExpiry', displayName: '保修到期', fieldType: 'date', required: false, visible: true, order: 11 }, + { fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, visible: true, order: 12 }, + { fieldName: 'brand', displayName: '品牌', fieldType: 'string', required: false, visible: true, order: 13 }, + { fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, visible: true, order: 14 }, + { fieldName: 'remark', displayName: '备注', fieldType: 'textarea', required: false, visible: true, order: 15 }, + ]; + + const fetchDeviceFields = async () => { + try { + const res = await api.get('/deviceFields'); + const sortedFields = res.data.sort((a, b) => a.order - b.order); + setDeviceFields(sortedFields); + } catch (error) { + console.error('获取字段配置失败:', error); + setDeviceFields(defaultDeviceFields); + } + }; + + const fetchPendingDevices = useCallback(async () => { + setLoading(true); + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + ...filters, + }; + Object.keys(params).forEach(key => { + if (params[key] === null || params[key] === '' || params[key] === undefined) { + delete params[key]; + } + }); + + const res = await api.get('/inventory/pending-devices', { params }); + setPendingDevices(res.data.pendingDevices || []); + setPagination(prev => ({ ...prev, total: res.data.total })); + } catch (error) { + message.error('获取暂存设备列表失败'); + } finally { + setLoading(false); + } + }, [pagination.current, pagination.pageSize, filters]); + + const fetchStats = async () => { + try { + const res = await api.get('/inventory/pending-devices/stats'); + setStats(res.data); + } catch (error) { + console.error('获取统计失败', error); + } + }; + + const fetchRooms = async () => { + try { + const res = await api.get('/rooms'); + setRooms(res.data.rooms || res.data || []); + } catch (error) { + console.error('获取机房列表失败', error); + } + }; + + const fetchRacks = async () => { + try { + const res = await api.get('/racks', { params: { pageSize: 1000 } }); + setRacks(res.data.racks || res.data || []); + } catch (error) { + console.error('获取机柜列表失败', error); + } + }; + + const fetchPlans = async () => { + try { + const res = await api.get('/inventory/plans', { params: { pageSize: 100 } }); + setPlans(res.data.plans || []); + } catch (error) { + console.error('获取盘点计划列表失败', error); + } + }; + + useEffect(() => { + fetchPendingDevices(); + fetchStats(); + }, [fetchPendingDevices]); + + useEffect(() => { + fetchRooms(); + fetchRacks(); + fetchPlans(); + fetchDeviceFields(); + }, []); + + const filteredRacks = selectedRoomId + ? racks.filter(rack => rack.roomId === selectedRoomId) + : racks; + + const handleSync = async (pendingId) => { + try { + const res = await api.post(`/inventory/pending-devices/${pendingId}/sync`); + message.success(res.data.message); + fetchPendingDevices(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '同步失败'); + } + }; + + const handleBatchSync = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择要同步的设备'); + return; + } + + try { + const res = await api.post('/inventory/pending-devices/batch-sync', { + pendingIds: selectedRowKeys, + }); + message.success(res.data.message); + setSelectedRowKeys([]); + fetchPendingDevices(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '批量同步失败'); + } + }; + + const handleEdit = (record) => { + setCurrentDevice(record); + setSelectedRoomId(record.roomId); + + // 字段名映射:数据库字段名 -> PendingDevice 模型字段名 + const fieldMapping = { + 'name': 'deviceName', + 'type': 'deviceType', + 'SN': 'serialNumber', + }; + + // 确保使用 deviceFields 中定义的字段名来设置表单值 + const formValues = { + serialNumber: record.serialNumber, + roomId: record.roomId, + rackId: record.rackId, + }; + + // 使用已加载的字段配置或默认配置 + const fields = deviceFields || defaultDeviceFields; + + // 遍历字段,将 record 中对应的值设置到表单 + fields.forEach(field => { + const fieldName = field.fieldName; + // 获取实际在 record 中的字段名(使用映射) + const recordFieldName = fieldMapping[fieldName] || fieldName; + if (record[recordFieldName] !== undefined && record[recordFieldName] !== null) { + formValues[fieldName] = record[recordFieldName]; + } + }); + + // 处理自定义字段 + if (record.customFields) { + Object.entries(record.customFields).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + formValues[key] = value; + } + }); + } + + form.setFieldsValue(formValues); + setEditModalVisible(true); + }; + + const handleEditSubmit = async (values) => { + try { + const { purchaseDate, warrantyExpiry, ...otherValues } = values; + + const payload = { + ...otherValues, + purchaseDate: purchaseDate ? dayjs(purchaseDate).toISOString() : null, + warrantyExpiry: warrantyExpiry ? dayjs(warrantyExpiry).toISOString() : null + }; + + await api.put(`/inventory/pending-devices/${currentDevice.pendingId}`, payload); + message.success('更新成功'); + setEditModalVisible(false); + setSelectedRoomId(null); + fetchPendingDevices(); + } catch (error) { + message.error(error.response?.data?.error || '更新失败'); + } + }; + + const handleDelete = async (pendingId) => { + try { + await api.delete(`/inventory/pending-devices/${pendingId}`); + message.success('删除成功'); + fetchPendingDevices(); + fetchStats(); + } catch (error) { + message.error(error.response?.data?.error || '删除失败'); + } + }; + + const getStatusTag = (status) => { + const statusMap = { + pending: { color: 'processing', text: '待同步', icon: }, + synced: { color: 'success', text: '已同步', icon: }, + }; + const config = statusMap[status] || statusMap.pending; + return {config.text}; + }; + + const getTypeLabel = (type) => { + const typeMap = { + server: '服务器', + switch: '交换机', + router: '路由器', + storage: '存储设备', + other: '其他', + }; + return typeMap[type] || type; + }; + + const renderFormField = (field) => { + const { fieldName, displayName, fieldType, required, options } = field; + + if (fieldName === 'roomId') { + return ( + + + + ); + } + + if (fieldName === 'rackId') { + const hasRoom = selectedRoomId || rooms.length === 0; + return ( + + + + ); + } + + if (fieldName === 'deviceType') { + return ( + + + + ); + } + + if (fieldType === 'select') { + return ( + + + + ); + } + + if (fieldType === 'number') { + return ( + + + + ); + } + + if (fieldType === 'date') { + return ( + + + + ); + } + + if (fieldType === 'text' || fieldType === 'textarea') { + return ( + + + + ); + } + + return ( + + + + ); + }; + + const renderFormFields = () => { + // 如果字段配置未加载,使用默认配置 + const fields = deviceFields || defaultDeviceFields; + + // 排除机房和机柜字段(已在Modal中单独渲染) + const textFields = []; // 描述、备注等全文本字段 + const otherFields = []; // 其他字段 + + fields.forEach(field => { + // 排除 roomId 和 rackId + if (field.fieldName === 'roomId' || field.fieldName === 'rackId') { + return; + } + if (field.visible !== false) { + if (field.fieldType === 'text') { + textFields.push(field); + } else { + otherFields.push(field); + } + } + }); + + return ( + <> + {/* 其他字段两列布局 */} + {otherFields.length > 0 && ( + + + {otherFields.filter((_, i) => i % 2 === 0).map(renderFormField)} + + + {otherFields.filter((_, i) => i % 2 === 1).map(renderFormField)} + + + )} + + {/* 文本字段全文本显示 */} + {textFields.map(renderFormField)} + + ); + }; + + const columns = [ + { + title: '设备信息', + key: 'deviceInfo', + width: 200, + render: (_, record) => ( +
+
{record.deviceName}
+
+ {record.serialNumber} +
+
+ ), + }, + { + title: '设备类型', + dataIndex: 'deviceType', + key: 'deviceType', + width: 100, + render: (type) => getTypeLabel(type), + }, + { + title: '位置', + key: 'location', + width: 180, + render: (_, record) => ( + + {record.Room?.name || '-'} + {record.Rack ? ` / ${record.Rack.name}` : ''} + {record.position ? ` / U${record.position}` : ''} + + ), + }, + { + title: '盘点计划', + dataIndex: ['Plan', 'name'], + key: 'planName', + width: 150, + render: (name) => name || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status) => getStatusTag(status), + }, + { + title: '创建人', + dataIndex: ['Creator', 'realName'], + key: 'creator', + width: 100, + render: (name) => name || '-', + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 140, + render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '同步时间', + dataIndex: 'syncedAt', + key: 'syncedAt', + width: 140, + render: (date) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '操作', + key: 'action', + width: 180, + fixed: 'right', + render: (_, record) => ( + + {record.status === 'pending' && ( + <> + + + + + + + ({ + disabled: record.status !== 'pending', + }), + }} + columns={columns} + dataSource={pendingDevices} + rowKey="pendingId" + loading={loading} + pagination={{ + ...pagination, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total) => `共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination(prev => ({ ...prev, current: page, pageSize })); + }, + }} + scroll={{ x: 1400 }} + locale={{ + emptyText: , + }} + /> + + + { + setEditModalVisible(false); + setSelectedRoomId(null); + }} + footer={null} + width={600} + > +
+ + + {currentDevice?.serialNumber} + + + {getStatusTag(currentDevice?.status)} + + + + {/* 机房机柜选择 - 始终显示 */} + +
+ + + + + + + + + + + + {renderFormFields()} + + + + + + + + + + + ); +}; + +export default PendingDeviceManagement;