diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md
new file mode 100644
index 0000000..0d98718
--- /dev/null
+++ b/.trae/rules/project_rules.md
@@ -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天,提测前完成自测+代码评审,标注改动影响范围。
\ No newline at end of file
diff --git a/backend/create_indexes.js b/backend/create_indexes.js
new file mode 100644
index 0000000..0f9fe08
--- /dev/null
+++ b/backend/create_indexes.js
@@ -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);
+ });
+}
diff --git a/backend/models/Consumable.js b/backend/models/Consumable.js
index ca69bdf..cd9f727 100644
--- a/backend/models/Consumable.js
+++ b/backend/models/Consumable.js
@@ -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;
diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js
index 7e49d9b..c470f29 100644
--- a/backend/models/ConsumableLog.js
+++ b/backend/models/ConsumableLog.js
@@ -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, {
diff --git a/backend/models/ConsumableRecord.js b/backend/models/ConsumableRecord.js
index d485b89..f454a53 100644
--- a/backend/models/ConsumableRecord.js
+++ b/backend/models/ConsumableRecord.js
@@ -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, {
diff --git a/backend/models/Device.js b/backend/models/Device.js
index 425489d..ecdaa1c 100644
--- a/backend/models/Device.js
+++ b/backend/models/Device.js
@@ -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'] }
+ ]
});
// 关联关系
diff --git a/backend/models/LoginHistory.js b/backend/models/LoginHistory.js
index 63166fd..11ec7de 100644
--- a/backend/models/LoginHistory.js
+++ b/backend/models/LoginHistory.js
@@ -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;
diff --git a/backend/models/OperationLog.js b/backend/models/OperationLog.js
index d217adc..9f28d80 100644
--- a/backend/models/OperationLog.js
+++ b/backend/models/OperationLog.js
@@ -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;
diff --git a/backend/models/Rack.js b/backend/models/Rack.js
index 07dffe2..499fba2 100644
--- a/backend/models/Rack.js
+++ b/backend/models/Rack.js
@@ -40,7 +40,12 @@ const Rack = sequelize.define('Rack', {
}
}, {
tableName: 'racks',
- timestamps: true
+ timestamps: true,
+ indexes: [
+ { fields: ['roomId'] },
+ { fields: ['status'] },
+ { fields: ['roomId', 'status'] }
+ ]
});
// 关联关系
diff --git a/backend/models/Room.js b/backend/models/Room.js
index 9f343fa..5f38978 100644
--- a/backend/models/Room.js
+++ b/backend/models/Room.js
@@ -33,7 +33,11 @@ const Room = sequelize.define('Room', {
}
}, {
tableName: 'rooms',
- timestamps: true
+ timestamps: true,
+ indexes: [
+ { fields: ['status'] },
+ { fields: ['name'] }
+ ]
});
module.exports = Room;
\ No newline at end of file
diff --git a/backend/models/User.js b/backend/models/User.js
index ac28830..c960569 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -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;
diff --git a/backend/package-lock.json b/backend/package-lock.json
index bb83662..68d668f 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -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",
diff --git a/backend/package.json b/backend/package.json
index c1c88f9..ed5d0bc 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/routes/consumableRecords.js b/backend/routes/consumableRecords.js
index 77e68a8..b082702 100644
--- a/backend/routes/consumableRecords.js
+++ b/backend/routes/consumableRecords.js
@@ -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
});
diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js
index 4512a4c..6ec5376 100644
--- a/backend/routes/consumables.js
+++ b/backend/routes/consumables.js
@@ -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 });
diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js
index 98819d4..fe46e97 100644
--- a/backend/routes/tickets.js
+++ b/backend/routes/tickets.js
@@ -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,
diff --git a/backend/routes/users.js b/backend/routes/users.js
index 5fe5e44..5958cac 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -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({
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a2d9297..168fbcb 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -27,6 +27,36 @@ const SystemSettings = lazy(() => import('./pages/SystemSettings'));
const { Header, Content, Sider } = Layout;
+const PageLoading = () => (
+
+
+ 正在加载页面...
+
+);
+
+const AuthLoading = () => (
+
+
+ 正在加载认证状态...
+
+);
+
const designTokens = {
colors: {
primary: {
@@ -81,50 +111,22 @@ const PrivateRoute = ({ children }) => {
const location = useLocation();
if (!initialized) {
- return (
-
-
- 正在加载认证状态...
-
- );
+ return ;
}
if (!token) {
return ;
}
- return (
-
-
- 正在加载页面...
-
- }
- >
-
- {children}
-
-
- );
+ return {children};
};
+const ProtectedRoute = ({ component: Component }) => (
+
+
+
+);
+
const AppLayout = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const [activeKey, setActiveKey] = useState('dashboard');
@@ -464,173 +466,30 @@ const AppLayout = ({ children }) => {
function App() {
return (
-
-
-
- 正在加载登录页面...
-
- }
- >
-
-
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
- } />
-
+ }>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
);
}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index edf330b..1a042a9 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -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)}
>
-
-
+
+
handleNavHover(e, true, `nav-${key}`)}
onMouseLeave={(e) => handleNavHover(e, false, `nav-${key}`)}
>
-
+
{text}
diff --git a/frontend/src/pages/DeviceFieldManagement.jsx b/frontend/src/pages/DeviceFieldManagement.jsx
index eae337c..d26f96c 100644
--- a/frontend/src/pages/DeviceFieldManagement.jsx
+++ b/frontend/src/pages/DeviceFieldManagement.jsx
@@ -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] ||
;
};
- const columns = [
+ const columns = useMemo(() => [
{
title: '字段名称',
dataIndex: 'fieldName',
key: 'fieldName',
width: 150,
- render: (text) => (
-
- {text}
-
- )
+ render: (text) =>
{text}
},
{
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 (
-
+
{getFieldTypeIcon(type)}
{config.text}
@@ -282,7 +348,7 @@ function DeviceFieldManagement() {
render: (required) => (
{required ? '是' : '否'}
@@ -296,7 +362,7 @@ function DeviceFieldManagement() {
render: (visible) => (
{visible ? '是' : '否'}
@@ -307,17 +373,7 @@ function DeviceFieldManagement() {
dataIndex: 'order',
key: 'order',
width: 80,
- render: (order) => (
-
- {order}
-
- )
+ render: (order) => {order}
},
{
title: '操作',
@@ -326,48 +382,25 @@ function DeviceFieldManagement() {
fixed: 'right',
render: (_, record) => (
- }
- onClick={() => showModal(record)}
- style={{
- color: designTokens.colors.primary.main,
- height: '28px',
- padding: '0 8px'
- }}
- >
+ } onClick={() => showModal(record)} style={editButtonStyle}>
编辑
- }
- onClick={() => handleDelete(record.fieldId)}
- style={{
- height: '28px',
- padding: '0 8px'
- }}
- >
+ } onClick={() => handleDelete(record.fieldId)} style={deleteButtonStyle}>
删除
),
},
- ];
+ ], []);
return (
-
}
- onClick={() => showModal()}
- style={primaryActionStyle}
- >
+
} onClick={() => showModal()} style={primaryActionStyle}>
添加字段
@@ -390,30 +423,18 @@ function DeviceFieldManagement() {
- {editingField ? '编辑字段' : '添加字段'}
-
- }
+ title={{editingField ? '编辑字段' : '添加字段'}}
open={modalVisible}
onCancel={handleCancel}
footer={null}
width={600}
- styles={{
- body: { padding: designTokens.spacing.lg }
- }}
- style={{
- borderRadius: designTokens.borderRadius.large
- }}
+ styles={{ body: modalBodyStyle }}
+ style={modalStyle}
>
- 字段名称}
+ label={字段名称}
rules={[{ required: true, message: '请输入字段名称' }]}
>
@@ -421,7 +442,7 @@ function DeviceFieldManagement() {
显示名称}
+ label={显示名称}
rules={[{ required: true, message: '请输入显示名称' }]}
>
@@ -429,34 +450,31 @@ function DeviceFieldManagement() {
字段类型}
+ label={字段类型}
rules={[{ required: true, message: '请选择字段类型' }]}
>
-
+
必填}
+ label={必填}
valuePropName="checked"
- style={{ flex: 1 }}
+ style={formItemFlexStyle}
>
可见}
+ label={可见}
valuePropName="checked"
- style={{ flex: 1 }}
+ style={formItemFlexStyle}
>
@@ -464,7 +482,7 @@ function DeviceFieldManagement() {
显示顺序}
+ label={显示顺序}
rules={[{ required: true, message: '请输入显示顺序' }]}
>
@@ -472,17 +490,13 @@ function DeviceFieldManagement() {
选项配置(JSON格式)}
+ label={选项配置(JSON格式)}
tooltip="格式示例:[{value: 'option1', label: '选项1'}],仅下拉选择类型需要配置"
>
-
+
-
+
diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx
index 8a8237a..073e6ed 100644
--- a/frontend/src/pages/DeviceManagement.jsx
+++ b/frontend/src/pages/DeviceManagement.jsx
@@ -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(() => {
diff --git a/frontend/src/pages/LoginHistory.jsx b/frontend/src/pages/LoginHistory.jsx
index 249c969..438078f 100644
--- a/frontend/src/pages/LoginHistory.jsx
+++ b/frontend/src/pages/LoginHistory.jsx
@@ -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 = () => {
({
+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() {
-
+
-
+
-
+
设备总数
{stats.totalDevices}
-
+
总功率
{(stats.totalPower / 1000).toFixed(1)}kW
diff --git a/frontend/src/pages/TicketManagement.jsx b/frontend/src/pages/TicketManagement.jsx
index 68e0815..4b31c6a 100644
--- a/frontend/src/pages/TicketManagement.jsx
+++ b/frontend/src/pages/TicketManagement.jsx
@@ -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() {
{
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 = () => {
)
}
- ];
+ ], [handleAvatarClick, handleEdit, handleResetPassword, handleDelete]);
const pageHeaderStyle = {
marginBottom: '24px',
@@ -426,7 +426,7 @@ const UserManagement = () => {