From 73cbe4ac1b9ea76c39d87952abcb59d061d8d6ac Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 20 Mar 2026 17:15:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E3=80=81=E5=8D=B1=E9=99=A9=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=92=8C=E4=B8=9A=E5=8A=A1=E5=85=B3=E8=81=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增操作日志记录功能,记录关键操作 2. 实现危险操作确认对话框,防止误删 3. 添加业务和库房管理模块 4. 支持设备标记为空闲状态 5. 完善API文档和健康检查 6. 优化前端删除操作的确认流程 7. 添加Swagger API文档支持 8. 实现设备与业务的关联功能 9. 改进设备模型,添加空闲相关字段 10. 优化用户、角色管理操作日志 --- .nvmrc | 1 + README.md | 62 +- backend/addNewColumns.js | 44 + backend/initDeviceFields.js | 9 +- backend/jest.config.js | 15 + backend/models/Business.js | 40 + backend/models/Device.js | 25 + backend/models/DeviceBusiness.js | 57 + backend/models/OperationLog.js | 89 + backend/models/Warehouse.js | 41 + backend/package-lock.json | 2335 ++++++--------- backend/package.json | 8 +- backend/routes/dangerousOperations.js | 152 + backend/routes/devices.js | 244 +- backend/routes/idleDevices.js | 862 ++++++ backend/routes/operationLogs.js | 263 ++ backend/routes/roles.js | 74 + backend/routes/rooms.js | 16 +- backend/routes/users.js | 130 +- backend/routes/warehouses.js | 229 ++ backend/scripts/add-isSystem-mysql.js | 73 - backend/scripts/add-isSystem-sqlite.js | 49 - backend/scripts/migrate-all.js | 138 + backend/server.js | 60 +- backend/swagger.js | 575 ++++ backend/swagger_docs.yaml | 2596 +++++++++++++++++ backend/swagger_index.html | 143 + .../tests/integration.operationLogs.test.js | 545 ++++ backend/tests/operationLog.model.test.js | 277 ++ backend/tests/operationLogger.test.js | 477 +++ backend/tests/operationLogs.api.test.js | 348 +++ backend/tests/setup.js | 24 + backend/tests/setupEnv.js | 4 + backend/utils/dangerousOperationLogger.js | 204 ++ backend/utils/healthCheck.js | 158 + backend/utils/operationLogger.js | 123 + frontend/src/App.jsx | 20 +- .../src/components/DangerConfirmModal.jsx | 299 ++ .../src/config/dangerousOperationConfig.js | 118 + frontend/src/hooks/useDangerousOperation.js | 220 ++ frontend/src/pages/CableManagement.jsx | 31 +- frontend/src/pages/CategoryManagement.jsx | 25 +- frontend/src/pages/ConsumableManagement.jsx | 31 +- frontend/src/pages/DeviceManagement.jsx | 108 +- frontend/src/pages/IdleDeviceManagement.jsx | 942 ++++++ frontend/src/pages/InventoryManagement.jsx | 25 +- frontend/src/pages/OperationLogs.jsx | 482 +++ .../src/pages/PendingDeviceManagement.jsx | 25 +- frontend/src/pages/PortManagement.jsx | 31 +- frontend/src/pages/RoomManagement.jsx | 2 +- .../src/pages/TicketCategoryManagement.jsx | 25 +- frontend/src/pages/UserManagement.jsx | 31 +- 52 files changed, 11149 insertions(+), 1756 deletions(-) create mode 100644 .nvmrc create mode 100644 backend/addNewColumns.js create mode 100644 backend/jest.config.js create mode 100644 backend/models/Business.js create mode 100644 backend/models/DeviceBusiness.js create mode 100644 backend/models/OperationLog.js create mode 100644 backend/models/Warehouse.js create mode 100644 backend/routes/dangerousOperations.js create mode 100644 backend/routes/idleDevices.js create mode 100644 backend/routes/operationLogs.js create mode 100644 backend/routes/warehouses.js delete mode 100644 backend/scripts/add-isSystem-mysql.js delete mode 100644 backend/scripts/add-isSystem-sqlite.js create mode 100644 backend/swagger.js create mode 100644 backend/swagger_docs.yaml create mode 100644 backend/swagger_index.html create mode 100644 backend/tests/integration.operationLogs.test.js create mode 100644 backend/tests/operationLog.model.test.js create mode 100644 backend/tests/operationLogger.test.js create mode 100644 backend/tests/operationLogs.api.test.js create mode 100644 backend/tests/setup.js create mode 100644 backend/tests/setupEnv.js create mode 100644 backend/utils/dangerousOperationLogger.js create mode 100644 backend/utils/healthCheck.js create mode 100644 backend/utils/operationLogger.js create mode 100644 frontend/src/components/DangerConfirmModal.jsx create mode 100644 frontend/src/config/dangerousOperationConfig.js create mode 100644 frontend/src/hooks/useDangerousOperation.js create mode 100644 frontend/src/pages/IdleDeviceManagement.jsx create mode 100644 frontend/src/pages/OperationLogs.jsx diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..d5a1596 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.10.0 diff --git a/README.md b/README.md index cc75efc..9d372e3 100644 --- a/README.md +++ b/README.md @@ -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接口 +- 📊 自动同步最新接口信息 --- diff --git a/backend/addNewColumns.js b/backend/addNewColumns.js new file mode 100644 index 0000000..a7e52c7 --- /dev/null +++ b/backend/addNewColumns.js @@ -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(); diff --git a/backend/initDeviceFields.js b/backend/initDeviceFields.js index f325298..1a18640 100644 --- a/backend/initDeviceFields.js +++ b/backend/initDeviceFields.js @@ -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}`); + } } } diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..51752e1 --- /dev/null +++ b/backend/jest.config.js @@ -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'] +}; diff --git a/backend/models/Business.js b/backend/models/Business.js new file mode 100644 index 0000000..62a0087 --- /dev/null +++ b/backend/models/Business.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; diff --git a/backend/models/Device.js b/backend/models/Device.js index a7ccc0f..f3cdb23 100644 --- a/backend/models/Device.js +++ b/backend/models/Device.js @@ -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; diff --git a/backend/models/DeviceBusiness.js b/backend/models/DeviceBusiness.js new file mode 100644 index 0000000..02d2a27 --- /dev/null +++ b/backend/models/DeviceBusiness.js @@ -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; diff --git a/backend/models/OperationLog.js b/backend/models/OperationLog.js new file mode 100644 index 0000000..d67de66 --- /dev/null +++ b/backend/models/OperationLog.js @@ -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; diff --git a/backend/models/Warehouse.js b/backend/models/Warehouse.js new file mode 100644 index 0000000..b914975 --- /dev/null +++ b/backend/models/Warehouse.js @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 4e93918..6595b44 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,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", @@ -41,12 +43,74 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", - "jest": "^30.2.0", + "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.8.1", "supertest": "^7.1.4" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -243,9 +307,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -368,13 +432,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -410,13 +474,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -536,13 +600,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -660,40 +724,6 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1028,109 +1058,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1159,61 +1086,61 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0" + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1224,150 +1151,117 @@ } } }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "jest-get-type": "^29.6.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", + "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", "slash": "^3.0.0", - "string-length": "^4.0.2", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1378,183 +1272,109 @@ } } }, - "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", "slash": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" + "write-file-atomic": "^4.0.2" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1607,18 +1427,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" }, "node_modules/@noble/hashes": { "version": "1.8.0", @@ -1669,17 +1482,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -1693,10 +1495,17 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { - "version": "0.34.45", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz", - "integrity": "sha512-qJcFVfCa5jxBFSuv7S5WYbA8XdeCPmhnaVVfX/2Y6L8WYg8sk3XY2+6W0zH+3mq1Cz+YC7Ki66HfqX6IHAwnkg==", + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, @@ -1711,13 +1520,13 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@sinonjs/commons": "^3.0.0" } }, "node_modules/@so-ric/colorspace": { @@ -1746,17 +1555,6 @@ "node": ">= 6" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1818,6 +1616,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1849,7 +1657,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -1904,282 +1711,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2628,58 +2159,85 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" + "@babel/core": "^7.8.0" } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/babel__core": "^7.20.5" + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/babel-preset-current-node-syntax": { @@ -2710,20 +2268,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.0.0" } }, "node_modules/balanced-match": { @@ -2868,7 +2426,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3082,6 +2639,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3230,9 +2793,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ { @@ -3246,9 +2809,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, "license": "MIT" }, @@ -3401,6 +2964,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -3415,7 +2987,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -3529,6 +3100,28 @@ "node": ">=0.8" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3665,9 +3258,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3815,6 +3408,16 @@ "wrappy": "1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3860,13 +3463,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4564,7 +4160,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -4603,12 +4198,11 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -4623,21 +4217,20 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/express": { @@ -4645,6 +4238,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4942,36 +4536,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -5067,7 +4631,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -5744,7 +5307,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6340,15 +5902,15 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" }, "engines": { "node": ">=10" @@ -6411,39 +5973,23 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -6455,75 +6001,76 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.2.0", + "execa": "^5.0.0", + "jest-util": "^29.7.0", "p-limit": "^3.1.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.1.2", + "chalk": "^4.0.0", "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "stack-utils": "^2.0.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -6535,114 +6082,51 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "@types/node": "*", - "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "esbuild-register": { - "optional": true - }, "ts-node": { "optional": true } } }, - "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/jest-config/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/jest-config/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6657,159 +6141,169 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.1.0" + "detect-newline": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", "walker": "^1.0.8" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "optionalDependencies": { - "fsevents": "^2.3.3" + "fsevents": "^2.3.2" } }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "stack-utils": "^2.0.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -6831,254 +6325,204 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.1.2", + "chalk": "^4.0.0", "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-validate/node_modules/camelcase": { @@ -7095,40 +6539,39 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" + "jest-util": "^29.7.0", + "string-length": "^4.0.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "^29.7.0", "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" + "supports-color": "^8.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-worker/node_modules/has-flag": { @@ -7325,6 +6768,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -7387,6 +6840,13 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7399,6 +6859,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -7430,6 +6897,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -7687,7 +7160,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7927,22 +7399,6 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8348,6 +7804,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8455,13 +7918,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8532,7 +7988,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8561,40 +8016,6 @@ "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", "license": "ISC" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -8728,18 +8149,18 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -8775,6 +8196,20 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8841,9 +8276,9 @@ } }, "node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "dev": true, "funding": [ { @@ -9064,6 +8499,16 @@ "node": ">=8" } }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -9559,6 +9004,13 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9860,22 +9312,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -9987,20 +9423,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -10141,6 +9563,95 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz", + "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -10299,14 +9810,6 @@ "node": ">= 14.0.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -10519,41 +10022,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -10964,25 +10432,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10990,30 +10439,17 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" + "signal-exit": "^3.0.7" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/xlsx": { @@ -11053,6 +10489,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -11095,6 +10540,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/backend/package.json b/backend/package.json index 6b854dc..ef52abd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/dangerousOperations.js b/backend/routes/dangerousOperations.js new file mode 100644 index 0000000..92e7afa --- /dev/null +++ b/backend/routes/dangerousOperations.js @@ -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; diff --git a/backend/routes/devices.js b/backend/routes/devices.js index a7f7e76..d296817 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -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, diff --git a/backend/routes/idleDevices.js b/backend/routes/idleDevices.js new file mode 100644 index 0000000..60764e2 --- /dev/null +++ b/backend/routes/idleDevices.js @@ -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; diff --git a/backend/routes/operationLogs.js b/backend/routes/operationLogs.js new file mode 100644 index 0000000..2b77f78 --- /dev/null +++ b/backend/routes/operationLogs.js @@ -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; diff --git a/backend/routes/roles.js b/backend/routes/roles.js index 675c949..4d378d5 100644 --- a/backend/routes/roles.js +++ b/backend/routes/roles.js @@ -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: '删除成功' diff --git a/backend/routes/rooms.js b/backend/routes/rooms.js index 70a7de0..8368f92 100644 --- a/backend/routes/rooms.js +++ b/backend/routes/rooms.js @@ -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 }); } diff --git a/backend/routes/users.js b/backend/routes/users.js index 2b6cd34..027d996 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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: '删除成功' diff --git a/backend/routes/warehouses.js b/backend/routes/warehouses.js new file mode 100644 index 0000000..3def914 --- /dev/null +++ b/backend/routes/warehouses.js @@ -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; diff --git a/backend/scripts/add-isSystem-mysql.js b/backend/scripts/add-isSystem-mysql.js deleted file mode 100644 index 6902ccf..0000000 --- a/backend/scripts/add-isSystem-mysql.js +++ /dev/null @@ -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(); diff --git a/backend/scripts/add-isSystem-sqlite.js b/backend/scripts/add-isSystem-sqlite.js deleted file mode 100644 index 085395c..0000000 --- a/backend/scripts/add-isSystem-sqlite.js +++ /dev/null @@ -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(); diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 5c40ed0..c28f1ad 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -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); diff --git a/backend/server.js b/backend/server.js index ebb130b..0688a18 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, () => { diff --git a/backend/swagger.js b/backend/swagger.js new file mode 100644 index 0000000..b703747 --- /dev/null +++ b/backend/swagger.js @@ -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文件 diff --git a/backend/swagger_docs.yaml b/backend/swagger_docs.yaml new file mode 100644 index 0000000..d427552 --- /dev/null +++ b/backend/swagger_docs.yaml @@ -0,0 +1,2596 @@ +openapi: 3.0.0 +info: + title: IDC设备管理系统 API + version: 1.0.0 + description: | + IDC设备管理系统后端API文档 + + ## 认证说明 + 除健康检查接口外,所有接口都需要在请求头中携带JWT Token: + ``` + Authorization: Bearer + ``` + + ## 通用响应格式 + ### 成功响应 + ```json + { + "success": true, + "data": {...}, + "message": "操作成功" + } + ``` + + ### 错误响应 + ```json + { + "success": false, + "error": "错误类型", + "message": "详细描述" + } + ``` + + ## 分页参数 + 列表接口支持 `page` 和 `pageSize` 参数,默认 page=1, pageSize=10 + +servers: + - url: http://localhost:8000 + description: 开发环境服务器 + +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: 备份管理 + +paths: + /health: + get: + summary: 健康检查 + description: 服务状态检查接口,无需认证 + tags: [health] + responses: + '200': + description: 服务正常运行 + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + message: + type: string + example: IDC设备管理系统后端服务正常运行 + + /api-docs: + get: + summary: API文档页面 + description: 返回Swagger UI界面 + + /api/auth/login: + post: + summary: 用户登录 + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: + type: string + description: 用户名 + password: + type: string + description: 密码 + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + token: + type: string + user: + type: object + properties: + userId: + type: string + username: + type: string + email: + type: string + phone: + type: string + status: + type: string + roles: + type: array + items: + type: object + message: + type: string + + /api/auth/register: + post: + summary: 用户注册 + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: + type: string + description: 用户名(3-20字符) + password: + type: string + description: 密码(6-20字符) + email: + type: string + phone: + type: string + responses: + '201': + description: 注册成功 + + /api/auth/me: + get: + summary: 获取当前用户信息 + tags: [auth] + responses: + '200': + description: 成功 + + /api/rooms: + get: + summary: 获取机房列表 + tags: [rooms] + parameters: + - name: keyword + in: query + schema: + type: string + description: 按名称搜索 + - name: status + in: query + schema: + type: string + description: 按状态筛选 + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + roomId: + type: string + name: + type: string + location: + type: string + area: + type: number + status: + type: string + post: + summary: 创建机房 + tags: [rooms] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [roomId, name] + properties: + roomId: + type: string + description: 机房ID(唯一标识) + name: + type: string + description: 机房名称 + location: + type: string + description: 机房位置 + area: + type: number + description: 面积(平方米) + description: + type: string + description: 描述 + status: + type: string + enum: [active, inactive] + responses: + '201': + description: 创建成功 + + /api/rooms/{roomId}: + get: + summary: 获取单个机房 + tags: [rooms] + parameters: + - name: roomId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新机房 + tags: [rooms] + parameters: + - name: roomId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + location: + type: string + area: + type: number + description: + type: string + status: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除机房 + tags: [rooms] + parameters: + - name: roomId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/racks: + get: + summary: 获取机柜列表 + tags: [racks] + parameters: + - name: roomId + in: query + schema: + type: string + description: 按机房ID筛选 + - name: status + in: query + schema: + type: string + description: 按状态筛选 + - name: keyword + in: query + schema: + type: string + description: 按名称搜索 + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 10 + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + racks: + type: array + items: + type: object + total: + type: integer + page: + type: integer + pageSize: + type: integer + post: + summary: 创建机柜 + tags: [racks] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, roomId] + properties: + name: + type: string + description: 机柜名称 + height: + type: integer + description: 高度(U),默认42 + maxPower: + type: integer + description: 额定功率(W) + roomId: + type: string + description: 所属机房ID + status: + type: string + enum: [active, maintenance, inactive] + description: + type: string + responses: + '201': + description: 创建成功 + + /api/racks/{rackId}: + get: + summary: 获取单个机柜 + tags: [racks] + parameters: + - name: rackId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新机柜 + tags: [racks] + parameters: + - name: rackId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除机柜 + tags: [racks] + parameters: + - name: rackId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/racks/import-template: + get: + summary: 获取机柜导入模板 + tags: [racks] + responses: + '200': + description: 返回Excel模板文件 + content: + application/octet-stream: + schema: + type: string + format: binary + + /api/racks/export: + get: + summary: 导出机柜数据 + tags: [racks] + responses: + '200': + description: 返回Excel文件 + content: + application/octet-stream: + schema: + type: string + format: binary + + /api/racks/import: + post: + summary: 导入机柜数据 + tags: [racks] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: Excel格式的机柜数据文件 + responses: + '200': + description: 导入成功 + + /api/devices: + get: + summary: 获取设备列表 + tags: [devices] + parameters: + - name: rackId + in: query + schema: + type: string + - name: type + in: query + schema: + type: string + description: 设备类型 + - name: status + in: query + schema: + type: string + - name: keyword + in: query + schema: + type: string + description: 关键词搜索(名称/IP/序列号) + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 10 + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + devices: + type: array + items: + type: object + total: + type: integer + page: + type: integer + pageSize: + type: integer + post: + summary: 创建设备 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, type, serialNumber] + properties: + name: + type: string + description: 设备名称 + type: + type: string + enum: [server, switch, router, storage, other] + description: 设备类型 + model: + type: string + serialNumber: + type: string + rackId: + type: string + position: + type: integer + description: 机柜位置(从1开始) + height: + type: integer + description: 占用高度(U) + powerConsumption: + type: integer + description: 功耗(W) + ipAddress: + type: string + status: + type: string + enum: [running, maintenance, offline, fault] + purchaseDate: + type: string + format: date + warrantyExpiry: + type: string + format: date + description: + type: string + customFields: + type: object + description: 自定义字段值 + responses: + '201': + description: 创建成功 + + /api/devices/{deviceId}: + get: + summary: 获取单个设备 + tags: [devices] + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新设备 + tags: [devices] + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除设备 + tags: [devices] + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/devices/batch-delete: + post: + summary: 批量删除设备 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [deviceIds] + properties: + deviceIds: + type: array + items: + type: string + description: 设备ID列表 + responses: + '200': + description: 删除成功 + + /api/devices/delete-all: + delete: + summary: 删除所有设备 + tags: [devices] + responses: + '200': + description: 删除成功 + + /api/devices/batch-online: + put: + summary: 批量上线设备 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [deviceIds] + properties: + deviceIds: + type: array + items: + type: string + responses: + '200': + description: 操作成功 + + /api/devices/batch-offline: + put: + summary: 批量下线设备 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [deviceIds] + properties: + deviceIds: + type: array + items: + type: string + responses: + '200': + description: 操作成功 + + /api/devices/batch-status: + put: + summary: 批量变更设备状态 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [deviceIds, status] + properties: + deviceIds: + type: array + items: + type: string + status: + type: string + enum: [running, maintenance, offline, fault] + responses: + '200': + description: 操作成功 + + /api/devices/batch-move: + put: + summary: 批量移动设备 + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [deviceIds, targetRackId] + properties: + deviceIds: + type: array + items: + type: string + targetRackId: + type: string + description: 目标机柜ID + startPosition: + type: integer + description: 起始位置 + responses: + '200': + description: 操作成功 + + /api/devices/import-template: + get: + summary: 获取设备导入模板 + tags: [devices] + responses: + '200': + description: 返回CSV模板文件 + + /api/devices/export: + get: + summary: 导出设备数据 + tags: [devices] + parameters: + - name: deviceIds + in: query + schema: + type: string + description: 指定导出的设备ID(逗号分隔) + responses: + '200': + description: 返回CSV文件 + + /api/devices/import: + post: + summary: 导入设备数据 + tags: [devices] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + csvFile: + type: string + format: binary + description: CSV格式的设备数据文件 + responses: + '200': + description: 导入成功 + + /api/devices/enhanced-export: + get: + summary: 增强导出设备数据 + tags: [devices] + parameters: + - name: deviceIds + in: query + schema: + type: string + - name: format + in: query + schema: + type: string + enum: [csv, json] + default: csv + - name: fields + in: query + schema: + type: string + description: JSON格式的字段列表 + - name: fieldLabels + in: query + schema: + type: string + description: JSON格式的字段标签映射 + responses: + '200': + description: 导出成功 + + /api/devices/{deviceId}/tickets: + get: + summary: 获取设备的工单列表 + tags: [devices] + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 10 + responses: + '200': + description: 成功 + + /api/tickets: + get: + summary: 获取工单列表 + tags: [tickets] + parameters: + - name: status + in: query + schema: + type: string + description: 按状态筛选(pending/in_progress/completed/closed) + - name: priority + in: query + schema: + type: string + description: 按优先级筛选(urgent/high/medium/low) + - name: faultCategory + in: query + schema: + type: string + - name: deviceId + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 10 + responses: + '200': + description: 成功 + post: + summary: 创建工单 + tags: [tickets] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [faultCategory, description] + properties: + title: + type: string + faultCategory: + type: string + description: 故障分类 + faultSubCategory: + type: string + priority: + type: string + enum: [urgent, high, medium, low] + description: + type: string + deviceId: + type: string + deviceName: + type: string + serialNumber: + type: string + expectedCompletionDate: + type: string + format: date + reporterId: + type: string + reporterName: + type: string + responses: + '201': + description: 创建成功 + + /api/cables: + get: + summary: 获取线缆列表 + tags: [cables] + parameters: + - name: sourceDeviceId + in: query + schema: + type: string + description: 源设备ID + - name: targetDeviceId + in: query + schema: + type: string + description: 目标设备ID + - name: cableType + in: query + schema: + type: string + description: 线缆类型 + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建线缆 + tags: [cables] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sourceDeviceId: + type: string + description: 源设备ID + sourcePortId: + type: string + description: 源端口ID + targetDeviceId: + type: string + description: 目标设备ID + targetPortId: + type: string + description: 目标端口ID + cableType: + type: string + description: 线缆类型 + fiberType: + type: string + description: 光纤类型 + length: + type: number + description: 长度(米) + status: + type: string + description: + type: string + responses: + '201': + description: 创建成功 + + /api/cables/{cableId}: + get: + summary: 获取单个线缆 + tags: [cables] + parameters: + - name: cableId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新线缆 + tags: [cables] + parameters: + - name: cableId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除线缆 + tags: [cables] + parameters: + - name: cableId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/cables/batch: + post: + summary: 批量创建线缆 + tags: [cables] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cables: + type: array + items: + type: object + responses: + '201': + description: 创建成功 + + /api/cables/batch-delete: + post: + summary: 批量删除线缆 + tags: [cables] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cableIds: + type: array + items: + type: string + responses: + '200': + description: 删除成功 + + /api/cables/device/:deviceId: + get: + summary: 获取设备关联的线缆列表 + tags: [cables] + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + + /api/cables/rack/:rackId: + get: + summary: 获取机柜关联的线缆列表 + tags: [cables] + parameters: + - name: rackId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + + /api/cables/check-conflict: + post: + summary: 检查线缆路径冲突 + tags: [cables] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sourceDeviceId: + type: string + sourcePortId: + type: string + targetDeviceId: + type: string + targetPortId: + type: string + responses: + '200': + description: 检查成功 + + /api/tickets/{ticketId}: + get: + summary: 获取单个工单 + tags: [tickets] + parameters: + - name: ticketId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新工单 + tags: [tickets] + parameters: + - name: ticketId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除工单 + tags: [tickets] + parameters: + - name: ticketId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/consumables: + get: + summary: 获取耗材列表 + tags: [consumables] + parameters: + - name: category + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: keyword + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 10 + responses: + '200': + description: 成功 + post: + summary: 创建耗材 + tags: [consumables] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, category] + properties: + name: + type: string + category: + type: string + specification: + type: string + unit: + type: string + currentStock: + type: integer + minStock: + type: integer + maxStock: + type: integer + unitPrice: + type: number + supplier: + type: string + location: + type: string + status: + type: string + description: + type: string + responses: + '201': + description: 创建成功 + + /api/consumables/{id}: + get: + summary: 获取单个耗材 + tags: [consumables] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新耗材 + tags: [consumables] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除耗材 + tags: [consumables] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/deviceFields: + get: + summary: 获取设备字段列表 + tags: [deviceFields] + parameters: + - name: entityType + in: query + schema: + type: string + description: 实体类型(如device) + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建设备字段 + tags: [deviceFields] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + fieldName: + type: string + description: 字段名称 + fieldLabel: + type: string + description: 字段标签 + fieldType: + type: string + description: 字段类型 + entityType: + type: string + description: 实体类型 + required: + type: boolean + defaultValue: + type: string + options: + type: array + items: + type: string + responses: + '201': + description: 创建成功 + + /api/deviceFields/{fieldId}: + get: + summary: 获取单个设备字段 + tags: [deviceFields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新设备字段 + tags: [deviceFields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除设备字段 + tags: [deviceFields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/deviceFields/batch: + post: + summary: 批量创建设备字段 + tags: [deviceFields] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + fields: + type: array + items: + type: object + responses: + '201': + description: 创建成功 + + /api/deviceFields/reorder: + put: + summary: 重新排序设备字段 + tags: [deviceFields] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + fieldIds: + type: array + items: + type: string + responses: + '200': + description: 更新成功 + + /api/device-ports: + get: + summary: 获取设备端口列表 + tags: [device-ports] + parameters: + - name: deviceId + in: query + schema: + type: string + - name: portType + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建设备端口 + tags: [device-ports] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + deviceId: + type: string + portName: + type: string + portType: + type: string + portNumber: + type: integer + status: + type: string + responses: + '201': + description: 创建成功 + + /api/device-ports/{portId}: + get: + summary: 获取单个设备端口 + tags: [device-ports] + parameters: + - name: portId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新设备端口 + tags: [device-ports] + parameters: + - name: portId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除设备端口 + tags: [device-ports] + parameters: + - name: portId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/device-ports/batch: + post: + summary: 批量创建设备端口 + tags: [device-ports] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ports: + type: array + items: + type: object + responses: + '201': + description: 创建成功 + + /api/device-ports/{portId}/connect: + post: + summary: 连接端口 + tags: [device-ports] + parameters: + - name: portId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cableId: + type: string + targetPortId: + type: string + responses: + '200': + description: 连接成功 + + /api/device-ports/{portId}/disconnect: + post: + summary: 断开端口连接 + tags: [device-ports] + parameters: + - name: portId + in: path + required: true + schema: + type: string + responses: + '200': + description: 断开成功 + + /api/network-cards: + get: + summary: 获取网卡列表 + tags: [network-cards] + parameters: + - name: deviceId + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建网卡 + tags: [network-cards] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + deviceId: + type: string + cardName: + type: string + cardType: + type: string + portCount: + type: integer + speed: + type: string + responses: + '201': + description: 创建成功 + + /api/network-cards/{cardId}: + get: + summary: 获取单个网卡 + tags: [network-cards] + parameters: + - name: cardId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新网卡 + tags: [network-cards] + parameters: + - name: cardId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除网卡 + tags: [network-cards] + parameters: + - name: cardId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/network-cards/{cardId}/ports: + get: + summary: 获取网卡端口列表 + tags: [network-cards] + parameters: + - name: cardId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + + /api/ticket-categories: + get: + summary: 获取工单分类列表 + tags: [ticket-categories] + responses: + '200': + description: 成功 + post: + summary: 创建工单分类 + tags: [ticket-categories] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + categoryName: + type: string + description: + type: string + priority: + type: string + responses: + '201': + description: 创建成功 + + /api/ticket-categories/{categoryId}: + get: + summary: 获取单个工单分类 + tags: [ticket-categories] + parameters: + - name: categoryId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新工单分类 + tags: [ticket-categories] + parameters: + - name: categoryId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除工单分类 + tags: [ticket-categories] + parameters: + - name: categoryId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/ticket-fields: + get: + summary: 获取工单字段列表 + tags: [ticket-fields] + responses: + '200': + description: 成功 + post: + summary: 创建工单字段 + tags: [ticket-fields] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + fieldName: + type: string + fieldLabel: + type: string + fieldType: + type: string + required: + type: boolean + options: + type: array + items: + type: string + responses: + '201': + description: 创建成功 + + /api/ticket-fields/{fieldId}: + get: + summary: 获取单个工单字段 + tags: [ticket-fields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新工单字段 + tags: [ticket-fields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除工单字段 + tags: [ticket-fields] + parameters: + - name: fieldId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/consumables/low-stock: + get: + summary: 获取低库存耗材 + tags: [consumables] + responses: + '200': + description: 成功 + + /api/consumables/quick-inout: + post: + summary: 快速出入库 + tags: [consumables] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [consumableId, type, quantity] + properties: + consumableId: + type: string + type: + type: string + enum: [in, out] + quantity: + type: integer + operator: + type: string + reason: + type: string + notes: + type: string + snList: + type: array + items: + type: string + responses: + '200': + description: 操作成功 + + /api/consumables/inout: + post: + summary: 出入库操作 + tags: [consumables] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [consumableId, type, quantity] + properties: + consumableId: + type: string + type: + type: string + enum: [in, out] + quantity: + type: integer + operator: + type: string + reason: + type: string + recipient: + type: string + notes: + type: string + snList: + type: array + items: + type: string + responses: + '200': + description: 操作成功 + + /api/consumables/adjust: + post: + summary: 库存调整 + tags: [consumables] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [consumableId, adjustType, quantity] + properties: + consumableId: + type: string + adjustType: + type: string + enum: [add, subtract, set] + quantity: + type: integer + operator: + type: string + reason: + type: string + notes: + type: string + responses: + '200': + description: 操作成功 + + /api/consumables/logs: + get: + summary: 获取耗材操作日志 + tags: [consumables] + parameters: + - name: consumableId + in: query + schema: + type: string + - name: operationType + in: query + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: page + in: query + schema: + type: integer + default: 1 + - name: pageSize + in: query + schema: + type: integer + default: 20 + responses: + '200': + description: 成功 + + /api/inventory/plans: + get: + summary: 获取盘点计划列表 + tags: [inventory] + parameters: + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建盘点计划 + tags: [inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + planName: + type: string + description: + type: string + startDate: + type: string + format: date + endDate: + type: string + format: date + responses: + '201': + description: 创建成功 + + /api/inventory/plans/{planId}: + get: + summary: 获取单个盘点计划 + tags: [inventory] + parameters: + - name: planId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新盘点计划 + tags: [inventory] + parameters: + - name: planId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除盘点计划 + tags: [inventory] + parameters: + - name: planId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/inventory/tasks: + get: + summary: 获取盘点任务列表 + tags: [inventory] + parameters: + - name: planId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建盘点任务 + tags: [inventory] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + planId: + type: string + assignedTo: + type: string + deviceIds: + type: array + items: + type: string + responses: + '201': + description: 创建成功 + + /api/inventory/tasks/{taskId}: + get: + summary: 获取单个盘点任务 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新盘点任务 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除盘点任务 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/inventory/tasks/{taskId}/records: + get: + summary: 获取盘点记录列表 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + + /api/inventory/records: + get: + summary: 获取盘点记录列表 + tags: [inventory] + parameters: + - name: taskId + in: query + schema: + type: string + - name: planId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + + /api/inventory/start/{taskId}: + post: + summary: 开始盘点任务 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 操作成功 + + /api/inventory/complete/{taskId}: + post: + summary: 完成盘点任务 + tags: [inventory] + parameters: + - name: taskId + in: path + required: true + schema: + type: string + responses: + '200': + description: 操作成功 + + /api/operation-logs: + get: + summary: 获取操作日志列表 + tags: [operation-logs] + parameters: + - name: userId + in: query + schema: + type: string + - name: action + in: query + schema: + type: string + - name: module + in: query + schema: + type: string + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + + /api/operation-logs/export: + get: + summary: 导出操作日志 + tags: [operation-logs] + responses: + '200': + description: 导出成功 + + /api/backup: + get: + summary: 获取备份列表 + tags: [backup] + responses: + '200': + description: 成功 + post: + summary: 创建备份 + tags: [backup] + responses: + '201': + description: 创建成功 + + /api/backup/{backupId}/restore: + post: + summary: 恢复备份 + tags: [backup] + parameters: + - name: backupId + in: path + required: true + schema: + type: string + responses: + '200': + description: 恢复成功 + + /api/backup/{backupId}: + delete: + summary: 删除备份 + tags: [backup] + parameters: + - name: backupId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/backup/settings: + get: + summary: 获取备份设置 + tags: [backup] + responses: + '200': + description: 成功 + put: + summary: 更新备份设置 + tags: [backup] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + autoBackup: + type: boolean + backupPath: + type: string + retentionDays: + type: integer + responses: + '200': + description: 更新成功 + + /api/statistics: + get: + summary: 获取统计数据 + tags: [statistics] + responses: + '200': + description: 成功 + + /api/statistics/overview: + get: + summary: 获取总览统计 + tags: [statistics] + responses: + '200': + description: 成功 + + /api/statistics/trends: + get: + summary: 获取趋势数据 + tags: [statistics] + parameters: + - name: period + in: query + schema: + type: string + enum: [day, week, month, year] + responses: + '200': + description: 成功 + + /api/statistics/export: + get: + summary: 导出统计数据 + tags: [statistics] + responses: + '200': + description: 导出成功 + + /api/users: + get: + summary: 获取用户列表 + tags: [users] + parameters: + - name: keyword + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + post: + summary: 创建用户 + tags: [users] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + email: + type: string + phone: + type: string + roleId: + type: string + responses: + '201': + description: 创建成功 + + /api/users/{userId}: + get: + summary: 获取单个用户 + tags: [users] + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新用户 + tags: [users] + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除用户 + tags: [users] + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/roles: + get: + summary: 获取角色列表 + tags: [roles] + responses: + '200': + description: 成功 + post: + summary: 创建角色 + tags: [roles] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + roleName: + type: string + description: + type: string + permissions: + type: array + items: + type: string + responses: + '201': + description: 创建成功 + + /api/roles/{roleId}: + get: + summary: 获取单个角色 + tags: [roles] + parameters: + - name: roleId + in: path + required: true + schema: + type: string + responses: + '200': + description: 成功 + put: + summary: 更新角色 + tags: [roles] + parameters: + - name: roleId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除角色 + tags: [roles] + parameters: + - name: roleId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/system-settings: + get: + summary: 获取系统设置 + tags: [system-settings] + responses: + '200': + description: 成功 + put: + summary: 更新系统设置 + tags: [system-settings] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: 更新成功 + + /api/background: + get: + summary: 获取背景配置 + tags: [background] + responses: + '200': + description: 成功 + post: + summary: 上传背景图片 + tags: [background] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: 上传成功 + delete: + summary: 删除背景图片 + tags: [background] + responses: + '200': + description: 删除成功 + + /api/consumable-categories: + get: + summary: 获取耗材分类列表 + tags: [consumable-categories] + responses: + '200': + description: 成功 + post: + summary: 创建耗材分类 + tags: [consumable-categories] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + responses: + '201': + description: 创建成功 + + /api/consumable-categories/{categoryId}: + put: + summary: 更新耗材分类 + tags: [consumable-categories] + parameters: + - name: categoryId + in: path + required: true + schema: + type: string + responses: + '200': + description: 更新成功 + delete: + summary: 删除耗材分类 + tags: [consumable-categories] + parameters: + - name: categoryId + in: path + required: true + schema: + type: string + responses: + '200': + description: 删除成功 + + /api/consumable-records: + get: + summary: 获取耗材记录列表 + tags: [consumable-records] + parameters: + - name: consumableId + in: query + schema: + type: string + - name: operationType + in: query + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: pageSize + in: query + schema: + type: integer + responses: + '200': + description: 成功 + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: 输入 JWT token diff --git a/backend/swagger_index.html b/backend/swagger_index.html new file mode 100644 index 0000000..262bb12 --- /dev/null +++ b/backend/swagger_index.html @@ -0,0 +1,143 @@ + + + + + + IDC设备管理系统 API文档 + + + + +
+ + + + + diff --git a/backend/tests/integration.operationLogs.test.js b/backend/tests/integration.operationLogs.test.js new file mode 100644 index 0000000..7af04b0 --- /dev/null +++ b/backend/tests/integration.operationLogs.test.js @@ -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'); + }); + }); +}); diff --git a/backend/tests/operationLog.model.test.js b/backend/tests/operationLog.model.test.js new file mode 100644 index 0000000..c0d452c --- /dev/null +++ b/backend/tests/operationLog.model.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/operationLogger.test.js b/backend/tests/operationLogger.test.js new file mode 100644 index 0000000..8dc4c5f --- /dev/null +++ b/backend/tests/operationLogger.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/operationLogs.api.test.js b/backend/tests/operationLogs.api.test.js new file mode 100644 index 0000000..647ced8 --- /dev/null +++ b/backend/tests/operationLogs.api.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js new file mode 100644 index 0000000..591b672 --- /dev/null +++ b/backend/tests/setup.js @@ -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); diff --git a/backend/tests/setupEnv.js b/backend/tests/setupEnv.js new file mode 100644 index 0000000..a474748 --- /dev/null +++ b/backend/tests/setupEnv.js @@ -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:'; diff --git a/backend/utils/dangerousOperationLogger.js b/backend/utils/dangerousOperationLogger.js new file mode 100644 index 0000000..e38f316 --- /dev/null +++ b/backend/utils/dangerousOperationLogger.js @@ -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, +}; diff --git a/backend/utils/healthCheck.js b/backend/utils/healthCheck.js new file mode 100644 index 0000000..d92f5af --- /dev/null +++ b/backend/utils/healthCheck.js @@ -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 +}; diff --git a/backend/utils/operationLogger.js b/backend/utils/operationLogger.js new file mode 100644 index 0000000..a7420fe --- /dev/null +++ b/backend/utils/operationLogger.js @@ -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 +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c614cc6..5137d73 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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: , label: 设备管理, }, + { + key: 'idle-devices', + icon: , + label: 空闲设备, + }, { key: 'fields', icon: , @@ -358,6 +365,11 @@ const AppLayout = ({ children }) => { icon: , label: 数据备份, }, + { + key: 'operation-logs', + icon: , + label: 操作日志, + }, ], }, ]; @@ -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 = () => { diff --git a/frontend/src/components/DangerConfirmModal.jsx b/frontend/src/components/DangerConfirmModal.jsx new file mode 100644 index 0000000..5450ba3 --- /dev/null +++ b/frontend/src/components/DangerConfirmModal.jsx @@ -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 ( +
+ + ⚠️ 影响范围 + +
+ {items.map((item, index) => ( +
+ {item.label}: + {item.value} +
+ ))} +
+
+ ); + }; + + const renderContent = () => { + return ( +
+ + + + {config.title} + + + } + description={ + + {description || `确定要${operationLabel} ${itemCount > 1 ? `${itemCount} 个` : '该'}${entityLabel}吗?`} + + } + type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'} + style={{ + backgroundColor: config.bgColor, + borderColor: config.borderColor, + }} + /> + + + + {riskLevel !== RISK_LEVEL.LOW && ( +
+ + + 此操作不可逆,一旦删除将无法恢复 + +
+ )} + + {config.showImpactDetails && renderImpactDetails()} + + {isKeywordRequired && ( +
+ + + 为确认此{operationLabel}操作,请输入确认关键词: + + {config.keyword} + + + } + type="error" + style={{ marginBottom: 12 }} + showIcon + /> + setKeyword(e.target.value)} + placeholder={`请输入 ${config.keyword}`} + status={keyword && !isKeywordValid ? 'error' : undefined} + onPressEnter={handleOk} + style={{ fontSize: 16, textAlign: 'center', letterSpacing: 2 }} + /> + {keyword && !isKeywordValid && ( + + 关键词不正确,请重新输入 + + )} +
+ )} + + {itemCount > 1 && itemCount <= 5 && items.length > 0 && ( +
+ + 即将删除: + +
+ {items.slice(0, 5).map((item, index) => ( + + {typeof item === 'string' ? item : item.name || item.label || item} + + ))} + {itemCount > 5 && ( + ...还有 {itemCount - 5} 项 + )} +
+
+ )} +
+ ); + }; + + return ( + + + + {title || `${operationLabel}确认`} + + + } + 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} + > + + {renderContent()} + + + ); +}; + +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; diff --git a/frontend/src/config/dangerousOperationConfig.js b/frontend/src/config/dangerousOperationConfig.js new file mode 100644 index 0000000..688e23e --- /dev/null +++ b/frontend/src/config/dangerousOperationConfig.js @@ -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: '备份', +}; diff --git a/frontend/src/hooks/useDangerousOperation.js b/frontend/src/hooks/useDangerousOperation.js new file mode 100644 index 0000000..f5fc638 --- /dev/null +++ b/frontend/src/hooks/useDangerousOperation.js @@ -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 ( +
+ + {config.icon} {config.title} + + } + description={ + + {description || `您即将${operationLabel} ${itemCount > 1 ? `${itemCount} 个` : '1 个'}${entityLabel}。`} +
+ + 此操作不可逆,一旦删除将无法恢复。 + +
+ } + type={riskLevel === RISK_LEVEL.EXTREME ? 'error' : riskLevel === RISK_LEVEL.HIGH ? 'warning' : 'info'} + style={{ + backgroundColor: config.bgColor, + borderColor: config.borderColor, + marginBottom: 16, + }} + /> + + {config.showImpactDetails && impactText && ( + <> +
+ + 影响范围: + + {impactText} +
+ + + )} + + {riskLevel === RISK_LEVEL.EXTREME && config.requireKeyword && ( +
+ + 为确认此操作,请输入 CONFIRM: + + } + type="error" + style={{ marginBottom: 8 }} + /> + { + const inputValue = e.target.value; + const okButton = document.querySelector('.ant-modal-confirm .ant-btn-primary'); + if (okButton) { + okButton.disabled = inputValue !== config.keyword; + } + }} + /> +
+ )} +
+ ); + }; + + 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 ? ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ) : null; + }; + + Modal.confirm({ + title: ( + + {config.icon} {title || `${operationLabel}确认`} + + ), + icon: , + 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; diff --git a/frontend/src/pages/CableManagement.jsx b/frontend/src/pages/CableManagement.jsx index ca7082d..c363ac6 100644 --- a/frontend/src/pages/CableManagement.jsx +++ b/frontend/src/pages/CableManagement.jsx @@ -304,17 +304,26 @@ function CableManagement() { }; const handleDelete = async cableId => { - try { - await axios.delete(`/api/cables/${cableId}`); - message.success({ - content: '删除成功', - icon: , - }); - 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: , + }); + fetchCables(); + } catch (error) { + message.error('删除失败'); + console.error('删除失败:', error); + } + }, + }); }; const handleSubmit = async () => { diff --git a/frontend/src/pages/CategoryManagement.jsx b/frontend/src/pages/CategoryManagement.jsx index 30ace43..5f7dee3 100644 --- a/frontend/src/pages/CategoryManagement.jsx +++ b/frontend/src/pages/CategoryManagement.jsx @@ -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 = [ diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx index 3f2a426..6af3817 100644 --- a/frontend/src/pages/ConsumableManagement.jsx +++ b/frontend/src/pages/ConsumableManagement.jsx @@ -243,17 +243,26 @@ function ConsumableManagement() { const handleDelete = useCallback( async consumableId => { - try { - await axios.delete(`/api/consumables/${consumableId}`); - message.success({ - content: '删除成功', - icon: , - }); - 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: , + }); + fetchConsumables(); + } catch (error) { + message.error('删除失败'); + console.error('删除失败:', error); + } + }, + }); }, [fetchConsumables] ); diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index 8fc294a..631ebf6 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -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 ( - - {status.map((s) => ( - - {STATUS_MAP[s]?.text || s} - - ))} - - ); - } + 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 ( - - {STATUS_MAP[status]?.text || status} - + + {config.text} + ); }, }); @@ -830,6 +882,18 @@ function DeviceManagement() { > 状态变更 ({selectedDevices.length}) + + + + + + + + + + + + index % 2 === 0 ? 'table-row-even' : 'table-row-odd'} + style={{ borderRadius: '0 0 16px 16px' }} + /> + + {pagination.total > 0 && ( +
+ +
+ + 共 {pagination.total} 条记录 + + + + + setPagination((prev) => ({ ...prev, current: page, pageSize })) + } + showSizeChanger + showQuickJumper + showTotal={(total) => `共 ${total} 条`} + size="small" + /> + + + + )} + + + +
+ {editingDevice ? '编辑空闲设备' : '添加空闲设备'} +
+ } + open={isModalVisible} + onOk={handleSubmit} + onCancel={() => setIsModalVisible(false)} + okText="确定" + cancelText="取消" + width={680} + destroyOnClose + bodyStyle={{ padding: '24px' }} + style={{ top: 100 }} + > +
+
+
+
+ 设备基本信息 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {editingDevice && ( + + + + + + )} + + + +
+
+
+ 位置信息 +
+ +
+ + + + + + + + + + + + + + + +
+ — 或 — +
+ + + + { + if (form.getFieldValue('warehouseId')) { + form.setFieldsValue({ roomId: null, rackId: null, position: null }); + setSelectedRoomId(null); + } + }} + style={{ borderRadius: '8px' }} + prefix={} + /> + + + + + +
+
+
+ 附加信息 +
+ +
+ + + + + + + + +