feat: 添加请求参数验证中间件和设计令牌Hook
feat(validation): 为机房、机柜和设备路由添加Joi验证中间件 feat(hooks): 创建useDesignTokens Hook集中管理主题配置 feat(models): 为耗材模型添加乐观锁version字段和updatedAt索引 feat(3d): 增强3D场景和设备模型视觉效果与交互 refactor: 移除数据备份相关功能并优化代码结构 fix: 修复前端安全日志和密码加密工具 chore: 更新依赖并添加axios和joi库 docs: 更新注释和文档说明 style: 改进代码格式和命名一致性
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 请求参数验证中间件
|
||||||
|
* 使用Joi进行参数校验
|
||||||
|
*/
|
||||||
|
|
||||||
|
const validate = (schema, source = 'body') => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const data = source === 'query' ? req.query : req.body;
|
||||||
|
|
||||||
|
const { error, value } = 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
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
error: '参数验证失败',
|
||||||
|
details: errorMessages
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将验证后的值替换到请求对象
|
||||||
|
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 = {
|
||||||
|
validate,
|
||||||
|
validateQuery,
|
||||||
|
validateBody
|
||||||
|
};
|
||||||
@@ -54,6 +54,12 @@ const Consumable = sequelize.define('Consumable', {
|
|||||||
status: {
|
status: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
defaultValue: 'active'
|
defaultValue: 'active'
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '乐观锁版本号'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'consumables',
|
tableName: 'consumables',
|
||||||
@@ -61,7 +67,8 @@ const Consumable = sequelize.define('Consumable', {
|
|||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ['category'] },
|
{ fields: ['category'] },
|
||||||
{ fields: ['status'] },
|
{ fields: ['status'] },
|
||||||
{ fields: ['category', 'status'] }
|
{ fields: ['category', 'status'] },
|
||||||
|
{ fields: ['updatedAt'] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+111
-6
@@ -8,6 +8,7 @@
|
|||||||
"name": "idc-backend",
|
"name": "idc-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.13.4",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
|
"joi": "^18.0.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"sequelize": "^6.32.1",
|
"sequelize": "^6.32.1",
|
||||||
@@ -677,6 +679,54 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@hapi/address": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^11.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/formula": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/hoek": {
|
||||||
|
"version": "11.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||||
|
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/pinpoint": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/tlds": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@hapi/topo": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/hoek": "^11.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1379,6 +1429,12 @@
|
|||||||
"text-hex": "1.0.x"
|
"text-hex": "1.0.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||||
@@ -2013,7 +2069,6 @@
|
|||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/aws-ssl-profiles": {
|
"node_modules/aws-ssl-profiles": {
|
||||||
@@ -2025,6 +2080,17 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "30.2.0",
|
"version": "30.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
||||||
@@ -2726,7 +2792,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
@@ -2935,7 +3000,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
@@ -3182,7 +3246,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -3458,6 +3521,26 @@
|
|||||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -3492,7 +3575,6 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
@@ -3795,7 +3877,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -5067,6 +5148,24 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joi": {
|
||||||
|
"version": "18.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz",
|
||||||
|
"integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/address": "^5.1.1",
|
||||||
|
"@hapi/formula": "^3.0.2",
|
||||||
|
"@hapi/hoek": "^11.0.7",
|
||||||
|
"@hapi/pinpoint": "^2.0.1",
|
||||||
|
"@hapi/tlds": "^1.1.1",
|
||||||
|
"@hapi/topo": "^6.0.2",
|
||||||
|
"@standard-schema/spec": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -6281,6 +6380,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pstree.remy": {
|
"node_modules/pstree.remy": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"drop-indexes": "node create_indexes.js drop"
|
"drop-indexes": "node create_indexes.js drop"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.13.4",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
|
"joi": "^18.0.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"sequelize": "^6.32.1",
|
"sequelize": "^6.32.1",
|
||||||
|
|||||||
+256
-162
@@ -250,187 +250,281 @@ router.get('/inout/records', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/quick-inout', async (req, res) => {
|
router.post('/quick-inout', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const MAX_RETRIES = 3;
|
||||||
try {
|
let attempt = 0;
|
||||||
const { consumableId, type, quantity, operator, reason, notes } = req.body;
|
|
||||||
|
while (attempt < MAX_RETRIES) {
|
||||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
const transaction = await sequelize.transaction();
|
||||||
if (!consumable) {
|
try {
|
||||||
await transaction.rollback();
|
const { consumableId, type, quantity, operator, reason, notes } = req.body;
|
||||||
return res.status(404).json({ error: '耗材不存在' });
|
|
||||||
}
|
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
|
if (!consumable) {
|
||||||
const previousStock = parseFloat(consumable.currentStock);
|
|
||||||
let newStock;
|
|
||||||
|
|
||||||
if (type === 'in') {
|
|
||||||
newStock = previousStock + parseFloat(quantity);
|
|
||||||
} else if (type === 'out') {
|
|
||||||
newStock = previousStock - parseFloat(quantity);
|
|
||||||
if (newStock < 0) {
|
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '库存不足' });
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
const previousStock = parseFloat(consumable.currentStock);
|
||||||
|
let newStock;
|
||||||
|
|
||||||
|
if (type === 'in') {
|
||||||
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
} else if (type === 'out') {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不足' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '操作类型无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [affectedRows] = await Consumable.update(
|
||||||
|
{
|
||||||
|
currentStock: newStock,
|
||||||
|
version: sequelize.literal('version + 1')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
consumableId,
|
||||||
|
version: consumable.version
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (affectedRows === 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
attempt++;
|
||||||
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await ConsumableRecord.create({
|
||||||
|
consumableId,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: type,
|
||||||
|
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '操作成功',
|
||||||
|
record,
|
||||||
|
consumable: await Consumable.findByPk(consumableId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '操作类型无效' });
|
if (attempt >= MAX_RETRIES - 1) {
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumable.update({ currentStock: newStock }, { transaction });
|
|
||||||
|
|
||||||
const record = await ConsumableRecord.create({
|
|
||||||
consumableId,
|
|
||||||
type,
|
|
||||||
quantity,
|
|
||||||
previousStock,
|
|
||||||
currentStock: newStock,
|
|
||||||
operator,
|
|
||||||
reason,
|
|
||||||
notes
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await ConsumableLog.create({
|
|
||||||
consumableId,
|
|
||||||
consumableName: consumable.name,
|
|
||||||
operationType: type,
|
|
||||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
|
||||||
previousStock,
|
|
||||||
currentStock: newStock,
|
|
||||||
operator,
|
|
||||||
reason,
|
|
||||||
notes
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: '操作成功',
|
|
||||||
record,
|
|
||||||
consumable: await consumable.reload()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/inout', async (req, res) => {
|
router.post('/inout', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const MAX_RETRIES = 3;
|
||||||
try {
|
let attempt = 0;
|
||||||
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
|
||||||
|
while (attempt < MAX_RETRIES) {
|
||||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
const transaction = await sequelize.transaction();
|
||||||
if (!consumable) {
|
try {
|
||||||
await transaction.rollback();
|
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
||||||
return res.status(404).json({ error: '耗材不存在' });
|
|
||||||
}
|
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
|
if (!consumable) {
|
||||||
const previousStock = parseFloat(consumable.currentStock);
|
|
||||||
let newStock;
|
|
||||||
|
|
||||||
if (type === 'in') {
|
|
||||||
newStock = previousStock + parseFloat(quantity);
|
|
||||||
} else {
|
|
||||||
newStock = previousStock - parseFloat(quantity);
|
|
||||||
if (newStock < 0) {
|
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '库存不足' });
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousStock = parseFloat(consumable.currentStock);
|
||||||
|
let newStock;
|
||||||
|
|
||||||
|
if (type === 'in') {
|
||||||
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
} else {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不足' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [affectedRows] = await Consumable.update(
|
||||||
|
{
|
||||||
|
currentStock: newStock,
|
||||||
|
version: sequelize.literal('version + 1')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
consumableId,
|
||||||
|
version: consumable.version
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (affectedRows === 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
attempt++;
|
||||||
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await ConsumableRecord.create({
|
||||||
|
consumableId,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
recipient,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: type,
|
||||||
|
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '操作成功',
|
||||||
|
record,
|
||||||
|
consumable: await Consumable.findByPk(consumableId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
if (attempt >= MAX_RETRIES - 1) {
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumable.update({ currentStock: newStock }, { transaction });
|
|
||||||
|
|
||||||
const record = await ConsumableRecord.create({
|
|
||||||
consumableId,
|
|
||||||
type,
|
|
||||||
quantity,
|
|
||||||
previousStock,
|
|
||||||
currentStock: newStock,
|
|
||||||
operator,
|
|
||||||
reason,
|
|
||||||
recipient,
|
|
||||||
notes
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await ConsumableLog.create({
|
|
||||||
consumableId,
|
|
||||||
consumableName: consumable.name,
|
|
||||||
operationType: type,
|
|
||||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
|
||||||
previousStock,
|
|
||||||
currentStock: newStock,
|
|
||||||
operator,
|
|
||||||
reason,
|
|
||||||
notes
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: '操作成功',
|
|
||||||
record,
|
|
||||||
consumable: await consumable.reload()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/adjust', async (req, res) => {
|
router.post('/adjust', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const MAX_RETRIES = 3;
|
||||||
try {
|
let attempt = 0;
|
||||||
const { consumableId, adjustType, quantity, operator, reason, notes } = req.body;
|
|
||||||
|
while (attempt < MAX_RETRIES) {
|
||||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
const transaction = await sequelize.transaction();
|
||||||
if (!consumable) {
|
try {
|
||||||
await transaction.rollback();
|
const { consumableId, adjustType, quantity, operator, reason, notes } = req.body;
|
||||||
return res.status(404).json({ error: '耗材不存在' });
|
|
||||||
}
|
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
|
if (!consumable) {
|
||||||
const previousStock = parseFloat(consumable.currentStock);
|
|
||||||
let newStock;
|
|
||||||
|
|
||||||
if (adjustType === 'add') {
|
|
||||||
newStock = previousStock + parseFloat(quantity);
|
|
||||||
} else if (adjustType === 'subtract') {
|
|
||||||
newStock = previousStock - parseFloat(quantity);
|
|
||||||
if (newStock < 0) {
|
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '调整后库存不能为负' });
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
}
|
}
|
||||||
} else if (adjustType === 'set') {
|
|
||||||
newStock = parseFloat(quantity);
|
const previousStock = parseFloat(consumable.currentStock);
|
||||||
} else {
|
let newStock;
|
||||||
|
|
||||||
|
if (adjustType === 'add') {
|
||||||
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
} else if (adjustType === 'subtract') {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '调整后库存不能为负' });
|
||||||
|
}
|
||||||
|
} else if (adjustType === 'set') {
|
||||||
|
newStock = parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不能设置为负数' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '调整类型无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [affectedRows] = await Consumable.update(
|
||||||
|
{
|
||||||
|
currentStock: newStock,
|
||||||
|
version: sequelize.literal('version + 1')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
consumableId,
|
||||||
|
version: consumable.version
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (affectedRows === 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
attempt++;
|
||||||
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeQuantity = newStock - previousStock;
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: 'adjust',
|
||||||
|
quantity: changeQuantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
||||||
|
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '调整成功',
|
||||||
|
consumable: await Consumable.findByPk(consumableId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '调整类型无效' });
|
if (attempt >= MAX_RETRIES - 1) {
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumable.update({ currentStock: newStock }, { transaction });
|
|
||||||
|
|
||||||
const changeQuantity = newStock - previousStock;
|
|
||||||
|
|
||||||
await ConsumableLog.create({
|
|
||||||
consumableId,
|
|
||||||
consumableName: consumable.name,
|
|
||||||
operationType: 'adjust',
|
|
||||||
quantity: changeQuantity,
|
|
||||||
previousStock,
|
|
||||||
currentStock: newStock,
|
|
||||||
operator,
|
|
||||||
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
|
||||||
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await transaction.commit();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: '调整成功',
|
|
||||||
consumable: await consumable.reload()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+15
-18
@@ -14,9 +14,18 @@ const DeviceField = require('../models/DeviceField');
|
|||||||
const Ticket = require('../models/Ticket');
|
const Ticket = require('../models/Ticket');
|
||||||
const DevicePort = require('../models/DevicePort'); // Import DevicePort
|
const DevicePort = require('../models/DevicePort'); // Import DevicePort
|
||||||
const Cable = require('../models/Cable'); // Import Cable
|
const Cable = require('../models/Cable'); // Import Cable
|
||||||
|
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||||
|
const {
|
||||||
|
createDeviceSchema,
|
||||||
|
updateDeviceSchema,
|
||||||
|
batchDeviceIdsSchema,
|
||||||
|
batchStatusSchema,
|
||||||
|
batchMoveSchema,
|
||||||
|
queryDeviceSchema
|
||||||
|
} = require('../validation/deviceSchema');
|
||||||
|
|
||||||
// 获取所有设备(支持搜索和筛选)
|
// 获取所有设备(支持搜索和筛选)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyword, status, type, rackId, page = 1, pageSize = 10 } = req.query;
|
const { keyword, status, type, rackId, page = 1, pageSize = 10 } = req.query;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
@@ -80,7 +89,7 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 创建设备
|
// 创建设备
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', validateBody(createDeviceSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const device = await Device.create(req.body);
|
const device = await Device.create(req.body);
|
||||||
|
|
||||||
@@ -602,7 +611,7 @@ router.post('/import', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 批量上线设备
|
// 批量上线设备
|
||||||
router.put('/batch-online', async (req, res) => {
|
router.put('/batch-online', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { deviceIds } = req.body;
|
const { deviceIds } = req.body;
|
||||||
|
|
||||||
@@ -626,7 +635,7 @@ router.put('/batch-online', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 批量下线设备
|
// 批量下线设备
|
||||||
router.put('/batch-offline', async (req, res) => {
|
router.put('/batch-offline', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { deviceIds } = req.body;
|
const { deviceIds } = req.body;
|
||||||
|
|
||||||
@@ -654,8 +663,6 @@ router.put('/batch-status', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { deviceIds, status } = req.body;
|
const { deviceIds, status } = req.body;
|
||||||
|
|
||||||
console.log('批量状态变更请求:', { deviceIds, status });
|
|
||||||
|
|
||||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||||
}
|
}
|
||||||
@@ -666,15 +673,11 @@ router.put('/batch-status', async (req, res) => {
|
|||||||
attributes: ['deviceId']
|
attributes: ['deviceId']
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('数据库中找到的设备:', existingDevices.map(d => d.deviceId));
|
|
||||||
console.log('请求的设备ID:', deviceIds);
|
|
||||||
|
|
||||||
// 检查是否有不存在的设备
|
// 检查是否有不存在的设备
|
||||||
const existingIds = existingDevices.map(d => d.deviceId);
|
const existingIds = existingDevices.map(d => d.deviceId);
|
||||||
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
||||||
|
|
||||||
if (missingIds.length > 0) {
|
if (missingIds.length > 0) {
|
||||||
console.log('不存在的设备ID:', missingIds);
|
|
||||||
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +783,7 @@ router.get('/:deviceId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新设备
|
// 更新设备
|
||||||
router.put('/:deviceId', async (req, res) => {
|
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 获取旧设备信息以更新功率
|
// 获取旧设备信息以更新功率
|
||||||
const oldDevice = await Device.findByPk(req.params.deviceId);
|
const oldDevice = await Device.findByPk(req.params.deviceId);
|
||||||
@@ -843,7 +846,7 @@ router.put('/batch-offline', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 批量删除设备
|
// 批量删除设备
|
||||||
router.delete('/batch-delete', async (req, res) => {
|
router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { deviceIds } = req.body;
|
const { deviceIds } = req.body;
|
||||||
@@ -1045,8 +1048,6 @@ router.put('/batch-status', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { deviceIds, status } = req.body;
|
const { deviceIds, status } = req.body;
|
||||||
|
|
||||||
console.log('批量状态变更请求:', { deviceIds, status });
|
|
||||||
|
|
||||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||||
}
|
}
|
||||||
@@ -1057,15 +1058,11 @@ router.put('/batch-status', async (req, res) => {
|
|||||||
attributes: ['deviceId']
|
attributes: ['deviceId']
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('数据库中找到的设备:', existingDevices.map(d => d.deviceId));
|
|
||||||
console.log('请求的设备ID:', deviceIds);
|
|
||||||
|
|
||||||
// 检查是否有不存在的设备
|
// 检查是否有不存在的设备
|
||||||
const existingIds = existingDevices.map(d => d.deviceId);
|
const existingIds = existingDevices.map(d => d.deviceId);
|
||||||
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
||||||
|
|
||||||
if (missingIds.length > 0) {
|
if (missingIds.length > 0) {
|
||||||
console.log('不存在的设备ID:', missingIds);
|
|
||||||
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const Room = require('../models/Room');
|
|||||||
const XLSX = require('xlsx');
|
const XLSX = require('xlsx');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||||
|
const { createRackSchema, updateRackSchema, queryRackSchema } = require('../validation/rackSchema');
|
||||||
|
|
||||||
// 获取所有机柜
|
// 获取所有机柜
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -57,7 +59,7 @@ router.get('/:rackId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 创建机柜
|
// 创建机柜
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', validateBody(createRackSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rack = await Rack.create(req.body);
|
const rack = await Rack.create(req.body);
|
||||||
res.status(201).json(rack);
|
res.status(201).json(rack);
|
||||||
@@ -67,7 +69,7 @@ router.post('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新机柜
|
// 更新机柜
|
||||||
router.put('/:rackId', async (req, res) => {
|
router.put('/:rackId', validateBody(updateRackSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [updated] = await Rack.update(req.body, {
|
const [updated] = await Rack.update(req.body, {
|
||||||
where: { rackId: req.params.rackId }
|
where: { rackId: req.params.rackId }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Room = require('../models/Room');
|
const Room = require('../models/Room');
|
||||||
const Rack = require('../models/Rack');
|
const Rack = require('../models/Rack');
|
||||||
|
const { validateBody } = require('../middleware/validation');
|
||||||
|
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
|
||||||
|
|
||||||
// 获取所有机房
|
// 获取所有机房
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -41,7 +43,7 @@ router.post('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新机房
|
// 更新机房
|
||||||
router.put('/:roomId', async (req, res) => {
|
router.put('/:roomId', validateBody(updateRoomSchema), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [updated] = await Room.update(req.body, {
|
const [updated] = await Room.update(req.body, {
|
||||||
where: { roomId: req.params.roomId }
|
where: { roomId: req.params.roomId }
|
||||||
|
|||||||
@@ -20,19 +20,11 @@ const initDefaultSettings = async () => {
|
|||||||
// 外观设置
|
// 外观设置
|
||||||
{ settingKey: 'primary_color', settingValue: JSON.stringify('#667eea'), settingType: 'string', category: 'appearance', 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: 'secondary_color', settingValue: JSON.stringify('#764ba2'), settingType: 'string', category: 'appearance', description: '主题辅助色调', isEditable: true },
|
||||||
{ settingKey: 'compact_mode', settingValue: JSON.stringify(false), type: 'boolean', category: 'appearance', description: '紧凑模式', isEditable: true },
|
{ settingKey: 'compact_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'appearance', description: '紧凑模式', isEditable: true },
|
||||||
{ settingKey: 'sidebar_collapsed', settingValue: JSON.stringify(false), settingType: '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: '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: '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: '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: 'company_name', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '公司/组织名称', isEditable: true },
|
||||||
@@ -241,10 +233,6 @@ router.post('/reset/:key', async (req, res) => {
|
|||||||
sidebar_collapsed: false,
|
sidebar_collapsed: false,
|
||||||
table_row_height: 'default',
|
table_row_height: 'default',
|
||||||
animation_enabled: true,
|
animation_enabled: true,
|
||||||
auto_backup_enabled: false,
|
|
||||||
backup_interval: 24,
|
|
||||||
backup_retention: 7,
|
|
||||||
backup_path: './backups',
|
|
||||||
company_name: '',
|
company_name: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 耗材管理乐观锁迁移脚本
|
||||||
|
* 为 consumables 表添加 version 字段
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sequelize, dbDialect } = require('../db');
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
console.log('开始执行耗材乐观锁迁移...');
|
||||||
|
console.log('数据库类型:', dbDialect);
|
||||||
|
|
||||||
|
if (dbDialect === 'sqlite') {
|
||||||
|
// SQLite: 检查字段是否存在
|
||||||
|
const tableInfo = await sequelize.query(
|
||||||
|
"PRAGMA table_info(consumables)",
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasVersion = tableInfo.some(col => col.name === 'version');
|
||||||
|
|
||||||
|
if (!hasVersion) {
|
||||||
|
console.log('添加 version 字段...');
|
||||||
|
await sequelize.query(
|
||||||
|
"ALTER TABLE consumables ADD COLUMN version INTEGER DEFAULT 0"
|
||||||
|
);
|
||||||
|
console.log('version 字段添加成功');
|
||||||
|
} else {
|
||||||
|
console.log('version 字段已存在,跳过');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 updatedAt 索引
|
||||||
|
const indexes = await sequelize.query(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='consumables'",
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUpdatedAtIndex = indexes.some(idx => idx.name === 'consumables_updatedAt');
|
||||||
|
|
||||||
|
if (!hasUpdatedAtIndex) {
|
||||||
|
console.log('添加 updatedAt 索引...');
|
||||||
|
await sequelize.query(
|
||||||
|
"CREATE INDEX consumables_updatedAt ON consumables(updatedAt)"
|
||||||
|
);
|
||||||
|
console.log('updatedAt 索引添加成功');
|
||||||
|
} else {
|
||||||
|
console.log('updatedAt 索引已存在,跳过');
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (dbDialect === 'mysql') {
|
||||||
|
// MySQL: 检查并添加字段
|
||||||
|
try {
|
||||||
|
console.log('添加 version 字段...');
|
||||||
|
await sequelize.query(
|
||||||
|
"ALTER TABLE consumables ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"
|
||||||
|
);
|
||||||
|
console.log('version 字段添加成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('Duplicate column')) {
|
||||||
|
console.log('version 字段已存在,跳过');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加索引
|
||||||
|
try {
|
||||||
|
console.log('添加 updatedAt 索引...');
|
||||||
|
await sequelize.query(
|
||||||
|
"CREATE INDEX idx_consumables_updatedAt ON consumables(updatedAt)"
|
||||||
|
);
|
||||||
|
console.log('updatedAt 索引添加成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('Duplicate key')) {
|
||||||
|
console.log('updatedAt 索引已存在,跳过');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化现有数据的 version 值
|
||||||
|
console.log('初始化现有数据的 version 值...');
|
||||||
|
await sequelize.query(
|
||||||
|
"UPDATE consumables SET version = 0 WHERE version IS NULL"
|
||||||
|
);
|
||||||
|
console.log('version 值初始化完成');
|
||||||
|
|
||||||
|
console.log('迁移完成!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('迁移失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
// 设备类型枚举
|
||||||
|
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
||||||
|
|
||||||
|
// 设备状态枚举
|
||||||
|
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault'];
|
||||||
|
|
||||||
|
// 创建设备验证Schema
|
||||||
|
const createDeviceSchema = Joi.object({
|
||||||
|
deviceId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '设备ID不能为空',
|
||||||
|
'string.max': '设备ID不能超过50个字符',
|
||||||
|
'string.pattern.base': '设备ID只能包含字母、数字、下划线和横线',
|
||||||
|
'any.required': '设备ID是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
name: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '设备名称不能为空',
|
||||||
|
'string.max': '设备名称不能超过100个字符',
|
||||||
|
'any.required': '设备名称是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(...DEVICE_TYPES)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.empty': '设备类型不能为空',
|
||||||
|
'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`,
|
||||||
|
'any.required': '设备类型是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
model: Joi.string()
|
||||||
|
.max(100)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '型号不能超过100个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
serialNumber: Joi.string()
|
||||||
|
.max(100)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '序列号不能超过100个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
rackId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机柜ID不能为空',
|
||||||
|
'string.max': '机柜ID不能超过50个字符',
|
||||||
|
'any.required': '机柜ID是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
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)
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'number.base': '位置必须是数字',
|
||||||
|
'number.integer': '位置必须是整数',
|
||||||
|
'number.min': '位置不能小于1',
|
||||||
|
'number.max': '位置不能大于100'
|
||||||
|
}),
|
||||||
|
|
||||||
|
height: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.messages({
|
||||||
|
'number.base': '高度必须是数字',
|
||||||
|
'number.integer': '高度必须是整数',
|
||||||
|
'number.min': '高度不能小于1',
|
||||||
|
'number.max': '高度不能大于50'
|
||||||
|
}),
|
||||||
|
|
||||||
|
powerConsumption: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.max(100000)
|
||||||
|
.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)
|
||||||
|
.messages({
|
||||||
|
'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
purchaseDate: Joi.date()
|
||||||
|
.allow(null),
|
||||||
|
|
||||||
|
warrantyExpiry: Joi.date()
|
||||||
|
.allow(null),
|
||||||
|
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.allow('', null),
|
||||||
|
|
||||||
|
customFields: Joi.object()
|
||||||
|
.allow(null)
|
||||||
|
}).min(1).messages({
|
||||||
|
'object.min': '至少需要提供一个字段进行更新'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量操作验证Schema
|
||||||
|
const batchDeviceIdsSchema = Joi.object({
|
||||||
|
deviceIds: Joi.array()
|
||||||
|
.items(Joi.string().required())
|
||||||
|
.min(1)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'array.base': '设备ID列表必须是数组',
|
||||||
|
'array.min': '至少需要提供一个设备ID',
|
||||||
|
'any.required': '设备ID列表是必填字段'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量状态变更验证Schema
|
||||||
|
const batchStatusSchema = Joi.object({
|
||||||
|
deviceIds: Joi.array()
|
||||||
|
.items(Joi.string().required())
|
||||||
|
.min(1)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'array.base': '设备ID列表必须是数组',
|
||||||
|
'array.min': '至少需要提供一个设备ID',
|
||||||
|
'any.required': '设备ID列表是必填字段'
|
||||||
|
}),
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...DEVICE_STATUS)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}`,
|
||||||
|
'any.required': '状态是必填字段'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量移动验证Schema
|
||||||
|
const batchMoveSchema = Joi.object({
|
||||||
|
deviceIds: Joi.array()
|
||||||
|
.items(Joi.string().required())
|
||||||
|
.min(1)
|
||||||
|
.required(),
|
||||||
|
targetRackId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '目标机柜ID不能为空',
|
||||||
|
'any.required': '目标机柜ID是必填字段'
|
||||||
|
}),
|
||||||
|
startPosition: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.allow(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询参数验证Schema
|
||||||
|
const queryDeviceSchema = Joi.object({
|
||||||
|
keyword: Joi.string()
|
||||||
|
.max(100)
|
||||||
|
.allow(''),
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...DEVICE_STATUS, 'all')
|
||||||
|
.allow(''),
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(...DEVICE_TYPES, 'all')
|
||||||
|
.allow(''),
|
||||||
|
rackId: Joi.string()
|
||||||
|
.max(50)
|
||||||
|
.allow(''),
|
||||||
|
page: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.default(1),
|
||||||
|
pageSize: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createDeviceSchema,
|
||||||
|
updateDeviceSchema,
|
||||||
|
batchDeviceIdsSchema,
|
||||||
|
batchStatusSchema,
|
||||||
|
batchMoveSchema,
|
||||||
|
queryDeviceSchema,
|
||||||
|
DEVICE_TYPES,
|
||||||
|
DEVICE_STATUS
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
// 机柜状态枚举
|
||||||
|
const RACK_STATUS = ['active', 'inactive', 'maintenance'];
|
||||||
|
|
||||||
|
// 创建机柜验证Schema
|
||||||
|
const createRackSchema = Joi.object({
|
||||||
|
rackId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机柜ID不能为空',
|
||||||
|
'string.max': '机柜ID不能超过50个字符',
|
||||||
|
'string.pattern.base': '机柜ID只能包含字母、数字、下划线和横线',
|
||||||
|
'any.required': '机柜ID是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
name: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机柜名称不能为空',
|
||||||
|
'string.max': '机柜名称不能超过100个字符',
|
||||||
|
'any.required': '机柜名称是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
height: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(42)
|
||||||
|
.messages({
|
||||||
|
'number.base': '高度必须是数字',
|
||||||
|
'number.integer': '高度必须是整数',
|
||||||
|
'number.min': '高度不能小于1',
|
||||||
|
'number.max': '高度不能大于100'
|
||||||
|
}),
|
||||||
|
|
||||||
|
maxPower: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1000000)
|
||||||
|
.default(10000)
|
||||||
|
.messages({
|
||||||
|
'number.base': '最大功率必须是数字',
|
||||||
|
'number.min': '最大功率不能小于0',
|
||||||
|
'number.max': '最大功率不能超过1000000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
currentPower: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.default(0)
|
||||||
|
.messages({
|
||||||
|
'number.base': '当前功率必须是数字',
|
||||||
|
'number.min': '当前功率不能小于0'
|
||||||
|
}),
|
||||||
|
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...RACK_STATUS)
|
||||||
|
.default('active')
|
||||||
|
.messages({
|
||||||
|
'any.only': `状态必须是以下之一: ${RACK_STATUS.join(', ')}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
roomId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机房ID不能为空',
|
||||||
|
'string.max': '机房ID不能超过50个字符',
|
||||||
|
'any.required': '机房ID是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '描述不能超过500个字符'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新机柜验证Schema
|
||||||
|
const updateRackSchema = Joi.object({
|
||||||
|
rackId: 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个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
height: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'number.base': '高度必须是数字',
|
||||||
|
'number.integer': '高度必须是整数',
|
||||||
|
'number.min': '高度不能小于1',
|
||||||
|
'number.max': '高度不能大于100'
|
||||||
|
}),
|
||||||
|
|
||||||
|
maxPower: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1000000)
|
||||||
|
.messages({
|
||||||
|
'number.base': '最大功率必须是数字',
|
||||||
|
'number.min': '最大功率不能小于0',
|
||||||
|
'number.max': '最大功率不能超过1000000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
currentPower: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.messages({
|
||||||
|
'number.base': '当前功率必须是数字',
|
||||||
|
'number.min': '当前功率不能小于0'
|
||||||
|
}),
|
||||||
|
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...RACK_STATUS)
|
||||||
|
.messages({
|
||||||
|
'any.only': `状态必须是以下之一: ${RACK_STATUS.join(', ')}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
roomId: Joi.string()
|
||||||
|
.max(50),
|
||||||
|
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '描述不能超过500个字符'
|
||||||
|
})
|
||||||
|
}).min(1).messages({
|
||||||
|
'object.min': '至少需要提供一个字段进行更新'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询机柜验证Schema
|
||||||
|
const queryRackSchema = Joi.object({
|
||||||
|
roomId: Joi.string()
|
||||||
|
.max(50)
|
||||||
|
.allow(''),
|
||||||
|
status: Joi.string()
|
||||||
|
.valid(...RACK_STATUS, 'all')
|
||||||
|
.allow(''),
|
||||||
|
keyword: Joi.string()
|
||||||
|
.max(100)
|
||||||
|
.allow(''),
|
||||||
|
page: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.default(1),
|
||||||
|
pageSize: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRackSchema,
|
||||||
|
updateRackSchema,
|
||||||
|
queryRackSchema,
|
||||||
|
RACK_STATUS
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
// 创建机房验证Schema
|
||||||
|
const createRoomSchema = Joi.object({
|
||||||
|
roomId: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(50)
|
||||||
|
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机房ID不能为空',
|
||||||
|
'string.max': '机房ID不能超过50个字符',
|
||||||
|
'string.pattern.base': '机房ID只能包含字母、数字、下划线和横线',
|
||||||
|
'any.required': '机房ID是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
name: Joi.string()
|
||||||
|
.required()
|
||||||
|
.max(100)
|
||||||
|
.messages({
|
||||||
|
'string.empty': '机房名称不能为空',
|
||||||
|
'string.max': '机房名称不能超过100个字符',
|
||||||
|
'any.required': '机房名称是必填字段'
|
||||||
|
}),
|
||||||
|
|
||||||
|
location: Joi.string()
|
||||||
|
.max(200)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '位置不能超过200个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
area: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1000000)
|
||||||
|
.allow(null)
|
||||||
|
.messages({
|
||||||
|
'number.base': '面积必须是数字',
|
||||||
|
'number.min': '面积不能小于0',
|
||||||
|
'number.max': '面积不能超过1000000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
capacity: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(10000)
|
||||||
|
.allow(null)
|
||||||
|
.messages({
|
||||||
|
'number.base': '容量必须是数字',
|
||||||
|
'number.integer': '容量必须是整数',
|
||||||
|
'number.min': '容量不能小于0',
|
||||||
|
'number.max': '容量不能超过10000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '描述不能超过500个字符'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新机房验证Schema
|
||||||
|
const updateRoomSchema = Joi.object({
|
||||||
|
roomId: 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个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
location: Joi.string()
|
||||||
|
.max(200)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '位置不能超过200个字符'
|
||||||
|
}),
|
||||||
|
|
||||||
|
area: Joi.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1000000)
|
||||||
|
.allow(null)
|
||||||
|
.messages({
|
||||||
|
'number.base': '面积必须是数字',
|
||||||
|
'number.min': '面积不能小于0',
|
||||||
|
'number.max': '面积不能超过1000000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
capacity: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(10000)
|
||||||
|
.allow(null)
|
||||||
|
.messages({
|
||||||
|
'number.base': '容量必须是数字',
|
||||||
|
'number.integer': '容量必须是整数',
|
||||||
|
'number.min': '容量不能小于0',
|
||||||
|
'number.max': '容量不能超过10000'
|
||||||
|
}),
|
||||||
|
|
||||||
|
description: Joi.string()
|
||||||
|
.max(500)
|
||||||
|
.allow('', null)
|
||||||
|
.messages({
|
||||||
|
'string.max': '描述不能超过500个字符'
|
||||||
|
})
|
||||||
|
}).min(1).messages({
|
||||||
|
'object.min': '至少需要提供一个字段进行更新'
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRoomSchema,
|
||||||
|
updateRoomSchema
|
||||||
|
};
|
||||||
+4
-151
@@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Link, Navigate, useLocation, us
|
|||||||
import { useAuth } from './context/AuthContext';
|
import { useAuth } from './context/AuthContext';
|
||||||
import { ConfigProvider, useConfig } from './context/ConfigContext';
|
import { ConfigProvider, useConfig } from './context/ConfigContext';
|
||||||
import { Scene3DProvider } from './context/Scene3DContext';
|
import { Scene3DProvider } from './context/Scene3DContext';
|
||||||
|
import { useDesignTokens } from './hooks/useDesignTokens';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -59,55 +60,6 @@ const AuthLoading = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const designTokens = {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
main: '#667eea',
|
|
||||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
light: '#8b9ff0'
|
|
||||||
},
|
|
||||||
success: { main: '#10b981' },
|
|
||||||
warning: { main: '#f59e0b' },
|
|
||||||
error: { main: '#ef4444' },
|
|
||||||
text: {
|
|
||||||
primary: '#1e293b',
|
|
||||||
secondary: '#64748b',
|
|
||||||
inverse: '#ffffff'
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
primary: '#ffffff',
|
|
||||||
secondary: '#f8fafc',
|
|
||||||
dark: '#1e293b'
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
light: '#e2e8f0'
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
bg: '#ffffff',
|
|
||||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
|
||||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
|
||||||
text: '#475569',
|
|
||||||
textHover: '#667eea',
|
|
||||||
textActive: '#667eea',
|
|
||||||
border: '#e2e8f0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shadows: {
|
|
||||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
|
||||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
|
||||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
small: '6px',
|
|
||||||
medium: '10px'
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
sm: '8px',
|
|
||||||
md: '16px',
|
|
||||||
lg: '24px'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const PrivateRoute = ({ children }) => {
|
const PrivateRoute = ({ children }) => {
|
||||||
const { token, initialized, loading } = useAuth();
|
const { token, initialized, loading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -133,9 +85,10 @@ const AppLayout = ({ children }) => {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [activeKey, setActiveKey] = useState('dashboard');
|
const [activeKey, setActiveKey] = useState('dashboard');
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { config } = useConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { config } = useConfig();
|
const designTokens = useDesignTokens();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -155,56 +108,6 @@ const AppLayout = ({ children }) => {
|
|||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 动态设计令牌
|
|
||||||
const designTokens = {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
main: config.primary_color || '#667eea',
|
|
||||||
gradient: `linear-gradient(135deg, ${config.primary_color || '#667eea'} 0%, ${config.secondary_color || '#764ba2'} 100%)`,
|
|
||||||
light: '#8b9ff0'
|
|
||||||
},
|
|
||||||
success: { main: '#10b981' },
|
|
||||||
warning: { main: '#f59e0b' },
|
|
||||||
error: { main: '#ef4444' },
|
|
||||||
text: {
|
|
||||||
primary: '#1e293b',
|
|
||||||
secondary: '#64748b',
|
|
||||||
inverse: '#ffffff'
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
primary: '#ffffff',
|
|
||||||
secondary: '#f8fafc',
|
|
||||||
dark: '#1e293b'
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
light: '#e2e8f0'
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
bg: '#ffffff',
|
|
||||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
|
||||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
|
||||||
text: '#475569',
|
|
||||||
textHover: config.primary_color || '#667eea',
|
|
||||||
textActive: config.primary_color || '#667eea',
|
|
||||||
border: '#e2e8f0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shadows: {
|
|
||||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
|
||||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
|
||||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
small: '6px',
|
|
||||||
medium: '10px'
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
sm: '8px',
|
|
||||||
md: '16px',
|
|
||||||
lg: '24px'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
key: 'dashboard',
|
key: 'dashboard',
|
||||||
@@ -518,57 +421,7 @@ const AppLayout = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ThemeConfig = () => {
|
const ThemeConfig = () => {
|
||||||
const { config } = useConfig();
|
const designTokens = useDesignTokens();
|
||||||
|
|
||||||
// 动态设计令牌
|
|
||||||
const designTokens = {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
main: config.primary_color || '#667eea',
|
|
||||||
gradient: `linear-gradient(135deg, ${config.primary_color || '#667eea'} 0%, ${config.secondary_color || '#764ba2'} 100%)`,
|
|
||||||
light: '#8b9ff0'
|
|
||||||
},
|
|
||||||
success: { main: '#10b981' },
|
|
||||||
warning: { main: '#f59e0b' },
|
|
||||||
error: { main: '#ef4444' },
|
|
||||||
text: {
|
|
||||||
primary: '#1e293b',
|
|
||||||
secondary: '#64748b',
|
|
||||||
inverse: '#ffffff'
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
primary: '#ffffff',
|
|
||||||
secondary: '#f8fafc',
|
|
||||||
dark: '#1e293b'
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
light: '#e2e8f0'
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
bg: '#ffffff',
|
|
||||||
bgHover: 'rgba(102, 126, 234, 0.08)',
|
|
||||||
bgActive: 'rgba(102, 126, 234, 0.15)',
|
|
||||||
text: '#475569',
|
|
||||||
textHover: config.primary_color || '#667eea',
|
|
||||||
textActive: config.primary_color || '#667eea',
|
|
||||||
border: '#e2e8f0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shadows: {
|
|
||||||
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
|
||||||
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
|
||||||
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
small: '6px',
|
|
||||||
medium: '10px'
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
sm: '8px',
|
|
||||||
md: '16px',
|
|
||||||
lg: '24px'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||||
|
|||||||
@@ -15,9 +15,20 @@ api.interceptors.request.use(
|
|||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
} else {
|
|
||||||
console.log('[API] No token found in localStorage');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开发环境下安全日志:过滤敏感字段
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'confirmPassword'];
|
||||||
|
const safeData = config.data ? { ...config.data } : null;
|
||||||
|
if (safeData) {
|
||||||
|
sensitiveFields.forEach(field => {
|
||||||
|
if (safeData[field]) safeData[field] = '***';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, safeData || '');
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ const InstancedStatusLights = ({ count, positions, colors: statusColors, zOffset
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (meshRef.current) {
|
if (meshRef.current) {
|
||||||
meshRef.current.dispose();
|
meshRef.current.geometry?.dispose();
|
||||||
|
meshRef.current.material?.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -219,8 +220,14 @@ const InstancedDriveBays = ({ count, positions, color, hasDetail = true }) => {
|
|||||||
// 资源清理
|
// 资源清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (meshRef.current) meshRef.current.dispose();
|
if (meshRef.current) {
|
||||||
if (detailRef.current) detailRef.current.dispose();
|
meshRef.current.geometry?.dispose();
|
||||||
|
meshRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (detailRef.current) {
|
||||||
|
detailRef.current.geometry?.dispose();
|
||||||
|
detailRef.current.material?.dispose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -265,8 +272,14 @@ const InstancedStorageBays = ({ count, positions, color }) => {
|
|||||||
// 资源清理
|
// 资源清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (meshRef.current) meshRef.current.dispose();
|
if (meshRef.current) {
|
||||||
if (detailRef.current) detailRef.current.dispose();
|
meshRef.current.geometry?.dispose();
|
||||||
|
meshRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (detailRef.current) {
|
||||||
|
detailRef.current.geometry?.dispose();
|
||||||
|
detailRef.current.material?.dispose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -335,10 +348,22 @@ const InstancedRJ45Ports = ({ count, positions, statuses, frontZ }) => {
|
|||||||
// 资源清理
|
// 资源清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (meshRef.current) meshRef.current.dispose();
|
if (meshRef.current) {
|
||||||
if (innerRef.current) innerRef.current.dispose();
|
meshRef.current.geometry?.dispose();
|
||||||
if (tabRef.current) tabRef.current.dispose();
|
meshRef.current.material?.dispose();
|
||||||
if (ledRef.current) ledRef.current.dispose();
|
}
|
||||||
|
if (innerRef.current) {
|
||||||
|
innerRef.current.geometry?.dispose();
|
||||||
|
innerRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (tabRef.current) {
|
||||||
|
tabRef.current.geometry?.dispose();
|
||||||
|
tabRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (ledRef.current) {
|
||||||
|
ledRef.current.geometry?.dispose();
|
||||||
|
ledRef.current.material?.dispose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -421,10 +446,22 @@ const InstancedSFPports = ({ count, positions, statuses, frontZ }) => {
|
|||||||
// 资源清理
|
// 资源清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (meshRef.current) meshRef.current.dispose();
|
if (meshRef.current) {
|
||||||
if (innerRef.current) innerRef.current.dispose();
|
meshRef.current.geometry?.dispose();
|
||||||
if (connectorRef.current) connectorRef.current.dispose();
|
meshRef.current.material?.dispose();
|
||||||
if (ledRef.current) ledRef.current.dispose();
|
}
|
||||||
|
if (innerRef.current) {
|
||||||
|
innerRef.current.geometry?.dispose();
|
||||||
|
innerRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (connectorRef.current) {
|
||||||
|
connectorRef.current.geometry?.dispose();
|
||||||
|
connectorRef.current.material?.dispose();
|
||||||
|
}
|
||||||
|
if (ledRef.current) {
|
||||||
|
ledRef.current.geometry?.dispose();
|
||||||
|
ledRef.current.material?.dispose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -443,10 +480,16 @@ const FirewallFace = ({ device, height, frontZ, isSelected }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
{/* 防火墙左侧红色标识条 */}
|
||||||
<mesh position={[-halfWidth + 0.02, 0, frontZ + 0.006]}>
|
<mesh position={[-halfWidth + 0.02, 0, frontZ + 0.006]}>
|
||||||
<boxGeometry args={[0.01, height, 0.003]} />
|
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||||
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.3} />
|
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{/* 防火墙主面板边框 */}
|
||||||
|
<mesh position={[-halfWidth + 0.035, 0, frontZ + 0.005]}>
|
||||||
|
<boxGeometry args={[0.005, height - 0.004, 0.002]} />
|
||||||
|
<meshStandardMaterial color="#dc2626" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||||
<boxGeometry args={[0.46, height - 0.004, 0.002]} />
|
<boxGeometry args={[0.46, height - 0.004, 0.002]} />
|
||||||
@@ -631,10 +674,16 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
{/* 服务器主面板 - 深蓝色 */}
|
||||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||||
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
|
<meshStandardMaterial color="#1e293b" roughness={0.7} metalness={0.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{/* 服务器左侧标识条 - 亮蓝色 */}
|
||||||
|
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||||
|
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||||
|
<meshStandardMaterial color="#3b82f6" emissive="#3b82f6" emissiveIntensity={0.2} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
<group position={[-0.18, 0, frontZ + 0.006]}>
|
<group position={[-0.18, 0, frontZ + 0.006]}>
|
||||||
<mesh position={[-0.02, 0, 0]}>
|
<mesh position={[-0.02, 0, 0]}>
|
||||||
@@ -710,11 +759,17 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
{/* 存储设备主面板 - 深紫色 */}
|
||||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||||
<meshStandardMaterial color="#0f172a" roughness={0.8} />
|
<meshStandardMaterial color="#0f172a" roughness={0.8} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{/* 存储设备左侧标识条 - 紫色 */}
|
||||||
|
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||||
|
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||||
|
<meshStandardMaterial color="#8b5cf6" emissive="#8b5cf6" emissiveIntensity={0.2} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
<group position={[0, 0, frontZ + 0.008]}>
|
<group position={[0, 0, frontZ + 0.008]}>
|
||||||
<InstancedStorageBays
|
<InstancedStorageBays
|
||||||
count={bayPositions.length}
|
count={bayPositions.length}
|
||||||
@@ -769,9 +824,20 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
{/* 交换机主面板 - 深绿色背景突出显示 */}
|
||||||
<mesh position={[0, 0, frontZ + 0.0055]}>
|
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||||
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||||
<meshStandardMaterial color="#334155" roughness={0.6} metalness={0.4} />
|
<meshStandardMaterial color="#064e3b" roughness={0.5} metalness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{/* 交换机左侧标识条 - 亮绿色 */}
|
||||||
|
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||||
|
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||||
|
<meshStandardMaterial color="#10b981" emissive="#10b981" emissiveIntensity={0.2} />
|
||||||
|
</mesh>
|
||||||
|
{/* 交换机类型标识 */}
|
||||||
|
<mesh position={[-0.19, height/2 - 0.015, frontZ + 0.007]}>
|
||||||
|
<boxGeometry args={[0.025, 0.012, 0.002]} />
|
||||||
|
<meshStandardMaterial color="#065f46" />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
<group position={[-0.19, 0, frontZ + 0.006]}>
|
<group position={[-0.19, 0, frontZ + 0.006]}>
|
||||||
@@ -969,13 +1035,18 @@ const DeviceModel = ({ device, rackHeight, isSelected, onClick, onHover, uHeight
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* 默认面板纹理 */}
|
{/* 默认面板纹理 */}
|
||||||
<mesh position={[0, 0, frontZ + 0.005]}>
|
<mesh position={[0, 0, frontZ + 0.0055]}>
|
||||||
<boxGeometry args={[0.7, height - gap - 0.01, 0.002]} />
|
<boxGeometry args={[0.44, height - 0.004, 0.002]} />
|
||||||
<meshStandardMaterial color="#1e293b" roughness={0.6} />
|
<meshStandardMaterial color="#374151" roughness={0.6} metalness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
{/* 左侧灰色标识条 */}
|
||||||
|
<mesh position={[-0.21, 0, frontZ + 0.006]}>
|
||||||
|
<boxGeometry args={[0.015, height - 0.008, 0.003]} />
|
||||||
|
<meshStandardMaterial color="#6b7280" emissive="#6b7280" emissiveIntensity={0.1} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* 装饰线 */}
|
{/* 装饰线 */}
|
||||||
<mesh position={[0, 0, frontZ + 0.006]}>
|
<mesh position={[0, 0, frontZ + 0.007]}>
|
||||||
<boxGeometry args={[0.6, 0.005, 0.001]} />
|
<boxGeometry args={[0.4, 0.003, 0.001]} />
|
||||||
<meshStandardMaterial color={deviceColor} />
|
<meshStandardMaterial color={deviceColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { Suspense, useMemo, useRef, useEffect } from 'react';
|
import React, { Suspense, useMemo, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { Canvas, useFrame } from '@react-three/fiber';
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||||
const envMapUrl = '/assets/3d/env.hdr';
|
const envMapUrl = '/assets/3d/env.hdr';
|
||||||
import RackModel from './RackModel';
|
import RackModel from './RackModel';
|
||||||
@@ -10,16 +10,50 @@ import * as THREE from 'three';
|
|||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
// 检测是否为小屏幕
|
// 检测是否为小屏幕
|
||||||
const isSmallScreen = window.innerWidth < 768;
|
const isSmallScreen = window.innerWidth < 768;
|
||||||
// 移动端或小屏幕使用 dpr=1,桌面端使用 dpr=[1, 1.5]
|
// 移动端或小屏幕使用 dpr=1,桌面端使用 dpr=[1, 2] 提升清晰度
|
||||||
const deviceDpr = isMobile || isSmallScreen ? 1 : [1, 1.5];
|
const deviceDpr = isMobile || isSmallScreen ? 1 : [1, 2];
|
||||||
|
|
||||||
|
// 创建全局 ref 用于外部访问 controls
|
||||||
|
const controlsRefGlobal = { current: null };
|
||||||
|
|
||||||
// 内部组件用于处理 OrbitControls
|
// 内部组件用于处理 OrbitControls
|
||||||
const Controls = ({ rack }) => {
|
const Controls = ({ rack, onControlsReady }) => {
|
||||||
const controlsRef = useRef();
|
const controlsRef = useRef();
|
||||||
|
const { camera } = useThree();
|
||||||
// 机柜中心点(中轴线)
|
// 机柜中心点(中轴线)
|
||||||
const targetY = (rack?.height || 45) * 0.04445 / 2 + 0.5;
|
const rackHeight = rack?.height || 45;
|
||||||
|
const targetY = rackHeight * 0.04445 / 2 + 0.5;
|
||||||
const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
|
const fixedTarget = useMemo(() => new THREE.Vector3(0, targetY, 0), [targetY]);
|
||||||
|
|
||||||
|
// 根据机柜高度计算合适的相机距离限制
|
||||||
|
const minDistance = useMemo(() => Math.max(1.5, rackHeight * 0.04445 * 0.3), [rackHeight]);
|
||||||
|
const maxDistance = useMemo(() => Math.max(8, rackHeight * 0.04445 * 1.5), [rackHeight]);
|
||||||
|
|
||||||
|
// 保存相机初始位置用于重置
|
||||||
|
const initialCameraPosition = useMemo(() => {
|
||||||
|
const rackHeightMeters = rackHeight * 0.04445;
|
||||||
|
const baseHeight = 2;
|
||||||
|
const heightFactor = rackHeightMeters * 0.6;
|
||||||
|
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||||
|
return new THREE.Vector3(distance * 0.7, baseHeight + heightFactor * 0.3, distance);
|
||||||
|
}, [rackHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRefGlobal.current = controlsRef.current;
|
||||||
|
if (onControlsReady) {
|
||||||
|
onControlsReady({
|
||||||
|
reset: () => {
|
||||||
|
// 重置相机位置
|
||||||
|
camera.position.copy(initialCameraPosition);
|
||||||
|
controlsRef.current.target.copy(fixedTarget);
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (controlsRef.current) {
|
if (controlsRef.current) {
|
||||||
// 强制保持 target 在机柜中轴线
|
// 强制保持 target 在机柜中轴线
|
||||||
@@ -32,17 +66,19 @@ const Controls = ({ rack }) => {
|
|||||||
<OrbitControls
|
<OrbitControls
|
||||||
ref={controlsRef}
|
ref={controlsRef}
|
||||||
makeDefault
|
makeDefault
|
||||||
minPolarAngle={0}
|
minPolarAngle={0.1}
|
||||||
maxPolarAngle={Math.PI / 1.75}
|
maxPolarAngle={Math.PI / 1.5}
|
||||||
minAzimuthAngle={-Infinity}
|
minAzimuthAngle={-Infinity}
|
||||||
maxAzimuthAngle={Infinity}
|
maxAzimuthAngle={Infinity}
|
||||||
|
minDistance={minDistance}
|
||||||
|
maxDistance={maxDistance}
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enableRotate={true}
|
enableRotate={true}
|
||||||
mouseButtons={{
|
mouseButtons={{
|
||||||
LEFT: 0, // 左键旋转
|
LEFT: 0, // 左键旋转
|
||||||
MIDDLE: 0, // 中键禁用(避免平移改变旋转中心)
|
MIDDLE: 1, // 中键平移
|
||||||
RIGHT: 0 // 右键禁用
|
RIGHT: 2 // 右键平移
|
||||||
}}
|
}}
|
||||||
touches={{
|
touches={{
|
||||||
ONE: 1,
|
ONE: 1,
|
||||||
@@ -52,7 +88,7 @@ const Controls = ({ rack }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }) => {
|
const Scene = forwardRef(({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeave }, ref) => {
|
||||||
// 从 Context 获取3D场景状态
|
// 从 Context 获取3D场景状态
|
||||||
const {
|
const {
|
||||||
devices,
|
devices,
|
||||||
@@ -60,6 +96,18 @@ const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeav
|
|||||||
deviceSlideEnabled
|
deviceSlideEnabled
|
||||||
} = useScene3D();
|
} = useScene3D();
|
||||||
|
|
||||||
|
// 用于存储 controls API
|
||||||
|
const controlsApiRef = useRef(null);
|
||||||
|
|
||||||
|
// 使用 useImperativeHandle 暴露重置方法给父组件
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
resetView: () => {
|
||||||
|
if (controlsApiRef.current) {
|
||||||
|
controlsApiRef.current.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// 使用 useMemo 稳定 props 引用
|
// 使用 useMemo 稳定 props 引用
|
||||||
const rackModelProps = useMemo(() => ({
|
const rackModelProps = useMemo(() => ({
|
||||||
rack,
|
rack,
|
||||||
@@ -72,47 +120,63 @@ const Scene = ({ rack, tooltipFields, onDeviceClick, onDeviceHover, onDeviceLeav
|
|||||||
deviceSlideEnabled
|
deviceSlideEnabled
|
||||||
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]);
|
}), [rack, devices, selectedDevice, onDeviceClick, onDeviceLeave, onDeviceHover, tooltipFields, deviceSlideEnabled]);
|
||||||
|
|
||||||
|
// 根据机柜高度动态计算相机初始位置
|
||||||
|
const rackHeight = rack?.height || 45;
|
||||||
|
const rackHeightMeters = rackHeight * 0.04445;
|
||||||
|
// 相机位置:确保能完整看到机柜,高度随机柜高度调整
|
||||||
|
const cameraPosition = useMemo(() => {
|
||||||
|
const baseHeight = 2;
|
||||||
|
const heightFactor = rackHeightMeters * 0.6;
|
||||||
|
const distance = Math.max(3, rackHeightMeters * 1.2);
|
||||||
|
return [distance * 0.7, baseHeight + heightFactor * 0.3, distance];
|
||||||
|
}, [rackHeightMeters]);
|
||||||
|
|
||||||
|
// 相机目标点(机柜中心)
|
||||||
|
const cameraTarget = useMemo(() => {
|
||||||
|
return [0, rackHeightMeters / 2 + 0.5, 0];
|
||||||
|
}, [rackHeightMeters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
shadows
|
shadows
|
||||||
dpr={deviceDpr}
|
dpr={deviceDpr}
|
||||||
performance={{ min: 0.5 }}
|
performance={{ min: 0.5 }}
|
||||||
gl={{
|
gl={{
|
||||||
antialias: !isMobile, // 移动端关闭抗锯齿提升性能
|
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||||
alpha: true, // 必须开启alpha以支持透明背景
|
alpha: true, // 必须开启alpha以支持透明背景
|
||||||
powerPreference: 'high-performance'
|
powerPreference: 'high-performance'
|
||||||
}}
|
}}
|
||||||
style={{ background: 'transparent' }}
|
style={{ background: 'transparent' }}
|
||||||
>
|
>
|
||||||
<PerspectiveCamera makeDefault position={[3, 2, 4]} fov={50} />
|
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||||
|
|
||||||
<ambientLight intensity={0.5} color="#ffffff" />
|
<ambientLight intensity={0.5} color="#ffffff" />
|
||||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={[10, 10, 5]}
|
position={[10, 10, 5]}
|
||||||
intensity={1}
|
intensity={1}
|
||||||
castShadow
|
castShadow
|
||||||
shadow-mapSize={[1024, 1024]}
|
shadow-mapSize={[2048, 2048]}
|
||||||
shadow-camera-far={20}
|
shadow-camera-far={20}
|
||||||
shadow-camera-left={-10}
|
shadow-camera-left={-10}
|
||||||
shadow-camera-right={10}
|
shadow-camera-right={10}
|
||||||
shadow-camera-top={10}
|
shadow-camera-top={10}
|
||||||
shadow-camera-bottom={-10}
|
shadow-camera-bottom={-10}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* Models */}
|
{/* Models */}
|
||||||
<group position={[0, 0, 0]}>
|
<group position={[0, 0, 0]}>
|
||||||
<RackModel {...rackModelProps} />
|
<RackModel {...rackModelProps} />
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||||
<Controls rack={rack} />
|
<Controls rack={rack} onControlsReady={(api) => { controlsApiRef.current = api; }} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Scene;
|
export default Scene;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useConfig } from '../context/ConfigContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用设计令牌 Hook
|
||||||
|
* 集中管理主题配置,避免在多个组件中重复定义
|
||||||
|
* @returns {Object} 设计令牌对象
|
||||||
|
*/
|
||||||
|
export const useDesignTokens = () => {
|
||||||
|
const { config } = useConfig();
|
||||||
|
|
||||||
|
const designTokens = useMemo(() => {
|
||||||
|
const primaryColor = config?.primary_color || '#667eea';
|
||||||
|
const secondaryColor = config?.secondary_color || '#764ba2';
|
||||||
|
|
||||||
|
return {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
main: primaryColor,
|
||||||
|
gradient: `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
|
||||||
|
light: '#8b9ff0'
|
||||||
|
},
|
||||||
|
success: { main: '#10b981' },
|
||||||
|
warning: { main: '#f59e0b' },
|
||||||
|
error: { main: '#ef4444' },
|
||||||
|
text: {
|
||||||
|
primary: '#1e293b',
|
||||||
|
secondary: '#64748b',
|
||||||
|
inverse: '#ffffff'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
primary: '#ffffff',
|
||||||
|
secondary: '#f8fafc',
|
||||||
|
dark: '#1e293b'
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
light: '#e2e8f0'
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
bgHover: `rgba(${hexToRgb(primaryColor)}, 0.08)`,
|
||||||
|
bgActive: `rgba(${hexToRgb(primaryColor)}, 0.15)`,
|
||||||
|
text: '#475569',
|
||||||
|
textHover: primaryColor,
|
||||||
|
textActive: primaryColor,
|
||||||
|
border: '#e2e8f0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
small: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
medium: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||||
|
large: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
small: '6px',
|
||||||
|
medium: '10px'
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
sm: '8px',
|
||||||
|
md: '16px',
|
||||||
|
lg: '24px'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [config?.primary_color, config?.secondary_color]);
|
||||||
|
|
||||||
|
return designTokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将十六进制颜色转换为RGB字符串
|
||||||
|
* @param {string} hex - 十六进制颜色值
|
||||||
|
* @returns {string} RGB字符串 (如: "102, 126, 234")
|
||||||
|
*/
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
// 移除 # 号
|
||||||
|
const cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// 处理简写格式 (如: #fff)
|
||||||
|
const fullHex = cleanHex.length === 3
|
||||||
|
? cleanHex.split('').map(c => c + c).join('')
|
||||||
|
: cleanHex;
|
||||||
|
|
||||||
|
const r = parseInt(fullHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(fullHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(fullHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDesignTokens;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd';
|
import { Layout, Select, Card, Spin, message, Typography, Descriptions, Tag, Button, Space, Empty, Modal, Form, Input, InputNumber, DatePicker, Checkbox, Switch } from 'antd';
|
||||||
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons';
|
import { CloudServerOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, UpOutlined, DownOutlined, EditOutlined, SettingOutlined, FullscreenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -17,7 +17,10 @@ const { Option } = Select;
|
|||||||
|
|
||||||
const Rack3DVisualization = () => {
|
const Rack3DVisualization = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Scene 组件的 ref,用于调用重置视角方法
|
||||||
|
const sceneRef = useRef(null);
|
||||||
|
|
||||||
// 使用 Scene3DContext 管理3D场景状态
|
// 使用 Scene3DContext 管理3D场景状态
|
||||||
const {
|
const {
|
||||||
devices,
|
devices,
|
||||||
@@ -465,16 +468,26 @@ const Rack3DVisualization = () => {
|
|||||||
<Option key={rack.rackId} value={rack.rackId}>{rack.name}</Option>
|
<Option key={rack.rackId} value={rack.rackId}>{rack.name}</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={() => { fetchRacks(); if(selectedRack) fetchDevices(selectedRack.rackId); }}
|
onClick={() => { fetchRacks(); if(selectedRack) fetchDevices(selectedRack.rackId); }}
|
||||||
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
|
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
|
||||||
className="hover-bright"
|
className="hover-bright"
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => { if(sceneRef.current) sceneRef.current.resetView(); }}
|
||||||
|
style={{ borderRadius: '6px', borderColor: 'rgba(255,255,255,0.3)', color: 'rgba(255,255,255,0.9)' }}
|
||||||
|
className="hover-bright"
|
||||||
|
>
|
||||||
|
重置视角
|
||||||
|
</Button>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -546,9 +559,10 @@ const Rack3DVisualization = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : selectedRack ? (
|
) : selectedRack ? (
|
||||||
<>
|
<>
|
||||||
<Scene
|
<Scene
|
||||||
rack={selectedRack}
|
ref={sceneRef}
|
||||||
devices={devices}
|
rack={selectedRack}
|
||||||
|
devices={devices}
|
||||||
selectedDeviceId={selectedDevice?.deviceId || selectedDevice?.id}
|
selectedDeviceId={selectedDevice?.deviceId || selectedDevice?.id}
|
||||||
onDeviceClick={handleDeviceClick}
|
onDeviceClick={handleDeviceClick}
|
||||||
onDeviceLeave={handleDeviceLeave}
|
onDeviceLeave={handleDeviceLeave}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { Tabs, Form, Input, Switch, Select, Button, Card, Space, message, Modal, Tag, Divider, Descriptions, Alert } from 'antd';
|
||||||
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, DatabaseOutlined, InfoCircleOutlined, CloudUploadOutlined, DeleteOutlined, ReloadOutlined, DownloadOutlined, SyncOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
import { SettingOutlined, GlobalOutlined, BgColorsOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useConfig } from '../context/ConfigContext';
|
import { useConfig } from '../context/ConfigContext';
|
||||||
|
|
||||||
@@ -12,15 +12,12 @@ const SystemSettings = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [settings, setSettings] = useState({});
|
const [settings, setSettings] = useState({});
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
const [backupList, setBackupList] = useState([]);
|
|
||||||
const [backupLoading, setBackupLoading] = useState(false);
|
|
||||||
const [systemInfo, setSystemInfo] = useState(null);
|
const [systemInfo, setSystemInfo] = useState(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { reloadConfig } = useConfig();
|
const { reloadConfig } = useConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchBackupList();
|
|
||||||
fetchSystemInfo();
|
fetchSystemInfo();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -42,18 +39,6 @@ const SystemSettings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 () => {
|
const fetchSystemInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/system-settings/system/info');
|
const response = await axios.get('/api/system-settings/system/info');
|
||||||
@@ -85,66 +70,6 @@ const SystemSettings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认创建备份',
|
|
||||||
icon: <ExclamationCircleOutlined />,
|
|
||||||
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: <ExclamationCircleOutlined />,
|
|
||||||
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: <ExclamationCircleOutlined />,
|
|
||||||
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) => {
|
const handleResetSetting = (key) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认重置',
|
title: '确认重置',
|
||||||
@@ -289,10 +214,6 @@ const SystemSettings = () => {
|
|||||||
{ value: 'false', label: '展开' },
|
{ value: 'false', label: '展开' },
|
||||||
{ value: 'true', label: '折叠' }
|
{ value: 'true', label: '折叠' }
|
||||||
],
|
],
|
||||||
auto_backup_enabled: [
|
|
||||||
{ value: 'false', label: '关闭' },
|
|
||||||
{ value: 'true', label: '开启' }
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
return optionsMap[key] || [];
|
return optionsMap[key] || [];
|
||||||
};
|
};
|
||||||
@@ -352,86 +273,7 @@ const SystemSettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBackupSettings = () => {
|
// 数据备份功能已移除
|
||||||
const backupColumns = [
|
|
||||||
{
|
|
||||||
title: '文件名',
|
|
||||||
dataIndex: 'filename',
|
|
||||||
key: 'filename',
|
|
||||||
render: (text) => <code>{text}</code>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => handleRestoreBackup(record.filename)}>恢复</Button>
|
|
||||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownloadBackup(record.filename)}>下载</Button>
|
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteBackup(record.filename)}>删除</Button>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// 备份设置键列表
|
|
||||||
const backupKeys = ['auto_backup_enabled', 'backup_interval', 'backup_retention', 'backup_path', 'last_backup_time', 'backup_count'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Card title="自动备份设置" bordered={false} style={{ marginBottom: 16 }}>
|
|
||||||
<Form form={form} layout="vertical" onFinish={handleSaveSettings}>
|
|
||||||
{backupKeys.map(key => {
|
|
||||||
if (settings[key]) {
|
|
||||||
return renderFormItem(key, settings[key]);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
<Form.Item>
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="手动备份管理" bordered={false}>
|
|
||||||
<Alert
|
|
||||||
message="数据安全提示"
|
|
||||||
description="建议定期创建备份,并将备份文件保存到安全的位置。恢复备份前请确保已创建当前数据的备份。"
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Button type="primary" icon={<CloudUploadOutlined />} onClick={handleCreateBackup}>立即备份</Button>
|
|
||||||
<Button icon={<SyncOutlined />} onClick={fetchBackupList}>刷新列表</Button>
|
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
dataSource={backupList}
|
|
||||||
columns={backupColumns}
|
|
||||||
rowKey="filename"
|
|
||||||
loading={backupLoading}
|
|
||||||
pagination={{ pageSize: 5 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAboutPage = () => {
|
const renderAboutPage = () => {
|
||||||
const aboutKeys = ['app_version', 'company_name', 'contact_email', 'contact_phone', 'company_address', 'system_description', 'privacy_policy', 'terms_of_service'];
|
const aboutKeys = ['app_version', 'company_name', 'contact_email', 'contact_phone', 'company_address', 'system_description', 'privacy_policy', 'terms_of_service'];
|
||||||
@@ -504,12 +346,6 @@ const SystemSettings = () => {
|
|||||||
>
|
>
|
||||||
{renderAppearanceSettings()}
|
{renderAppearanceSettings()}
|
||||||
</TabPane>
|
</TabPane>
|
||||||
<TabPane
|
|
||||||
tab={<span><DatabaseOutlined /> 数据备份</span>}
|
|
||||||
key="backup"
|
|
||||||
>
|
|
||||||
{renderBackupSettings()}
|
|
||||||
</TabPane>
|
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={<span><InfoCircleOutlined /> 关于</span>}
|
tab={<span><InfoCircleOutlined /> 关于</span>}
|
||||||
key="about"
|
key="about"
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// 安全地从 localStorage 获取用户信息
|
||||||
|
const getUserFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const userStr = localStorage.getItem('user');
|
||||||
|
return userStr ? JSON.parse(userStr) : {};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析用户信息失败:', e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -412,7 +423,7 @@ function TicketManagement() {
|
|||||||
await axios.put(`/api/tickets/${editingTicket.ticketId}`, ticketData);
|
await axios.put(`/api/tickets/${editingTicket.ticketId}`, ticketData);
|
||||||
message.success('工单更新成功');
|
message.success('工单更新成功');
|
||||||
} else {
|
} else {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
const user = getUserFromStorage();
|
||||||
ticketData.reporterId = user.userId || localStorage.getItem('userId') || 'USER001';
|
ticketData.reporterId = user.userId || localStorage.getItem('userId') || 'USER001';
|
||||||
ticketData.reporterName = user.username || '系统用户';
|
ticketData.reporterName = user.username || '系统用户';
|
||||||
await axios.post('/api/tickets', ticketData);
|
await axios.post('/api/tickets', ticketData);
|
||||||
@@ -457,10 +468,11 @@ function TicketManagement() {
|
|||||||
|
|
||||||
const handleProcessSubmit = useCallback(async (values) => {
|
const handleProcessSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
|
const user = getUserFromStorage();
|
||||||
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
|
await axios.put(`/api/tickets/${selectedTicket.ticketId}/process`, {
|
||||||
...values,
|
...values,
|
||||||
operatorId: localStorage.getItem('userId'),
|
operatorId: localStorage.getItem('userId'),
|
||||||
operatorName: JSON.parse(localStorage.getItem('user') || '{}').username
|
operatorName: user.username
|
||||||
});
|
});
|
||||||
message.success('工单处理完成');
|
message.success('工单处理完成');
|
||||||
setProcessingModalVisible(false);
|
setProcessingModalVisible(false);
|
||||||
@@ -473,10 +485,11 @@ function TicketManagement() {
|
|||||||
|
|
||||||
const handleStatusChange = useCallback(async (ticketId, newStatus) => {
|
const handleStatusChange = useCallback(async (ticketId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
|
const user = getUserFromStorage();
|
||||||
await axios.put(`/api/tickets/${ticketId}/status`, {
|
await axios.put(`/api/tickets/${ticketId}/status`, {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
operatorId: localStorage.getItem('userId'),
|
operatorId: localStorage.getItem('userId'),
|
||||||
operatorName: JSON.parse(localStorage.getItem('user') || '{}').username
|
operatorName: user.username
|
||||||
});
|
});
|
||||||
message.success('状态更新成功');
|
message.success('状态更新成功');
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 前端密码加密工具
|
||||||
|
* 注意:这只是增加一层保护,真正的安全需要 HTTPS
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 SHA-256 对密码进行哈希
|
||||||
|
* @param {string} password - 明文密码
|
||||||
|
* @returns {Promise<string>} - 返回十六进制哈希值
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为登录请求准备密码(双重哈希:SHA-256 + 服务器 bcrypt)
|
||||||
|
* @param {string} password - 明文密码
|
||||||
|
* @returns {Promise<string>} - 哈希后的密码
|
||||||
|
*/
|
||||||
|
export async function preparePassword(password) {
|
||||||
|
if (!password) return password;
|
||||||
|
return await hashPassword(password);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user