feat: 添加操作日志、危险操作确认和业务关联功能
1. 新增操作日志记录功能,记录关键操作 2. 实现危险操作确认对话框,防止误删 3. 添加业务和库房管理模块 4. 支持设备标记为空闲状态 5. 完善API文档和健康检查 6. 优化前端删除操作的确认流程 7. 添加Swagger API文档支持 8. 实现设备与业务的关联功能 9. 改进设备模型,添加空闲相关字段 10. 优化用户、角色管理操作日志
This commit is contained in:
@@ -319,6 +319,7 @@ npm run dev
|
||||
**访问地址:**
|
||||
- 前端应用:http://localhost:3000
|
||||
- 后端API:http://localhost:8000/api
|
||||
- API文档:http://localhost:8000/api-docs
|
||||
- 健康检查:http://localhost:8000/health
|
||||
|
||||
---
|
||||
@@ -725,62 +726,13 @@ node uninstall.js --skip-deps # 跳过依赖删除
|
||||
|
||||
## API接口
|
||||
|
||||
完整接口文档请参考 [docs/api/README.md](docs/api/README.md)。
|
||||
完整交互式API文档请访问:**http://localhost:8000/api-docs**
|
||||
|
||||
### 基础信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Base URL | `http://localhost:8000/api` |
|
||||
| Content-Type | `application/json` |
|
||||
| 认证方式 | Bearer Token (JWT) |
|
||||
|
||||
### API端点列表
|
||||
|
||||
| 模块 | 路由端点 | 功能说明 |
|
||||
|------|----------|----------|
|
||||
| 认证 | `/api/auth` | 登录、注册、登出、令牌刷新 |
|
||||
| 机房 | `/api/rooms` | 机房增删改查 |
|
||||
| 机柜 | `/api/racks` | 机柜增删改查 |
|
||||
| 设备 | `/api/devices` | 设备管理、批量导入导出 |
|
||||
| 设备字段 | `/api/deviceFields` | 设备自定义字段配置 |
|
||||
| 设备端口 | `/api/device-ports` | 设备端口管理 |
|
||||
| 网卡 | `/api/network-cards` | 网卡管理 |
|
||||
| 线缆 | `/api/cables` | 线缆连接管理 |
|
||||
| 工单 | `/api/tickets` | 工单管理、状态更新 |
|
||||
| 工单分类 | `/api/ticket-categories` | 工单分类管理 |
|
||||
| 工单字段 | `/api/ticket-fields` | 工单自定义字段配置 |
|
||||
| 耗材 | `/api/consumables` | 耗材库存管理 |
|
||||
| 耗材分类 | `/api/consumable-categories` | 耗材分类管理 |
|
||||
| 耗材记录 | `/api/consumable-records` | 耗材领用记录 |
|
||||
| 盘点 | `/api/inventory` | 盘点计划、任务、记录 |
|
||||
| 用户 | `/api/users` | 用户管理 |
|
||||
| 角色 | `/api/roles` | 角色权限管理 |
|
||||
| 系统设置 | `/api/system-settings` | 系统配置管理 |
|
||||
| 背景配置 | `/api/background` | 系统背景配置 |
|
||||
| 备份管理 | `/api/backup` | 数据库备份、恢复、自动备份设置 |
|
||||
| 统计分析 | `/api/statistics` | 多维度数据统计报表 |
|
||||
| 健康检查 | `/health` | 后端服务健康状态 |
|
||||
|
||||
### 通用响应格式
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {...},
|
||||
"message": "操作成功"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息",
|
||||
"message": "详细描述"
|
||||
}
|
||||
```
|
||||
该文档基于Swagger/OpenAPI 3.0标准,提供:
|
||||
- 📚 可视化API文档界面
|
||||
- 🔐 在线JWT认证测试
|
||||
- ⚡ 支持直接在线调试API接口
|
||||
- 📊 自动同步最新接口信息
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const { sequelize } = require('./db');
|
||||
|
||||
async function addNewColumns() {
|
||||
try {
|
||||
console.log('开始添加新列...');
|
||||
|
||||
await sequelize.query(`
|
||||
ALTER TABLE devices ADD COLUMN isIdle INTEGER DEFAULT 0;
|
||||
`);
|
||||
console.log('已添加 isIdle 列');
|
||||
|
||||
await sequelize.query(`
|
||||
ALTER TABLE devices ADD COLUMN idleDate DATETIME;
|
||||
`);
|
||||
console.log('已添加 idleDate 列');
|
||||
|
||||
await sequelize.query(`
|
||||
ALTER TABLE devices ADD COLUMN idleReason TEXT;
|
||||
`);
|
||||
console.log('已添加 idleReason 列');
|
||||
|
||||
await sequelize.query(`
|
||||
ALTER TABLE devices ADD COLUMN warehouseId TEXT;
|
||||
`);
|
||||
console.log('已添加 warehouseId 列');
|
||||
|
||||
await sequelize.query(`
|
||||
ALTER TABLE devices ADD COLUMN sourceType TEXT DEFAULT 'rack';
|
||||
`);
|
||||
console.log('已添加 sourceType 列');
|
||||
|
||||
console.log('所有新列添加完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (error.message.includes('duplicate column')) {
|
||||
console.log('列已存在,跳过');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('添加列失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
addNewColumns();
|
||||
@@ -172,8 +172,13 @@ async function initDeviceFields() {
|
||||
await DeviceField.create(field);
|
||||
console.log(`创建字段: ${field.displayName}`);
|
||||
} else {
|
||||
// 已存在的字段跳过,保留用户自定义配置
|
||||
console.log(`跳过已存在字段: ${field.displayName}`);
|
||||
// 如果字段已存在但缺少 options,则补充 options
|
||||
if (field.options && !existingField.options) {
|
||||
await existingField.update({ options: field.options });
|
||||
console.log(`更新字段 options: ${field.displayName}`);
|
||||
} else {
|
||||
console.log(`跳过已存在字段: ${field.displayName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
collectCoverageFrom: [
|
||||
'models/**/*.js',
|
||||
'utils/**/*.js',
|
||||
'routes/**/*.js',
|
||||
'!models/ticketIndex.js'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
verbose: true,
|
||||
testTimeout: 30000,
|
||||
setupFiles: ['./tests/setupEnv.js'],
|
||||
setupFilesAfterEnv: ['./tests/setup.js']
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
const Business = sequelize.define('Business', {
|
||||
businessId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'offline'),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
offlineDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
offlineReason: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'businesses',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['name'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = Business;
|
||||
@@ -1,5 +1,7 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const Rack = require('./Rack');
|
||||
const Warehouse = require('./Warehouse');
|
||||
|
||||
const Device = sequelize.define('Device', {
|
||||
deviceId: {
|
||||
@@ -47,6 +49,26 @@ const Device = sequelize.define('Device', {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'offline'
|
||||
},
|
||||
isIdle: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
idleDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
idleReason: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
warehouseId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
sourceType: {
|
||||
type: DataTypes.ENUM('rack', 'warehouse'),
|
||||
defaultValue: 'rack'
|
||||
},
|
||||
purchaseDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
@@ -81,4 +103,7 @@ const Device = sequelize.define('Device', {
|
||||
]
|
||||
});
|
||||
|
||||
Device.belongsTo(Rack, { foreignKey: 'rackId' });
|
||||
Device.belongsTo(Warehouse, { foreignKey: 'warehouseId' });
|
||||
|
||||
module.exports = Device;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const Device = require('./Device');
|
||||
const Business = require('./Business');
|
||||
|
||||
const DeviceBusiness = sequelize.define('DeviceBusiness', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
deviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Device,
|
||||
key: 'deviceId'
|
||||
}
|
||||
},
|
||||
businessId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Business,
|
||||
key: 'businessId'
|
||||
}
|
||||
},
|
||||
isPrimary: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'device_business',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['deviceId'] },
|
||||
{ fields: ['businessId'] },
|
||||
{ unique: true, fields: ['deviceId', 'businessId'] }
|
||||
]
|
||||
});
|
||||
|
||||
Device.belongsToMany(Business, {
|
||||
through: DeviceBusiness,
|
||||
foreignKey: 'deviceId',
|
||||
otherKey: 'businessId'
|
||||
});
|
||||
|
||||
Business.belongsToMany(Device, {
|
||||
through: DeviceBusiness,
|
||||
foreignKey: 'businessId',
|
||||
otherKey: 'deviceId'
|
||||
});
|
||||
|
||||
DeviceBusiness.belongsTo(Business, { foreignKey: 'businessId' });
|
||||
DeviceBusiness.belongsTo(Device, { foreignKey: 'deviceId' });
|
||||
|
||||
module.exports = DeviceBusiness;
|
||||
@@ -0,0 +1,89 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
const generateRecordId = () => {
|
||||
return `OPLOG_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
const OperationLog = sequelize.define('OperationLog', {
|
||||
recordId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
module: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '模块:device/user/role/consumable/rack/room'
|
||||
},
|
||||
operationType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '操作类型: create/update/delete/batch_delete/batch_update/status_change/move/permission_change'
|
||||
},
|
||||
operationDescription: {
|
||||
type: DataTypes.TEXT,
|
||||
comment: '操作描述'
|
||||
},
|
||||
targetId: {
|
||||
type: DataTypes.STRING,
|
||||
comment: '目标对象ID'
|
||||
},
|
||||
targetName: {
|
||||
type: DataTypes.STRING,
|
||||
comment: '目标对象名称(冗余便于展示)'
|
||||
},
|
||||
operatorId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '操作人ID'
|
||||
},
|
||||
operatorName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '操作人姓名'
|
||||
},
|
||||
operatorRole: {
|
||||
type: DataTypes.STRING,
|
||||
comment: '操作人角色'
|
||||
},
|
||||
beforeState: {
|
||||
type: DataTypes.JSON,
|
||||
comment: '操作前状态'
|
||||
},
|
||||
afterState: {
|
||||
type: DataTypes.JSON,
|
||||
comment: '操作后状态'
|
||||
},
|
||||
result: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'success',
|
||||
comment: '操作结果: success/failed'
|
||||
},
|
||||
ipAddress: {
|
||||
type: DataTypes.STRING,
|
||||
comment: 'IP地址'
|
||||
},
|
||||
userAgent: {
|
||||
type: DataTypes.STRING,
|
||||
comment: '用户代理'
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {},
|
||||
comment: '扩展字段'
|
||||
}
|
||||
}, {
|
||||
tableName: 'operation_logs',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['module'] },
|
||||
{ fields: ['operationType'] },
|
||||
{ fields: ['targetId'] },
|
||||
{ fields: ['operatorId'] },
|
||||
{ fields: ['createdAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = OperationLog;
|
||||
@@ -0,0 +1,41 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
const Warehouse = sequelize.define('Warehouse', {
|
||||
warehouseId: {
|
||||
type: DataTypes.STRING,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
location: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
capacity: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 100
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive'),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'warehouses',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['name'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = Warehouse;
|
||||
Generated
+905
-1430
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,11 @@
|
||||
"lint": "eslint . --ext js --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext js --fix",
|
||||
"format": "prettier --write \"**/*.js\"",
|
||||
"format:check": "prettier --check \"**/*.js\""
|
||||
"format:check": "prettier --check \"**/*.js\"",
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:operation-logs": "jest tests/operationLog.model.test.js tests/operationLogger.test.js tests/operationLogs.api.test.js --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
@@ -34,6 +38,8 @@
|
||||
"smb2": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ssh2-sftp-client": "^9.1.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"three": "^0.182.0",
|
||||
"webdav": "^5.3.1",
|
||||
"winston": "^3.19.0",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { logDangerousOperation, getDangerousOperationsLogs, cleanOldLogs, DANGEROUS_OPERATION_TYPES, RISK_LEVELS, calculateRiskLevel } = require('../utils/dangerousOperationLogger');
|
||||
|
||||
router.post('/log', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
operationType,
|
||||
operationName,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
metadata = {},
|
||||
success = true,
|
||||
errorMessage = null,
|
||||
} = req.body;
|
||||
|
||||
if (!operationType || !operationName) {
|
||||
return res.status(400).json({ error: '缺少必需参数 operationType 或 operationName' });
|
||||
}
|
||||
|
||||
const riskLevel = metadata.riskLevel || calculateRiskLevel(operationType, metadata.itemCount || 1, {
|
||||
hasRelatedData: metadata.relatedDataCount > 0,
|
||||
isSystemLevel: metadata.isSystemLevel,
|
||||
});
|
||||
|
||||
await logDangerousOperation(req, {
|
||||
operationType,
|
||||
operationName,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
metadata: {
|
||||
...metadata,
|
||||
riskLevel,
|
||||
},
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
res.json({ success: true, riskLevel });
|
||||
} catch (error) {
|
||||
console.error('Failed to log dangerous operation:', error);
|
||||
res.status(500).json({ error: '日志记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logs', async (req, res) => {
|
||||
try {
|
||||
const { operationType, targetType, success, startDate, endDate, username, riskLevel, page = 1, pageSize = 50 } = req.query;
|
||||
|
||||
const filters = {
|
||||
operationType,
|
||||
targetType,
|
||||
success: success !== undefined ? success === 'true' : undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
username,
|
||||
riskLevel,
|
||||
};
|
||||
|
||||
const allLogs = getDangerousOperationsLogs(filters);
|
||||
const total = allLogs.length;
|
||||
const startIndex = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
const endIndex = startIndex + parseInt(pageSize);
|
||||
const logs = allLogs.slice(startIndex, endIndex);
|
||||
|
||||
res.json({
|
||||
logs,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
totalPages: Math.ceil(total / parseInt(pageSize)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get dangerous operations logs:', error);
|
||||
res.status(500).json({ error: '获取日志失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/logs/clean', async (req, res) => {
|
||||
try {
|
||||
const { daysToKeep = 90 } = req.query;
|
||||
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '只有管理员才能清理日志' });
|
||||
}
|
||||
|
||||
const result = await cleanOldLogs(parseInt(daysToKeep));
|
||||
|
||||
await logDangerousOperation(req, {
|
||||
operationType: DANGEROUS_OPERATION_TYPES.PURGE,
|
||||
operationName: '清理危险操作日志',
|
||||
targetType: 'operation_logs',
|
||||
targetId: null,
|
||||
targetName: `清理 ${daysToKeep} 天前的日志`,
|
||||
metadata: {
|
||||
riskLevel: RISK_LEVELS.MEDIUM,
|
||||
deletedCount: result.deletedCount,
|
||||
remainingCount: result.remainingCount,
|
||||
daysToKeep: parseInt(daysToKeep),
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `已清理 ${result.deletedCount} 条过期日志,保留 ${result.remainingCount} 条日志`,
|
||||
deletedCount: result.deletedCount,
|
||||
remainingCount: result.remainingCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clean logs:', error);
|
||||
res.status(500).json({ error: '清理日志失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/risk-assessment', async (req, res) => {
|
||||
try {
|
||||
const { operationType, itemCount, hasRelatedData, isSystemLevel } = req.query;
|
||||
|
||||
const riskLevel = calculateRiskLevel(
|
||||
operationType,
|
||||
parseInt(itemCount) || 1,
|
||||
{
|
||||
hasRelatedData: hasRelatedData === 'true',
|
||||
isSystemLevel: isSystemLevel === 'true',
|
||||
}
|
||||
);
|
||||
|
||||
const riskDescriptions = {
|
||||
[RISK_LEVELS.EXTREME]: '极高风险操作,需要输入确认关键词才能执行',
|
||||
[RISK_LEVELS.HIGH]: '高风险操作,需要详细确认信息',
|
||||
[RISK_LEVELS.MEDIUM]: '中等风险操作,需要明确确认',
|
||||
[RISK_LEVELS.LOW]: '低风险操作,使用标准确认即可',
|
||||
};
|
||||
|
||||
res.json({
|
||||
riskLevel,
|
||||
description: riskDescriptions[riskLevel],
|
||||
requiresKeyword: riskLevel === RISK_LEVELS.EXTREME,
|
||||
confirmationLevel: riskLevel === RISK_LEVELS.EXTREME ? 'KEYWORD' : riskLevel === RISK_LEVELS.HIGH ? 'ENHANCED' : 'STANDARD',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to assess risk:', error);
|
||||
res.status(500).json({ error: '风险评估失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+207
-37
@@ -16,6 +16,7 @@ const DevicePort = require('../models/DevicePort');
|
||||
const Cable = require('../models/Cable');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
const InventoryRecord = require('../models/InventoryRecord');
|
||||
const { logDeviceOperation } = require('../utils/operationLogger');
|
||||
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||
const {
|
||||
createDeviceSchema,
|
||||
@@ -469,7 +470,7 @@ router.post('/import-preview', async (req, res) => {
|
||||
|
||||
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
const { keyword, status, type, rackId, roomId, page = 1, pageSize = 10 } = req.query;
|
||||
const { keyword, status, type, rackId, roomId, page = 1, pageSize = 10, isIdle } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
@@ -544,6 +545,11 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||
where['$Rack.roomId$'] = roomId;
|
||||
}
|
||||
|
||||
// 空闲设备筛选
|
||||
if (isIdle !== undefined && isIdle !== '') {
|
||||
where.isIdle = isIdle === 'true' || isIdle === true;
|
||||
}
|
||||
|
||||
// 执行查询 - 优化:使用 JOIN 避免 N+1 查询问题
|
||||
const { count, rows } = await Device.findAndCountAll({
|
||||
where,
|
||||
@@ -626,14 +632,31 @@ router.post('/', validateBody(createDeviceSchema), async (req, res) => {
|
||||
}
|
||||
|
||||
const device = await Device.create(deviceData);
|
||||
|
||||
|
||||
const rack = await Rack.findByPk(deviceData.rackId);
|
||||
if (rack) {
|
||||
await rack.update({
|
||||
currentPower: rack.currentPower + deviceData.powerConsumption
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const createDetails = [
|
||||
`设备名称: ${device.name}`,
|
||||
`设备编号: ${device.deviceId}`,
|
||||
`设备类型: ${device.type}`,
|
||||
`所属机柜: ${rack ? rack.name : '未分配'}`,
|
||||
`安装位置: U${device.position}`,
|
||||
`功耗: ${device.powerConsumption}W`
|
||||
].join(';');
|
||||
|
||||
await logDeviceOperation('create', `创建设备【${device.name}】`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
afterState: device.toJSON(),
|
||||
req,
|
||||
metadata: { deviceType: device.type, rackName: rack?.name, powerConsumption: device.powerConsumption }
|
||||
});
|
||||
|
||||
res.status(201).json(device);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
@@ -1363,32 +1386,32 @@ router.put('/batch-offline', validateBody(batchDeviceIdsSchema), async (req, res
|
||||
router.put('/batch-status', async (req, res) => {
|
||||
try {
|
||||
const { deviceIds, status } = req.body;
|
||||
|
||||
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||
}
|
||||
|
||||
|
||||
// 检查数据库中是否存在这些设备
|
||||
const existingDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
attributes: ['deviceId']
|
||||
});
|
||||
|
||||
|
||||
// 检查是否有不存在的设备
|
||||
const existingIds = existingDevices.map(d => d.deviceId);
|
||||
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
||||
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
||||
}
|
||||
|
||||
|
||||
const validStatus = ['running', 'maintenance', 'offline', 'fault'];
|
||||
if (!validStatus.includes(status)) {
|
||||
return res.status(400).json({
|
||||
error: `状态值无效,有效值为:${validStatus.join('、')}`
|
||||
return res.status(400).json({
|
||||
error: `状态值无效,有效值为:${validStatus.join('、')}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 状态映射
|
||||
const statusText = {
|
||||
running: '运行中',
|
||||
@@ -1396,13 +1419,30 @@ router.put('/batch-status', async (req, res) => {
|
||||
offline: '离线',
|
||||
fault: '故障'
|
||||
};
|
||||
|
||||
|
||||
const beforeDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } }
|
||||
});
|
||||
|
||||
const deviceNames = beforeDevices.map(d => d.name);
|
||||
|
||||
// 更新设备状态
|
||||
const [affectedCount] = await Device.update(
|
||||
{ status },
|
||||
{ where: { deviceId: { [Op.in]: deviceIds } } }
|
||||
);
|
||||
|
||||
|
||||
const statusChangeDesc = `批量变更${affectedCount}台设备状态:${deviceNames.join('、')} → ${statusText[status]}`;
|
||||
|
||||
await logDeviceOperation('status_change', statusChangeDesc, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${affectedCount}台设备`,
|
||||
beforeState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status: d.status })),
|
||||
afterState: beforeDevices.map(d => ({ deviceId: d.deviceId, name: d.name, status })),
|
||||
req,
|
||||
metadata: { status, statusText: statusText[status], count: affectedCount, deviceNames }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `批量状态变更成功,已将 ${affectedCount} 个设备状态变更为"${statusText[status]}"`,
|
||||
affectedCount,
|
||||
@@ -1433,9 +1473,16 @@ router.put('/batch-move', async (req, res) => {
|
||||
|
||||
const devicesToMove = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
attributes: ['deviceId', 'position', 'height']
|
||||
attributes: ['deviceId', 'name', 'rackId', 'position', 'height']
|
||||
});
|
||||
|
||||
const beforeMoveState = devicesToMove.map(d => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
rackId: d.rackId,
|
||||
position: d.position
|
||||
}));
|
||||
|
||||
const deviceHeightMap = new Map(devicesToMove.map(d => [d.deviceId, d.height || 1]));
|
||||
|
||||
if (startPosition) {
|
||||
@@ -1511,6 +1558,20 @@ router.put('/batch-move', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deviceNames = devicesToMove.map(d => d.name);
|
||||
const moveDesc = startPosition
|
||||
? `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')} → U${startPosition}起`
|
||||
: `批量移动${movedCount}台设备到机柜【${targetRack.name}】:${deviceNames.join('、')}`;
|
||||
|
||||
await logDeviceOperation('move', moveDesc, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${movedCount}台设备`,
|
||||
beforeState: beforeMoveState,
|
||||
afterState: { targetRackId, targetRackName: targetRack.name, startPosition },
|
||||
req,
|
||||
metadata: { count: movedCount, targetRackId, targetRackName: targetRack.name, startPosition, deviceNames }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `批量移动成功,已将 ${movedCount} 个设备移动到机柜 ${targetRackId}`,
|
||||
movedCount
|
||||
@@ -1542,6 +1603,61 @@ router.get('/:deviceId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 将设备标记为空闲
|
||||
router.put('/:deviceId/to-idle', async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { idleReason } = req.body;
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const device = await Device.findByPk(deviceId, { transaction: t });
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
if (device.isIdle) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '设备已经标记为空闲设备' });
|
||||
}
|
||||
|
||||
await device.update({
|
||||
isIdle: true,
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `从设备管理转入`
|
||||
}, { transaction: t });
|
||||
|
||||
if (device.rackId) {
|
||||
const rack = await Rack.findByPk(device.rackId, { transaction: t });
|
||||
if (rack) {
|
||||
await rack.update({
|
||||
currentPower: Math.max(0, rack.currentPower - (device.powerConsumption || 0))
|
||||
}, { transaction: t });
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
await logDeviceOperation('to_idle', `设备【${device.name}】转入空闲设备`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState: { ...device.toJSON(), isIdle: false },
|
||||
afterState: { ...device.toJSON(), isIdle: true },
|
||||
req,
|
||||
metadata: { idleReason, type: 'device_to_idle' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: '设备已转入空闲设备',
|
||||
device: device.toJSON()
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('设备转入空闲设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取设备的工单列表
|
||||
router.get('/:deviceId/tickets', async (req, res) => {
|
||||
try {
|
||||
@@ -1583,18 +1699,19 @@ router.get('/:deviceId/tickets', async (req, res) => {
|
||||
// 更新设备
|
||||
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
// 获取旧设备信息以更新功率
|
||||
const oldDevice = await Device.findByPk(req.params.deviceId);
|
||||
if (!oldDevice) {
|
||||
return res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
|
||||
const beforeState = oldDevice.toJSON();
|
||||
const changedFields = {};
|
||||
|
||||
const [updated] = await Device.update(req.body, {
|
||||
where: { deviceId: req.params.deviceId }
|
||||
});
|
||||
|
||||
|
||||
if (updated) {
|
||||
// 更新机柜当前功率
|
||||
const rack = await Rack.findByPk(oldDevice.rackId);
|
||||
if (rack) {
|
||||
const powerDiff = req.body.powerConsumption - oldDevice.powerConsumption;
|
||||
@@ -1602,7 +1719,7 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
currentPower: rack.currentPower + powerDiff
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const updatedDevice = await Device.findByPk(req.params.deviceId, {
|
||||
include: [
|
||||
{
|
||||
@@ -1613,6 +1730,38 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const afterState = updatedDevice.toJSON();
|
||||
for (const key of Object.keys(req.body)) {
|
||||
if (JSON.stringify(beforeState[key]) !== JSON.stringify(afterState[key])) {
|
||||
changedFields[key] = { from: beforeState[key], to: afterState[key] };
|
||||
}
|
||||
}
|
||||
|
||||
const changeDetails = Object.entries(changedFields).map(([field, values]) => {
|
||||
const fieldNames = {
|
||||
name: '名称', deviceId: '设备编号', type: '类型', model: '型号',
|
||||
manufacturer: '制造商', serialNumber: '序列号', status: '状态',
|
||||
position: '安装位置(U)', height: '占用高度(U)', powerConsumption: '功耗(W)',
|
||||
ipAddress: 'IP地址', macAddress: 'MAC地址', managementIp: '管理IP'
|
||||
};
|
||||
const displayName = fieldNames[field] || field;
|
||||
return `${displayName}: ${values.from ?? '空'} → ${values.to ?? '空'}`;
|
||||
}).join(';');
|
||||
|
||||
const operationDesc = changeDetails
|
||||
? `更新设备【${updatedDevice.name}】:${changeDetails}`
|
||||
: `更新设备【${updatedDevice.name}】`;
|
||||
|
||||
await logDeviceOperation('update', operationDesc, {
|
||||
targetId: updatedDevice.deviceId,
|
||||
targetName: updatedDevice.name,
|
||||
beforeState,
|
||||
afterState,
|
||||
req,
|
||||
metadata: { changedFields }
|
||||
});
|
||||
|
||||
res.json(updatedDevice);
|
||||
} else {
|
||||
res.status(404).json({ error: '设备不存在' });
|
||||
@@ -1627,17 +1776,19 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { deviceIds } = req.body;
|
||||
|
||||
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '请提供有效的设备 ID 列表' });
|
||||
}
|
||||
|
||||
|
||||
const devices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
const deviceNames = devices.map(d => d.name).join(', ');
|
||||
|
||||
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
|
||||
await DevicePort.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
@@ -1660,19 +1811,19 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res
|
||||
},
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
// 4. 解除工单关联
|
||||
await Ticket.update(
|
||||
{ deviceId: null },
|
||||
{ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }
|
||||
);
|
||||
|
||||
|
||||
// 5. 删除盘点记录
|
||||
await InventoryRecord.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
// 6. 更新机柜功率
|
||||
for (const device of devices) {
|
||||
if (device.rackId) {
|
||||
@@ -1684,7 +1835,7 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 7. 删除设备
|
||||
const deletedCount = await Device.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
@@ -1692,7 +1843,15 @@ router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res
|
||||
});
|
||||
|
||||
await t.commit();
|
||||
|
||||
|
||||
await logDeviceOperation('batch_delete', `批量删除${deletedCount}台设备:${deviceNames}`, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${deletedCount}台设备`,
|
||||
beforeState: devices.map(d => d.toJSON()),
|
||||
req,
|
||||
metadata: { count: deletedCount, deviceNames }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `批量删除成功,已删除 ${deletedCount} 个设备`,
|
||||
deletedCount
|
||||
@@ -1789,20 +1948,23 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
|
||||
// 获取设备信息以更新功率
|
||||
const device = await Device.findByPk(deviceId, { transaction: t });
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
|
||||
const deviceName = device.name;
|
||||
const beforeState = device.toJSON();
|
||||
|
||||
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
|
||||
const deletedPorts = await DevicePort.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
// 2. 删除相关网卡
|
||||
const deletedNetworkCards = await NetworkCard.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
@@ -1826,13 +1988,13 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
{ deviceId: null },
|
||||
{ where: { deviceId: deviceId }, transaction: t }
|
||||
);
|
||||
|
||||
|
||||
// 5. 删除盘点记录
|
||||
await InventoryRecord.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
// 6. 更新机柜功率 (必须在删除设备之前)
|
||||
if (device.rackId) {
|
||||
try {
|
||||
@@ -1847,21 +2009,29 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
throw err; // 重新抛出错误,触发事务回滚
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 7. 删除设备 (Delete Device)
|
||||
await Device.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
|
||||
// 提交事务
|
||||
await t.commit();
|
||||
|
||||
|
||||
if (deletedCables > 0) {
|
||||
console.log(`已删除 ${deletedCables} 条相关接线`);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
await logDeviceOperation('delete', `删除设备【${deviceName}】(编号:${deviceId},类型:${device.type},关联删除:${deletedCables}条接线、${deletedPorts}个端口、${deletedNetworkCards}张网卡)`, {
|
||||
targetId: deviceId,
|
||||
targetName: deviceName,
|
||||
beforeState,
|
||||
req,
|
||||
metadata: { deletedCables, deletedPorts, deletedNetworkCards, deviceType: device.type }
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
message: '删除成功',
|
||||
deviceId: deviceId,
|
||||
deletedCablesCount: deletedCables,
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Op } = require('sequelize');
|
||||
const Device = require('../models/Device');
|
||||
const Rack = require('../models/Rack');
|
||||
const Room = require('../models/Room');
|
||||
const { logDeviceOperation } = require('../utils/operationLogger');
|
||||
|
||||
async function generateIdleDeviceId() {
|
||||
const devices = await Device.findAll({
|
||||
where: {
|
||||
deviceId: {
|
||||
[Op.like]: 'DEV%'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let maxNumber = 0;
|
||||
devices.forEach(device => {
|
||||
const match = device.deviceId.match(/^DEV(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num > maxNumber) {
|
||||
maxNumber = num;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newNumber = maxNumber + 1;
|
||||
return `DEV${String(newNumber).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { keyword, sourceType, idleReason, page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = { isIdle: true };
|
||||
|
||||
if (keyword) {
|
||||
where[Op.or] = [
|
||||
{ deviceId: { [Op.like]: `%${keyword}%` } },
|
||||
{ name: { [Op.like]: `%${keyword}%` } },
|
||||
{ serialNumber: { [Op.like]: `%${keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
if (sourceType && sourceType !== 'all') {
|
||||
where.sourceType = sourceType;
|
||||
}
|
||||
|
||||
if (idleReason) {
|
||||
where.idleReason = { [Op.like]: `%${idleReason}%` };
|
||||
}
|
||||
|
||||
const { count, rows } = await Device.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Rack,
|
||||
attributes: ['rackId', 'name', 'roomId'],
|
||||
include: [{
|
||||
model: Room,
|
||||
attributes: ['roomId', 'name']
|
||||
}]
|
||||
}
|
||||
],
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(pageSize),
|
||||
order: [['idleDate', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
idleDevices: rows,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取空闲设备列表失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const device = await Device.findOne({
|
||||
where: { deviceId: req.params.deviceId, isIdle: true },
|
||||
include: [
|
||||
{
|
||||
model: Rack,
|
||||
attributes: ['rackId', 'name', 'roomId'],
|
||||
include: [{
|
||||
model: Room,
|
||||
attributes: ['roomId', 'name']
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({ error: '空闲设备不存在' });
|
||||
}
|
||||
|
||||
res.json(device);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, type, model, serialNumber, powerConsumption, idleReason, warehouseId, description, rackId, position } = req.body;
|
||||
|
||||
let { deviceId } = req.body;
|
||||
|
||||
if (!deviceId || deviceId.trim() === '') {
|
||||
deviceId = await generateIdleDeviceId();
|
||||
}
|
||||
|
||||
const existingDevice = await Device.findByPk(deviceId);
|
||||
if (existingDevice) {
|
||||
return res.status(400).json({ error: '设备ID已存在' });
|
||||
}
|
||||
|
||||
if (rackId) {
|
||||
const rack = await Rack.findByPk(rackId);
|
||||
if (!rack) {
|
||||
return res.status(404).json({ error: '机柜不存在' });
|
||||
}
|
||||
}
|
||||
|
||||
const device = await Device.create({
|
||||
deviceId,
|
||||
name: name || '',
|
||||
type: type || 'other',
|
||||
model: model || '',
|
||||
serialNumber: serialNumber || '',
|
||||
powerConsumption: powerConsumption || 0,
|
||||
status: 'offline',
|
||||
isIdle: true,
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || '',
|
||||
warehouseId: warehouseId || null,
|
||||
rackId: rackId || null,
|
||||
position: position || null,
|
||||
sourceType: warehouseId ? 'warehouse' : (rackId ? 'rack' : 'rack'),
|
||||
description: description || ''
|
||||
});
|
||||
|
||||
await logDeviceOperation('create', `新增空闲设备【${device.name || deviceId}】`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
afterState: device.toJSON(),
|
||||
req,
|
||||
metadata: { sourceType: device.sourceType, type: 'idle_device_create' }
|
||||
});
|
||||
|
||||
res.status(201).json(device);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/from-device/:deviceId', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { idleReason } = req.body;
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const device = await Device.findByPk(deviceId, { transaction: t });
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
if (device.isIdle) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '设备已经标记为空闲设备' });
|
||||
}
|
||||
|
||||
await device.update({
|
||||
isIdle: true,
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `从设备管理转入`,
|
||||
sourceType: 'rack'
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
await logDeviceOperation('to_idle', `设备【${device.name}】转入空闲设备`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState: { ...device.toJSON(), isIdle: false },
|
||||
afterState: { ...device.toJSON(), isIdle: true },
|
||||
req,
|
||||
metadata: { idleReason, type: 'device_to_idle' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: '设备已转入空闲设备',
|
||||
device: device.toJSON()
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('设备转入空闲设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/batch-from-devices', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { deviceIds, idleReason } = req.body;
|
||||
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||
}
|
||||
|
||||
const devices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
const notIdleDevices = devices.filter(d => !d.isIdle);
|
||||
const alreadyIdleDevices = devices.filter(d => d.isIdle);
|
||||
|
||||
if (notIdleDevices.length > 0) {
|
||||
await Device.update(
|
||||
{
|
||||
isIdle: true,
|
||||
idleDate: new Date(),
|
||||
idleReason: idleReason || `批量转入`
|
||||
},
|
||||
{
|
||||
where: { deviceId: { [Op.in]: notIdleDevices.map(d => d.deviceId) } },
|
||||
transaction: t
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
await logDeviceOperation('batch_to_idle', `批量将 ${notIdleDevices.length} 台设备转入空闲设备`, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${notIdleDevices.length}台设备`,
|
||||
req,
|
||||
metadata: { idleReason, type: 'batch_device_to_idle' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `成功将 ${notIdleDevices.length} 台设备转入空闲设备`,
|
||||
total: devices.length,
|
||||
updated: notIdleDevices.length,
|
||||
skipped: alreadyIdleDevices.length
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('批量转入空闲设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/batch-restore', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { devices } = req.body;
|
||||
|
||||
console.log('========== batch-restore 开始 ==========');
|
||||
console.log('原始请求 body:', JSON.stringify(req.body));
|
||||
console.log('devices 参数:', devices);
|
||||
|
||||
if (!devices || !Array.isArray(devices) || devices.length === 0) {
|
||||
await t.rollback();
|
||||
console.log('错误: devices 参数无效');
|
||||
return res.status(400).json({ error: '请提供有效的设备列表' });
|
||||
}
|
||||
|
||||
const deviceIds = devices.map(d => d.deviceId).filter(Boolean);
|
||||
console.log('提取的 deviceIds:', deviceIds);
|
||||
|
||||
if (deviceIds.length === 0) {
|
||||
await t.rollback();
|
||||
console.log('错误: deviceIds 为空');
|
||||
return res.status(400).json({ error: '设备ID不能为空' });
|
||||
}
|
||||
|
||||
console.log('开始查询设备,条件:', { deviceId: { [Op.in]: deviceIds }, isIdle: true });
|
||||
|
||||
const idleDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds }, isIdle: true },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
console.log('查询到的空闲设备数量:', idleDevices.length);
|
||||
if (idleDevices.length > 0) {
|
||||
console.log('查询到的设备ID:', idleDevices.map(d => d.deviceId));
|
||||
}
|
||||
|
||||
if (idleDevices.length === 0) {
|
||||
console.log('没有找到空闲设备,检查设备是否存在:');
|
||||
const allDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
console.log('设备表中存在的设备数量:', allDevices.length);
|
||||
if (allDevices.length > 0) {
|
||||
console.log('存在的设备及其 isIdle 状态:', allDevices.map(d => ({ deviceId: d.deviceId, isIdle: d.isIdle })));
|
||||
}
|
||||
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '没有找到空闲设备' });
|
||||
}
|
||||
|
||||
let restoredCount = 0;
|
||||
const results = [];
|
||||
|
||||
for (const device of idleDevices) {
|
||||
const deviceConfig = devices.find(d => d.deviceId === device.deviceId);
|
||||
if (!deviceConfig) continue;
|
||||
|
||||
const targetRackId = deviceConfig.targetRackId;
|
||||
const targetPosition = deviceConfig.targetPosition;
|
||||
|
||||
if (!targetRackId) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'skipped',
|
||||
reason: '未指定目标机柜'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRack = await Rack.findByPk(targetRackId, { transaction: t });
|
||||
if (!targetRack) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'failed',
|
||||
reason: '目标机柜不存在'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const height = device.height || 1;
|
||||
const position = targetPosition || 1;
|
||||
|
||||
const checkResult = await checkPositionAvailable(targetRackId, position, height, null, t);
|
||||
if (!checkResult.available) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'failed',
|
||||
reason: `U位${position}已被占用`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await device.update({
|
||||
isIdle: false,
|
||||
idleDate: null,
|
||||
idleReason: null,
|
||||
rackId: targetRackId,
|
||||
position: position,
|
||||
warehouseId: null,
|
||||
sourceType: 'rack',
|
||||
status: 'offline'
|
||||
}, { transaction: t });
|
||||
|
||||
await targetRack.update({
|
||||
currentPower: targetRack.currentPower + (device.powerConsumption || 0)
|
||||
}, { transaction: t });
|
||||
|
||||
restoredCount++;
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'success',
|
||||
targetRack: targetRack.name,
|
||||
targetPosition: position
|
||||
});
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const successCount = results.filter(r => r.status === 'success').length;
|
||||
const failedCount = results.filter(r => r.status === 'failed').length;
|
||||
const skippedCount = results.filter(r => r.status === 'skipped').length;
|
||||
|
||||
await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备`, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${successCount}台设备`,
|
||||
req,
|
||||
metadata: { results, type: 'batch_idle_device_restore' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `成功上架 ${successCount} 台设备`,
|
||||
total: idleDevices.length,
|
||||
restored: successCount,
|
||||
failed: failedCount,
|
||||
skipped: skippedCount,
|
||||
details: results
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('批量上架设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:deviceId/shelve', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
const { name, type, model, serialNumber, height, powerConsumption, rackId, position, description } = req.body;
|
||||
|
||||
const device = await Device.findOne({
|
||||
where: { deviceId, isIdle: true },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '空闲设备不存在' });
|
||||
}
|
||||
|
||||
if (!rackId) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '请选择目标机柜' });
|
||||
}
|
||||
|
||||
const targetRack = await Rack.findByPk(rackId, { transaction: t });
|
||||
if (!targetRack) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '目标机柜不存在' });
|
||||
}
|
||||
|
||||
const deviceHeight = height || device.height || 1;
|
||||
const positionCheck = await checkPositionAvailable(rackId, position, deviceHeight, deviceId, t);
|
||||
if (!positionCheck.available) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: positionCheck.reason });
|
||||
}
|
||||
|
||||
const beforeState = device.toJSON();
|
||||
|
||||
await device.update({
|
||||
name: name || device.name,
|
||||
type: type || device.type,
|
||||
model: model || device.model,
|
||||
serialNumber: serialNumber || device.serialNumber,
|
||||
height: deviceHeight,
|
||||
powerConsumption: powerConsumption || device.powerConsumption || 0,
|
||||
rackId: rackId,
|
||||
position: position,
|
||||
description: description || device.description,
|
||||
isIdle: false,
|
||||
idleDate: null,
|
||||
idleReason: null,
|
||||
warehouseId: null,
|
||||
sourceType: 'rack',
|
||||
status: 'running'
|
||||
}, { transaction: t });
|
||||
|
||||
await targetRack.update({
|
||||
currentPower: targetRack.currentPower + (powerConsumption || device.powerConsumption || 0)
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
const updatedDevice = await Device.findByPk(deviceId, {
|
||||
include: [
|
||||
{ model: Rack, include: [Room] }
|
||||
]
|
||||
});
|
||||
|
||||
await logDeviceOperation('shelve', `空闲设备【${device.name}】上架到机柜【${targetRack.name}】U${position}`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState: { ...beforeState, isIdle: true },
|
||||
afterState: updatedDevice.toJSON(),
|
||||
req,
|
||||
metadata: { rackId, position, type: 'idle_device_shelve' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: '设备上架成功',
|
||||
device: updatedDevice
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('设备上架失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const device = await Device.findOne({
|
||||
where: { deviceId: req.params.deviceId, isIdle: true }
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({ error: '空闲设备不存在' });
|
||||
}
|
||||
|
||||
const beforeState = device.toJSON();
|
||||
const allowedFields = ['name', 'type', 'model', 'idleReason', 'description', 'powerConsumption'];
|
||||
|
||||
allowedFields.forEach(field => {
|
||||
if (req.body[field] !== undefined) {
|
||||
device[field] = req.body[field];
|
||||
}
|
||||
});
|
||||
|
||||
if (req.body.warehouseId !== undefined) {
|
||||
device.warehouseId = req.body.warehouseId || null;
|
||||
device.sourceType = req.body.warehouseId ? 'warehouse' : 'rack';
|
||||
}
|
||||
|
||||
if (req.body.rackId !== undefined) {
|
||||
if (req.body.rackId) {
|
||||
const rack = await Rack.findByPk(req.body.rackId);
|
||||
if (!rack) {
|
||||
return res.status(404).json({ error: '机柜不存在' });
|
||||
}
|
||||
}
|
||||
device.rackId = req.body.rackId || null;
|
||||
if (!req.body.warehouseId) {
|
||||
device.sourceType = 'rack';
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.position !== undefined) {
|
||||
device.position = req.body.position || null;
|
||||
}
|
||||
|
||||
await device.save();
|
||||
|
||||
await logDeviceOperation('update', `更新空闲设备【${device.name}】`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState,
|
||||
afterState: device.toJSON(),
|
||||
req,
|
||||
metadata: { type: 'idle_device_update' }
|
||||
});
|
||||
|
||||
res.json(device);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:deviceId/restore', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { targetRackId, targetPosition } = req.body;
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const device = await Device.findOne({
|
||||
where: { deviceId, isIdle: true },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '空闲设备不存在' });
|
||||
}
|
||||
|
||||
if (!targetRackId || !targetPosition) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: '恢复设备需要指定目标机柜和位置' });
|
||||
}
|
||||
|
||||
const targetRack = await Rack.findByPk(targetRackId, { transaction: t });
|
||||
if (!targetRack) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '目标机柜不存在' });
|
||||
}
|
||||
|
||||
const positionCheck = await checkPositionAvailable(targetRackId, targetPosition, device.height || 1, deviceId, t);
|
||||
if (!positionCheck.available) {
|
||||
await t.rollback();
|
||||
return res.status(400).json({ error: positionCheck.reason });
|
||||
}
|
||||
|
||||
await device.update({
|
||||
isIdle: false,
|
||||
idleDate: null,
|
||||
idleReason: null,
|
||||
rackId: targetRackId,
|
||||
position: targetPosition,
|
||||
warehouseId: null,
|
||||
sourceType: 'rack',
|
||||
status: 'offline'
|
||||
}, { transaction: t });
|
||||
|
||||
await targetRack.update({
|
||||
currentPower: targetRack.currentPower + (device.powerConsumption || 0)
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
const updatedDevice = await Device.findByPk(deviceId, {
|
||||
include: [
|
||||
{ model: Rack, include: [Room] }
|
||||
]
|
||||
});
|
||||
|
||||
await logDeviceOperation('restore', `空闲设备【${device.name}】恢复到机柜【${targetRack.name}】U${targetPosition}`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState: { ...device.toJSON(), isIdle: true },
|
||||
afterState: updatedDevice.toJSON(),
|
||||
req,
|
||||
metadata: { targetRackId, targetPosition, type: 'idle_device_restore' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: '设备已恢复到设备管理',
|
||||
device: updatedDevice
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('恢复设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/batch-restore', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const { devices } = req.body;
|
||||
|
||||
console.log('========== batch-restore 开始 ==========');
|
||||
console.log('原始请求 body:', JSON.stringify(req.body));
|
||||
console.log('devices 参数:', devices);
|
||||
|
||||
if (!devices || !Array.isArray(devices) || devices.length === 0) {
|
||||
await t.rollback();
|
||||
console.log('错误: devices 参数无效');
|
||||
return res.status(400).json({ error: '请提供有效的设备列表' });
|
||||
}
|
||||
|
||||
const deviceIds = devices.map(d => d.deviceId).filter(Boolean);
|
||||
console.log('提取的 deviceIds:', deviceIds);
|
||||
console.log('deviceIds 类型:', typeof deviceIds, Array.isArray(deviceIds));
|
||||
|
||||
if (deviceIds.length === 0) {
|
||||
await t.rollback();
|
||||
console.log('错误: deviceIds 为空');
|
||||
return res.status(400).json({ error: '设备ID不能为空' });
|
||||
}
|
||||
|
||||
console.log('开始查询设备,条件:', { deviceId: { [Op.in]: deviceIds }, isIdle: true });
|
||||
|
||||
const idleDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds }, isIdle: true },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
console.log('查询到的空闲设备数量:', idleDevices.length);
|
||||
if (idleDevices.length > 0) {
|
||||
console.log('查询到的设备ID:', idleDevices.map(d => d.deviceId));
|
||||
}
|
||||
|
||||
if (idleDevices.length === 0) {
|
||||
console.log('没有找到空闲设备,检查设备是否存在:');
|
||||
const allDevices = await Device.findAll({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
console.log('设备表中存在的设备数量:', allDevices.length);
|
||||
if (allDevices.length > 0) {
|
||||
console.log('存在的设备及其 isIdle 状态:', allDevices.map(d => ({ deviceId: d.deviceId, isIdle: d.isIdle })));
|
||||
}
|
||||
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '没有找到空闲设备' });
|
||||
}
|
||||
|
||||
let restoredCount = 0;
|
||||
const results = [];
|
||||
|
||||
for (const device of idleDevices) {
|
||||
const deviceConfig = devices.find(d => d.deviceId === device.deviceId);
|
||||
if (!deviceConfig) continue;
|
||||
|
||||
const targetRackId = deviceConfig.targetRackId;
|
||||
const targetPosition = deviceConfig.targetPosition;
|
||||
|
||||
if (!targetRackId) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'skipped',
|
||||
reason: '未指定目标机柜'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRack = await Rack.findByPk(targetRackId, { transaction: t });
|
||||
if (!targetRack) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'failed',
|
||||
reason: '目标机柜不存在'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const height = device.height || 1;
|
||||
const position = targetPosition || 1;
|
||||
|
||||
const checkResult = await checkPositionAvailable(targetRackId, position, height, null, t);
|
||||
if (!checkResult.available) {
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'failed',
|
||||
reason: `U位${position}已被占用`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await device.update({
|
||||
isIdle: false,
|
||||
idleDate: null,
|
||||
idleReason: null,
|
||||
rackId: targetRackId,
|
||||
position: position,
|
||||
warehouseId: null,
|
||||
sourceType: 'rack',
|
||||
status: 'offline'
|
||||
}, { transaction: t });
|
||||
|
||||
await targetRack.update({
|
||||
currentPower: targetRack.currentPower + (device.powerConsumption || 0)
|
||||
}, { transaction: t });
|
||||
|
||||
restoredCount++;
|
||||
results.push({
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
status: 'success',
|
||||
targetRack: targetRack.name,
|
||||
targetPosition: position
|
||||
});
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const successCount = results.filter(r => r.status === 'success').length;
|
||||
const failedCount = results.filter(r => r.status === 'failed').length;
|
||||
const skippedCount = results.filter(r => r.status === 'skipped').length;
|
||||
|
||||
await logDeviceOperation('batch_restore', `批量上架 ${successCount} 台空闲设备`, {
|
||||
targetId: deviceIds.join(','),
|
||||
targetName: `${successCount}台设备`,
|
||||
req,
|
||||
metadata: { results, type: 'batch_idle_device_restore' }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `成功上架 ${successCount} 台设备`,
|
||||
total: idleDevices.length,
|
||||
restored: successCount,
|
||||
failed: failedCount,
|
||||
skipped: skippedCount,
|
||||
details: results
|
||||
});
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('批量上架设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:deviceId', async (req, res) => {
|
||||
const t = await require('../db').sequelize.transaction();
|
||||
try {
|
||||
const device = await Device.findOne({
|
||||
where: { deviceId: req.params.deviceId, isIdle: true },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
await t.rollback();
|
||||
return res.status(404).json({ error: '空闲设备不存在' });
|
||||
}
|
||||
|
||||
const beforeState = device.toJSON();
|
||||
|
||||
await device.destroy({ transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
await logDeviceOperation('delete', `删除空闲设备【${device.name || device.deviceId}】`, {
|
||||
targetId: device.deviceId,
|
||||
targetName: device.name,
|
||||
beforeState,
|
||||
req,
|
||||
metadata: { type: 'idle_device_delete' }
|
||||
});
|
||||
|
||||
res.json({ message: '空闲设备删除成功' });
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error('删除空闲设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
async function checkPositionAvailable(rackId, position, height, excludeDeviceId = null, transaction = null) {
|
||||
if (!position || position <= 0) {
|
||||
return { available: true, reason: null };
|
||||
}
|
||||
|
||||
const deviceHeight = height || 1;
|
||||
const startU = position;
|
||||
const endU = position + deviceHeight - 1;
|
||||
|
||||
const queryOptions = {
|
||||
where: {
|
||||
rackId: rackId,
|
||||
position: { [Op.ne]: null },
|
||||
isIdle: false
|
||||
},
|
||||
attributes: ['deviceId', 'position', 'height']
|
||||
};
|
||||
|
||||
if (transaction) {
|
||||
queryOptions.transaction = transaction;
|
||||
}
|
||||
|
||||
const existingDevices = await Device.findAll(queryOptions);
|
||||
|
||||
for (const d of existingDevices) {
|
||||
if (excludeDeviceId && d.deviceId === excludeDeviceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existStart = d.position;
|
||||
const existEnd = d.position + (d.height || 1) - 1;
|
||||
|
||||
if (!(endU < existStart || startU > existEnd)) {
|
||||
return {
|
||||
available: false,
|
||||
reason: `U位冲突:机柜中已有设备 ${d.deviceId} 占用 U${existStart}${existEnd !== existStart ? '-' + existEnd : ''}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { available: true, reason: null };
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,263 @@
|
||||
const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
module,
|
||||
operationType,
|
||||
targetId,
|
||||
operatorId,
|
||||
keyword,
|
||||
startDate,
|
||||
endDate,
|
||||
result
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
const limit = Math.min(parseInt(pageSize), 100);
|
||||
|
||||
const where = {};
|
||||
|
||||
if (module && module !== 'all') {
|
||||
where.module = module;
|
||||
}
|
||||
|
||||
if (operationType && operationType !== 'all') {
|
||||
where.operationType = operationType;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
where.targetId = { [Op.like]: `%${targetId}%` };
|
||||
}
|
||||
|
||||
if (operatorId) {
|
||||
where.operatorId = operatorId;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where[Op.or] = [
|
||||
{ operationDescription: { [Op.like]: `%${keyword}%` } },
|
||||
{ targetName: { [Op.like]: `%${keyword}%` } },
|
||||
{ operatorName: { [Op.like]: `%${keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
if (result && result !== 'all') {
|
||||
where.result = result;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
const endOfDay = new Date(endDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
where.createdAt[Op.lte] = endOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows: logs } = await OperationLog.findAndCountAll({
|
||||
where,
|
||||
order: [['createdAt', 'DESC']],
|
||||
offset,
|
||||
limit
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
logs
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作日志失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作日志失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/modules', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const modules = await OperationLog.findAll({
|
||||
attributes: ['module'],
|
||||
group: ['module']
|
||||
});
|
||||
|
||||
const moduleList = modules.map(m => ({
|
||||
value: m.module,
|
||||
label: getModuleName(m.module)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: moduleList
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取模块列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取模块列表失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/types', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { module } = req.query;
|
||||
|
||||
const where = {};
|
||||
if (module && module !== 'all') {
|
||||
where.module = module;
|
||||
}
|
||||
|
||||
const types = await OperationLog.findAll({
|
||||
where,
|
||||
attributes: ['operationType'],
|
||||
group: ['operationType']
|
||||
});
|
||||
|
||||
const typeList = types.map(t => ({
|
||||
value: t.operationType,
|
||||
label: getOperationTypeName(t.operationType)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: typeList
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作类型列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作类型列表失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/statistics', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const where = {};
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
const endOfDay = new Date(endDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
where.createdAt[Op.lte] = endOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
const [moduleStats, typeStats, dailyStats] = await Promise.all([
|
||||
OperationLog.findAll({
|
||||
where,
|
||||
attributes: ['module', [sequelize.fn('COUNT', sequelize.col('module')), 'count']],
|
||||
group: ['module']
|
||||
}),
|
||||
OperationLog.findAll({
|
||||
where,
|
||||
attributes: ['operationType', [sequelize.fn('COUNT', sequelize.col('operationType')), 'count']],
|
||||
group: ['operationType']
|
||||
}),
|
||||
OperationLog.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[sequelize.fn('DATE', sequelize.col('createdAt')), 'date'],
|
||||
[sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: [sequelize.fn('DATE', sequelize.col('createdAt'))],
|
||||
order: [[sequelize.fn('DATE', sequelize.col('createdAt')), 'DESC']],
|
||||
limit: 30
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
byModule: moduleStats.map(s => ({ module: s.module, count: s.get('count') })),
|
||||
byType: typeStats.map(s => ({ type: s.operationType, count: s.get('count') })),
|
||||
byDay: dailyStats.map(s => ({ date: s.get('date'), count: s.get('count') }))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作日志统计失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作日志统计失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:recordId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const log = await OperationLog.findByPk(req.params.recordId);
|
||||
|
||||
if (!log) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '日志记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: log
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作日志详情失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作日志详情失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getModuleName(module) {
|
||||
const moduleNames = {
|
||||
device: '设备管理',
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
consumable: '耗材管理',
|
||||
rack: '机柜管理',
|
||||
room: '机房管理',
|
||||
ticket: '工单管理',
|
||||
backup: '备份管理'
|
||||
};
|
||||
return moduleNames[module] || module;
|
||||
}
|
||||
|
||||
function getOperationTypeName(type) {
|
||||
const typeNames = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
batch_delete: '批量删除',
|
||||
batch_update: '批量更新',
|
||||
status_change: '状态变更',
|
||||
move: '移动',
|
||||
permission_change: '权限变更',
|
||||
import: '导入',
|
||||
export: '导出'
|
||||
};
|
||||
return typeNames[type] || type;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,6 +4,7 @@ const Permission = require('../models/Permission');
|
||||
const UserRole = require('../models/UserRole');
|
||||
const User = require('../models/User');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { logRoleOperation } = require('../utils/operationLogger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -134,6 +135,18 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
sort: sort || 0
|
||||
});
|
||||
|
||||
const permissionNames = permissions && permissions.length > 0
|
||||
? permissions.join('、')
|
||||
: '无';
|
||||
|
||||
await logRoleOperation('create', `创建角色【${roleName}】(编码:${roleCode},权限:${permissionNames})`, {
|
||||
targetId: role.roleId,
|
||||
targetName: roleName,
|
||||
afterState: role.toJSON(),
|
||||
req,
|
||||
metadata: { roleCode, permissions, permissionNames }
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '创建成功',
|
||||
@@ -160,6 +173,8 @@ router.put('/:roleId', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const beforeState = role.toJSON();
|
||||
|
||||
if (roleName !== undefined) role.roleName = roleName;
|
||||
if (description !== undefined) role.description = description;
|
||||
if (permissions !== undefined) role.permissions = permissions;
|
||||
@@ -168,6 +183,53 @@ router.put('/:roleId', authMiddleware, async (req, res) => {
|
||||
|
||||
await role.save();
|
||||
|
||||
const afterState = role.toJSON();
|
||||
const changedFields = {};
|
||||
|
||||
if (roleName !== undefined && beforeState.roleName !== roleName) {
|
||||
changedFields.roleName = { from: beforeState.roleName, to: roleName };
|
||||
}
|
||||
if (description !== undefined && beforeState.description !== description) {
|
||||
changedFields.description = { from: beforeState.description, to: description };
|
||||
}
|
||||
if (permissions !== undefined) {
|
||||
const oldPerms = (beforeState.permissions || []).sort().join(',');
|
||||
const newPerms = (permissions || []).sort().join(',');
|
||||
if (oldPerms !== newPerms) {
|
||||
changedFields.permissions = { from: beforeState.permissions, to: permissions };
|
||||
}
|
||||
}
|
||||
if (status !== undefined && beforeState.status !== status) {
|
||||
const statusText = { active: '启用', inactive: '禁用' };
|
||||
changedFields.status = { from: beforeState.status, to: status, fromText: statusText[beforeState.status], toText: statusText[status] };
|
||||
}
|
||||
|
||||
const changeDetails = Object.entries(changedFields).map(([field, values]) => {
|
||||
const fieldNames = { roleName: '角色名称', description: '描述', permissions: '权限', status: '状态' };
|
||||
const displayName = fieldNames[field] || field;
|
||||
|
||||
if (field === 'permissions') {
|
||||
return `权限: ${(values.from || []).join('、') || '无'} → ${(values.to || []).join('、') || '无'}`;
|
||||
}
|
||||
if (field === 'status') {
|
||||
return `状态: ${values.fromText} → ${values.toText}`;
|
||||
}
|
||||
return `${displayName}: ${values.from ?? '空'} → ${values.to ?? '空'}`;
|
||||
}).join(';');
|
||||
|
||||
const updateDesc = changeDetails
|
||||
? `更新角色【${role.roleName}】:${changeDetails}`
|
||||
: `更新角色【${role.roleName}】`;
|
||||
|
||||
await logRoleOperation('update', updateDesc, {
|
||||
targetId: role.roleId,
|
||||
targetName: role.roleName,
|
||||
beforeState,
|
||||
afterState,
|
||||
req,
|
||||
metadata: { changedFields, oldRoleName: beforeState.roleName, oldPermissions: beforeState.permissions }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '更新成功',
|
||||
@@ -208,8 +270,20 @@ router.delete('/:roleId', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const roleName = role.roleName;
|
||||
const roleCode = role.roleCode;
|
||||
const beforeState = role.toJSON();
|
||||
|
||||
await role.destroy();
|
||||
|
||||
await logRoleOperation('delete', `删除角色【${roleName}】(编码:${roleCode},权限:${(role.permissions || []).join('、') || '无'})`, {
|
||||
targetId: req.params.roleId,
|
||||
targetName: roleName,
|
||||
beforeState,
|
||||
req,
|
||||
metadata: { roleCode, userCount, permissions: role.permissions }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
|
||||
+13
-3
@@ -8,10 +8,20 @@ const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema
|
||||
// 获取所有机房
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rooms = await Room.findAll({
|
||||
include: Rack
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize) || 100;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const { count, rows } = await Room.findAndCountAll({
|
||||
include: [{ model: Rack, attributes: ['rackId', 'name'] }],
|
||||
offset: offset,
|
||||
limit: pageSize
|
||||
});
|
||||
|
||||
res.json({
|
||||
rooms: rows,
|
||||
total: count
|
||||
});
|
||||
res.json(rooms);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
+123
-7
@@ -7,6 +7,7 @@ const Role = require('../models/Role');
|
||||
const UserRole = require('../models/UserRole');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { SALT_ROUNDS, PASSWORD_MIN_LENGTH, FILE_UPLOAD, PAGINATION } = require('../config');
|
||||
const { logUserOperation } = require('../utils/operationLogger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -16,22 +17,30 @@ const generateId = () => {
|
||||
|
||||
const getWhereClause = (query) => {
|
||||
const where = {};
|
||||
|
||||
|
||||
if (query.username) {
|
||||
where.username = { [Op.like]: `%${query.username}%` };
|
||||
}
|
||||
|
||||
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
|
||||
if (query.realName) {
|
||||
where.realName = { [Op.like]: `%${query.realName}%` };
|
||||
}
|
||||
|
||||
|
||||
return where;
|
||||
};
|
||||
|
||||
const getUserRoleIds = async (userId) => {
|
||||
const userRoles = await UserRole.findAll({
|
||||
where: { UserId: userId },
|
||||
attributes: ['RoleId']
|
||||
});
|
||||
return userRoles.map(ur => ur.RoleId);
|
||||
};
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
@@ -204,6 +213,23 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const roleNames = roleIds && roleIds.length > 0
|
||||
? (await Role.findAll({ where: { roleId: { [Op.in]: roleIds } } })).map(r => r.roleName).join('、')
|
||||
: '未分配角色';
|
||||
|
||||
await logUserOperation('create', `创建用户【${username}】(姓名:${realName || '未填写'},邮箱:${email || '未填写'},角色:${roleNames})`, {
|
||||
targetId: user.userId,
|
||||
targetName: username,
|
||||
afterState: {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
status: user.status
|
||||
},
|
||||
req,
|
||||
metadata: { roleIds, roleNames }
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '创建成功',
|
||||
@@ -236,9 +262,20 @@ router.put('/:userId', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const beforeState = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
realName: user.realName,
|
||||
status: user.status,
|
||||
remark: user.remark
|
||||
};
|
||||
|
||||
const oldRoleIds = roleIds !== undefined ? null : await getUserRoleIds(user.userId);
|
||||
|
||||
if (username !== undefined && username !== user.username) {
|
||||
const existingUser = await User.findOne({
|
||||
where: { username, userId: { [Op.ne]: user.userId } }
|
||||
const existingUser = await User.findOne({
|
||||
where: { username, userId: { [Op.ne]: user.userId } }
|
||||
});
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
@@ -261,21 +298,82 @@ router.put('/:userId', authMiddleware, async (req, res) => {
|
||||
|
||||
await user.save();
|
||||
|
||||
let permissionChanged = false;
|
||||
let oldRoleNames = [];
|
||||
let newRoleNames = [];
|
||||
|
||||
if (roleIds !== undefined) {
|
||||
const oldRoles = await Role.findAll({ where: { roleId: { [Op.in]: oldRoleIds || [] } } });
|
||||
oldRoleNames = oldRoles.map(r => r.roleName);
|
||||
|
||||
await UserRole.destroy({ where: { UserId: user.userId } });
|
||||
|
||||
|
||||
for (const roleId of roleIds) {
|
||||
await UserRole.create({
|
||||
UserId: user.userId,
|
||||
RoleId: roleId
|
||||
});
|
||||
}
|
||||
|
||||
const newRoles = await Role.findAll({ where: { roleId: { [Op.in]: roleIds } } });
|
||||
newRoleNames = newRoles.map(r => r.roleName);
|
||||
permissionChanged = true;
|
||||
}
|
||||
|
||||
const updatedUser = await User.findByPk(req.params.userId, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
|
||||
if (permissionChanged) {
|
||||
const roleChangeDesc = `变更用户【${updatedUser.username}】的角色:${oldRoleNames.join('、') || '无'} → ${newRoleNames.join('、') || '无'}`;
|
||||
await logUserOperation('permission_change', roleChangeDesc, {
|
||||
targetId: updatedUser.userId,
|
||||
targetName: updatedUser.username,
|
||||
beforeState: { ...beforeState, roleIds: oldRoleIds, roleNames: oldRoleNames },
|
||||
afterState: { ...beforeState, roleIds, roleNames: newRoleNames },
|
||||
req,
|
||||
metadata: { oldRoleIds, newRoleIds: roleIds, oldRoleNames, newRoleNames }
|
||||
});
|
||||
} else {
|
||||
const afterState = {
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
phone: updatedUser.phone,
|
||||
realName: updatedUser.realName,
|
||||
status: updatedUser.status,
|
||||
remark: updatedUser.remark
|
||||
};
|
||||
|
||||
const changedFields = {};
|
||||
for (const key of Object.keys(beforeState)) {
|
||||
if (JSON.stringify(beforeState[key]) !== JSON.stringify(afterState[key])) {
|
||||
changedFields[key] = { from: beforeState[key], to: afterState[key] };
|
||||
}
|
||||
}
|
||||
|
||||
const changeDetails = Object.entries(changedFields).map(([field, values]) => {
|
||||
const fieldNames = {
|
||||
username: '用户名', email: '邮箱', phone: '电话', realName: '姓名',
|
||||
status: '状态', remark: '备注'
|
||||
};
|
||||
const displayName = fieldNames[field] || field;
|
||||
return `${displayName}: ${values.from ?? '空'} → ${values.to ?? '空'}`;
|
||||
}).join(';');
|
||||
|
||||
const updateDesc = changeDetails
|
||||
? `更新用户【${updatedUser.username}】:${changeDetails}`
|
||||
: `更新用户【${updatedUser.username}】`;
|
||||
|
||||
await logUserOperation('update', updateDesc, {
|
||||
targetId: updatedUser.userId,
|
||||
targetName: updatedUser.username,
|
||||
beforeState,
|
||||
afterState,
|
||||
req,
|
||||
metadata: { changedFields }
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '更新成功',
|
||||
@@ -343,9 +441,27 @@ router.delete('/:userId', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const userName = user.username;
|
||||
const userRealName = user.realName;
|
||||
const userEmail = user.email;
|
||||
const beforeState = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
status: user.status
|
||||
};
|
||||
|
||||
await UserRole.destroy({ where: { UserId: user.userId } });
|
||||
await user.destroy();
|
||||
|
||||
await logUserOperation('delete', `删除用户【${userName}】(姓名:${userRealName || '未填写'},邮箱:${userEmail || '未填写'})`, {
|
||||
targetId: req.params.userId,
|
||||
targetName: userName,
|
||||
beforeState,
|
||||
req,
|
||||
metadata: { deletedUsername: userName, realName: userRealName, email: userEmail }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Op } = require('sequelize');
|
||||
const Warehouse = require('../models/Warehouse');
|
||||
const Device = require('../models/Device');
|
||||
const { logDeviceOperation } = require('../utils/operationLogger');
|
||||
|
||||
async function generateWarehouseId() {
|
||||
const warehouses = await Warehouse.findAll({
|
||||
where: {
|
||||
warehouseId: { [Op.like]: 'WH%' }
|
||||
}
|
||||
});
|
||||
|
||||
let maxNumber = 0;
|
||||
warehouses.forEach(wh => {
|
||||
const match = wh.warehouseId.match(/^WH(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num > maxNumber) {
|
||||
maxNumber = num;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newNumber = maxNumber + 1;
|
||||
return `WH${String(newNumber).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { keyword, status, page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where = {};
|
||||
|
||||
if (keyword) {
|
||||
where[Op.or] = [
|
||||
{ warehouseId: { [Op.like]: `%${keyword}%` } },
|
||||
{ name: { [Op.like]: `%${keyword}%` } },
|
||||
{ location: { [Op.like]: `%${keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const { count, rows } = await Warehouse.findAndCountAll({
|
||||
where,
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(pageSize),
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
const warehousesWithCount = await Promise.all(
|
||||
rows.map(async (warehouse) => {
|
||||
const deviceCount = await Device.count({
|
||||
where: { warehouseId: warehouse.warehouseId, isIdle: true }
|
||||
});
|
||||
return {
|
||||
...warehouse.toJSON(),
|
||||
deviceCount
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
warehouses: warehousesWithCount,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取库房列表失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:warehouseId', async (req, res) => {
|
||||
try {
|
||||
const warehouse = await Warehouse.findByPk(req.params.warehouseId);
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({ error: '库房不存在' });
|
||||
}
|
||||
|
||||
const deviceCount = await Device.count({
|
||||
where: { warehouseId: warehouse.warehouseId, isIdle: true }
|
||||
});
|
||||
|
||||
res.json({
|
||||
...warehouse.toJSON(),
|
||||
deviceCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:warehouseId/devices', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const warehouse = await Warehouse.findByPk(req.params.warehouseId);
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({ error: '库房不存在' });
|
||||
}
|
||||
|
||||
const { count, rows } = await Device.findAndCountAll({
|
||||
where: { warehouseId: req.params.warehouseId, isIdle: true },
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(pageSize),
|
||||
order: [['idleDate', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
devices: rows,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取库房设备失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, location, capacity, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: '库房名称不能为空' });
|
||||
}
|
||||
|
||||
const warehouseId = await generateWarehouseId();
|
||||
|
||||
const warehouse = await Warehouse.create({
|
||||
warehouseId,
|
||||
name,
|
||||
location: location || '',
|
||||
capacity: capacity || 100,
|
||||
status: 'active',
|
||||
description: description || ''
|
||||
});
|
||||
|
||||
await logDeviceOperation('create', `创建库房【${name}】`, {
|
||||
targetId: warehouse.warehouseId,
|
||||
targetName: name,
|
||||
afterState: warehouse.toJSON(),
|
||||
req,
|
||||
metadata: { type: 'warehouse_create' }
|
||||
});
|
||||
|
||||
res.status(201).json(warehouse);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:warehouseId', async (req, res) => {
|
||||
try {
|
||||
const warehouse = await Warehouse.findByPk(req.params.warehouseId);
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({ error: '库房不存在' });
|
||||
}
|
||||
|
||||
const beforeState = warehouse.toJSON();
|
||||
const { name, location, capacity, status, description } = req.body;
|
||||
|
||||
if (name) warehouse.name = name;
|
||||
if (location !== undefined) warehouse.location = location;
|
||||
if (capacity !== undefined) warehouse.capacity = capacity;
|
||||
if (status) warehouse.status = status;
|
||||
if (description !== undefined) warehouse.description = description;
|
||||
|
||||
await warehouse.save();
|
||||
|
||||
await logDeviceOperation('update', `更新库房【${warehouse.name}】`, {
|
||||
targetId: warehouse.warehouseId,
|
||||
targetName: warehouse.name,
|
||||
beforeState,
|
||||
afterState: warehouse.toJSON(),
|
||||
req,
|
||||
metadata: { type: 'warehouse_update' }
|
||||
});
|
||||
|
||||
res.json(warehouse);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:warehouseId', async (req, res) => {
|
||||
try {
|
||||
const warehouse = await Warehouse.findByPk(req.params.warehouseId);
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({ error: '库房不存在' });
|
||||
}
|
||||
|
||||
const idleDeviceCount = await Device.count({
|
||||
where: { warehouseId: req.params.warehouseId, isIdle: true }
|
||||
});
|
||||
|
||||
if (idleDeviceCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: `库房中还有 ${idleDeviceCount} 台空闲设备,请先处理后再删除`
|
||||
});
|
||||
}
|
||||
|
||||
const warehouseName = warehouse.name;
|
||||
await warehouse.destroy();
|
||||
|
||||
await logDeviceOperation('delete', `删除库房【${warehouseName}】`, {
|
||||
targetId: req.params.warehouseId,
|
||||
targetName: warehouseName,
|
||||
req,
|
||||
metadata: { type: 'warehouse_delete' }
|
||||
});
|
||||
|
||||
res.json({ message: '库房删除成功' });
|
||||
} catch (error) {
|
||||
console.error('删除库房失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,73 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('开始 MySQL 数据库迁移...');
|
||||
console.log('数据库类型:', process.env.DB_TYPE);
|
||||
|
||||
// 检查列是否存在(MySQL 方式)
|
||||
const [columns] = await sequelize.query(`
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = 'deviceFields'
|
||||
AND COLUMN_NAME = 'isSystem'
|
||||
AND TABLE_SCHEMA = '${process.env.MYSQL_DATABASE || 'it_assest'}'
|
||||
`);
|
||||
|
||||
if (columns.length === 0) {
|
||||
// 添加 isSystem 列
|
||||
await sequelize.query(`
|
||||
ALTER TABLE deviceFields
|
||||
ADD COLUMN isSystem BOOLEAN DEFAULT 0
|
||||
COMMENT '是否为系统字段,系统字段不可删除'
|
||||
`);
|
||||
console.log('✓ isSystem 列添加成功');
|
||||
} else {
|
||||
console.log('✓ isSystem 列已存在');
|
||||
}
|
||||
|
||||
// 更新系统字段标记
|
||||
const systemFields = [
|
||||
'deviceId', 'name', 'type', 'model', 'serialNumber',
|
||||
'rackId', 'position', 'height', 'powerConsumption',
|
||||
'status', 'purchaseDate', 'warrantyExpiry'
|
||||
];
|
||||
|
||||
for (const fieldName of systemFields) {
|
||||
await sequelize.query(`
|
||||
UPDATE deviceFields SET isSystem = 1 WHERE fieldName = '${fieldName}'
|
||||
`);
|
||||
console.log(`✓ 标记系统字段: ${fieldName}`);
|
||||
}
|
||||
|
||||
// 验证结果
|
||||
const [results] = await sequelize.query(`
|
||||
SELECT fieldName, displayName, isSystem
|
||||
FROM deviceFields
|
||||
ORDER BY isSystem DESC, fieldName
|
||||
`);
|
||||
|
||||
console.log('\n========== 迁移结果 ==========');
|
||||
console.log('字段总数:', results.length);
|
||||
console.log('系统字段数:', results.filter(r => r.isSystem).length);
|
||||
console.log('\n系统字段列表:');
|
||||
results.filter(r => r.isSystem).forEach(r => {
|
||||
console.log(` 🔒 ${r.displayName} (${r.fieldName})`);
|
||||
});
|
||||
console.log('\n可选字段列表:');
|
||||
results.filter(r => !r.isSystem).forEach(r => {
|
||||
console.log(` ✏️ ${r.displayName} (${r.fieldName})`);
|
||||
});
|
||||
console.log('==============================\n');
|
||||
|
||||
console.log('✅ 迁移完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ 迁移失败:', error.message);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -1,49 +0,0 @@
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('开始 SQLite 数据库迁移...');
|
||||
|
||||
// 检查列是否存在(SQLite 使用 PRAGMA)
|
||||
const tableInfo = await sequelize.query(
|
||||
"PRAGMA table_info(deviceFields)",
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const hasIsSystemColumn = tableInfo.some(col => col.name === 'isSystem');
|
||||
|
||||
if (!hasIsSystemColumn) {
|
||||
// 添加 isSystem 列
|
||||
await sequelize.query(
|
||||
"ALTER TABLE deviceFields ADD COLUMN isSystem BOOLEAN DEFAULT 0",
|
||||
{ type: sequelize.QueryTypes.RAW }
|
||||
);
|
||||
console.log('isSystem 列添加成功');
|
||||
} else {
|
||||
console.log('isSystem 列已存在');
|
||||
}
|
||||
|
||||
// 更新系统字段标记
|
||||
const systemFields = [
|
||||
'deviceId', 'name', 'type', 'model', 'serialNumber',
|
||||
'rackId', 'position', 'height', 'powerConsumption',
|
||||
'status', 'purchaseDate', 'warrantyExpiry'
|
||||
];
|
||||
|
||||
for (const fieldName of systemFields) {
|
||||
await sequelize.query(
|
||||
`UPDATE deviceFields SET isSystem = 1 WHERE fieldName = '${fieldName}'`,
|
||||
{ type: sequelize.QueryTypes.RAW }
|
||||
);
|
||||
console.log(`标记系统字段: ${fieldName}`);
|
||||
}
|
||||
|
||||
console.log('迁移完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('迁移失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -84,6 +84,16 @@ const migrations = [
|
||||
name: '暂存设备自定义字段',
|
||||
description: '为 pending_devices 表添加 customFields 字段,支持自定义字段存储',
|
||||
migrate: migratePendingDeviceCustomFields
|
||||
},
|
||||
{
|
||||
name: '空闲设备与业务关联',
|
||||
description: '创建 businesses、warehouses、device_business 表,为 devices 添加空闲设备字段',
|
||||
migrate: migrateIdleDeviceAndBusiness
|
||||
},
|
||||
{
|
||||
name: '设备字段系统标记',
|
||||
description: '为 deviceFields 表添加 isSystem 字段,标记系统字段不可删除',
|
||||
migrate: migrateDeviceFieldsIsSystem
|
||||
}
|
||||
];
|
||||
|
||||
@@ -565,6 +575,134 @@ async function migratePendingDeviceCustomFields() {
|
||||
await addColumnIfNotExists('pending_devices', 'customFields', columnDef);
|
||||
}
|
||||
|
||||
async function migrateDeviceFieldsIsSystem() {
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
if (!(await tableExists('deviceFields'))) {
|
||||
console.log(' deviceFields 表不存在,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
const [columns] = await sequelize.query(`
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = 'deviceFields'
|
||||
AND COLUMN_NAME = 'isSystem'
|
||||
AND TABLE_SCHEMA = '${process.env.MYSQL_DATABASE || 'it_assest'}'
|
||||
`);
|
||||
|
||||
if (columns.length === 0) {
|
||||
await sequelize.query(`
|
||||
ALTER TABLE deviceFields
|
||||
ADD COLUMN isSystem BOOLEAN DEFAULT 0
|
||||
COMMENT '是否为系统字段,系统字段不可删除'
|
||||
`);
|
||||
console.log(' deviceFields 表添加 isSystem 字段成功');
|
||||
} else {
|
||||
console.log(' deviceFields 表 isSystem 字段已存在,跳过');
|
||||
}
|
||||
} else {
|
||||
const columns = await getTableColumns('deviceFields');
|
||||
if (!columns.includes('isSystem')) {
|
||||
await sequelize.query(
|
||||
"ALTER TABLE deviceFields ADD COLUMN isSystem BOOLEAN DEFAULT 0",
|
||||
{ type: sequelize.QueryTypes.RAW }
|
||||
);
|
||||
console.log(' deviceFields 表添加 isSystem 字段成功');
|
||||
} else {
|
||||
console.log(' deviceFields 表 isSystem 字段已存在,跳过');
|
||||
}
|
||||
}
|
||||
|
||||
const systemFields = [
|
||||
'deviceId', 'name', 'type', 'model', 'serialNumber',
|
||||
'rackId', 'position', 'height', 'powerConsumption',
|
||||
'status', 'purchaseDate', 'warrantyExpiry'
|
||||
];
|
||||
|
||||
for (const fieldName of systemFields) {
|
||||
await sequelize.query(
|
||||
`UPDATE deviceFields SET isSystem = 1 WHERE fieldName = ?`,
|
||||
{ replacements: [fieldName], type: sequelize.QueryTypes.RAW }
|
||||
);
|
||||
console.log(` 标记系统字段: ${fieldName}`);
|
||||
}
|
||||
|
||||
console.log(' 设备字段系统标记迁移完成');
|
||||
}
|
||||
|
||||
async function migrateIdleDeviceAndBusiness() {
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
const dialect = sequelize.getDialect();
|
||||
|
||||
if (dialect === 'sqlite') {
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await tableExists('businesses'))) {
|
||||
await queryInterface.createTable('businesses', {
|
||||
businessId: { type: sequelize.Sequelize.STRING, primaryKey: true, allowNull: false, unique: true },
|
||||
name: { type: sequelize.Sequelize.STRING, allowNull: false },
|
||||
description: { type: sequelize.Sequelize.TEXT },
|
||||
status: { type: sequelize.Sequelize.ENUM('active', 'offline'), defaultValue: 'active' },
|
||||
offlineDate: { type: sequelize.Sequelize.DATE },
|
||||
offlineReason: { type: sequelize.Sequelize.STRING },
|
||||
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
||||
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false }
|
||||
});
|
||||
console.log(' businesses 表创建成功');
|
||||
} else {
|
||||
console.log(' businesses 表已存在,跳过');
|
||||
}
|
||||
|
||||
if (!(await tableExists('warehouses'))) {
|
||||
await queryInterface.createTable('warehouses', {
|
||||
warehouseId: { type: sequelize.Sequelize.STRING, primaryKey: true, allowNull: false, unique: true },
|
||||
name: { type: sequelize.Sequelize.STRING, allowNull: false },
|
||||
location: { type: sequelize.Sequelize.STRING },
|
||||
capacity: { type: sequelize.Sequelize.INTEGER, defaultValue: 100 },
|
||||
status: { type: sequelize.Sequelize.ENUM('active', 'inactive'), defaultValue: 'active' },
|
||||
description: { type: sequelize.Sequelize.TEXT },
|
||||
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
||||
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false }
|
||||
});
|
||||
console.log(' warehouses 表创建成功');
|
||||
} else {
|
||||
console.log(' warehouses 表已存在,跳过');
|
||||
}
|
||||
|
||||
if (!(await tableExists('device_business'))) {
|
||||
await queryInterface.createTable('device_business', {
|
||||
id: { type: sequelize.Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
deviceId: { type: sequelize.Sequelize.STRING, allowNull: false },
|
||||
businessId: { type: sequelize.Sequelize.STRING, allowNull: false },
|
||||
isPrimary: { type: sequelize.Sequelize.BOOLEAN, defaultValue: false },
|
||||
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
||||
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false }
|
||||
});
|
||||
console.log(' device_business 表创建成功');
|
||||
} else {
|
||||
console.log(' device_business 表已存在,跳过');
|
||||
}
|
||||
|
||||
if (await tableExists('devices')) {
|
||||
await addColumnIfNotExists('devices', 'isIdle', 'BOOLEAN DEFAULT 0');
|
||||
await addColumnIfNotExists('devices', 'idleDate', 'DATETIME');
|
||||
await addColumnIfNotExists('devices', 'idleReason', 'TEXT');
|
||||
await addColumnIfNotExists('devices', 'warehouseId', 'VARCHAR(255)');
|
||||
await addColumnIfNotExists('devices', 'sourceType', "TEXT DEFAULT 'rack'");
|
||||
}
|
||||
|
||||
console.log(' 空闲设备与业务关联迁移完成');
|
||||
} finally {
|
||||
if (dialect === 'sqlite') {
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
runMigrations().catch(error => {
|
||||
console.error('迁移执行失败:', error);
|
||||
|
||||
+58
-2
@@ -1,4 +1,5 @@
|
||||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const { ensureJwtSecret } = require('./initConfig');
|
||||
ensureJwtSecret();
|
||||
|
||||
@@ -84,6 +85,23 @@ async function syncBackupLogModel() {
|
||||
console.log('备份日志模型同步完成');
|
||||
}
|
||||
|
||||
async function syncOperationLogModel() {
|
||||
const OperationLog = require('./models/OperationLog');
|
||||
await OperationLog.sync();
|
||||
console.log('操作日志模型同步完成');
|
||||
}
|
||||
|
||||
async function syncBusinessModels() {
|
||||
const Business = require('./models/Business');
|
||||
const DeviceBusiness = require('./models/DeviceBusiness');
|
||||
const Warehouse = require('./models/Warehouse');
|
||||
|
||||
await Business.sync({ alter: true });
|
||||
await DeviceBusiness.sync({ alter: true });
|
||||
await Warehouse.sync({ alter: true });
|
||||
console.log('业务/设备关联/库房模型同步完成');
|
||||
}
|
||||
|
||||
async function initDefaultSystemSettings() {
|
||||
console.log('开始初始化系统设置默认值...');
|
||||
const { initDefaultSettings } = require('./routes/systemSettings');
|
||||
@@ -144,6 +162,8 @@ async function initializeApp() {
|
||||
await syncConsumableModels();
|
||||
await syncInventoryModels();
|
||||
await syncBackupLogModel();
|
||||
await syncOperationLogModel();
|
||||
await syncBusinessModels();
|
||||
await initDefaultSystemSettings();
|
||||
await initFaultCategories();
|
||||
await initAutoBackupScheduler();
|
||||
@@ -155,6 +175,9 @@ async function initializeApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const { specs, customCSS } = require('./swagger');
|
||||
|
||||
initializeApp();
|
||||
|
||||
const deviceRoutes = require('./routes/devices');
|
||||
@@ -178,6 +201,10 @@ const networkCardRoutes = require('./routes/networkCards');
|
||||
const inventoryRoutes = require('./routes/inventory');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
const statisticsRoutes = require('./routes/statistics');
|
||||
const operationLogsRoutes = require('./routes/operationLogs');
|
||||
const idleDeviceRoutes = require('./routes/idleDevices');
|
||||
const warehouseRoutes = require('./routes/warehouses');
|
||||
const dangerousOperationsRoutes = require('./routes/dangerousOperations');
|
||||
|
||||
app.use('/api/devices', deviceRoutes);
|
||||
app.use('/api/racks', rackRoutes);
|
||||
@@ -200,9 +227,34 @@ app.use('/api/network-cards', networkCardRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/statistics', statisticsRoutes);
|
||||
app.use('/api/operation-logs', operationLogsRoutes);
|
||||
app.use('/api/idle-devices', idleDeviceRoutes);
|
||||
app.use('/api/warehouses', warehouseRoutes);
|
||||
app.use('/api/dangerous-operations', dangerousOperationsRoutes);
|
||||
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs, {
|
||||
customCss: customCSS,
|
||||
customSiteTitle: 'IDC设备管理系统 API文档',
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true,
|
||||
docExpansion: 'none',
|
||||
deepLinking: true,
|
||||
defaultModelsExpandDepth: -1,
|
||||
defaultModelExpandDepth: 2
|
||||
}
|
||||
}));
|
||||
|
||||
app.get('/api-docs', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'swagger_index.html'));
|
||||
});
|
||||
|
||||
app.get('/api-docs.json', (req, res) => {
|
||||
res.json(specs);
|
||||
});
|
||||
|
||||
app.get('/api', (req, res) => {
|
||||
res.json({
|
||||
name: 'IDC设备管理系统 API',
|
||||
@@ -234,8 +286,12 @@ app.get('/api', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'IDC设备管理系统后端服务正常运行' });
|
||||
const { performHealthCheck } = require('./utils/healthCheck');
|
||||
|
||||
app.get('/health', async (req, res) => {
|
||||
const health = await performHealthCheck();
|
||||
const statusCode = health.status === 'error' ? 503 : health.status === 'warning' ? 200 : 200;
|
||||
res.status(statusCode).json(health);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
|
||||
const customCSS = `
|
||||
/* ========================================
|
||||
IDC设备管理系统 - Swagger UI 翠竹绿风格定制
|
||||
======================================== */
|
||||
|
||||
/* 隐藏默认顶部栏 */
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 全局字体设置 */
|
||||
.swagger-ui {
|
||||
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* 主容器背景 - 淡绿灰渐变 */
|
||||
.swagger-ui .swagger-container {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 50%, #e2e8e0 100%);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.swagger-ui .title {
|
||||
font-size: 24px !important;
|
||||
font-weight: 700 !important;
|
||||
color: #166534 !important;
|
||||
text-shadow: 0 2px 8px rgba(22, 101, 52, 0.1);
|
||||
}
|
||||
|
||||
/* 描述文字 */
|
||||
.swagger-ui .title small {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a) !important;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 信息容器 */
|
||||
.swagger-ui .info {
|
||||
margin: 30px 0 !important;
|
||||
padding: 24px !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2) !important;
|
||||
box-shadow: 0 4px 24px rgba(22, 101, 52, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .info .title {
|
||||
color: #166534 !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .info p {
|
||||
color: #64748b !important;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
/* 服务器选择器 */
|
||||
.swagger-ui .servers {
|
||||
margin: 20px 0 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .servers > label {
|
||||
color: #64748b !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 1px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .servers select {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #166534 !important;
|
||||
padding: 10px 16px !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .servers select:hover {
|
||||
border-color: rgba(34, 197, 94, 0.6) !important;
|
||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 标签页导航 */
|
||||
.swagger-ui .opblock-tag {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
border: none !important;
|
||||
border-bottom: 2px solid transparent !important;
|
||||
margin: 0 !important;
|
||||
padding: 16px 20px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag:hover {
|
||||
background: rgba(34, 197, 94, 0.08) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag .tag-header {
|
||||
color: #1e293b !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag .tag-header span {
|
||||
color: #94a3b8 !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* 展开的API块 */
|
||||
.swagger-ui .opblock {
|
||||
background: rgba(255, 255, 255, 0.85) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
border-radius: 12px !important;
|
||||
margin: 8px 0 !important;
|
||||
box-shadow: 0 2px 8px rgba(22, 101, 52, 0.04) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock:hover {
|
||||
border-color: rgba(34, 197, 94, 0.3) !important;
|
||||
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.1) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-post {
|
||||
border-left: 4px solid #22c55e !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-get {
|
||||
border-left: 4px solid #3b82f6 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-put {
|
||||
border-left: 4px solid #f59e0b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-delete {
|
||||
border-left: 4px solid #ef4444 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-patch {
|
||||
border-left: 4px solid #8b5cf6 !important;
|
||||
}
|
||||
|
||||
/* 操作标题栏 */
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-path {
|
||||
color: #1e293b !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
font-family: 'SF Mono', 'Consolas', monospace !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-path:hover {
|
||||
color: #166534 !important;
|
||||
}
|
||||
|
||||
/* HTTP方法标签 */
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-method {
|
||||
border-radius: 6px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
min-width: 60px !important;
|
||||
padding: 6px 10px !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-method span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* GET 方法 - 静谧蓝 */
|
||||
.swagger-ui .opblock-get .opblock-summary-method {
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* POST 方法 - 翠竹绿 */
|
||||
.swagger-ui .opblock-post .opblock-summary-method {
|
||||
background: linear-gradient(135deg, #4ade80, #22c55e) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
|
||||
/* PUT 方法 - 暖阳橙 */
|
||||
.swagger-ui .opblock-put .opblock-summary-method {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3) !important;
|
||||
}
|
||||
|
||||
/* DELETE 方法 - 胭脂红 */
|
||||
.swagger-ui .opblock-delete .opblock-summary-method {
|
||||
background: linear-gradient(135deg, #f87171, #ef4444) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3) !important;
|
||||
}
|
||||
|
||||
/* PATCH 方法 - 梦幻紫 */
|
||||
.swagger-ui .opblock-patch .opblock-summary-method {
|
||||
background: linear-gradient(135deg, #a78bfa, #8b5cf6) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 参数区域 */
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: rgba(248, 250, 252, 0.8) !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header h4 {
|
||||
color: #64748b !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 1px !important;
|
||||
}
|
||||
|
||||
/* 参数框 */
|
||||
.swagger-ui .parameters .parameter {
|
||||
padding: 12px 0 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .parameters .parameter .parameter__name {
|
||||
color: #1e293b !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .parameters .parameter .parameter__name.required:after {
|
||||
color: #ef4444 !important;
|
||||
content: " *";
|
||||
}
|
||||
|
||||
.swagger-ui .parameters .parameter input,
|
||||
.swagger-ui .parameters .parameter textarea {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #1e293b !important;
|
||||
padding: 10px 14px !important;
|
||||
font-size: 14px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .parameters .parameter input:focus,
|
||||
.swagger-ui .parameters .parameter textarea:focus {
|
||||
border-color: #22c55e !important;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 请求体编辑器 */
|
||||
.swagger-ui .body-edit-area {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #1e293b !important;
|
||||
font-family: 'SF Mono', 'Consolas', monospace !important;
|
||||
}
|
||||
|
||||
.swagger-ui .body-edit-area:focus {
|
||||
border-color: #22c55e !important;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 执行按钮 - 翠竹绿 */
|
||||
.swagger-ui .btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px !important;
|
||||
padding: 10px 20px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .btn.execute {
|
||||
background: linear-gradient(135deg, #4ade80, #22c55e) !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .btn.execute:hover {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a) !important;
|
||||
box-shadow: 0 6px 24px rgba(34, 197, 94, 0.4) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
/* 响应区域 */
|
||||
.swagger-ui .responses-wrapper {
|
||||
background: rgba(248, 250, 252, 0.6) !important;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status {
|
||||
color: #64748b !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status.success {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status.error {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* 认证区域 */
|
||||
.swagger-ui .auth-wrapper {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .auth-wrapper .authorize {
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #166534 !important;
|
||||
padding: 8px 16px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .auth-wrapper .authorize:hover {
|
||||
background: rgba(34, 197, 94, 0.2) !important;
|
||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.swagger-ui ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.swagger-ui ::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
.swagger-ui ::-webkit-scrollbar-thumb {
|
||||
background: rgba(34, 197, 94, 0.3) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.swagger-ui ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(34, 197, 94, 0.5) !important;
|
||||
}
|
||||
|
||||
/* 模型区域 */
|
||||
.swagger-ui .model-container {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .model {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .model .model-title {
|
||||
color: #166534 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .model .prop {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .model .prop-type {
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .model .prop-primitive {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
/* Markdown 内容 */
|
||||
.swagger-ui .markdown p,
|
||||
.swagger-ui .markdown li {
|
||||
color: #64748b !important;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .markdown code {
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
border-radius: 4px !important;
|
||||
color: #166534 !important;
|
||||
padding: 2px 6px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Loading 状态 */
|
||||
.swagger-ui .loading-container .loading {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .loading-container .loading::after {
|
||||
border-color: #22c55e transparent transparent transparent !important;
|
||||
}
|
||||
|
||||
/* 展开/折叠箭头 */
|
||||
.swagger-ui .expand-operations {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .expand-operations button {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .expand-operations button:hover {
|
||||
color: #166534 !important;
|
||||
}
|
||||
|
||||
/* 过滤器 */
|
||||
.swagger-ui .filter-container .filter-wrapper {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .filter-container input {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
border: none !important;
|
||||
color: #1e293b !important;
|
||||
padding: 8px 12px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .filter-container input::placeholder {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* 版本信息 */
|
||||
.swagger-ui .version-stamp {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.15)) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .version-stamp span {
|
||||
color: #166534 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* 图标颜色 */
|
||||
.swagger-ui .svg_assets {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
/* 角标/徽章 */
|
||||
.swagger-ui .badge {
|
||||
border-radius: 4px !important;
|
||||
font-size: 11px !important;
|
||||
padding: 3px 8px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .badge--deprecated {
|
||||
background: rgba(245, 158, 11, 0.15) !important;
|
||||
color: #d97706 !important;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Try it out 按钮 */
|
||||
.swagger-ui .try-out-btn {
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||
border-radius: 6px !important;
|
||||
color: #166534 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 6px 12px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.swagger-ui .try-out-btn:hover {
|
||||
background: rgba(34, 197, 94, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Copy 按钮 */
|
||||
.swagger-ui .copy-to-clipboard {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.swagger-ui .copy-to-clipboard button {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.swagger-ui .copy-to-clipboard button:hover {
|
||||
color: #166534 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'IDC设备管理系统 API',
|
||||
version: '1.0.0',
|
||||
description: '数据中心设备管理平台后端服务 API 文档',
|
||||
contact: {
|
||||
name: 'API Support'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:8000',
|
||||
description: '开发环境服务器'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: '输入 JWT token'
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [{
|
||||
bearerAuth: []
|
||||
}],
|
||||
tags: [
|
||||
{ name: 'health', description: '健康检查' },
|
||||
{ name: 'auth', description: '认证接口' },
|
||||
{ name: 'rooms', description: '机房管理' },
|
||||
{ name: 'racks', description: '机柜管理' },
|
||||
{ name: 'devices', description: '设备管理' },
|
||||
{ name: 'deviceFields', description: '设备字段' },
|
||||
{ name: 'device-ports', description: '设备端口' },
|
||||
{ name: 'network-cards', description: '网卡管理' },
|
||||
{ name: 'cables', description: '线缆管理' },
|
||||
{ name: 'tickets', description: '工单管理' },
|
||||
{ name: 'ticket-categories', description: '工单分类' },
|
||||
{ name: 'ticket-fields', description: '工单字段' },
|
||||
{ name: 'consumables', description: '耗材管理' },
|
||||
{ name: 'consumable-categories', description: '耗材分类' },
|
||||
{ name: 'consumable-records', description: '耗材记录' },
|
||||
{ name: 'users', description: '用户管理' },
|
||||
{ name: 'roles', description: '角色管理' },
|
||||
{ name: 'system-settings', description: '系统设置' },
|
||||
{ name: 'background', description: '背景配置' },
|
||||
{ name: 'inventory', description: '盘点管理' },
|
||||
{ name: 'statistics', description: '统计接口' },
|
||||
{ name: 'operation-logs', description: '操作日志' },
|
||||
{ name: 'backup', description: '备份管理' }
|
||||
]
|
||||
},
|
||||
apis: ['./routes/*.js', './swagger_docs.yaml']
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
|
||||
module.exports = { specs, customCSS };
|
||||
|
||||
// 如需独立使用CSS文件,可导出:
|
||||
// module.exports = { specs, customCSS };
|
||||
// 并在server.js中引入外部CSS文件
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IDC设备管理系统 API文档</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui.css">
|
||||
<style>
|
||||
.swagger-ui .topbar { display: none }
|
||||
.swagger-ui .title { font-size: 24px; font-weight: 700; color: #166534; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||||
.swagger-ui .swagger-container { background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 50%, #e2e8e0 100%); min-height: 100vh; }
|
||||
.swagger-ui .info { margin: 30px 0; padding: 24px; background: rgba(255, 255, 255, 0.9); border-radius: 16px; border: 1px solid rgba(34, 197, 94, 0.2); box-shadow: 0 4px 24px rgba(22, 101, 52, 0.08); }
|
||||
.swagger-ui .info .title { color: #166534; font-size: 28px; }
|
||||
.swagger-ui .info p { color: #64748b; }
|
||||
.swagger-ui .opblock-tag { background: rgba(255, 255, 255, 0.8); border: none; border-bottom: 2px solid transparent; padding: 16px 20px; transition: all 0.3s; }
|
||||
.swagger-ui .opblock-tag:hover { background: rgba(34, 197, 94, 0.08); }
|
||||
.swagger-ui .opblock-tag .tag-header { color: #1e293b; font-size: 15px; font-weight: 600; }
|
||||
.swagger-ui .opblock { background: rgba(255, 255, 255, 0.85); border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 12px; margin: 8px 0; transition: all 0.3s; }
|
||||
.swagger-ui .opblock:hover { border-color: rgba(34, 197, 94, 0.3); transform: translateY(-1px); }
|
||||
.swagger-ui .opblock.opblock-post { border-left: 4px solid #22c55e; }
|
||||
.swagger-ui .opblock.opblock-get { border-left: 4px solid #3b82f6; }
|
||||
.swagger-ui .opblock.opblock-put { border-left: 4px solid #f59e0b; }
|
||||
.swagger-ui .opblock.opblock-delete { border-left: 4px solid #ef4444; }
|
||||
.swagger-ui .opblock.opblock-patch { border-left: 4px solid #8b5cf6; }
|
||||
.swagger-ui .opblock .opblock-summary { padding: 12px 16px; }
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-method { border-radius: 6px; font-size: 12px; font-weight: 700; min-width: 60px; padding: 6px 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.swagger-ui .opblock-get .opblock-summary-method { background: linear-gradient(135deg, #60a5fa, #3b82f6); color: white; }
|
||||
.swagger-ui .opblock-post .opblock-summary-method { background: linear-gradient(135deg, #4ade80, #22c55e); color: white; }
|
||||
.swagger-ui .opblock-put .opblock-summary-method { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: white; }
|
||||
.swagger-ui .opblock-delete .opblock-summary-method { background: linear-gradient(135deg, #f87171, #ef4444); color: white; }
|
||||
.swagger-ui .opblock-patch .opblock-summary-method { background: linear-gradient(135deg, #a78bfa, #8b5cf6); color: white; }
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-path { color: #1e293b; font-family: 'SF Mono', Consolas, monospace; }
|
||||
.swagger-ui .opblock .opblock-summary .opblock-summary-path:hover { color: #166534; }
|
||||
.swagger-ui .parameters .parameter input, .swagger-ui .parameters .parameter textarea { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 8px; color: #1e293b; padding: 10px 14px; }
|
||||
.swagger-ui .parameters .parameter input:focus, .swagger-ui .parameters .parameter textarea:focus { border-color: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1); outline: none; }
|
||||
.swagger-ui .btn.execute { background: linear-gradient(135deg, #4ade80, #22c55e); border: none; color: white; border-radius: 8px; font-weight: 600; padding: 10px 20px; text-transform: uppercase; box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3); }
|
||||
.swagger-ui .btn.execute:hover { background: linear-gradient(135deg, #22c55e, #16a34a); box-shadow: 0 6px 24px rgba(34, 197, 94, 0.4); }
|
||||
.swagger-ui .try-out-btn { background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 6px; color: #166534; font-size: 12px; padding: 6px 12px; }
|
||||
.swagger-ui .try-out-btn:hover { background: rgba(34, 197, 94, 0.2); }
|
||||
.swagger-ui .auth-wrapper { background: rgba(255, 255, 255, 0.9); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 12px; padding: 16px; }
|
||||
.swagger-ui .auth-wrapper .authorize { background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 8px; color: #166534; padding: 8px 16px; font-weight: 600; }
|
||||
.swagger-ui .auth-wrapper .authorize:hover { background: rgba(34, 197, 94, 0.2); }
|
||||
.swagger-ui .servers select { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 8px; color: #166534; padding: 10px 16px; cursor: pointer; }
|
||||
.swagger-ui .model-container { background: rgba(255, 255, 255, 0.8); border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 8px; padding: 16px; }
|
||||
.swagger-ui .model .model-title { color: #166534; font-weight: 600; }
|
||||
.swagger-ui .model .prop-type { color: #f59e0b; }
|
||||
.swagger-ui .model .prop-primitive { color: #22c55e; }
|
||||
.swagger-ui ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.swagger-ui ::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.04); }
|
||||
.swagger-ui ::-webkit-scrollbar-thumb { background: rgba(34, 197, 94, 0.3); border-radius: 4px; }
|
||||
.swagger-ui ::-webkit-scrollbar-thumb:hover { background: rgba(34, 197, 94, 0.5); }
|
||||
.swagger-ui .version-stamp { background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.15)); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 6px; }
|
||||
.swagger-ui .version-stamp span { color: #166534; font-weight: 600; }
|
||||
.swagger-ui .filter-container input { background: rgba(255, 255, 255, 0.95); border: none; color: #1e293b; padding: 8px 12px; border-radius: 6px; }
|
||||
.swagger-ui .copy-to-clipboard { background: rgba(255, 255, 255, 0.9); border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 6px; }
|
||||
.swagger-ui .copy-to-clipboard button { color: #64748b; }
|
||||
.swagger-ui .markdown code { background: rgba(34, 197, 94, 0.1); border-radius: 4px; color: #166534; padding: 2px 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
var translations = {
|
||||
"Operations":"接口列表","Operation":"操作","Parameters":"参数列表","Parameter":"参数",
|
||||
"Headers":"请求头","Request_Body":"请求体","Body":"请求体","Responses":"响应列表",
|
||||
"Response":"响应","Try it out":"在线测试","Execute":"执行","Cancel":"取消",
|
||||
"Copy":"复制","Copied":"已复制","reset":"重置","Authorize":"授权","Log Out":"退出",
|
||||
"Logout":"退出","Welcome":"欢迎","Authorized":"已授权","Explore":"浏览",
|
||||
"Filter":"过滤","Search":"搜索","Collapse":"收起","Expand":"展开",
|
||||
"Loading":"加载中...","Fetch error":"获取失败","No parameters":"无参数",
|
||||
"No response":"无响应","No content":"无内容","schema":"结构","Model Schema":"模型结构","Model":"模型",
|
||||
"Example":"示例","Examples":"示例","Value":"值","Type":"类型",
|
||||
"Description":"描述","Required":"必填","Deprecated":"已弃用","Version":"版本",
|
||||
"Host":"主机","Base URL":"基础地址","Schemes":"协议","Info":"信息",
|
||||
"External Documentation":"外部文档","Response Class":"响应类型","Status Codes":"状态码",
|
||||
"Success":"成功","Error":"错误","Server":"服务器","Servers":"服务器列表",
|
||||
"Security":"安全设置","Actions":"操作","DELETE":"删除","POST":"创建",
|
||||
"GET":"查询","PUT":"更新","PATCH":"部分更新","OPTIONS":"选项","HEAD":"头信息",
|
||||
"userId":"用户ID","Device ID":"设备ID","Room ID":"机房ID","Rack ID":"机柜ID","Ticket ID":"工单ID",
|
||||
"Created at":"创建时间","Updated at":"更新时间","Created":"创建于","Updated":"更新于",
|
||||
"Status":"状态","Name":"名称","ID":"ID","type":"类型","status":"状态",
|
||||
"page":"页码","pageSize":"每页数量","total":"总数","keyword":"关键词",
|
||||
"Filter by":"筛选","Sort by":"排序","Showing":"显示",
|
||||
"Authorizations":"授权","Available authorizations":"可用授权",
|
||||
"Close":"关闭","OK":"确定","Yes":"是","No":"否",
|
||||
"Path Parameters":"路径参数","Query Parameters":"查询参数","Request Body":"请求体",
|
||||
"Response Headers":"响应头","Response Body":"响应体",
|
||||
"Implementation notes":"实现说明","Response messages":"响应消息",
|
||||
"Default":"默认","Example Value":"示例值","Model":"模型",
|
||||
"Request Headers":"请求头","Response Code":"响应码","Response Description":"响应描述",
|
||||
"page":"页码","pageSize":"每页数量","total":"总数",
|
||||
"password":"密码","username":"用户名","email":"邮箱","phone":"电话",
|
||||
"Authorization":"授权","Bearer":"令牌","access_token":"访问令牌","token_type":"令牌类型",
|
||||
"expires_in":"过期时间","scope":"作用域","grant_type":"授权类型",
|
||||
"client_id":"客户端ID","client_secret":"客户端密钥",
|
||||
"undefined":"未定义","None":"无","object":"对象","string":"字符串","integer":"整数",
|
||||
"number":"数字","boolean":"布尔值","array":"数组"
|
||||
};
|
||||
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/api-docs.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||
layout: "BaseLayout",
|
||||
docExpansion: "none",
|
||||
displayRequestDuration: true,
|
||||
defaultModelsExpandDepth: -1,
|
||||
defaultModelExpandDepth: 2,
|
||||
onComplete: function() {
|
||||
translatePage();
|
||||
setInterval(translatePage, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
function translatePage() {
|
||||
var els = document.querySelectorAll('.swagger-ui');
|
||||
els.forEach(function(el) {
|
||||
var nodes = el.querySelectorAll('*');
|
||||
nodes.forEach(function(node) {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) {
|
||||
var text = node.nodeValue.trim();
|
||||
for (var key in translations) {
|
||||
if (text === key && node.nodeValue === key) {
|
||||
node.nodeValue = translations[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,545 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bodyParser = require('body-parser');
|
||||
const { sequelize } = require('../db');
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
const Device = require('../models/Device');
|
||||
const Rack = require('../models/Rack');
|
||||
const Room = require('../models/Room');
|
||||
const User = require('../models/User');
|
||||
const Role = require('../models/Role');
|
||||
const UserRole = require('../models/UserRole');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
describe('设备/用户/角色操作日志集成测试', () => {
|
||||
let app;
|
||||
let authToken;
|
||||
let adminUser;
|
||||
let testRack;
|
||||
let testRoom;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
adminUser = await User.create({
|
||||
userId: 'admin_int_test',
|
||||
username: 'admin',
|
||||
password: '$2a$10$test',
|
||||
realName: '管理员',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
testRoom = await Room.create({
|
||||
roomId: 'ROOM_INT_TEST',
|
||||
name: '测试机房',
|
||||
location: '测试位置',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
testRack = await Rack.create({
|
||||
rackId: 'RACK_INT_TEST',
|
||||
name: '测试机柜',
|
||||
roomId: testRoom.roomId,
|
||||
totalPower: 10000,
|
||||
currentPower: 0,
|
||||
totalUnits: 48,
|
||||
usedUnits: 0,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
app = createTestApp();
|
||||
authToken = jwt.sign(
|
||||
{ userId: adminUser.userId, username: adminUser.username, realName: adminUser.realName, roleName: '管理员' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OperationLog.destroy({ where: {} });
|
||||
await Device.destroy({ where: {} });
|
||||
});
|
||||
|
||||
const createTestApp = () => {
|
||||
const app = express();
|
||||
app.use(bodyParser.json());
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, message: '未授权' });
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ success: false, message: '无效的令牌' });
|
||||
}
|
||||
};
|
||||
|
||||
const devicesRouter = require('../routes/devices');
|
||||
const usersRouter = require('../routes/users');
|
||||
const rolesRouter = require('../routes/roles');
|
||||
|
||||
app.use('/api/devices', authMiddleware, devicesRouter);
|
||||
app.use('/api/users', authMiddleware, usersRouter);
|
||||
app.use('/api/roles', authMiddleware, rolesRouter);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('设备操作日志记录', () => {
|
||||
test('创建设备应该记录操作日志', async () => {
|
||||
const deviceData = {
|
||||
name: '集成测试设备',
|
||||
deviceId: `DEV_INT_${Date.now()}`,
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 1,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'running'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/devices')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(deviceData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.name).toBe(deviceData.name);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
targetId: response.body.deviceId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].operationDescription).toContain('创建设备');
|
||||
expect(logs[0].operatorId).toBe(adminUser.userId);
|
||||
expect(logs[0].result).toBe('success');
|
||||
});
|
||||
|
||||
test('更新设备应该记录操作日志', async () => {
|
||||
const device = await Device.create({
|
||||
deviceId: `DEV_UPD_INT_${Date.now()}`,
|
||||
name: '更新前设备',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 5,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'offline'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/devices/${device.deviceId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: '更新后设备', status: 'running' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.name).toBe('更新后设备');
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
targetId: device.deviceId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.name).toBe('更新前设备');
|
||||
expect(logs[0].afterState.name).toBe('更新后设备');
|
||||
});
|
||||
|
||||
test('删除设备应该记录操作日志', async () => {
|
||||
const device = await Device.create({
|
||||
deviceId: `DEV_DEL_INT_${Date.now()}`,
|
||||
name: '待删除设备',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 10,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/devices/${device.deviceId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'device',
|
||||
operationType: 'delete',
|
||||
targetId: device.deviceId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.name).toBe('待删除设备');
|
||||
});
|
||||
|
||||
test('批量删除设备应该记录操作日志', async () => {
|
||||
const device1 = await Device.create({
|
||||
deviceId: `DEV_BATCH1_${Date.now()}`,
|
||||
name: '批量设备1',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 15,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
const device2 = await Device.create({
|
||||
deviceId: `DEV_BATCH2_${Date.now()}`,
|
||||
name: '批量设备2',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 20,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/devices/batch-delete')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ deviceIds: [device1.deviceId, device2.deviceId] })
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { operationType: 'batch_delete' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.count).toBe(2);
|
||||
});
|
||||
|
||||
test('批量变更设备状态应该记录操作日志', async () => {
|
||||
const device1 = await Device.create({
|
||||
deviceId: `DEV_STAT1_${Date.now()}`,
|
||||
name: '状态设备1',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 25,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'offline'
|
||||
});
|
||||
|
||||
const device2 = await Device.create({
|
||||
deviceId: `DEV_STAT2_${Date.now()}`,
|
||||
name: '状态设备2',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 30,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'offline'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/devices/batch-status')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ deviceIds: [device1.deviceId, device2.deviceId], status: 'running' })
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { operationType: 'status_change' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.status).toBe('running');
|
||||
expect(logs[0].metadata.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('用户操作日志记录', () => {
|
||||
let testRole;
|
||||
|
||||
beforeAll(async () => {
|
||||
testRole = await Role.create({
|
||||
roleId: `ROLE_INT_TEST_${Date.now()}`,
|
||||
roleName: '测试角色',
|
||||
roleCode: `test_role_${Date.now()}`,
|
||||
permissions: ['read', 'write'],
|
||||
status: 'active'
|
||||
});
|
||||
});
|
||||
|
||||
test('创建用户应该记录操作日志', async () => {
|
||||
const userData = {
|
||||
username: `int_test_user_${Date.now()}`,
|
||||
password: 'Password123!',
|
||||
realName: '集成测试用户',
|
||||
email: `test_${Date.now()}@example.com`,
|
||||
roleIds: [testRole.roleId]
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(userData)
|
||||
.expect(201);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'user',
|
||||
operationType: 'create',
|
||||
targetName: userData.username
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].operationDescription).toContain(`创建用户 ${userData.username}`);
|
||||
expect(logs[0].metadata.roleIds).toContain(testRole.roleId);
|
||||
|
||||
await User.destroy({ where: { userId: response.body.userId } });
|
||||
});
|
||||
|
||||
test('更新用户应该记录操作日志', async () => {
|
||||
const user = await User.create({
|
||||
userId: `USER_UPD_INT_${Date.now()}`,
|
||||
username: `old_name_${Date.now()}`,
|
||||
password: 'Password123!',
|
||||
realName: '旧名称用户',
|
||||
email: `old_${Date.now()}@example.com`,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/users/${user.userId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ realName: '新名称用户' })
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'user',
|
||||
operationType: 'update',
|
||||
targetId: user.userId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.realName).toBe('旧名称用户');
|
||||
expect(logs[0].afterState.realName).toBe('新名称用户');
|
||||
|
||||
await User.destroy({ where: { userId: user.userId } });
|
||||
});
|
||||
|
||||
test('变更用户角色应该记录权限变更日志', async () => {
|
||||
const user = await User.create({
|
||||
userId: `USER_ROLE_INT_${Date.now()}`,
|
||||
username: `role_test_${Date.now()}`,
|
||||
password: 'Password123!',
|
||||
realName: '角色测试用户',
|
||||
email: `role_${Date.now()}@example.com`,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const newRole = await Role.create({
|
||||
roleId: `ROLE_NEW_INT_${Date.now()}`,
|
||||
roleName: '新测试角色',
|
||||
roleCode: `new_role_${Date.now()}`,
|
||||
permissions: ['admin'],
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
await UserRole.create({
|
||||
UserId: user.userId,
|
||||
RoleId: testRole.roleId
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/users/${user.userId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ roleIds: [newRole.roleId] })
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'user',
|
||||
operationType: 'permission_change',
|
||||
targetId: user.userId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.oldRoleIds).toContain(testRole.roleId);
|
||||
expect(logs[0].metadata.newRoleIds).toContain(newRole.roleId);
|
||||
|
||||
await UserRole.destroy({ where: { UserId: user.userId } });
|
||||
await User.destroy({ where: { userId: user.userId } });
|
||||
await Role.destroy({ where: { roleId: newRole.roleId } });
|
||||
});
|
||||
|
||||
test('删除用户应该记录操作日志', async () => {
|
||||
const user = await User.create({
|
||||
userId: `USER_DEL_INT_${Date.now()}`,
|
||||
username: `del_test_${Date.now()}`,
|
||||
password: 'Password123!',
|
||||
realName: '删除测试用户',
|
||||
email: `del_${Date.now()}@example.com`,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/users/${user.userId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'user',
|
||||
operationType: 'delete',
|
||||
targetId: user.userId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.username).toBe(user.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色操作日志记录', () => {
|
||||
test('创建角色应该记录操作日志', async () => {
|
||||
const roleData = {
|
||||
roleName: `集成测试角色_${Date.now()}`,
|
||||
roleCode: `int_test_role_${Date.now()}`,
|
||||
description: '集成测试用角色',
|
||||
permissions: ['read', 'write'],
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/roles')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(roleData)
|
||||
.expect(201);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'role',
|
||||
operationType: 'create',
|
||||
targetId: response.body.roleId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].operationDescription).toContain(`创建角色 ${roleData.roleName}`);
|
||||
expect(logs[0].metadata.roleCode).toBe(roleData.roleCode);
|
||||
|
||||
await Role.destroy({ where: { roleId: response.body.roleId } });
|
||||
});
|
||||
|
||||
test('更新角色应该记录操作日志', async () => {
|
||||
const role = await Role.create({
|
||||
roleId: `ROLE_UPD_INT_${Date.now()}`,
|
||||
roleName: `旧角色名_${Date.now()}`,
|
||||
roleCode: `old_role_${Date.now()}`,
|
||||
description: '旧描述',
|
||||
permissions: ['read'],
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/roles/${role.roleId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
roleName: `新角色名_${Date.now()}`,
|
||||
permissions: ['read', 'write', 'delete']
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'role',
|
||||
operationType: 'update',
|
||||
targetId: role.roleId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.roleName).toContain('旧角色名');
|
||||
expect(logs[0].afterState.roleName).toContain('新角色名');
|
||||
|
||||
await Role.destroy({ where: { roleId: role.roleId } });
|
||||
});
|
||||
|
||||
test('删除角色应该记录操作日志', async () => {
|
||||
const role = await Role.create({
|
||||
roleId: `ROLE_DEL_INT_${Date.now()}`,
|
||||
roleName: `待删除角色_${Date.now()}`,
|
||||
roleCode: `del_role_${Date.now()}`,
|
||||
description: '待删除',
|
||||
permissions: ['read'],
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/roles/${role.roleId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'role',
|
||||
operationType: 'delete',
|
||||
targetId: role.roleId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.roleName).toContain('待删除角色');
|
||||
});
|
||||
});
|
||||
|
||||
describe('日志查询验证', () => {
|
||||
test('通过 API 应该能够查询到刚记录的操作日志', async () => {
|
||||
const device = await Device.create({
|
||||
deviceId: `DEV_QUERY_INT_${Date.now()}`,
|
||||
name: '查询测试设备',
|
||||
type: 'server',
|
||||
rackId: testRack.rackId,
|
||||
position: 40,
|
||||
height: 2,
|
||||
powerConsumption: 500,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put(`/api/devices/${device.deviceId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ status: 'maintenance' })
|
||||
.expect(200);
|
||||
|
||||
const logsResponse = await request(app)
|
||||
.get('/api/devices')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
targetId: device.deviceId
|
||||
}
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.status).toBe('running');
|
||||
expect(logs[0].afterState.status).toBe('maintenance');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
|
||||
describe('OperationLog 模型测试', () => {
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OperationLog.destroy({ where: {} });
|
||||
});
|
||||
|
||||
describe('模型定义', () => {
|
||||
test('OperationLog 模型应该正确定义', () => {
|
||||
expect(OperationLog).toBeDefined();
|
||||
expect(typeof OperationLog.create).toBe('function');
|
||||
expect(typeof OperationLog.findAll).toBe('function');
|
||||
expect(typeof OperationLog.findOne).toBe('function');
|
||||
});
|
||||
|
||||
test('模型应该有正确的表名', () => {
|
||||
expect(OperationLog.options.tableName).toBe('operation_logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('创建日志记录', () => {
|
||||
test('应该能够创建基本的操作日志', async () => {
|
||||
const logData = {
|
||||
recordId: 'OPLOG_TEST_001',
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
operationDescription: '创建设备 TEST_SERVER',
|
||||
targetId: 'DEV001',
|
||||
targetName: 'TEST_SERVER',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '测试用户',
|
||||
operatorRole: '管理员',
|
||||
result: 'success'
|
||||
};
|
||||
|
||||
const log = await OperationLog.create(logData);
|
||||
|
||||
expect(log.recordId).toBe(logData.recordId);
|
||||
expect(log.module).toBe(logData.module);
|
||||
expect(log.operationType).toBe(logData.operationType);
|
||||
expect(log.operationDescription).toBe(logData.operationDescription);
|
||||
expect(log.targetId).toBe(logData.targetId);
|
||||
expect(log.targetName).toBe(logData.targetName);
|
||||
expect(log.operatorId).toBe(logData.operatorId);
|
||||
expect(log.operatorName).toBe(logData.operatorName);
|
||||
expect(log.result).toBe(logData.result);
|
||||
expect(log.createdAt).toBeDefined();
|
||||
expect(log.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test('应该能够创建带有 beforeState 和 afterState 的日志', async () => {
|
||||
const beforeState = { name: '旧名称', status: 'offline' };
|
||||
const afterState = { name: '新名称', status: 'running' };
|
||||
|
||||
const log = await OperationLog.create({
|
||||
recordId: 'OPLOG_TEST_002',
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
operationDescription: '更新设备 TEST_DEVICE',
|
||||
targetId: 'DEV002',
|
||||
targetName: 'TEST_DEVICE',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '测试用户',
|
||||
beforeState,
|
||||
afterState,
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
expect(log.beforeState).toEqual(beforeState);
|
||||
expect(log.afterState).toEqual(afterState);
|
||||
});
|
||||
|
||||
test('应该能够创建带有 metadata 的日志', async () => {
|
||||
const metadata = {
|
||||
count: 5,
|
||||
source: 'batch_operation',
|
||||
extraInfo: '额外信息'
|
||||
};
|
||||
|
||||
const log = await OperationLog.create({
|
||||
recordId: 'OPLOG_TEST_003',
|
||||
module: 'device',
|
||||
operationType: 'batch_delete',
|
||||
operationDescription: '批量删除设备',
|
||||
targetId: 'DEV001,DEV002,DEV003',
|
||||
targetName: '3台设备',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '测试用户',
|
||||
metadata,
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
expect(log.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
test('应该能够创建带有 IP 地址和 UserAgent 的日志', async () => {
|
||||
const log = await OperationLog.create({
|
||||
recordId: 'OPLOG_TEST_004',
|
||||
module: 'user',
|
||||
operationType: 'create',
|
||||
operationDescription: '创建用户 test_user',
|
||||
targetId: 'user_test',
|
||||
targetName: 'test_user',
|
||||
operatorId: 'admin_user',
|
||||
operatorName: '管理员',
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
expect(log.ipAddress).toBe('192.168.1.100');
|
||||
expect(log.userAgent).toBe('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
|
||||
});
|
||||
|
||||
test('result 字段默认为 success', async () => {
|
||||
const log = await OperationLog.create({
|
||||
recordId: 'OPLOG_TEST_005',
|
||||
module: 'device',
|
||||
operationType: 'delete',
|
||||
operationDescription: '删除设备',
|
||||
targetId: 'DEV005',
|
||||
targetName: 'TEST_DEVICE',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '测试用户'
|
||||
});
|
||||
|
||||
expect(log.result).toBe('success');
|
||||
});
|
||||
|
||||
test('metadata 字段默认为空对象', async () => {
|
||||
const log = await OperationLog.create({
|
||||
recordId: 'OPLOG_TEST_006',
|
||||
module: 'role',
|
||||
operationType: 'create',
|
||||
operationDescription: '创建角色',
|
||||
targetId: 'role_test',
|
||||
targetName: '测试角色',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '测试用户'
|
||||
});
|
||||
|
||||
expect(log.metadata).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('查询日志记录', () => {
|
||||
beforeEach(async () => {
|
||||
await OperationLog.bulkCreate([
|
||||
{
|
||||
recordId: 'OPLOG_QUERY_001',
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
operationDescription: '创建设备 设备A',
|
||||
targetId: 'DEV_A',
|
||||
targetName: '设备A',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '用户A',
|
||||
result: 'success'
|
||||
},
|
||||
{
|
||||
recordId: 'OPLOG_QUERY_002',
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
operationDescription: '更新设备 设备B',
|
||||
targetId: 'DEV_B',
|
||||
targetName: '设备B',
|
||||
operatorId: 'user_002',
|
||||
operatorName: '用户B',
|
||||
result: 'success'
|
||||
},
|
||||
{
|
||||
recordId: 'OPLOG_QUERY_003',
|
||||
module: 'user',
|
||||
operationType: 'create',
|
||||
operationDescription: '创建用户 用户C',
|
||||
targetId: 'user_C',
|
||||
targetName: '用户C',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '用户A',
|
||||
result: 'success'
|
||||
},
|
||||
{
|
||||
recordId: 'OPLOG_QUERY_004',
|
||||
module: 'device',
|
||||
operationType: 'delete',
|
||||
operationDescription: '删除设备 设备D',
|
||||
targetId: 'DEV_D',
|
||||
targetName: '设备D',
|
||||
operatorId: 'user_001',
|
||||
operatorName: '用户A',
|
||||
result: 'failed'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('应该能够按 module 查询', async () => {
|
||||
const deviceLogs = await OperationLog.findAll({
|
||||
where: { module: 'device' }
|
||||
});
|
||||
expect(deviceLogs.length).toBe(3);
|
||||
});
|
||||
|
||||
test('应该能够按 operationType 查询', async () => {
|
||||
const createLogs = await OperationLog.findAll({
|
||||
where: { operationType: 'create' }
|
||||
});
|
||||
expect(createLogs.length).toBe(2);
|
||||
});
|
||||
|
||||
test('应该能够按 operatorId 查询', async () => {
|
||||
const user001Logs = await OperationLog.findAll({
|
||||
where: { operatorId: 'user_001' }
|
||||
});
|
||||
expect(user001Logs.length).toBe(3);
|
||||
});
|
||||
|
||||
test('应该能够按 result 查询', async () => {
|
||||
const failedLogs = await OperationLog.findAll({
|
||||
where: { result: 'failed' }
|
||||
});
|
||||
expect(failedLogs.length).toBe(1);
|
||||
});
|
||||
|
||||
test('应该能够按 targetId 模糊查询', async () => {
|
||||
const { Op } = require('sequelize');
|
||||
const logs = await OperationLog.findAll({
|
||||
where: {
|
||||
targetId: { [Op.like]: '%DEV%' }
|
||||
}
|
||||
});
|
||||
expect(logs.length).toBe(3);
|
||||
});
|
||||
|
||||
test('应该支持分页查询', async () => {
|
||||
const { count, rows } = await OperationLog.findAndCountAll({
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
expect(count).toBe(4);
|
||||
expect(rows.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('索引测试', () => {
|
||||
test('应该有 module 索引', () => {
|
||||
const indexes = OperationLog.options.indexes;
|
||||
expect(indexes.some(idx => idx.fields.includes('module'))).toBe(true);
|
||||
});
|
||||
|
||||
test('应该有 operationType 索引', () => {
|
||||
const indexes = OperationLog.options.indexes;
|
||||
expect(indexes.some(idx => idx.fields.includes('operationType'))).toBe(true);
|
||||
});
|
||||
|
||||
test('应该有 targetId 索引', () => {
|
||||
const indexes = OperationLog.options.indexes;
|
||||
expect(indexes.some(idx => idx.fields.includes('targetId'))).toBe(true);
|
||||
});
|
||||
|
||||
test('应该有 operatorId 索引', () => {
|
||||
const indexes = OperationLog.options.indexes;
|
||||
expect(indexes.some(idx => idx.fields.includes('operatorId'))).toBe(true);
|
||||
});
|
||||
|
||||
test('应该有 createdAt 索引', () => {
|
||||
const indexes = OperationLog.options.indexes;
|
||||
expect(indexes.some(idx => idx.fields.includes('createdAt'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
const { sequelize } = require('../db');
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
const {
|
||||
logOperation,
|
||||
logDeviceOperation,
|
||||
logUserOperation,
|
||||
logRoleOperation
|
||||
} = require('../utils/operationLogger');
|
||||
|
||||
describe('operationLogger 工具函数测试', () => {
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OperationLog.destroy({ where: {} });
|
||||
});
|
||||
|
||||
describe('logOperation 基础函数', () => {
|
||||
test('应该能够记录基本操作日志', async () => {
|
||||
const mockReq = {
|
||||
user: {
|
||||
userId: 'user_test_001',
|
||||
realName: '测试用户',
|
||||
roleName: '管理员'
|
||||
},
|
||||
headers: {
|
||||
'x-forwarded-for': '192.168.1.100',
|
||||
'user-agent': 'Mozilla/5.0 Test Browser'
|
||||
}
|
||||
};
|
||||
|
||||
await logOperation({
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
operationDescription: '测试创建设备',
|
||||
targetId: 'DEV_TEST_001',
|
||||
targetName: '测试设备',
|
||||
beforeState: null,
|
||||
afterState: { name: '测试设备', status: 'running' },
|
||||
result: 'success',
|
||||
req: mockReq
|
||||
});
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV_TEST_001' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].module).toBe('device');
|
||||
expect(logs[0].operationType).toBe('create');
|
||||
expect(logs[0].operationDescription).toBe('测试创建设备');
|
||||
expect(logs[0].targetId).toBe('DEV_TEST_001');
|
||||
expect(logs[0].targetName).toBe('测试设备');
|
||||
expect(logs[0].operatorId).toBe('user_test_001');
|
||||
expect(logs[0].operatorName).toBe('测试用户');
|
||||
expect(logs[0].operatorRole).toBe('管理员');
|
||||
expect(logs[0].ipAddress).toBe('192.168.1.100');
|
||||
expect(logs[0].userAgent).toBe('Mozilla/5.0 Test Browser');
|
||||
expect(logs[0].result).toBe('success');
|
||||
});
|
||||
|
||||
test('应该能够处理没有用户信息的请求', async () => {
|
||||
const mockReq = {
|
||||
user: null,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logOperation({
|
||||
module: 'system',
|
||||
operationType: 'batch_update',
|
||||
operationDescription: '系统批量更新',
|
||||
targetId: 'SYSTEM',
|
||||
targetName: '系统',
|
||||
result: 'success',
|
||||
req: mockReq
|
||||
});
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'SYSTEM' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].operatorId).toBe('system');
|
||||
expect(logs[0].operatorName).toBe('系统');
|
||||
});
|
||||
|
||||
test('应该能够处理没有请求对象的场景', async () => {
|
||||
await logOperation({
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
operationDescription: '无请求更新',
|
||||
targetId: 'DEV_NO_REQ',
|
||||
targetName: '无请求设备',
|
||||
result: 'success',
|
||||
req: null
|
||||
});
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV_NO_REQ' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].ipAddress).toBeNull();
|
||||
expect(logs[0].userAgent).toBeNull();
|
||||
});
|
||||
|
||||
test('应该能够记录失败的操作', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_fail', realName: '失败用户' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logOperation({
|
||||
module: 'device',
|
||||
operationType: 'delete',
|
||||
operationDescription: '删除设备失败',
|
||||
targetId: 'DEV_FAIL',
|
||||
targetName: '失败设备',
|
||||
result: 'failed',
|
||||
req: mockReq,
|
||||
metadata: { errorMessage: '设备不存在' }
|
||||
});
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV_FAIL' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].result).toBe('failed');
|
||||
});
|
||||
|
||||
test('应该使用提供的 metadata', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_meta', realName: '元数据用户' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const customMetadata = {
|
||||
batchCount: 10,
|
||||
source: 'import',
|
||||
duration: 5000
|
||||
};
|
||||
|
||||
await logOperation({
|
||||
module: 'device',
|
||||
operationType: 'batch_update',
|
||||
operationDescription: '批量更新设备',
|
||||
targetId: 'DEV_BATCH',
|
||||
targetName: '批量设备',
|
||||
result: 'success',
|
||||
req: mockReq,
|
||||
metadata: customMetadata
|
||||
});
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV_BATCH' }
|
||||
});
|
||||
|
||||
expect(logs[0].metadata).toEqual(customMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logDeviceOperation 设备操作日志', () => {
|
||||
test('应该记录设备创建日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_dev', realName: '设备管理员' },
|
||||
headers: { 'x-forwarded-for': '10.0.0.1' }
|
||||
};
|
||||
|
||||
await logDeviceOperation(
|
||||
'create',
|
||||
'创建设备 测试服务器 (DEV001)',
|
||||
{
|
||||
targetId: 'DEV001',
|
||||
targetName: '测试服务器',
|
||||
afterState: { deviceId: 'DEV001', name: '测试服务器', status: 'running' },
|
||||
req: mockReq
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'device', operationType: 'create' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].module).toBe('device');
|
||||
expect(logs[0].operationType).toBe('create');
|
||||
expect(logs[0].targetId).toBe('DEV001');
|
||||
expect(logs[0].targetName).toBe('测试服务器');
|
||||
});
|
||||
|
||||
test('应该记录设备更新日志并包含状态变更', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_upd', realName: '更新操作员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const beforeState = { name: '旧名称', status: 'offline' };
|
||||
const afterState = { name: '新名称', status: 'running' };
|
||||
|
||||
await logDeviceOperation(
|
||||
'update',
|
||||
'更新设备 DEV002',
|
||||
{
|
||||
targetId: 'DEV002',
|
||||
targetName: 'DEV002',
|
||||
beforeState,
|
||||
afterState,
|
||||
req: mockReq
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV002', operationType: 'update' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState).toEqual(beforeState);
|
||||
expect(logs[0].afterState).toEqual(afterState);
|
||||
});
|
||||
|
||||
test('应该记录设备删除日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_del', realName: '删除操作员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logDeviceOperation(
|
||||
'delete',
|
||||
'删除设备 DEV003 (测试服务器)',
|
||||
{
|
||||
targetId: 'DEV003',
|
||||
targetName: '测试服务器',
|
||||
beforeState: { deviceId: 'DEV003', name: '测试服务器' },
|
||||
req: mockReq
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { targetId: 'DEV003', operationType: 'delete' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
});
|
||||
|
||||
test('应该记录批量删除日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_batch', realName: '批量操作员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logDeviceOperation(
|
||||
'batch_delete',
|
||||
'批量删除设备 3台 (DEV_A,DEV_B,DEV_C)',
|
||||
{
|
||||
targetId: 'DEV_A,DEV_B,DEV_C',
|
||||
targetName: '3台设备',
|
||||
beforeState: [
|
||||
{ deviceId: 'DEV_A', name: '设备A' },
|
||||
{ deviceId: 'DEV_B', name: '设备B' },
|
||||
{ deviceId: 'DEV_C', name: '设备C' }
|
||||
],
|
||||
req: mockReq,
|
||||
metadata: { count: 3 }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { operationType: 'batch_delete' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.count).toBe(3);
|
||||
});
|
||||
|
||||
test('应该记录状态变更日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'user_status', realName: '状态管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logDeviceOperation(
|
||||
'status_change',
|
||||
'批量变更设备状态为"运行中"',
|
||||
{
|
||||
targetId: 'DEV_STATUS_1,DEV_STATUS_2',
|
||||
targetName: '2台设备',
|
||||
beforeState: [
|
||||
{ deviceId: 'DEV_STATUS_1', status: 'offline' },
|
||||
{ deviceId: 'DEV_STATUS_2', status: 'maintenance' }
|
||||
],
|
||||
afterState: [
|
||||
{ deviceId: 'DEV_STATUS_1', status: 'running' },
|
||||
{ deviceId: 'DEV_STATUS_2', status: 'running' }
|
||||
],
|
||||
req: mockReq,
|
||||
metadata: { status: 'running', count: 2 }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { operationType: 'status_change' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logUserOperation 用户操作日志', () => {
|
||||
test('应该记录用户创建日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '系统管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logUserOperation(
|
||||
'create',
|
||||
'创建用户 new_user',
|
||||
{
|
||||
targetId: 'user_new',
|
||||
targetName: 'new_user',
|
||||
afterState: { username: 'new_user', email: 'new@example.com' },
|
||||
req: mockReq,
|
||||
metadata: { roleIds: ['role_admin'] }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'user', operationType: 'create' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].module).toBe('user');
|
||||
expect(logs[0].targetName).toBe('new_user');
|
||||
});
|
||||
|
||||
test('应该记录权限变更日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logUserOperation(
|
||||
'permission_change',
|
||||
'变更用户 test_user 的角色',
|
||||
{
|
||||
targetId: 'user_test',
|
||||
targetName: 'test_user',
|
||||
beforeState: { roleIds: ['role_viewer'] },
|
||||
afterState: { roleIds: ['role_admin'] },
|
||||
req: mockReq,
|
||||
metadata: { oldRoleIds: ['role_viewer'], newRoleIds: ['role_admin'] }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { operationType: 'permission_change' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.oldRoleIds).toEqual(['role_viewer']);
|
||||
expect(logs[0].metadata.newRoleIds).toEqual(['role_admin']);
|
||||
});
|
||||
|
||||
test('应该记录用户删除日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logUserOperation(
|
||||
'delete',
|
||||
'删除用户 deleted_user',
|
||||
{
|
||||
targetId: 'user_deleted',
|
||||
targetName: 'deleted_user',
|
||||
beforeState: { username: 'deleted_user', email: 'deleted@example.com' },
|
||||
req: mockReq
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'user', operationType: 'delete' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logRoleOperation 角色操作日志', () => {
|
||||
test('应该记录角色创建日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logRoleOperation(
|
||||
'create',
|
||||
'创建角色 测试角色',
|
||||
{
|
||||
targetId: 'role_test',
|
||||
targetName: '测试角色',
|
||||
afterState: { roleName: '测试角色', permissions: ['read', 'write'] },
|
||||
req: mockReq,
|
||||
metadata: { roleCode: 'test_role', permissions: ['read', 'write'] }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'role', operationType: 'create' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].targetName).toBe('测试角色');
|
||||
expect(logs[0].metadata.roleCode).toBe('test_role');
|
||||
});
|
||||
|
||||
test('应该记录角色更新日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const beforeState = { roleName: '旧角色', permissions: ['read'] };
|
||||
const afterState = { roleName: '新角色', permissions: ['read', 'write', 'delete'] };
|
||||
|
||||
await logRoleOperation(
|
||||
'update',
|
||||
'更新角色 角色A',
|
||||
{
|
||||
targetId: 'role_a',
|
||||
targetName: '角色A',
|
||||
beforeState,
|
||||
afterState,
|
||||
req: mockReq,
|
||||
metadata: { oldRoleName: '旧角色', oldPermissions: ['read'] }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'role', operationType: 'update' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].beforeState.roleName).toBe('旧角色');
|
||||
expect(logs[0].afterState.roleName).toBe('新角色');
|
||||
});
|
||||
|
||||
test('应该记录角色删除日志', async () => {
|
||||
const mockReq = {
|
||||
user: { userId: 'admin', realName: '管理员' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await logRoleOperation(
|
||||
'delete',
|
||||
'删除角色 测试角色B',
|
||||
{
|
||||
targetId: 'role_b',
|
||||
targetName: '测试角色B',
|
||||
beforeState: { roleName: '测试角色B', userCount: 0 },
|
||||
req: mockReq,
|
||||
metadata: { userCount: 0 }
|
||||
}
|
||||
);
|
||||
|
||||
const logs = await OperationLog.findAll({
|
||||
where: { module: 'role', operationType: 'delete' }
|
||||
});
|
||||
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].metadata.userCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
process.env.JWT_SECRET = 'test-secret-key-for-jest-testing-minimum-32-chars-long';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_DIALECT = 'sqlite';
|
||||
process.env.DB_STORAGE = ':memory:';
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Op } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
|
||||
const JWT_SECRET = 'test-secret-key-for-jest-testing-minimum-32-chars-long';
|
||||
|
||||
const createTestApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, message: '未授权' });
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ success: false, message: '无效的令牌' });
|
||||
}
|
||||
};
|
||||
|
||||
const operationLogsRouter = express.Router();
|
||||
|
||||
operationLogsRouter.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
module,
|
||||
operationType,
|
||||
targetId,
|
||||
operatorId,
|
||||
keyword,
|
||||
startDate,
|
||||
endDate,
|
||||
result
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
const limit = Math.min(parseInt(pageSize), 100);
|
||||
|
||||
const where = {};
|
||||
|
||||
if (module && module !== 'all') {
|
||||
where.module = module;
|
||||
}
|
||||
|
||||
if (operationType && operationType !== 'all') {
|
||||
where.operationType = operationType;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
where.targetId = { [Op.like]: `%${targetId}%` };
|
||||
}
|
||||
|
||||
if (operatorId) {
|
||||
where.operatorId = operatorId;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
where[Op.or] = [
|
||||
{ operationDescription: { [Op.like]: `%${keyword}%` } },
|
||||
{ targetName: { [Op.like]: `%${keyword}%` } },
|
||||
{ operatorName: { [Op.like]: `%${keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
if (result && result !== 'all') {
|
||||
where.result = result;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
const endOfDay = new Date(endDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
where.createdAt[Op.lte] = endOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows: logs } = await OperationLog.findAndCountAll({
|
||||
where,
|
||||
order: [['createdAt', 'DESC']],
|
||||
offset,
|
||||
limit
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
logs
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作日志失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作日志失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
operationLogsRouter.get('/:recordId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const log = await OperationLog.findByPk(req.params.recordId);
|
||||
|
||||
if (!log) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '日志记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: log
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取操作日志详情失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取操作日志详情失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/operation-logs', operationLogsRouter);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('OperationLogs API 路由测试', () => {
|
||||
let app;
|
||||
let authToken;
|
||||
const testUser = {
|
||||
userId: 'test_user_001',
|
||||
username: 'testuser',
|
||||
realName: '测试用户',
|
||||
roleName: '管理员'
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
app = createTestApp();
|
||||
authToken = jwt.sign(testUser, JWT_SECRET, { expiresIn: '24h' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await OperationLog.destroy({ where: {} });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await sequelize.close();
|
||||
});
|
||||
|
||||
const createTestLog = async (data = {}) => {
|
||||
const defaultData = {
|
||||
recordId: `OPLOG_API_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
operationDescription: '测试日志',
|
||||
targetId: 'DEV_TEST',
|
||||
targetName: '测试设备',
|
||||
operatorId: testUser.userId,
|
||||
operatorName: testUser.realName,
|
||||
operatorRole: testUser.roleName,
|
||||
result: 'success'
|
||||
};
|
||||
return await OperationLog.create({ ...defaultData, ...data });
|
||||
};
|
||||
|
||||
describe('GET /api/operation-logs', () => {
|
||||
test('未授权访问应该返回 401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('应该能够获取日志列表', async () => {
|
||||
await createTestLog({ recordId: 'OPLOG_LST_001' });
|
||||
await createTestLog({ recordId: 'OPLOG_LST_002' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.logs).toHaveLength(2);
|
||||
expect(response.body.data.total).toBe(2);
|
||||
});
|
||||
|
||||
test('应该支持分页参数', async () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await createTestLog({ recordId: `OPLOG_PAGE_${i}` });
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ page: 1, pageSize: 10 })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.logs).toHaveLength(10);
|
||||
expect(response.body.data.total).toBe(15);
|
||||
expect(response.body.data.page).toBe(1);
|
||||
expect(response.body.data.pageSize).toBe(10);
|
||||
});
|
||||
|
||||
test('应该支持按 module 筛选', async () => {
|
||||
await createTestLog({ module: 'device', recordId: 'OPLOG_MOD_1' });
|
||||
await createTestLog({ module: 'user', recordId: 'OPLOG_MOD_2' });
|
||||
await createTestLog({ module: 'role', recordId: 'OPLOG_MOD_3' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ module: 'device' })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.logs).toHaveLength(1);
|
||||
expect(response.body.data.logs[0].module).toBe('device');
|
||||
});
|
||||
|
||||
test('应该支持按 operationType 筛选', async () => {
|
||||
await createTestLog({ operationType: 'create', recordId: 'OPLOG_TYPE_1' });
|
||||
await createTestLog({ operationType: 'update', recordId: 'OPLOG_TYPE_2' });
|
||||
await createTestLog({ operationType: 'delete', recordId: 'OPLOG_TYPE_3' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ operationType: 'create' })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.logs).toHaveLength(1);
|
||||
expect(response.body.data.logs[0].operationType).toBe('create');
|
||||
});
|
||||
|
||||
test('应该支持按 keyword 搜索', async () => {
|
||||
await createTestLog({ operationDescription: '创建设备 SERVER_A', recordId: 'OPLOG_KW_1', targetName: '服务器A' });
|
||||
await createTestLog({ operationDescription: '更新设备 SERVER_B', recordId: 'OPLOG_KW_2', targetName: '服务器B' });
|
||||
await createTestLog({ operationDescription: '删除用户 USER_C', recordId: 'OPLOG_KW_3', targetName: '用户C' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ keyword: 'SERVER' })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.logs).toHaveLength(2);
|
||||
expect(response.body.data.logs.every(log => log.operationDescription.includes('SERVER'))).toBe(true);
|
||||
});
|
||||
|
||||
test('应该支持按 result 筛选', async () => {
|
||||
await createTestLog({ result: 'success', recordId: 'OPLOG_RES_1' });
|
||||
await createTestLog({ result: 'failed', recordId: 'OPLOG_RES_2' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ result: 'failed' })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.logs).toHaveLength(1);
|
||||
expect(response.body.data.logs[0].result).toBe('failed');
|
||||
});
|
||||
|
||||
test('应该支持多条件组合筛选', async () => {
|
||||
await createTestLog({
|
||||
module: 'device',
|
||||
operationType: 'create',
|
||||
result: 'success',
|
||||
recordId: 'OPLOG_COMB_1'
|
||||
});
|
||||
await createTestLog({
|
||||
module: 'device',
|
||||
operationType: 'update',
|
||||
result: 'success',
|
||||
recordId: 'OPLOG_COMB_2'
|
||||
});
|
||||
await createTestLog({
|
||||
module: 'user',
|
||||
operationType: 'create',
|
||||
result: 'success',
|
||||
recordId: 'OPLOG_COMB_3'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs')
|
||||
.query({ module: 'device', operationType: 'create' })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.logs).toHaveLength(1);
|
||||
expect(response.body.data.logs[0].module).toBe('device');
|
||||
expect(response.body.data.logs[0].operationType).toBe('create');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/operation-logs/:recordId', () => {
|
||||
test('应该能够获取单个日志详情', async () => {
|
||||
const log = await createTestLog({
|
||||
recordId: 'OPLOG_DETAIL_001',
|
||||
beforeState: { name: '旧名称' },
|
||||
afterState: { name: '新名称' },
|
||||
metadata: { customField: '自定义值' }
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs/OPLOG_DETAIL_001')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.recordId).toBe('OPLOG_DETAIL_001');
|
||||
});
|
||||
|
||||
test('不存在的日志应该返回 404', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/operation-logs/NON_EXISTENT_LOG')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
let isClosed = false;
|
||||
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
if (!isClosed) {
|
||||
await sequelize.sync({ force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup beforeAll error:', error);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
await sequelize.close();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore close errors
|
||||
}
|
||||
}, 30000);
|
||||
@@ -0,0 +1,4 @@
|
||||
process.env.JWT_SECRET = 'test-secret-key-for-jest-testing-minimum-32-chars-long';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_DIALECT = 'sqlite';
|
||||
process.env.DB_STORAGE = ':memory:';
|
||||
@@ -0,0 +1,204 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOG_DIR = path.join(__dirname, '../../logs');
|
||||
const DANGEROUS_OPERATIONS_LOG = path.join(LOG_DIR, 'dangerous-operations.log');
|
||||
|
||||
const ensureLogDir = () => {
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const formatLogEntry = (entry) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
...entry,
|
||||
}) + '\n';
|
||||
};
|
||||
|
||||
const logDangerousOperation = async (req, {
|
||||
operationType,
|
||||
operationName,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
metadata = {},
|
||||
success = true,
|
||||
errorMessage = null,
|
||||
}) => {
|
||||
ensureLogDir();
|
||||
|
||||
const clientIp = req?.ip || req?.connection?.remoteAddress || 'unknown';
|
||||
const userAgent = req?.get?.('User-Agent') || 'unknown';
|
||||
const userId = req?.user?.userId || req?.session?.userId || 'anonymous';
|
||||
const username = req?.user?.username || req?.session?.username || 'anonymous';
|
||||
|
||||
const logEntry = {
|
||||
operationType,
|
||||
operationName,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState: beforeState ? JSON.stringify(beforeState) : null,
|
||||
metadata,
|
||||
success,
|
||||
errorMessage,
|
||||
clientIp,
|
||||
userAgent,
|
||||
userId,
|
||||
username,
|
||||
riskLevel: metadata.riskLevel || 'UNKNOWN',
|
||||
relatedDataCount: metadata.relatedDataCount || 0,
|
||||
itemCount: metadata.itemCount || 1,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.appendFileSync(DANGEROUS_OPERATIONS_LOG, formatLogEntry(logEntry));
|
||||
console.log(`[DANGEROUS-OP] ${logEntry.operationName} by ${logEntry.username} - ${success ? 'SUCCESS' : 'FAILED'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to write dangerous operation log:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getDangerousOperationsLogs = (filters = {}) => {
|
||||
ensureLogDir();
|
||||
|
||||
if (!fs.existsSync(DANGEROUS_OPERATIONS_LOG)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(DANGEROUS_OPERATIONS_LOG, 'utf-8');
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
let logs = lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(log => log !== null);
|
||||
|
||||
if (filters.operationType) {
|
||||
logs = logs.filter(log => log.operationType === filters.operationType);
|
||||
}
|
||||
|
||||
if (filters.targetType) {
|
||||
logs = logs.filter(log => log.targetType === filters.targetType);
|
||||
}
|
||||
|
||||
if (filters.success !== undefined) {
|
||||
logs = logs.filter(log => log.success === filters.success);
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
logs = logs.filter(log => new Date(log.timestamp) >= new Date(filters.startDate));
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
logs = logs.filter(log => new Date(log.timestamp) <= new Date(filters.endDate));
|
||||
}
|
||||
|
||||
if (filters.username) {
|
||||
logs = logs.filter(log => log.username?.toLowerCase().includes(filters.username.toLowerCase()));
|
||||
}
|
||||
|
||||
if (filters.riskLevel) {
|
||||
logs = logs.filter(log => log.riskLevel === filters.riskLevel);
|
||||
}
|
||||
|
||||
return logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
} catch (error) {
|
||||
console.error('Failed to read dangerous operations log:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const cleanOldLogs = (daysToKeep = 90) => {
|
||||
ensureLogDir();
|
||||
|
||||
if (!fs.existsSync(DANGEROUS_OPERATIONS_LOG)) {
|
||||
return { deletedCount: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(DANGEROUS_OPERATIONS_LOG, 'utf-8');
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
const remainingLogs = [];
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const logEntry = JSON.parse(line);
|
||||
if (new Date(logEntry.timestamp) >= cutoffDate) {
|
||||
remainingLogs.push(line);
|
||||
} else {
|
||||
deletedCount++;
|
||||
}
|
||||
} catch {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(DANGEROUS_OPERATIONS_LOG, remainingLogs.join('\n') + '\n');
|
||||
|
||||
return { deletedCount, remainingCount: remainingLogs.length };
|
||||
} catch (error) {
|
||||
console.error('Failed to clean old logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const DANGEROUS_OPERATION_TYPES = {
|
||||
DELETE_SINGLE: 'DELETE_SINGLE',
|
||||
DELETE_BATCH: 'DELETE_BATCH',
|
||||
DELETE_ALL: 'DELETE_ALL',
|
||||
UPDATE_BATCH: 'UPDATE_BATCH',
|
||||
RESTORE: 'RESTORE',
|
||||
PURGE: 'PURGE',
|
||||
BATCH_RESTORE: 'BATCH_RESTORE',
|
||||
};
|
||||
|
||||
const RISK_LEVELS = {
|
||||
EXTREME: 'EXTREME',
|
||||
HIGH: 'HIGH',
|
||||
MEDIUM: 'MEDIUM',
|
||||
LOW: 'LOW',
|
||||
};
|
||||
|
||||
const calculateRiskLevel = (operationType, itemCount, options = {}) => {
|
||||
const { hasRelatedData = false, isSystemLevel = false } = options;
|
||||
|
||||
if (operationType === DANGEROUS_OPERATION_TYPES.DELETE_ALL || isSystemLevel) {
|
||||
return RISK_LEVELS.EXTREME;
|
||||
}
|
||||
|
||||
if (operationType === DANGEROUS_OPERATION_TYPES.DELETE_BATCH) {
|
||||
if (itemCount > 10) {
|
||||
return RISK_LEVELS.EXTREME;
|
||||
}
|
||||
return itemCount > 3 ? RISK_LEVELS.HIGH : RISK_LEVELS.MEDIUM;
|
||||
}
|
||||
|
||||
if (hasRelatedData) {
|
||||
return RISK_LEVELS.MEDIUM;
|
||||
}
|
||||
|
||||
return RISK_LEVELS.LOW;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logDangerousOperation,
|
||||
getDangerousOperationsLogs,
|
||||
cleanOldLogs,
|
||||
DANGEROUS_OPERATION_TYPES,
|
||||
RISK_LEVELS,
|
||||
calculateRiskLevel,
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
const { sequelize, DB_TYPE, dbDialect } = require('../db');
|
||||
|
||||
const checkDatabase = async () => {
|
||||
const result = {
|
||||
status: 'ok',
|
||||
type: dbDialect,
|
||||
message: '数据库连接正常'
|
||||
};
|
||||
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
result.status = 'ok';
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.message = `数据库连接失败: ${error.message}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('SELECT 1');
|
||||
result.status = 'ok';
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.message = `数据库查询失败: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkCriticalConfig = () => {
|
||||
const checks = [];
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
checks.push({
|
||||
key: 'JWT_SECRET',
|
||||
status: 'error',
|
||||
message: 'JWT_SECRET 未配置'
|
||||
});
|
||||
} else if (jwtSecret.length < 32) {
|
||||
checks.push({
|
||||
key: 'JWT_SECRET',
|
||||
status: 'warning',
|
||||
message: 'JWT_SECRET 长度不足,建议至少 32 字符'
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
key: 'JWT_SECRET',
|
||||
status: 'ok',
|
||||
message: 'JWT_SECRET 已配置'
|
||||
});
|
||||
}
|
||||
|
||||
const port = process.env.PORT;
|
||||
checks.push({
|
||||
key: 'PORT',
|
||||
status: port ? 'ok' : 'warning',
|
||||
message: port ? `服务端口: ${port}` : '使用默认端口 8000'
|
||||
});
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
checks.push({
|
||||
key: 'DB_TYPE',
|
||||
status: 'ok',
|
||||
message: `数据库类型: ${dbType}`
|
||||
});
|
||||
|
||||
if (dbType === 'mysql') {
|
||||
const mysqlHost = process.env.MYSQL_HOST;
|
||||
const mysqlDb = process.env.MYSQL_DATABASE;
|
||||
if (!mysqlHost || !mysqlDb) {
|
||||
checks.push({
|
||||
key: 'MYSQL_CONFIG',
|
||||
status: 'warning',
|
||||
message: 'MySQL 配置不完整'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const overallStatus = checks.every(c => c.status === 'ok')
|
||||
? 'ok'
|
||||
: checks.some(c => c.status === 'error')
|
||||
? 'error'
|
||||
: 'warning';
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
checks
|
||||
};
|
||||
};
|
||||
|
||||
const getSystemInfo = () => {
|
||||
const memUsage = process.memoryUsage();
|
||||
const uptime = process.uptime();
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}天`);
|
||||
if (hours > 0) parts.push(`${hours}小时`);
|
||||
if (minutes > 0) parts.push(`${minutes}分钟`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}秒`);
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
return {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
memory: {
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100,
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100,
|
||||
unit: 'MB'
|
||||
},
|
||||
uptime: formatUptime(uptime),
|
||||
uptimeSeconds: Math.round(uptime)
|
||||
};
|
||||
};
|
||||
|
||||
const performHealthCheck = async () => {
|
||||
const [dbCheck] = await Promise.all([checkDatabase()]);
|
||||
const configCheck = checkCriticalConfig();
|
||||
const systemInfo = getSystemInfo();
|
||||
|
||||
const allChecks = [
|
||||
{ name: 'database', ...dbCheck },
|
||||
{ name: 'config', ...configCheck }
|
||||
];
|
||||
|
||||
const overallStatus = allChecks.every(c => c.status === 'ok')
|
||||
? 'ok'
|
||||
: allChecks.some(c => c.status === 'error')
|
||||
? 'error'
|
||||
: 'warning';
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: {
|
||||
name: 'IDC设备管理系统',
|
||||
version: '1.0.0'
|
||||
},
|
||||
checks: allChecks,
|
||||
system: systemInfo
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
performHealthCheck,
|
||||
checkDatabase,
|
||||
checkCriticalConfig,
|
||||
getSystemInfo
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
const OperationLog = require('../models/OperationLog');
|
||||
|
||||
const generateRecordId = () => {
|
||||
return `OPLOG_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
const getOperatorInfo = (req) => {
|
||||
if (!req || !req.user) {
|
||||
return {
|
||||
operatorId: 'system',
|
||||
operatorName: '系统',
|
||||
operatorRole: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
operatorId: req.user.userId || req.user.id || 'unknown',
|
||||
operatorName: req.user.realName || req.user.username || '未知用户',
|
||||
operatorRole: req.user.roleName || req.user.role || null
|
||||
};
|
||||
};
|
||||
|
||||
const getClientInfo = (req) => {
|
||||
if (!req) {
|
||||
return { ipAddress: null, userAgent: null };
|
||||
}
|
||||
const ipAddress = req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.ip ||
|
||||
null;
|
||||
const userAgent = req.headers['user-agent'] || null;
|
||||
return { ipAddress, userAgent };
|
||||
};
|
||||
|
||||
async function logOperation({
|
||||
module,
|
||||
operationType,
|
||||
operationDescription,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
afterState,
|
||||
result = 'success',
|
||||
req,
|
||||
metadata = {}
|
||||
}) {
|
||||
try {
|
||||
const operatorInfo = getOperatorInfo(req);
|
||||
const clientInfo = getClientInfo(req);
|
||||
|
||||
await OperationLog.create({
|
||||
recordId: generateRecordId(),
|
||||
module,
|
||||
operationType,
|
||||
operationDescription,
|
||||
targetId: targetId || null,
|
||||
targetName: targetName || null,
|
||||
operatorId: operatorInfo.operatorId,
|
||||
operatorName: operatorInfo.operatorName,
|
||||
operatorRole: operatorInfo.operatorRole,
|
||||
beforeState: beforeState || null,
|
||||
afterState: afterState || null,
|
||||
result,
|
||||
ipAddress: clientInfo.ipAddress,
|
||||
userAgent: clientInfo.userAgent,
|
||||
metadata
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('记录操作日志失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function logDeviceOperation(operationType, operationDescription, { targetId, targetName, beforeState, afterState, result, req, metadata = {} }) {
|
||||
return logOperation({
|
||||
module: 'device',
|
||||
operationType,
|
||||
operationDescription,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
afterState,
|
||||
result,
|
||||
req,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
|
||||
async function logUserOperation(operationType, operationDescription, { targetId, targetName, beforeState, afterState, result, req, metadata = {} }) {
|
||||
return logOperation({
|
||||
module: 'user',
|
||||
operationType,
|
||||
operationDescription,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
afterState,
|
||||
result,
|
||||
req,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
|
||||
async function logRoleOperation(operationType, operationDescription, { targetId, targetName, beforeState, afterState, result, req, metadata = {} }) {
|
||||
return logOperation({
|
||||
module: 'role',
|
||||
operationType,
|
||||
operationDescription,
|
||||
targetId,
|
||||
targetName,
|
||||
beforeState,
|
||||
afterState,
|
||||
result,
|
||||
req,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logOperation,
|
||||
logDeviceOperation,
|
||||
logUserOperation,
|
||||
logRoleOperation
|
||||
};
|
||||
+17
-3
@@ -80,7 +80,9 @@ const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManageme
|
||||
const BackupManagement = lazy(() => import('./pages/BackupManagement'));
|
||||
const AutoBackupSettings = lazy(() => import('./pages/AutoBackupSettings'));
|
||||
const RemoteBackupSettings = lazy(() => import('./pages/RemoteBackupSettings'));
|
||||
const OperationLogs = lazy(() => import('./pages/OperationLogs'));
|
||||
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
|
||||
const IdleDeviceManagement = lazy(() => import('./pages/IdleDeviceManagement'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -191,11 +193,11 @@ const AppLayout = ({ children }) => {
|
||||
if (path === '/') return 'dashboard';
|
||||
if (path.startsWith('/visualization-3d')) return 'visualization-3d';
|
||||
if (path.startsWith('/rooms') || path.startsWith('/racks')) return 'room-management';
|
||||
if (
|
||||
path.startsWith('/devices') ||
|
||||
if (path.startsWith('/devices') ||
|
||||
path.startsWith('/fields') ||
|
||||
path.startsWith('/cables') ||
|
||||
path.startsWith('/ports')
|
||||
path.startsWith('/ports') ||
|
||||
path.startsWith('/idle-devices')
|
||||
)
|
||||
return 'asset-management';
|
||||
if (path.startsWith('/consumables')) return 'consumables-management';
|
||||
@@ -250,6 +252,11 @@ const AppLayout = ({ children }) => {
|
||||
icon: <CloudServerOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/devices">设备管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'idle-devices',
|
||||
icon: <CloudServerOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/idle-devices">空闲设备</Link>,
|
||||
},
|
||||
{
|
||||
key: 'fields',
|
||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||
@@ -358,6 +365,11 @@ const AppLayout = ({ children }) => {
|
||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/backup">数据备份</Link>,
|
||||
},
|
||||
{
|
||||
key: 'operation-logs',
|
||||
icon: <AuditOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/operation-logs">操作日志</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -597,7 +609,9 @@ const routeConfig = [
|
||||
{ path: '/backup', component: BackupManagement },
|
||||
{ path: '/auto-backup-settings', component: AutoBackupSettings },
|
||||
{ path: '/remote-backup-settings', component: RemoteBackupSettings },
|
||||
{ path: '/operation-logs', component: OperationLogs },
|
||||
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
|
||||
{ path: '/idle-devices', component: IdleDeviceManagement },
|
||||
];
|
||||
|
||||
const ThemeConfig = () => {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Input, Alert, Typography, Space, Divider, Spin } from 'antd';
|
||||
import { WarningOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
RISK_LEVEL,
|
||||
RISK_CONFIG,
|
||||
OPERATION_TYPES,
|
||||
OPERATION_LABELS,
|
||||
ENTITY_LABELS,
|
||||
getRiskLevel,
|
||||
} from '../config/dangerousOperationConfig';
|
||||
|
||||
const { Text, Paragraph, Title } = Typography;
|
||||
|
||||
export const DangerConfirmModal = ({
|
||||
open,
|
||||
operationType = OPERATION_TYPES.DELETE_SINGLE,
|
||||
entityType = 'item',
|
||||
items = [],
|
||||
itemCount = 1,
|
||||
title = '',
|
||||
description = '',
|
||||
impactDetails = {},
|
||||
onConfirm,
|
||||
onCancel,
|
||||
okText = '确认',
|
||||
cancelText = '取消',
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const riskLevel = getRiskLevel(operationType, itemCount, {
|
||||
isSystemLevel: impactDetails.isSystemLevel,
|
||||
hasRelatedData: impactDetails.relatedDataCount > 0,
|
||||
});
|
||||
|
||||
const config = RISK_CONFIG[riskLevel];
|
||||
const entityLabel = ENTITY_LABELS[entityType] || entityType;
|
||||
const operationLabel = OPERATION_LABELS[operationType] || operationType;
|
||||
|
||||
const isKeywordRequired = riskLevel === RISK_LEVEL.EXTREME && config.requireKeyword;
|
||||
const isKeywordValid = !isKeywordRequired || keyword.toUpperCase() === config.keyword;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isKeywordRequired) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [open, isKeywordRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword('');
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!isKeywordValid) return;
|
||||
|
||||
setConfirmLoading(true);
|
||||
try {
|
||||
await onConfirm?.();
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderImpactDetails = () => {
|
||||
if (!impactDetails || Object.keys(impactDetails).length === 0) return null;
|
||||
|
||||
const items = [];
|
||||
|
||||
if (impactDetails.relatedDevices !== undefined) {
|
||||
items.push({ label: '关联设备', value: `${impactDetails.relatedDevices} 个` });
|
||||
}
|
||||
if (impactDetails.relatedCables !== undefined) {
|
||||
items.push({ label: '关联接线', value: `${impactDetails.relatedCables} 条` });
|
||||
}
|
||||
if (impactDetails.relatedPorts !== undefined) {
|
||||
items.push({ label: '关联端口', value: `${impactDetails.relatedPorts} 个` });
|
||||
}
|
||||
if (impactDetails.relatedNetworkCards !== undefined) {
|
||||
items.push({ label: '关联网卡', value: `${impactDetails.relatedNetworkCards} 张` });
|
||||
}
|
||||
if (impactDetails.relatedTickets !== undefined) {
|
||||
items.push({ label: '关联工单', value: `${impactDetails.relatedTickets} 个` });
|
||||
}
|
||||
if (impactDetails.relatedRacks !== undefined) {
|
||||
items.push({ label: '关联机柜', value: `${impactDetails.relatedRacks} 个` });
|
||||
}
|
||||
if (impactDetails.relatedRooms !== undefined) {
|
||||
items.push({ label: '关联机房', value: `${impactDetails.relatedRooms} 个` });
|
||||
}
|
||||
if (impactDetails.relatedDataCount > 0) {
|
||||
items.push({ label: '其他关联数据', value: `${impactDetails.relatedDataCount} 项` });
|
||||
}
|
||||
if (impactDetails.totalAffected !== undefined) {
|
||||
items.push({ label: '总影响数量', value: `${impactDetails.totalAffected} 项` });
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.impactSection}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8, color: config.color }}>
|
||||
⚠️ 影响范围
|
||||
</Text>
|
||||
<div style={styles.impactList}>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={styles.impactItem}>
|
||||
<Text type="secondary">{item.label}:</Text>
|
||||
<Text strong>{item.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
return (
|
||||
<div style={styles.contentContainer}>
|
||||
<Alert
|
||||
message={
|
||||
<Space>
|
||||
<WarningOutlined style={{ color: config.color }} />
|
||||
<Text strong style={{ color: config.color, fontSize: 16 }}>
|
||||
{config.title}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Paragraph style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
{description || `确定要${operationLabel} ${itemCount > 1 ? `${itemCount} 个` : '该'}${entityLabel}吗?`}
|
||||
</Paragraph>
|
||||
}
|
||||
type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'}
|
||||
style={{
|
||||
backgroundColor: config.bgColor,
|
||||
borderColor: config.borderColor,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{riskLevel !== RISK_LEVEL.LOW && (
|
||||
<div style={styles.warningBox}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f', marginRight: 6 }} />
|
||||
此操作不可逆,一旦删除将无法恢复
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.showImpactDetails && renderImpactDetails()}
|
||||
|
||||
{isKeywordRequired && (
|
||||
<div style={styles.keywordSection}>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<Alert
|
||||
message={
|
||||
<Text>
|
||||
为确认此{operationLabel}操作,请输入确认关键词:
|
||||
<Text code strong style={{ marginLeft: 8, fontSize: 14 }}>
|
||||
{config.keyword}
|
||||
</Text>
|
||||
</Text>
|
||||
}
|
||||
type="error"
|
||||
style={{ marginBottom: 12 }}
|
||||
showIcon
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={`请输入 ${config.keyword}`}
|
||||
status={keyword && !isKeywordValid ? 'error' : undefined}
|
||||
onPressEnter={handleOk}
|
||||
style={{ fontSize: 16, textAlign: 'center', letterSpacing: 2 }}
|
||||
/>
|
||||
{keyword && !isKeywordValid && (
|
||||
<Text type="danger" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
|
||||
关键词不正确,请重新输入
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemCount > 1 && itemCount <= 5 && items.length > 0 && (
|
||||
<div style={styles.itemPreview}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
即将删除:
|
||||
</Text>
|
||||
<div style={styles.itemList}>
|
||||
{items.slice(0, 5).map((item, index) => (
|
||||
<Text key={index} style={styles.itemTag}>
|
||||
{typeof item === 'string' ? item : item.name || item.label || item}
|
||||
</Text>
|
||||
))}
|
||||
{itemCount > 5 && (
|
||||
<Text type="secondary">...还有 {itemCount - 5} 项</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<WarningOutlined style={{ color: config.color }} />
|
||||
<span style={{ color: config.color }}>
|
||||
{title || `${operationLabel}确认`}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText={okText}
|
||||
cancelText={cancelText}
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: !isKeywordValid,
|
||||
loading: confirmLoading,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
disabled: confirmLoading,
|
||||
}}
|
||||
width={520}
|
||||
centered
|
||||
maskClosable={!confirmLoading}
|
||||
closable={!confirmLoading}
|
||||
>
|
||||
<Spin spinning={confirmLoading} tip="正在执行操作...">
|
||||
{renderContent()}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
contentContainer: {
|
||||
padding: '8px 0',
|
||||
},
|
||||
impactSection: {
|
||||
backgroundColor: '#fafafa',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
},
|
||||
impactList: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 8,
|
||||
},
|
||||
impactItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
warningBox: {
|
||||
backgroundColor: '#fff2f0',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ffccc7',
|
||||
},
|
||||
keywordSection: {
|
||||
marginTop: 8,
|
||||
},
|
||||
itemPreview: {
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
},
|
||||
itemList: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
itemTag: {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #d9d9d9',
|
||||
fontSize: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export default DangerConfirmModal;
|
||||
@@ -0,0 +1,118 @@
|
||||
export const RISK_LEVEL = {
|
||||
EXTREME: 'EXTREME',
|
||||
HIGH: 'HIGH',
|
||||
MEDIUM: 'MEDIUM',
|
||||
LOW: 'LOW',
|
||||
};
|
||||
|
||||
export const RISK_CONFIG = {
|
||||
[RISK_LEVEL.EXTREME]: {
|
||||
title: '⚠️ 极高危险操作',
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff2f0',
|
||||
borderColor: '#ffccc7',
|
||||
requireKeyword: true,
|
||||
keyword: 'CONFIRM',
|
||||
showImpactDetails: true,
|
||||
icon: '🔥',
|
||||
},
|
||||
[RISK_LEVEL.HIGH]: {
|
||||
title: '⚠️ 高风险操作',
|
||||
color: '#fa8c16',
|
||||
bgColor: '#fff7e6',
|
||||
borderColor: '#ffd591',
|
||||
requireKeyword: false,
|
||||
showImpactDetails: true,
|
||||
icon: '⚡',
|
||||
},
|
||||
[RISK_LEVEL.MEDIUM]: {
|
||||
title: '⚡ 操作确认',
|
||||
color: '#1890ff',
|
||||
bgColor: '#e6f7ff',
|
||||
borderColor: '#91d5ff',
|
||||
requireKeyword: false,
|
||||
showImpactDetails: false,
|
||||
icon: '💡',
|
||||
},
|
||||
[RISK_LEVEL.LOW]: {
|
||||
title: '确认操作',
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
requireKeyword: false,
|
||||
showImpactDetails: false,
|
||||
icon: '✓',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATION_TYPES = {
|
||||
DELETE_SINGLE: 'DELETE_SINGLE',
|
||||
DELETE_BATCH: 'DELETE_BATCH',
|
||||
DELETE_ALL: 'DELETE_ALL',
|
||||
UPDATE_BATCH: 'UPDATE_BATCH',
|
||||
RESTORE: 'RESTORE',
|
||||
PURGE: 'PURGE',
|
||||
};
|
||||
|
||||
export const getRiskLevel = (operationType, itemCount = 1, options = {}) => {
|
||||
const { hasRelatedData = false, isSystemLevel = false } = options;
|
||||
|
||||
if (operationType === OPERATION_TYPES.DELETE_ALL || isSystemLevel) {
|
||||
return RISK_LEVEL.EXTREME;
|
||||
}
|
||||
|
||||
if (operationType === OPERATION_TYPES.DELETE_BATCH) {
|
||||
if (itemCount > 10) {
|
||||
return RISK_LEVEL.EXTREME;
|
||||
}
|
||||
return itemCount > 3 ? RISK_LEVEL.HIGH : RISK_LEVEL.MEDIUM;
|
||||
}
|
||||
|
||||
if (hasRelatedData) {
|
||||
return RISK_LEVEL.MEDIUM;
|
||||
}
|
||||
|
||||
return RISK_LEVEL.LOW;
|
||||
};
|
||||
|
||||
export const OPERATION_LABELS = {
|
||||
[OPERATION_TYPES.DELETE_SINGLE]: '删除',
|
||||
[OPERATION_TYPES.DELETE_BATCH]: '批量删除',
|
||||
[OPERATION_TYPES.DELETE_ALL]: '删除所有',
|
||||
[OPERATION_TYPES.UPDATE_BATCH]: '批量更新',
|
||||
[OPERATION_TYPES.RESTORE]: '恢复',
|
||||
[OPERATION_TYPES.PURGE]: '清除',
|
||||
};
|
||||
|
||||
export const ENTITY_LABELS = {
|
||||
device: '设备',
|
||||
devices: '设备',
|
||||
rack: '机柜',
|
||||
racks: '机柜',
|
||||
room: '机房',
|
||||
rooms: '机房',
|
||||
cable: '接线',
|
||||
cables: '接线',
|
||||
port: '端口',
|
||||
ports: '端口',
|
||||
networkCard: '网卡',
|
||||
networkCards: '网卡',
|
||||
user: '用户',
|
||||
users: '用户',
|
||||
role: '角色',
|
||||
roles: '角色',
|
||||
consumable: '耗材',
|
||||
consumables: '耗材',
|
||||
ticket: '工单',
|
||||
tickets: '工单',
|
||||
category: '分类',
|
||||
categories: '分类',
|
||||
idleDevice: '空闲设备',
|
||||
idleDevices: '空闲设备',
|
||||
pendingDevice: '待同步设备',
|
||||
pendingDevices: '待同步设备',
|
||||
inventoryPlan: '盘点计划',
|
||||
inventoryPlans: '盘点计划',
|
||||
backup: '备份',
|
||||
backups: '备份',
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Alert, Typography, Descriptions, Divider } from 'antd';
|
||||
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
RISK_LEVEL,
|
||||
RISK_CONFIG,
|
||||
OPERATION_TYPES,
|
||||
OPERATION_LABELS,
|
||||
ENTITY_LABELS,
|
||||
getRiskLevel,
|
||||
} from '../config/dangerousOperationConfig';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
export const useDangerousOperation = () => {
|
||||
const confirm = async ({
|
||||
operationType = OPERATION_TYPES.DELETE_SINGLE,
|
||||
entityType = 'item',
|
||||
items = [],
|
||||
itemCount = 1,
|
||||
title = '',
|
||||
description = '',
|
||||
impactDetails = {},
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const riskLevel = getRiskLevel(operationType, itemCount, {
|
||||
isSystemLevel: impactDetails.isSystemLevel,
|
||||
hasRelatedData: impactDetails.relatedDataCount > 0,
|
||||
});
|
||||
|
||||
const config = RISK_CONFIG[riskLevel];
|
||||
const entityLabel = ENTITY_LABELS[entityType] || entityType;
|
||||
const operationLabel = OPERATION_LABELS[operationType] || operationType;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modalKey = `dangerous-${Date.now()}`;
|
||||
|
||||
const handleOk = () => {
|
||||
Modal.confirm({
|
||||
title: '确认执行此操作?',
|
||||
content: '此操作不可逆,请再次确认。',
|
||||
okText: '确认执行',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
if (onConfirm) {
|
||||
await onConfirm();
|
||||
}
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
if (onCancel) onCancel();
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
const impactText = renderImpactDetails(impactDetails, entityLabel);
|
||||
|
||||
return (
|
||||
<div style={styles.contentContainer}>
|
||||
<Alert
|
||||
message={
|
||||
<Text strong style={{ color: config.color }}>
|
||||
{config.icon} {config.title}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
{description || `您即将${operationLabel} ${itemCount > 1 ? `${itemCount} 个` : '1 个'}${entityLabel}。`}
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
此操作不可逆,一旦删除将无法恢复。
|
||||
</Text>
|
||||
</Paragraph>
|
||||
}
|
||||
type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'}
|
||||
style={{
|
||||
backgroundColor: config.bgColor,
|
||||
borderColor: config.borderColor,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
/>
|
||||
|
||||
{config.showImpactDetails && impactText && (
|
||||
<>
|
||||
<div style={styles.impactSection}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
<InfoCircleOutlined /> 影响范围:
|
||||
</Text>
|
||||
{impactText}
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{riskLevel === RISK_LEVEL.EXTREME && config.requireKeyword && (
|
||||
<div style={styles.keywordSection}>
|
||||
<Alert
|
||||
message={
|
||||
<Text>
|
||||
为确认此操作,请输入 <Text code strong>CONFIRM</Text>:
|
||||
</Text>
|
||||
}
|
||||
type="error"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
id={`keyword-input-${modalKey}`}
|
||||
placeholder="请输入 CONFIRM"
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value;
|
||||
const okButton = document.querySelector('.ant-modal-confirm .ant-btn-primary');
|
||||
if (okButton) {
|
||||
okButton.disabled = inputValue !== config.keyword;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImpactDetails = (details, entityLabel) => {
|
||||
if (!details || Object.keys(details).length === 0) return null;
|
||||
|
||||
const items = [];
|
||||
|
||||
if (details.relatedDevices) {
|
||||
items.push(`关联设备:${details.relatedDevices} 个`);
|
||||
}
|
||||
if (details.relatedCables) {
|
||||
items.push(`关联接线:${details.relatedCables} 条`);
|
||||
}
|
||||
if (details.relatedPorts) {
|
||||
items.push(`关联端口:${details.relatedPorts} 个`);
|
||||
}
|
||||
if (details.relatedNetworkCards) {
|
||||
items.push(`关联网卡:${details.relatedNetworkCards} 张`);
|
||||
}
|
||||
if (details.relatedTickets) {
|
||||
items.push(`关联工单:${details.relatedTickets} 个`);
|
||||
}
|
||||
if (details.relatedDataCount > 0) {
|
||||
items.push(`其他关联数据:${details.relatedDataCount} 项`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? (
|
||||
<ul style={{ margin: 0, paddingLeft: 20, color: '#666' }}>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null;
|
||||
};
|
||||
|
||||
Modal.confirm({
|
||||
title: (
|
||||
<span style={{ color: config.color }}>
|
||||
{config.icon} {title || `${operationLabel}确认`}
|
||||
</span>
|
||||
),
|
||||
icon: <WarningOutlined style={{ color: config.color }} />,
|
||||
content: renderContent(),
|
||||
okText: '确认',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
okButtonProps: {
|
||||
disabled: riskLevel === RISK_LEVEL.EXTREME,
|
||||
},
|
||||
width: 520,
|
||||
onOk: handleOk,
|
||||
onCancel: () => {
|
||||
if (onCancel) onCancel();
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const logOperation = async ({
|
||||
operationType,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
metadata = {},
|
||||
success = true,
|
||||
}) => {
|
||||
try {
|
||||
await axios.post('/api/operation-logs/dangerous', {
|
||||
operationType,
|
||||
targetType,
|
||||
targetId,
|
||||
targetName,
|
||||
metadata,
|
||||
success,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log dangerous operation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { confirm, logOperation };
|
||||
};
|
||||
|
||||
export const confirmDangerousOperation = async (options) => {
|
||||
const hook = useDangerousOperation();
|
||||
return hook.confirm(options);
|
||||
};
|
||||
|
||||
export default useDangerousOperation;
|
||||
@@ -304,17 +304,26 @@ function CableManagement() {
|
||||
};
|
||||
|
||||
const handleDelete = async cableId => {
|
||||
try {
|
||||
await axios.delete(`/api/cables/${cableId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchCables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条接线吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/cables/${cableId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchCables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
||||
@@ -102,14 +102,23 @@ function CategoryManagement() {
|
||||
};
|
||||
|
||||
const handleDelete = async id => {
|
||||
try {
|
||||
await axios.delete(`/api/consumable-categories/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个耗材分类吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/consumable-categories/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -243,17 +243,26 @@ function ConsumableManagement() {
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async consumableId => {
|
||||
try {
|
||||
await axios.delete(`/api/consumables/${consumableId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个耗材吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/consumables/${consumableId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchConsumables();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[fetchConsumables]
|
||||
);
|
||||
|
||||
@@ -89,6 +89,7 @@ function DeviceManagement() {
|
||||
const [type, setType] = useState('all');
|
||||
const [roomId, setRoomId] = useState('all');
|
||||
const [rackId, setRackId] = useState('all');
|
||||
const [isIdle, setIsIdle] = useState('');
|
||||
const [searchForm] = Form.useForm();
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
@@ -137,6 +138,7 @@ function DeviceManagement() {
|
||||
type: type !== 'all' ? type : undefined,
|
||||
roomId: roomId !== 'all' ? roomId : undefined,
|
||||
rackId: rackId !== 'all' ? rackId : undefined,
|
||||
isIdle: isIdle || undefined,
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/devices', { params });
|
||||
@@ -160,8 +162,22 @@ function DeviceManagement() {
|
||||
try {
|
||||
setLoadingFields(true);
|
||||
const response = await axios.get('/api/deviceFields');
|
||||
const sortedFields = response.data.sort((a, b) => a.order - b.order);
|
||||
setDeviceFields(sortedFields);
|
||||
let fields = response.data.sort((a, b) => a.order - b.order);
|
||||
|
||||
// 补充缺失的 options(状态和设备类型)
|
||||
fields = fields.map(field => {
|
||||
if (field.fieldName === 'type' && !field.options) {
|
||||
const defaultTypeField = DEFAULT_DEVICE_FIELDS_LOCAL.find(f => f.fieldName === 'type');
|
||||
return { ...field, options: defaultTypeField?.options || [] };
|
||||
}
|
||||
if (field.fieldName === 'status' && !field.options) {
|
||||
const defaultStatusField = DEFAULT_DEVICE_FIELDS_LOCAL.find(f => f.fieldName === 'status');
|
||||
return { ...field, options: defaultStatusField?.options || [] };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
setDeviceFields(fields);
|
||||
} catch (error) {
|
||||
message.error('获取字段配置失败');
|
||||
console.error('获取字段配置失败:', error);
|
||||
@@ -186,7 +202,7 @@ function DeviceManagement() {
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/rooms');
|
||||
setRooms(response.data || []);
|
||||
setRooms(response.data.rooms || []);
|
||||
} catch (error) {
|
||||
message.error('获取机房列表失败');
|
||||
console.error('获取机房列表失败:', error);
|
||||
@@ -277,6 +293,7 @@ function DeviceManagement() {
|
||||
setType(values.type || 'all');
|
||||
setRoomId(values.roomId || 'all');
|
||||
setRackId(values.rackId || 'all');
|
||||
setIsIdle(values.isIdle || '');
|
||||
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
|
||||
@@ -291,6 +308,7 @@ function DeviceManagement() {
|
||||
setType('all');
|
||||
setRoomId('all');
|
||||
setRackId('all');
|
||||
setIsIdle('');
|
||||
searchForm.resetFields();
|
||||
|
||||
setTimeout(() => setSearching(false), 300);
|
||||
@@ -358,6 +376,34 @@ function DeviceManagement() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchToIdle = async () => {
|
||||
if (selectedDevices.length === 0) {
|
||||
message.warning('请先选择要标记为空闲的设备');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认标记为空闲',
|
||||
content: `确定要将选中的 ${selectedDevices.length} 个设备标记为空闲设备吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/idle-devices/batch-from-devices', {
|
||||
deviceIds: selectedDevices,
|
||||
idleReason: '从设备管理批量转入',
|
||||
});
|
||||
message.success(response.data.message || '设备已标记为空闲');
|
||||
setSelectedDevices([]);
|
||||
setSelectAll(false);
|
||||
fetchDevices(1, 10, true);
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '标记为空闲失败');
|
||||
console.error('标记为空闲失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (deviceId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
@@ -628,21 +674,27 @@ function DeviceManagement() {
|
||||
width: columnWidths[field.fieldName] || 100,
|
||||
onHeaderCell: handleHeaderCellResize(field.fieldName),
|
||||
render: (status) => {
|
||||
if (Array.isArray(status)) {
|
||||
return (
|
||||
<Space>
|
||||
{status.map((s) => (
|
||||
<span key={s} style={{ color: STATUS_MAP[s]?.color || 'black' }}>
|
||||
{STATUS_MAP[s]?.text || s}
|
||||
</span>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
const config = STATUS_MAP[status] || { text: status, color: 'default' };
|
||||
const statusStyles = {
|
||||
running: { bg: '#f6ffed', border: '#52c41a', text: '#389e0d' },
|
||||
maintenance: { bg: '#fffbE6', border: '#faad14', text: '#d48806' },
|
||||
offline: { bg: '#f5f5f5', border: '#8c8c8c', text: '#595959' },
|
||||
fault: { bg: '#fff2f0', border: '#ff4d4f', text: '#cf1322' },
|
||||
};
|
||||
const style = statusStyles[status] || { bg: '#fafafa', border: '#d9d9d9', text: '#595959' };
|
||||
return (
|
||||
<span style={{ color: STATUS_MAP[status]?.color || 'black' }}>
|
||||
{STATUS_MAP[status]?.text || status}
|
||||
</span>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.text,
|
||||
borderRadius: '4px',
|
||||
fontWeight: 500,
|
||||
boxShadow: `0 1px 2px ${style.border}30`,
|
||||
}}
|
||||
>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -830,6 +882,18 @@ function DeviceManagement() {
|
||||
>
|
||||
状态变更 ({selectedDevices.length})
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
...secondaryActionStyle,
|
||||
color: '#f59e0b',
|
||||
borderColor: '#f59e0b',
|
||||
}}
|
||||
icon={<CloudServerOutlined />}
|
||||
disabled={selectedDevices.length === 0}
|
||||
onClick={handleBatchToIdle}
|
||||
>
|
||||
标记为空闲 ({selectedDevices.length})
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
...secondaryActionStyle,
|
||||
@@ -964,6 +1028,16 @@ function DeviceManagement() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="isIdle" style={{ margin: 0 }}>
|
||||
<Select
|
||||
style={{ width: 140, borderRadius: designTokens.borderRadius.medium }}
|
||||
>
|
||||
<Option value="">所有设备</Option>
|
||||
<Option value="false">在用设备</Option>
|
||||
<Option value="true">空闲设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ margin: 0 }}>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,942 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Card,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Avatar,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
InboxOutlined,
|
||||
ClockCircleOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const IdleDeviceManagement = () => {
|
||||
const [idleDevices, setIdleDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [sourceTypeFilter, setSourceTypeFilter] = useState('all');
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isShelveModalVisible, setIsShelveModalVisible] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState(null);
|
||||
const [shelvingDevice, setShelvingDevice] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const [shelveForm] = Form.useForm();
|
||||
const [racks, setRacks] = useState([]);
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [selectedRoomId, setSelectedRoomId] = useState(null);
|
||||
const [selectedShelveRoomId, setSelectedShelveRoomId] = useState(null);
|
||||
|
||||
const fetchIdleDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword,
|
||||
sourceType: sourceTypeFilter,
|
||||
};
|
||||
const response = await axios.get('/api/idle-devices', { params });
|
||||
setIdleDevices(response.data.idleDevices || []);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: response.data.total || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error('获取空闲设备列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.current, pagination.pageSize, searchKeyword, sourceTypeFilter]);
|
||||
|
||||
const fetchRacks = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/racks', { params: { pageSize: 100 } });
|
||||
setRacks(response.data.racks || []);
|
||||
} catch (error) {
|
||||
console.error('获取机柜列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/rooms', { params: { pageSize: 100 } });
|
||||
setRooms(response.data.rooms || []);
|
||||
} catch (error) {
|
||||
console.error('获取机房列表失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdleDevices();
|
||||
}, [fetchIdleDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRacks();
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingDevice(null);
|
||||
setSelectedRoomId(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record) => {
|
||||
setEditingDevice(record);
|
||||
let roomId = null;
|
||||
if (record.rackId && record.Rack) {
|
||||
roomId = record.Rack.roomId;
|
||||
setSelectedRoomId(roomId);
|
||||
}
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
model: record.model,
|
||||
serialNumber: record.serialNumber,
|
||||
powerConsumption: record.powerConsumption,
|
||||
idleReason: record.idleReason,
|
||||
warehouseId: record.warehouseId,
|
||||
roomId: roomId,
|
||||
rackId: record.rackId,
|
||||
position: record.position,
|
||||
description: record.description,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (deviceId) => {
|
||||
try {
|
||||
await axios.delete(`/api/idle-devices/${deviceId}`);
|
||||
message.success('删除成功');
|
||||
fetchIdleDevices();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShelve = (record) => {
|
||||
setShelvingDevice(record);
|
||||
let roomId = null;
|
||||
if (record.rackId) {
|
||||
const rack = racks.find(r => r.rackId === record.rackId);
|
||||
if (rack) {
|
||||
roomId = rack.roomId;
|
||||
}
|
||||
}
|
||||
setSelectedShelveRoomId(roomId);
|
||||
shelveForm.setFieldsValue({
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
model: record.model,
|
||||
serialNumber: record.serialNumber,
|
||||
height: record.height || 1,
|
||||
powerConsumption: record.powerConsumption,
|
||||
roomId: roomId,
|
||||
rackId: record.rackId,
|
||||
position: record.position,
|
||||
description: record.description,
|
||||
});
|
||||
setIsShelveModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShelveSubmit = async () => {
|
||||
try {
|
||||
const values = await shelveForm.validateFields();
|
||||
const submitData = {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
model: values.model,
|
||||
serialNumber: values.serialNumber,
|
||||
height: values.height || 1,
|
||||
powerConsumption: values.powerConsumption || 0,
|
||||
rackId: values.rackId,
|
||||
position: values.position,
|
||||
description: values.description || '',
|
||||
};
|
||||
await axios.put(`/api/idle-devices/${shelvingDevice.deviceId}/shelve`, submitData);
|
||||
message.success('设备上架成功');
|
||||
setIsShelveModalVisible(false);
|
||||
fetchIdleDevices();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '上架失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const submitData = { ...values };
|
||||
if (submitData.warehouseId) {
|
||||
submitData.rackId = null;
|
||||
submitData.position = null;
|
||||
submitData.sourceType = 'warehouse';
|
||||
} else if (submitData.rackId) {
|
||||
submitData.warehouseId = null;
|
||||
submitData.sourceType = 'rack';
|
||||
}
|
||||
if (editingDevice) {
|
||||
await axios.put(`/api/idle-devices/${editingDevice.deviceId}`, submitData);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
const response = await axios.post('/api/idle-devices', submitData);
|
||||
message.success(`添加成功,设备ID:${response.data.deviceId}`);
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
fetchIdleDevices();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getIdleDays = (idleDate) => {
|
||||
if (!idleDate) return 0;
|
||||
const diff = new Date() - new Date(idleDate);
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (_, __, index) => (
|
||||
<Badge count={index + 1 + (pagination.current - 1) * pagination.pageSize} style={{ backgroundColor: '#f59e0b' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '设备信息',
|
||||
key: 'deviceInfo',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Avatar
|
||||
style={{ backgroundColor: record.type === 'server' ? '#3b82f6' : record.type === 'switch' ? '#8b5cf6' : '#64748b' }}
|
||||
icon={<InboxOutlined />}
|
||||
/>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: '14px', display: 'block' }}>{record.name || '-'}</Text>
|
||||
<Space size={4}>
|
||||
<Tag color={record.type === 'server' ? 'blue' : record.type === 'switch' ? 'purple' : 'default'} style={{ marginRight: 0 }}>
|
||||
{record.type === 'server' ? '服务器' : record.type === 'switch' ? '交换机' : '其他'}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>{record.model || '-'}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '设备ID',
|
||||
dataIndex: 'deviceId',
|
||||
key: 'deviceId',
|
||||
width: 100,
|
||||
render: (text) => (
|
||||
<Text code style={{ fontSize: '12px', padding: '2px 6px' }}>{text}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
key: 'location',
|
||||
width: 160,
|
||||
render: (_, record) => {
|
||||
if (record.sourceType === 'warehouse' && record.warehouseId) {
|
||||
return (
|
||||
<Space>
|
||||
<InboxOutlined style={{ color: '#64748b' }} />
|
||||
<Text type="secondary">{record.warehouseId}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (record.sourceType === 'rack' && record.Rack) {
|
||||
const location = [record.Rack.Room?.name, record.Rack.name, record.position ? `U${record.position}` : null].filter(Boolean).join(' / ');
|
||||
return (
|
||||
<Space>
|
||||
<InboxOutlined style={{ color: '#3b82f6' }} />
|
||||
<Text type="secondary">{location || '-'}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '空闲天数',
|
||||
key: 'idleDays',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_, record) => {
|
||||
const days = getIdleDays(record.idleDate);
|
||||
const color = days > 30 ? '#ef4444' : days > 7 ? '#f59e0b' : '#22c55e';
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text style={{ color, fontWeight: 600, fontSize: '16px' }}>{days}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: '11px' }}>天</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '空闲原因',
|
||||
dataIndex: 'idleReason',
|
||||
key: 'idleReason',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<Tooltip title={text || '-'}>
|
||||
<Text type="secondary" ellipsis>{text || '-'}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'sourceType',
|
||||
key: 'sourceType',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (type) => (
|
||||
<Tag color={type === 'warehouse' ? 'green' : 'blue'}>
|
||||
{type === 'warehouse' ? '库房' : '机架'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="上架">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UploadOutlined style={{ color: '#22c55e' }} />}
|
||||
onClick={() => handleShelve(record)}
|
||||
style={{ borderRadius: '6px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} style={{ borderRadius: '6px', color: '#3b82f6' }} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.deviceId)} okText="确认" cancelText="取消" okButtonProps={{ danger: true }}>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} style={{ borderRadius: '6px' }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: '空闲设备总数',
|
||||
value: pagination.total,
|
||||
icon: <InboxOutlined />,
|
||||
color: '#f59e0b',
|
||||
bg: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
},
|
||||
{
|
||||
title: '本周新增',
|
||||
value: idleDevices.filter(d => {
|
||||
if (!d.idleDate) return false;
|
||||
const diff = new Date() - new Date(d.idleDate);
|
||||
return diff < 7 * 24 * 60 * 60 * 1000;
|
||||
}).length,
|
||||
icon: <PlusOutlined />,
|
||||
color: '#22c55e',
|
||||
bg: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
},
|
||||
{
|
||||
title: '长期空闲(>30天)',
|
||||
value: idleDevices.filter(d => getIdleDays(d.idleDate) > 30).length,
|
||||
icon: <ClockCircleOutlined />,
|
||||
color: '#ef4444',
|
||||
bg: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8fafc', padding: '24px' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Title level={4} style={{ marginBottom: '4px', color: '#1e293b' }}>空闲设备管理</Title>
|
||||
<Text type="secondary">管理已下线或空闲的设备,支持恢复领用到设备管理</Text>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||
{statCards.map((stat, index) => (
|
||||
<Col key={index} xs={12} sm={8} md={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
background: stat.bg,
|
||||
}}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: '13px' }}>{stat.title}</Text>
|
||||
<div style={{ fontSize: '28px', fontWeight: 700, color: stat.color, lineHeight: 1.2, marginTop: '4px' }}>
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
color: stat.color
|
||||
}}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
}}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col flex="auto">
|
||||
<Space size="middle" wrap>
|
||||
<Input
|
||||
placeholder="搜索设备ID/名称/序列号"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
style={{ borderRadius: '10px', width: '260px', height: '40px' }}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
value={sourceTypeFilter}
|
||||
onChange={setSourceTypeFilter}
|
||||
style={{ width: 120, height: 40 }}
|
||||
>
|
||||
<Option value="all">全部来源</Option>
|
||||
<Option value="rack">机架</Option>
|
||||
<Option value="warehouse">库房</Option>
|
||||
</Select>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchIdleDevices} style={{ height: 40, borderRadius: '10px' }}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
添加空闲设备
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={idleDevices}
|
||||
rowKey="deviceId"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(record, index) => index % 2 === 0 ? 'table-row-even' : 'table-row-odd'}
|
||||
style={{ borderRadius: '0 0 16px 16px' }}
|
||||
/>
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #f1f5f9', background: '#fafafa' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Text type="secondary">
|
||||
共 <Text strong>{pagination.total}</Text> 条记录
|
||||
</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onChange={(page, pageSize) =>
|
||||
setPagination((prev) => ({ ...prev, current: page, pageSize }))
|
||||
}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
size="small"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: editingDevice ? '#3b82f6' : '#f59e0b'
|
||||
}} />
|
||||
{editingDevice ? '编辑空闲设备' : '添加空闲设备'}
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={680}
|
||||
destroyOnClose
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
style={{ top: 100 }}
|
||||
>
|
||||
<Form form={form} layout="vertical" size="middle">
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
|
||||
设备基本信息
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
|
||||
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="type" label="设备类型">
|
||||
<Select placeholder="请选择设备类型" allowClear style={{ borderRadius: '8px' }}>
|
||||
<Option value="server">服务器</Option>
|
||||
<Option value="switch">交换机</Option>
|
||||
<Option value="router">路由器</Option>
|
||||
<Option value="storage">存储设备</Option>
|
||||
<Option value="other">其他</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="model" label="设备型号">
|
||||
<Input placeholder="请输入设备型号" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="serialNumber" label="序列号">
|
||||
<Input placeholder="请输入序列号" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="powerConsumption" label="功耗(W)">
|
||||
<Input type="number" placeholder="请输入功耗" min={0} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{editingDevice && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="设备ID">
|
||||
<Input value={editingDevice.deviceId} disabled style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
|
||||
位置信息
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="roomId" label="机房">
|
||||
<Select
|
||||
placeholder="请选择机房"
|
||||
allowClear
|
||||
onChange={(value) => {
|
||||
setSelectedRoomId(value);
|
||||
form.setFieldsValue({ rackId: null, position: null });
|
||||
if (value) {
|
||||
form.setFieldsValue({ warehouseId: null });
|
||||
}
|
||||
}}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="rackId" label="机柜">
|
||||
<Select
|
||||
placeholder={selectedRoomId ? "请选择机柜" : "请先选择机房"}
|
||||
allowClear
|
||||
disabled={!selectedRoomId}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
{racks
|
||||
.filter((rack) => rack.roomId === selectedRoomId)
|
||||
.map((rack) => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="position" label="U位">
|
||||
<Input type="number" placeholder="请输入U位" min={1} disabled={!selectedRoomId} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: '12px', margin: '8px 0' }}>
|
||||
— 或 —
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="warehouseId" label="库房位置">
|
||||
<Input
|
||||
placeholder="手动输入库房位置"
|
||||
allowClear
|
||||
onChange={() => {
|
||||
if (form.getFieldValue('warehouseId')) {
|
||||
form.setFieldsValue({ roomId: null, rackId: null, position: null });
|
||||
setSelectedRoomId(null);
|
||||
}
|
||||
}}
|
||||
style={{ borderRadius: '8px' }}
|
||||
prefix={<InboxOutlined style={{ color: '#94a3b8' }} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
|
||||
附加信息
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="idleReason" label="空闲原因">
|
||||
<Input placeholder="请输入空闲原因,如:设备下线、备件库存等" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="description" label="备注">
|
||||
<TextArea rows={2} placeholder="请输入备注信息" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#22c55e' }} />
|
||||
设备上架
|
||||
</div>
|
||||
}
|
||||
open={isShelveModalVisible}
|
||||
onOk={handleShelveSubmit}
|
||||
onCancel={() => setIsShelveModalVisible(false)}
|
||||
okText="确认上架"
|
||||
cancelText="取消"
|
||||
width={680}
|
||||
destroyOnClose
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
<Form form={shelveForm} layout="vertical" size="middle">
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#3b82f6', borderRadius: '2px' }} />
|
||||
设备基本信息
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
|
||||
<Input placeholder="请输入设备名称" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="type" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
|
||||
<Select placeholder="请选择设备类型" style={{ borderRadius: '8px' }}>
|
||||
<Option value="server">服务器</Option>
|
||||
<Option value="switch">交换机</Option>
|
||||
<Option value="router">路由器</Option>
|
||||
<Option value="storage">存储设备</Option>
|
||||
<Option value="other">其他设备</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="model" label="设备型号">
|
||||
<Input placeholder="请输入设备型号" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="serialNumber" label="序列号" rules={[{ required: true, message: '请输入序列号' }]}>
|
||||
<Input placeholder="请输入序列号" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="height" label="高度(U)">
|
||||
<Input type="number" placeholder="请输入高度" min={1} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="powerConsumption" label="功率(W)">
|
||||
<Input type="number" placeholder="请输入功率" min={0} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#22c55e', borderRadius: '2px' }} />
|
||||
上架位置
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="roomId" label="机房">
|
||||
<Select
|
||||
placeholder="请选择机房"
|
||||
onChange={(value) => {
|
||||
setSelectedShelveRoomId(value);
|
||||
shelveForm.setFieldsValue({ rackId: null });
|
||||
}}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
{rooms.map((room) => (
|
||||
<Option key={room.roomId} value={room.roomId}>
|
||||
{room.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="rackId" label="机柜">
|
||||
<Select
|
||||
placeholder={selectedShelveRoomId ? "请选择机柜" : "请先选择机房"}
|
||||
disabled={!selectedShelveRoomId}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
{racks
|
||||
.filter((rack) => rack.roomId === selectedShelveRoomId)
|
||||
.map((rack) => (
|
||||
<Option key={rack.rackId} value={rack.rackId}>
|
||||
{rack.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="position" label="U位">
|
||||
<Input type="number" placeholder="请输入U位" min={1} style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fefefe 0%, #f8fafc 100%)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ width: '4px', height: '16px', background: '#64748b', borderRadius: '2px' }} />
|
||||
备注信息
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item name="description" label="备注">
|
||||
<TextArea rows={2} placeholder="请输入备注信息" style={{ borderRadius: '8px' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<style>{`
|
||||
.ant-table-thead > tr > th {
|
||||
background: #f8fafc !important;
|
||||
font-weight: 600 !important;
|
||||
color: #334155 !important;
|
||||
border-bottom: 2px solid #e2e8f0 !important;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
padding: 16px 12px !important;
|
||||
}
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #fafafa !important;
|
||||
}
|
||||
.ant-badge-count {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.ant-btn-primary:hover {
|
||||
opacity: 0.9 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdleDeviceManagement;
|
||||
@@ -183,14 +183,23 @@ const InventoryManagement = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (planId) => {
|
||||
try {
|
||||
await api.delete(`/inventory/plans/${planId}`);
|
||||
message.success('删除成功');
|
||||
fetchPlans();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个盘点计划吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/inventory/plans/${planId}`);
|
||||
message.success('删除成功');
|
||||
fetchPlans();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Space,
|
||||
Select,
|
||||
DatePicker,
|
||||
Input,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Descriptions,
|
||||
Typography,
|
||||
Drawer,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined,
|
||||
SearchOutlined,
|
||||
EyeOutlined,
|
||||
FilterOutlined,
|
||||
ClearOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import CloseButton from '../components/CloseButton';
|
||||
import dayjs from 'dayjs';
|
||||
import { selectStyles, filterInputStyles, inputPlaceholders } from '../styles/deviceManagementStyles';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
const { Text } = Typography;
|
||||
|
||||
const MODULE_OPTIONS = [
|
||||
{ value: 'device', label: '设备管理' },
|
||||
{ value: 'user', label: '用户管理' },
|
||||
{ value: 'role', label: '角色管理' },
|
||||
];
|
||||
|
||||
const OPERATION_TYPE_OPTIONS = [
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'update', label: '更新' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
{ value: 'batch_delete', label: '批量删除' },
|
||||
{ value: 'status_change', label: '状态变更' },
|
||||
{ value: 'move', label: '移动' },
|
||||
{ value: 'permission_change', label: '权限变更' },
|
||||
];
|
||||
|
||||
const RESULT_OPTIONS = [
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
];
|
||||
|
||||
function OperationLogs() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
|
||||
const [filters, setFilters] = useState({
|
||||
module: null,
|
||||
operationType: null,
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
result: null,
|
||||
});
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [currentLog, setCurrentLog] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, pageSize = 20, currentFilters = filters) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = { page, pageSize };
|
||||
|
||||
if (currentFilters.module) {
|
||||
params.module = currentFilters.module;
|
||||
}
|
||||
if (currentFilters.operationType) {
|
||||
params.operationType = currentFilters.operationType;
|
||||
}
|
||||
if (currentFilters.keyword) {
|
||||
params.keyword = currentFilters.keyword;
|
||||
}
|
||||
if (currentFilters.result) {
|
||||
params.result = currentFilters.result;
|
||||
}
|
||||
if (currentFilters.dateRange && currentFilters.dateRange.length === 2) {
|
||||
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const response = await api.get('/operation-logs', { params });
|
||||
if (response.success) {
|
||||
setLogs(response.data.logs);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取操作日志失败');
|
||||
console.error('获取操作日志失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(1, pagination.pageSize, filters);
|
||||
}, []);
|
||||
|
||||
const handleTableChange = (newPagination, tableFilters) => {
|
||||
fetchLogs(newPagination.current, newPagination.pageSize, filters);
|
||||
};
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
fetchLogs(1, pagination.pageSize, newFilters);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {
|
||||
module: null,
|
||||
operationType: null,
|
||||
keyword: '',
|
||||
dateRange: null,
|
||||
result: null,
|
||||
};
|
||||
setFilters(clearedFilters);
|
||||
fetchLogs(1, pagination.pageSize, clearedFilters);
|
||||
};
|
||||
|
||||
const handleViewDetail = async (record) => {
|
||||
setDetailLoading(true);
|
||||
setDetailVisible(true);
|
||||
try {
|
||||
const response = await api.get(`/operation-logs/${record.recordId}`);
|
||||
if (response.success) {
|
||||
setCurrentLog(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取日志详情失败');
|
||||
console.error('获取日志详情失败:', error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getModuleTag = (module) => {
|
||||
const colors = {
|
||||
device: 'blue',
|
||||
user: 'green',
|
||||
role: 'purple',
|
||||
consumable: 'orange',
|
||||
rack: 'cyan',
|
||||
room: 'magenta',
|
||||
ticket: 'red',
|
||||
backup: 'gold',
|
||||
};
|
||||
const names = {
|
||||
device: '设备',
|
||||
user: '用户',
|
||||
role: '角色',
|
||||
consumable: '耗材',
|
||||
rack: '机柜',
|
||||
room: '机房',
|
||||
ticket: '工单',
|
||||
backup: '备份',
|
||||
};
|
||||
return <Tag color={colors[module] || 'default'}>{names[module] || module}</Tag>;
|
||||
};
|
||||
|
||||
const getOperationTag = (type) => {
|
||||
const colors = {
|
||||
create: 'green',
|
||||
update: 'blue',
|
||||
delete: 'red',
|
||||
batch_delete: 'red',
|
||||
batch_update: 'orange',
|
||||
status_change: 'cyan',
|
||||
move: 'purple',
|
||||
permission_change: 'gold',
|
||||
import: 'lime',
|
||||
export: 'lime',
|
||||
};
|
||||
const names = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
batch_delete: '批量删除',
|
||||
batch_update: '批量更新',
|
||||
status_change: '状态变更',
|
||||
move: '移动',
|
||||
permission_change: '权限变更',
|
||||
import: '导入',
|
||||
export: '导出',
|
||||
};
|
||||
return <Tag color={colors[type] || 'default'}>{names[type] || type}</Tag>;
|
||||
};
|
||||
|
||||
const getResultTag = (result) => {
|
||||
return result === 'success'
|
||||
? <Tag color="success">成功</Tag>
|
||||
: <Tag color="error">失败</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
|
||||
render: date => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 100,
|
||||
render: module => getModuleTag(module),
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operationType',
|
||||
key: 'operationType',
|
||||
width: 120,
|
||||
render: type => getOperationTag(type),
|
||||
},
|
||||
{
|
||||
title: '操作描述',
|
||||
dataIndex: 'operationDescription',
|
||||
key: 'operationDescription',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作对象',
|
||||
dataIndex: 'targetName',
|
||||
key: 'targetName',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (text, record) => text || record.targetId,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operatorName',
|
||||
key: 'operatorName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
key: 'result',
|
||||
width: 80,
|
||||
render: result => getResultTag(result),
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
width: 140,
|
||||
render: ip => ip || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hasFilters = filters.module || filters.operationType || filters.keyword || filters.dateRange || filters.result;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>操作日志审计</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Text type="secondary">共 {pagination.total} 条记录</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择模块"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.module}
|
||||
onChange={value => handleFilterChange('module', value)}
|
||||
>
|
||||
{MODULE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择操作类型"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.operationType}
|
||||
onChange={value => handleFilterChange('operationType', value)}
|
||||
>
|
||||
{OPERATION_TYPE_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="200px">
|
||||
<Select
|
||||
placeholder="选择结果"
|
||||
allowClear
|
||||
style={{ width: '100%', ...selectStyles }}
|
||||
value={filters.result}
|
||||
onChange={value => handleFilterChange('result', value)}
|
||||
>
|
||||
{RESULT_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<Input
|
||||
placeholder={inputPlaceholders.keyword || '搜索操作描述/对象/操作人'}
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ ...filterInputStyles }}
|
||||
value={filters.keyword}
|
||||
onChange={e => handleFilterChange('keyword', e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="280px">
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
value={filters.dateRange}
|
||||
onChange={dates => handleFilterChange('dateRange', dates)}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => fetchLogs(1, pagination.pageSize, filters)}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={loading}
|
||||
rowKey="recordId"
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title="操作日志详情"
|
||||
placement="right"
|
||||
width={600}
|
||||
onClose={() => {
|
||||
setDetailVisible(false);
|
||||
setCurrentLog(null);
|
||||
}}
|
||||
open={detailVisible}
|
||||
extra={
|
||||
currentLog && (
|
||||
<Space>
|
||||
{getModuleTag(currentLog.module)}
|
||||
{getOperationTag(currentLog.operationType)}
|
||||
{getResultTag(currentLog.result)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : currentLog ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="日志ID">{currentLog.recordId}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作时间">
|
||||
{dayjs(currentLog.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="模块">{currentLog.module}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作类型">{currentLog.operationType}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作描述">{currentLog.operationDescription}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标ID">{currentLog.targetId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标名称">{currentLog.targetName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人ID">{currentLog.operatorId}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">{currentLog.operatorName}</Descriptions.Item>
|
||||
{currentLog.operatorRole && (
|
||||
<Descriptions.Item label="操作人角色">{currentLog.operatorRole}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="IP地址">{currentLog.ipAddress || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户代理">{currentLog.userAgent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
{currentLog.result === 'success' ? '成功' : '失败'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
|
||||
{currentLog && (currentLog.beforeState || currentLog.afterState) && (
|
||||
<>
|
||||
<h4 style={{ marginTop: 16 }}>状态变更</h4>
|
||||
{currentLog.beforeState && (
|
||||
<>
|
||||
<Text strong>变更前:</Text>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.beforeState, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{currentLog.afterState && (
|
||||
<>
|
||||
<Text strong>变更后:</Text>
|
||||
<pre style={{ background: '#f0f0f0', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.afterState, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentLog && currentLog.metadata && Object.keys(currentLog.metadata).length > 0 && (
|
||||
<>
|
||||
<h4 style={{ marginTop: 16 }}>扩展信息</h4>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', maxHeight: 200 }}>
|
||||
{JSON.stringify(currentLog.metadata, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OperationLogs;
|
||||
@@ -284,14 +284,23 @@ const PendingDeviceManagement = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (pendingId) => {
|
||||
try {
|
||||
await api.delete(`/inventory/pending-devices/${pendingId}`);
|
||||
message.success('删除成功');
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个待同步设备吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/inventory/pending-devices/${pendingId}`);
|
||||
message.success('删除成功');
|
||||
fetchPendingDevices();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
|
||||
@@ -298,17 +298,26 @@ function PortManagement() {
|
||||
};
|
||||
|
||||
const handleDelete = async portId => {
|
||||
try {
|
||||
await api.delete(`/device-ports/${portId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchPorts();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个端口吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/device-ports/${portId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
});
|
||||
fetchPorts();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error('删除失败:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const parsePortRange = portName => {
|
||||
|
||||
@@ -291,7 +291,7 @@ function RoomManagement() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('/api/rooms');
|
||||
setRooms(response.data);
|
||||
setRooms(response.data.rooms || []);
|
||||
} catch (error) {
|
||||
message.error('获取机房列表失败');
|
||||
console.error('获取机房列表失败:', error);
|
||||
|
||||
@@ -83,14 +83,23 @@ function TicketCategoryManagement() {
|
||||
};
|
||||
|
||||
const handleDelete = async categoryId => {
|
||||
try {
|
||||
await axios.delete(`/api/ticket-categories/${categoryId}`);
|
||||
message.success('分类删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error('分类删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个故障分类吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/ticket-categories/${categoryId}`);
|
||||
message.success('分类删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
message.error('分类删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -173,17 +173,26 @@ const UserManagement = () => {
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async userId => {
|
||||
try {
|
||||
const response = await userAPI.delete(userId);
|
||||
if (response.success) {
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个用户吗?此操作不可恢复!',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await userAPI.delete(userId);
|
||||
if (response.success) {
|
||||
message.success('删除成功');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user