feat(backup): 实现自动备份功能及错误边界处理
This commit is contained in:
+35
-52
@@ -1,125 +1,108 @@
|
||||
# IDC设备管理系统环境配置示例
|
||||
# 复制此文件为 .env 并根据需要修改配置
|
||||
# IDC设备管理系统 - 环境配置
|
||||
# 生成时间: 2026-02-26T05:07:19.876Z
|
||||
|
||||
# ==============================================
|
||||
# 服务器配置
|
||||
# ==============================================
|
||||
|
||||
# 服务器端口(默认8000)
|
||||
# 如需修改,请设置为其他端口号
|
||||
# 服务器端口
|
||||
PORT=8000
|
||||
|
||||
# 运行环境(development/production)
|
||||
# development: 开发模式,详细日志便于调试
|
||||
# production: 生产模式,性能优化,减少日志输出
|
||||
# 运行环境: development 或 production
|
||||
NODE_ENV=development
|
||||
|
||||
# ==============================================
|
||||
# 数据库配置
|
||||
# ==============================================
|
||||
|
||||
# 数据库类型:sqlite(默认)或 mysql
|
||||
# sqlite: 零配置嵌入式数据库,适合开发和小规模应用
|
||||
# mysql: 关系型数据库,适合生产环境和大规模应用
|
||||
# 数据库类型: sqlite 或 mysql
|
||||
DB_TYPE=sqlite
|
||||
|
||||
# SQLite 配置(当DB_TYPE=sqlite时使用)
|
||||
# SQLite数据库文件路径,文件会自动创建
|
||||
# SQLite 配置(当 DB_TYPE=sqlite 时使用)
|
||||
DB_PATH=./idc_management.db
|
||||
|
||||
# MySQL 配置(当DB_TYPE=mysql时使用)
|
||||
# 注意:使用MySQL前请确保MySQL服务已安装并创建相应的数据库
|
||||
# MySQL 配置(当 DB_TYPE=mysql 时使用)
|
||||
# 注意:使用 MySQL 前请确保 MySQL 服务已安装并创建相应的数据库
|
||||
# 数据库创建命令:CREATE DATABASE idc_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
MYSQL_HOST=localhost # MySQL服务器地址
|
||||
MYSQL_PORT=3306 # MySQL端口号
|
||||
MYSQL_USERNAME=root # MySQL用户名
|
||||
MYSQL_PASSWORD= # MySQL密码(为空则无密码)
|
||||
MYSQL_DATABASE=idc_management # MySQL数据库名
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_DATABASE=idc_management
|
||||
|
||||
# ==============================================
|
||||
# 安全配置(必填)
|
||||
# 安全配置
|
||||
# ==============================================
|
||||
|
||||
# JWT 密钥 - 用于签名和验证用户身份令牌
|
||||
# ⚠️ 安全警告:
|
||||
# - 生产环境必须修改默认值,使用强随机密钥
|
||||
# - 至少 32 位字符,推荐 64 位
|
||||
# - 定期更换(建议每3-6个月)
|
||||
# - 不要提交到代码仓库,仅保存在服务器环境变量
|
||||
#
|
||||
# 生成强密钥命令:
|
||||
# PowerShell: -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })
|
||||
# Linux/Mac: openssl rand -base64 64
|
||||
# Node.js: require('crypto').randomBytes(64).toString('hex')
|
||||
#
|
||||
JWT_SECRET=your-strong-secret-key-minimum-32-characters-change-in-production
|
||||
# 开发环境留空会自动生成并保存到 .env 文件
|
||||
# 生产环境必须设置强随机密钥(至少32位)
|
||||
# 生成命令(PowerShell):-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })
|
||||
# 生成命令(Node.js):require('crypto').randomBytes(64).toString('hex')
|
||||
JWT_SECRET=
|
||||
|
||||
# Token 过期时间(格式:数字+单位,如 24h, 2h, 30m)
|
||||
# 建议:开发环境 24h,生产环境 2h 或更短
|
||||
TOKEN_EXPIRY=24h
|
||||
|
||||
# 密码加密强度(bcrypt salt rounds)
|
||||
# 默认值:10,范围:4-12,值越大越安全但计算越慢
|
||||
SALT_ROUNDS=10
|
||||
# 密码加密强度(bcrypt salt rounds,范围:4-12)
|
||||
SALT_ROUNDS=12
|
||||
|
||||
# 登录失败锁定阈值
|
||||
# 连续登录失败超过此次数后锁定账户
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
MAX_LOGIN_ATTEMPTS=3
|
||||
|
||||
# 账户锁定时间(分钟)
|
||||
# 登录失败锁定后的解锁等待时间
|
||||
LOCK_TIME_MINUTES=30
|
||||
LOCK_TIME_MINUTES=15
|
||||
|
||||
# 密码最小长度
|
||||
PASSWORD_MIN_LENGTH=6
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
|
||||
# 用户名长度限制
|
||||
USERNAME_MIN_LENGTH=3
|
||||
USERNAME_MAX_LENGTH=50
|
||||
USERNAME_MIN_LENGTH=4
|
||||
USERNAME_MAX_LENGTH=30
|
||||
|
||||
# ==============================================
|
||||
# API 配置
|
||||
# ==============================================
|
||||
|
||||
# API 请求超时时间(毫秒)
|
||||
API_TIMEOUT=30000
|
||||
API_TIMEOUT=20000
|
||||
|
||||
# 数据库查询超时时间(毫秒)
|
||||
DB_QUERY_TIMEOUT=30000
|
||||
DB_QUERY_TIMEOUT=15000
|
||||
|
||||
# ==============================================
|
||||
# 分页配置
|
||||
# ==============================================
|
||||
|
||||
# 默认每页条数
|
||||
DEFAULT_PAGE_SIZE=10
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
|
||||
# 最大每页条数
|
||||
MAX_PAGE_SIZE=1000
|
||||
MAX_PAGE_SIZE=500
|
||||
|
||||
# ==============================================
|
||||
# 文件上传配置
|
||||
# ==============================================
|
||||
|
||||
# 最大文件上传大小(MB)
|
||||
MAX_FILE_SIZE_MB=50
|
||||
MAX_FILE_SIZE_MB=30
|
||||
|
||||
# 最大头像上传大小(MB)
|
||||
MAX_AVATAR_SIZE_MB=5
|
||||
MAX_AVATAR_SIZE_MB=2
|
||||
|
||||
# ==============================================
|
||||
# 重试配置
|
||||
# ==============================================
|
||||
|
||||
# 最大重试次数
|
||||
MAX_RETRIES=3
|
||||
MAX_RETRIES=5
|
||||
|
||||
# 重试延迟(毫秒)
|
||||
RETRY_DELAY=1000
|
||||
RETRY_DELAY=2000
|
||||
|
||||
# ==============================================
|
||||
# 前端配置
|
||||
# ==============================================
|
||||
|
||||
# 前端默认端口
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"cronExpression": "0 20 * * *",
|
||||
"description": "自动备份",
|
||||
"backupType": "full",
|
||||
"includeFiles": true,
|
||||
"compress": true,
|
||||
"maxCount": 30,
|
||||
"maxAgeDays": 90
|
||||
}
|
||||
@@ -11,7 +11,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
FILE_UPLOAD: {
|
||||
MAX_FILE_SIZE: (parseInt(process.env.MAX_FILE_SIZE_MB, 10) || 50) * 1024 * 1024,
|
||||
MAX_FILE_SIZE: (parseInt(process.env.MAX_FILE_SIZE_MB, 10) || 500) * 1024 * 1024,
|
||||
MAX_AVATAR_SIZE: (parseInt(process.env.MAX_AVATAR_SIZE_MB, 10) || 5) * 1024 * 1024,
|
||||
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
ALLOWED_DOC_TYPES: [
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ENV_FILE_PATH = path.join(__dirname, '.env');
|
||||
|
||||
function generateSecret(length = 64) {
|
||||
return crypto.randomBytes(length).toString('base64');
|
||||
}
|
||||
|
||||
function parseEnvContent(content) {
|
||||
const lines = content.split('\n');
|
||||
const result = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const equalIndex = trimmed.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
const value = trimmed.substring(equalIndex + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringifyEnvContent(envObj, originalContent) {
|
||||
const lines = originalContent.split('\n');
|
||||
const updatedLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
updatedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const equalIndex = trimmed.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
const key = trimmed.substring(0, equalIndex).trim();
|
||||
|
||||
if (key === 'JWT_SECRET' && envObj.hasOwnProperty('JWT_SECRET')) {
|
||||
updatedLines.push(`JWT_SECRET=${envObj.JWT_SECRET}`);
|
||||
} else {
|
||||
updatedLines.push(line);
|
||||
}
|
||||
} else {
|
||||
updatedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedLines.join('\n');
|
||||
}
|
||||
|
||||
function ensureJwtSecret() {
|
||||
const currentSecret = process.env.JWT_SECRET;
|
||||
|
||||
if (currentSecret && currentSecret.length >= 32) {
|
||||
console.log('✓ JWT_SECRET 已配置');
|
||||
return currentSecret;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'[致命错误] 生产环境未设置 JWT_SECRET 环境变量!\n' +
|
||||
'请在服务器环境变量中设置强密钥(至少32位随机字符)。\n' +
|
||||
'生成命令(PowerShell):-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })'
|
||||
);
|
||||
}
|
||||
|
||||
const newSecret = generateSecret(64);
|
||||
console.log('⚠️ JWT_SECRET 未配置或长度不足,正在自动生成...');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(ENV_FILE_PATH)) {
|
||||
const originalContent = fs.readFileSync(ENV_FILE_PATH, 'utf-8');
|
||||
const updatedContent = stringifyEnvContent({ JWT_SECRET: newSecret }, originalContent);
|
||||
fs.writeFileSync(ENV_FILE_PATH, updatedContent, 'utf-8');
|
||||
console.log('✓ JWT_SECRET 已自动生成并保存到 .env 文件');
|
||||
} else {
|
||||
const defaultContent = `# IDC设备管理系统 - 环境配置
|
||||
# 自动生成时间: ${new Date().toISOString()}
|
||||
|
||||
JWT_SECRET=${newSecret}
|
||||
`;
|
||||
fs.writeFileSync(ENV_FILE_PATH, defaultContent, 'utf-8');
|
||||
console.log('✓ JWT_SECRET 已自动生成并创建 .env 文件');
|
||||
}
|
||||
|
||||
process.env.JWT_SECRET = newSecret;
|
||||
return newSecret;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 无法保存 JWT_SECRET 到 .env 文件,使用临时密钥(重启后失效)');
|
||||
console.warn(' 错误信息:', err.message);
|
||||
process.env.JWT_SECRET = newSecret;
|
||||
return newSecret;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureJwtSecret,
|
||||
generateSecret,
|
||||
};
|
||||
@@ -1,16 +1,9 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const User = require('../models/User');
|
||||
|
||||
/**
|
||||
* 获取 JWT Secret
|
||||
* - 生产环境:强制从环境变量读取,未设置则抛出错误
|
||||
* - 开发环境:未设置时自动生成临时密钥(重启后失效)
|
||||
*/
|
||||
function getJwtSecret() {
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
|
||||
// 生产环境强制校验
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (!envSecret) {
|
||||
throw new Error(
|
||||
@@ -25,11 +18,10 @@ function getJwtSecret() {
|
||||
return envSecret;
|
||||
}
|
||||
|
||||
// 开发环境:使用环境变量或临时密钥
|
||||
if (!envSecret) {
|
||||
const tempSecret = crypto.randomBytes(32).toString('hex');
|
||||
console.warn('⚠️ [开发模式] JWT_SECRET 未设置,使用临时密钥(重启后所有 Token 失效)');
|
||||
return tempSecret;
|
||||
throw new Error(
|
||||
'[错误] JWT_SECRET 未配置,请检查 initConfig.js 是否正确执行'
|
||||
);
|
||||
}
|
||||
|
||||
return envSecret;
|
||||
|
||||
Generated
+57
-1
@@ -20,7 +20,9 @@
|
||||
"iconv-lite": "^0.6.3",
|
||||
"joi": "^18.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.16.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.32.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"three": "^0.182.0",
|
||||
@@ -2355,6 +2357,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
@@ -2913,7 +2921,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
@@ -3341,6 +3348,21 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
@@ -7612,6 +7634,25 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.16.0",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz",
|
||||
@@ -7716,6 +7757,15 @@
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
|
||||
@@ -10036,6 +10086,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"iconv-lite": "^0.6.3",
|
||||
"joi": "^18.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.16.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.32.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"three": "^0.182.0",
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const {
|
||||
getBackupPath,
|
||||
ensureBackupDir,
|
||||
createBackup,
|
||||
validateBackupFile,
|
||||
restoreBackup,
|
||||
cleanOldBackups,
|
||||
} = require('../utils/backup');
|
||||
const {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
validateCronExpression,
|
||||
timeToCron,
|
||||
startAutoBackup,
|
||||
stopAutoBackup,
|
||||
getAutoBackupStatus,
|
||||
updateAutoBackupSettings,
|
||||
executeBackupNow,
|
||||
} = require('../utils/autoBackupScheduler');
|
||||
|
||||
const tempDir = path.join(__dirname, '..', 'temp');
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { description = '', includeFiles = true } = req.body;
|
||||
|
||||
console.log('开始创建备份...');
|
||||
const result = await createBackup({
|
||||
description,
|
||||
includeFiles: includeFiles !== false,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '备份创建成功',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建备份失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '创建备份失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const backupPath = getBackupPath();
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { backups: [], total: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(backupPath)
|
||||
.filter(f => (f.startsWith('backup_') || f.startsWith('uploaded_')) && (f.endsWith('.json') || f.endsWith('.json.gz')))
|
||||
.map(async f => {
|
||||
const filePath = path.join(backupPath, f);
|
||||
const stats = fs.statSync(filePath);
|
||||
const isCompressed = f.endsWith('.gz');
|
||||
|
||||
// 尝试从文件内容中提取元数据
|
||||
let metadata = {
|
||||
filename: f,
|
||||
size: stats.size,
|
||||
compressed: isCompressed,
|
||||
createdAt: stats.birthtime,
|
||||
modifiedAt: stats.mtime,
|
||||
};
|
||||
|
||||
try {
|
||||
// 读取文件头部的元数据信息
|
||||
let content;
|
||||
if (isCompressed) {
|
||||
const compressed = fs.readFileSync(filePath);
|
||||
content = zlib.gunzipSync(compressed).toString('utf8');
|
||||
} else {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
const backupData = JSON.parse(content);
|
||||
|
||||
// 提取关键元数据
|
||||
metadata.description = backupData.description || '';
|
||||
metadata.backupType = backupData.backupType || 'full';
|
||||
metadata.version = backupData.version || '1.0.0';
|
||||
metadata.timestamp = backupData.timestamp;
|
||||
metadata.checksum = backupData.checksum;
|
||||
metadata.metadata = backupData.metadata;
|
||||
metadata.systemInfo = backupData.systemInfo;
|
||||
|
||||
// 判断是否为上传的文件(通过文件名判断)
|
||||
metadata.isUploaded = f.startsWith('uploaded_');
|
||||
|
||||
} catch (error) {
|
||||
// 如果读取失败,标记为无效文件
|
||||
metadata.invalid = true;
|
||||
metadata.error = '无法读取文件内容';
|
||||
}
|
||||
|
||||
return metadata;
|
||||
});
|
||||
|
||||
const resolvedFiles = await Promise.all(files);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { backups: resolvedFiles, total: resolvedFiles.length },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取备份列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取备份列表失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/validate/:filename', async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const backupPath = getBackupPath();
|
||||
const filePath = path.join(backupPath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份文件不存在',
|
||||
});
|
||||
}
|
||||
|
||||
const validation = await validateBackupFile(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: validation,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('验证备份文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '验证备份文件失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/restore', async (req, res) => {
|
||||
try {
|
||||
const { filename, options = {} } = req.body;
|
||||
|
||||
if (!filename) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请提供备份文件名',
|
||||
});
|
||||
}
|
||||
|
||||
const backupPath = getBackupPath();
|
||||
const filePath = path.join(backupPath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份文件不存在',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`开始恢复备份: ${filename}`);
|
||||
|
||||
const result = await restoreBackup(filePath, {
|
||||
overwriteExisting: options.overwriteExisting !== false,
|
||||
skipTables: options.skipTables || [],
|
||||
skipFiles: options.skipFiles === true,
|
||||
onProgress: (tableName, status, count) => {
|
||||
console.log(` ${tableName}: ${status}${count ? ` (${count})` : ''}`);
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '数据恢复成功',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('恢复备份失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '恢复备份失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload', async (req, res) => {
|
||||
try {
|
||||
if (!req.files || !req.files.backup) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请上传备份文件',
|
||||
});
|
||||
}
|
||||
|
||||
const backupFile = req.files.backup;
|
||||
const originalName = backupFile.name || '';
|
||||
const nameLower = originalName.toLowerCase();
|
||||
|
||||
// 验证文件类型
|
||||
if (!nameLower.endsWith('.json') && !nameLower.endsWith('.gz')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '只支持 JSON 或 GZ 格式的备份文件',
|
||||
});
|
||||
}
|
||||
|
||||
const isCompressed = nameLower.endsWith('.gz');
|
||||
|
||||
console.log(`上传备份文件: ${originalName}, 压缩: ${isCompressed}, 大小: ${backupFile.size}`);
|
||||
|
||||
// 保存到临时文件
|
||||
const tempFilename = `upload_${Date.now()}`;
|
||||
const tempPath = path.join(tempDir, tempFilename);
|
||||
|
||||
await backupFile.mv(tempPath);
|
||||
|
||||
const validation = await validateBackupFile(tempPath, { isCompressed });
|
||||
|
||||
if (!validation.valid) {
|
||||
fs.unlinkSync(tempPath);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `备份文件验证失败: ${validation.error}`,
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
const ext = isCompressed ? '.json.gz' : '.json';
|
||||
const newFilename = `uploaded_${timestamp}${ext}`;
|
||||
const backupPath = getBackupPath();
|
||||
ensureBackupDir(backupPath);
|
||||
const targetPath = path.join(backupPath, newFilename);
|
||||
|
||||
fs.renameSync(tempPath, targetPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '备份文件上传成功',
|
||||
data: {
|
||||
filename: newFilename,
|
||||
validation,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传备份文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '上传备份文件失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/download/:filename', (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const backupPath = getBackupPath();
|
||||
const filePath = path.join(backupPath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份文件不存在',
|
||||
});
|
||||
}
|
||||
|
||||
res.download(filePath, filename, (err) => {
|
||||
if (err) {
|
||||
console.error('下载备份文件失败:', err);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('下载备份文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '下载备份文件失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:filename', (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const backupPath = getBackupPath();
|
||||
const filePath = path.join(backupPath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份文件不存在',
|
||||
});
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '备份文件已删除',
|
||||
data: { filename },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除备份文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '删除备份文件失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/info', (req, res) => {
|
||||
try {
|
||||
const backupPath = getBackupPath();
|
||||
let totalSize = 0;
|
||||
let backupCount = 0;
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
const files = fs.readdirSync(backupPath).filter(f => f.endsWith('.json') || f.endsWith('.json.gz'));
|
||||
backupCount = files.length;
|
||||
files.forEach(f => {
|
||||
const stats = fs.statSync(path.join(backupPath, f));
|
||||
totalSize += stats.size;
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
backupPath,
|
||||
backupCount,
|
||||
totalSize,
|
||||
totalSizeFormatted: formatBytes(totalSize),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取备份信息失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取备份信息失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
router.post('/clean', (req, res) => {
|
||||
try {
|
||||
const { maxCount = 30, maxAgeDays = 90, dryRun = false } = req.body;
|
||||
|
||||
const result = cleanOldBackups({ maxCount, maxAgeDays, dryRun });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: dryRun ? '预览完成' : '清理完成',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('清理备份失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '清理备份失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.use((error, req, res, next) => {
|
||||
console.error('路由错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误',
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 自动备份接口 ====================
|
||||
|
||||
// 获取自动备份状态
|
||||
router.get('/auto/status', (req, res) => {
|
||||
try {
|
||||
const status = getAutoBackupStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data: status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取自动备份状态失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取自动备份状态失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 更新自动备份设置
|
||||
router.post('/auto/settings', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
enabled,
|
||||
hour,
|
||||
minute,
|
||||
cronExpression,
|
||||
description,
|
||||
includeFiles,
|
||||
compress,
|
||||
maxCount,
|
||||
maxAgeDays,
|
||||
} = req.body;
|
||||
|
||||
const newSettings = {};
|
||||
if (enabled !== undefined) newSettings.enabled = enabled;
|
||||
if (hour !== undefined || minute !== undefined) {
|
||||
newSettings.hour = hour || 2;
|
||||
newSettings.minute = minute || 0;
|
||||
}
|
||||
if (cronExpression) {
|
||||
if (!validateCronExpression(cronExpression)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的 Cron 表达式',
|
||||
});
|
||||
}
|
||||
newSettings.cronExpression = cronExpression;
|
||||
}
|
||||
if (description) newSettings.description = description;
|
||||
if (includeFiles !== undefined) newSettings.includeFiles = includeFiles;
|
||||
if (compress !== undefined) newSettings.compress = compress;
|
||||
if (maxCount !== undefined) newSettings.maxCount = maxCount;
|
||||
if (maxAgeDays !== undefined) newSettings.maxAgeDays = maxAgeDays;
|
||||
|
||||
const success = updateAutoBackupSettings(newSettings);
|
||||
if (success) {
|
||||
const status = getAutoBackupStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
message: '自动备份设置已更新',
|
||||
data: status,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '保存设置失败',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新自动备份设置失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '更新自动备份设置失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 立即执行备份
|
||||
router.post('/auto/execute', async (req, res) => {
|
||||
try {
|
||||
const { description, includeFiles, compress } = req.body;
|
||||
|
||||
const result = await executeBackupNow({
|
||||
description: description || '手动触发备份',
|
||||
includeFiles,
|
||||
compress,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: '备份执行成功',
|
||||
data: result.result,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('立即执行备份失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '立即执行备份失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 测试 Cron 表达式
|
||||
router.post('/auto/test-cron', (req, res) => {
|
||||
try {
|
||||
const { cronExpression } = req.body;
|
||||
|
||||
if (!cronExpression) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请提供 Cron 表达式',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = validateCronExpression(cronExpression);
|
||||
|
||||
res.json({
|
||||
success: isValid,
|
||||
message: isValid ? 'Cron 表达式有效' : 'Cron 表达式无效',
|
||||
data: {
|
||||
valid: isValid,
|
||||
cronExpression,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('测试 Cron 表达式失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '测试 Cron 表达式失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -41,9 +41,17 @@ router.get('/', async (req, res) => {
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
const consumables = rows.map(item => {
|
||||
const data = item.toJSON();
|
||||
if (!Array.isArray(data.snList)) {
|
||||
data.snList = [];
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
consumables: rows,
|
||||
consumables,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
});
|
||||
@@ -182,12 +190,16 @@ router.get('/by-sn/:sn', async (req, res) => {
|
||||
const sn = req.params.sn;
|
||||
const consumables = await Consumable.findAll();
|
||||
const consumable = consumables.find(c => {
|
||||
const snList = c.snList || [];
|
||||
const snList = Array.isArray(c.snList) ? c.snList : [];
|
||||
return snList.includes(sn);
|
||||
});
|
||||
const result = consumable ? consumable.toJSON() : null;
|
||||
if (result && !Array.isArray(result.snList)) {
|
||||
result.snList = [];
|
||||
}
|
||||
res.json({
|
||||
found: !!consumable,
|
||||
consumable
|
||||
consumable: result
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -831,7 +843,11 @@ router.get('/:id', async (req, res) => {
|
||||
if (!consumable) {
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
res.json(consumable);
|
||||
const data = consumable.toJSON();
|
||||
if (!Array.isArray(data.snList)) {
|
||||
data.snList = [];
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
+67
-49
@@ -15,6 +15,7 @@ const Ticket = require('../models/Ticket');
|
||||
const DevicePort = require('../models/DevicePort');
|
||||
const Cable = require('../models/Cable');
|
||||
const NetworkCard = require('../models/NetworkCard');
|
||||
const InventoryRecord = require('../models/InventoryRecord');
|
||||
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||
const {
|
||||
createDeviceSchema,
|
||||
@@ -1043,14 +1044,14 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
});
|
||||
|
||||
// 批量删除设备
|
||||
router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||
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列表' });
|
||||
return res.status(400).json({ error: '请提供有效的设备 ID 列表' });
|
||||
}
|
||||
|
||||
const devices = await Device.findAll({
|
||||
@@ -1058,7 +1059,19 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 1. 删除相关接线 (Delete associated Cables)
|
||||
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
|
||||
await DevicePort.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡
|
||||
await NetworkCard.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关接线
|
||||
await Cable.destroy({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
@@ -1068,18 +1081,6 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r
|
||||
},
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡 (Delete associated NetworkCards)
|
||||
await NetworkCard.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关端口 (Delete associated DevicePorts)
|
||||
await DevicePort.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 4. 解除工单关联
|
||||
await Ticket.update(
|
||||
@@ -1087,13 +1088,13 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r
|
||||
{ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }
|
||||
);
|
||||
|
||||
// 5. 删除设备
|
||||
const deletedCount = await Device.destroy({
|
||||
// 5. 删除盘点记录
|
||||
await InventoryRecord.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 更新机柜功率
|
||||
// 6. 更新机柜功率
|
||||
for (const device of devices) {
|
||||
if (device.rackId) {
|
||||
const rack = await Rack.findByPk(device.rackId, { transaction: t });
|
||||
@@ -1104,6 +1105,12 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 删除设备
|
||||
const deletedCount = await Device.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
await t.commit();
|
||||
|
||||
@@ -1132,7 +1139,19 @@ router.delete('/delete-all', async (req, res) => {
|
||||
|
||||
const deviceIds = allDevices.map(d => d.deviceId);
|
||||
|
||||
// 1. 删除相关接线
|
||||
// 1. 删除相关端口
|
||||
await DevicePort.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡
|
||||
await NetworkCard.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关接线
|
||||
await Cable.destroy({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
@@ -1143,25 +1162,19 @@ router.delete('/delete-all', async (req, res) => {
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡
|
||||
await NetworkCard.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关端口
|
||||
await DevicePort.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 4. 解除工单关联
|
||||
await Ticket.update(
|
||||
{ deviceId: null },
|
||||
{ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }
|
||||
);
|
||||
|
||||
// 5. 更新机柜功率
|
||||
// 5. 删除盘点记录
|
||||
await InventoryRecord.destroy({
|
||||
where: { deviceId: { [Op.in]: deviceIds } },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 6. 更新机柜功率
|
||||
for (const device of allDevices) {
|
||||
if (device.rackId) {
|
||||
const rack = await Rack.findByPk(device.rackId, { transaction: t });
|
||||
@@ -1173,7 +1186,7 @@ router.delete('/delete-all', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 删除所有设备
|
||||
// 7. 删除所有设备
|
||||
const deletedCount = await Device.destroy({
|
||||
where: {},
|
||||
transaction: t
|
||||
@@ -1205,7 +1218,19 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
return res.status(404).json({ error: '设备不存在' });
|
||||
}
|
||||
|
||||
// 1. 删除相关接线 (Delete associated Cables)
|
||||
// 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡)
|
||||
const deletedPorts = await DevicePort.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡
|
||||
const deletedNetworkCards = await NetworkCard.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关接线
|
||||
// 必须在删除设备之前删除,否则可能触发外键约束错误
|
||||
const deletedCables = await Cable.destroy({
|
||||
where: {
|
||||
@@ -1216,19 +1241,6 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
},
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 2. 删除相关网卡 (Delete associated NetworkCards)
|
||||
const deletedNetworkCards = await NetworkCard.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 3. 删除相关端口 (Delete associated DevicePorts)
|
||||
// 必须在删除设备之前删除,否则触发外键约束错误
|
||||
const deletedPorts = await DevicePort.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 4. 解除工单关联 (Unlink Tickets)
|
||||
await Ticket.update(
|
||||
@@ -1236,7 +1248,13 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
{ where: { deviceId: deviceId }, transaction: t }
|
||||
);
|
||||
|
||||
// 5. 更新机柜功率 (必须在删除设备之前)
|
||||
// 5. 删除盘点记录
|
||||
await InventoryRecord.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
});
|
||||
|
||||
// 6. 更新机柜功率 (必须在删除设备之前)
|
||||
if (device.rackId) {
|
||||
try {
|
||||
const rack = await Rack.findByPk(device.rackId, { transaction: t });
|
||||
@@ -1251,7 +1269,7 @@ router.delete('/:deviceId', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 删除设备 (Delete Device)
|
||||
// 7. 删除设备 (Delete Device)
|
||||
await Device.destroy({
|
||||
where: { deviceId: deviceId },
|
||||
transaction: t
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 命令行备份脚本
|
||||
* 用于独立执行数据备份,支持环境迁移
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/backup.js [options]
|
||||
*
|
||||
* 选项:
|
||||
* --output, -o 指定备份文件输出路径
|
||||
* --description 备份描述
|
||||
* --no-files 不包含上传文件
|
||||
* --help, -h 显示帮助信息
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
output: null,
|
||||
description: '',
|
||||
includeFiles: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case '--output':
|
||||
case '-o':
|
||||
options.output = args[++i];
|
||||
break;
|
||||
case '--description':
|
||||
options.description = args[++i];
|
||||
break;
|
||||
case '--no-files':
|
||||
options.includeFiles = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`
|
||||
数据备份脚本
|
||||
|
||||
使用方法:
|
||||
node scripts/backup.js [options]
|
||||
|
||||
选项:
|
||||
--output, -o <path> 指定备份文件输出路径
|
||||
--description <text> 备份描述
|
||||
--no-files 不包含上传文件
|
||||
--help, -h 显示帮助信息
|
||||
|
||||
示例:
|
||||
node scripts/backup.js
|
||||
node scripts/backup.js -o /path/to/backup.json
|
||||
node scripts/backup.js --description "迁移前备份" --no-files
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
console.log('========================================');
|
||||
console.log(' IDC设备管理系统 - 数据备份工具');
|
||||
console.log('========================================\n');
|
||||
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
|
||||
const { createBackup, getBackupPath } = require('../utils/backup');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
console.log('连接数据库...');
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功\n');
|
||||
|
||||
const backupPath = options.output ? path.dirname(options.output) : getBackupPath();
|
||||
|
||||
console.log('备份配置:');
|
||||
console.log(` 输出路径: ${backupPath}`);
|
||||
console.log(` 包含文件: ${options.includeFiles ? '是' : '否'}`);
|
||||
if (options.description) {
|
||||
console.log(` 备份描述: ${options.description}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const result = await createBackup({
|
||||
description: options.description,
|
||||
includeFiles: options.includeFiles,
|
||||
backupPath: options.output ? path.dirname(options.output) : null,
|
||||
});
|
||||
|
||||
if (options.output) {
|
||||
const targetPath = options.output;
|
||||
const sourcePath = result.path;
|
||||
if (sourcePath !== targetPath) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
result.path = targetPath;
|
||||
result.filename = path.basename(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log(' 备份完成!');
|
||||
console.log('========================================');
|
||||
console.log(`\n备份文件: ${result.filename}`);
|
||||
console.log(`完整路径: ${result.path}`);
|
||||
console.log(`文件大小: ${(result.size / 1024).toFixed(2)} KB`);
|
||||
console.log(`数据记录: ${result.recordCount} 条`);
|
||||
console.log(`文件数量: ${result.fileCount} 个`);
|
||||
console.log(`创建时间: ${result.createdAt}`);
|
||||
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n备份失败:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runBackup();
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 命令行恢复脚本
|
||||
* 用于独立执行数据恢复,支持跨环境迁移
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/restore.js <backup-file> [options]
|
||||
*
|
||||
* 选项:
|
||||
* --skip-users 跳过用户数据恢复
|
||||
* --skip-files 跳过文件恢复
|
||||
* --no-overwrite 不覆盖现有数据(追加模式)
|
||||
* --help, -h 显示帮助信息
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
file: null,
|
||||
skipUsers: false,
|
||||
skipFiles: false,
|
||||
overwrite: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case '--skip-users':
|
||||
options.skipUsers = true;
|
||||
break;
|
||||
case '--skip-files':
|
||||
options.skipFiles = true;
|
||||
break;
|
||||
case '--no-overwrite':
|
||||
options.overwrite = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`
|
||||
数据恢复脚本
|
||||
|
||||
使用方法:
|
||||
node scripts/restore.js <backup-file> [options]
|
||||
|
||||
选项:
|
||||
--skip-users 跳过用户数据恢复
|
||||
--skip-files 跳过文件恢复
|
||||
--no-overwrite 不覆盖现有数据(追加模式)
|
||||
--help, -h 显示帮助信息
|
||||
|
||||
示例:
|
||||
node scripts/restore.js backup_2024-01-15.json
|
||||
node scripts/restore.js ./backups/backup_xxx.json --skip-users
|
||||
node scripts/restore.js /path/to/backup.json --no-overwrite
|
||||
`);
|
||||
process.exit(0);
|
||||
default:
|
||||
if (!arg.startsWith('-') && !options.file) {
|
||||
options.file = arg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.file) {
|
||||
console.error('错误: 请指定备份文件路径');
|
||||
console.error('使用 --help 查看帮助信息');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runRestore() {
|
||||
console.log('========================================');
|
||||
console.log(' IDC设备管理系统 - 数据恢复工具');
|
||||
console.log('========================================\n');
|
||||
|
||||
const backupFile = path.resolve(options.file);
|
||||
|
||||
if (!fs.existsSync(backupFile)) {
|
||||
console.error(`错误: 备份文件不存在: ${backupFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
|
||||
const { restoreBackup, validateBackupFile } = require('../utils/backup');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
console.log('连接数据库...');
|
||||
await sequelize.authenticate();
|
||||
console.log('数据库连接成功\n');
|
||||
|
||||
console.log('验证备份文件...');
|
||||
const validation = await validateBackupFile(backupFile);
|
||||
|
||||
if (!validation.valid) {
|
||||
console.error(`错误: 备份文件验证失败: ${validation.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n备份文件信息:');
|
||||
console.log(` 版本: ${validation.version}`);
|
||||
console.log(` 类型: ${validation.backupType || 'full'}`);
|
||||
console.log(` 时间: ${validation.timestamp}`);
|
||||
console.log(` 表数: ${validation.metadata.tableCount}`);
|
||||
console.log(` 记录数: ${validation.metadata.totalRecords}`);
|
||||
console.log(` 文件数: ${validation.metadata.fileCount || 0}`);
|
||||
|
||||
if (validation.description) {
|
||||
console.log(` 描述: ${validation.description}`);
|
||||
}
|
||||
|
||||
console.log('\n恢复配置:');
|
||||
console.log(` 覆盖模式: ${options.overwrite ? '覆盖现有数据' : '追加模式'}`);
|
||||
console.log(` 恢复用户: ${options.skipUsers ? '否' : '是'}`);
|
||||
console.log(` 恢复文件: ${options.skipFiles ? '否' : '是'}`);
|
||||
console.log('');
|
||||
|
||||
const skipTables = [];
|
||||
if (options.skipUsers) {
|
||||
skipTables.push('User', 'UserRole', 'Permission');
|
||||
}
|
||||
|
||||
console.log('开始恢复数据...\n');
|
||||
|
||||
const result = await restoreBackup(backupFile, {
|
||||
overwriteExisting: options.overwrite,
|
||||
skipTables,
|
||||
skipFiles: options.skipFiles,
|
||||
onProgress: (tableName, status, count) => {
|
||||
const statusMap = {
|
||||
'restored': '✓ 已恢复',
|
||||
'skipped': '○ 已跳过',
|
||||
'empty': '- 无数据',
|
||||
'error': '✗ 错误',
|
||||
};
|
||||
const statusText = statusMap[status] || status;
|
||||
const countText = count ? ` (${count} 条)` : '';
|
||||
console.log(` ${tableName.padEnd(25)} ${statusText}${countText}`);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log(' 恢复完成!');
|
||||
console.log('========================================');
|
||||
console.log(`\n恢复统计:`);
|
||||
console.log(` 恢复表数: ${result.tablesRestored} 个`);
|
||||
console.log(` 恢复记录: ${result.recordsRestored} 条`);
|
||||
console.log(` 恢复文件: ${result.filesRestored} 个`);
|
||||
console.log(` 完成时间: ${result.restoredAt}`);
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.log(`\n警告: 有 ${result.errors.length} 个错误`);
|
||||
result.errors.slice(0, 10).forEach(err => {
|
||||
console.log(` - ${err.table}: ${err.error}`);
|
||||
});
|
||||
if (result.errors.length > 10) {
|
||||
console.log(` ... 还有 ${result.errors.length - 10} 个错误`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n提示: 请重启后端服务以确保所有数据生效');
|
||||
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n恢复失败:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runRestore();
|
||||
@@ -1,4 +1,7 @@
|
||||
require('dotenv').config();
|
||||
const { ensureJwtSecret } = require('./initConfig');
|
||||
ensureJwtSecret();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const fileUpload = require('express-fileupload');
|
||||
@@ -117,6 +120,14 @@ async function initFaultCategories() {
|
||||
console.log('故障分类初始化完成');
|
||||
}
|
||||
|
||||
async function initAutoBackupScheduler() {
|
||||
const { initAutoBackup } = require('./utils/autoBackupScheduler');
|
||||
const status = initAutoBackup();
|
||||
if (status.enabled) {
|
||||
console.log(`自动备份已启用,下次执行时间:${status.nextRun || '未知'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
try {
|
||||
await syncDatabase();
|
||||
@@ -128,6 +139,7 @@ async function initializeApp() {
|
||||
await syncInventoryModels();
|
||||
await initDefaultSystemSettings();
|
||||
await initFaultCategories();
|
||||
await initAutoBackupScheduler();
|
||||
|
||||
console.log('所有初始化完成,服务器准备就绪');
|
||||
} catch (error) {
|
||||
@@ -157,6 +169,7 @@ const cableRoutes = require('./routes/cables');
|
||||
const devicePortRoutes = require('./routes/devicePorts');
|
||||
const networkCardRoutes = require('./routes/networkCards');
|
||||
const inventoryRoutes = require('./routes/inventory');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
|
||||
app.use('/api/devices', deviceRoutes);
|
||||
app.use('/api/racks', rackRoutes);
|
||||
@@ -177,6 +190,7 @@ app.use('/api/cables', cableRoutes);
|
||||
app.use('/api/device-ports', devicePortRoutes);
|
||||
app.use('/api/network-cards', networkCardRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 自动备份调度器模块
|
||||
* 使用 node-cron 实现定时自动备份功能
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { createBackup, createIncrementalBackup, getBackupPath } = require('./backup');
|
||||
|
||||
// 全局调度器存储
|
||||
const schedulers = new Map();
|
||||
|
||||
// 备份设置文件路径
|
||||
const SETTINGS_FILE = path.join(__dirname, '..', 'config', 'auto-backup-settings.json');
|
||||
|
||||
// 默认设置
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: false,
|
||||
cronExpression: '0 2 * * *', // 每天凌晨 2 点
|
||||
description: '自动备份',
|
||||
backupType: 'full', // 'full' 或 'incremental'
|
||||
includeFiles: true,
|
||||
compress: true,
|
||||
maxCount: 30,
|
||||
maxAgeDays: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载备份设置
|
||||
*/
|
||||
function loadSettings() {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const content = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(content) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载自动备份设置失败:', error);
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存备份设置
|
||||
*/
|
||||
function saveSettings(settings) {
|
||||
try {
|
||||
const configDir = path.dirname(SETTINGS_FILE);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存自动备份设置失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Cron 表达式
|
||||
*/
|
||||
function validateCronExpression(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文时间转换为 Cron 表达式
|
||||
*/
|
||||
function timeToCron(hour, minute) {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自动备份任务
|
||||
*/
|
||||
function createAutoBackupTask(settings) {
|
||||
const { cronExpression, description, backupType, includeFiles, compress, maxCount, maxAgeDays } = settings;
|
||||
|
||||
if (!validateCronExpression(cronExpression)) {
|
||||
throw new Error('无效的 Cron 表达式');
|
||||
}
|
||||
|
||||
// 如果已有调度器,先停止
|
||||
if (schedulers.has('auto-backup')) {
|
||||
stopAutoBackup();
|
||||
}
|
||||
|
||||
// 创建新的调度器
|
||||
const task = cron.schedule(cronExpression, async () => {
|
||||
console.log('=== 开始执行自动备份 ===');
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
|
||||
// 根据备份类型选择函数
|
||||
const backupFunction = backupType === 'incremental' ? createIncrementalBackup : createBackup;
|
||||
|
||||
const result = await backupFunction({
|
||||
description: `${description} - ${timestamp}`,
|
||||
includeFiles,
|
||||
compress,
|
||||
autoClean: true,
|
||||
maxCount,
|
||||
maxAgeDays,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
console.log('自动备份完成:', result.filename);
|
||||
console.log(`备份类型:${result.isIncremental ? '增量备份' : '全量备份'}`);
|
||||
console.log('========================\n');
|
||||
} else {
|
||||
console.log('无数据变化,跳过备份');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动备份失败:', error);
|
||||
console.error('========================\n');
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'Asia/Shanghai', // 设置时区为中国时区
|
||||
});
|
||||
|
||||
schedulers.set('auto-backup', task);
|
||||
console.log(`自动备份任务已启动,Cron 表达式:${cronExpression}, 备份类型:${backupType}`);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动备份
|
||||
*/
|
||||
function startAutoBackup(settings = null) {
|
||||
if (!settings) {
|
||||
settings = loadSettings();
|
||||
}
|
||||
|
||||
if (!settings.enabled) {
|
||||
console.log('自动备份已禁用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
createAutoBackupTask(settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('启动自动备份失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动备份
|
||||
*/
|
||||
function stopAutoBackup() {
|
||||
if (schedulers.has('auto-backup')) {
|
||||
const task = schedulers.get('auto-backup');
|
||||
task.stop();
|
||||
schedulers.delete('auto-backup');
|
||||
console.log('自动备份任务已停止');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自动备份状态
|
||||
*/
|
||||
function getAutoBackupStatus() {
|
||||
const settings = loadSettings();
|
||||
const isActive = schedulers.has('auto-backup');
|
||||
|
||||
// 计算下次执行时间
|
||||
let nextRun = null;
|
||||
if (isActive && settings.enabled) {
|
||||
// 简单计算下次执行时间(基于当前时间和 Cron 表达式)
|
||||
const now = new Date();
|
||||
const [minute, hour] = settings.cronExpression.split(' ').slice(0, 2);
|
||||
|
||||
const next = new Date(now);
|
||||
next.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
nextRun = next.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
isActive,
|
||||
cronExpression: settings.cronExpression,
|
||||
description: settings.description,
|
||||
backupType: settings.backupType || 'full',
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
maxCount: settings.maxCount,
|
||||
maxAgeDays: settings.maxAgeDays,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新自动备份设置
|
||||
*/
|
||||
function updateAutoBackupSettings(newSettings) {
|
||||
const currentSettings = loadSettings();
|
||||
const updatedSettings = { ...currentSettings, ...newSettings };
|
||||
|
||||
// 如果提供了小时和分钟,转换为 Cron 表达式
|
||||
if (newSettings.hour !== undefined && newSettings.minute !== undefined) {
|
||||
updatedSettings.cronExpression = timeToCron(newSettings.hour, newSettings.minute);
|
||||
delete updatedSettings.hour;
|
||||
delete updatedSettings.minute;
|
||||
}
|
||||
|
||||
// 验证 Cron 表达式
|
||||
if (!validateCronExpression(updatedSettings.cronExpression)) {
|
||||
throw new Error('无效的 Cron 表达式');
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
if (saveSettings(updatedSettings)) {
|
||||
// 如果启用了自动备份,重新启动调度器
|
||||
if (updatedSettings.enabled) {
|
||||
startAutoBackup(updatedSettings);
|
||||
} else {
|
||||
stopAutoBackup();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即执行一次备份
|
||||
*/
|
||||
async function executeBackupNow(options = {}) {
|
||||
console.log('=== 手动触发备份 ===');
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const result = await createBackup({
|
||||
description: options.description || '手动备份',
|
||||
includeFiles: options.includeFiles !== undefined ? options.includeFiles : settings.includeFiles,
|
||||
compress: options.compress !== undefined ? options.compress : settings.compress,
|
||||
autoClean: true,
|
||||
maxCount: settings.maxCount,
|
||||
maxAgeDays: settings.maxAgeDays,
|
||||
});
|
||||
|
||||
console.log('手动备份完成:', result.filename);
|
||||
console.log('====================\n');
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
console.error('手动备份失败:', error);
|
||||
console.error('====================\n');
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动备份(服务器启动时调用)
|
||||
*/
|
||||
function initAutoBackup() {
|
||||
console.log('初始化自动备份...');
|
||||
const settings = loadSettings();
|
||||
|
||||
if (settings.enabled) {
|
||||
startAutoBackup(settings);
|
||||
} else {
|
||||
console.log('自动备份当前为禁用状态');
|
||||
}
|
||||
|
||||
return getAutoBackupStatus();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
validateCronExpression,
|
||||
timeToCron,
|
||||
startAutoBackup,
|
||||
stopAutoBackup,
|
||||
getAutoBackupStatus,
|
||||
updateAutoBackupSettings,
|
||||
executeBackupNow,
|
||||
initAutoBackup,
|
||||
};
|
||||
@@ -0,0 +1,935 @@
|
||||
/**
|
||||
* 数据备份与恢复工具模块
|
||||
* 支持完整备份所有数据库表和上传文件
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
|
||||
const BACKUP_VERSION = '2.0.0';
|
||||
|
||||
// 数据表名称中英文映射
|
||||
const TABLE_NAME_MAPPING = {
|
||||
'User': '用户',
|
||||
'Role': '角色',
|
||||
'UserRole': '用户角色关联',
|
||||
'Permission': '权限',
|
||||
'Room': '机房',
|
||||
'Rack': '机柜',
|
||||
'Device': '设备',
|
||||
'DeviceField': '设备自定义字段',
|
||||
'DevicePort': '设备端口',
|
||||
'NetworkCard': '网卡',
|
||||
'Cable': '线缆',
|
||||
'PendingDevice': '待入库设备',
|
||||
'FaultCategory': '故障分类',
|
||||
'Ticket': '工单',
|
||||
'TicketField': '工单自定义字段',
|
||||
'TicketOperationRecord': '工单操作记录',
|
||||
'ConsumableCategory': '耗材分类',
|
||||
'Consumable': '耗材',
|
||||
'ConsumableRecord': '耗材记录',
|
||||
'ConsumableLog': '耗材操作日志',
|
||||
'ConsumableLogArchive': '耗材操作日志归档',
|
||||
'InventoryPlan': '盘点计划',
|
||||
'InventoryTask': '盘点任务',
|
||||
'InventoryRecord': '盘点记录',
|
||||
'SystemSetting': '系统设置',
|
||||
};
|
||||
|
||||
// 增量备份配置
|
||||
const INCREMENTAL_BACKUP_CONFIG = {
|
||||
// 哪些表支持增量备份(需要有 updatedAt 或 createdAt 字段)
|
||||
supportedTables: [
|
||||
'Device',
|
||||
'Ticket',
|
||||
'TicketOperationRecord',
|
||||
'Consumable',
|
||||
'ConsumableRecord',
|
||||
'ConsumableLog',
|
||||
'InventoryTask',
|
||||
'InventoryRecord',
|
||||
],
|
||||
// 必须全量备份的表
|
||||
fullBackupTables: [
|
||||
'User',
|
||||
'Role',
|
||||
'UserRole',
|
||||
'Permission',
|
||||
'Room',
|
||||
'Rack',
|
||||
'DeviceField',
|
||||
'DevicePort',
|
||||
'NetworkCard',
|
||||
'Cable',
|
||||
'PendingDevice',
|
||||
'FaultCategory',
|
||||
'TicketField',
|
||||
'ConsumableCategory',
|
||||
'ConsumableLogArchive',
|
||||
'InventoryPlan',
|
||||
'SystemSetting',
|
||||
],
|
||||
};
|
||||
|
||||
const BACKUP_MODELS_CONFIG = [
|
||||
{ name: 'User', modelPath: '../models/User' },
|
||||
{ name: 'Role', modelPath: '../models/Role' },
|
||||
{ name: 'UserRole', modelPath: '../models/UserRole' },
|
||||
{ name: 'Permission', modelPath: '../models/Permission' },
|
||||
{ name: 'Room', modelPath: '../models/Room' },
|
||||
{ name: 'Rack', modelPath: '../models/Rack' },
|
||||
{ name: 'Device', modelPath: '../models/Device' },
|
||||
{ name: 'DeviceField', modelPath: '../models/DeviceField' },
|
||||
{ name: 'DevicePort', modelPath: '../models/DevicePort' },
|
||||
{ name: 'NetworkCard', modelPath: '../models/NetworkCard' },
|
||||
{ name: 'Cable', modelPath: '../models/Cable' },
|
||||
{ name: 'PendingDevice', modelPath: '../models/PendingDevice' },
|
||||
{ name: 'FaultCategory', modelPath: '../models/FaultCategory' },
|
||||
{ name: 'Ticket', modelPath: '../models/Ticket' },
|
||||
{ name: 'TicketField', modelPath: '../models/TicketField' },
|
||||
{ name: 'TicketOperationRecord', modelPath: '../models/TicketOperationRecord' },
|
||||
{ name: 'ConsumableCategory', modelPath: '../models/ConsumableCategory' },
|
||||
{ name: 'Consumable', modelPath: '../models/Consumable' },
|
||||
{ name: 'ConsumableRecord', modelPath: '../models/ConsumableRecord' },
|
||||
{ name: 'ConsumableLog', modelPath: '../models/ConsumableLog' },
|
||||
{ name: 'ConsumableLogArchive', modelPath: '../models/ConsumableLogArchive' },
|
||||
{ name: 'InventoryPlan', modelPath: '../models/InventoryPlan' },
|
||||
{ name: 'InventoryTask', modelPath: '../models/InventoryTask' },
|
||||
{ name: 'InventoryRecord', modelPath: '../models/InventoryRecord' },
|
||||
{ name: 'SystemSetting', modelPath: '../models/SystemSetting' },
|
||||
];
|
||||
|
||||
const RESTORE_ORDER = [
|
||||
'SystemSetting',
|
||||
'Role',
|
||||
'User',
|
||||
'UserRole',
|
||||
'Permission',
|
||||
'Room',
|
||||
'Rack',
|
||||
'DeviceField',
|
||||
'Device',
|
||||
'DevicePort',
|
||||
'NetworkCard',
|
||||
'Cable',
|
||||
'PendingDevice',
|
||||
'FaultCategory',
|
||||
'TicketField',
|
||||
'Ticket',
|
||||
'TicketOperationRecord',
|
||||
'ConsumableCategory',
|
||||
'Consumable',
|
||||
'ConsumableRecord',
|
||||
'ConsumableLog',
|
||||
'ConsumableLogArchive',
|
||||
'InventoryPlan',
|
||||
'InventoryTask',
|
||||
'InventoryRecord',
|
||||
];
|
||||
|
||||
function getBackupPath() {
|
||||
const backupDir = path.join(__dirname, '..', 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上次备份的时间
|
||||
*/
|
||||
function getLastBackupTime() {
|
||||
const backupDir = getBackupPath();
|
||||
const files = fs.readdirSync(backupDir)
|
||||
.filter(f => (f.startsWith('backup_') || f.startsWith('incremental_')) && (f.endsWith('.json') || f.endsWith('.json.gz')))
|
||||
.map(f => {
|
||||
const filePath = path.join(backupDir, f);
|
||||
return {
|
||||
filename: f,
|
||||
createdAt: fs.statSync(filePath).birthtime,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
if (files.length > 0) {
|
||||
return files[0].createdAt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集增量数据(只收集自上次备份以来变化的数据)
|
||||
*/
|
||||
async function collectIncrementalData(lastBackupTime) {
|
||||
const incrementalData = {};
|
||||
let totalNewRecords = 0;
|
||||
let totalUpdatedRecords = 0;
|
||||
|
||||
for (const config of BACKUP_MODELS_CONFIG) {
|
||||
if (!INCREMENTAL_BACKUP_CONFIG.supportedTables.includes(config.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
|
||||
// 查询自上次备份以来新增或更新的记录
|
||||
const newRecords = await Model.findAll({
|
||||
where: {
|
||||
createdAt: {
|
||||
[require('sequelize').Op.gt]: lastBackupTime,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRecords = await Model.findAll({
|
||||
where: {
|
||||
updatedAt: {
|
||||
[require('sequelize').Op.gt]: lastBackupTime,
|
||||
},
|
||||
createdAt: {
|
||||
[require('sequelize').Op.lte]: lastBackupTime,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (newRecords.length > 0 || updatedRecords.length > 0) {
|
||||
incrementalData[config.name] = {
|
||||
new: newRecords.map(r => r.toJSON()),
|
||||
updated: updatedRecords.map(r => r.toJSON()),
|
||||
deleted: [], // 删除的记录需要特殊处理,暂时不支持
|
||||
};
|
||||
totalNewRecords += newRecords.length;
|
||||
totalUpdatedRecords += updatedRecords.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`收集表 ${config.name} 的增量数据失败:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: incrementalData,
|
||||
totalNewRecords,
|
||||
totalUpdatedRecords,
|
||||
totalChangedRecords: totalNewRecords + totalUpdatedRecords,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBackupDir(backupPath) {
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.mkdirSync(backupPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAllData() {
|
||||
const data = {};
|
||||
let totalRecords = 0;
|
||||
|
||||
for (const config of BACKUP_MODELS_CONFIG) {
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
const records = await Model.findAll({ raw: true });
|
||||
data[config.name] = records;
|
||||
totalRecords += records.length;
|
||||
console.log(`收集 ${config.name}: ${records.length} 条记录`);
|
||||
} catch (error) {
|
||||
console.warn(`收集 ${config.name} 失败:`, error.message);
|
||||
data[config.name] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { data, totalRecords };
|
||||
}
|
||||
|
||||
async function collectFiles(uploadsDir) {
|
||||
const files = {
|
||||
avatars: [],
|
||||
others: [],
|
||||
};
|
||||
|
||||
const avatarsDir = path.join(uploadsDir, 'avatars');
|
||||
if (fs.existsSync(avatarsDir)) {
|
||||
const avatarFiles = fs.readdirSync(avatarsDir);
|
||||
for (const filename of avatarFiles) {
|
||||
const filePath = path.join(avatarsDir, filename);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isFile()) {
|
||||
const content = fs.readFileSync(filePath);
|
||||
files.avatars.push({
|
||||
filename,
|
||||
path: 'uploads/avatars/',
|
||||
content: content.toString('base64'),
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
const otherFiles = fs.readdirSync(uploadsDir).filter(f => {
|
||||
const filePath = path.join(uploadsDir, f);
|
||||
return fs.statSync(filePath).isFile();
|
||||
});
|
||||
|
||||
for (const filename of otherFiles) {
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
const content = fs.readFileSync(filePath);
|
||||
files.others.push({
|
||||
filename,
|
||||
path: 'uploads/',
|
||||
content: content.toString('base64'),
|
||||
size: content.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function calculateChecksum(data) {
|
||||
const content = JSON.stringify(data);
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
async function createBackup(options = {}) {
|
||||
const {
|
||||
description = '',
|
||||
includeFiles = true,
|
||||
backupPath = null,
|
||||
compress = true,
|
||||
autoClean = true,
|
||||
maxCount = 30,
|
||||
maxAgeDays = 90,
|
||||
} = options;
|
||||
|
||||
const finalBackupPath = backupPath || getBackupPath();
|
||||
ensureBackupDir(finalBackupPath);
|
||||
|
||||
console.log('开始备份数据...');
|
||||
const { data, totalRecords } = await collectAllData();
|
||||
|
||||
let files = { avatars: [], others: [] };
|
||||
let fileCount = 0;
|
||||
|
||||
if (includeFiles) {
|
||||
console.log('备份上传文件...');
|
||||
const uploadsDir = path.join(__dirname, '..', 'uploads');
|
||||
files = await collectFiles(uploadsDir);
|
||||
fileCount = files.avatars.length + files.others.length;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
const ext = compress ? '.json.gz' : '.json';
|
||||
const filename = `backup_${timestamp}${ext}`;
|
||||
|
||||
const backupData = {
|
||||
version: BACKUP_VERSION,
|
||||
backupType: 'full',
|
||||
timestamp: new Date().toISOString(),
|
||||
description,
|
||||
compressed: compress,
|
||||
systemInfo: {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dbType: process.env.DB_TYPE || 'sqlite',
|
||||
},
|
||||
metadata: {
|
||||
tableCount: Object.keys(data).length,
|
||||
totalRecords,
|
||||
fileCount,
|
||||
},
|
||||
data,
|
||||
files,
|
||||
};
|
||||
|
||||
backupData.checksum = calculateChecksum(backupData);
|
||||
|
||||
const filePath = path.join(finalBackupPath, filename);
|
||||
const jsonContent = JSON.stringify(backupData);
|
||||
|
||||
if (compress) {
|
||||
const compressed = zlib.gzipSync(Buffer.from(jsonContent, 'utf8'));
|
||||
fs.writeFileSync(filePath, compressed);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, jsonContent, 'utf8');
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
console.log(`备份完成: ${filename}`);
|
||||
console.log(`文件大小: ${(stat.size / 1024).toFixed(2)} KB`);
|
||||
if (compress) {
|
||||
const originalSize = Buffer.byteLength(jsonContent, 'utf8');
|
||||
const ratio = ((1 - stat.size / originalSize) * 100).toFixed(1);
|
||||
console.log(`压缩率: ${ratio}% (原始 ${(originalSize / 1024).toFixed(2)} KB)`);
|
||||
}
|
||||
console.log(`数据记录: ${totalRecords} 条`);
|
||||
console.log(`文件数量: ${fileCount} 个`);
|
||||
|
||||
let cleanResult = null;
|
||||
if (autoClean) {
|
||||
console.log('\n检查旧备份文件...');
|
||||
cleanResult = cleanOldBackups({ maxCount, maxAgeDays });
|
||||
if (cleanResult.deletedCount > 0) {
|
||||
console.log(`已清理 ${cleanResult.deletedCount} 个旧备份,释放 ${cleanResult.freedSizeFormatted}`);
|
||||
} else {
|
||||
console.log('无需清理旧备份');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
recordCount: totalRecords,
|
||||
fileCount,
|
||||
compressed: compress,
|
||||
createdAt: backupData.timestamp,
|
||||
cleaned: cleanResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建增量备份
|
||||
*/
|
||||
async function createIncrementalBackup(options = {}) {
|
||||
const {
|
||||
description = '',
|
||||
includeFiles = false,
|
||||
backupPath = null,
|
||||
compress = true,
|
||||
autoClean = true,
|
||||
maxCount = 30,
|
||||
maxAgeDays = 90,
|
||||
} = options;
|
||||
|
||||
const finalBackupPath = backupPath || getBackupPath();
|
||||
ensureBackupDir(finalBackupPath);
|
||||
|
||||
// 获取上次备份时间
|
||||
const lastBackupTime = getLastBackupTime();
|
||||
if (!lastBackupTime) {
|
||||
console.log('未找到上次备份,执行全量备份');
|
||||
return await createBackup(options);
|
||||
}
|
||||
|
||||
console.log(`开始增量备份(上次备份时间:${lastBackupTime.toISOString()})...`);
|
||||
|
||||
// 收集增量数据
|
||||
const { data: incrementalData, totalChangedRecords } = await collectIncrementalData(lastBackupTime);
|
||||
|
||||
if (totalChangedRecords === 0) {
|
||||
console.log('自上次备份以来没有数据变化,跳过备份');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 收集配置数据(总是全量备份)
|
||||
console.log('备份配置数据...');
|
||||
const { data: fullData } = await collectAllData(INCREMENTAL_BACKUP_CONFIG.fullBackupTables);
|
||||
|
||||
let files = { avatars: [], others: [] };
|
||||
let fileCount = 0;
|
||||
|
||||
if (includeFiles) {
|
||||
console.log('备份上传文件...');
|
||||
const uploadsDir = path.join(__dirname, '..', 'uploads');
|
||||
files = await collectFiles(uploadsDir);
|
||||
fileCount = files.avatars.length + files.others.length;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
const ext = compress ? '.json.gz' : '.json';
|
||||
const filename = `incremental_${timestamp}${ext}`;
|
||||
|
||||
const backupData = {
|
||||
version: BACKUP_VERSION,
|
||||
backupType: 'incremental',
|
||||
timestamp: new Date().toISOString(),
|
||||
description,
|
||||
compressed: compress,
|
||||
lastBackupTime: lastBackupTime.toISOString(),
|
||||
systemInfo: {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dbType: process.env.DB_TYPE || 'sqlite',
|
||||
},
|
||||
metadata: {
|
||||
fullBackupTables: Object.keys(fullData).length,
|
||||
incrementalTables: Object.keys(incrementalData).length,
|
||||
totalChangedRecords,
|
||||
fileCount,
|
||||
},
|
||||
fullData,
|
||||
incrementalData,
|
||||
files,
|
||||
};
|
||||
|
||||
backupData.checksum = calculateChecksum(backupData);
|
||||
|
||||
const filePath = path.join(finalBackupPath, filename);
|
||||
const jsonContent = JSON.stringify(backupData);
|
||||
|
||||
if (compress) {
|
||||
const compressed = zlib.gzipSync(Buffer.from(jsonContent, 'utf8'));
|
||||
fs.writeFileSync(filePath, compressed);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, jsonContent, 'utf8');
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
console.log(`增量备份完成:${filename}`);
|
||||
console.log(`文件大小:${(stat.size / 1024).toFixed(2)} KB`);
|
||||
if (compress) {
|
||||
const originalSize = Buffer.byteLength(jsonContent, 'utf8');
|
||||
const ratio = ((1 - stat.size / originalSize) * 100).toFixed(1);
|
||||
console.log(`压缩率:${ratio}% (原始 ${(originalSize / 1024).toFixed(2)} KB)`);
|
||||
}
|
||||
console.log(`变化记录:${totalChangedRecords} 条`);
|
||||
console.log(`文件数量:${fileCount} 个`);
|
||||
|
||||
let cleanResult = null;
|
||||
if (autoClean) {
|
||||
console.log('\n检查旧备份文件...');
|
||||
cleanResult = cleanOldBackups({ maxCount, maxAgeDays });
|
||||
if (cleanResult.deletedCount > 0) {
|
||||
console.log(`已清理 ${cleanResult.deletedCount} 个旧备份,释放 ${cleanResult.freedSizeFormatted}`);
|
||||
} else {
|
||||
console.log('无需清理旧备份');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
recordCount: totalChangedRecords,
|
||||
fileCount,
|
||||
compressed: compress,
|
||||
createdAt: backupData.timestamp,
|
||||
cleaned: cleanResult,
|
||||
isIncremental: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateBackupFile(filePath, options = {}) {
|
||||
const { isCompressed: forceCompressed } = options;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { valid: false, error: '备份文件不存在' };
|
||||
}
|
||||
|
||||
let backupData;
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const isCompressed = forceCompressed !== undefined ? forceCompressed : filePath.endsWith('.gz');
|
||||
|
||||
if (isCompressed) {
|
||||
const decompressed = zlib.gunzipSync(buffer);
|
||||
backupData = JSON.parse(decompressed.toString('utf8'));
|
||||
} else {
|
||||
backupData = JSON.parse(buffer.toString('utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('备份文件解析失败:', error.message);
|
||||
return { valid: false, error: `备份文件格式无效: ${error.message}` };
|
||||
}
|
||||
|
||||
if (!backupData.version) {
|
||||
return { valid: false, error: '备份文件缺少版本信息' };
|
||||
}
|
||||
|
||||
if (!backupData.data) {
|
||||
return { valid: false, error: '备份文件缺少数据内容' };
|
||||
}
|
||||
|
||||
const savedChecksum = backupData.checksum;
|
||||
if (savedChecksum) {
|
||||
const dataToCheck = { ...backupData };
|
||||
delete dataToCheck.checksum;
|
||||
const calculatedChecksum = calculateChecksum(dataToCheck);
|
||||
if (calculatedChecksum !== savedChecksum) {
|
||||
return { valid: false, error: '备份文件校验和不匹配,文件可能已损坏' };
|
||||
}
|
||||
}
|
||||
|
||||
// 详细的表信息统计
|
||||
const tableDetails = {};
|
||||
let totalRecords = 0;
|
||||
for (const tableName of Object.keys(backupData.data)) {
|
||||
if (Array.isArray(backupData.data[tableName])) {
|
||||
const recordCount = backupData.data[tableName].length;
|
||||
tableDetails[tableName] = {
|
||||
recordCount,
|
||||
hasData: recordCount > 0,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName, // 使用中文显示名称
|
||||
};
|
||||
totalRecords += recordCount;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件详情
|
||||
const fileDetails = {
|
||||
avatars: backupData.files?.avatars?.length || 0,
|
||||
others: backupData.files?.others?.length || 0,
|
||||
total: (backupData.files?.avatars?.length || 0) + (backupData.files?.others?.length || 0),
|
||||
avatarList: backupData.files?.avatars?.map(f => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
})) || [],
|
||||
otherList: backupData.files?.others?.map(f => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
})) || [],
|
||||
};
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version: backupData.version,
|
||||
backupType: backupData.backupType,
|
||||
timestamp: backupData.timestamp,
|
||||
description: backupData.description,
|
||||
compressed: backupData.compressed,
|
||||
systemInfo: backupData.systemInfo,
|
||||
metadata: {
|
||||
tableCount: Object.keys(backupData.data).length,
|
||||
totalRecords,
|
||||
fileCount: fileDetails.total,
|
||||
},
|
||||
// 详细信息
|
||||
details: {
|
||||
tables: tableDetails,
|
||||
files: fileDetails,
|
||||
systemInfo: backupData.systemInfo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreData(backupData, options = {}) {
|
||||
const {
|
||||
overwriteExisting = true,
|
||||
skipTables = [],
|
||||
onProgress = () => {},
|
||||
} = options;
|
||||
|
||||
const results = {
|
||||
tablesRestored: 0,
|
||||
recordsRestored: 0,
|
||||
errors: [],
|
||||
skipped: [],
|
||||
tableDetails: {}, // 每个表的详细恢复信息
|
||||
};
|
||||
|
||||
for (const tableName of RESTORE_ORDER) {
|
||||
if (skipTables.includes(tableName)) {
|
||||
results.skipped.push(tableName);
|
||||
onProgress(tableName, 'skipped');
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableData = backupData.data[tableName];
|
||||
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
|
||||
onProgress(tableName, 'empty');
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
|
||||
if (!config) {
|
||||
results.errors.push({ table: tableName, error: '未找到模型配置' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
|
||||
if (overwriteExisting) {
|
||||
await Model.destroy({ where: {}, truncate: true });
|
||||
}
|
||||
|
||||
// 预处理记录:修复 JSON 字段格式
|
||||
const processedRecords = tableData.map(record => {
|
||||
const processed = { ...record };
|
||||
|
||||
// 修复 Device 表的 customFields 字段
|
||||
if (tableName === 'Device' && processed.customFields !== undefined && processed.customFields !== null) {
|
||||
if (typeof processed.customFields === 'string') {
|
||||
try {
|
||||
processed.customFields = JSON.parse(processed.customFields);
|
||||
} catch (e) {
|
||||
console.warn(`解析 Device.customFields 失败:${processed.deviceId}, 错误:${e.message}`);
|
||||
processed.customFields = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
let insertedCount = 0;
|
||||
for (const record of processedRecords) {
|
||||
try {
|
||||
await Model.create(record, { validate: false, silent: true });
|
||||
insertedCount++;
|
||||
} catch (insertError) {
|
||||
if (insertError.name === 'SequelizeUniqueConstraintError') {
|
||||
try {
|
||||
await Model.upsert(record, { validate: false, silent: true });
|
||||
insertedCount++;
|
||||
} catch (upsertError) {
|
||||
results.errors.push({
|
||||
table: tableName,
|
||||
record: record[Object.keys(record)[0]],
|
||||
error: upsertError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.errors.push({
|
||||
table: tableName,
|
||||
record: record[Object.keys(record)[0]],
|
||||
error: insertError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.tablesRestored++;
|
||||
results.recordsRestored += insertedCount;
|
||||
|
||||
// 记录每个表的详细信息
|
||||
results.tableDetails[tableName] = {
|
||||
recordCount: insertedCount,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
success: insertedCount > 0,
|
||||
};
|
||||
|
||||
onProgress(tableName, 'restored', insertedCount);
|
||||
} catch (error) {
|
||||
results.errors.push({ table: tableName, error: error.message });
|
||||
onProgress(tableName, 'error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function restoreFiles(files, uploadsDir) {
|
||||
const results = {
|
||||
filesRestored: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const avatarsDir = path.join(uploadsDir, 'avatars');
|
||||
if (!fs.existsSync(avatarsDir)) {
|
||||
fs.mkdirSync(avatarsDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (files.avatars && Array.isArray(files.avatars)) {
|
||||
for (const file of files.avatars) {
|
||||
try {
|
||||
const filePath = path.join(avatarsDir, file.filename);
|
||||
const content = Buffer.from(file.content, 'base64');
|
||||
fs.writeFileSync(filePath, content);
|
||||
results.filesRestored++;
|
||||
} catch (error) {
|
||||
results.errors.push({ file: file.filename, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.others && Array.isArray(files.others)) {
|
||||
for (const file of files.others) {
|
||||
try {
|
||||
const filePath = path.join(uploadsDir, file.filename);
|
||||
const content = Buffer.from(file.content, 'base64');
|
||||
fs.writeFileSync(filePath, content);
|
||||
results.filesRestored++;
|
||||
} catch (error) {
|
||||
results.errors.push({ file: file.filename, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function restoreBackup(filePath, options = {}) {
|
||||
const {
|
||||
overwriteExisting = true,
|
||||
skipTables = [],
|
||||
skipFiles = false,
|
||||
onProgress = () => {},
|
||||
} = options;
|
||||
|
||||
console.log('验证备份文件...');
|
||||
const validation = await validateBackupFile(filePath);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`备份文件验证失败: ${validation.error}`);
|
||||
}
|
||||
|
||||
console.log('备份文件信息:');
|
||||
console.log(` 版本: ${validation.version}`);
|
||||
console.log(` 压缩: ${validation.compressed ? '是' : '否'}`);
|
||||
console.log(` 表数: ${validation.metadata.tableCount}`);
|
||||
console.log(` 记录数: ${validation.metadata.totalRecords}`);
|
||||
console.log(` 文件数: ${validation.metadata.fileCount}`);
|
||||
|
||||
console.log('\n读取备份数据...');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const isCompressed = filePath.endsWith('.gz');
|
||||
|
||||
let backupData;
|
||||
if (isCompressed) {
|
||||
console.log('解压备份文件...');
|
||||
const decompressed = zlib.gunzipSync(buffer);
|
||||
backupData = JSON.parse(decompressed.toString('utf8'));
|
||||
} else {
|
||||
backupData = JSON.parse(buffer.toString('utf8'));
|
||||
}
|
||||
|
||||
console.log('\n开始恢复数据...');
|
||||
const dataResults = await restoreData(backupData, {
|
||||
overwriteExisting,
|
||||
skipTables,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
let fileResults = { filesRestored: 0, errors: [] };
|
||||
if (!skipFiles && backupData.files) {
|
||||
console.log('\n恢复上传文件...');
|
||||
const uploadsDir = path.join(__dirname, '..', 'uploads');
|
||||
fileResults = await restoreFiles(backupData.files, uploadsDir);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
console.log('\n恢复完成!');
|
||||
console.log(`备份文件: ${(stat.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(`数据记录: ${dataResults.recordsRestored} 条`);
|
||||
console.log(`恢复表数: ${dataResults.tablesRestored} 个`);
|
||||
console.log(`文件恢复: ${fileResults.filesRestored} 个`);
|
||||
|
||||
if (dataResults.errors.length > 0) {
|
||||
console.log('\n恢复错误:');
|
||||
dataResults.errors.forEach(err => {
|
||||
console.log(` - ${err.table}: ${err.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
restoredAt: new Date().toISOString(),
|
||||
tablesRestored: dataResults.tablesRestored,
|
||||
recordsRestored: dataResults.recordsRestored,
|
||||
filesRestored: fileResults.filesRestored,
|
||||
errors: dataResults.errors,
|
||||
tableDetails: dataResults.tableDetails, // 每个表的详细恢复信息
|
||||
fileDetails: backupData.files ? {
|
||||
avatars: backupData.files.avatars?.length || 0,
|
||||
others: backupData.files.others?.length || 0,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanOldBackups(options = {}) {
|
||||
const {
|
||||
maxCount = 30,
|
||||
maxAgeDays = 90,
|
||||
dryRun = false,
|
||||
} = options;
|
||||
|
||||
const backupPath = getBackupPath();
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
return { deleted: [], kept: [], totalSize: 0, freedSize: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const files = fs.readdirSync(backupPath)
|
||||
.filter(f => (f.startsWith('backup_') || f.startsWith('uploaded_')) && (f.endsWith('.json') || f.endsWith('.json.gz')))
|
||||
.map(f => {
|
||||
const filePath = path.join(backupPath, f);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
filename: f,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime,
|
||||
createdMs: stats.birthtimeMs || stats.birthtime.getTime(),
|
||||
age: now - (stats.birthtimeMs || stats.birthtime.getTime()),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdMs - a.createdMs);
|
||||
|
||||
const toDelete = [];
|
||||
const toKeep = [];
|
||||
let freedSize = 0;
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const tooOld = file.age > maxAgeMs;
|
||||
const tooMany = index >= maxCount;
|
||||
|
||||
if (tooOld || tooMany) {
|
||||
toDelete.push(file);
|
||||
freedSize += file.size;
|
||||
} else {
|
||||
toKeep.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
if (!dryRun) {
|
||||
toDelete.forEach(file => {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
console.log(`已删除旧备份: ${file.filename}`);
|
||||
} catch (err) {
|
||||
console.warn(`删除备份失败: ${file.filename}`, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: toDelete.map(f => f.filename),
|
||||
kept: toKeep.map(f => f.filename),
|
||||
deletedCount: toDelete.length,
|
||||
keptCount: toKeep.length,
|
||||
totalSize: files.reduce((sum, f) => sum + f.size, 0),
|
||||
freedSize,
|
||||
freedSizeFormatted: formatBytes(freedSize),
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKUP_VERSION,
|
||||
BACKUP_MODELS_CONFIG,
|
||||
RESTORE_ORDER,
|
||||
INCREMENTAL_BACKUP_CONFIG,
|
||||
getBackupPath,
|
||||
ensureBackupDir,
|
||||
createBackup,
|
||||
createIncrementalBackup,
|
||||
getLastBackupTime,
|
||||
validateBackupFile,
|
||||
restoreData,
|
||||
restoreFiles,
|
||||
restoreBackup,
|
||||
calculateChecksum,
|
||||
cleanOldBackups,
|
||||
};
|
||||
+67
-21
@@ -53,6 +53,7 @@ import useIdleTimeout from './hooks/useIdleTimeout';
|
||||
import { SWRConfig, swrConfig } from './hooks/useSWR';
|
||||
import axios from 'axios';
|
||||
import { Spin } from 'antd';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const DeviceManagement = lazy(() => import('./pages/DeviceManagement'));
|
||||
@@ -76,6 +77,9 @@ const PortManagement = lazy(() => import('./pages/PortManagement'));
|
||||
const InventoryManagement = lazy(() => import('./pages/InventoryManagement'));
|
||||
const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution'));
|
||||
const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement'));
|
||||
const BackupManagement = lazy(() => import('./pages/BackupManagement'));
|
||||
const AutoBackupSettings = lazy(() => import('./pages/AutoBackupSettings'));
|
||||
const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest'));
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
@@ -198,7 +202,8 @@ const AppLayout = ({ children }) => {
|
||||
path.startsWith('/users') ||
|
||||
path.startsWith('/login-history') ||
|
||||
path.startsWith('/operation-logs') ||
|
||||
path.startsWith('/settings')
|
||||
path.startsWith('/settings') ||
|
||||
path.startsWith('/backup')
|
||||
)
|
||||
return 'system-management';
|
||||
if (path.startsWith('/tickets')) return 'ticket-management';
|
||||
@@ -347,6 +352,11 @@ const AppLayout = ({ children }) => {
|
||||
icon: <SettingOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/settings">系统设置</Link>,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <DatabaseOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/backup">数据备份</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -583,43 +593,79 @@ const routeConfig = [
|
||||
{ path: '/inventory/execution', component: InventoryTaskExecution },
|
||||
{ path: '/pending-devices', component: PendingDeviceManagement },
|
||||
{ path: '/ports', component: PortManagement },
|
||||
{ path: '/backup', component: BackupManagement },
|
||||
{ path: '/auto-backup-settings', component: AutoBackupSettings },
|
||||
{ path: '/error-boundary-test', component: ErrorBoundaryTest },
|
||||
];
|
||||
|
||||
const ThemeConfig = () => {
|
||||
const designTokens = useDesignTokens();
|
||||
|
||||
const renderRoute = (path, Component, needsErrorBoundary = true) => {
|
||||
const element = (
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
</PrivateRoute>
|
||||
);
|
||||
|
||||
if (needsErrorBoundary) {
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="页面加载失败"
|
||||
subTitle="该页面在加载过程中遇到了错误,请尝试重新加载"
|
||||
>
|
||||
{element}
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Route key={path} path={path} element={element} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<AntdConfigProvider theme={{ token: designTokens }}>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<Router>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
{routeConfig.map(({ path, component: Component }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
path="/login"
|
||||
element={
|
||||
<ErrorBoundary fullPage>
|
||||
<Login />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
{routeConfig.map(({ path, component: Component }) =>
|
||||
renderRoute(path, Component)
|
||||
)}
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Component />
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 可视化加载失败"
|
||||
subTitle="3D 场景在加载过程中遇到错误,可能是浏览器不支持 WebGL 或模型文件加载失败"
|
||||
>
|
||||
<Scene3DProvider>
|
||||
<Rack3DVisualization />
|
||||
</Scene3DProvider>
|
||||
</ErrorBoundary>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/visualization-3d"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Scene3DProvider>
|
||||
<Rack3DVisualization />
|
||||
</Scene3DProvider>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</SWRConfig>
|
||||
</AntdConfigProvider>
|
||||
);
|
||||
|
||||
@@ -153,4 +153,21 @@ export const ticketCategoryAPI = {
|
||||
init: () => api.post('/ticket-categories/init'),
|
||||
};
|
||||
|
||||
export const backupAPI = {
|
||||
list: () => api.get('/backup/list'),
|
||||
create: (data = {}) => api.post('/backup', data),
|
||||
validate: filename => api.get(`/backup/validate/${filename}`),
|
||||
restore: (filename, options = {}) => api.post('/backup/restore', { filename, options }),
|
||||
download: filename => `/api/backup/download/${filename}`,
|
||||
delete: filename => api.delete(`/backup/${filename}`),
|
||||
upload: file => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
return api.post('/backup/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
info: () => api.get('/backup/info'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,7 @@ const envMapUrl = '/assets/3d/env.hdr';
|
||||
import RackModel from './RackModel';
|
||||
import { useScene3D } from '../../context/Scene3DContext';
|
||||
import * as THREE from 'three';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
@@ -153,50 +154,56 @@ const Scene = forwardRef(
|
||||
}, [rackHeightMeters]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启alpha以支持透明背景
|
||||
powerPreference: 'high-performance',
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 场景渲染失败"
|
||||
subTitle="3D 场景在渲染过程中遇到错误,请检查浏览器是否支持 WebGL"
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls
|
||||
rack={rack}
|
||||
onControlsReady={api => {
|
||||
controlsApiRef.current = api;
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={deviceDpr}
|
||||
performance={{ min: 0.5 }}
|
||||
gl={{
|
||||
antialias: true, // 对所有设备开启抗锯齿提升清晰度
|
||||
alpha: true, // 必须开启 alpha 以支持透明背景
|
||||
powerPreference: 'high-performance',
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<PerspectiveCamera makeDefault position={cameraPosition} fov={45} />
|
||||
|
||||
<ambientLight intensity={0.5} color="#ffffff" />
|
||||
<pointLight position={[5, 8, 5]} intensity={2} color="#ffffff" castShadow />
|
||||
<directionalLight
|
||||
position={[10, 10, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-10}
|
||||
shadow-camera-right={10}
|
||||
shadow-camera-top={10}
|
||||
shadow-camera-bottom={-10}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Environment files={envMapUrl} blur={0.5} resolution={256} background={false} />
|
||||
</Suspense>
|
||||
|
||||
{/* Models */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<RackModel {...rackModelProps} />
|
||||
</group>
|
||||
|
||||
{/* Controls - 使用独立组件保持旋转中心固定 */}
|
||||
<Controls
|
||||
rack={rack}
|
||||
onControlsReady={api => {
|
||||
controlsApiRef.current = api;
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Button, Result, Space, Collapse, Typography } from 'antd';
|
||||
import {
|
||||
WarningOutlined,
|
||||
ReloadOutlined,
|
||||
HomeOutlined,
|
||||
BugOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
console.error('错误边界捕获到错误:', error, errorInfo);
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: this.props.fullPage ? '100vh' : '400px',
|
||||
background: this.props.fullPage ? '#f5f5f5' : 'transparent',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="error"
|
||||
title={this.props.title || '组件加载失败'}
|
||||
subTitle={
|
||||
this.props.subTitle ||
|
||||
'抱歉,该组件在加载过程中遇到了错误。您可以尝试重新加载或返回首页。'
|
||||
}
|
||||
icon={
|
||||
<WarningOutlined
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
color: '#ff4d4f',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
extra={
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={this.handleReload}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
icon={<HomeOutlined />}
|
||||
onClick={this.handleGoHome}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{this.state.error && (
|
||||
<div style={{ textAlign: 'left', marginTop: '24px' }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'error-details',
|
||||
label: (
|
||||
<Space>
|
||||
<BugOutlined />
|
||||
<Text type="secondary">查看错误详情(开发环境)</Text>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Paragraph
|
||||
code
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{this.state.error.toString()}
|
||||
</Paragraph>
|
||||
{this.state.errorInfo?.componentStack && (
|
||||
<Paragraph
|
||||
code
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
}}
|
||||
>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</Paragraph>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.defaultProps = {
|
||||
fallback: null,
|
||||
fullPage: false,
|
||||
title: null,
|
||||
subTitle: null,
|
||||
onError: null,
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -103,7 +103,7 @@ const DeviceFormModal = ({
|
||||
style={inputStyle}
|
||||
className="form-input-enhanced"
|
||||
>
|
||||
{field.options &&
|
||||
{Array.isArray(field.options) &&
|
||||
field.options.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
||||
@@ -2,12 +2,15 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
<ErrorBoundary fullPage>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Switch,
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
Tag,
|
||||
Alert,
|
||||
Modal,
|
||||
message,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Typography,
|
||||
Spin,
|
||||
Radio,
|
||||
} from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
InfoCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ThunderboltOutlined,
|
||||
SafetyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
FileProtectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const AutoBackupSettings = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [settings, setSettings] = useState({
|
||||
enabled: false,
|
||||
hour: 2,
|
||||
minute: 0,
|
||||
includeFiles: true,
|
||||
compress: true,
|
||||
maxCount: 30,
|
||||
maxAgeDays: 90,
|
||||
});
|
||||
const [modified, setModified] = useState(false);
|
||||
const isInitialMount = useRef(true);
|
||||
const isFetching = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
fetchStatus();
|
||||
isInitialMount.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (isFetching.current) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
isFetching.current = true;
|
||||
const response = await axios.get('/api/backup/auto/status');
|
||||
if (response.data?.success) {
|
||||
const data = response.data.data;
|
||||
setStatus(data);
|
||||
|
||||
// 解析 Cron 表达式获取小时和分钟
|
||||
if (data.cronExpression) {
|
||||
const parts = data.cronExpression.split(' ');
|
||||
const minute = parseInt(parts[0]);
|
||||
const hour = parseInt(parts[1]);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
enabled: data.enabled || false,
|
||||
hour: isNaN(hour) ? 2 : hour,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
includeFiles: data.includeFiles !== undefined ? data.includeFiles : true,
|
||||
compress: data.compress !== undefined ? data.compress : true,
|
||||
maxCount: data.maxCount || 30,
|
||||
maxAgeDays: data.maxAgeDays || 90,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取自动备份状态失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetching.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post('/api/backup/auto/settings', {
|
||||
enabled: settings.enabled,
|
||||
hour: settings.hour,
|
||||
minute: settings.minute,
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
maxCount: settings.maxCount,
|
||||
maxAgeDays: settings.maxAgeDays,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
message.success('自动备份设置已保存');
|
||||
setModified(false);
|
||||
// 不需要立即刷新,因为保存成功后状态已经是最新的
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存失败:' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteNow = async () => {
|
||||
Modal.confirm({
|
||||
title: (
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '12px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<PlayCircleOutlined style={{ color: '#fff', fontSize: 20 }} />
|
||||
</div>
|
||||
<span>立即执行备份</span>
|
||||
</Space>
|
||||
),
|
||||
content: '确定要立即执行一次备份吗?这将在后台创建一个新的备份文件。',
|
||||
okText: '立即备份',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.post('/api/backup/auto/execute', {
|
||||
description: `手动触发 - ${new Date().toLocaleString('zh-CN')}`,
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
message.success('备份执行成功!');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('执行失败:' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (hour, minute) => {
|
||||
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatNextRun = (nextRun) => {
|
||||
if (!nextRun) return '未知';
|
||||
return new Date(nextRun).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status) return 'default';
|
||||
if (status.enabled && status.isActive) return 'success';
|
||||
if (status.enabled && !status.isActive) return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!status) return '加载中...';
|
||||
if (status.enabled && status.isActive) return '自动备份运行中';
|
||||
if (status.enabled && !status.isActive) return '自动备份已启用但未运行';
|
||||
return '自动备份已禁用';
|
||||
};
|
||||
|
||||
// 状态卡片组件
|
||||
const StatusCard = ({ icon, title, value, subtitle, gradient }) => (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderRadius: '16px',
|
||||
background: gradient,
|
||||
color: '#fff',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minHeight: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', right: -20, top: -20, opacity: 0.1 }}>
|
||||
{React.cloneElement(icon, { style: { fontSize: 120 } })}
|
||||
</div>
|
||||
<div>
|
||||
<Text style={{ fontSize: 13, opacity: 0.9, fontWeight: 500 }}>{title}</Text>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 8 }}>{value}</div>
|
||||
</div>
|
||||
{subtitle && <Text style={{ fontSize: 12, opacity: 0.8 }}>{subtitle}</Text>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 设置项组件
|
||||
const SettingItem = ({ title, description, children, bordered = true }) => (
|
||||
<div style={{
|
||||
padding: '16px 0',
|
||||
borderBottom: bordered ? '1px solid #f0f0f0' : 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 14, color: '#1f2937', marginBottom: 4 }}>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<Paragraph style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
}}>
|
||||
<Spin size="large" tip="加载设置中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/backup')}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
返回备份管理
|
||||
</Button>
|
||||
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '24px 32px',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<div>
|
||||
<Title level={2} style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}>
|
||||
<SettingOutlined style={{ marginRight: 12, verticalAlign: 'middle' }} />
|
||||
自动备份设置
|
||||
</Title>
|
||||
<Paragraph style={{ margin: 0, fontSize: 14, color: '#6b7280' }}>
|
||||
配置定时自动备份,确保数据安全无忧
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handleExecuteNow}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
borderRadius: '12px',
|
||||
padding: '8px 24px',
|
||||
}}
|
||||
>
|
||||
立即备份
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!modified || loading}
|
||||
loading={loading}
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
padding: '8px 24px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
保存设置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Title level={4} style={{ margin: '0 0 16px 0', color: '#1f2937', fontWeight: 600 }}>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
状态概览
|
||||
</Title>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<CheckCircleOutlined />}
|
||||
title="当前状态"
|
||||
value={getStatusText()}
|
||||
gradient={status?.enabled && status.isActive ?
|
||||
'linear-gradient(135deg, #10b981 0%, #34d399 100%)' :
|
||||
'linear-gradient(135deg, #6b7280 0%, #9ca3af 100%)'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<ClockCircleOutlined />}
|
||||
title="下次执行时间"
|
||||
value={formatNextRun(status?.nextRun)}
|
||||
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<CloudDownloadOutlined />}
|
||||
title="备份时间"
|
||||
value={formatTime(settings.hour, settings.minute)}
|
||||
gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatusCard
|
||||
icon={<FileProtectOutlined />}
|
||||
title="保留策略"
|
||||
value={`${settings.maxCount}个 / ${settings.maxAgeDays}天`}
|
||||
gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 设置卡片 */}
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* 基本设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<SettingOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>基本设置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SettingItem
|
||||
title="启用自动备份"
|
||||
description="开启后系统会按照设定时间自动执行备份任务"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, enabled: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
size="default"
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="备份时间"
|
||||
description="每天自动执行备份的时间,建议设置在业务低峰期"
|
||||
>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={23}
|
||||
value={settings.hour}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, hour: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="时"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={59}
|
||||
value={settings.minute}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, minute: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="分"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Space>
|
||||
</SettingItem>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 高级设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>高级设置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SettingItem
|
||||
title="包含文件"
|
||||
description="备份时包含上传的文件(如用户头像等)"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.includeFiles}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, includeFiles: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
checkedChildren="包含"
|
||||
unCheckedChildren="不包含"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="压缩备份"
|
||||
description="使用 gzip 压缩备份文件,可节省约 60-80% 磁盘空间"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.compress}
|
||||
onChange={(checked) => {
|
||||
setSettings({ ...settings, compress: checked });
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
checkedChildren="压缩"
|
||||
unCheckedChildren="不压缩"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="最大备份数量"
|
||||
description="保留最近的备份数量,超过后会自动删除旧备份"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.maxCount}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, maxCount: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="个"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="最长保存天数"
|
||||
description="备份文件的最长保存天数,超过后会自动删除"
|
||||
bordered={false}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.maxAgeDays}
|
||||
onChange={(value) => {
|
||||
setSettings({ ...settings, maxAgeDays: value });
|
||||
setModified(true);
|
||||
}}
|
||||
addonAfter="天"
|
||||
disabled={!settings.enabled}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</SettingItem>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: '#3b82f6' }} />
|
||||
<span style={{ fontWeight: 500 }}>最佳实践建议</span>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>备份时间:</strong>建议设置在凌晨 2-4 点业务低峰期,避免影响正常使用
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>保留策略:</strong>开发环境建议 7 个/7 天,生产环境建议 30 个/90 天
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>压缩备份:</strong>强烈建议开启,可显著减少磁盘空间占用
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<CheckCircleOutlined style={{ color: '#10b981', marginTop: 2, flexShrink: 0 }} />
|
||||
<Text style={{ color: '#4b5563' }}>
|
||||
<strong>定期测试:</strong>每月至少手动执行一次备份,验证功能正常
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #dbeafe',
|
||||
background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
<Space>
|
||||
<SafetyOutlined style={{ color: '#f59e0b' }} />
|
||||
<span style={{ fontWeight: 500 }}>安全提示</span>
|
||||
</Space>
|
||||
}
|
||||
description="自动备份功能需要服务器持续运行。如果服务器重启,自动备份任务会自动恢复。请确保服务器时间设置正确。"
|
||||
type="warning"
|
||||
showIcon={false}
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #fef3c7',
|
||||
background: 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoBackupSettings;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -245,16 +245,17 @@ function ConsumableLogs() {
|
||||
key: 'snList',
|
||||
width: 150,
|
||||
render: (snList, record) => {
|
||||
if (!snList || snList.length === 0) {
|
||||
const snListArray = Array.isArray(snList) ? snList : [];
|
||||
if (!snListArray || snListArray.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
if (record.operationType !== 'in' && record.operationType !== 'out') {
|
||||
return '-';
|
||||
}
|
||||
if (snList.length <= 3) {
|
||||
if (snListArray.length <= 3) {
|
||||
return (
|
||||
<Space size={2} wrap>
|
||||
{snList.map((sn, index) => (
|
||||
{snListArray.map((sn, index) => (
|
||||
<Tag key={index} color="purple" style={{ fontSize: '11px' }}>
|
||||
{sn}
|
||||
</Tag>
|
||||
@@ -264,7 +265,7 @@ function ConsumableLogs() {
|
||||
}
|
||||
const content = (
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
|
||||
{snList.map((sn, index) => (
|
||||
{snListArray.map((sn, index) => (
|
||||
<Tag key={index} color="purple" style={{ marginBottom: '4px', fontSize: '11px' }}>
|
||||
{sn}
|
||||
</Tag>
|
||||
@@ -272,9 +273,9 @@ function ConsumableLogs() {
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} title={`SN列表 (${snList.length}个)`} trigger="click">
|
||||
<Popover content={content} title={`SN列表 (${snListArray.length}个)`} trigger="click">
|
||||
<Tag color="purple" style={{ cursor: 'pointer' }}>
|
||||
{snList.length} 个SN 🔍
|
||||
{snListArray.length} 个SN 🔍
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -715,11 +715,11 @@ function ConsumableManagement() {
|
||||
render: text => <Text type="secondary">{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'SN数量',
|
||||
title: 'SN 数量',
|
||||
key: 'snCount',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const snList = record.snList || [];
|
||||
const snList = Array.isArray(record.snList) ? record.snList : [];
|
||||
const snCount = snList.length;
|
||||
const stock = record.currentStock || 0;
|
||||
|
||||
@@ -744,7 +744,7 @@ function ConsumableManagement() {
|
||||
return (
|
||||
<Popover
|
||||
content={snContent}
|
||||
title={`SN列表 (${snCount}个)`}
|
||||
title={`SN 列表 (${snCount}个)`}
|
||||
trigger="click"
|
||||
placement="right"
|
||||
>
|
||||
@@ -1954,11 +1954,12 @@ function ConsumableManagement() {
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const filteredSnList = stockRecord.snList.filter(sn =>
|
||||
const snListArray = Array.isArray(stockRecord.snList) ? stockRecord.snList : [];
|
||||
const filteredSnList = snListArray.filter(sn =>
|
||||
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
|
||||
);
|
||||
if (filteredSnList.length === 0) {
|
||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的SN</Text>;
|
||||
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的 SN</Text>;
|
||||
}
|
||||
return filteredSnList.map((sn, index) => (
|
||||
<Tag.CheckableTag
|
||||
@@ -2090,7 +2091,7 @@ function ConsumableManagement() {
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
{snList.map((sn, index) => (
|
||||
{(Array.isArray(snList) ? snList : []).map((sn, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
closable
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DatabaseOutlined, CloudServerOutlined, WarningOutlined, HomeOutlined, T
|
||||
import api from '../api';
|
||||
import { designTokens } from '../config/theme';
|
||||
import { useFetch } from '../hooks/useSWR';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import {
|
||||
AnimatedCounter,
|
||||
CircularProgress,
|
||||
@@ -380,14 +381,30 @@ function Dashboard() {
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
percentage={parseFloat(stats.onlineRate)}
|
||||
size={140}
|
||||
strokeWidth={12}
|
||||
color={designTokens.colors.success.main}
|
||||
label="在线率"
|
||||
/>
|
||||
<PowerGauge value={stats.powerUsage} maxValue={10000} />
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
在线率图表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CircularProgress
|
||||
percentage={parseFloat(stats.onlineRate)}
|
||||
size={140}
|
||||
strokeWidth={12}
|
||||
color={designTokens.colors.success.main}
|
||||
label="在线率"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
功率仪表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PowerGauge value={stats.powerUsage} maxValue={10000} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -403,7 +420,15 @@ function Dashboard() {
|
||||
周设备趋势
|
||||
</Title>
|
||||
<div style={chartContainerStyle}>
|
||||
<DeviceTrendChart data={deviceTrendData} />
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
趋势图表加载失败
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DeviceTrendChart data={deviceTrendData} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -308,8 +308,8 @@ function DeviceManagement() {
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await axios.delete('/api/devices/batch-delete', {
|
||||
data: { deviceIds: selectedDevices },
|
||||
const response = await axios.post('/api/devices/batch-delete', {
|
||||
deviceIds: selectedDevices,
|
||||
});
|
||||
message.success(response.data.message || '批量删除成功');
|
||||
setSelectedDevices([]);
|
||||
@@ -318,6 +318,10 @@ function DeviceManagement() {
|
||||
} catch (error) {
|
||||
message.error('批量删除失败');
|
||||
console.error('批量删除设备失败:', error);
|
||||
// 显示更具体的错误信息
|
||||
if (error.response?.data?.error) {
|
||||
console.error('后端错误详情:', error.response.data.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Space } from 'antd';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
// 测试用的会抛出错误的组件
|
||||
const BrokenComponent = () => {
|
||||
throw new Error('这是一个测试错误!');
|
||||
};
|
||||
|
||||
// 测试用的会抛出错误的类组件
|
||||
class BrokenClassComponent extends React.Component {
|
||||
render() {
|
||||
throw new Error('这是一个类组件的测试错误!');
|
||||
}
|
||||
}
|
||||
|
||||
// 测试用的正常组件
|
||||
const NormalComponent = () => (
|
||||
<Card style={{ marginBottom: '16px' }}>
|
||||
<h3>✅ 正常组件</h3>
|
||||
<p>这个组件正常渲染,没有错误</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
function ErrorBoundaryTest() {
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
|
||||
const handleError = (error, errorInfo) => {
|
||||
console.error('错误边界捕获到错误:', error, errorInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>
|
||||
🧪 错误边界测试页面
|
||||
</h1>
|
||||
<p style={{ opacity: 0.9 }}>
|
||||
用于测试 React 错误边界(Error Boundary)功能,确保单个组件错误不会导致整个页面崩溃
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="测试 1:根级别错误边界" size="small">
|
||||
<p>这个测试会触发整个应用级别的错误边界(在 main.jsx 中定义)</p>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => {
|
||||
throw new Error('根级别测试错误!');
|
||||
}}
|
||||
>
|
||||
触发根级别错误
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 2:页面级别错误边界" size="small">
|
||||
<p>这个测试会触发页面级别的错误边界</p>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => setShowError(true)}
|
||||
>
|
||||
触发页面错误
|
||||
</Button>
|
||||
{showError && <BrokenComponent />}
|
||||
</Card>
|
||||
|
||||
<Card title="测试 3:组件级别错误边界(带自定义错误处理)" size="small">
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="组件渲染失败"
|
||||
subTitle="这个组件在渲染时遇到了错误"
|
||||
onError={handleError}
|
||||
>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 4:组件级别错误边界(带自定义 fallback)" size="small">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: '#fff2f0',
|
||||
border: '1px solid #ffccc7',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ color: '#ff4d4f', margin: '0 0 8px 0' }}>
|
||||
⚠️ 自定义错误提示
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: '#666' }}>
|
||||
这是自定义的 fallback UI,当组件出错时会显示这个界面
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrokenClassComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="测试 5:混合正常和错误组件" size="small">
|
||||
<p>验证错误边界只影响出错的组件,不影响其他组件</p>
|
||||
<NormalComponent />
|
||||
<ErrorBoundary>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
<NormalComponent />
|
||||
</Card>
|
||||
|
||||
<Card title="测试 6:3D 可视化错误边界" size="small">
|
||||
<p>模拟 3D 场景加载失败的情况</p>
|
||||
<ErrorBoundary
|
||||
fullPage={false}
|
||||
title="3D 场景加载失败"
|
||||
subTitle="3D 场景在加载过程中遇到错误,可能是浏览器不支持 WebGL 或模型文件加载失败"
|
||||
>
|
||||
<BrokenComponent />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
|
||||
<Card title="使用说明" size="small">
|
||||
<ul style={{ lineHeight: '2' }}>
|
||||
<li>
|
||||
<strong>错误边界(Error Boundary)</strong>:
|
||||
是 React 提供的错误处理机制,可以捕获子组件树中的 JavaScript 错误
|
||||
</li>
|
||||
<li>
|
||||
<strong>作用</strong>:
|
||||
防止单个组件的错误导致整个应用崩溃,提供友好的错误提示界面
|
||||
</li>
|
||||
<li>
|
||||
<strong>实现方式</strong>:
|
||||
使用 React.Component 的 componentDidCatch 和 getDerivedStateFromError 生命周期方法
|
||||
</li>
|
||||
<li>
|
||||
<strong>注意事项</strong>:
|
||||
错误边界无法捕获事件处理器、异步代码、服务端渲染、自身抛出的错误
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundaryTest;
|
||||
@@ -392,7 +392,7 @@ const PendingDeviceManagement = () => {
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`}>
|
||||
{(options || [
|
||||
{(Array.isArray(options) ? options : [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
@@ -417,7 +417,7 @@ const PendingDeviceManagement = () => {
|
||||
rules={required ? [{ required: true, message: `请选择${displayName}` }] : []}
|
||||
>
|
||||
<Select placeholder={`请选择${displayName}`} allowClear>
|
||||
{(options || []).map(opt => (
|
||||
{(Array.isArray(options) ? options : []).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
|
||||
Reference in New Issue
Block a user