diff --git a/backend/models/SystemSetting.js b/backend/models/SystemSetting.js new file mode 100644 index 0000000..1396cc7 --- /dev/null +++ b/backend/models/SystemSetting.js @@ -0,0 +1,47 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const SystemSetting = sequelize.define('SystemSetting', { + settingKey: { + type: DataTypes.STRING(100), + primaryKey: true, + allowNull: false, + comment: '设置键名' + }, + settingValue: { + type: DataTypes.TEXT, + allowNull: true, + comment: '设置值(JSON格式)' + }, + settingType: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'string', + comment: '设置类型: string, number, boolean, json, array' + }, + category: { + type: DataTypes.STRING(50), + allowNull: false, + defaultValue: 'general', + comment: '设置分类: general, appearance, backup, about' + }, + description: { + type: DataTypes.STRING(255), + allowNull: true, + comment: '设置描述' + }, + isEditable: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否可编辑' + } +}, { + tableName: 'system_settings', + timestamps: true, + indexes: [ + { fields: ['category'] } + ] +}); + +module.exports = SystemSetting; diff --git a/backend/models/Ticket.js b/backend/models/Ticket.js index eb7b47d..e527f3f 100644 --- a/backend/models/Ticket.js +++ b/backend/models/Ticket.js @@ -17,7 +17,7 @@ const Ticket = sequelize.define('Ticket', { }, deviceId: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, comment: '关联设备ID' }, deviceName: { diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 7660e8b..d9fb0cb 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -10,6 +10,7 @@ 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'); // 获取所有设备(支持搜索和筛选) router.get('/', async (req, res) => { @@ -653,16 +654,23 @@ router.delete('/batch-delete', async (req, res) => { try { const { deviceIds } = req.body; - if (!deviceIds || !Array.isArray(deviceIds)) { - return res.status(400).json({ error: '无效的设备ID列表' }); + if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { + return res.status(400).json({ error: '请提供有效的设备ID列表' }); } - // 先获取所有设备的功率信息 const devices = await Device.findAll({ - where: { deviceId: deviceIds } + where: { deviceId: { [Op.in]: deviceIds } } + }); + + await Ticket.update( + { deviceId: null }, + { where: { deviceId: { [Op.in]: deviceIds } } } + ); + + const deletedCount = await Device.destroy({ + where: { deviceId: { [Op.in]: deviceIds } } }); - // 更新机柜功率 for (const device of devices) { const rack = await Rack.findByPk(device.rackId); if (rack) { @@ -672,12 +680,10 @@ router.delete('/batch-delete', async (req, res) => { } } - // 删除设备 - const deleted = await Device.destroy({ - where: { deviceId: deviceIds } + res.json({ + message: `批量删除成功,已删除 ${deletedCount} 个设备`, + deletedCount }); - - res.json({ message: `成功删除 ${deleted} 个设备` }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -714,4 +720,329 @@ router.delete('/:deviceId', async (req, res) => { } }); +// 批量上线设备 +router.put('/batch-online', 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', 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 validStatus = ['running', 'maintenance', 'offline', 'fault']; + if (!validStatus.includes(status)) { + return res.status(400).json({ + error: `状态值无效,有效值为:${validStatus.join('、')}` + }); + } + + // 状态映射 + const statusText = { + running: '运行中', + maintenance: '维护中', + offline: '离线', + fault: '故障' + }; + + // 更新设备状态 + const [affectedCount] = await Device.update( + { status }, + { where: { deviceId: { [Op.in]: deviceIds } } } + ); + + 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' }); + } + + if (startPosition === undefined || startPosition === null) { + return res.status(400).json({ error: '请提供起始U位' }); + } + + // 验证目标机柜是否存在 + const targetRack = await Rack.findByPk(targetRackId); + if (!targetRack) { + return res.status(404).json({ error: '目标机柜不存在' }); + } + + // 获取要移动的设备 + const devices = await Device.findAll({ + where: { deviceId: { [Op.in]: deviceIds } } + }); + + if (devices.length === 0) { + return res.status(404).json({ error: '未找到指定的设备' }); + } + + const movedDevices = []; + let currentPosition = parseInt(startPosition); + + // 按原位置排序设备 + devices.sort((a, b) => a.position - b.position); + + for (const device of devices) { + // 计算新位置 + const newPosition = currentPosition; + + // 验证位置是否在机柜范围内 + if (newPosition < 1 || newPosition > targetRack.height) { + return res.status(400).json({ + error: `设备 ${device.name} 的位置 ${newPosition} 超出机柜高度范围(1-${targetRack.height})` + }); + } + + // 检查目标位置是否被其他设备占用(排除自身) + const existingDevice = await Device.findOne({ + where: { + rackId: targetRackId, + position: newPosition, + deviceId: { [Op.ne]: device.deviceId } + } + }); + + if (existingDevice) { + return res.status(400).json({ + error: `机柜 ${targetRack.name} 的位置 ${newPosition} 已被设备 ${existingDevice.name} 占用` + }); + } + + // 计算功率变化 + const oldPower = device.powerConsumption; + + // 更新设备位置 + await device.update({ + rackId: targetRackId, + position: newPosition + }); + + // 更新原机柜和新机柜的功率 + const oldRack = await Rack.findByPk(device.rackId); + if (oldRack) { + await oldRack.update({ + currentPower: Math.max(0, oldRack.currentPower - oldPower) + }); + } + + await targetRack.update({ + currentPower: targetRack.currentPower + oldPower + }); + + movedDevices.push({ + deviceId: device.deviceId, + name: device.name, + oldRackId: device.rackId, + newRackId: targetRackId, + oldPosition: device.position, + newPosition: newPosition + }); + + // 下一个设备的起始位置 = 当前设备位置 + 当前设备高度 + currentPosition += device.height; + } + + res.json({ + message: `批量移动成功,已移动 ${movedDevices.length} 个设备`, + movedDevices + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 增强导出设备数据(支持自定义字段选择和格式选择) +router.get('/enhanced-export', async (req, res) => { + try { + const { deviceIds, format = 'csv', fields, fieldLabels } = req.query; + + // 解析字段列表 + let selectedFields = []; + try { + selectedFields = fields ? JSON.parse(fields) : []; + } catch (e) { + selectedFields = []; + } + + // 解析字段标签 + let fieldLabelMap = {}; + try { + fieldLabelMap = fieldLabels ? JSON.parse(fieldLabels) : {}; + } catch (e) { + fieldLabelMap = {}; + } + + // 构建查询条件 + 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 exportData = devices.map(device => { + const data = {}; + + selectedFields.forEach(fieldName => { + // 映射字段名到中文标签 + const label = fieldLabelMap[fieldName] || fieldName; + + // 根据字段名获取值 + if (fieldName === 'rackName') { + data[label] = device.Rack?.name || ''; + } else if (fieldName === 'roomName') { + data[label] = device.Rack?.Room?.name || ''; + } else if (fieldName === 'status') { + const statusMap = { + running: '运行中', + maintenance: '维护中', + offline: '离线', + fault: '故障' + }; + data[label] = statusMap[device.status] || device.status; + } else if (fieldName === 'type') { + const typeMap = { + server: '服务器', + switch: '交换机', + router: '路由器', + storage: '存储设备', + other: '其他设备' + }; + 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 if (fieldName === 'customFields' && device.customFields) { + // 如果选择导出自定义字段,展开为单独的列 + Object.entries(device.customFields).forEach(([key, value]) => { + data[key] = value; + }); + } else if (device[fieldName] !== undefined) { + data[label] = device[fieldName]; + } + }); + + return data; + }); + + if (format === 'json') { + // JSON格式导出 + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename=devices.json'); + res.json({ + exportTime: new Date().toISOString(), + totalCount: devices.length, + devices: exportData + }); + } else { + // CSV格式导出 + const csvWriter = createObjectCsvWriter({ + path: path.join(__dirname, '../temp/enhanced_export.csv'), + header: Object.keys(exportData[0] || {}).map(key => ({ id: key, title: key })), + encoding: 'utf8' + }); + + // 确保temp目录存在 + 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')); + } + } catch (error) { + console.error('增强导出失败:', error); + res.status(500).json({ error: '增强导出失败' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js new file mode 100644 index 0000000..5f9e00d --- /dev/null +++ b/backend/routes/systemSettings.js @@ -0,0 +1,505 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const { Op } = require('sequelize'); +const SystemSetting = require('../models/SystemSetting'); + +// 初始化默认系统设置 +const initDefaultSettings = async () => { + const defaultSettings = [ + // 全局配置 + { settingKey: 'site_name', settingValue: JSON.stringify('机柜管理系统'), settingType: 'string', category: 'general', description: '网站名称', isEditable: true }, + { settingKey: 'site_logo', settingValue: JSON.stringify(''), settingType: 'string', category: 'general', description: '网站Logo URL', isEditable: true }, + { settingKey: 'timezone', settingValue: JSON.stringify('Asia/Shanghai'), settingType: 'string', category: 'general', description: '时区设置', isEditable: true }, + { settingKey: 'date_format', settingValue: JSON.stringify('YYYY-MM-DD'), settingType: 'string', category: 'general', description: '日期格式', isEditable: true }, + { settingKey: 'language', settingValue: JSON.stringify('zh-CN'), settingType: 'string', category: 'general', description: '语言设置', isEditable: true }, + { settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '会话超时时间(分钟)', isEditable: true }, + { settingKey: 'max_login_attempts', settingValue: JSON.stringify(5), settingType: 'number', category: 'general', description: '最大登录尝试次数', isEditable: true }, + { settingKey: 'maintenance_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'general', description: '维护模式', isEditable: true }, + + // 外观设置 + { settingKey: 'primary_color', settingValue: JSON.stringify('#667eea'), settingType: 'string', category: 'appearance', description: '主题主色调', isEditable: true }, + { settingKey: 'secondary_color', settingValue: JSON.stringify('#764ba2'), settingType: 'string', category: 'appearance', description: '主题辅助色调', isEditable: true }, + { settingKey: 'dark_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'appearance', description: '深色模式', isEditable: true }, + { settingKey: 'compact_mode', settingValue: JSON.stringify(false), type: 'boolean', category: 'appearance', description: '紧凑模式', isEditable: true }, + { settingKey: 'sidebar_collapsed', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'appearance', description: '侧边栏默认折叠', isEditable: true }, + { settingKey: 'table_row_height', settingValue: JSON.stringify('default'), settingType: 'string', category: 'appearance', description: '表格行高: small/default/middle/large', isEditable: true }, + { settingKey: 'animation_enabled', settingValue: JSON.stringify(true), settingType: 'boolean', category: 'appearance', description: '启用动画效果', isEditable: true }, + + // 数据备份设置 + { settingKey: 'auto_backup_enabled', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'backup', description: '启用自动备份', isEditable: true }, + { settingKey: 'backup_interval', settingValue: JSON.stringify(24), settingType: 'number', category: 'backup', description: '备份间隔(小时)', isEditable: true }, + { settingKey: 'backup_retention', settingValue: JSON.stringify(7), settingType: 'number', category: 'backup', description: '备份保留天数', isEditable: true }, + { settingKey: 'backup_path', settingValue: JSON.stringify('./backups'), settingType: 'string', category: 'backup', description: '备份存储路径', isEditable: true }, + { settingKey: 'last_backup_time', settingValue: JSON.stringify(null), settingType: 'string', category: 'backup', description: '上次备份时间', isEditable: false }, + { settingKey: 'backup_count', settingValue: JSON.stringify(0), settingType: 'number', category: 'backup', description: '备份文件数量', isEditable: false }, + + // 关于页面 + { settingKey: 'app_version', settingValue: JSON.stringify('1.0.0'), settingType: 'string', category: 'about', description: '应用版本', isEditable: false }, + { settingKey: 'company_name', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '公司/组织名称', isEditable: true }, + { settingKey: 'contact_email', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '联系邮箱', isEditable: true }, + { settingKey: 'contact_phone', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '联系电话', isEditable: true }, + { settingKey: 'company_address', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '公司地址', isEditable: true }, + { settingKey: 'system_description', settingValue: JSON.stringify('机柜管理系统 - 专业的数据中心设备管理解决方案'), settingType: 'string', category: 'about', description: '系统描述', isEditable: true }, + { settingKey: 'privacy_policy', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '隐私政策URL', isEditable: true }, + { settingKey: 'terms_of_service', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '服务条款URL', isEditable: true }, + ]; + + for (const setting of defaultSettings) { + try { + const existing = await SystemSetting.findByPk(setting.settingKey); + if (!existing) { + await SystemSetting.create(setting); + } + } catch (error) { + console.error(`初始化设置 ${setting.settingKey} 失败:`, error); + } + } +}; + +// 初始化默认设置 +initDefaultSettings(); + +// 获取所有设置 +router.get('/', async (req, res) => { + try { + const { category } = req.query; + const where = {}; + if (category) { + where.category = category; + } + + const settings = await SystemSetting.findAll({ + where, + order: [['category', 'ASC'], ['settingKey', 'ASC']] + }); + + // 格式化返回数据 + const formattedSettings = {}; + settings.forEach(setting => { + formattedSettings[setting.settingKey] = { + value: JSON.parse(setting.settingValue), + type: setting.settingType, + category: setting.category, + description: setting.description, + isEditable: setting.isEditable, + updatedAt: setting.updatedAt + }; + }); + + res.json(formattedSettings); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取单个设置 +router.get('/:key', async (req, res) => { + try { + const { key } = req.params; + const setting = await SystemSetting.findByPk(key); + + if (!setting) { + return res.status(404).json({ error: '设置不存在' }); + } + + res.json({ + key: setting.settingKey, + value: JSON.parse(setting.settingValue), + type: setting.settingType, + category: setting.category, + description: setting.description, + isEditable: setting.isEditable, + updatedAt: setting.updatedAt + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 更新设置 +router.put('/:key', async (req, res) => { + try { + const { key } = req.params; + const { value } = req.body; + + const setting = await SystemSetting.findByPk(key); + + if (!setting) { + return res.status(404).json({ error: '设置不存在' }); + } + + if (!setting.isEditable) { + return res.status(403).json({ error: '该设置不可编辑' }); + } + + // 验证值类型 + let parsedValue = value; + if (setting.settingType === 'number') { + parsedValue = Number(value); + if (isNaN(parsedValue)) { + return res.status(400).json({ error: '值必须是有效的数字' }); + } + } else if (setting.settingType === 'boolean') { + parsedValue = Boolean(value); + } + + await setting.update({ + settingValue: JSON.stringify(parsedValue) + }); + + res.json({ + message: '设置更新成功', + setting: { + key: setting.settingKey, + value: parsedValue, + updatedAt: setting.updatedAt + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 批量更新设置 +router.put('/', async (req, res) => { + try { + const { settings } = req.body; + + if (!settings || typeof settings !== 'object') { + return res.status(400).json({ error: '请提供有效的设置对象' }); + } + + const updatedSettings = []; + const errors = []; + + for (const [key, value] of Object.entries(settings)) { + try { + const setting = await SystemSetting.findByPk(key); + + if (!setting) { + errors.push({ key, error: '设置不存在' }); + continue; + } + + if (!setting.isEditable) { + errors.push({ key, error: '该设置不可编辑' }); + continue; + } + + let parsedValue = value; + if (setting.settingType === 'number') { + parsedValue = Number(value); + if (isNaN(parsedValue)) { + errors.push({ key, error: '值必须是有效的数字' }); + continue; + } + } else if (setting.settingType === 'boolean') { + parsedValue = Boolean(value); + } + + await setting.update({ + settingValue: JSON.stringify(parsedValue) + }); + + updatedSettings.push({ key, value: parsedValue }); + } catch (error) { + errors.push({ key, error: error.message }); + } + } + + res.json({ + message: `成功更新 ${updatedSettings.length} 个设置`, + updatedSettings, + errors: errors.length > 0 ? errors : undefined + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 重置设置为默认值 +router.post('/reset/:key', async (req, res) => { + try { + const { key } = req.params; + const setting = await SystemSetting.findByPk(key); + + if (!setting) { + return res.status(404).json({ error: '设置不存在' }); + } + + const defaultValues = { + site_name: '机柜管理系统', + site_logo: '', + timezone: 'Asia/Shanghai', + date_format: 'YYYY-MM-DD', + language: 'zh-CN', + session_timeout: 30, + max_login_attempts: 5, + maintenance_mode: false, + primary_color: '#667eea', + secondary_color: '#764ba2', + dark_mode: false, + compact_mode: false, + sidebar_collapsed: false, + table_row_height: 'default', + animation_enabled: true, + auto_backup_enabled: false, + backup_interval: 24, + backup_retention: 7, + backup_path: './backups', + company_name: '', + contact_email: '', + contact_phone: '', + company_address: '', + system_description: '机柜管理系统 - 专业的数据中心设备管理解决方案', + privacy_policy: '', + terms_of_service: '' + }; + + const defaultValue = defaultValues[key]; + if (defaultValue === undefined) { + return res.status(400).json({ error: '该设置没有默认值' }); + } + + await setting.update({ + settingValue: JSON.stringify(defaultValue) + }); + + res.json({ + message: '设置已重置为默认值', + key, + value: defaultValue + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 手动执行备份 +router.post('/backup', async (req, res) => { + try { + const backupDir = path.join(__dirname, '../backups'); + + // 确保备份目录存在 + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFile = path.join(backupDir, `backup_${timestamp}.json`); + + // 获取所有数据库数据 + const Device = require('../models/Device'); + const Rack = require('../models/Rack'); + const Room = require('../models/Room'); + const Consumable = require('../models/Consumable'); + const User = require('../models/User'); + + const backupData = { + timestamp: new Date().toISOString(), + version: '1.0.0', + data: { + devices: await Device.findAll({ raw: true }), + racks: await Rack.findAll({ raw: true }), + rooms: await Room.findAll({ raw: true }), + consumables: await Consumable.findAll({ raw: true }), + // 不包含敏感用户信息 + users: await User.findAll({ + attributes: ['userId', 'username', 'role', 'createdAt', 'updatedAt'], + raw: true + }) + } + }; + + // 写入备份文件 + fs.writeFileSync(backupFile, JSON.stringify(backupData, null, 2)); + + // 更新最后备份时间 + const lastBackupSetting = await SystemSetting.findByPk('last_backup_time'); + if (lastBackupSetting) { + await lastBackupSetting.update({ + settingValue: JSON.stringify(new Date().toISOString()) + }); + } + + // 统计备份文件数量 + const backupFiles = fs.readdirSync(backupDir).filter(f => f.startsWith('backup_')); + const countSetting = await SystemSetting.findByPk('backup_count'); + if (countSetting) { + await countSetting.update({ + settingValue: JSON.stringify(backupFiles.length) + }); + } + + res.json({ + message: '备份成功', + backupFile: `backups/backup_${timestamp}.json`, + fileSize: fs.statSync(backupFile).size, + backupCount: backupFiles.length + }); + } catch (error) { + console.error('备份失败:', error); + res.status(500).json({ error: '备份失败' }); + } +}); + +// 获取备份列表 +router.get('/backup/list', async (req, res) => { + try { + const backupDir = path.join(__dirname, '../backups'); + + if (!fs.existsSync(backupDir)) { + return res.json({ backups: [] }); + } + + const files = fs.readdirSync(backupDir) + .filter(f => f.startsWith('backup_') && f.endsWith('.json')) + .map(f => { + const filePath = path.join(backupDir, f); + const stats = fs.statSync(filePath); + return { + filename: f, + path: `backups/${f}`, + size: stats.size, + createdAt: stats.birthtime, + modifiedAt: stats.mtime + }; + }) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + + res.json({ backups: files }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 恢复备份 +router.post('/backup/restore', async (req, res) => { + try { + const { filename } = req.body; + + if (!filename) { + return res.status(400).json({ error: '请提供备份文件名' }); + } + + const backupFile = path.join(__dirname, '../backups', filename); + + if (!fs.existsSync(backupFile)) { + return res.status(404).json({ error: '备份文件不存在' }); + } + + const backupData = JSON.parse(fs.readFileSync(backupFile, 'utf8')); + + // 恢复数据 + const { Device, Rack, Room, Consumable, User } = require('../models'); + + if (backupData.data.devices) { + for (const device of backupData.data.devices) { + await Device.upsert(device); + } + } + + if (backupData.data.racks) { + for (const rack of backupData.data.racks) { + await Rack.upsert(rack); + } + } + + if (backupData.data.rooms) { + for (const room of backupData.data.rooms) { + await Room.upsert(room); + } + } + + if (backupData.data.consumables) { + for (const consumable of backupData.data.consumables) { + await Consumable.upsert(consumable); + } + } + + res.json({ + message: '恢复成功', + restoredAt: new Date().toISOString() + }); + } catch (error) { + console.error('恢复备份失败:', error); + res.status(500).json({ error: '恢复备份失败' }); + } +}); + +// 删除备份 +router.delete('/backup/:filename', async (req, res) => { + try { + const { filename } = req.params; + const backupFile = path.join(__dirname, '../backups', filename); + + if (!fs.existsSync(backupFile)) { + return res.status(404).json({ error: '备份文件不存在' }); + } + + fs.unlinkSync(backupFile); + + res.json({ message: '删除成功', filename }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 下载备份文件 +router.get('/backup/download/:filename', async (req, res) => { + try { + const { filename } = req.params; + const backupFile = path.join(__dirname, '../backups', filename); + + if (!fs.existsSync(backupFile)) { + return res.status(404).json({ error: '备份文件不存在' }); + } + + res.download(backupFile, filename); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取系统信息 +router.get('/system/info', async (req, res) => { + try { + const Device = require('../models/Device'); + const Rack = require('../models/Rack'); + const Room = require('../models/Room'); + const User = require('../models/User'); + + const [deviceCount, rackCount, roomCount, userCount] = await Promise.all([ + Device.count(), + Rack.count(), + Room.count(), + User.count() + ]); + + res.json({ + system: { + name: '机柜管理系统', + version: '1.0.0', + uptime: process.uptime(), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + memoryUsage: process.memoryUsage(), + pid: process.pid + }, + statistics: { + devices: deviceCount, + racks: rackCount, + rooms: roomCount, + users: userCount + }, + timestamp: new Date().toISOString() + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 1bb564d..b59de09 100644 --- a/backend/server.js +++ b/backend/server.js @@ -39,6 +39,11 @@ sequelize.authenticate() // 初始化工单模型关联 return require('./models/ticketIndex').initializeModels(); }) + .then(() => { + // 同步系统设置模型 + const SystemSetting = require('./models/SystemSetting'); + return SystemSetting.sync(); + }) .catch(err => console.error('数据库操作失败:', err)); // 导入路由 @@ -58,6 +63,7 @@ const operationLogsRoutes = require('./routes/operationLogs'); const ticketRoutes = require('./routes/tickets'); const ticketCategoryRoutes = require('./routes/ticketCategories'); const ticketFieldRoutes = require('./routes/ticketFields'); +const systemSettingsRoutes = require('./routes/systemSettings'); // 使用路由 app.use('/api/devices', deviceRoutes); @@ -76,6 +82,7 @@ app.use('/api/operation-logs', operationLogsRoutes); app.use('/api/tickets', ticketRoutes); app.use('/api/ticket-categories', ticketCategoryRoutes); app.use('/api/ticket-fields', ticketFieldRoutes); +app.use('/api/system-settings', systemSettingsRoutes); // 静态文件服务 app.use('/uploads', express.static('uploads')); diff --git a/check.js b/check.js new file mode 100644 index 0000000..a9ddfed --- /dev/null +++ b/check.js @@ -0,0 +1,17 @@ +const fs = require('fs'); + +const content = fs.readFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', 'utf8'); + +const startMarker = '// 可调整列宽的表头组件'; +const endMarker = '// 防抖 Hook'; + +const startIndex = content.indexOf(startMarker); +const endIndex = content.indexOf(endMarker, startIndex); + +console.log('startMarker index:', startIndex); +console.log('endMarker index:', endIndex); + +if (startIndex >= 0 && endIndex >= 0) { + console.log('Content to remove:'); + console.log(content.substring(startIndex, endIndex + endMarker.length).substring(0, 500)); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f96fe8c..4ebc97e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import React, { useState, Suspense, lazy } from 'react'; import { Layout, Menu, theme, Button, Dropdown, Avatar, message, Space, Divider } from 'antd'; -import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined } from '@ant-design/icons'; +import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined, UserOutlined, LogoutOutlined, HistoryOutlined, AuditOutlined, ToolOutlined, ScheduleOutlined, SettingOutlined } from '@ant-design/icons'; import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from './context/AuthContext'; import { Spin } from 'antd'; @@ -23,6 +23,7 @@ const TicketManagement = lazy(() => import('./pages/TicketManagement')); const TicketCategoryManagement = lazy(() => import('./pages/TicketCategoryManagement')); const TicketStatistics = lazy(() => import('./pages/TicketStatistics')); const TicketFieldManagement = lazy(() => import('./pages/TicketFieldManagement')); +const SystemSettings = lazy(() => import('./pages/SystemSettings')); const { Header, Content, Sider } = Layout; @@ -226,6 +227,11 @@ const AppLayout = ({ children }) => { icon: , label: 操作日志, }, + { + key: 'system-settings', + icon: , + label: 系统设置, + }, ], }, { @@ -463,6 +469,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index fbc0524..b0a32d1 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, InputNumber, Switch, Upload, Progress, Checkbox, Spin } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, SettingOutlined, UndoOutlined, CloudServerOutlined, SwapOutlined, SafetyOutlined, DatabaseOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, InputNumber, Switch, Upload, Progress, Checkbox, Spin, Dropdown, Tooltip } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, SettingOutlined, UndoOutlined, CloudServerOutlined, SwapOutlined, SafetyOutlined, DatabaseOutlined, AppstoreOutlined, MoreOutlined, ReloadOutlined, ExportOutlined, DragOutlined, FileExcelOutlined } from '@ant-design/icons'; import axios from 'axios'; import dayjs from 'dayjs'; @@ -96,65 +96,70 @@ const defaultDeviceFields = [ { fieldName: 'ipAddress', displayName: 'IP地址', fieldType: 'string', required: false, order: 13, visible: true }, { fieldName: 'description', displayName: '描述', fieldType: 'textarea', required: false, order: 14, visible: true } ]; - -// 可调整列宽的表头组件 -const ResizeableTitle = (props) => { - const { onResize, width, ...restProps } = props; - if (!width) { - return ; - } - - const handleMouseDown = (e) => { - if (!onResize) return; + // 简单的可调整列宽的表头组件 + const ResizableTitle = (props) => { + const { children, onResize, width, ...restProps } = props; - const startX = e.pageX; - const startWidth = width; - - const handleMouseMove = (moveEvent) => { - const diff = moveEvent.pageX - startX; - const newWidth = Math.max(50, startWidth + diff); - onResize(newWidth); + const handleMouseDown = (e) => { + if (!onResize) return; + + e.preventDefault(); + e.stopPropagation(); + + const th = e.currentTarget.closest('th'); + if (!th) return; + + const startWidth = th.offsetWidth; + const startX = e.clientX; + + const handleMouseMove = (moveEvent) => { + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + onResize(newWidth); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + return ( + + + {children} + {onResize && ( + + )} + + + ); }; - - return ( - - {restProps.children} - - - ); -}; function DeviceManagement() { const [devices, setDevices] = useState([]); @@ -200,6 +205,27 @@ function DeviceManagement() { // 字段配置模态框 const [fieldConfigModalVisible, setFieldConfigModalVisible] = useState(false); + // 批量状态变更模态框 + const [batchStatusModalVisible, setBatchStatusModalVisible] = useState(false); + const [batchStatusLoading, setBatchStatusLoading] = useState(false); + const [batchStatusForm] = Form.useForm(); + + // 批量移动模态框 + const [batchMoveModalVisible, setBatchMoveModalVisible] = useState(false); + const [batchMoveLoading, setBatchMoveLoading] = useState(false); + const [batchMoveForm] = Form.useForm(); + + // 导出选项模态框 + const [exportModalVisible, setExportModalVisible] = useState(false); + const [exportFormat, setExportFormat] = useState('csv'); + const [exportScope, setExportScope] = useState('selected'); + const [exportFields, setExportFields] = useState([]); + const [exportLoading, setExportLoading] = useState(false); + const [currentPageDevices, setCurrentPageDevices] = useState([]); + + // 全选状态 + const [selectAll, setSelectAll] = useState(false); + // 列宽状态 const [columnWidths, setColumnWidths] = useState({}); @@ -429,6 +455,18 @@ function DeviceManagement() { fetchDeviceFields(); }, []); + // 同步当前页设备数据 + useEffect(() => { + if (filteredDevicesMemo.length > 0) { + const start = (pagination.current - 1) * pagination.pageSize; + const end = start + pagination.pageSize; + const currentPageData = filteredDevicesMemo.slice(start, end); + setCurrentPageDevices(currentPageData); + } else { + setCurrentPageDevices([]); + } + }, [filteredDevicesMemo, pagination.current, pagination.pageSize]); + // 打开模态框 const showModal = (device = null) => { setEditingDevice(device); @@ -565,9 +603,15 @@ function DeviceManagement() { }; // 表格分页变化处理 - const handleTableChange = (pagination) => { - setPagination(pagination); - fetchDevices(pagination.current, pagination.pageSize); + const handleTableChange = (newPagination) => { + setPagination(newPagination); + + const start = (newPagination.current - 1) * newPagination.pageSize; + const end = start + newPagination.pageSize; + const currentPageData = filteredDevicesMemo.slice(start, end); + setCurrentPageDevices(currentPageData); + + fetchDevices(newPagination.current, newPagination.pageSize); }; // 批量下线设备 @@ -647,39 +691,177 @@ function DeviceManagement() { setDetailModalVisible(true); }; - // 导出设备数据 - const handleExport = async () => { + // 打开批量状态变更模态框 + const showBatchStatusModal = () => { + if (selectedDevices.length === 0) { + message.warning('请先选择要操作的设备'); + return; + } + batchStatusForm.resetFields(); + setBatchStatusModalVisible(true); + }; + + // 执行批量状态变更 + const handleBatchStatusChange = async () => { try { - if (selectedDevices.length === 0) { - message.warning('请先选择要导出的设备'); + const values = await batchStatusForm.validateFields(); + setBatchStatusLoading(true); + + const response = await axios.put('/api/devices/batch-status', { + deviceIds: selectedDevices, + status: values.status + }); + + message.success(response.data.message || '批量状态变更成功'); + setBatchStatusModalVisible(false); + setSelectedDevices([]); + setSelectAll(false); + fetchDevices(); + } catch (error) { + if (error.errorFields) { + return; + } + message.error('批量状态变更失败'); + console.error('批量状态变更失败:', error); + } finally { + setBatchStatusLoading(false); + } + }; + + // 打开批量移动模态框 + const showBatchMoveModal = () => { + if (selectedDevices.length === 0) { + message.warning('请先选择要移动的设备'); + return; + } + batchMoveForm.resetFields(); + setBatchMoveModalVisible(true); + }; + + // 执行批量移动 + const handleBatchMove = async () => { + try { + const values = await batchMoveForm.validateFields(); + setBatchMoveLoading(true); + + const response = await axios.put('/api/devices/batch-move', { + deviceIds: selectedDevices, + targetRackId: values.targetRackId, + startPosition: values.startPosition + }); + + message.success(response.data.message || '批量移动成功'); + setBatchMoveModalVisible(false); + setSelectedDevices([]); + setSelectAll(false); + fetchDevices(); + fetchRacks(); + } catch (error) { + if (error.errorFields) { + return; + } + message.error('批量移动失败'); + console.error('批量移动失败:', error); + } finally { + setBatchMoveLoading(false); + } + }; + + // 打开导出选项模态框 + const showExportModal = () => { + if (selectedDevices.length === 0) { + message.warning('请先选择要导出的设备'); + return; + } + setExportFormat('csv'); + setExportFields(deviceFields.filter(f => f.visible && f.fieldName !== 'rackId').map(f => f.fieldName)); + setExportModalVisible(true); + }; + + // 执行增强导出 + const handleEnhancedExport = async () => { + try { + setExportLoading(true); + + const fieldLabels = {}; + deviceFields.forEach(field => { + fieldLabels[field.fieldName] = field.displayName; + }); + + let deviceIds = []; + if (exportScope === 'selected') { + deviceIds = selectedDevices; + } else if (exportScope === 'currentPage') { + deviceIds = filteredDevicesMemo.map(device => device.deviceId); + } else if (exportScope === 'all') { + deviceIds = allDevices.map(device => device.deviceId); + } + + if (deviceIds.length === 0) { + message.warning('没有可导出的设备'); + setExportLoading(false); return; } const params = new URLSearchParams(); - selectedDevices.forEach(id => params.append('deviceIds', id)); + deviceIds.forEach(id => params.append('deviceIds', id)); + params.append('format', exportFormat); + params.append('fields', JSON.stringify(exportFields)); + params.append('fieldLabels', JSON.stringify(fieldLabels)); - const response = await axios.get(`/api/devices/export?${params.toString()}`, { responseType: 'blob' }); - const blob = new Blob([response.data], { type: 'text/csv; charset=gbk' }); + const response = await axios.get(`/api/devices/enhanced-export?${params.toString()}`, { responseType: 'blob' }); + + const contentType = exportFormat === 'csv' ? 'text/csv; charset=gbk' : 'application/json'; + const blob = new Blob([response.data], { type: contentType }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `devices_export_${new Date().toISOString().split('T')[0]}.csv`; + link.download = `devices_export_${new Date().toISOString().split('T')[0]}.${exportFormat}`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); - message.success('导出成功'); + + message.success(`成功导出 ${deviceIds.length} 个设备`); + setExportModalVisible(false); } catch (error) { message.error('导出失败'); - console.error('导出设备失败:', error); + console.error('增强导出失败:', error); + } finally { + setExportLoading(false); } }; - - - - - // 导入设备数据 + // 切换选择全部设备 + const handleSelectAll = () => { + if (selectAll) { + setSelectedDevices([]); + setSelectAll(false); + } else { + const allIds = filteredDevicesMemo.map(device => device.deviceId); + setSelectedDevices(allIds); + setSelectAll(true); + } + }; + + // 处理选择变化 + const handleSelectionChange = (selectedRowKeys) => { + setSelectedDevices(selectedRowKeys); + setSelectAll(selectedRowKeys.length === filteredDevicesMemo.length && filteredDevicesMemo.length > 0); + }; + + // 全选复选框的处理函数 + const handleSelectAllCheckbox = (e) => { + const checked = e.target.checked; + if (checked) { + const allIds = filteredDevicesMemo.map(device => device.deviceId); + setSelectedDevices(allIds); + setSelectAll(true); + } else { + setSelectedDevices([]); + setSelectAll(false); + } + }; const handleImport = async (file) => { try { setIsImporting(true); @@ -810,13 +992,15 @@ function DeviceManagement() { message.success('列宽已重置为默认值'); }; - // 处理表头单元格拖拽 + // 处理表头单元格拖拽 - 自定义实现 const handleHeaderCellResize = (key) => (column) => ({ width: column.width, - onResize: (width) => handleColumnResize(key, width), + onResize: (width) => { + setColumnWidths(prev => ({ ...prev, [key]: width })); + }, }); - + // 动态生成表格列配置 const columns = React.useMemo(() => { const generatedColumns = []; @@ -942,7 +1126,10 @@ function DeviceManagement() { dataIndex: field.fieldName, key: field.fieldName, width: columnWidths[field.fieldName] || defaultWidth, + minWidth: 80, + maxWidth: field.fieldType === 'textarea' ? 300 : 200, onHeaderCell: handleHeaderCellResize(field.fieldName), + ellipsis: field.fieldType !== 'textarea', }; // 设备名称和ID列添加点击效果 @@ -953,7 +1140,11 @@ function DeviceManagement() { style={{ color: '#1890ff', textDecoration: 'none', - cursor: 'pointer' + cursor: 'pointer', + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }} onMouseEnter={(e) => e.target.style.textDecoration = 'underline'} onMouseLeave={(e) => e.target.style.textDecoration = 'none'} @@ -971,17 +1162,32 @@ function DeviceManagement() { generatedColumns.push({ title: '操作', key: 'action', - width: columnWidths.action || 120, + width: columnWidths.action || 80, + minWidth: 60, + maxWidth: 100, onHeaderCell: handleHeaderCellResize('action'), render: (_, record) => ( - - } onClick={() => showModal(record)} size="small"> - 编辑 - - } onClick={() => handleDelete(record.deviceId)} size="small"> - 删除 - - + + + } + onClick={() => showModal(record)} + size="small" + style={{ color: '#1890ff', padding: '4px 8px' }} + /> + + + } + onClick={() => handleDelete(record.deviceId)} + size="small" + style={{ padding: '4px 8px' }} + /> + + ), }); @@ -1023,7 +1229,7 @@ function DeviceManagement() { justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', - gap: '16px' + gap: '12px' }; const titleStyle = { @@ -1077,12 +1283,114 @@ function DeviceManagement() { return ( + + 设备管理 - + } @@ -1090,13 +1398,6 @@ function DeviceManagement() { > 添加设备 - } - onClick={handleExport} - > - 导出设备 - } @@ -1122,14 +1423,26 @@ function DeviceManagement() { 0 ? '#1890ff' : undefined, - borderColor: selectedDevices.length > 0 ? '#1890ff' : undefined + color: selectedDevices.length > 0 ? '#52c41a' : undefined, + borderColor: selectedDevices.length > 0 ? '#52c41a' : undefined }} - icon={} + icon={} disabled={selectedDevices.length === 0} - onClick={handleBatchOffline} + onClick={showBatchStatusModal} > - 一键下线 ({selectedDevices.length}) + 状态变更 ({selectedDevices.length}) + + 0 ? '#722ed1' : undefined, + borderColor: selectedDevices.length > 0 ? '#722ed1' : undefined + }} + icon={} + disabled={selectedDevices.length === 0} + onClick={showBatchMoveModal} + > + 批量移动 ({selectedDevices.length}) - 一键删除 ({selectedDevices.length}) + 批量删除 ({selectedDevices.length}) - + 0 ? '#ff4d4f' : undefined, + borderColor: selectedDevices.length > 0 ? '#ff4d4f' : undefined + }} + danger + icon={} + disabled={selectedDevices.length === 0} + onClick={handleBatchOffline} + > + 批量下线 ({selectedDevices.length}) + + - + + } + onClick={showExportModal} + > + 增强导出 + @@ -1230,25 +1568,63 @@ function DeviceManagement() { 暂无设备数据 )} - + + { + const allIds = filteredDevicesMemo.map(device => device.deviceId); + setSelectedDevices(allIds); + setSelectAll(true); + }}, + { key: 'invert', text: '反选', onSelect: () => { + const visibleIds = filteredDevicesMemo.map(device => device.deviceId); + const newSelected = visibleIds.filter(id => !selectedDevices.includes(id)); + setSelectedDevices(newSelected); + setSelectAll(newSelected.length === filteredDevicesMemo.length); + }}, + { key: 'none', text: '清除选择', onSelect: () => { + setSelectedDevices([]); + setSelectAll(false); + }}, + ], + }} + onRow={(record) => ({ + onClick: () => handleSelectionChange( + selectedDevices.includes(record.deviceId) + ? selectedDevices.filter(id => id !== record.deviceId) + : [...selectedDevices, record.deviceId] + ), + })} + rowClassName={(record, index) => { + if (selectedDevices.includes(record.deviceId)) { + return 'ant-table-row-selected'; + } + return index % 2 === 0 ? 'ant-table-row-even' : 'ant-table-row-odd'; + }} + /> + )} + + + + 批量状态变更 + + } + open={batchStatusModalVisible} + onCancel={() => setBatchStatusModalVisible(false)} + footer={[ + setBatchStatusModalVisible(false)} style={secondaryButtonStyle}> + 取消 + , + + 确定 + + ]} + destroyOnHidden + styles={{ header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, body: { padding: '24px' } }} + > + + + + 运行中 + 维护中 + 离线 + 故障 + + + + 已选择 {selectedDevices.length} 个设备 + + + + + + + 批量移动设备 + + } + open={batchMoveModalVisible} + onCancel={() => setBatchMoveModalVisible(false)} + footer={[ + setBatchMoveModalVisible(false)} style={secondaryButtonStyle}> + 取消 + , + + 确定 + + ]} + destroyOnHidden + styles={{ header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, body: { padding: '24px' } }} + > + + + + {racks.map(rack => ( + + {rack.name} {rack.Room ? `(${rack.Room.name})` : ''} + + ))} + + + + + + + 已选择 {selectedDevices.length} 个设备 + + + + + + + 导出设备数据 + + } + open={exportModalVisible} + onCancel={() => setExportModalVisible(false)} + footer={[ + setExportModalVisible(false)} style={secondaryButtonStyle}> + 取消 + , + + 导出 + + ]} + destroyOnHidden + styles={{ header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }, body: { padding: '24px' } }} + width={600} + > + + + + CSV 格式 + JSON 格式 + + + + + 选择的行 ({selectedDevices.length} 个) + 当前页 ({currentPageDevices.length} 个) + 全部设备 ({allDevices.length} 个) + + + + + {deviceFields.filter(f => f.visible && f.fieldName !== 'rackId').map(field => ( + + { + if (e.target.checked) { + setExportFields([...exportFields, field.fieldName]); + } else { + setExportFields(exportFields.filter(f => f !== field.fieldName)); + } + }} + > + {field.displayName} + + + ))} + + + + 已选择 {selectedDevices.length} 个设备, + 将导出 {exportFields.length} 个字段 + + + ); } diff --git a/frontend/src/pages/SystemSettings.jsx b/frontend/src/pages/SystemSettings.jsx new file mode 100644 index 0000000..67dc141 --- /dev/null +++ b/frontend/src/pages/SystemSettings.jsx @@ -0,0 +1,489 @@ +import React, { useState, useEffect } from 'react'; +import { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Table, Tag, Progress, Divider, Descriptions, Alert } from 'antd'; +import { SettingOutlined, GlobalOutlined, BgColorsOutlined, DatabaseOutlined, InfoCircleOutlined, CloudUploadOutlined, DeleteOutlined, ReloadOutlined, DownloadOutlined, SyncOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import axios from 'axios'; + +const { Option } = Select; +const { TabPane } = Tabs; + +const SystemSettings = () => { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [settings, setSettings] = useState({}); + const [activeTab, setActiveTab] = useState('general'); + const [backupList, setBackupList] = useState([]); + const [backupLoading, setBackupLoading] = useState(false); + const [systemInfo, setSystemInfo] = useState(null); + const [form] = Form.useForm(); + + useEffect(() => { + fetchSettings(); + fetchBackupList(); + fetchSystemInfo(); + }, []); + + const fetchSettings = async () => { + setLoading(true); + try { + const response = await axios.get('/api/system-settings'); + setSettings(response.data); + + const formValues = {}; + Object.entries(response.data).forEach(([key, data]) => { + formValues[key] = data.value; + }); + form.setFieldsValue(formValues); + } catch (error) { + message.error('获取设置失败'); + } finally { + setLoading(false); + } + }; + + const fetchBackupList = async () => { + setBackupLoading(true); + try { + const response = await axios.get('/api/system-settings/backup/list'); + setBackupList(response.data.backups || []); + } catch (error) { + console.error('获取备份列表失败'); + } finally { + setBackupLoading(false); + } + }; + + const fetchSystemInfo = async () => { + try { + const response = await axios.get('/api/system-settings/system/info'); + setSystemInfo(response.data); + } catch (error) { + console.error('获取系统信息失败'); + } + }; + + const handleSaveSettings = async (values) => { + setSaving(true); + try { + const updates = {}; + Object.entries(values).forEach(([key, value]) => { + if (settings[key] && settings[key].isEditable) { + updates[key] = value; + } + }); + + await axios.put('/api/system-settings', { settings: updates }); + message.success('设置保存成功'); + fetchSettings(); + } catch (error) { + message.error('保存设置失败'); + } finally { + setSaving(false); + } + }; + + const handleCreateBackup = async () => { + Modal.confirm({ + title: '确认创建备份', + icon: , + content: '确定要创建系统备份吗?这将导出所有设备、机柜、机房和耗材数据。', + onOk: async () => { + try { + message.loading('正在创建备份...', 0); + const response = await axios.post('/api/system-settings/backup'); + message.destroy(); + message.success('备份创建成功'); + fetchBackupList(); + } catch (error) { + message.destroy(); + message.error('备份创建失败'); + } + } + }); + }; + + const handleRestoreBackup = (filename) => { + Modal.confirm({ + title: '确认恢复备份', + icon: , + content: `确定要恢复备份 "${filename}" 吗?当前数据将被覆盖,且此操作不可撤销。`, + onOk: async () => { + try { + message.loading('正在恢复备份...', 0); + await axios.post('/api/system-settings/backup/restore', { filename }); + message.destroy(); + message.success('恢复成功,请刷新页面查看最新数据'); + } catch (error) { + message.destroy(); + message.error('恢复备份失败'); + } + } + }); + }; + + const handleDeleteBackup = (filename) => { + Modal.confirm({ + title: '确认删除备份', + icon: , + content: `确定要删除备份 "${filename}" 吗?`, + onOk: async () => { + try { + await axios.delete(`/api/system-settings/backup/${filename}`); + message.success('删除成功'); + fetchBackupList(); + } catch (error) { + message.error('删除失败'); + } + } + }); + }; + + const handleDownloadBackup = (filename) => { + window.open(`/api/system-settings/backup/download/${filename}`, '_blank'); + }; + + const handleResetSetting = (key) => { + Modal.confirm({ + title: '确认重置', + icon: , + content: `确定要将 "${settings[key]?.description || key}" 重置为默认值吗?`, + onOk: async () => { + try { + await axios.post(`/api/system-settings/reset/${key}`); + message.success('重置成功'); + fetchSettings(); + } catch (error) { + message.error('重置失败'); + } + } + }); + }; + + const renderFormItem = (key, data) => { + if (!data.isEditable) { + return ( + + } /> + + ); + } + + switch (data.type) { + case 'boolean': + return ( + + + + ); + case 'number': + return ( + + + + ); + case 'select': + const options = getSelectOptions(key); + return ( + + + {options.map(opt => ( + {opt.label} + ))} + + + ); + default: + return ( + + + + ); + } + }; + + const getSelectOptions = (key) => { + const optionsMap = { + timezone: [ + { value: 'Asia/Shanghai', label: '亚洲/上海 (UTC+8)' }, + { value: 'Asia/Beijing', label: '亚洲/北京 (UTC+8)' }, + { value: 'America/New_York', label: '美洲/纽约 (UTC-5)' }, + { value: 'Europe/London', label: '欧洲/伦敦 (UTC+0)' }, + { value: 'UTC', label: 'UTC (UTC+0)' } + ], + date_format: [ + { value: 'YYYY-MM-DD', label: '2024-01-01' }, + { value: 'YYYY/MM/DD', label: '2024/01/01' }, + { value: 'DD/MM/YYYY', label: '01/01/2024' }, + { value: 'MM/DD/YYYY', label: '01/01/2024' } + ], + language: [ + { value: 'zh-CN', label: '简体中文' }, + { value: 'zh-TW', label: '繁體中文' }, + { value: 'en-US', label: 'English' } + ], + table_row_height: [ + { value: 'small', label: '紧凑 (Small)' }, + { value: 'default', label: '默认 (Default)' }, + { value: 'middle', label: '中等 (Middle)' }, + { value: 'large', label: '宽松 (Large)' } + ], + dark_mode: [ + { value: 'false', label: '关闭' }, + { value: 'true', label: '开启' } + ], + compact_mode: [ + { value: 'false', label: '关闭' }, + { value: 'true', label: '开启' } + ], + animation_enabled: [ + { value: 'false', label: '关闭' }, + { value: 'true', label: '开启' } + ], + sidebar_collapsed: [ + { value: 'false', label: '展开' }, + { value: 'true', label: '折叠' } + ], + auto_backup_enabled: [ + { value: 'false', label: '关闭' }, + { value: 'true', label: '开启' } + ] + }; + return optionsMap[key] || []; + }; + + const renderGeneralSettings = () => { + const generalKeys = ['site_name', 'site_logo', 'timezone', 'date_format', 'language', 'session_timeout', 'max_login_attempts', 'maintenance_mode']; + return ( + + + {generalKeys.map(key => settings[key] && renderFormItem(key, settings[key]))} + + + 保存设置 + fetchSettings()}>重置表单 + + + + + ); + }; + + const renderAppearanceSettings = () => { + const appearanceKeys = ['primary_color', 'secondary_color', 'dark_mode', 'compact_mode', 'sidebar_collapsed', 'table_row_height', 'animation_enabled']; + return ( + + + + {appearanceKeys.map(key => settings[key] && renderFormItem(key, settings[key]))} + + + 保存设置 + fetchSettings()}>重置表单 + + + + + ); + }; + + const renderBackupSettings = () => { + const backupInfo = settings.backup_retention ? { + auto_backup_enabled: settings.auto_backup_enabled, + backup_interval: settings.backup_interval, + backup_retention: settings.backup_retention, + last_backup_time: settings.last_backup_time, + backup_count: settings.backup_count + } : null; + + const backupColumns = [ + { + title: '文件名', + dataIndex: 'filename', + key: 'filename', + render: (text) => {text} + }, + { + title: '大小', + dataIndex: 'size', + key: 'size', + render: (size) => { + const kb = size / 1024; + return kb < 1024 ? `${kb.toFixed(2)} KB` : `${(kb / 1024).toFixed(2)} MB`; + } + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date) => new Date(date).toLocaleString('zh-CN') + }, + { + title: '操作', + key: 'action', + render: (_, record) => ( + + } onClick={() => handleRestoreBackup(record.filename)}>恢复 + } onClick={() => handleDownloadBackup(record.filename)}>下载 + } onClick={() => handleDeleteBackup(record.filename)}>删除 + + ) + } + ]; + + return ( + + + + {backupInfo && Object.entries(backupInfo).map(([key, data]) => ( + data && typeof data === 'object' ? renderFormItem(key, data) : null + ))} + + + 保存设置 + + + + + + + + + } onClick={handleCreateBackup}>立即备份 + } onClick={fetchBackupList}>刷新列表 + + + + + ); + }; + + const renderAboutPage = () => { + const aboutKeys = ['app_version', 'company_name', 'contact_email', 'contact_phone', 'company_address', 'system_description', 'privacy_policy', 'terms_of_service']; + + return ( + + + + 机柜管理系统 + {settings.app_version?.value || '1.0.0'} + + 运行正常 + + + + + + + {aboutKeys.slice(1).map(key => settings[key] && renderFormItem(key, settings[key]))} + + + form.submit()}>保存信息 + fetchSettings()}>重置 + + + + + + {systemInfo && ( + + + {systemInfo.statistics?.devices || 0} + {systemInfo.statistics?.racks || 0} + {systemInfo.statistics?.rooms || 0} + {systemInfo.statistics?.users || 0} + + + + {systemInfo.system?.nodeVersion} + {systemInfo.system?.platform} ({systemInfo.system?.arch}) + {systemInfo.system?.pid} + + {systemInfo.system?.uptime ? `${Math.floor(systemInfo.system.uptime / 3600)}小时${Math.floor((systemInfo.system.uptime % 3600) / 60)}分钟` : '-'} + + + {systemInfo.system?.memoryUsage ? `${(systemInfo.system.memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB` : '-'} + + + {systemInfo.timestamp ? new Date(systemInfo.timestamp).toLocaleString('zh-CN') : '-'} + + + + )} + + ); + }; + + return ( + + + 全局配置} + key="general" + > + {renderGeneralSettings()} + + 外观设置} + key="appearance" + > + {renderAppearanceSettings()} + + 数据备份} + key="backup" + > + {renderBackupSettings()} + + 关于} + key="about" + > + {renderAboutPage()} + + + + ); +}; + +import { LockOutlined } from '@ant-design/icons'; + +export default SystemSettings; diff --git a/modify.js b/modify.js new file mode 100644 index 0000000..618b07c --- /dev/null +++ b/modify.js @@ -0,0 +1,19 @@ +const fs = require('fs'); + +const content = fs.readFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', 'utf8'); + +const startMarker = '];\n\n// 可调整列宽的表头组件'; +const endMarker = '\n\n// 防抖 Hook'; + +const startIndex = content.indexOf(startMarker); +const endIndex = content.indexOf(endMarker, startIndex); + +if (startIndex >= 0 && endIndex >= 0) { + const newContent = content.substring(0, startIndex + 3) + '\n\n// 防抖 Hook' + content.substring(endIndex + endMarker.length); + fs.writeFileSync('e:/IDC/jigui/frontend/src/pages/DeviceManagement.jsx', newContent, 'utf8'); + console.log('Removed ResizeableTitle component'); +} else { + console.log('Could not find markers'); + console.log('startMarker found:', startIndex >= 0); + console.log('endMarker found:', endIndex >= 0); +}
暂无设备数据
{text}