feat(资产盘点): 新增暂存设备管理功能
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
+344
-56
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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月
|
||||
@@ -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: <InboxOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/inventory">盘点计划</Link>,
|
||||
},
|
||||
{
|
||||
key: 'pending-devices',
|
||||
icon: <CloudUploadOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/pending-devices">暂存设备</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -721,6 +729,14 @@ const ThemeConfig = () => {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/pending-devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PendingDeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ports"
|
||||
element={
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { designTokens } from '../config/theme';
|
||||
import {
|
||||
PAGINATION_CONFIG,
|
||||
@@ -226,6 +226,7 @@ const ResizableTitle = props => {
|
||||
|
||||
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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: <SyncOutlined spin /> },
|
||||
synced: { color: 'success', text: '已同步', icon: <CheckCircleOutlined /> },
|
||||
};
|
||||
const config = statusMap[status] || statusMap.pending;
|
||||
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select
|
||||
placeholder={`请选择${displayName}`}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
onChange={(value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldsValue({ rackId: undefined });
|
||||
}}
|
||||
>
|
||||
{rooms.length > 0 ? (
|
||||
rooms.map(room => (
|
||||
<Select.Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>暂无机房数据</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldName === 'rackId') {
|
||||
const hasRoom = selectedRoomId || rooms.length === 0;
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedRoomId ? `请选择${displayName}` : "请先选择机房"}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
disabled={!selectedRoomId}
|
||||
>
|
||||
{selectedRoomId ? (
|
||||
filteredRacks.length > 0 ? (
|
||||
filteredRacks.map(rack => (
|
||||
<Select.Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
|
||||
)
|
||||
) : (
|
||||
<Select.Option value="" disabled>请先选择机房</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldName === 'deviceType') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`}>
|
||||
{(options || [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
{ value: 'storage', label: '存储设备' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`} allowClear>
|
||||
{(options || []).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'number') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<InputNumber placeholder={`请输入${displayName}`} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} placeholder={`请选择${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'text' || fieldType === 'textarea') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder={`请输入${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
label={displayName}
|
||||
rules={required ? [{ required: true, message: `请输入${displayName}` }] : []}
|
||||
>
|
||||
<Input placeholder={`请输入${displayName}`} />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
{otherFields.filter((_, i) => i % 2 === 0).map(renderFormField)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{otherFields.filter((_, i) => i % 2 === 1).map(renderFormField)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 文本字段全文本显示 */}
|
||||
{textFields.map(renderFormField)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备信息',
|
||||
key: 'deviceInfo',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{record.deviceName}</div>
|
||||
<div style={{ fontSize: '12px', color: designTokens.colors.text.tertiary }}>
|
||||
<code>{record.serialNumber}</code>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'deviceType',
|
||||
key: 'deviceType',
|
||||
width: 100,
|
||||
render: (type) => getTypeLabel(type),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
key: 'location',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
{record.Room?.name || '-'}
|
||||
{record.Rack ? ` / ${record.Rack.name}` : ''}
|
||||
{record.position ? ` / U${record.position}` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Tooltip title="同步到设备管理">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => handleSync(record.pendingId)}
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
style={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'synced' && record.syncedDeviceId && (
|
||||
<Tooltip title="查看设备">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloudServerOutlined />}
|
||||
onClick={() => navigate(`/devices?deviceId=${record.syncedDeviceId}`)}
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除此暂存设备吗?"
|
||||
onConfirm={() => handleDelete(record.pendingId)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="暂存设备总数"
|
||||
value={stats.total}
|
||||
prefix={<InboxOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="待同步"
|
||||
value={stats.pending}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
prefix={<SyncOutlined spin />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="已同步"
|
||||
value={stats.synced}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title="同步进度"
|
||||
value={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
|
||||
suffix="%"
|
||||
/>
|
||||
<Progress
|
||||
percent={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="搜索序列号/设备名称"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 200 }}
|
||||
value={filters.keyword}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, keyword: e.target.value }))}
|
||||
onPressEnter={() => setPagination(prev => ({ ...prev, current: 1 }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Select.Option value="pending">待同步</Select.Option>
|
||||
<Select.Option value="synced">已同步</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="盘点计划"
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
value={filters.planId}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, planId: value }))}
|
||||
>
|
||||
{plans.map(plan => (
|
||||
<Select.Option key={plan.planId} value={plan.planId}>
|
||||
{plan.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleBatchSync}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量同步 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
getCheckboxProps: (record) => ({
|
||||
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: <Empty description="暂无暂存设备" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="编辑暂存设备"
|
||||
open={editModalVisible}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setSelectedRoomId(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleEditSubmit}
|
||||
>
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="序列号">
|
||||
<code>{currentDevice?.serialNumber}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{getStatusTag(currentDevice?.status)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 机房机柜选择 - 始终显示 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="roomId"
|
||||
label="所属机房"
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择机房"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
onChange={(value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldsValue({ rackId: undefined });
|
||||
}}
|
||||
>
|
||||
{rooms.length > 0 ? (
|
||||
rooms.map(room => (
|
||||
<Select.Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>暂无机房数据</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="rackId"
|
||||
label="所属机柜"
|
||||
>
|
||||
<Select
|
||||
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
disabled={!selectedRoomId}
|
||||
>
|
||||
{selectedRoomId ? (
|
||||
filteredRacks.length > 0 ? (
|
||||
filteredRacks.map(rack => (
|
||||
<Select.Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Select.Option>
|
||||
))
|
||||
) : (
|
||||
<Select.Option value="" disabled>该机房下无机柜</Select.Option>
|
||||
)
|
||||
) : (
|
||||
<Select.Option value="" disabled>请先选择机房</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{renderFormFields()}
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button onClick={() => {
|
||||
setEditModalVisible(false);
|
||||
setSelectedRoomId(null);
|
||||
}}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingDeviceManagement;
|
||||
Reference in New Issue
Block a user