Files
yunrui_asset/backend/routes/devices.js
T
zhang1106 4e8d9be864 feat: 添加工单统计自动刷新功能并优化设备选择逻辑
refactor(工单管理): 重构设备选择表单并优化UI体验

fix(工单统计): 修复每日完成工单统计不准确的问题

perf(后端): 添加批量导出接口优化大数据量查询性能

chore: 添加react-transition-group依赖支持动画效果

feat(设备管理): 添加设备全量查询接口支持导出功能

style(工单管理): 优化表单布局和样式提升用户体验

refactor(工单字段): 优化字段管理组件处理空值情况

fix(机柜管理): 修复机柜列表查询接口参数问题

docs: 移除不再使用的工单字段管理页面
2026-03-24 17:10:38 +08:00

2284 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
const { sequelize, dbDialect } = require('../db');
const fs = require('fs');
const path = require('path');
const csv = require('csv-parser');
const { createObjectCsvWriter } = require('csv-writer');
const iconv = require('iconv-lite');
const Device = require('../models/Device');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
const DeviceField = require('../models/DeviceField');
const Ticket = require('../models/Ticket');
const DevicePort = require('../models/DevicePort');
const Cable = require('../models/Cable');
const NetworkCard = require('../models/NetworkCard');
const InventoryRecord = require('../models/InventoryRecord');
const { logDeviceOperation } = require('../utils/operationLogger');
const { validateBody, validateQuery } = require('../middleware/validation');
const {
createDeviceSchema,
updateDeviceSchema,
batchDeviceIdsSchema,
batchStatusSchema,
batchMoveSchema,
queryDeviceSchema
} = require('../validation/deviceSchema');
Device.belongsTo(Rack, { foreignKey: 'rackId' });
Rack.hasMany(Device, { foreignKey: 'rackId' });
Rack.belongsTo(Room, { foreignKey: 'roomId' });
Room.hasMany(Rack, { foreignKey: 'roomId' });
const PREVIEW_COUNT = 20;
async function checkPositionAvailable(rackId, position, height, excludeDeviceId = null, transaction = null) {
if (!position || position <= 0) {
return { available: true, reason: null };
}
const deviceHeight = height || 1;
const startU = position;
const endU = position + deviceHeight - 1;
const queryOptions = {
where: {
rackId: rackId,
position: { [Op.ne]: null }
},
attributes: ['deviceId', 'position', 'height']
};
if (transaction) {
queryOptions.transaction = transaction;
}
const existingDevices = await Device.findAll(queryOptions);
for (const device of existingDevices) {
if (excludeDeviceId && device.deviceId === excludeDeviceId) {
continue;
}
const existStart = device.position;
const existEnd = device.position + (device.height || 1) - 1;
if (!(endU < existStart || startU > existEnd)) {
return {
available: false,
reason: `U位冲突:机柜中已有设备 ${device.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''},与当前位置范围 U${startU}-U${endU} 冲突`
};
}
}
return { available: true, reason: null };
}
async function checkBatchPositions(rackId, devices, excludeDeviceIds = [], transaction = null) {
const conflicts = [];
const sortedDevices = [...devices].sort((a, b) => a.position - b.position);
for (let i = 0; i < sortedDevices.length; i++) {
const device = sortedDevices[i];
if (!device.position || device.position <= 0) continue;
const startU = device.position;
const endU = device.position + (device.height || 1) - 1;
for (let j = i + 1; j < sortedDevices.length; j++) {
const other = sortedDevices[j];
if (!other.position || other.position <= 0) continue;
const otherStart = other.position;
const otherEnd = other.position + (other.height || 1) - 1;
if (!(endU < otherStart || startU > otherEnd)) {
conflicts.push(`导入数据内部冲突:设备 ${device.deviceId || '新设备'}(U${startU}-U${endU}) 与 设备 ${other.deviceId || '新设备'}(U${otherStart}-U${otherEnd}) U位重叠`);
}
}
}
const queryOptions = {
where: {
rackId: rackId,
position: { [Op.ne]: null }
},
attributes: ['deviceId', 'position', 'height']
};
if (transaction) {
queryOptions.transaction = transaction;
}
const existingDevices = await Device.findAll(queryOptions);
for (const existing of existingDevices) {
if (excludeDeviceIds.includes(existing.deviceId)) continue;
const existStart = existing.position;
const existEnd = existing.position + (existing.height || 1) - 1;
for (const device of devices) {
if (!device.position || device.position <= 0) continue;
const startU = device.position;
const endU = device.position + (device.height || 1) - 1;
if (!(endU < existStart || startU > existEnd)) {
conflicts.push(`与已有设备冲突:机柜中已有设备 ${existing.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''},与导入设备 ${device.deviceId || '新设备'}(U${startU}-U${endU}) 冲突`);
}
}
}
return conflicts;
}
router.post('/import-preview', async (req, res) => {
try {
if (!req.files || !req.files.csvFile) {
return res.status(400).json({ error: '请上传CSV文件' });
}
const csvFile = req.files.csvFile;
const stats = { total: 0, valid: 0, invalid: 0, errors: [] };
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'), { recursive: true });
}
const filePath = path.join(__dirname, '../temp', `preview_${Date.now()}_${csvFile.name}`);
await csvFile.mv(filePath);
const results = [];
const stream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('gbk'))
.pipe(csv());
await new Promise((resolve, reject) => {
stream.on('data', (data) => results.push(data))
.on('end', resolve)
.on('error', reject);
});
fs.unlinkSync(filePath);
const [rooms, racks, deviceFields] = await Promise.all([
Room.findAll(),
Rack.findAll({ include: [{ model: Room }] }),
DeviceField.findAll({ order: [['order', 'ASC']] })
]);
const roomNameToIdMap = new Map(rooms.map(room => [room.name, room.roomId]));
const rackLocationMap = new Map(racks.map(rack => [`${rack.Room?.name || ''}_${rack.name}`, rack.rackId]));
const fieldMapping = {};
const fieldNameToDisplayName = {};
deviceFields.forEach(field => {
fieldMapping[field.displayName] = field;
fieldNameToDisplayName[field.fieldName] = field.displayName;
});
const extractFieldName = (fieldNameWithFormat) => {
const match = fieldNameWithFormat.match(/^(.+?)(\(必填\)|\(可选\)|\([a-zA-Z0-9\-\/]+\))$/);
return match ? match[1].trim() : fieldNameWithFormat;
};
const validTypes = ['server', 'switch', 'router', 'storage', 'other'];
const validStatuses = ['running', 'maintenance', 'offline', 'fault'];
const baseFieldNames = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId', 'position', 'height', 'powerConsumption', 'ipAddress', 'status', 'purchaseDate', 'warrantyExpiry', 'description'];
const previewData = [];
const allDeviceIds = new Set();
const allSerialNumbers = new Set();
const rackDevicesMap = new Map();
stats.total = results.length;
for (let i = 0; i < results.length; i++) {
const row = results[i];
const rowNum = i + 2;
const rowErrors = [];
const parsedRow = { _rowNum: rowNum };
try {
const fieldValueMap = {};
Object.entries(row).forEach(([displayName, value]) => {
const originalFieldName = extractFieldName(displayName);
fieldValueMap[originalFieldName] = value;
fieldValueMap[displayName] = value;
});
const getFieldValue = (fieldName) => {
const displayName = fieldNameToDisplayName[fieldName];
return displayName ? fieldValueMap[displayName] : undefined;
};
const trulyRequiredFields = [];
deviceFields.forEach(field => {
if (field.fieldName === 'deviceId') return;
if (field.fieldName === 'rackId') {
if (field.required) {
trulyRequiredFields.push('所在机房名称', '所在机柜名称');
}
return;
}
if (field.required) trulyRequiredFields.push(field.displayName);
});
const missingFields = trulyRequiredFields.filter(fieldName => {
const value = fieldValueMap[fieldName];
return !value || (typeof value === 'string' && value.trim() === '');
});
if (missingFields.length > 0) {
rowErrors.push(`缺少必填字段:${missingFields.join('、')}`);
}
const deviceType = getFieldValue('type');
if (deviceType && !validTypes.includes(deviceType)) {
rowErrors.push(`设备类型无效:${deviceType}`);
}
let deviceId = getFieldValue('deviceId');
if (deviceId && deviceId.trim() !== '') {
if (allDeviceIds.has(deviceId)) {
rowErrors.push(`设备ID重复:${deviceId}`);
}
allDeviceIds.add(deviceId);
}
const serialNumber = getFieldValue('serialNumber');
if (!serialNumber || serialNumber.trim() === '') {
rowErrors.push('序列号不能为空');
} else if (allSerialNumbers.has(serialNumber)) {
rowErrors.push(`序列号重复:${serialNumber}`);
} else {
allSerialNumbers.add(serialNumber);
}
const roomName = fieldValueMap['所在机房名称'];
const rackName = fieldValueMap['所在机柜名称'];
if (!roomName?.trim()) {
rowErrors.push('所在机房名称不能为空');
} else if (!roomNameToIdMap.get(roomName.trim())) {
rowErrors.push(`机房不存在:${roomName}`);
}
if (!rackName?.trim()) {
rowErrors.push('所在机柜名称不能为空');
}
const status = getFieldValue('status');
if (status && !validStatuses.includes(status)) {
rowErrors.push(`状态值无效:${status}`);
}
const position = getFieldValue('position');
const height = getFieldValue('height');
const powerConsumption = getFieldValue('powerConsumption');
if (position !== undefined && position !== '' && isNaN(Number(position))) {
rowErrors.push(`位置必须是数字:${position}`);
}
if (height !== undefined && height !== '' && isNaN(Number(height))) {
rowErrors.push(`高度必须是数字:${height}`);
}
if (powerConsumption !== undefined && powerConsumption !== '' && isNaN(Number(powerConsumption))) {
rowErrors.push(`功率必须是数字:${powerConsumption}`);
}
const purchaseDateValue = getFieldValue('purchaseDate');
const warrantyExpiryValue = getFieldValue('warrantyExpiry');
const purchaseDate = purchaseDateValue ? new Date(purchaseDateValue) : null;
const warrantyExpiry = warrantyExpiryValue ? new Date(warrantyExpiryValue) : null;
if (purchaseDateValue && isNaN(purchaseDate.getTime())) {
rowErrors.push(`购买日期格式无效:${purchaseDateValue}`);
}
if (warrantyExpiryValue && isNaN(warrantyExpiry.getTime())) {
rowErrors.push(`保修日期格式无效:${warrantyExpiryValue}`);
}
if (purchaseDate && warrantyExpiry && warrantyExpiry <= purchaseDate) {
rowErrors.push('保修日期必须晚于购买日期');
}
Object.keys(row).forEach(displayName => {
const originalDisplayName = extractFieldName(displayName);
const fieldConfig = fieldMapping[originalDisplayName];
if (fieldConfig && !baseFieldNames.includes(fieldConfig.fieldName)) {
parsedRow[fieldConfig.fieldName] = row[displayName];
}
});
parsedRow.name = getFieldValue('name') || '';
parsedRow.type = deviceType || '';
parsedRow.model = getFieldValue('model') || '';
parsedRow.serialNumber = serialNumber || '';
parsedRow.roomName = roomName || '';
parsedRow.rackName = rackName || '';
parsedRow.status = status || '';
parsedRow.position = position || '';
parsedRow.height = height || '';
parsedRow.powerConsumption = powerConsumption || '';
parsedRow.ipAddress = getFieldValue('ipAddress') || '';
parsedRow.description = getFieldValue('description') || '';
if (rowErrors.length === 0) {
stats.valid++;
parsedRow._hasError = false;
const rackId = rackLocationMap.get(`${roomName?.trim()}_${rackName?.trim()}`);
if (rackId && position) {
const posNum = parseInt(position);
const heightNum = parseInt(height) || 1;
if (!rackDevicesMap.has(rackId)) {
rackDevicesMap.set(rackId, []);
}
rackDevicesMap.get(rackId).push({
rowNum,
deviceId: getFieldValue('deviceId') || null,
position: posNum,
height: heightNum
});
}
} else {
stats.invalid++;
parsedRow._hasError = true;
parsedRow._errors = rowErrors;
stats.errors.push({ row: rowNum, errors: rowErrors });
}
if (previewData.length < PREVIEW_COUNT) {
previewData.push(parsedRow);
}
} catch (error) {
stats.invalid++;
const errorMsg = error.message || '未知错误';
stats.errors.push({ row: rowNum, errors: [errorMsg] });
if (previewData.length < PREVIEW_COUNT) {
previewData.push({
_rowNum: rowNum,
_hasError: true,
_errors: [errorMsg],
name: row['设备名称'] || '',
type: row['设备类型'] || '',
serialNumber: row['序列号'] || '',
roomName: row['所在机房名称'] || '',
rackName: row['所在机柜名称'] || '',
status: row['状态'] || ''
});
}
}
}
const positionConflictRows = new Set();
for (const [rackId, devices] of rackDevicesMap) {
const existingDevices = await Device.findAll({
where: { rackId, position: { [Op.ne]: null } },
attributes: ['deviceId', 'position', 'height']
});
for (const newDevice of devices) {
const startU = newDevice.position;
const endU = newDevice.position + newDevice.height - 1;
let hasConflict = false;
for (const existing of existingDevices) {
const existStart = existing.position;
const existEnd = existing.position + (existing.height || 1) - 1;
if (!(endU < existStart || startU > existEnd)) {
const conflictMsg = `U位冲突:机柜中已有设备 ${existing.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''},与第 ${newDevice.rowNum} 行设备(导入)占用 U${startU}${endU !== startU ? '-' + endU : ''} 冲突`;
stats.errors.push({ row: newDevice.rowNum, errors: [conflictMsg] });
hasConflict = true;
break;
}
}
if (!hasConflict) {
for (const otherDevice of devices) {
if (otherDevice === newDevice) continue;
const otherStart = otherDevice.position;
const otherEnd = otherDevice.position + otherDevice.height - 1;
if (!(endU < otherStart || startU > otherEnd)) {
const conflictMsg = `U位冲突:第 ${newDevice.rowNum} 行设备与第 ${otherDevice.rowNum} 行设备在 U位上重叠 (U${startU}-U${endU} vs U${otherStart}-U${otherEnd})`;
stats.errors.push({ row: newDevice.rowNum, errors: [conflictMsg] });
hasConflict = true;
break;
}
}
}
if (hasConflict) {
positionConflictRows.add(newDevice.rowNum);
}
}
}
if (positionConflictRows.size > 0) {
stats.invalid += positionConflictRows.size;
stats.valid -= positionConflictRows.size;
for (const rowNum of positionConflictRows) {
const previewItem = previewData.find(item => item._rowNum === rowNum);
if (previewItem) {
previewItem._hasError = true;
const existingErrors = previewItem._errors || [];
const positionErrors = stats.errors.filter(e => e.row === rowNum).flatMap(e => e.errors);
previewItem._errors = [...existingErrors, ...positionErrors];
}
}
}
const fieldList = deviceFields
.filter(field => field.visible && field.fieldName !== 'deviceId')
.map(field => ({
fieldName: field.fieldName,
displayName: field.displayName,
fieldType: field.fieldType,
required: field.required
}));
res.json({
success: true,
data: {
preview: previewData,
total: stats.total,
previewCount: PREVIEW_COUNT,
statistics: {
total: stats.total,
valid: stats.valid,
invalid: stats.invalid
},
errors: stats.errors.slice(0, 50),
fieldList
}
});
} catch (error) {
console.error('预览设备数据失败:', error);
res.status(500).json({
error: error.message || '预览过程中发生未知错误'
});
}
});
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
try {
const { keyword, status, type, rackId, roomId, page = 1, pageSize = 10, isIdle } = req.query;
const offset = (page - 1) * pageSize;
// 构建查询条件
const where = {};
// 关键词搜索(所有文本字段 + 自定义字段)
if (keyword) {
console.log('搜索关键词:', keyword);
console.log('数据库类型:', dbDialect);
// 转义关键词中的特殊字符,防止SQL注入
const escapedKeyword = keyword.replace(/'/g, "''");
// 基础字段搜索条件(只包含文本类型字段)
const searchConditions = [
{ deviceId: { [Op.like]: `%${escapedKeyword}%` } },
{ name: { [Op.like]: `%${escapedKeyword}%` } },
{ type: { [Op.like]: `%${escapedKeyword}%` } },
{ model: { [Op.like]: `%${escapedKeyword}%` } },
{ serialNumber: { [Op.like]: `%${escapedKeyword}%` } },
{ ipAddress: { [Op.like]: `%${escapedKeyword}%` } },
{ description: { [Op.like]: `%${escapedKeyword}%` } }
];
// 动态获取文本类型的自定义字段
const customFields = await DeviceField.findAll({
where: {
isSystem: false,
fieldType: { [Op.in]: ['string', 'textarea'] }
}
});
console.log('找到的自定义字段:', customFields.map(f => f.fieldName));
// 构建自定义字段搜索条件(使用原始SQL,兼容SQLite和MySQL
if (customFields.length > 0) {
// 使用原始SQL查询JSON字段
const jsonConditions = customFields.map(field => {
const fieldName = field.fieldName;
// 使用 sequelize.literal 构建原始SQL条件
if (dbDialect === 'mysql') {
return sequelize.literal(`JSON_EXTRACT(customFields, '$."${fieldName}"') LIKE '%${escapedKeyword}%'`);
} else {
return sequelize.literal(`json_extract(customFields, '$.${fieldName}') LIKE '%${escapedKeyword}%'`);
}
});
searchConditions.push(...jsonConditions);
}
// 合并所有搜索条件
where[Op.or] = searchConditions;
console.log('搜索条件数量:', searchConditions.length);
}
// 状态筛选
if (status && status !== 'all') {
where.status = status;
}
// 分类筛选
if (type && type !== 'all') {
where.type = type;
}
// 机柜筛选(用于机柜可视化功能)
if (rackId) {
where.rackId = rackId;
}
// 机房筛选 - 通过机柜关联查询
if (roomId && roomId !== 'all') {
where['$Rack.roomId$'] = roomId;
}
// 空闲设备筛选
if (isIdle !== undefined && isIdle !== '') {
where.isIdle = isIdle === 'true' || isIdle === true;
}
// 执行查询 - 优化:使用 JOIN 避免 N+1 查询问题
const { count, rows } = await Device.findAndCountAll({
where,
include: [
{
model: Rack,
include: [
{ model: Room }
],
separate: false // 强制使用 JOIN 而不是单独查询
}
],
offset,
limit: parseInt(pageSize),
distinct: true, // 避免 count 不准确
subQuery: false // 避免子查询导致的性能问题
});
res.json({
total: count,
devices: rows,
page: parseInt(page),
pageSize: parseInt(pageSize)
});
} catch (error) {
console.error('搜索设备失败:', error);
console.error('错误详情:', error.message);
if (error.sql) {
console.error('SQL:', error.sql);
}
res.status(500).json({ error: error.message, sql: error.sql });
}
});
const MAX_EXPORT_SIZE = 50000;
router.get('/all', async (req, res) => {
try {
const { keyword, status, type, rackId, roomId } = req.query;
const where = {};
if (keyword) {
const escapedKeyword = keyword.replace(/'/g, "''");
where[Op.or] = [
{ deviceId: { [Op.like]: `%${escapedKeyword}%` } },
{ name: { [Op.like]: `%${escapedKeyword}%` } },
{ type: { [Op.like]: `%${escapedKeyword}%` } },
{ model: { [Op.like]: `%${escapedKeyword}%` } },
{ serialNumber: { [Op.like]: `%${escapedKeyword}%` } },
{ ipAddress: { [Op.like]: `%${escapedKeyword}%` } }
];
}
if (status && status !== 'all') {
where.status = status;
}
if (type && type !== 'all') {
where.type = type;
}
if (rackId) {
where.rackId = rackId;
}
if (roomId && roomId !== 'all') {
where['$Rack.roomId$'] = roomId;
}
const devices = await Device.findAll({
where,
include: [
{
model: Rack,
include: [{ model: Room }],
separate: false
}
],
limit: MAX_EXPORT_SIZE,
order: [['createdAt', 'DESC']],
distinct: true,
subQuery: false
});
res.json({
devices,
total: devices.length
});
} catch (error) {
console.error('获取设备列表失败:', error);
res.status(500).json({ error: error.message });
}
});
// 生成设备ID的辅助函数
async function generateDeviceId() {
// 获取当前最大的设备ID序号
const devices = await Device.findAll({
where: {
deviceId: {
[require('sequelize').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;
}
}
});
// 生成新的设备ID,序号+1,至少3位数字
const newNumber = maxNumber + 1;
return `DEV${String(newNumber).padStart(3, '0')}`;
}
// 创建设备
router.post('/', validateBody(createDeviceSchema), async (req, res) => {
try {
const deviceData = { ...req.body };
if (!deviceData.deviceId || deviceData.deviceId.trim() === '') {
deviceData.deviceId = await generateDeviceId();
}
if (deviceData.rackId && deviceData.position) {
const positionCheck = await checkPositionAvailable(
deviceData.rackId,
deviceData.position,
deviceData.height
);
if (!positionCheck.available) {
return res.status(400).json({ error: positionCheck.reason });
}
}
const device = await Device.create(deviceData);
const rack = await Rack.findByPk(deviceData.rackId);
if (rack) {
await rack.update({
currentPower: rack.currentPower + deviceData.powerConsumption
});
}
const createDetails = [
`设备名称: ${device.name}`,
`设备编号: ${device.deviceId}`,
`设备类型: ${device.type}`,
`所属机柜: ${rack ? rack.name : '未分配'}`,
`安装位置: U${device.position}`,
`功耗: ${device.powerConsumption}W`
].join('');
await logDeviceOperation('create', `创建设备【${device.name}】`, {
targetId: device.deviceId,
targetName: device.name,
afterState: device.toJSON(),
req,
metadata: { deviceType: device.type, rackName: rack?.name, powerConsumption: device.powerConsumption }
});
res.status(201).json(device);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// 设备导入模板下载
router.get('/import-template', async (req, res) => {
try {
// 查询设备字段配置
const deviceFields = await DeviceField.findAll({
order: [['order', 'ASC']]
});
// 根据字段配置动态生成CSV标题(排除设备ID,由系统自动生成)
// 将rackId字段替换为机房名称+机柜名称,以便唯一定位
// 注意:导入模板包含所有字段(不论visible状态),确保数据完整性
const headers = [];
deviceFields
.filter(field => field.fieldName !== 'deviceId')
.forEach(field => {
// 如果是机柜字段,拆分为机房名称和机柜名称两列
if (field.fieldName === 'rackId') {
headers.push({
id: '所在机房名称',
title: '所在机房名称'
});
headers.push({
id: '所在机柜名称',
title: '所在机柜名称'
});
return;
}
// 直接使用displayName作为列名,不添加任何后缀
headers.push({ id: field.displayName, title: field.displayName });
});
// 准备示例数据(根据字段配置生成,排除设备ID)
// 注意:包含所有字段(不论visible状态)
const exampleData = {};
deviceFields
.filter(field => field.fieldName !== 'deviceId')
.forEach(field => {
// 如果是机柜字段,拆分为机房名称和机柜名称
if (field.fieldName === 'rackId') {
exampleData['所在机房名称'] = '测试机房';
exampleData['所在机柜名称'] = 'A01';
return;
}
switch (field.fieldName) {
case 'name':
exampleData[field.displayName] = '测试服务器001';
break;
case 'type':
exampleData[field.displayName] = 'server';
break;
case 'model':
exampleData[field.displayName] = 'DELL R740';
break;
case 'serialNumber':
exampleData[field.displayName] = 'SN1234567890';
break;
case 'position':
exampleData[field.displayName] = '1';
break;
case 'height':
exampleData[field.displayName] = '2';
break;
case 'powerConsumption':
exampleData[field.displayName] = '500';
break;
case 'status':
exampleData[field.displayName] = 'running';
break;
case 'purchaseDate':
exampleData[field.displayName] = '2023-01-01';
break;
case 'warrantyExpiry':
exampleData[field.displayName] = '2026-12-31';
break;
case 'ipAddress':
exampleData[field.displayName] = '192.168.1.100';
break;
case 'description':
exampleData[field.displayName] = '测试用服务器';
break;
default:
// 自定义字段根据类型生成示例值
if (field.fieldType === 'number') {
exampleData[field.displayName] = '100';
} else if (field.fieldType === 'boolean') {
exampleData[field.displayName] = 'true';
} else if (field.fieldType === 'date') {
exampleData[field.displayName] = '2023-01-01';
} else if (field.fieldType === 'select' && field.options && field.options.length > 0) {
exampleData[field.displayName] = field.options[0].value;
} else {
exampleData[field.displayName] = `示例${field.displayName}`;
}
}
});
const templateData = [exampleData];
// 确保temp目录存在
const tempDir = path.join(__dirname, '../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const csvWriter = createObjectCsvWriter({
path: path.join(tempDir, 'import_template.csv'),
header: headers
});
// 写入CSV文件
await csvWriter.writeRecords(templateData);
// 读取文件并转换为GBK编码
const csvContent = fs.readFileSync(path.join(tempDir, 'import_template.csv'), 'utf8');
const gbkContent = iconv.encode(csvContent, 'gbk');
// 设置响应头
res.setHeader('Content-Type', 'text/csv; charset=gbk');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent('设备导入模板.csv')}`);
// 发送CSV数据
res.send(gbkContent);
// 删除临时文件
fs.unlinkSync(path.join(tempDir, 'import_template.csv'));
} catch (error) {
console.error('生成导入模板失败:', error);
res.status(500).json({ error: '生成导入模板失败' });
}
});
// 导出设备数据为CSV
router.get('/export', async (req, res) => {
try {
const { deviceIds } = req.query;
// 查询条件
const where = {};
if (deviceIds) {
// 支持多个设备ID,使用数组格式
const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds];
where.deviceId = { [Op.in]: ids };
}
// 查询设备字段配置
const deviceFields = await DeviceField.findAll({
order: [['order', 'ASC']]
});
// 查询设备数据
const devices = await Device.findAll({
where,
include: [
{
model: Rack,
include: [
{ model: Room }
]
}
]
});
// 如果没有找到设备
if (devices.length === 0) {
return res.status(404).json({ error: '未找到指定的设备' });
}
// 创建字段名到displayName的映射
const fieldNameToDisplayName = {};
deviceFields.forEach(field => {
fieldNameToDisplayName[field.fieldName] = field.displayName;
});
// 准备CSV数据 - 根据字段配置动态生成,与导入模板保持一致
const csvData = devices.map(device => {
const deviceData = {};
// 根据字段配置生成数据(排除deviceId)
deviceFields
.filter(field => field.visible && field.fieldName !== 'deviceId')
.forEach(field => {
// rackId字段拆分为机房名称和机柜名称
if (field.fieldName === 'rackId') {
deviceData['所在机房名称'] = device.Rack?.Room?.name || '';
deviceData['所在机柜名称'] = device.Rack?.name || '';
return;
}
// 其他字段使用displayName作为列名
const value = device[field.fieldName];
// 日期格式处理
if (field.fieldType === 'date' && value) {
deviceData[field.displayName] = new Date(value).toLocaleDateString();
} else {
deviceData[field.displayName] = value !== undefined && value !== null ? value : '';
}
});
// 添加自定义字段
if (device.customFields) {
Object.entries(device.customFields).forEach(([fieldName, value]) => {
const field = deviceFields.find(f => f.fieldName === fieldName);
if (field && field.visible) {
deviceData[field.displayName] = value;
}
});
}
return deviceData;
});
// 设置CSV标题 - 与导入模板保持一致
const headers = [];
deviceFields
.filter(field => field.visible && field.fieldName !== 'deviceId')
.forEach(field => {
// rackId拆分为两个列
if (field.fieldName === 'rackId') {
headers.push({ id: '所在机房名称', title: '所在机房名称' });
headers.push({ id: '所在机柜名称', title: '所在机柜名称' });
return;
}
headers.push({ id: field.displayName, title: field.displayName });
});
const csvWriter = createObjectCsvWriter({
path: path.join(__dirname, '../temp/devices.csv'),
header: headers,
encoding: 'utf8'
});
// 确保temp目录存在
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'));
}
// 写入CSV文件
await csvWriter.writeRecords(csvData);
// 读取文件并转换为GBK编码
const csvContent = fs.readFileSync(path.join(__dirname, '../temp/devices.csv'), 'utf8');
const gbkContent = iconv.encode(csvContent, 'gbk');
// 设置响应头
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=devices.csv');
// 发送CSV数据
res.send(gbkContent);
// 删除临时文件
fs.unlinkSync(path.join(__dirname, '../temp/devices.csv'));
} catch (error) {
console.error('导出设备数据失败:', error);
res.status(500).json({ error: '导出设备数据失败' });
}
});
// 导入设备数据从CSV - 优化版:使用事务+批量插入
router.post('/import', async (req, res) => {
const t = await sequelize.transaction();
try {
if (!req.files || !req.files.csvFile) {
await t.rollback();
return res.status(400).json({ error: '请上传CSV文件' });
}
const csvFile = req.files.csvFile;
const stats = { total: 0, success: 0, failed: 0, errors: [] };
// 确保temp目录存在
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'));
}
// 保存上传的文件
const filePath = path.join(__dirname, '../temp', csvFile.name);
await csvFile.mv(filePath);
// 读取并解析CSV文件(GBK编码)
const results = [];
const stream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('gbk'))
.pipe(csv());
await new Promise((resolve, reject) => {
stream.on('data', (data) => {
stats.total++;
results.push(data);
})
.on('end', resolve)
.on('error', reject);
});
// 【优化1】批量查询所有必要数据(单次查询)
const [rooms, racks, deviceFields, maxDeviceResult] = await Promise.all([
Room.findAll({ transaction: t }),
Rack.findAll({ include: [{ model: Room }], transaction: t }),
DeviceField.findAll({ transaction: t }),
// 查询最大设备ID序号
Device.findOne({
attributes: [[sequelize.fn('MAX', sequelize.cast(sequelize.fn('SUBSTR', sequelize.col('deviceId'), 4), 'INTEGER')), 'maxNum']],
where: { deviceId: { [Op.like]: 'DEV%' } },
transaction: t
})
]);
// 创建查找映射
const roomNameToIdMap = new Map(rooms.map(room => [room.name, room.roomId]));
const rackLocationMap = new Map(racks.map(rack => [`${rack.Room?.name || ''}_${rack.name}`, rack.rackId]));
const fieldMapping = {};
const fieldNameToDisplayName = {};
deviceFields.forEach(field => {
fieldMapping[field.displayName] = field;
fieldNameToDisplayName[field.fieldName] = field.displayName;
});
// 设备ID生成器
let maxDeviceNum = maxDeviceResult?.get('maxNum') || 0;
const generateDeviceId = () => {
maxDeviceNum++;
return `DEV${String(maxDeviceNum).padStart(3, '0')}`;
};
// 辅助函数:提取字段名
const extractFieldName = (fieldNameWithFormat) => {
const match = fieldNameWithFormat.match(/^(.+?)(\(必填\)|\(可选\)|\([a-zA-Z0-9\-\/]+\))$/);
return match ? match[1].trim() : fieldNameWithFormat;
};
// 【优化2】收集所有需要验证的唯一键
const allDeviceIds = new Set();
const allSerialNumbers = new Set();
const validTypes = ['server', 'switch', 'router', 'storage', 'other'];
const validStatuses = ['running', 'maintenance', 'offline', 'fault'];
const baseFieldNames = ['deviceId', 'name', 'type', 'model', 'serialNumber', 'rackId', 'position', 'height', 'powerConsumption', 'ipAddress', 'status', 'purchaseDate', 'warrantyExpiry', 'description'];
// 第一遍:验证和收集数据
const validDevices = [];
for (let i = 0; i < results.length; i++) {
const row = results[i];
const rowNum = i + 2;
try {
// 解析字段值
const fieldValueMap = {};
Object.entries(row).forEach(([displayName, value]) => {
const originalFieldName = extractFieldName(displayName);
fieldValueMap[originalFieldName] = value;
fieldValueMap[displayName] = value;
});
const getFieldValue = (fieldName) => {
const displayName = fieldNameToDisplayName[fieldName];
return displayName ? fieldValueMap[displayName] : undefined;
};
// 验证必填字段
const trulyRequiredFields = [];
deviceFields.forEach(field => {
if (field.fieldName === 'deviceId') return;
if (field.fieldName === 'rackId') {
if (field.required) {
trulyRequiredFields.push('所在机房名称', '所在机柜名称');
}
return;
}
if (field.required) trulyRequiredFields.push(field.displayName);
});
const missingFields = trulyRequiredFields.filter(fieldName => {
const value = fieldValueMap[fieldName];
return !value || (typeof value === 'string' && value.trim() === '');
});
if (missingFields.length > 0) {
throw new Error(`缺少必填字段:${missingFields.join('、')}`);
}
// 验证设备类型
const deviceType = getFieldValue('type');
if (!validTypes.includes(deviceType)) {
throw new Error(`设备类型无效:${deviceType}`);
}
// 处理设备ID
let deviceId = getFieldValue('deviceId');
if (!deviceId || deviceId.trim() === '') {
deviceId = generateDeviceId();
} else if (allDeviceIds.has(deviceId)) {
throw new Error(`设备ID重复:${deviceId}`);
}
allDeviceIds.add(deviceId);
// 验证序列号
const serialNumber = getFieldValue('serialNumber');
if (!serialNumber) throw new Error('序列号不能为空');
if (allSerialNumbers.has(serialNumber)) {
throw new Error(`序列号重复:${serialNumber}`);
}
allSerialNumbers.add(serialNumber);
// 验证机房和机柜
const roomName = fieldValueMap['所在机房名称'];
const rackName = fieldValueMap['所在机柜名称'];
if (!roomName?.trim()) throw new Error('所在机房名称不能为空');
if (!rackName?.trim()) throw new Error('所在机柜名称不能为空');
const roomId = roomNameToIdMap.get(roomName.trim());
if (!roomId) throw new Error(`机房不存在:${roomName}`);
const locationKey = `${roomName.trim()}_${rackName.trim()}`;
let rackId = rackLocationMap.get(locationKey);
// 机柜不存在则自动创建
if (!rackId) {
const maxRackResult = await Rack.findOne({
attributes: [[sequelize.fn('MAX', sequelize.cast(sequelize.fn('SUBSTR', sequelize.col('rackId'), 5), 'INTEGER')), 'maxNum']],
where: { rackId: { [Op.like]: 'RACK%' } },
transaction: t
});
let maxRackNum = maxRackResult?.get('maxNum') || 0;
maxRackNum++;
const newRack = await Rack.create({
rackId: `RACK${String(maxRackNum).padStart(3, '0')}`,
name: rackName.trim(),
height: 42,
maxPower: 10000,
currentPower: 0,
status: 'active',
roomId: roomId
}, { transaction: t });
rackId = newRack.rackId;
rackLocationMap.set(locationKey, rackId);
}
// 验证状态
const status = getFieldValue('status');
if (!validStatuses.includes(status)) {
throw new Error(`状态值无效:${status}`);
}
// 验证数字字段
const position = getFieldValue('position');
const height = getFieldValue('height');
const powerConsumption = getFieldValue('powerConsumption');
if (position !== undefined && isNaN(Number(position))) {
throw new Error(`位置必须是数字:${position}`);
}
if (height !== undefined && isNaN(Number(height))) {
throw new Error(`高度必须是数字:${height}`);
}
if (powerConsumption !== undefined && isNaN(Number(powerConsumption))) {
throw new Error(`功率必须是数字:${powerConsumption}`);
}
// 验证日期
const purchaseDateValue = getFieldValue('purchaseDate');
const warrantyExpiryValue = getFieldValue('warrantyExpiry');
const purchaseDate = purchaseDateValue ? new Date(purchaseDateValue) : null;
const warrantyExpiry = warrantyExpiryValue ? new Date(warrantyExpiryValue) : null;
if (purchaseDateValue && isNaN(purchaseDate.getTime())) {
throw new Error(`购买日期格式无效:${purchaseDateValue}`);
}
if (warrantyExpiryValue && isNaN(warrantyExpiry.getTime())) {
throw new Error(`保修日期格式无效:${warrantyExpiryValue}`);
}
if (purchaseDate && warrantyExpiry && warrantyExpiry <= purchaseDate) {
throw new Error(`保修日期必须晚于购买日期`);
}
// 处理自定义字段
const customFields = {};
Object.entries(row).forEach(([displayName, value]) => {
const originalDisplayName = extractFieldName(displayName);
const fieldConfig = fieldMapping[originalDisplayName];
if (fieldConfig && !baseFieldNames.includes(fieldConfig.fieldName)) {
let processedValue = value;
if (fieldConfig.fieldType === 'number') {
processedValue = value ? parseFloat(value) : null;
} else if (fieldConfig.fieldType === 'boolean') {
processedValue = value ? (value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === '是') : false;
} else if (fieldConfig.fieldType === 'date') {
processedValue = value ? new Date(value) : null;
}
customFields[fieldConfig.fieldName] = processedValue;
}
});
// 收集有效设备数据
validDevices.push({
deviceId,
name: getFieldValue('name'),
type: deviceType,
model: getFieldValue('model'),
serialNumber,
rackId,
position: position ? parseInt(position) : 0,
height: height ? parseInt(height) : 1,
powerConsumption: powerConsumption ? parseFloat(powerConsumption) : 0,
ipAddress: getFieldValue('ipAddress') || '',
status,
purchaseDate,
warrantyExpiry,
description: getFieldValue('description') || '',
customFields: Object.keys(customFields).length > 0 ? customFields : null
});
} catch (error) {
stats.failed++;
stats.errors.push({ row: rowNum, error: error.message, data: row });
}
}
if (validDevices.length > 0) {
const rackDevicesMap = new Map();
for (const device of validDevices) {
if (device.rackId && device.position > 0) {
if (!rackDevicesMap.has(device.rackId)) {
rackDevicesMap.set(device.rackId, []);
}
rackDevicesMap.get(device.rackId).push(device);
}
}
for (const [rackId, devices] of rackDevicesMap) {
const existingDevices = await Device.findAll({
where: { rackId, position: { [Op.ne]: null } },
attributes: ['deviceId', 'position', 'height'],
transaction: t
});
for (const newDevice of devices) {
const startU = newDevice.position;
const endU = newDevice.position + newDevice.height - 1;
let hasConflict = false;
for (const existing of existingDevices) {
const existStart = existing.position;
const existEnd = existing.position + (existing.height || 1) - 1;
if (!(endU < existStart || startU > existEnd)) {
stats.failed++;
stats.errors.push({
row: 0,
error: `U位冲突:机柜中已有设备 ${existing.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''},与导入设备 ${newDevice.deviceId}(U${startU}-U${endU}) 冲突`,
data: { deviceId: newDevice.deviceId }
});
hasConflict = true;
break;
}
}
if (!hasConflict) {
for (const otherDevice of devices) {
if (otherDevice === newDevice) continue;
const otherStart = otherDevice.position;
const otherEnd = otherDevice.position + otherDevice.height - 1;
if (!(endU < otherStart || startU > otherEnd)) {
stats.failed++;
stats.errors.push({
row: 0,
error: `U位冲突:导入数据内部冲突,设备 ${newDevice.deviceId}(U${startU}-U${endU}) 与设备 ${otherDevice.deviceId}(U${otherStart}-U${otherEnd}) U位重叠`,
data: { deviceId: newDevice.deviceId }
});
hasConflict = true;
break;
}
}
}
if (hasConflict) {
const idx = validDevices.indexOf(newDevice);
if (idx > -1) validDevices.splice(idx, 1);
}
}
}
}
// 【优化3】批量查询已存在的设备ID和序列号(单次查询)
if (validDevices.length > 0) {
const existingDevices = await Device.findAll({
where: {
[Op.or]: [
{ deviceId: validDevices.map(d => d.deviceId) },
{ serialNumber: validDevices.map(d => d.serialNumber) }
]
},
attributes: ['deviceId', 'serialNumber'],
transaction: t
});
const existingDeviceIds = new Set(existingDevices.map(d => d.deviceId));
const existingSerialNumbers = new Set(existingDevices.map(d => d.serialNumber));
// 过滤掉已存在的设备
const newDevices = validDevices.filter(device => {
if (existingDeviceIds.has(device.deviceId)) {
stats.failed++;
stats.errors.push({ row: 0, error: `设备ID已存在:${device.deviceId}`, data: {} });
return false;
}
if (existingSerialNumbers.has(device.serialNumber)) {
stats.failed++;
stats.errors.push({ row: 0, error: `序列号已存在:${device.serialNumber}`, data: {} });
return false;
}
return true;
});
// 【优化4】批量创建设备
if (newDevices.length > 0) {
await Device.bulkCreate(newDevices, { transaction: t });
stats.success = newDevices.length;
// 【优化5】批量更新机柜功率
const rackPowerMap = new Map();
newDevices.forEach(device => {
const current = rackPowerMap.get(device.rackId) || 0;
rackPowerMap.set(device.rackId, current + device.powerConsumption);
});
for (const [rackId, powerToAdd] of rackPowerMap) {
await Rack.update(
{ currentPower: sequelize.literal(`currentPower + ${powerToAdd}`) },
{ where: { rackId }, transaction: t }
);
}
}
}
// 提交事务
await t.commit();
fs.unlinkSync(filePath);
res.json({ statistics: stats });
} catch (error) {
await t.rollback();
console.error('导入设备数据失败:', error);
// 清理临时文件
try {
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
console.error('删除临时文件失败:', fileErr);
}
res.status(500).json({
errors: [{ row: 0, error: error.message || '导入过程中发生未知错误' }]
});
}
});
// 批量上线设备
router.put('/batch-online', validateBody(batchDeviceIdsSchema), async (req, res) => {
try {
const { deviceIds } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
return res.status(400).json({ error: '请提供有效的设备ID列表' });
}
// 更新设备状态为运行中
const [affectedCount] = await Device.update(
{ status: 'running' },
{ where: { deviceId: { [Op.in]: deviceIds } } }
);
res.json({
message: `批量上线成功,已更新 ${affectedCount} 个设备`,
affectedCount
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 批量下线设备
router.put('/batch-offline', validateBody(batchDeviceIdsSchema), async (req, res) => {
try {
const { deviceIds } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
return res.status(400).json({ error: '请提供有效的设备ID列表' });
}
// 更新设备状态为离线
const [affectedCount] = await Device.update(
{ status: 'offline' },
{ where: { deviceId: { [Op.in]: deviceIds } } }
);
res.json({
message: `批量下线成功,已更新 ${affectedCount} 个设备`,
affectedCount
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 批量变更设备状态
router.put('/batch-status', async (req, res) => {
try {
const { deviceIds, status } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
return res.status(400).json({ error: '请提供有效的设备ID列表' });
}
// 检查数据库中是否存在这些设备
const existingDevices = await Device.findAll({
where: { deviceId: { [Op.in]: deviceIds } },
attributes: ['deviceId']
});
// 检查是否有不存在的设备
const existingIds = existingDevices.map(d => d.deviceId);
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
if (missingIds.length > 0) {
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
}
const validStatus = ['running', 'maintenance', 'offline', 'fault'];
if (!validStatus.includes(status)) {
return res.status(400).json({
error: `状态值无效,有效值为:${validStatus.join('、')}`
});
}
// 状态映射
const statusText = {
running: '运行中',
maintenance: '维护中',
offline: '离线',
fault: '故障'
};
const beforeDevices = await Device.findAll({
where: { deviceId: { [Op.in]: deviceIds } }
});
const deviceNames = beforeDevices.map(d => d.name);
// 更新设备状态
const [affectedCount] = await Device.update(
{ status },
{ where: { deviceId: { [Op.in]: deviceIds } } }
);
const statusChangeDesc = `批量变更${affectedCount}台设备状态:${deviceNames.join('、')}${statusText[status]}`;
await logDeviceOperation('status_change', statusChangeDesc, {
targetId: deviceIds.join(','),
targetName: `${affectedCount}台设备`,
beforeState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status: d.status })),
afterState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status })),
req,
metadata: { status, statusText: statusText[status], count: affectedCount, deviceNames }
});
res.json({
message: `批量状态变更成功,已将 ${affectedCount} 个设备状态变更为"${statusText[status]}"`,
affectedCount,
newStatus: status
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 批量移动设备
router.put('/batch-move', async (req, res) => {
try {
const { deviceIds, targetRackId, startPosition } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
return res.status(400).json({ error: '请提供有效的设备ID列表' });
}
if (!targetRackId) {
return res.status(400).json({ error: '请提供目标机柜ID' });
}
const targetRack = await Rack.findByPk(targetRackId);
if (!targetRack) {
return res.status(404).json({ error: '目标机柜不存在' });
}
const devicesToMove = await Device.findAll({
where: { deviceId: { [Op.in]: deviceIds } },
attributes: ['deviceId', 'name', 'rackId', 'position', 'height']
});
const beforeMoveState = devicesToMove.map(d => ({
deviceId: d.deviceId,
name: d.name,
rackId: d.rackId,
position: d.position
}));
const deviceHeightMap = new Map(devicesToMove.map(d => [d.deviceId, d.height || 1]));
if (startPosition) {
const devicesToCheck = [];
for (let i = 0; i < deviceIds.length; i++) {
const deviceId = deviceIds[i];
const height = deviceHeightMap.get(deviceId) || 1;
devicesToCheck.push({
deviceId,
position: startPosition + i,
height
});
}
const existingDevices = await Device.findAll({
where: {
rackId: targetRackId,
position: { [Op.ne]: null }
},
attributes: ['deviceId', 'position', 'height']
});
for (const newDevice of devicesToCheck) {
const startU = newDevice.position;
const endU = newDevice.position + newDevice.height - 1;
for (const existing of existingDevices) {
if (devicesToCheck.some(d => d.deviceId === existing.deviceId)) {
continue;
}
const existStart = existing.position;
const existEnd = existing.position + (existing.height || 1) - 1;
if (!(endU < existStart || startU > existEnd)) {
return res.status(400).json({
error: `U位冲突:机柜中已有设备 ${existing.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''},与移动设备 ${newDevice.deviceId}(U${startU}-U${endU}) 冲突`
});
}
}
for (const other of devicesToCheck) {
if (other === newDevice) continue;
const otherStart = other.position;
const otherEnd = other.position + other.height - 1;
if (!(endU < otherStart || startU > otherEnd)) {
return res.status(400).json({
error: `U位冲突:移动设备 ${newDevice.deviceId}(U${startU}-U${endU}) 与设备 ${other.deviceId}(U${otherStart}-U${otherEnd}) U位重叠`
});
}
}
}
}
let movedCount = 0;
for (let i = 0; i < deviceIds.length; i++) {
const deviceId = deviceIds[i];
const position = startPosition ? startPosition + i : undefined;
const updateData = { rackId: targetRackId };
if (position) {
updateData.position = position;
}
const [updated] = await Device.update(updateData, {
where: { deviceId }
});
if (updated) {
movedCount++;
}
}
const deviceNames = devicesToMove.map(d => d.name);
const moveDesc = startPosition
? `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')} → U${startPosition}起`
: `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')}`;
await logDeviceOperation('move', moveDesc, {
targetId: deviceIds.join(','),
targetName: `${movedCount}台设备`,
beforeState: beforeMoveState,
afterState: { targetRackId, targetRackName: targetRack.name, startPosition },
req,
metadata: { count: movedCount, targetRackId, targetRackName: targetRack.name, startPosition, deviceNames }
});
res.json({
message: `批量移动成功,已将 ${movedCount} 个设备移动到机柜 ${targetRackId}`,
movedCount
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 增强导出设备数据(支持所有字段和自定义字段)
router.get('/enhanced-export', async (req, res) => {
try {
const { deviceIds, format = 'csv' } = req.query;
// 从数据库读取所有字段配置(不过滤 visible,以导出所有信息)
const allFields = await DeviceField.findAll({
order: [['order', 'ASC']]
});
// 构建字段映射表
const fieldMap = {};
const fieldLabels = {};
allFields.forEach(field => {
fieldMap[field.fieldName] = field;
fieldLabels[field.fieldName] = field.displayName;
});
// 构建查询条件
const where = {};
if (deviceIds) {
const ids = Array.isArray(deviceIds) ? deviceIds : [deviceIds];
where.deviceId = { [Op.in]: ids };
}
// 查询设备数据
const devices = await Device.findAll({
where,
include: [
{
model: Rack,
include: [{ model: Room }]
}
]
});
if (devices.length === 0) {
return res.status(404).json({ error: '未找到指定的设备' });
}
// 状态和类型映射
const statusMap = {
running: '运行中',
maintenance: '维护中',
offline: '离线',
fault: '故障'
};
const typeMap = {
server: '服务器',
switch: '交换机',
router: '路由器',
storage: '存储设备',
other: '其他设备'
};
// 准备导出数据 - 遍历所有设备
const exportData = devices.map(device => {
const data = {};
// 首先处理关联字段(机房)- 如果 DeviceField 中没有配置 roomName,也导出机房信息
const hasRoomField = allFields.some(f => f.fieldName === 'roomName');
if (!hasRoomField) {
data['所在机房'] = device.Rack?.Room?.name || '';
}
// 遍历所有字段配置动态获取值
allFields.forEach(field => {
const fieldName = field.fieldName;
const label = field.displayName;
// 首先检查 device 表的直字段
if (device[fieldName] !== undefined && device[fieldName] !== null) {
if (fieldName === 'rackId') {
data[label] = device.Rack?.name || '';
} else if (fieldName === 'roomName') {
data[label] = device.Rack?.Room?.name || '';
} else if (fieldName === 'status') {
data[label] = statusMap[device.status] || device.status || '';
} else if (fieldName === 'type') {
data[label] = typeMap[device.type] || device.type || '';
} else if (fieldName === 'purchaseDate' || fieldName === 'warrantyExpiry') {
data[label] = device[fieldName] ? new Date(device[fieldName]).toLocaleDateString('zh-CN') : '';
} else {
data[label] = device[fieldName];
}
} else if (device.customFields && typeof device.customFields === 'object' && device.customFields[fieldName] !== undefined) {
data[label] = device.customFields[fieldName];
} else {
// 设备表中没有该字段且 customFields 中也没有,设为空字符串
data[label] = '';
}
});
// 展开 customFields 中额外的自定义字段(不在 DeviceField 配置中的)
if (device.customFields && typeof device.customFields === 'object') {
Object.entries(device.customFields).forEach(([key, value]) => {
if (!fieldMap[key]) {
data[key] = value;
}
});
}
return data;
});
// CSV 导出
if (format === 'csv') {
// 构建完整的 header 列表(基于所有字段配置 + customFields 中的额外字段)
const headerSet = new Set();
// 添加机房字段(如果存在)
const hasRoomField = allFields.some(f => f.fieldName === 'roomName');
if (!hasRoomField) {
headerSet.add('所在机房');
}
// 添加所有 DeviceField 配置的字段
allFields.forEach(field => {
headerSet.add(field.displayName);
});
// 收集所有设备 customFields 中的额外字段键
devices.forEach(device => {
if (device.customFields && typeof device.customFields === 'object') {
Object.keys(device.customFields).forEach(key => {
if (!fieldMap[key]) {
headerSet.add(key);
}
});
}
});
const headers = Array.from(headerSet).map(key => ({ id: key, title: key }));
if (headers.length === 0) {
return res.status(400).json({ error: '没有可导出的字段' });
}
const csvWriter = createObjectCsvWriter({
path: path.join(__dirname, '../temp/enhanced_export.csv'),
header: headers,
encoding: 'utf8'
});
if (!fs.existsSync(path.join(__dirname, '../temp'))) {
fs.mkdirSync(path.join(__dirname, '../temp'));
}
await csvWriter.writeRecords(exportData);
const csvContent = fs.readFileSync(path.join(__dirname, '../temp/enhanced_export.csv'), 'utf8');
const gbkContent = iconv.encode(csvContent, 'gbk');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=devices.csv');
res.send(gbkContent);
fs.unlinkSync(path.join(__dirname, '../temp/enhanced_export.csv'));
} else {
// JSON 导出
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename=devices.json');
res.json({
exportTime: new Date().toISOString(),
totalCount: devices.length,
fields: Object.values(fieldLabels),
devices: exportData
});
}
} catch (error) {
console.error('增强导出失败:', error);
res.status(500).json({ error: '增强导出失败' });
}
});
// 获取单个设备
router.get('/:deviceId', async (req, res) => {
try {
const device = await Device.findByPk(req.params.deviceId, {
include: [
{
model: Rack,
include: [
{ model: Room }
]
}
]
});
if (!device) {
return res.status(404).json({ error: '设备不存在' });
}
res.json(device);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 将设备标记为空闲
router.put('/:deviceId/to-idle', async (req, res) => {
const t = await sequelize.transaction();
try {
const { idleReason } = req.body;
const { deviceId } = req.params;
const device = await Device.findByPk(deviceId, { transaction: t });
if (!device) {
await t.rollback();
return res.status(404).json({ error: '设备不存在' });
}
if (device.isIdle) {
await t.rollback();
return res.status(400).json({ error: '设备已经标记为空闲设备' });
}
await device.update({
isIdle: true,
status: 'idle',
idleDate: new Date(),
idleReason: idleReason || `从设备管理转入`
}, { transaction: t });
if (device.rackId) {
const rack = await Rack.findByPk(device.rackId, { transaction: t });
if (rack) {
await rack.update({
currentPower: Math.max(0, rack.currentPower - (device.powerConsumption || 0))
}, { transaction: t });
}
}
await t.commit();
await logDeviceOperation('to_idle', `设备【${device.name}】转入空闲设备`, {
targetId: device.deviceId,
targetName: device.name,
beforeState: { ...device.toJSON(), isIdle: false },
afterState: { ...device.toJSON(), isIdle: true },
req,
metadata: { idleReason, type: 'device_to_idle' }
});
res.json({
message: '设备已转入空闲设备',
device: device.toJSON()
});
} catch (error) {
await t.rollback();
console.error('设备转入空闲设备失败:', error);
res.status(500).json({ error: error.message });
}
});
// 获取设备的工单列表
router.get('/:deviceId/tickets', async (req, res) => {
try {
const { deviceId } = req.params;
const { status, page = 1, pageSize = 10 } = req.query;
const offset = (page - 1) * pageSize;
// 检查设备是否存在
const device = await Device.findByPk(deviceId);
if (!device) {
return res.status(404).json({ error: '设备不存在' });
}
// 构建查询条件
const where = { deviceId };
if (status && status !== 'all') {
where.status = status;
}
// 查询工单列表
const { count, rows: tickets } = await Ticket.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
offset: parseInt(offset),
limit: parseInt(pageSize)
});
res.json({
data: tickets,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize)
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 更新设备
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
try {
const oldDevice = await Device.findByPk(req.params.deviceId);
if (!oldDevice) {
return res.status(404).json({ error: '设备不存在' });
}
const beforeState = oldDevice.toJSON();
const changedFields = {};
const [updated] = await Device.update(req.body, {
where: { deviceId: req.params.deviceId }
});
if (updated) {
const rack = await Rack.findByPk(oldDevice.rackId);
if (rack) {
const powerDiff = req.body.powerConsumption - oldDevice.powerConsumption;
await rack.update({
currentPower: rack.currentPower + powerDiff
});
}
const updatedDevice = await Device.findByPk(req.params.deviceId, {
include: [
{
model: Rack,
include: [
{ model: Room }
]
}
]
});
const afterState = updatedDevice.toJSON();
for (const key of Object.keys(req.body)) {
if (JSON.stringify(beforeState[key]) !== JSON.stringify(afterState[key])) {
changedFields[key] = { from: beforeState[key], to: afterState[key] };
}
}
const changeDetails = Object.entries(changedFields).map(([field, values]) => {
const fieldNames = {
name: '名称', deviceId: '设备编号', type: '类型', model: '型号',
manufacturer: '制造商', serialNumber: '序列号', status: '状态',
position: '安装位置(U)', height: '占用高度(U)', powerConsumption: '功耗(W)',
ipAddress: 'IP地址', macAddress: 'MAC地址', managementIp: '管理IP'
};
const displayName = fieldNames[field] || field;
return `${displayName}: ${values.from ?? '空'}${values.to ?? '空'}`;
}).join('');
const operationDesc = changeDetails
? `更新设备【${updatedDevice.name}】:${changeDetails}`
: `更新设备【${updatedDevice.name}】`;
await logDeviceOperation('update', operationDesc, {
targetId: updatedDevice.deviceId,
targetName: updatedDevice.name,
beforeState,
afterState,
req,
metadata: { changedFields }
});
res.json(updatedDevice);
} else {
res.status(404).json({ error: '设备不存在' });
}
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// 批量删除设备
router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => {
const t = await sequelize.transaction();
try {
const { deviceIds } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
await t.rollback();
return res.status(400).json({ error: '请提供有效的设备 ID 列表' });
}
const devices = await Device.findAll({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
const deviceNames = devices.map(d => d.name).join(', ');
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
await DevicePort.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 2. 删除相关网卡
await NetworkCard.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 3. 删除相关接线
await Cable.destroy({
where: {
[Op.or]: [
{ sourceDeviceId: { [Op.in]: deviceIds } },
{ targetDeviceId: { [Op.in]: deviceIds } }
]
},
transaction: t
});
// 4. 解除工单关联
await Ticket.update(
{ deviceId: null },
{ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }
);
// 5. 删除盘点记录
await InventoryRecord.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 6. 更新机柜功率
for (const device of devices) {
if (device.rackId) {
const rack = await Rack.findByPk(device.rackId, { transaction: t });
if (rack) {
await rack.update({
currentPower: Math.max(0, rack.currentPower - device.powerConsumption)
}, { transaction: t });
}
}
}
// 7. 删除设备
const deletedCount = await Device.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
await t.commit();
await logDeviceOperation('batch_delete', `批量删除${deletedCount}台设备:${deviceNames}`, {
targetId: deviceIds.join(','),
targetName: `${deletedCount}台设备`,
beforeState: devices.map(d => d.toJSON()),
req,
metadata: { count: deletedCount, deviceNames }
});
res.json({
message: `批量删除成功,已删除 ${deletedCount} 个设备`,
deletedCount
});
} catch (error) {
await t.rollback();
console.error('批量删除设备错误:', error);
res.status(500).json({ error: error.message, stack: error.stack });
}
});
// 删除所有设备(一键清空)
router.delete('/delete-all', async (req, res) => {
const t = await sequelize.transaction();
try {
// 获取所有设备
const allDevices = await Device.findAll({ transaction: t });
if (allDevices.length === 0) {
await t.rollback();
return res.json({ message: '没有设备需要删除', deletedCount: 0 });
}
const deviceIds = allDevices.map(d => d.deviceId);
// 1. 删除相关端口
await DevicePort.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 2. 删除相关网卡
await NetworkCard.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 3. 删除相关接线
await Cable.destroy({
where: {
[Op.or]: [
{ sourceDeviceId: { [Op.in]: deviceIds } },
{ targetDeviceId: { [Op.in]: deviceIds } }
]
},
transaction: t
});
// 4. 解除工单关联
await Ticket.update(
{ deviceId: null },
{ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }
);
// 5. 删除盘点记录
await InventoryRecord.destroy({
where: { deviceId: { [Op.in]: deviceIds } },
transaction: t
});
// 6. 更新机柜功率
for (const device of allDevices) {
if (device.rackId) {
const rack = await Rack.findByPk(device.rackId, { transaction: t });
if (rack) {
await rack.update({
currentPower: Math.max(0, rack.currentPower - device.powerConsumption)
}, { transaction: t });
}
}
}
// 7. 删除所有设备
const deletedCount = await Device.destroy({
where: {},
transaction: t
});
await t.commit();
res.json({
message: `成功删除所有设备,共删除 ${deletedCount} 个设备`,
deletedCount
});
} catch (error) {
await t.rollback();
console.error('删除所有设备错误:', error);
res.status(500).json({ error: error.message, stack: error.stack });
}
});
// 删除设备
router.delete('/:deviceId', async (req, res) => {
const t = await sequelize.transaction();
try {
const { deviceId } = req.params;
// 获取设备信息以更新功率
const device = await Device.findByPk(deviceId, { transaction: t });
if (!device) {
await t.rollback();
return res.status(404).json({ error: '设备不存在' });
}
const deviceName = device.name;
const beforeState = device.toJSON();
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
const deletedPorts = await DevicePort.destroy({
where: { deviceId: deviceId },
transaction: t
});
// 2. 删除相关网卡
const deletedNetworkCards = await NetworkCard.destroy({
where: { deviceId: deviceId },
transaction: t
});
// 3. 删除相关接线
// 必须在删除设备之前删除,否则可能触发外键约束错误
const deletedCables = await Cable.destroy({
where: {
[Op.or]: [
{ sourceDeviceId: deviceId },
{ targetDeviceId: deviceId }
]
},
transaction: t
});
// 4. 解除工单关联 (Unlink Tickets)
await Ticket.update(
{ deviceId: null },
{ where: { deviceId: deviceId }, transaction: t }
);
// 5. 删除盘点记录
await InventoryRecord.destroy({
where: { deviceId: deviceId },
transaction: t
});
// 6. 更新机柜功率 (必须在删除设备之前)
if (device.rackId) {
try {
const rack = await Rack.findByPk(device.rackId, { transaction: t });
if (rack) {
await rack.update({
currentPower: Math.max(0, rack.currentPower - device.powerConsumption)
}, { transaction: t });
}
} catch (err) {
console.error('更新机柜功率失败:', err);
throw err; // 重新抛出错误,触发事务回滚
}
}
// 7. 删除设备 (Delete Device)
await Device.destroy({
where: { deviceId: deviceId },
transaction: t
});
// 提交事务
await t.commit();
if (deletedCables > 0) {
console.log(`已删除 ${deletedCables} 条相关接线`);
}
await logDeviceOperation('delete', `删除设备【${deviceName}】(编号:${deviceId},类型:${device.type},关联删除:${deletedCables}条接线、${deletedPorts}个端口、${deletedNetworkCards}张网卡)`, {
targetId: deviceId,
targetName: deviceName,
beforeState,
req,
metadata: { deletedCables, deletedPorts, deletedNetworkCards, deviceType: device.type }
});
res.status(200).json({
message: '删除成功',
deviceId: deviceId,
deletedCablesCount: deletedCables,
deletedPortsCount: deletedPorts
});
} catch (error) {
await t.rollback();
console.error('删除设备失败:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;