feat: 添加数据库索引优化查询性能
refactor(前端): 使用useMemo和useCallback优化性能 perf(后端): 优化统计查询性能 style: 统一前端样式定义 build: 添加创建索引脚本
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
### 项目专属规则
|
||||
项目初始化:React+TS 优先用 Vite 创建(npm create vite@latest),默认配置 ESLint+Prettier,锁定包管理器为 pnpm。
|
||||
目录规范:src 下分 api/(接口)、components/(通用组件)、pages/(页面)、assets/(静态资源)、utils/(工具函数),禁止多层嵌套。
|
||||
版本管理:用 nvm 锁定 Node.js LTS 版本(如 v20.10.0),package.json 标注依赖版本(如 react@18.2.0),提交前执行 pnpm lint 校验。分支管理:主分支 main/develop,功能分支 feature/xxx,修复分支 fix/xxx,合并前必须通过单元测试(vitest 覆盖率≥80%)。
|
||||
静态资源:public 目录仅存无需打包的静态文件,src/assets 资源 import 引入,大文件(>1MB)用懒加载+CDN 备选方案。
|
||||
构建部署:本地构建 npm run build,生产环境加 --mode production,输出目录 dist 适配 Nginx 部署,配置 gzip 压缩。
|
||||
文档要求:项目根目录必须有 README.md(含启动/部署步骤)、CHANGELOG.md(版本迭代记录),接口文档放 docs/api 目录。
|
||||
性能监控:接入 web-vitals 监测首屏加载/交互延迟,打包后用 bundle-analyzer 分析体积,按需拆分 chunk。
|
||||
环境配置:区分 .env.development/.env.production,敏感配置通过环境变量注入,禁止硬编码密钥。
|
||||
迭代规范:需求拆分按「原子化功能」,单分支迭代周期≤7天,提测前完成自测+代码评审,标注改动影响范围。
|
||||
@@ -0,0 +1,165 @@
|
||||
const { sequelize } = require('./db');
|
||||
|
||||
const createIndexes = async () => {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
console.log('开始创建数据库索引...');
|
||||
|
||||
try {
|
||||
// Device 表索引
|
||||
console.log('创建 devices 表索引...');
|
||||
await queryInterface.addIndex('devices', ['status']);
|
||||
await queryInterface.addIndex('devices', ['type']);
|
||||
await queryInterface.addIndex('devices', ['rackId']);
|
||||
await queryInterface.addIndex('devices', ['createdAt']);
|
||||
await queryInterface.addIndex('devices', ['status', 'type']);
|
||||
await queryInterface.addIndex('devices', ['name']);
|
||||
console.log(' ✓ devices 表索引创建完成');
|
||||
|
||||
// User 表索引
|
||||
console.log('创建 users 表索引...');
|
||||
await queryInterface.addIndex('users', ['status']);
|
||||
await queryInterface.addIndex('users', ['username']);
|
||||
await queryInterface.addIndex('users', ['email']);
|
||||
console.log(' ✓ users 表索引创建完成');
|
||||
|
||||
// Consumable 表索引
|
||||
console.log('创建 consumables 表索引...');
|
||||
await queryInterface.addIndex('consumables', ['category']);
|
||||
await queryInterface.addIndex('consumables', ['status']);
|
||||
await queryInterface.addIndex('consumables', ['category', 'status']);
|
||||
console.log(' ✓ consumables 表索引创建完成');
|
||||
|
||||
// ConsumableRecord 表索引
|
||||
console.log('创建 consumable_records 表索引...');
|
||||
await queryInterface.addIndex('consumable_records', ['consumableId']);
|
||||
await queryInterface.addIndex('consumable_records', ['type']);
|
||||
await queryInterface.addIndex('consumable_records', ['createdAt']);
|
||||
console.log(' ✓ consumable_records 表索引创建完成');
|
||||
|
||||
// ConsumableLog 表索引
|
||||
console.log('创建 consumable_logs 表索引...');
|
||||
await queryInterface.addIndex('consumable_logs', ['consumableId']);
|
||||
await queryInterface.addIndex('consumable_logs', ['operationType']);
|
||||
await queryInterface.addIndex('consumable_logs', ['createdAt']);
|
||||
await queryInterface.addIndex('consumable_logs', ['consumableId', 'createdAt']);
|
||||
console.log(' ✓ consumable_logs 表索引创建完成');
|
||||
|
||||
// OperationLog 表索引
|
||||
console.log('创建 operation_logs 表索引...');
|
||||
await queryInterface.addIndex('operation_logs', ['userId']);
|
||||
await queryInterface.addIndex('operation_logs', ['action']);
|
||||
await queryInterface.addIndex('operation_logs', ['module']);
|
||||
await queryInterface.addIndex('operation_logs', ['createdAt']);
|
||||
await queryInterface.addIndex('operation_logs', ['userId', 'createdAt']);
|
||||
console.log(' ✓ operation_logs 表索引创建完成');
|
||||
|
||||
// LoginHistory 表索引
|
||||
console.log('创建 login_histories 表索引...');
|
||||
await queryInterface.addIndex('login_histories', ['userId']);
|
||||
await queryInterface.addIndex('login_histories', ['loginTime']);
|
||||
await queryInterface.addIndex('login_histories', ['loginType']);
|
||||
await queryInterface.addIndex('login_histories', ['userId', 'loginTime']);
|
||||
console.log(' ✓ login_histories 表索引创建完成');
|
||||
|
||||
// Rack 表索引
|
||||
console.log('创建 racks 表索引...');
|
||||
await queryInterface.addIndex('racks', ['roomId']);
|
||||
await queryInterface.addIndex('racks', ['status']);
|
||||
await queryInterface.addIndex('racks', ['roomId', 'status']);
|
||||
console.log(' ✓ racks 表索引创建完成');
|
||||
|
||||
// Room 表索引
|
||||
console.log('创建 rooms 表索引...');
|
||||
await queryInterface.addIndex('rooms', ['status']);
|
||||
await queryInterface.addIndex('rooms', ['name']);
|
||||
console.log(' ✓ rooms 表索引创建完成');
|
||||
|
||||
console.log('\n✅ 所有索引创建完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建索引失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const checkIndexes = async () => {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
const tables = [
|
||||
'devices', 'users', 'consumables', 'consumable_records',
|
||||
'consumable_logs', 'operation_logs', 'login_histories',
|
||||
'racks', 'rooms'
|
||||
];
|
||||
|
||||
console.log('\n检查现有索引...');
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const indexes = await queryInterface.showIndex(table);
|
||||
console.log(`\n${table} 表索引:`);
|
||||
indexes.forEach(idx => {
|
||||
console.log(` - ${idx.name}: [${idx.fields.join(', ')}]`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(` ${table} 表检查失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dropIndexes = async () => {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
console.log('开始删除自定义索引...');
|
||||
|
||||
try {
|
||||
const indexDefinitions = [
|
||||
{ table: 'devices', indexes: ['status', 'type', 'rackId', 'createdAt', 'status_type', 'name'] },
|
||||
{ table: 'users', indexes: ['status', 'username', 'email'] },
|
||||
{ table: 'consumables', indexes: ['category', 'status', 'category_status'] },
|
||||
{ table: 'consumable_records', indexes: ['consumableId', 'type', 'createdAt'] },
|
||||
{ table: 'consumable_logs', indexes: ['consumableId', 'operationType', 'createdAt', 'consumableId_createdAt'] },
|
||||
{ table: 'operation_logs', indexes: ['userId', 'action', 'module', 'createdAt', 'userId_createdAt'] },
|
||||
{ table: 'login_histories', indexes: ['userId', 'loginTime', 'loginType', 'userId_loginTime'] },
|
||||
{ table: 'racks', indexes: ['roomId', 'status', 'roomId_status'] },
|
||||
{ table: 'rooms', indexes: ['status', 'name'] }
|
||||
];
|
||||
|
||||
for (const def of indexDefinitions) {
|
||||
console.log(`处理 ${def.table} 表...`);
|
||||
const existingIndexes = await queryInterface.showIndex(def.table);
|
||||
for (const existing of existingIndexes) {
|
||||
if (def.indexes.includes(existing.name)) {
|
||||
await queryInterface.removeIndex(def.table, existing.name);
|
||||
console.log(` ✓ 删除索引: ${existing.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 索引删除完成!');
|
||||
} catch (error) {
|
||||
console.error('删除索引失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { createIndexes, checkIndexes, dropIndexes };
|
||||
|
||||
if (require.main === module) {
|
||||
const command = process.argv[2] || 'create';
|
||||
|
||||
sequelize.authenticate()
|
||||
.then(async () => {
|
||||
console.log('数据库连接成功\n');
|
||||
if (command === 'check') {
|
||||
await checkIndexes();
|
||||
} else if (command === 'drop') {
|
||||
await dropIndexes();
|
||||
} else {
|
||||
await createIndexes();
|
||||
}
|
||||
await sequelize.close();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('数据库连接失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -57,7 +57,12 @@ const Consumable = sequelize.define('Consumable', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'consumables',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['category'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['category', 'status'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = Consumable;
|
||||
|
||||
@@ -57,7 +57,13 @@ const ConsumableLog = sequelize.define('ConsumableLog', {
|
||||
}, {
|
||||
tableName: 'consumable_logs',
|
||||
timestamps: true,
|
||||
comment: '耗材操作日志表'
|
||||
comment: '耗材操作日志表',
|
||||
indexes: [
|
||||
{ fields: ['consumableId'] },
|
||||
{ fields: ['operationType'] },
|
||||
{ fields: ['createdAt'] },
|
||||
{ fields: ['consumableId', 'createdAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
ConsumableLog.belongsTo(Consumable, {
|
||||
|
||||
@@ -49,7 +49,12 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'consumable_records',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['consumableId'] },
|
||||
{ fields: ['type'] },
|
||||
{ fields: ['createdAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
ConsumableRecord.belongsTo(Consumable, {
|
||||
|
||||
@@ -72,7 +72,15 @@ const Device = sequelize.define('Device', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'devices',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['type'] },
|
||||
{ fields: ['rackId'] },
|
||||
{ fields: ['createdAt'] },
|
||||
{ fields: ['status', 'type'] },
|
||||
{ fields: ['name'] }
|
||||
]
|
||||
});
|
||||
|
||||
// 关联关系
|
||||
|
||||
@@ -51,7 +51,13 @@ const LoginHistory = sequelize.define('LoginHistory', {
|
||||
tableName: 'login_histories',
|
||||
timestamps: true,
|
||||
createdAt: 'loginTime',
|
||||
updatedAt: false
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ fields: ['userId'] },
|
||||
{ fields: ['loginTime'] },
|
||||
{ fields: ['loginType'] },
|
||||
{ fields: ['userId', 'loginTime'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = LoginHistory;
|
||||
|
||||
@@ -76,7 +76,14 @@ const OperationLog = sequelize.define('OperationLog', {
|
||||
tableName: 'operation_logs',
|
||||
timestamps: true,
|
||||
createdAt: 'operateTime',
|
||||
updatedAt: false
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ fields: ['userId'] },
|
||||
{ fields: ['action'] },
|
||||
{ fields: ['module'] },
|
||||
{ fields: ['createdAt'] },
|
||||
{ fields: ['userId', 'createdAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = OperationLog;
|
||||
|
||||
@@ -40,7 +40,12 @@ const Rack = sequelize.define('Rack', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'racks',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['roomId'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['roomId', 'status'] }
|
||||
]
|
||||
});
|
||||
|
||||
// 关联关系
|
||||
|
||||
@@ -33,7 +33,11 @@ const Room = sequelize.define('Room', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'rooms',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['name'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = Room;
|
||||
@@ -57,7 +57,12 @@ const User = sequelize.define('User', {
|
||||
}
|
||||
}, {
|
||||
tableName: 'users',
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['username'] },
|
||||
{ fields: ['email'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = User;
|
||||
|
||||
Generated
+49
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"compression": "^1.7.5",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
@@ -2646,6 +2647,45 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -5771,6 +5811,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "IDC设备管理系统后端",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"create-indexes": "node create_indexes.js",
|
||||
"check-indexes": "node create_indexes.js check",
|
||||
"drop-indexes": "node create_indexes.js drop"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -124,40 +124,42 @@ router.post('/', async (req, res) => {
|
||||
router.get('/statistics', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
|
||||
const dateWhere = {};
|
||||
if (startDate && endDate) {
|
||||
dateWhere.createdAt = {
|
||||
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||
};
|
||||
}
|
||||
|
||||
const inCount = await ConsumableRecord.count({
|
||||
where: { ...dateWhere, type: 'in' }
|
||||
});
|
||||
|
||||
const outCount = await ConsumableRecord.count({
|
||||
where: { ...dateWhere, type: 'out' }
|
||||
});
|
||||
|
||||
const inQuantity = await ConsumableRecord.sum('quantity', {
|
||||
where: { ...dateWhere, type: 'in' }
|
||||
}) || 0;
|
||||
|
||||
const outQuantity = await ConsumableRecord.sum('quantity', {
|
||||
where: { ...dateWhere, type: 'out' }
|
||||
}) || 0;
|
||||
|
||||
const byType = await ConsumableRecord.findAll({
|
||||
|
||||
const records = await ConsumableRecord.findAll({
|
||||
where: dateWhere,
|
||||
attributes: [
|
||||
'type',
|
||||
[sequelize.fn('SUM', sequelize.col('quantity')), 'totalQuantity'],
|
||||
[sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['type']
|
||||
attributes: ['type', 'quantity']
|
||||
});
|
||||
|
||||
|
||||
let inCount = 0;
|
||||
let outCount = 0;
|
||||
let inQuantity = 0;
|
||||
let outQuantity = 0;
|
||||
const typeMap = {};
|
||||
|
||||
records.forEach(item => {
|
||||
const qty = parseFloat(item.quantity) || 0;
|
||||
if (item.type === 'in') {
|
||||
inCount++;
|
||||
inQuantity += qty;
|
||||
} else if (item.type === 'out') {
|
||||
outCount++;
|
||||
outQuantity += qty;
|
||||
}
|
||||
|
||||
if (!typeMap[item.type]) {
|
||||
typeMap[item.type] = { count: 0, totalQuantity: 0 };
|
||||
}
|
||||
typeMap[item.type].count++;
|
||||
typeMap[item.type].totalQuantity += qty;
|
||||
});
|
||||
|
||||
const recentRecords = await ConsumableRecord.findAll({
|
||||
where: dateWhere,
|
||||
include: [
|
||||
@@ -166,17 +168,17 @@ router.get('/statistics', async (req, res) => {
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 10
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
inCount,
|
||||
outCount,
|
||||
inQuantity,
|
||||
outQuantity,
|
||||
netQuantity: inQuantity - outQuantity,
|
||||
byType: byType.map(item => ({
|
||||
type: item.type,
|
||||
totalQuantity: item.dataValues.totalQuantity,
|
||||
count: item.dataValues.count
|
||||
byType: Object.entries(typeMap).map(([type, data]) => ({
|
||||
type,
|
||||
totalQuantity: data.totalQuantity,
|
||||
count: data.count
|
||||
})),
|
||||
recentRecords
|
||||
});
|
||||
|
||||
@@ -179,34 +179,43 @@ router.get('/low-stock', async (req, res) => {
|
||||
|
||||
router.get('/statistics/summary', async (req, res) => {
|
||||
try {
|
||||
const total = await Consumable.count();
|
||||
const lowStock = await Consumable.count({
|
||||
where: sequelize.where(sequelize.col('currentStock'), {
|
||||
[Op.lte]: sequelize.col('minStock')
|
||||
})
|
||||
});
|
||||
|
||||
const consumables = await Consumable.findAll({
|
||||
attributes: ['currentStock', 'unitPrice']
|
||||
attributes: ['currentStock', 'unitPrice', 'category']
|
||||
});
|
||||
|
||||
const totalValue = consumables.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item.currentStock) || 0) * (parseFloat(item.unitPrice) || 0);
|
||||
}, 0);
|
||||
|
||||
const byCategory = await Consumable.findAll({
|
||||
attributes: ['category', [sequelize.fn('COUNT', '*'), 'count']],
|
||||
group: ['category']
|
||||
|
||||
let total = consumables.length;
|
||||
let lowStock = 0;
|
||||
let totalValue = 0;
|
||||
const categoryMap = {};
|
||||
|
||||
consumables.forEach(item => {
|
||||
const currentStock = parseFloat(item.currentStock) || 0;
|
||||
const unitPrice = parseFloat(item.unitPrice) || 0;
|
||||
|
||||
totalValue += currentStock * unitPrice;
|
||||
|
||||
if (currentStock <= (parseFloat(item.minStock) || 0)) {
|
||||
lowStock++;
|
||||
}
|
||||
|
||||
if (item.category) {
|
||||
if (!categoryMap[item.category]) {
|
||||
categoryMap[item.category] = 0;
|
||||
}
|
||||
categoryMap[item.category]++;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const byCategory = Object.entries(categoryMap).map(([category, count]) => ({
|
||||
category,
|
||||
count
|
||||
}));
|
||||
|
||||
res.json({
|
||||
total,
|
||||
lowStock,
|
||||
totalValue: totalValue.toFixed(2),
|
||||
byCategory: byCategory.map(item => ({
|
||||
category: item.category,
|
||||
count: item.dataValues.count
|
||||
}))
|
||||
byCategory
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
||||
+53
-64
@@ -19,48 +19,60 @@ router.get('/stats', async (req, res) => {
|
||||
if (endDate) where.createdAt[Op.lte] = new Date(endDate + ' 23:59:59');
|
||||
}
|
||||
|
||||
const total = await Ticket.count({ where });
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
const statusStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'status',
|
||||
[require('sequelize').fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['status']
|
||||
});
|
||||
|
||||
const priorityStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'priority',
|
||||
[require('sequelize').fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['priority']
|
||||
});
|
||||
|
||||
const categoryStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'faultCategory',
|
||||
[require('sequelize').fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['faultCategory']
|
||||
});
|
||||
|
||||
const monthlyStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[dbDialect === 'mysql'
|
||||
? require('sequelize').fn('DATE_FORMAT', require('sequelize').col('createdAt'), '%Y-%m')
|
||||
: require('sequelize').fn('strftime', '%Y-%m', require('sequelize').col('createdAt')),
|
||||
'month'],
|
||||
[require('sequelize').fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['month'],
|
||||
order: [['month', 'DESC']],
|
||||
limit: 12
|
||||
});
|
||||
const [total, statusStats, priorityStats, categoryStats, monthlyStats, deviceStats, dailyStats] = await Promise.all([
|
||||
Ticket.count({ where }),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: ['status', [Sequelize.fn('COUNT', '*'), 'count']],
|
||||
group: ['status']
|
||||
}),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: ['priority', [Sequelize.fn('COUNT', '*'), 'count']],
|
||||
group: ['priority']
|
||||
}),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: ['faultCategory', [Sequelize.fn('COUNT', '*'), 'count']],
|
||||
group: ['faultCategory']
|
||||
}),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[dbDialect === 'mysql'
|
||||
? Sequelize.fn('DATE_FORMAT', Sequelize.col('createdAt'), '%Y-%m')
|
||||
: Sequelize.fn('strftime', '%Y-%m', Sequelize.col('createdAt')),
|
||||
'month'],
|
||||
[Sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['month'],
|
||||
order: [['month', 'DESC']],
|
||||
limit: 12
|
||||
}),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'deviceId',
|
||||
'deviceName',
|
||||
[Sequelize.fn('COUNT', '*'), 'count'],
|
||||
[Sequelize.fn('MAX', Sequelize.col('createdAt')), 'lastFaultTime']
|
||||
],
|
||||
group: ['deviceId', 'deviceName'],
|
||||
order: [[Sequelize.fn('COUNT', '*'), 'DESC']],
|
||||
limit: 10
|
||||
}),
|
||||
Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[Sequelize.fn('DATE', Sequelize.col('createdAt')), 'date'],
|
||||
[Sequelize.fn('COUNT', '*'), 'created']
|
||||
],
|
||||
group: ['date'],
|
||||
order: [['date', 'ASC']]
|
||||
})
|
||||
]);
|
||||
|
||||
const statusData = statusStats.map(s => s.dataValues);
|
||||
const pending = statusData.find(s => s.status === 'pending')?.count || 0;
|
||||
@@ -88,19 +100,6 @@ router.get('/stats', async (req, res) => {
|
||||
avgTime: 0
|
||||
}));
|
||||
|
||||
const deviceStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'deviceId',
|
||||
'deviceName',
|
||||
[require('sequelize').fn('COUNT', '*'), 'count'],
|
||||
[require('sequelize').fn('MAX', require('sequelize').col('createdAt')), 'lastFaultTime']
|
||||
],
|
||||
group: ['deviceId', 'deviceName'],
|
||||
order: [[require('sequelize').fn('COUNT', '*'), 'DESC']],
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const byDevice = deviceStats.map(d => ({
|
||||
deviceId: d.deviceId,
|
||||
deviceName: d.deviceName,
|
||||
@@ -109,16 +108,6 @@ router.get('/stats', async (req, res) => {
|
||||
deviceType: ''
|
||||
}));
|
||||
|
||||
const dailyStats = await Ticket.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[require('sequelize').fn('DATE', require('sequelize').col('createdAt')), 'date'],
|
||||
[require('sequelize').fn('COUNT', '*'), 'created']
|
||||
],
|
||||
group: ['date'],
|
||||
order: [['date', 'ASC']]
|
||||
});
|
||||
|
||||
const trend = dailyStats.map(d => ({
|
||||
date: d.dataValues.date,
|
||||
created: d.dataValues.created,
|
||||
|
||||
+28
-10
@@ -51,20 +51,38 @@ router.get('/', authMiddleware, async (req, res) => {
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
const userRoles = await UserRole.findAll({
|
||||
if (users.length > 0) {
|
||||
const userIds = users.map(u => u.userId);
|
||||
const allUserRoles = await UserRole.findAll({
|
||||
include: [{
|
||||
model: Role,
|
||||
where: { status: 'active' }
|
||||
where: { status: 'active' },
|
||||
attributes: ['roleId', 'roleName', 'roleCode']
|
||||
}],
|
||||
where: { UserId: user.userId }
|
||||
where: {
|
||||
UserId: { [Op.in]: userIds }
|
||||
}
|
||||
});
|
||||
|
||||
const userRolesMap = {};
|
||||
allUserRoles.forEach(ur => {
|
||||
if (!userRolesMap[ur.UserId]) {
|
||||
userRolesMap[ur.UserId] = [];
|
||||
}
|
||||
userRolesMap[ur.UserId].push({
|
||||
roleId: ur.Role.roleId,
|
||||
roleName: ur.Role.roleName,
|
||||
roleCode: ur.Role.roleCode
|
||||
});
|
||||
});
|
||||
|
||||
users.forEach(user => {
|
||||
user.dataValues.roles = userRolesMap[user.userId] || [];
|
||||
});
|
||||
} else {
|
||||
users.forEach(user => {
|
||||
user.dataValues.roles = [];
|
||||
});
|
||||
|
||||
user.dataValues.roles = userRoles.map(ur => ({
|
||||
roleId: ur.Role.roleId,
|
||||
roleName: ur.Role.roleName,
|
||||
roleCode: ur.Role.roleCode
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
+62
-203
@@ -27,6 +27,36 @@ const SystemSettings = lazy(() => import('./pages/SystemSettings'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const PageLoading = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AuthLoading = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const designTokens = {
|
||||
colors: {
|
||||
primary: {
|
||||
@@ -81,50 +111,22 @@ const PrivateRoute = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载认证状态...</span>
|
||||
</div>
|
||||
);
|
||||
return <AuthLoading />;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载页面...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
</Suspense>
|
||||
);
|
||||
return <AppLayout>{children}</AppLayout>;
|
||||
};
|
||||
|
||||
const ProtectedRoute = ({ component: Component }) => (
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
</PrivateRoute>
|
||||
);
|
||||
|
||||
const AppLayout = ({ children }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState('dashboard');
|
||||
@@ -464,173 +466,30 @@ const AppLayout = ({ children }) => {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<span style={{ color: '#8c8c8c', fontSize: '14px' }}>正在加载登录页面...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Login />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/devices"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/racks"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RackManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RoomManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<DeviceFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/visualization"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<RackVisualization />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-stats"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/consumables-logs"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ConsumableLogs />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<UserManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login-history"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<LoginHistory />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/operation-logs"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<OperationLogs />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tickets"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-categories"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketCategoryManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-statistics"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketStatistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ticket-fields"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TicketFieldManagement />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SystemSettings />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||
<Route path="/devices" element={<PrivateRoute><DeviceManagement /></PrivateRoute>} />
|
||||
<Route path="/racks" element={<PrivateRoute><RackManagement /></PrivateRoute>} />
|
||||
<Route path="/rooms" element={<PrivateRoute><RoomManagement /></PrivateRoute>} />
|
||||
<Route path="/fields" element={<PrivateRoute><DeviceFieldManagement /></PrivateRoute>} />
|
||||
<Route path="/visualization" element={<PrivateRoute><RackVisualization /></PrivateRoute>} />
|
||||
<Route path="/consumables" element={<PrivateRoute><ConsumableManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-categories" element={<PrivateRoute><CategoryManagement /></PrivateRoute>} />
|
||||
<Route path="/consumables-stats" element={<PrivateRoute><ConsumableStatistics /></PrivateRoute>} />
|
||||
<Route path="/consumables-logs" element={<PrivateRoute><ConsumableLogs /></PrivateRoute>} />
|
||||
<Route path="/users" element={<PrivateRoute><UserManagement /></PrivateRoute>} />
|
||||
<Route path="/login-history" element={<PrivateRoute><LoginHistory /></PrivateRoute>} />
|
||||
<Route path="/operation-logs" element={<PrivateRoute><OperationLogs /></PrivateRoute>} />
|
||||
<Route path="/tickets" element={<PrivateRoute><TicketManagement /></PrivateRoute>} />
|
||||
<Route path="/ticket-categories" element={<PrivateRoute><TicketCategoryManagement /></PrivateRoute>} />
|
||||
<Route path="/ticket-statistics" element={<PrivateRoute><TicketStatistics /></PrivateRoute>} />
|
||||
<Route path="/ticket-fields" element={<PrivateRoute><TicketFieldManagement /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute><SystemSettings /></PrivateRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,65 @@ const subtitleStyle = {
|
||||
margin: '0'
|
||||
};
|
||||
|
||||
const statCardStyle = (color) => ({
|
||||
const progressCardStyle = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
background: '#fff',
|
||||
height: '100%',
|
||||
animation: 'fadeInUp 0.6s ease-out 0.3s backwards'
|
||||
};
|
||||
|
||||
const chartContainerStyle = {
|
||||
padding: '20px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: 'linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%)',
|
||||
border: '1px solid #f0f0f0'
|
||||
};
|
||||
|
||||
const PIE_CHART_COLORS = {
|
||||
success: designTokens.colors.success.main,
|
||||
warning: designTokens.colors.warning.main,
|
||||
error: designTokens.colors.error.main,
|
||||
primary: designTokens.colors.primary.main
|
||||
};
|
||||
|
||||
const pieChartStyle = {
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
borderRadius: '50%',
|
||||
background: `conic-gradient(
|
||||
${PIE_CHART_COLORS.success} 0deg 216deg,
|
||||
${PIE_CHART_COLORS.warning} 216deg 288deg,
|
||||
${PIE_CHART_COLORS.error} 288deg 324deg,
|
||||
${PIE_CHART_COLORS.primary} 324deg 360deg
|
||||
)`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
};
|
||||
|
||||
const pieChartInner = {
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const trendItemStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
};
|
||||
|
||||
const STAT_CARD_BASE_STYLE = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
@@ -145,9 +203,9 @@ const statCardStyle = (color) => ({
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
animation: 'fadeInUp 0.6s ease-out backwards'
|
||||
});
|
||||
};
|
||||
|
||||
const statIconContainer = (color) => ({
|
||||
const STAT_ICON_CONTAINER_BASE = {
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
@@ -157,19 +215,68 @@ const statIconContainer = (color) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`,
|
||||
fontSize: '32px',
|
||||
transition: `all ${designTokens.transitions.normal}`
|
||||
});
|
||||
};
|
||||
|
||||
const topBorderStyle = (color) => ({
|
||||
const TOP_BORDER_BASE = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: `linear-gradient(90deg, ${color}, ${color}80)`,
|
||||
borderRadius: `${designTokens.borderRadius.large} ${designTokens.borderRadius.large} 0 0`
|
||||
};
|
||||
|
||||
const NAV_BUTTON_BASE = {
|
||||
height: 'auto',
|
||||
padding: '24px 20px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
border: '2px solid #f0f0f0',
|
||||
background: '#fff',
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: designTokens.shadows.small
|
||||
};
|
||||
|
||||
const NAV_ICON_CONTAINER_BASE = {
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '28px',
|
||||
transition: `all ${designTokens.transitions.normal}`
|
||||
};
|
||||
|
||||
const createStatCardStyle = (color) => ({
|
||||
...STAT_CARD_BASE_STYLE,
|
||||
borderTop: `4px solid ${color}`
|
||||
});
|
||||
|
||||
const createStatIconContainer = (color) => ({
|
||||
...STAT_ICON_CONTAINER_BASE,
|
||||
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`
|
||||
});
|
||||
|
||||
const createTopBorderStyle = (color) => ({
|
||||
...TOP_BORDER_BASE,
|
||||
background: `linear-gradient(90deg, ${color}, ${color}80)`
|
||||
});
|
||||
|
||||
const createNavButtonStyle = (color) => ({
|
||||
...NAV_BUTTON_BASE,
|
||||
borderColor: color
|
||||
});
|
||||
|
||||
const createNavIconContainer = (color) => ({
|
||||
...NAV_ICON_CONTAINER_BASE,
|
||||
background: `linear-gradient(135deg, ${color}20 0%, ${color}10 100%)`
|
||||
});
|
||||
|
||||
const overviewCardStyle = {
|
||||
@@ -245,57 +352,6 @@ const quickStatItemStyle = {
|
||||
boxShadow: designTokens.shadows.small
|
||||
};
|
||||
|
||||
const progressCardStyle = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
border: 'none',
|
||||
boxShadow: designTokens.shadows.medium,
|
||||
background: '#fff',
|
||||
height: '100%',
|
||||
animation: 'fadeInUp 0.6s ease-out 0.3s backwards'
|
||||
};
|
||||
|
||||
const chartContainerStyle = {
|
||||
padding: '20px',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
background: 'linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%)',
|
||||
border: '1px solid #f0f0f0'
|
||||
};
|
||||
|
||||
const pieChartStyle = {
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
borderRadius: '50%',
|
||||
background: `conic-gradient(
|
||||
${designTokens.colors.success.main} 0deg 216deg,
|
||||
${designTokens.colors.warning.main} 216deg 288deg,
|
||||
${designTokens.colors.error.main} 288deg 324deg,
|
||||
${designTokens.colors.primary.main} 324deg 360deg
|
||||
)`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
};
|
||||
|
||||
const pieChartInner = {
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const trendItemStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
};
|
||||
|
||||
const AnimatedCounter = ({ value, duration = 1500 }) => {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
const animationRef = useRef(null);
|
||||
@@ -680,7 +736,7 @@ function Dashboard() {
|
||||
const { icon: Icon, color, statKey, title, trend, tagColor, customStatus, xs, sm, lg, xl, delay } = config;
|
||||
const colProps = { xs, sm, lg, xl };
|
||||
const cardStyle = {
|
||||
...statCardStyle(color),
|
||||
...createStatCardStyle(color),
|
||||
...(hoveredCard === statKey ? { transform: 'translateY(-6px)', boxShadow: designTokens.shadows.hover } : {}),
|
||||
animationDelay: `${delay * 0.1}s`
|
||||
};
|
||||
@@ -693,8 +749,8 @@ function Dashboard() {
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={topBorderStyle(color)} />
|
||||
<div style={statIconContainer(color)}>
|
||||
<div style={createTopBorderStyle(color)} />
|
||||
<div style={createStatIconContainer(color)}>
|
||||
<Icon style={{ color }} />
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -770,7 +826,7 @@ function Dashboard() {
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
...navButtonStyle(color),
|
||||
...createNavButtonStyle(color),
|
||||
...(hoveredCard === `nav-${key}` ? {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: designTokens.shadows.large,
|
||||
@@ -781,7 +837,7 @@ function Dashboard() {
|
||||
onMouseEnter={(e) => handleNavHover(e, true, `nav-${key}`)}
|
||||
onMouseLeave={(e) => handleNavHover(e, false, `nav-${key}`)}
|
||||
>
|
||||
<div style={navIconContainer(color)}>
|
||||
<div style={createNavIconContainer(color)}>
|
||||
<Icon style={{ color, fontSize: '28px' }} />
|
||||
</div>
|
||||
<span style={navTextStyle}>{text}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, message, Card, Space, InputNumber, Switch, Tag, Statistic } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FontSizeOutlined, NumberOutlined, CheckCircleOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
@@ -126,6 +126,92 @@ const tableStyle = {
|
||||
background: designTokens.colors.background.primary
|
||||
};
|
||||
|
||||
const titleIconStyle = {
|
||||
color: designTokens.colors.primary.main
|
||||
};
|
||||
|
||||
const modalTitleStyle = {
|
||||
fontWeight: '600'
|
||||
};
|
||||
|
||||
const formLabelStyle = {
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const tableCellStyle = {
|
||||
fontWeight: '500',
|
||||
color: designTokens.colors.text.primary
|
||||
};
|
||||
|
||||
const typeTagStyle = {
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const orderBadgeStyle = {
|
||||
background: designTokens.colors.background.tertiary,
|
||||
padding: '2px 8px',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
};
|
||||
|
||||
const editButtonStyle = {
|
||||
color: designTokens.colors.primary.main,
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
};
|
||||
|
||||
const deleteButtonStyle = {
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
};
|
||||
|
||||
const formRowStyle = {
|
||||
display: 'flex',
|
||||
gap: designTokens.spacing.md
|
||||
};
|
||||
|
||||
const formItemFlexStyle = {
|
||||
flex: 1
|
||||
};
|
||||
|
||||
const textAreaStyle = {
|
||||
fontFamily: 'monospace'
|
||||
};
|
||||
|
||||
const modalBodyStyle = {
|
||||
padding: designTokens.spacing.lg
|
||||
};
|
||||
|
||||
const formActionsStyle = {
|
||||
marginBottom: 0,
|
||||
textAlign: 'right'
|
||||
};
|
||||
|
||||
const modalStyle = {
|
||||
borderRadius: designTokens.borderRadius.large
|
||||
};
|
||||
|
||||
const FIELD_TYPE_MAP = {
|
||||
string: { text: '文本', color: designTokens.colors.fieldType.string },
|
||||
number: { text: '数字', color: designTokens.colors.fieldType.number },
|
||||
boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
|
||||
select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
|
||||
date: { text: '日期', color: designTokens.colors.fieldType.date },
|
||||
textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea }
|
||||
};
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔值' },
|
||||
{ value: 'select', label: '下拉选择' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'textarea', label: '多行文本' }
|
||||
];
|
||||
|
||||
function DeviceFieldManagement() {
|
||||
const [fields, setFields] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -225,17 +311,13 @@ function DeviceFieldManagement() {
|
||||
return iconMap[type] || <FontSizeOutlined />;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '字段名称',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 150,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: '500', color: designTokens.colors.text.primary }}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
render: (text) => <span style={tableCellStyle}>{text}</span>
|
||||
},
|
||||
{
|
||||
title: '显示名称',
|
||||
@@ -249,25 +331,9 @@ function DeviceFieldManagement() {
|
||||
key: 'fieldType',
|
||||
width: 110,
|
||||
render: (type) => {
|
||||
const typeMap = {
|
||||
string: { text: '文本', color: designTokens.colors.fieldType.string },
|
||||
number: { text: '数字', color: designTokens.colors.fieldType.number },
|
||||
boolean: { text: '布尔值', color: designTokens.colors.fieldType.boolean },
|
||||
select: { text: '下拉选择', color: designTokens.colors.fieldType.select },
|
||||
date: { text: '日期', color: designTokens.colors.fieldType.date },
|
||||
textarea: { text: '多行文本', color: designTokens.colors.fieldType.textarea }
|
||||
};
|
||||
const config = typeMap[type] || { text: type, color: designTokens.colors.text.tertiary };
|
||||
const config = FIELD_TYPE_MAP[type] || { text: type, color: designTokens.colors.text.tertiary };
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
background: `${config.color}15`,
|
||||
color: config.color,
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
<Tag style={{ ...typeTagStyle, background: `${config.color}15`, color: config.color }}>
|
||||
{getFieldTypeIcon(type)}
|
||||
<span style={{ marginLeft: '4px' }}>{config.text}</span>
|
||||
</Tag>
|
||||
@@ -282,7 +348,7 @@ function DeviceFieldManagement() {
|
||||
render: (required) => (
|
||||
<span style={{
|
||||
color: required ? designTokens.colors.success.main : designTokens.colors.text.tertiary,
|
||||
fontWeight: '500'
|
||||
...tableCellStyle
|
||||
}}>
|
||||
{required ? '是' : '否'}
|
||||
</span>
|
||||
@@ -296,7 +362,7 @@ function DeviceFieldManagement() {
|
||||
render: (visible) => (
|
||||
<span style={{
|
||||
color: visible ? designTokens.colors.primary.main : designTokens.colors.text.tertiary,
|
||||
fontWeight: '500'
|
||||
...tableCellStyle
|
||||
}}>
|
||||
{visible ? '是' : '否'}
|
||||
</span>
|
||||
@@ -307,17 +373,7 @@ function DeviceFieldManagement() {
|
||||
dataIndex: 'order',
|
||||
key: 'order',
|
||||
width: 80,
|
||||
render: (order) => (
|
||||
<span style={{
|
||||
background: designTokens.colors.background.tertiary,
|
||||
padding: `2px ${designTokens.spacing.sm}`,
|
||||
borderRadius: designTokens.borderRadius.small,
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{order}
|
||||
</span>
|
||||
)
|
||||
render: (order) => <span style={orderBadgeStyle}>{order}</span>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -326,48 +382,25 @@ function DeviceFieldManagement() {
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => showModal(record)}
|
||||
style={{
|
||||
color: designTokens.colors.primary.main,
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => showModal(record)} style={editButtonStyle}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.fieldId)}
|
||||
style={{
|
||||
height: '28px',
|
||||
padding: '0 8px'
|
||||
}}
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.fieldId)} style={deleteButtonStyle}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div style={pageContainerStyle}>
|
||||
<div style={titleRowStyle}>
|
||||
<div style={titleStyle}>
|
||||
<AppstoreOutlined style={{ color: designTokens.colors.primary.main }} />
|
||||
<AppstoreOutlined style={titleIconStyle} />
|
||||
设备字段管理
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => showModal()}
|
||||
style={primaryActionStyle}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()} style={primaryActionStyle}>
|
||||
添加字段
|
||||
</Button>
|
||||
</div>
|
||||
@@ -390,30 +423,18 @@ function DeviceFieldManagement() {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<span style={{ fontWeight: '600' }}>
|
||||
{editingField ? '编辑字段' : '添加字段'}
|
||||
</span>
|
||||
}
|
||||
title={<span style={modalTitleStyle}>{editingField ? '编辑字段' : '添加字段'}</span>}
|
||||
open={modalVisible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
styles={{
|
||||
body: { padding: designTokens.spacing.lg }
|
||||
}}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.large
|
||||
}}
|
||||
styles={{ body: modalBodyStyle }}
|
||||
style={modalStyle}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="fieldName"
|
||||
label={<span style={{ fontWeight: '500' }}>字段名称</span>}
|
||||
label={<span style={formLabelStyle}>字段名称</span>}
|
||||
rules={[{ required: true, message: '请输入字段名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入字段名称(英文,如:deviceId)" />
|
||||
@@ -421,7 +442,7 @@ function DeviceFieldManagement() {
|
||||
|
||||
<Form.Item
|
||||
name="displayName"
|
||||
label={<span style={{ fontWeight: '500' }}>显示名称</span>}
|
||||
label={<span style={formLabelStyle}>显示名称</span>}
|
||||
rules={[{ required: true, message: '请输入显示名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入显示名称(中文,如:设备ID)" />
|
||||
@@ -429,34 +450,31 @@ function DeviceFieldManagement() {
|
||||
|
||||
<Form.Item
|
||||
name="fieldType"
|
||||
label={<span style={{ fontWeight: '500' }}>字段类型</span>}
|
||||
label={<span style={formLabelStyle}>字段类型</span>}
|
||||
rules={[{ required: true, message: '请选择字段类型' }]}
|
||||
>
|
||||
<Select placeholder="请选择字段类型">
|
||||
<Option value="string">文本</Option>
|
||||
<Option value="number">数字</Option>
|
||||
<Option value="boolean">布尔值</Option>
|
||||
<Option value="select">下拉选择</Option>
|
||||
<Option value="date">日期</Option>
|
||||
<Option value="textarea">多行文本</Option>
|
||||
{FIELD_TYPE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: designTokens.spacing.md }}>
|
||||
<div style={formRowStyle}>
|
||||
<Form.Item
|
||||
name="required"
|
||||
label={<span style={{ fontWeight: '500' }}>必填</span>}
|
||||
label={<span style={formLabelStyle}>必填</span>}
|
||||
valuePropName="checked"
|
||||
style={{ flex: 1 }}
|
||||
style={formItemFlexStyle}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="visible"
|
||||
label={<span style={{ fontWeight: '500' }}>可见</span>}
|
||||
label={<span style={formLabelStyle}>可见</span>}
|
||||
valuePropName="checked"
|
||||
style={{ flex: 1 }}
|
||||
style={formItemFlexStyle}
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
@@ -464,7 +482,7 @@ function DeviceFieldManagement() {
|
||||
|
||||
<Form.Item
|
||||
name="order"
|
||||
label={<span style={{ fontWeight: '500' }}>显示顺序</span>}
|
||||
label={<span style={formLabelStyle}>显示顺序</span>}
|
||||
rules={[{ required: true, message: '请输入显示顺序' }]}
|
||||
>
|
||||
<InputNumber placeholder="请输入显示顺序" min={0} style={{ width: '100%' }} />
|
||||
@@ -472,17 +490,13 @@ function DeviceFieldManagement() {
|
||||
|
||||
<Form.Item
|
||||
name="options"
|
||||
label={<span style={{ fontWeight: '500' }}>选项配置(JSON格式)</span>}
|
||||
label={<span style={formLabelStyle}>选项配置(JSON格式)</span>}
|
||||
tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请输入JSON格式的选项配置,使用单引号"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Input.TextArea rows={3} placeholder="请输入JSON格式的选项配置,使用单引号" style={textAreaStyle} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<Form.Item style={formActionsStyle}>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">确定</Button>
|
||||
|
||||
@@ -312,174 +312,51 @@ function DeviceManagement() {
|
||||
// 列宽状态
|
||||
const [columnWidths, setColumnWidths] = useState({});
|
||||
|
||||
// 缓存用于搜索的数据(避免重复处理)
|
||||
const devicesCacheRef = useRef({
|
||||
timestamp: 0,
|
||||
data: null,
|
||||
TTL: 5 * 60 * 1000 // 缓存5分钟
|
||||
});
|
||||
|
||||
// 防抖搜索关键词
|
||||
const debouncedKeyword = useDebounce(keyword, 300);
|
||||
|
||||
// 预计算所有设备的搜索索引(提升搜索性能)
|
||||
const searchIndexRef = useRef(new Map());
|
||||
// 使用 useMemo 缓存筛选后的设备数据(现在直接使用 allDevices,因为后端已经处理了筛选)
|
||||
const filteredDevicesMemo = useMemo(() => {
|
||||
return allDevices;
|
||||
}, [allDevices]);
|
||||
|
||||
// 构建设备搜索索引
|
||||
const buildSearchIndex = useCallback((devices) => {
|
||||
const index = new Map();
|
||||
devices.forEach((device, idx) => {
|
||||
const searchableValues = [];
|
||||
|
||||
// 收集所有基本类型字段值
|
||||
Object.entries(device).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return;
|
||||
if (typeof value === 'object') {
|
||||
// 收集嵌套对象值
|
||||
if (device.Rack?.name) searchableValues.push(String(device.Rack.name).toLowerCase());
|
||||
if (device.Rack?.Room?.name) searchableValues.push(String(device.Rack.Room.name).toLowerCase());
|
||||
// 收集自定义字段值
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.values(device.customFields).forEach(cfValue => {
|
||||
if (cfValue !== null && cfValue !== undefined && typeof cfValue !== 'object') {
|
||||
searchableValues.push(String(cfValue).toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
searchableValues.push(String(value).toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
index.set(idx, searchableValues);
|
||||
});
|
||||
return index;
|
||||
}, []);
|
||||
|
||||
// 优化的全字段搜索函数
|
||||
const searchDevices = useCallback((devices, keyword) => {
|
||||
if (!keyword || !keyword.trim()) {
|
||||
return devices;
|
||||
}
|
||||
|
||||
const searchTerm = keyword.toLowerCase().trim();
|
||||
|
||||
return devices.filter((device, idx) => {
|
||||
// 使用预计算的搜索索引
|
||||
let searchableValues = searchIndexRef.current.get(idx);
|
||||
|
||||
if (!searchableValues) {
|
||||
// 如果没有预计算索引,当场计算并缓存
|
||||
searchableValues = [];
|
||||
Object.entries(device).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return;
|
||||
if (typeof value === 'object') {
|
||||
if (device.Rack?.name) searchableValues.push(String(device.Rack.name).toLowerCase());
|
||||
if (device.Rack?.Room?.name) searchableValues.push(String(device.Rack.Room.name).toLowerCase());
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.values(device.customFields).forEach(cfValue => {
|
||||
if (cfValue !== null && cfValue !== undefined && typeof cfValue !== 'object') {
|
||||
searchableValues.push(String(cfValue).toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
searchableValues.push(String(value).toLowerCase());
|
||||
}
|
||||
});
|
||||
searchIndexRef.current.set(idx, searchableValues);
|
||||
}
|
||||
|
||||
return searchableValues.some(value => value.includes(searchTerm));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 获取所有设备数据(不分页,用于本地搜索)- 使用缓存
|
||||
const fetchAllDevices = useCallback(async (forceRefresh = false) => {
|
||||
const now = Date.now();
|
||||
const cache = devicesCacheRef.current;
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (!forceRefresh && cache.data && (now - cache.timestamp) < cache.TTL) {
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
// 获取所有设备(支持搜索、筛选和分页)
|
||||
const fetchDevices = useCallback(async (page = 1, pageSize = 10, forceRefresh = false) => {
|
||||
try {
|
||||
const response = await axios.get('/api/devices', {
|
||||
params: { page: 1, pageSize: 99999 }
|
||||
});
|
||||
const { devices } = response.data;
|
||||
setLoading(true);
|
||||
|
||||
// 将customFields中的字段值映射为设备对象的直接属性
|
||||
// 使用后端分页加载
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: debouncedKeyword || undefined,
|
||||
status: status !== 'all' ? status : undefined,
|
||||
type: type !== 'all' ? type : undefined
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/devices', { params });
|
||||
const { devices, total } = response.data;
|
||||
|
||||
// 处理设备数据,展开自定义字段
|
||||
const processedDevices = devices.map(device => {
|
||||
const deviceWithFields = { ...device };
|
||||
|
||||
// 如果有自定义字段,将其展开为设备对象的直接属性
|
||||
if (device.customFields && typeof device.customFields === 'object') {
|
||||
Object.entries(device.customFields).forEach(([fieldName, value]) => {
|
||||
deviceWithFields[fieldName] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return deviceWithFields;
|
||||
});
|
||||
|
||||
// 更新缓存
|
||||
cache.data = processedDevices;
|
||||
cache.timestamp = now;
|
||||
|
||||
// 预计算搜索索引
|
||||
searchIndexRef.current = buildSearchIndex(processedDevices);
|
||||
|
||||
return processedDevices;
|
||||
} catch (error) {
|
||||
console.error('获取所有设备数据失败:', error);
|
||||
return cache.data || [];
|
||||
}
|
||||
}, [buildSearchIndex]);
|
||||
|
||||
// 使用 useMemo 缓存筛选后的设备数据
|
||||
const filteredDevicesMemo = useMemo(() => {
|
||||
if (!allDevices.length) return [];
|
||||
|
||||
let result = allDevices;
|
||||
|
||||
// 状态筛选
|
||||
if (status && status !== 'all') {
|
||||
result = result.filter(device => device.status === status);
|
||||
}
|
||||
|
||||
// 类型筛选
|
||||
if (type && type !== 'all') {
|
||||
result = result.filter(device => device.type === type);
|
||||
}
|
||||
|
||||
// 关键词搜索(使用防抖后的关键词)
|
||||
if (debouncedKeyword && debouncedKeyword.trim()) {
|
||||
result = searchDevices(result, debouncedKeyword);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allDevices, status, type, debouncedKeyword, searchDevices]);
|
||||
|
||||
// 获取所有设备(支持搜索、筛选和分页)- 使用缓存和useCallback
|
||||
const fetchDevices = useCallback(async (page = 1, pageSize = 10, forceRefresh = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 先获取所有设备数据(使用缓存,批量删除后强制刷新)
|
||||
const allData = await fetchAllDevices(forceRefresh);
|
||||
setAllDevices(allData);
|
||||
|
||||
// 更新分页信息(筛选后的数据会通过useMemo自动更新)
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize, total: filteredDevicesMemo.length }));
|
||||
setAllDevices(processedDevices);
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize, total }));
|
||||
} catch (error) {
|
||||
message.error('获取设备列表失败');
|
||||
console.error('获取设备列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchAllDevices, filteredDevicesMemo.length]);
|
||||
}, [debouncedKeyword, status, type]);
|
||||
|
||||
// 获取设备字段配置
|
||||
const fetchDeviceFields = async () => {
|
||||
@@ -533,10 +410,10 @@ function DeviceManagement() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
fetchDevices(1, pagination.pageSize);
|
||||
fetchRacks();
|
||||
fetchDeviceFields();
|
||||
}, []);
|
||||
}, [fetchDevices]);
|
||||
|
||||
// 同步当前页设备数据
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, Table, Tag, Space, Button, DatePicker, Select, message, Popconfirm, Typography, Descriptions } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, EyeOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { loginHistoryAPI } from '../api';
|
||||
@@ -17,7 +17,7 @@ const LoginHistory = () => {
|
||||
fetchHistories();
|
||||
}, [pagination.current, filters]);
|
||||
|
||||
const fetchHistories = async () => {
|
||||
const fetchHistories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
@@ -35,14 +35,14 @@ const LoginHistory = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [pagination.current, pagination.pageSize, filters]);
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const handleFilterChange = useCallback((key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDateChange = (dates) => {
|
||||
const handleDateChange = useCallback((dates) => {
|
||||
if (dates) {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
@@ -53,9 +53,9 @@ const LoginHistory = () => {
|
||||
setFilters(prev => ({ ...prev, startDate: undefined, endDate: undefined }));
|
||||
}
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClear = async () => {
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
const response = await loginHistoryAPI.clear({ days: 30 });
|
||||
if (response.success) {
|
||||
@@ -65,9 +65,9 @@ const LoginHistory = () => {
|
||||
} catch (error) {
|
||||
message.error('清理失败');
|
||||
}
|
||||
};
|
||||
}, [fetchHistories]);
|
||||
|
||||
const columns = [
|
||||
const tableColumns = useMemo(() => [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
@@ -128,7 +128,7 @@ const LoginHistory = () => {
|
||||
return browser;
|
||||
}
|
||||
}
|
||||
];
|
||||
], []);
|
||||
|
||||
const pageHeaderStyle = {
|
||||
marginBottom: '24px',
|
||||
@@ -172,7 +172,7 @@ const LoginHistory = () => {
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={tableColumns}
|
||||
dataSource={histories}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
|
||||
@@ -76,13 +76,13 @@ const headerStyle = {
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)'
|
||||
};
|
||||
|
||||
const statCardStyle = () => ({
|
||||
const statCardStyle = {
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
borderRadius: designTokens.borderRadius.medium,
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
});
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
borderRadius: designTokens.borderRadius.large,
|
||||
@@ -654,19 +654,19 @@ function RackManagement() {
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div style={statCardStyle()}>
|
||||
<div style={statCardStyle}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>总机柜</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>{stats.total}</div>
|
||||
</div>
|
||||
<div style={statCardStyle()}>
|
||||
<div style={statCardStyle}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>在用机柜</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#52c41a' }}>{stats.active}</div>
|
||||
</div>
|
||||
<div style={statCardStyle()}>
|
||||
<div style={statCardStyle}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>设备总数</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>{stats.totalDevices}</div>
|
||||
</div>
|
||||
<div style={statCardStyle()}>
|
||||
<div style={statCardStyle}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '12px' }}>总功率</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700' }}>{(stats.totalPower / 1000).toFixed(1)}kW</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, DatePicker, message, Card, Space, Tag, Dropdown, Menu, Tabs, Timeline, Descriptions, Checkbox, Popover, InputNumber, Switch } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, EyeOutlined, MoreOutlined, UserOutlined, ToolOutlined, CheckCircleOutlined, SyncOutlined, ClockCircleOutlined, CloseCircleOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
@@ -233,7 +233,7 @@ function TicketManagement() {
|
||||
);
|
||||
}, [devices]);
|
||||
|
||||
const getTableColumns = useCallback(() => {
|
||||
const tableColumns = useMemo(() => {
|
||||
const baseColumns = [
|
||||
{ title: '工单编号', dataIndex: 'ticketId', key: 'ticketId', width: 150, fixed: 'left' },
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', width: 200, ellipsis: true },
|
||||
@@ -609,7 +609,7 @@ function TicketManagement() {
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
columns={getTableColumns()}
|
||||
columns={tableColumns}
|
||||
dataSource={tickets}
|
||||
rowKey="ticketId"
|
||||
pagination={pagination}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Card, Table, Button, Space, Modal, Form, Input, Select, message, Tag, Popconfirm, Avatar, Tooltip, Badge } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, UserOutlined, ReloadOutlined, LockOutlined, CameraOutlined } from '@ant-design/icons';
|
||||
import { userAPI, roleAPI } from '../api';
|
||||
@@ -26,7 +26,7 @@ const UserManagement = () => {
|
||||
fetchRoles();
|
||||
}, [pagination.current]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await userAPI.list({
|
||||
@@ -42,9 +42,9 @@ const UserManagement = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [pagination.current, pagination.pageSize]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const response = await roleAPI.all();
|
||||
if (response.success) {
|
||||
@@ -56,15 +56,15 @@ const UserManagement = () => {
|
||||
console.error('获取角色列表失败:', error);
|
||||
message.error('获取角色列表失败,请检查网络连接');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEdit = (user) => {
|
||||
const handleEdit = useCallback((user) => {
|
||||
setEditingUser(user);
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
@@ -75,20 +75,20 @@ const UserManagement = () => {
|
||||
roleIds: user.roles?.map(r => r.roleId) || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleResetPassword = (user) => {
|
||||
const handleResetPassword = useCallback((user) => {
|
||||
setPasswordUser(user);
|
||||
passwordForm.resetFields();
|
||||
setPasswordModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAvatarClick = (user) => {
|
||||
const handleAvatarClick = useCallback((user) => {
|
||||
setAvatarUser(user);
|
||||
setAvatarModalVisible(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const handleAvatarUpload = useCallback(async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -120,9 +120,9 @@ const UserManagement = () => {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [avatarUser, fetchUsers]);
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
const handleAvatarDelete = useCallback(async () => {
|
||||
try {
|
||||
const response = await userAPI.deleteAvatar(avatarUser.userId);
|
||||
if (response.success) {
|
||||
@@ -135,9 +135,9 @@ const UserManagement = () => {
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
}, [avatarUser, fetchUsers]);
|
||||
|
||||
const handleDelete = async (userId) => {
|
||||
const handleDelete = useCallback(async (userId) => {
|
||||
try {
|
||||
const response = await userAPI.delete(userId);
|
||||
if (response.success) {
|
||||
@@ -149,9 +149,9 @@ const UserManagement = () => {
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const handleSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
let response;
|
||||
if (editingUser) {
|
||||
@@ -170,9 +170,9 @@ const UserManagement = () => {
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
}, [editingUser, fetchUsers]);
|
||||
|
||||
const handleResetPasswordSubmit = async (values) => {
|
||||
const handleResetPasswordSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
const response = await userAPI.resetPassword(passwordUser.userId, values);
|
||||
if (response.success) {
|
||||
@@ -184,7 +184,7 @@ const UserManagement = () => {
|
||||
} catch (error) {
|
||||
message.error('重置失败');
|
||||
}
|
||||
};
|
||||
}, [passwordUser]);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
@@ -209,7 +209,7 @@ const UserManagement = () => {
|
||||
return user.avatar;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const tableColumns = useMemo(() => [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
@@ -317,7 +317,7 @@ const UserManagement = () => {
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
], [handleAvatarClick, handleEdit, handleResetPassword, handleDelete]);
|
||||
|
||||
const pageHeaderStyle = {
|
||||
marginBottom: '24px',
|
||||
@@ -426,7 +426,7 @@ const UserManagement = () => {
|
||||
|
||||
<Card style={cardStyle} styles={{ header: cardHeadStyle, body: { padding: '20px 24px' } }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={tableColumns}
|
||||
dataSource={users}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
|
||||
Reference in New Issue
Block a user