feat: 修改了bug

This commit is contained in:
zhang1106
2026-03-06 13:57:37 +08:00
parent 0bb51ec6ac
commit 0cd0107008
12 changed files with 710 additions and 331 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ const defaultDeviceFields = [
fieldName: 'powerConsumption',
displayName: '功率(W)',
fieldType: 'number',
required: false,
required: true,
order: 9,
visible: true,
isSystem: true
+69 -30
View File
@@ -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 = {
+17 -15
View File
@@ -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;
module.exports = Device;
+9 -13
View File
@@ -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);
}
+6 -6
View File
@@ -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,
+158
View File
@@ -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);
+86 -146
View File
@@ -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
};
+2 -2
View File
@@ -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 }) => {
+2 -2
View File
@@ -202,7 +202,7 @@ const RackModel = ({
return (
<LODManager
key={device.id}
key={device.deviceId || device.id}
device={device}
uHeight={uHeight}
rackDepth={depth}
@@ -216,7 +216,7 @@ const RackModel = ({
uHeight={uHeight}
rackDepth={depth}
position={[0, 0, 0]}
isSelected={selectedDeviceId === device.id}
isSelected={selectedDeviceId === (device.deviceId || device.id)}
onClick={onDeviceClick}
onPointerOver={onDeviceHover}
onPointerOut={onDeviceLeave}
+328 -115
View File
@@ -229,6 +229,8 @@ function DeviceManagement() {
const [devices, setDevices] = useState([]);
const [allDevices, setAllDevices] = useState([]);
const [racks, setRacks] = useState([]);
const [rooms, setRooms] = useState([]);
const [selectedRoomId, setSelectedRoomId] = useState(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
@@ -361,9 +363,9 @@ function DeviceManagement() {
fieldName: 'deviceId',
displayName: '设备ID',
fieldType: 'string',
required: true,
required: false,
order: 1,
visible: true,
visible: false,
},
{
fieldName: 'name',
@@ -392,7 +394,7 @@ function DeviceManagement() {
fieldName: 'model',
displayName: '型号',
fieldType: 'string',
required: true,
required: false,
order: 4,
visible: true,
},
@@ -454,7 +456,7 @@ function DeviceManagement() {
fieldName: 'purchaseDate',
displayName: '购买日期',
fieldType: 'date',
required: true,
required: false,
order: 11,
visible: true,
},
@@ -462,7 +464,7 @@ function DeviceManagement() {
fieldName: 'warrantyExpiry',
displayName: '保修到期',
fieldType: 'date',
required: true,
required: false,
order: 12,
visible: true,
},
@@ -487,8 +489,9 @@ function DeviceManagement() {
// 获取所有机柜
const fetchRacks = async () => {
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"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{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 = (
<Input
placeholder={`请输入${field.displayName}`}
style={{ borderRadius: '8px' }}
style={inputStyle}
className="form-input-enhanced"
/>
);
break;
@@ -1636,7 +1670,8 @@ function DeviceManagement() {
<InputNumber
placeholder={`请输入${field.displayName}`}
min={0}
style={{ width: '100%', borderRadius: '8px' }}
style={{ width: '100%', ...inputStyle }}
className="form-input-enhanced"
/>
);
break;
@@ -1646,8 +1681,9 @@ function DeviceManagement() {
case 'date':
control = (
<DatePicker
style={{ width: '100%', borderRadius: '8px' }}
style={{ width: '100%', ...inputStyle }}
placeholder={`请选择${field.displayName}`}
className="form-input-enhanced"
/>
);
break;
@@ -1656,90 +1692,250 @@ function DeviceManagement() {
<Input.TextArea
placeholder={`请输入${field.displayName}`}
rows={3}
style={{ borderRadius: '8px' }}
style={inputStyle}
className="form-input-enhanced"
/>
);
break;
case 'select':
if (field.fieldName === 'rackId') {
control = (
<Select
placeholder={`请选择${field.displayName}`}
style={{ borderRadius: '8px' }}
>
{racks.map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
control = (
<Select
placeholder={`请选择${field.displayName}`}
style={inputStyle}
className="form-input-enhanced"
>
{field.options &&
field.options.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
);
} else {
control = (
<Select
placeholder={`请选择${field.displayName}`}
style={{ borderRadius: '8px' }}
>
{field.options &&
field.options.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
);
}
</Select>
);
break;
default:
control = (
<Input
placeholder={`请输入${field.displayName}`}
style={{ borderRadius: '8px' }}
style={inputStyle}
className="form-input-enhanced"
/>
);
}
return (
<Form.Item
key={field.fieldName}
name={field.fieldName}
label={field.displayName}
rules={
field.required && field.fieldName !== 'deviceId'
? [{ required: true, message: `请输入${field.displayName}` }]
: []
}
>
{control}
</Form.Item>
);
})}
// 机房和机柜联动选择区域特殊处理
if (field.fieldName === 'serialNumber') {
formItems.push(
<React.Fragment key={field.fieldName}>
<Col span={12} key={`${field.fieldName}-col`}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required
? [{ required: true, message: `请输入${field.displayName}` }]
: []
}
>
{control}
</Form.Item>
</Col>
{/* 机房机柜联动选择区域 - 特殊突出显示 */}
<Col span={24} key="room-rack-section">
<div
style={{
background: 'linear-gradient(135deg, #f0f5ff 0%, #e6f7ff 100%)',
borderRadius: '12px',
padding: '20px',
marginBottom: '16px',
border: '2px solid #d6e4ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.1)',
}}
>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#1890ff',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
}}
>
<DatabaseOutlined style={{ marginRight: '8px' }} />
设备位置选择
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="roomId"
label={
<span>
机房
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机房' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder="请选择机房"
style={{ borderRadius: '8px' }}
showSearch
optionFilterProp="children"
onChange={(value) => {
setSelectedRoomId(value);
form.setFieldValue('rackId', undefined);
}}
>
{rooms.map(room => (
<Option key={room.roomId} value={room.roomId}>
{room.name}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="rackId"
label={
<span>
机柜
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
</span>
}
rules={[{ required: true, message: '请选择机柜' }]}
style={{ marginBottom: '0' }}
>
<Select
placeholder={selectedRoomId ? '请选择机柜' : '请先选择机房'}
style={{ borderRadius: '8px' }}
disabled={!selectedRoomId}
showSearch
optionFilterProp="children"
>
{(selectedRoomId
? racks.filter(rack => rack.roomId === selectedRoomId)
: []
).map(rack => (
<Option key={rack.rackId} value={rack.rackId}>
{rack.name} ({rack.rackId})
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</div>
</Col>
</React.Fragment>
);
} else if (field.fieldType === 'textarea') {
// textarea 占整行
formItems.push(
<Col span={24} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required
? [{ required: true, message: `请输入${field.displayName}` }]
: []
}
>
{control}
</Form.Item>
</Col>
);
} else {
// 其他字段两列布局
formItems.push(
<Col span={12} key={field.fieldName}>
<Form.Item
name={field.fieldName}
label={
<span>
{field.displayName}
{field.required && (
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
)}
</span>
}
rules={
field.required
? [{ required: true, message: `请输入${field.displayName}` }]
: []
}
>
{control}
</Form.Item>
</Col>
);
}
});
<Form.Item style={{ textAlign: 'right', marginTop: '24px' }}>
<Space>
<Button onClick={handleCancel} style={secondaryActionStyle}>
取消
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: designTokens.borderRadius.small,
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
确定
</Button>
</Space>
</Form.Item>
return <Row gutter={16}>{formItems}</Row>;
})()}
{/* 底部按钮区域 */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '32px',
paddingTop: '24px',
borderTop: '1px solid #f0f0f0',
}}
>
<Button
onClick={handleCancel}
style={{
height: '40px',
borderRadius: '8px',
padding: '0 24px',
fontWeight: '500',
transition: 'all 0.3s ease',
}}
>
取消
</Button>
<Button
type="primary"
htmlType="submit"
style={{
height: '40px',
borderRadius: '8px',
background: designTokens.colors.primary.gradient,
border: 'none',
color: '#ffffff',
boxShadow: designTokens.shadows.small,
fontWeight: '500',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 32px',
transition: 'all 0.3s ease',
}}
>
确定
</Button>
</div>
</Form>
</Modal>
@@ -1762,29 +1958,46 @@ function DeviceManagement() {
<Form
layout="vertical"
onFinish={handleSaveFieldConfig}
initialValues={deviceFields.reduce(
(acc, field) => ({
...acc,
[field.fieldName]: field.visible,
}),
{}
)}
initialValues={{
...deviceFields.reduce(
(acc, field) => ({
...acc,
[`visible_${field.fieldName}`]: field.visible,
[`required_${field.fieldName}`]: field.required,
}),
{}
),
}}
>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
{deviceFields
.filter(field => field.fieldName !== 'deviceId')
.map(field => (
<Form.Item
key={field.fieldName}
name={field.fieldName}
valuePropName="checked"
noStyle
>
<Checkbox style={{ marginBottom: '8px' }}>{field.displayName}</Checkbox>
</Form.Item>
))}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<th style={{ padding: '8px', textAlign: 'left', width: '40%' }}>字段名称</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>可见</th>
<th style={{ padding: '8px', textAlign: 'center', width: '30%' }}>必填</th>
</tr>
</thead>
<tbody>
{deviceFields
.filter(field => field.fieldName !== 'deviceId')
.map(field => (
<tr key={field.fieldName} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px' }}>{field.displayName}</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`visible_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
<Form.Item name={`required_${field.fieldName}`} valuePropName="checked" noStyle>
<Switch size="small" />
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form.Item style={{ textAlign: 'right', marginTop: '20px' }}>
+1 -1
View File
@@ -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)) {
@@ -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;