diff --git a/backend/initDeviceFields.js b/backend/initDeviceFields.js index 3aed9a2..f325298 100644 --- a/backend/initDeviceFields.js +++ b/backend/initDeviceFields.js @@ -87,7 +87,7 @@ const defaultDeviceFields = [ fieldName: 'powerConsumption', displayName: '功率(W)', fieldType: 'number', - required: false, + required: true, order: 9, visible: true, isSystem: true diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 6f5c28b..010e081 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -1,45 +1,84 @@ -/** - * 请求参数验证中间件 - * 使用Joi进行参数校验 - */ - const validate = (schema, source = 'body') => { - return (req, res, next) => { + return async (req, res, next) => { const data = source === 'query' ? req.query : req.body; - const { error, value } = schema.validate(data, { - abortEarly: false, // 返回所有错误 - stripUnknown: true, // 移除未定义的字段 - allowUnknown: source === 'query' // 查询参数允许未知字段 - }); + try { + let result; + + if (schema.validate && typeof schema.validate === 'function') { + if (schema.validate.constructor.name === 'AsyncFunction' || + schema.validate.length === 1) { + result = await schema.validate(data, { + abortEarly: false, + stripUnknown: true, + allowUnknown: source === 'query' + }); + } else { + result = schema.validate(data, { + abortEarly: false, + stripUnknown: true, + allowUnknown: source === 'query' + }); + } + } else if (schema.validateAsync) { + result = await schema.validateAsync(data, { + abortEarly: false, + stripUnknown: true, + allowUnknown: source === 'query' + }); + } else { + result = schema.validate(data, { + abortEarly: false, + stripUnknown: true, + allowUnknown: source === 'query' + }); + } - if (error) { - const errorMessages = error.details.map(detail => ({ - field: detail.path.join('.'), - message: detail.message - })); + const { error, value } = result; - return res.status(400).json({ - error: '参数验证失败', - details: errorMessages + if (error) { + const errorMessages = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + return res.status(400).json({ + error: '参数验证失败', + details: errorMessages + }); + } + + if (source === 'query') { + req.query = value; + } else { + req.body = value; + } + + next(); + } catch (error) { + if (error.details) { + const errorMessages = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + return res.status(400).json({ + error: '参数验证失败', + details: errorMessages + }); + } + + console.error('验证中间件错误:', error); + return res.status(500).json({ + error: '验证过程发生错误', + message: error.message }); } - - // 将验证后的值替换到请求对象 - if (source === 'query') { - req.query = value; - } else { - req.body = value; - } - - next(); }; }; -// 验证查询参数 const validateQuery = (schema) => validate(schema, 'query'); -// 验证请求体 const validateBody = (schema) => validate(schema, 'body'); module.exports = { diff --git a/backend/models/Device.js b/backend/models/Device.js index 516ad33..0ce0bb1 100644 --- a/backend/models/Device.js +++ b/backend/models/Device.js @@ -11,24 +11,24 @@ const Device = sequelize.define('Device', { }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: true }, type: { type: DataTypes.STRING, - allowNull: false + allowNull: true }, model: { type: DataTypes.STRING, - allowNull: false + allowNull: true }, serialNumber: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, unique: true }, rackId: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, references: { model: Rack, key: 'rackId' @@ -36,20 +36,21 @@ const Device = sequelize.define('Device', { }, position: { type: DataTypes.INTEGER, - allowNull: false // 设备在机柜中的位置(U数) + allowNull: true }, height: { type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 1 // 设备高度(U数) + allowNull: true, + defaultValue: 1 }, powerConsumption: { type: DataTypes.FLOAT, - allowNull: false + allowNull: true, + defaultValue: 0 }, status: { type: DataTypes.STRING, - defaultValue: 'running' + defaultValue: 'offline' }, purchaseDate: { type: DataTypes.DATE, @@ -60,15 +61,17 @@ const Device = sequelize.define('Device', { allowNull: true }, ipAddress: { - type: DataTypes.STRING + type: DataTypes.STRING, + allowNull: true }, description: { - type: DataTypes.TEXT + type: DataTypes.TEXT, + allowNull: true }, customFields: { type: DataTypes.JSON, defaultValue: {}, - allowNull: false + allowNull: true } }, { tableName: 'devices', @@ -83,8 +86,7 @@ const Device = sequelize.define('Device', { ] }); -// 关联关系 Device.belongsTo(Rack, { foreignKey: 'rackId' }); Rack.hasMany(Device, { foreignKey: 'rackId' }); -module.exports = Device; \ No newline at end of file +module.exports = Device; diff --git a/backend/routes/deviceFields.js b/backend/routes/deviceFields.js index 811f965..8a5f7f5 100644 --- a/backend/routes/deviceFields.js +++ b/backend/routes/deviceFields.js @@ -88,33 +88,29 @@ router.post('/config', async (req, res) => { try { const fieldConfigs = req.body; - // 验证输入 if (!Array.isArray(fieldConfigs)) { return res.status(400).json({ error: '输入必须是数组' }); } - // 批量更新字段配置 const updatedFields = []; for (const config of fieldConfigs) { - // 检查字段是否存在 const existingField = await DeviceField.findOne({ where: { fieldName: config.fieldName } }); if (existingField) { - // 更新现有字段 await existingField.update({ - visible: config.visible, - displayName: config.displayName // 同时更新显示名称,以防变化 + visible: config.visible !== undefined ? config.visible : existingField.visible, + required: config.required !== undefined ? config.required : existingField.required, + displayName: config.displayName || existingField.displayName, }); updatedFields.push(existingField); } else { - // 创建新字段 const newField = await DeviceField.create({ - fieldName: config.fieldName, - visible: config.visible, - displayName: config.displayName, - fieldType: config.fieldType || 'text', - required: false, // 默认为非必填 - order: 0 // 默认顺序 + fieldName: config.fieldName, + visible: config.visible !== undefined ? config.visible : true, + required: config.required !== undefined ? config.required : false, + displayName: config.displayName || config.fieldName, + fieldType: config.fieldType || 'text', + order: config.order || 0, }); updatedFields.push(newField); } diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js index 2230333..f8957b5 100644 --- a/backend/routes/systemSettings.js +++ b/backend/routes/systemSettings.js @@ -14,8 +14,8 @@ const initDefaultSettings = async () => { { 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: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '登录有效期(分钟)', isEditable: true }, - { settingKey: 'idle_timeout', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '用户空闲超时时间(分钟)', isEditable: true }, - { settingKey: 'idle_warning_time', settingValue: JSON.stringify(10), settingType: 'number', category: 'general', description: '空闲超时前警告时间(秒)', isEditable: false }, + { settingKey: 'idle_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '用户空闲超时时间(分钟)', isEditable: true }, + { settingKey: 'idle_warning_time', settingValue: JSON.stringify(60), settingType: 'number', category: 'general', description: '空闲超时前警告时间(秒)', isEditable: false }, { 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 }, @@ -106,8 +106,8 @@ router.get('/idle-timeout', async (req, res) => { const timeoutSetting = await SystemSetting.findByPk('idle_timeout'); // 默认配置 - const defaultTimeout = 10; // 10分钟 - const fixedWarningTime = 10; // 固定10秒警告时间 + const defaultTimeout = 30; // 30分钟 + const fixedWarningTime = 60; // 固定60秒警告时间 const timeout = timeoutSetting ? JSON.parse(timeoutSetting.settingValue) : defaultTimeout; @@ -263,8 +263,8 @@ router.post('/reset/:key', async (req, res) => { timezone: 'Asia/Shanghai', date_format: 'YYYY-MM-DD', session_timeout: 30, - idle_timeout: 10, - idle_warning_time: 10, + idle_timeout: 30, + idle_warning_time: 60, max_login_attempts: 5, maintenance_mode: false, frontend_port: 3000, diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 0f302a4..795e8ee 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -64,6 +64,21 @@ const migrations = [ name: '耗材SN序列号字段', description: '为 consumables、consumable_records、consumable_logs 添加 snList 字段', migrate: migrateSnList + }, + { + name: '设备型号字段可空', + description: '将 devices 表 model 字段改为可空,支持非必填', + migrate: migrateDeviceModelField + }, + { + name: '设备字段配置同步', + description: '同步前后端字段必填配置', + migrate: migrateDeviceFieldsConfig + }, + { + name: '设备表字段可空', + description: '将设备表所有字段改为可空,由应用层验证控制', + migrate: migrateDeviceFieldsNullable } ]; @@ -392,6 +407,149 @@ async function migrateSnList() { } } +async function migrateDeviceModelField() { + if (!(await tableExists('devices'))) { + console.log(' devices 表不存在,跳过'); + return; + } + + const dialect = sequelize.getDialect(); + + if (dialect === 'mysql') { + await sequelize.query( + 'ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL' + ); + console.log(' devices 表 model 字段已改为可空'); + } else if (dialect === 'sqlite') { + const columns = await getTableColumns('devices'); + if (columns.includes('model_old')) { + console.log(' model_old 字段已存在,跳过迁移'); + return; + } + + await sequelize.query('ALTER TABLE devices RENAME COLUMN model TO model_old'); + await sequelize.query('ALTER TABLE devices ADD COLUMN model VARCHAR(255)'); + await sequelize.query('UPDATE devices SET model = model_old'); + await sequelize.query('ALTER TABLE devices DROP COLUMN model_old'); + console.log(' devices 表 model 字段已改为可空'); + } +} + +async function migrateDeviceFieldsConfig() { + const DeviceField = require('../models/DeviceField'); + + const updates = [ + { fieldName: 'model', required: false }, + { fieldName: 'powerConsumption', required: true }, + { fieldName: 'purchaseDate', required: false }, + { fieldName: 'warrantyExpiry', required: false }, + ]; + + for (const update of updates) { + const field = await DeviceField.findOne({ where: { fieldName: update.fieldName } }); + if (field && field.required !== update.required) { + await field.update(update); + console.log(` 更新字段 ${update.fieldName}: required=${update.required}`); + } else if (!field) { + console.log(` 字段 ${update.fieldName} 不存在,跳过`); + } else { + console.log(` 字段 ${update.fieldName} 配置已正确,跳过`); + } + } +} + +async function migrateDeviceFieldsNullable() { + const dialect = sequelize.getDialect(); + + if (dialect === 'mysql') { + const alterCommands = [ + "ALTER TABLE devices MODIFY COLUMN name VARCHAR(255) NULL", + "ALTER TABLE devices MODIFY COLUMN type VARCHAR(255) NULL", + "ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL", + "ALTER TABLE devices MODIFY COLUMN serialNumber VARCHAR(255) NULL", + "ALTER TABLE devices MODIFY COLUMN rackId VARCHAR(255) NULL", + "ALTER TABLE devices MODIFY COLUMN position INTEGER NULL", + "ALTER TABLE devices MODIFY COLUMN height INTEGER NULL", + "ALTER TABLE devices MODIFY COLUMN powerConsumption FLOAT NULL", + "ALTER TABLE devices MODIFY COLUMN customFields JSON NULL" + ]; + + for (const sql of alterCommands) { + try { + await sequelize.query(sql); + } catch (e) { + if (!e.message.includes('Unknown column')) { + console.log(` 警告: ${e.message}`); + } + } + } + console.log(' devices 表字段已改为可空'); + + } else if (dialect === 'sqlite') { + const columns = await getTableColumns('devices'); + const hasNullableFlag = columns.includes('_nullable_migration_done'); + + if (hasNullableFlag) { + console.log(' 已完成可空迁移,跳过'); + return; + } + + await sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + await sequelize.query('DROP TABLE IF EXISTS devices_new'); + + await sequelize.query(` + CREATE TABLE devices_new ( + deviceId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE, + name VARCHAR(255), + type VARCHAR(255), + model VARCHAR(255), + serialNumber VARCHAR(255) UNIQUE, + rackId VARCHAR(255), + position INTEGER, + height INTEGER DEFAULT 1, + powerConsumption FLOAT DEFAULT 0, + status VARCHAR(255) DEFAULT 'offline', + purchaseDate DATETIME, + warrantyExpiry DATETIME, + ipAddress VARCHAR(255), + description TEXT, + customFields JSON DEFAULT '{}', + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + _nullable_migration_done INTEGER DEFAULT 1 + ) + `); + + await sequelize.query(` + INSERT INTO devices_new ( + deviceId, name, type, model, serialNumber, rackId, position, height, + powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress, + description, customFields, createdAt, updatedAt + ) + SELECT + deviceId, name, type, model, serialNumber, rackId, position, height, + powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress, + description, customFields, createdAt, updatedAt + FROM devices + `); + + await sequelize.query('DROP TABLE devices'); + await sequelize.query('ALTER TABLE devices_new RENAME TO devices'); + + await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status)'); + await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(type)'); + await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_rackId ON devices(rackId)'); + await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name)'); + + console.log(' devices 表字段已改为可空'); + } finally { + await sequelize.query('PRAGMA foreign_keys = ON'); + } + } +} + // 执行迁移 runMigrations().catch(error => { console.error('迁移执行失败:', error); diff --git a/backend/validation/deviceSchema.js b/backend/validation/deviceSchema.js index 4f3ad30..594bc83 100644 --- a/backend/validation/deviceSchema.js +++ b/backend/validation/deviceSchema.js @@ -1,13 +1,10 @@ const Joi = require('joi'); +const DeviceField = require('../models/DeviceField'); -// 设备类型枚举 const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other']; - -// 设备状态枚举 const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault']; -// 创建设备验证Schema -const createDeviceSchema = Joi.object({ +const baseFieldSchemas = { deviceId: Joi.string() .max(50) .pattern(/^[a-zA-Z0-9_-]+$/) @@ -18,21 +15,17 @@ const createDeviceSchema = Joi.object({ }), name: Joi.string() - .required() .max(100) .messages({ 'string.empty': '设备名称不能为空', - 'string.max': '设备名称不能超过100个字符', - 'any.required': '设备名称是必填字段' + 'string.max': '设备名称不能超过100个字符' }), type: Joi.string() .valid(...DEVICE_TYPES) - .required() .messages({ 'string.empty': '设备类型不能为空', - 'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`, - 'any.required': '设备类型是必填字段' + 'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}` }), model: Joi.string() @@ -44,138 +37,18 @@ const createDeviceSchema = Joi.object({ serialNumber: Joi.string() .max(100) - .allow('', null) .messages({ + 'string.empty': '序列号不能为空', 'string.max': '序列号不能超过100个字符' }), rackId: Joi.string() - .required() .max(50) .messages({ 'string.empty': '机柜ID不能为空', - 'string.max': '机柜ID不能超过50个字符', - 'any.required': '机柜ID是必填字段' + 'string.max': '机柜ID不能超过50个字符' }), - position: Joi.number() - .integer() - .min(1) - .max(100) - .required() - .messages({ - 'number.base': '位置必须是数字', - 'number.integer': '位置必须是整数', - 'number.min': '位置不能小于1', - 'number.max': '位置不能大于100', - 'any.required': '位置是必填字段' - }), - - height: Joi.number() - .integer() - .min(1) - .max(50) - .required() - .messages({ - 'number.base': '高度必须是数字', - 'number.integer': '高度必须是整数', - 'number.min': '高度不能小于1', - 'number.max': '高度不能大于50', - 'any.required': '高度是必填字段' - }), - - powerConsumption: Joi.number() - .min(0) - .max(100000) - .default(0) - .messages({ - 'number.base': '功率必须是数字', - 'number.min': '功率不能小于0', - 'number.max': '功率不能超过100000' - }), - - ipAddress: Joi.string() - .ip({ version: ['ipv4', 'ipv6'] }) - .allow('', null) - .messages({ - 'string.ip': 'IP地址格式无效' - }), - - status: Joi.string() - .valid(...DEVICE_STATUS) - .default('offline') - .messages({ - 'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}` - }), - - purchaseDate: Joi.date() - .allow(null) - .messages({ - 'date.base': '购买日期格式无效' - }), - - warrantyExpiry: Joi.date() - .allow(null) - .messages({ - 'date.base': '保修到期日期格式无效' - }), - - description: Joi.string() - .max(500) - .allow('', null) - .messages({ - 'string.max': '描述不能超过500个字符' - }), - - customFields: Joi.object() - .allow(null) -}).custom((value, helpers) => { - // 验证保修日期必须晚于购买日期 - if (value.purchaseDate && value.warrantyExpiry) { - const purchase = new Date(value.purchaseDate); - const warranty = new Date(value.warrantyExpiry); - if (warranty <= purchase) { - return helpers.error('date.warrantyAfterPurchase'); - } - } - return value; -}).messages({ - 'date.warrantyAfterPurchase': '保修到期日期必须晚于购买日期' -}); - -// 更新设备验证Schema(所有字段可选) -const updateDeviceSchema = Joi.object({ - deviceId: Joi.string() - .max(50) - .pattern(/^[a-zA-Z0-9_-]+$/) - .messages({ - 'string.max': '设备ID不能超过50个字符', - 'string.pattern.base': '设备ID只能包含字母、数字、下划线和横线' - }), - - name: Joi.string() - .max(100) - .messages({ - 'string.max': '设备名称不能超过100个字符' - }), - - type: Joi.string() - .valid(...DEVICE_TYPES) - .messages({ - 'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}` - }), - - model: Joi.string() - .max(100) - .allow('', null), - - serialNumber: Joi.string() - .max(100) - .allow('', null), - - rackId: Joi.string() - .max(50), - position: Joi.number() .integer() .min(1) @@ -216,27 +89,81 @@ const updateDeviceSchema = Joi.object({ status: Joi.string() .valid(...DEVICE_STATUS) + .default('offline') .messages({ 'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}` }), purchaseDate: Joi.date() - .allow(null), + .allow(null) + .messages({ + 'date.base': '购买日期格式无效' + }), warrantyExpiry: Joi.date() - .allow(null), + .allow(null) + .messages({ + 'date.base': '保修到期日期格式无效' + }), description: Joi.string() .max(500) - .allow('', null), + .allow('', null) + .messages({ + 'string.max': '描述不能超过500个字符' + }), - customFields: Joi.object() - .allow(null) -}).min(1).messages({ - 'object.min': '至少需要提供一个字段进行更新' -}); + customFields: Joi.object().allow(null) +}; + +async function buildDynamicSchema(isCreate = true) { + const fields = await DeviceField.findAll({ + where: { isSystem: true }, + order: [['order', 'ASC']] + }); + + const schemaObj = {}; + + fields.forEach(field => { + const baseSchema = baseFieldSchemas[field.fieldName]; + if (baseSchema) { + let fieldSchema = baseSchema.clone(); + + if (field.required && isCreate) { + fieldSchema = fieldSchema.required(); + } + + schemaObj[field.fieldName] = fieldSchema; + } + }); + + schemaObj.customFields = baseFieldSchemas.customFields; + + return Joi.object(schemaObj).custom((value, helpers) => { + if (value.purchaseDate && value.warrantyExpiry) { + const purchase = new Date(value.purchaseDate); + const warranty = new Date(value.warrantyExpiry); + if (warranty <= purchase) { + return helpers.error('date.warrantyAfterPurchase'); + } + } + return value; + }).messages({ + 'date.warrantyAfterPurchase': '保修到期日期必须晚于购买日期' + }); +} + +async function getCreateDeviceSchema() { + return buildDynamicSchema(true); +} + +async function getUpdateDeviceSchema() { + const schema = await buildDynamicSchema(false); + return schema.min(1).messages({ + 'object.min': '至少需要提供一个字段进行更新' + }); +} -// 批量操作验证Schema const batchDeviceIdsSchema = Joi.object({ deviceIds: Joi.array() .items(Joi.string().required()) @@ -249,7 +176,6 @@ const batchDeviceIdsSchema = Joi.object({ }) }); -// 批量状态变更验证Schema const batchStatusSchema = Joi.object({ deviceIds: Joi.array() .items(Joi.string().required()) @@ -269,7 +195,6 @@ const batchStatusSchema = Joi.object({ }) }); -// 批量移动验证Schema const batchMoveSchema = Joi.object({ deviceIds: Joi.array() .items(Joi.string().required()) @@ -288,7 +213,6 @@ const batchMoveSchema = Joi.object({ .allow(null) }); -// 查询参数验证Schema const queryDeviceSchema = Joi.object({ keyword: Joi.string() .max(100) @@ -313,6 +237,20 @@ const queryDeviceSchema = Joi.object({ .default(10) }); +const createDeviceSchema = { + validate: async (data, options = {}) => { + const schema = await getCreateDeviceSchema(); + return schema.validate(data, options); + } +}; + +const updateDeviceSchema = { + validate: async (data, options = {}) => { + const schema = await getUpdateDeviceSchema(); + return schema.validate(data, options); + } +}; + module.exports = { createDeviceSchema, updateDeviceSchema, @@ -321,5 +259,7 @@ module.exports = { batchMoveSchema, queryDeviceSchema, DEVICE_TYPES, - DEVICE_STATUS + DEVICE_STATUS, + getCreateDeviceSchema, + getUpdateDeviceSchema }; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 95cf13e..e1b612c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -133,8 +133,8 @@ const ProtectedRoute = ({ component: Component }) => ( // 默认空闲超时配置 const DEFAULT_IDLE_CONFIG = { - timeout: 10 * 60 * 1000, // 10分钟 - warningTime: 30 * 1000, // 30秒 + timeout: 30 * 60 * 1000, // 30分钟 + warningTime: 60 * 1000, // 60秒 }; const AppLayout = ({ children }) => { diff --git a/frontend/src/components/3d/RackModel.jsx b/frontend/src/components/3d/RackModel.jsx index b803370..40ad0d2 100644 --- a/frontend/src/components/3d/RackModel.jsx +++ b/frontend/src/components/3d/RackModel.jsx @@ -202,7 +202,7 @@ const RackModel = ({ return ( { try { - const response = await axios.get('/api/racks'); - // 现在API返回的格式是 { racks: [], total: number } + const response = await axios.get('/api/racks', { + params: { pageSize: 1000 } + }); setRacks(response.data.racks || []); } catch (error) { message.error('获取机柜列表失败'); @@ -496,9 +499,21 @@ function DeviceManagement() { } }; + // 获取所有机房 + const fetchRooms = async () => { + try { + const response = await axios.get('/api/rooms'); + setRooms(response.data || []); + } catch (error) { + message.error('获取机房列表失败'); + console.error('获取机房列表失败:', error); + } + }; + useEffect(() => { fetchDevices(1, pagination.pageSize); fetchRacks(); + fetchRooms(); fetchDeviceFields(); }, [fetchDevices]); @@ -576,8 +591,17 @@ function DeviceManagement() { } form.setFieldsValue(cleanDeviceData); + + // 编辑设备时,根据 rackId 找到对应的机房并设置 selectedRoomId + if (device.rackId) { + const rack = racks.find(r => r.rackId === device.rackId); + if (rack) { + setSelectedRoomId(rack.roomId); + } + } } else { form.resetFields(); + setSelectedRoomId(null); } setModalVisible(true); }; @@ -586,12 +610,12 @@ function DeviceManagement() { const handleCancel = () => { setModalVisible(false); setEditingDevice(null); + setSelectedRoomId(null); }; // 提交表单 const handleSubmit = async values => { try { - // 定义设备模型的固定字段 const fixedFields = [ 'deviceId', 'name', @@ -607,25 +631,25 @@ function DeviceManagement() { 'warrantyExpiry', 'ipAddress', 'description', + 'roomId', ]; - // 构建最终的设备数据,包含固定字段和自定义字段 const deviceData = { ...values, purchaseDate: values.purchaseDate ? values.purchaseDate.format('YYYY-MM-DD') : null, warrantyExpiry: values.warrantyExpiry ? values.warrantyExpiry.format('YYYY-MM-DD') : null, - customFields: {}, // 用于存储自定义字段 + customFields: {}, }; - // 分离固定字段和自定义字段 Object.keys(deviceData).forEach(key => { if (!fixedFields.includes(key) && key !== 'customFields') { - // 将非固定字段移动到customFields对象中 deviceData.customFields[key] = deviceData[key]; delete deviceData[key]; } }); + delete deviceData.roomId; + if (editingDevice) { // 更新设备 await axios.put(`/api/devices/${editingDevice.deviceId}`, deviceData); @@ -640,7 +664,8 @@ function DeviceManagement() { fetchDevices(); setEditingDevice(null); } catch (error) { - message.error(editingDevice ? '设备更新失败' : '设备创建失败'); + const errorMsg = error.response?.data?.error || error.message || '未知错误'; + message.error(editingDevice ? `设备更新失败: ${errorMsg}` : `设备创建失败: ${errorMsg}`); console.error(editingDevice ? '设备更新失败:' : '设备创建失败:', error); } }; @@ -1242,17 +1267,17 @@ function DeviceManagement() { // 保存字段配置 const handleSaveFieldConfig = async values => { try { - // 更新设备字段配置的可见性 const updatedFields = deviceFields.map(field => ({ - ...field, - visible: values[field.fieldName], + fieldId: field.fieldId, + fieldName: field.fieldName, + displayName: field.displayName, + visible: values[`visible_${field.fieldName}`] ?? field.visible, + required: values[`required_${field.fieldName}`] ?? field.required, })); - // 保存到后端 - await axios.post('/api/deviceFields/config', updatedFields); + const response = await axios.post('/api/deviceFields/config', updatedFields); - // 更新本地状态 - setDeviceFields(updatedFields); + setDeviceFields(response.data); message.success('字段配置保存成功'); setFieldConfigModalVisible(false); } catch (error) { @@ -1616,10 +1641,18 @@ function DeviceManagement() { className="device-modal" >
- {deviceFields - .filter(field => field.fieldName !== 'deviceId') - .map(field => { + {(() => { + const filteredFields = deviceFields.filter( + field => field.fieldName !== 'deviceId' && field.fieldName !== 'rackId' + ); + const formItems = []; + + filteredFields.forEach((field, index) => { let control = null; + const inputStyle = { + borderRadius: '8px', + transition: 'all 0.3s ease', + }; switch (field.fieldType) { case 'text': @@ -1627,7 +1660,8 @@ function DeviceManagement() { control = ( ); break; @@ -1636,7 +1670,8 @@ function DeviceManagement() { ); break; @@ -1646,8 +1681,9 @@ function DeviceManagement() { case 'date': control = ( ); break; @@ -1656,90 +1692,250 @@ function DeviceManagement() { ); break; case 'select': - if (field.fieldName === 'rackId') { - control = ( - + {field.options && + field.options.map(option => ( + ))} - - ); - } else { - control = ( - - ); - } + + ); break; default: control = ( ); } - return ( - - {control} - - ); - })} + // 机房和机柜联动选择区域特殊处理 + if (field.fieldName === 'serialNumber') { + formItems.push( + + + + {field.displayName} + {field.required && ( + * + )} + + } + rules={ + field.required + ? [{ required: true, message: `请输入${field.displayName}` }] + : [] + } + > + {control} + + + {/* 机房机柜联动选择区域 - 特殊突出显示 */} + +
+
+ + 设备位置选择 +
+ + + + 机房 + * + + } + rules={[{ required: true, message: '请选择机房' }]} + style={{ marginBottom: '0' }} + > + + + + + + 机柜 + * + + } + rules={[{ required: true, message: '请选择机柜' }]} + style={{ marginBottom: '0' }} + > + + + + +
+ +
+ ); + } else if (field.fieldType === 'textarea') { + // textarea 占整行 + formItems.push( + + + {field.displayName} + {field.required && ( + * + )} + + } + rules={ + field.required + ? [{ required: true, message: `请输入${field.displayName}` }] + : [] + } + > + {control} + + + ); + } else { + // 其他字段两列布局 + formItems.push( + + + {field.displayName} + {field.required && ( + * + )} + + } + rules={ + field.required + ? [{ required: true, message: `请输入${field.displayName}` }] + : [] + } + > + {control} + + + ); + } + }); - - - - - - + return {formItems}; + })()} + + {/* 底部按钮区域 */} +
+ + +
@@ -1762,29 +1958,46 @@ function DeviceManagement() {
({ - ...acc, - [field.fieldName]: field.visible, - }), - {} - )} + initialValues={{ + ...deviceFields.reduce( + (acc, field) => ({ + ...acc, + [`visible_${field.fieldName}`]: field.visible, + [`required_${field.fieldName}`]: field.required, + }), + {} + ), + }} >
-
- {deviceFields - .filter(field => field.fieldName !== 'deviceId') - .map(field => ( - - {field.displayName} - - ))} -
+ + + + + + + + + + {deviceFields + .filter(field => field.fieldName !== 'deviceId') + .map(field => ( + + + + + + ))} + +
字段名称可见必填
{field.displayName} + + + + + + + +
diff --git a/frontend/src/pages/Rack3DVisualization.jsx b/frontend/src/pages/Rack3DVisualization.jsx index 3513b63..6398134 100644 --- a/frontend/src/pages/Rack3DVisualization.jsx +++ b/frontend/src/pages/Rack3DVisualization.jsx @@ -241,7 +241,7 @@ const Rack3DVisualization = () => { if (!rackId) return; try { setLoadingDevices(true); - const response = await axios.get(`/api/devices?rackId=${rackId}`); + const response = await axios.get(`/api/devices?rackId=${rackId}&pageSize=100`); let devicesData = []; if (Array.isArray(response.data)) { diff --git a/frontend/src/styles/deviceManagementStyles.js b/frontend/src/styles/deviceManagementStyles.js index ff1aacf..2b2f29c 100644 --- a/frontend/src/styles/deviceManagementStyles.js +++ b/frontend/src/styles/deviceManagementStyles.js @@ -954,6 +954,37 @@ export const generateGlobalStyles = tokens => ` background: inherit !important; } + /* 表单输入框增强样式 */ + .form-input-enhanced:hover { + border-color: ${tokens.colors.primary.main} !important; + box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1) !important; + } + + .form-input-enhanced:focus, + .form-input-enhanced.ant-input-focused, + .form-input-enhanced.ant-select-focused .ant-select-selector, + .form-input-enhanced.ant-input-number-focused { + border-color: ${tokens.colors.primary.main} !important; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) !important; + } + + /* 表单项标签增强 */ + .device-modal .ant-form-item-label > label { + font-weight: 500; + color: ${tokens.colors.text.primary}; + font-size: 14px; + } + + /* 表单项间距优化 */ + .device-modal .ant-form-item { + margin-bottom: 20px; + } + + /* 必填项标识动画 */ + .device-modal .ant-form-item-label > label::after { + content: ''; + } + @media screen and (max-width: 768px) { .device-table-wrapper .ant-table-tbody > tr > td { max-width: 150px !important;