feat: 修复BUG
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"cronExpression": "0 20 * * *",
|
||||
"cronExpression": "0 19 * * *",
|
||||
"description": "自动备份",
|
||||
"backupType": "full",
|
||||
"includeFiles": true,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../db');
|
||||
|
||||
const BackupLog = sequelize.define('BackupLog', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
logType: {
|
||||
type: DataTypes.ENUM('auto', 'manual'),
|
||||
allowNull: false,
|
||||
comment: '备份类型:auto自动,manual手动'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'running', 'success', 'failed'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
comment: '状态:pending待执行,running执行中,success成功,failed失败'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: '备份描述'
|
||||
},
|
||||
backupType: {
|
||||
type: DataTypes.ENUM('full', 'incremental'),
|
||||
allowNull: true,
|
||||
comment: '备份类型:full全量,incremental增量'
|
||||
},
|
||||
filename: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: '备份文件名'
|
||||
},
|
||||
filePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: '备份文件路径'
|
||||
},
|
||||
fileSize: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
comment: '文件大小(字节)'
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: '错误信息'
|
||||
},
|
||||
startTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '开始时间'
|
||||
},
|
||||
endTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: '结束时间'
|
||||
},
|
||||
duration: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '执行时长(毫秒)'
|
||||
},
|
||||
includeFiles: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否包含文件'
|
||||
},
|
||||
compressed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否压缩'
|
||||
},
|
||||
remoteUploads: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: '远端上传结果'
|
||||
}
|
||||
}, {
|
||||
tableName: 'backup_logs',
|
||||
timestamps: true,
|
||||
comment: '备份日志表',
|
||||
indexes: [
|
||||
{ fields: ['logType'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['createdAt'] },
|
||||
{ fields: ['logType', 'createdAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = BackupLog;
|
||||
@@ -64,10 +64,12 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', {
|
||||
|
||||
ConsumableRecord.belongsTo(Consumable, {
|
||||
foreignKey: 'consumableId',
|
||||
as: 'consumable',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
Consumable.hasMany(ConsumableRecord, {
|
||||
foreignKey: 'consumableId',
|
||||
as: 'records',
|
||||
onDelete: 'CASCADE'
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const {
|
||||
updateAutoBackupSettings,
|
||||
executeBackupNow,
|
||||
} = require('../utils/autoBackupScheduler');
|
||||
const {
|
||||
getBackupLogs,
|
||||
getBackupLogById,
|
||||
deleteOldLogs,
|
||||
} = require('../utils/backupLog');
|
||||
const {
|
||||
getAllTargets,
|
||||
getTarget,
|
||||
@@ -30,6 +35,7 @@ const {
|
||||
deleteTarget,
|
||||
getGlobalSettings,
|
||||
updateGlobalSettings,
|
||||
getEnabledTargets,
|
||||
} = require('../utils/remoteBackupConfig');
|
||||
const { testRemoteConnection, PROTOCOL_TYPES, PROTOCOL_LABELS } = require('../utils/remoteBackup');
|
||||
|
||||
@@ -446,6 +452,7 @@ router.post('/auto/settings', (req, res) => {
|
||||
compress,
|
||||
maxCount,
|
||||
maxAgeDays,
|
||||
backupType,
|
||||
} = req.body;
|
||||
|
||||
const newSettings = {};
|
||||
@@ -468,6 +475,7 @@ router.post('/auto/settings', (req, res) => {
|
||||
if (compress !== undefined) newSettings.compress = compress;
|
||||
if (maxCount !== undefined) newSettings.maxCount = maxCount;
|
||||
if (maxAgeDays !== undefined) newSettings.maxAgeDays = maxAgeDays;
|
||||
if (backupType !== undefined) newSettings.backupType = backupType;
|
||||
|
||||
const success = updateAutoBackupSettings(newSettings);
|
||||
if (success) {
|
||||
@@ -496,12 +504,13 @@ router.post('/auto/settings', (req, res) => {
|
||||
// 立即执行备份
|
||||
router.post('/auto/execute', async (req, res) => {
|
||||
try {
|
||||
const { description, includeFiles, compress } = req.body;
|
||||
const { description, includeFiles, compress, backupType } = req.body;
|
||||
|
||||
const result = await executeBackupNow({
|
||||
description: description || '手动触发备份',
|
||||
includeFiles,
|
||||
compress,
|
||||
backupType,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -854,4 +863,80 @@ router.post('/remote/upload', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 备份日志接口 ====================
|
||||
|
||||
// 获取备份日志列表
|
||||
router.get('/logs', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, logType, status } = req.query;
|
||||
|
||||
const result = await getBackupLogs({
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
logType,
|
||||
status,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取备份日志失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取备份日志失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取备份日志详情
|
||||
router.get('/logs/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const log = await getBackupLogById(parseInt(id));
|
||||
|
||||
if (!log) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份日志不存在',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: log,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取备份日志详情失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取备份日志详情失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 清理旧日志
|
||||
router.delete('/logs/clean', async (req, res) => {
|
||||
try {
|
||||
const { days = 30 } = req.body;
|
||||
const deletedCount = await deleteOldLogs(parseInt(days));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '清理完成',
|
||||
data: { deletedCount },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('清理旧日志失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '清理旧日志失败',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -34,7 +34,7 @@ router.get('/', async (req, res) => {
|
||||
const { count, rows } = await ConsumableRecord.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{ model: Consumable, attributes: ['name', 'category', 'unit'] }
|
||||
{ model: Consumable, as: 'consumable', attributes: ['name', 'category', 'unit'] }
|
||||
],
|
||||
offset,
|
||||
limit: parseInt(pageSize),
|
||||
@@ -123,17 +123,36 @@ router.post('/', async (req, res) => {
|
||||
|
||||
router.get('/statistics', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
const { startDate, endDate, category } = req.query;
|
||||
|
||||
const dateWhere = {};
|
||||
if (startDate && endDate) {
|
||||
// 修复日期范围查询:startDate 从 00:00:00 开始,endDate 到 23:59:59 结束
|
||||
const startDateTime = new Date(startDate);
|
||||
const endDateTime = new Date(endDate);
|
||||
endDateTime.setHours(23, 59, 59, 999); // 设置 endDate 为当天最后一刻
|
||||
|
||||
dateWhere.createdAt = {
|
||||
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||
[Op.gte]: startDateTime,
|
||||
[Op.lte]: endDateTime
|
||||
};
|
||||
}
|
||||
|
||||
const consumableWhere = {};
|
||||
if (category) {
|
||||
consumableWhere.category = category;
|
||||
}
|
||||
|
||||
const records = await ConsumableRecord.findAll({
|
||||
where: dateWhere,
|
||||
include: [
|
||||
{
|
||||
model: Consumable,
|
||||
as: 'consumable',
|
||||
attributes: ['name', 'category'],
|
||||
where: Object.keys(consumableWhere).length > 0 ? consumableWhere : undefined
|
||||
}
|
||||
],
|
||||
attributes: ['type', 'quantity']
|
||||
});
|
||||
|
||||
@@ -163,7 +182,12 @@ router.get('/statistics', async (req, res) => {
|
||||
const recentRecords = await ConsumableRecord.findAll({
|
||||
where: dateWhere,
|
||||
include: [
|
||||
{ model: Consumable, attributes: ['name', 'category'] }
|
||||
{
|
||||
model: Consumable,
|
||||
as: 'consumable',
|
||||
attributes: ['name', 'category'],
|
||||
where: Object.keys(consumableWhere).length > 0 ? consumableWhere : undefined
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 10
|
||||
@@ -180,7 +204,17 @@ router.get('/statistics', async (req, res) => {
|
||||
totalQuantity: data.totalQuantity,
|
||||
count: data.count
|
||||
})),
|
||||
recentRecords
|
||||
recentRecords: recentRecords.map(record => ({
|
||||
recordId: record.recordId,
|
||||
type: record.type,
|
||||
quantity: record.quantity,
|
||||
operator: record.operator,
|
||||
createdAt: record.createdAt,
|
||||
consumableId: record.consumableId,
|
||||
consumableName: record.consumable?.name || '未知耗材',
|
||||
category: record.consumable?.category || null,
|
||||
unit: record.consumable?.unit || '个'
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
||||
@@ -223,12 +223,17 @@ router.get('/low-stock', async (req, res) => {
|
||||
try {
|
||||
const consumables = await Consumable.findAll({
|
||||
where: {
|
||||
status: 'active',
|
||||
[Op.and]: [
|
||||
sequelize.where(sequelize.col('currentStock'), {
|
||||
[Op.lte]: sequelize.col('minStock')
|
||||
}),
|
||||
sequelize.where(sequelize.col('minStock'), {
|
||||
[Op.gt]: 0
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
order: [['currentStock', 'ASC']]
|
||||
});
|
||||
res.json(consumables);
|
||||
} catch (error) {
|
||||
@@ -239,35 +244,41 @@ router.get('/low-stock', async (req, res) => {
|
||||
router.get('/statistics/summary', async (req, res) => {
|
||||
try {
|
||||
const consumables = await Consumable.findAll({
|
||||
attributes: ['currentStock', 'unitPrice', 'category']
|
||||
attributes: ['currentStock', 'unitPrice', 'category', 'minStock', 'status']
|
||||
});
|
||||
|
||||
let total = consumables.length;
|
||||
let total = 0;
|
||||
let lowStock = 0;
|
||||
let totalValue = 0;
|
||||
const categoryMap = {};
|
||||
|
||||
consumables.forEach(item => {
|
||||
if (item.status === 'inactive') return;
|
||||
|
||||
total++;
|
||||
const currentStock = parseFloat(item.currentStock) || 0;
|
||||
const unitPrice = parseFloat(item.unitPrice) || 0;
|
||||
const minStockValue = parseFloat(item.minStock) || 0;
|
||||
|
||||
totalValue += currentStock * unitPrice;
|
||||
|
||||
if (currentStock <= (parseFloat(item.minStock) || 0)) {
|
||||
if (minStockValue > 0 && currentStock <= minStockValue) {
|
||||
lowStock++;
|
||||
}
|
||||
|
||||
if (item.category) {
|
||||
if (!categoryMap[item.category]) {
|
||||
categoryMap[item.category] = 0;
|
||||
categoryMap[item.category] = { count: 0, totalQuantity: 0 };
|
||||
}
|
||||
categoryMap[item.category]++;
|
||||
categoryMap[item.category].count++;
|
||||
categoryMap[item.category].totalQuantity += currentStock;
|
||||
}
|
||||
});
|
||||
|
||||
const byCategory = Object.entries(categoryMap).map(([category, count]) => ({
|
||||
const byCategory = Object.entries(categoryMap).map(([category, data]) => ({
|
||||
category,
|
||||
count
|
||||
count: data.count,
|
||||
totalQuantity: data.totalQuantity
|
||||
}));
|
||||
|
||||
res.json({
|
||||
@@ -292,7 +303,7 @@ router.get('/inout/records', async (req, res) => {
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [{
|
||||
model: Consumable,
|
||||
as: 'Consumable',
|
||||
as: 'consumable',
|
||||
attributes: ['consumableId', 'name', 'category']
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -60,6 +60,68 @@ router.get('/device/:deviceId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId/with-ports', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const networkCards = await NetworkCard.findAll({
|
||||
where: { deviceId },
|
||||
order: [['slotNumber', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
const cardsWithPorts = await Promise.all(
|
||||
networkCards.map(async (card) => {
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { nicId: card.nicId },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
const freeCount = ports.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = ports.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = ports.filter(p => p.status === 'fault').length;
|
||||
|
||||
return {
|
||||
...card.toJSON(),
|
||||
ports,
|
||||
stats: {
|
||||
total: ports.length,
|
||||
free: freeCount,
|
||||
occupied: occupiedCount,
|
||||
fault: faultCount
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const ungroupedPorts = await DevicePort.findAll({
|
||||
where: { deviceId, nicId: null },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
if (ungroupedPorts.length > 0) {
|
||||
cardsWithPorts.push({
|
||||
nicId: '_ungrouped',
|
||||
name: '未分组端口',
|
||||
description: '未分配到网卡的端口',
|
||||
portCount: ungroupedPorts.length,
|
||||
isUngrouped: true,
|
||||
ports: ungroupedPorts,
|
||||
stats: {
|
||||
total: ungroupedPorts.length,
|
||||
free: ungroupedPorts.filter(p => p.status === 'free').length,
|
||||
occupied: ungroupedPorts.filter(p => p.status === 'occupied').length,
|
||||
fault: ungroupedPorts.filter(p => p.status === 'fault').length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json(cardsWithPorts);
|
||||
} catch (error) {
|
||||
console.error('获取网卡及端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:nicId', async (req, res) => {
|
||||
try {
|
||||
const networkCard = await NetworkCard.findByPk(req.params.nicId, {
|
||||
@@ -198,66 +260,4 @@ router.delete('/:nicId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/device/:deviceId/with-ports', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
const networkCards = await NetworkCard.findAll({
|
||||
where: { deviceId },
|
||||
order: [['slotNumber', 'ASC'], ['name', 'ASC']]
|
||||
});
|
||||
|
||||
const cardsWithPorts = await Promise.all(
|
||||
networkCards.map(async (card) => {
|
||||
const ports = await DevicePort.findAll({
|
||||
where: { nicId: card.nicId },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
const freeCount = ports.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = ports.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = ports.filter(p => p.status === 'fault').length;
|
||||
|
||||
return {
|
||||
...card.toJSON(),
|
||||
ports,
|
||||
stats: {
|
||||
total: ports.length,
|
||||
free: freeCount,
|
||||
occupied: occupiedCount,
|
||||
fault: faultCount
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const ungroupedPorts = await DevicePort.findAll({
|
||||
where: { deviceId, nicId: null },
|
||||
order: [['portName', 'ASC']]
|
||||
});
|
||||
|
||||
if (ungroupedPorts.length > 0) {
|
||||
cardsWithPorts.push({
|
||||
nicId: '_ungrouped',
|
||||
name: '未分组端口',
|
||||
description: '未分配到网卡的端口',
|
||||
portCount: ungroupedPorts.length,
|
||||
isUngrouped: true,
|
||||
ports: ungroupedPorts,
|
||||
stats: {
|
||||
total: ungroupedPorts.length,
|
||||
free: ungroupedPorts.filter(p => p.status === 'free').length,
|
||||
occupied: ungroupedPorts.filter(p => p.status === 'occupied').length,
|
||||
fault: ungroupedPorts.filter(p => p.status === 'fault').length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json(cardsWithPorts);
|
||||
} catch (error) {
|
||||
console.error('获取网卡及端口失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -78,6 +78,12 @@ async function syncInventoryModels() {
|
||||
console.log('盘点模型同步完成');
|
||||
}
|
||||
|
||||
async function syncBackupLogModel() {
|
||||
const BackupLog = require('./models/BackupLog');
|
||||
await BackupLog.sync();
|
||||
console.log('备份日志模型同步完成');
|
||||
}
|
||||
|
||||
async function initDefaultSystemSettings() {
|
||||
console.log('开始初始化系统设置默认值...');
|
||||
const { initDefaultSettings } = require('./routes/systemSettings');
|
||||
@@ -137,6 +143,7 @@ async function initializeApp() {
|
||||
await syncSystemSettings();
|
||||
await syncConsumableModels();
|
||||
await syncInventoryModels();
|
||||
await syncBackupLogModel();
|
||||
await initDefaultSystemSettings();
|
||||
await initFaultCategories();
|
||||
await initAutoBackupScheduler();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* 自动备份调度器模块
|
||||
* 使用 node-cron 实现定时自动备份功能
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const path = require('path');
|
||||
@@ -9,28 +5,23 @@ const fs = require('fs');
|
||||
const { createBackup, createIncrementalBackup, getBackupPath } = require('./backup');
|
||||
const { uploadToRemote } = require('./remoteBackup');
|
||||
const { getEnabledTargets, getGlobalSettings } = require('./remoteBackupConfig');
|
||||
const { createLogEntry, updateLogStatus } = require('./backupLog');
|
||||
|
||||
// 全局调度器存储
|
||||
const schedulers = new Map();
|
||||
|
||||
// 备份设置文件路径
|
||||
const SETTINGS_FILE = path.join(__dirname, '..', 'config', 'auto-backup-settings.json');
|
||||
|
||||
// 默认设置
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: false,
|
||||
cronExpression: '0 2 * * *', // 每天凌晨 2 点
|
||||
cronExpression: '0 2 * * *',
|
||||
description: '自动备份',
|
||||
backupType: 'full', // 'full' 或 'incremental'
|
||||
backupType: 'full',
|
||||
includeFiles: true,
|
||||
compress: true,
|
||||
maxCount: 30,
|
||||
maxAgeDays: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载备份设置
|
||||
*/
|
||||
function loadSettings() {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
@@ -43,9 +34,6 @@ function loadSettings() {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存备份设置
|
||||
*/
|
||||
function saveSettings(settings) {
|
||||
try {
|
||||
const configDir = path.dirname(SETTINGS_FILE);
|
||||
@@ -60,23 +48,46 @@ function saveSettings(settings) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Cron 表达式
|
||||
*/
|
||||
function validateCronExpression(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文时间转换为 Cron 表达式
|
||||
*/
|
||||
function timeToCron(hour, minute) {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自动备份任务
|
||||
*/
|
||||
function calculateNextRun(cronExpression) {
|
||||
try {
|
||||
const parts = cronExpression.split(' ');
|
||||
const minute = parseInt(parts[0]) || 0;
|
||||
const hour = parseInt(parts[1]) || 0;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
return next.toLocaleString('zh-CN');
|
||||
} catch (error) {
|
||||
return '计算失败';
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSize(filePath) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
return stats.size;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文件大小失败:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAutoBackupTask(settings) {
|
||||
const { cronExpression, description, backupType, includeFiles, compress, maxCount, maxAgeDays } = settings;
|
||||
|
||||
@@ -84,18 +95,44 @@ function createAutoBackupTask(settings) {
|
||||
throw new Error('无效的 Cron 表达式');
|
||||
}
|
||||
|
||||
// 如果已有调度器,先停止
|
||||
console.log('=== 创建自动备份任务 ===');
|
||||
console.log('Cron表达式:', cronExpression);
|
||||
console.log('备份类型:', backupType);
|
||||
console.log('下次执行:', calculateNextRun(cronExpression));
|
||||
|
||||
if (schedulers.has('auto-backup')) {
|
||||
console.log('停止已存在的调度器...');
|
||||
stopAutoBackup();
|
||||
}
|
||||
|
||||
// 创建新的调度器
|
||||
const task = cron.schedule(cronExpression, async () => {
|
||||
console.log('=== 开始执行自动备份 ===');
|
||||
console.log('创建新调度器...');
|
||||
const task = cron.schedule(cronExpression, async function() {
|
||||
console.log('');
|
||||
console.log('============================================');
|
||||
console.log('=== 自动备份任务触发 ===');
|
||||
console.log('触发时间:', new Date().toLocaleString('zh-CN'));
|
||||
console.log('============================================');
|
||||
|
||||
let logId = null;
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
|
||||
// 根据备份类型选择函数
|
||||
console.log('创建备份日志...');
|
||||
const log = await createLogEntry({
|
||||
logType: 'auto',
|
||||
description: `${description} - ${timestamp}`,
|
||||
backupType: backupType,
|
||||
includeFiles: includeFiles,
|
||||
compressed: compress
|
||||
});
|
||||
logId = log ? log.id : null;
|
||||
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'running');
|
||||
}
|
||||
|
||||
console.log('准备执行备份...');
|
||||
const backupFunction = backupType === 'incremental' ? createIncrementalBackup : createBackup;
|
||||
|
||||
const result = await backupFunction({
|
||||
@@ -109,33 +146,53 @@ function createAutoBackupTask(settings) {
|
||||
|
||||
if (result) {
|
||||
console.log('自动备份完成:', result.filename);
|
||||
console.log(`备份类型:${result.isIncremental ? '增量备份' : '全量备份'}`);
|
||||
console.log('备份类型:', result.isIncremental ? '增量备份' : '全量备份');
|
||||
|
||||
// 上传到远端
|
||||
await uploadToRemoteTargets(result.path, result.filename);
|
||||
const fileSize = getFileSize(result.path);
|
||||
|
||||
console.log('========================\n');
|
||||
const uploadResults = await uploadToRemoteTargets(result.path, result.filename);
|
||||
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'success', {
|
||||
filename: result.filename,
|
||||
filePath: result.path,
|
||||
fileSize: fileSize,
|
||||
remoteUploads: uploadResults
|
||||
});
|
||||
}
|
||||
|
||||
console.log('============================================\n');
|
||||
} else {
|
||||
console.log('无数据变化,跳过备份');
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'success', {
|
||||
errorMessage: '无数据变化,跳过备份'
|
||||
});
|
||||
}
|
||||
console.log('============================================\n');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动备份失败:', error);
|
||||
console.error('========================\n');
|
||||
console.error('错误堆栈:', error.stack);
|
||||
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'failed', {
|
||||
errorMessage: error.message || '未知错误'
|
||||
});
|
||||
}
|
||||
|
||||
console.error('============================================\n');
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'Asia/Shanghai', // 设置时区为中国时区
|
||||
timezone: 'Asia/Shanghai'
|
||||
});
|
||||
|
||||
schedulers.set('auto-backup', task);
|
||||
console.log(`自动备份任务已启动,Cron 表达式:${cronExpression}, 备份类型:${backupType}`);
|
||||
console.log('自动备份任务已成功创建并启动');
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动备份
|
||||
*/
|
||||
function startAutoBackup(settings = null) {
|
||||
if (!settings) {
|
||||
settings = loadSettings();
|
||||
@@ -155,9 +212,6 @@ function startAutoBackup(settings = null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动备份
|
||||
*/
|
||||
function stopAutoBackup() {
|
||||
if (schedulers.has('auto-backup')) {
|
||||
const task = schedulers.get('auto-backup');
|
||||
@@ -169,17 +223,12 @@ function stopAutoBackup() {
|
||||
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);
|
||||
|
||||
@@ -207,28 +256,21 @@ function getAutoBackupStatus() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新自动备份设置
|
||||
*/
|
||||
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 {
|
||||
@@ -240,9 +282,6 @@ function updateAutoBackupSettings(newSettings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传备份到所有启用的远端目标
|
||||
*/
|
||||
async function uploadToRemoteTargets(localFilePath, filename) {
|
||||
const globalSettings = getGlobalSettings();
|
||||
|
||||
@@ -262,9 +301,9 @@ async function uploadToRemoteTargets(localFilePath, filename) {
|
||||
|
||||
for (const target of enabledTargets) {
|
||||
try {
|
||||
console.log(`开始上传到目标:${target.name} (${target.protocol})`);
|
||||
console.log('开始上传到目标:' + target.name + ' (' + target.protocol + ')');
|
||||
|
||||
const remotePath = `${target.prefix || 'backups/'}${filename}`;
|
||||
const remotePath = (target.prefix || 'backups/') + filename;
|
||||
|
||||
const result = await uploadToRemote(target, localFilePath, remotePath);
|
||||
|
||||
@@ -276,9 +315,9 @@ async function uploadToRemoteTargets(localFilePath, filename) {
|
||||
...result,
|
||||
});
|
||||
|
||||
console.log(`上传到 ${target.name} 成功`);
|
||||
console.log('上传到 ' + target.name + ' 成功');
|
||||
} catch (error) {
|
||||
console.error(`上传到 ${target.name} 失败:`, error.message);
|
||||
console.error('上传到 ' + target.name + ' 失败:', error.message);
|
||||
uploadResults.push({
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
@@ -289,7 +328,6 @@ async function uploadToRemoteTargets(localFilePath, filename) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要删除本地文件
|
||||
const settings = getGlobalSettings();
|
||||
if (settings.deleteLocalAfterUpload && uploadResults.every(r => r.success)) {
|
||||
try {
|
||||
@@ -303,14 +341,34 @@ async function uploadToRemoteTargets(localFilePath, filename) {
|
||||
return uploadResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即执行一次备份
|
||||
*/
|
||||
async function executeBackupNow(options = {}) {
|
||||
console.log('');
|
||||
console.log('============================================');
|
||||
console.log('=== 手动触发备份 ===');
|
||||
console.log('============================================');
|
||||
|
||||
let logId = null;
|
||||
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const result = await createBackup({
|
||||
const backupType = options.backupType || settings.backupType || 'full';
|
||||
|
||||
console.log('创建备份日志...');
|
||||
const log = await createLogEntry({
|
||||
logType: 'manual',
|
||||
description: options.description || '手动备份',
|
||||
backupType: backupType,
|
||||
includeFiles: options.includeFiles !== undefined ? options.includeFiles : settings.includeFiles,
|
||||
compressed: options.compress !== undefined ? options.compress : settings.compress
|
||||
});
|
||||
logId = log ? log.id : null;
|
||||
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'running');
|
||||
}
|
||||
|
||||
const backupFunction = backupType === 'incremental' ? createIncrementalBackup : createBackup;
|
||||
const result = await backupFunction({
|
||||
description: options.description || '手动备份',
|
||||
includeFiles: options.includeFiles !== undefined ? options.includeFiles : settings.includeFiles,
|
||||
compress: options.compress !== undefined ? options.compress : settings.compress,
|
||||
@@ -321,10 +379,20 @@ async function executeBackupNow(options = {}) {
|
||||
|
||||
console.log('手动备份完成:', result.filename);
|
||||
|
||||
// 上传到远端
|
||||
const fileSize = getFileSize(result.path);
|
||||
|
||||
const uploadResults = await uploadToRemoteTargets(result.path, result.filename);
|
||||
|
||||
console.log('====================\n');
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'success', {
|
||||
filename: result.filename,
|
||||
filePath: result.path,
|
||||
fileSize: fileSize,
|
||||
remoteUploads: uploadResults
|
||||
});
|
||||
}
|
||||
|
||||
console.log('============================================\n');
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
@@ -332,24 +400,38 @@ async function executeBackupNow(options = {}) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('手动备份失败:', error);
|
||||
console.error('====================\n');
|
||||
console.error('============================================\n');
|
||||
|
||||
if (logId) {
|
||||
await updateLogStatus(logId, 'failed', {
|
||||
errorMessage: error.message || '未知错误'
|
||||
});
|
||||
}
|
||||
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动备份(服务器启动时调用)
|
||||
*/
|
||||
function initAutoBackup() {
|
||||
console.log('初始化自动备份...');
|
||||
console.log('');
|
||||
console.log('============================================');
|
||||
console.log('=== 初始化自动备份调度器 ===');
|
||||
console.log('============================================');
|
||||
|
||||
const settings = loadSettings();
|
||||
|
||||
console.log('当前设置:');
|
||||
console.log(' 启用:', settings.enabled ? '是' : '否');
|
||||
console.log(' Cron表达式:', settings.cronExpression);
|
||||
console.log(' 备份类型:', settings.backupType);
|
||||
|
||||
if (settings.enabled) {
|
||||
startAutoBackup(settings);
|
||||
} else {
|
||||
console.log('自动备份当前为禁用状态');
|
||||
}
|
||||
|
||||
console.log('============================================\n');
|
||||
return getAutoBackupStatus();
|
||||
}
|
||||
|
||||
@@ -365,3 +447,4 @@ module.exports = {
|
||||
executeBackupNow,
|
||||
initAutoBackup,
|
||||
};
|
||||
|
||||
|
||||
+165
-26
@@ -225,11 +225,15 @@ function ensureBackupDir(backupPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAllData() {
|
||||
async function collectAllData(tableNames = null) {
|
||||
const data = {};
|
||||
let totalRecords = 0;
|
||||
|
||||
for (const config of BACKUP_MODELS_CONFIG) {
|
||||
const configsToProcess = tableNames
|
||||
? BACKUP_MODELS_CONFIG.filter(c => tableNames.includes(c.name))
|
||||
: BACKUP_MODELS_CONFIG;
|
||||
|
||||
for (const config of configsToProcess) {
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
const records = await Model.findAll({ raw: true });
|
||||
@@ -545,7 +549,10 @@ async function validateBackupFile(filePath, options = {}) {
|
||||
return { valid: false, error: '备份文件缺少版本信息' };
|
||||
}
|
||||
|
||||
if (!backupData.data) {
|
||||
const isIncremental = backupData.backupType === 'incremental';
|
||||
const dataToValidate = isIncremental ? backupData.fullData : backupData.data;
|
||||
|
||||
if (!dataToValidate && !isIncremental) {
|
||||
return { valid: false, error: '备份文件缺少数据内容' };
|
||||
}
|
||||
|
||||
@@ -559,22 +566,57 @@ async function validateBackupFile(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 详细的表信息统计
|
||||
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;
|
||||
|
||||
if (isIncremental) {
|
||||
if (backupData.fullData) {
|
||||
for (const tableName of Object.keys(backupData.fullData)) {
|
||||
if (Array.isArray(backupData.fullData[tableName])) {
|
||||
const recordCount = backupData.fullData[tableName].length;
|
||||
tableDetails[tableName] = {
|
||||
recordCount,
|
||||
hasData: recordCount > 0,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
type: 'full',
|
||||
};
|
||||
totalRecords += recordCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backupData.incrementalData) {
|
||||
for (const tableName of Object.keys(backupData.incrementalData)) {
|
||||
const inc = backupData.incrementalData[tableName];
|
||||
const newCount = inc.new?.length || 0;
|
||||
const updatedCount = inc.updated?.length || 0;
|
||||
if (newCount > 0 || updatedCount > 0) {
|
||||
tableDetails[tableName] = {
|
||||
...tableDetails[tableName],
|
||||
newCount,
|
||||
updatedCount,
|
||||
recordCount: (tableDetails[tableName]?.recordCount || 0) + newCount + updatedCount,
|
||||
hasData: true,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
type: tableDetails[tableName] ? 'both' : 'incremental',
|
||||
};
|
||||
totalRecords += newCount + updatedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
@@ -589,6 +631,19 @@ async function validateBackupFile(filePath, options = {}) {
|
||||
})) || [],
|
||||
};
|
||||
|
||||
const metadata = isIncremental ? {
|
||||
tableCount: Object.keys(backupData.fullData || {}).length,
|
||||
incrementalTableCount: Object.keys(backupData.incrementalData || {}).length,
|
||||
totalRecords,
|
||||
totalChangedRecords: backupData.metadata?.totalChangedRecords || totalRecords,
|
||||
fileCount: fileDetails.total,
|
||||
lastBackupTime: backupData.lastBackupTime,
|
||||
} : {
|
||||
tableCount: Object.keys(backupData.data).length,
|
||||
totalRecords,
|
||||
fileCount: fileDetails.total,
|
||||
};
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version: backupData.version,
|
||||
@@ -597,12 +652,7 @@ async function validateBackupFile(filePath, options = {}) {
|
||||
description: backupData.description,
|
||||
compressed: backupData.compressed,
|
||||
systemInfo: backupData.systemInfo,
|
||||
metadata: {
|
||||
tableCount: Object.keys(backupData.data).length,
|
||||
totalRecords,
|
||||
fileCount: fileDetails.total,
|
||||
},
|
||||
// 详细信息
|
||||
metadata,
|
||||
details: {
|
||||
tables: tableDetails,
|
||||
files: fileDetails,
|
||||
@@ -623,9 +673,17 @@ async function restoreData(backupData, options = {}) {
|
||||
recordsRestored: 0,
|
||||
errors: [],
|
||||
skipped: [],
|
||||
tableDetails: {}, // 每个表的详细恢复信息
|
||||
tableDetails: {},
|
||||
};
|
||||
|
||||
const isIncremental = backupData.backupType === 'incremental';
|
||||
const dataToRestore = isIncremental ? backupData.fullData : backupData.data;
|
||||
|
||||
if (!dataToRestore) {
|
||||
results.errors.push({ error: '备份数据为空' });
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const tableName of RESTORE_ORDER) {
|
||||
if (skipTables.includes(tableName)) {
|
||||
results.skipped.push(tableName);
|
||||
@@ -633,7 +691,7 @@ async function restoreData(backupData, options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableData = backupData.data[tableName];
|
||||
const tableData = dataToRestore[tableName];
|
||||
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
|
||||
onProgress(tableName, 'empty');
|
||||
continue;
|
||||
@@ -652,11 +710,9 @@ async function restoreData(backupData, options = {}) {
|
||||
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 {
|
||||
@@ -701,7 +757,6 @@ async function restoreData(backupData, options = {}) {
|
||||
results.tablesRestored++;
|
||||
results.recordsRestored += insertedCount;
|
||||
|
||||
// 记录每个表的详细信息
|
||||
results.tableDetails[tableName] = {
|
||||
recordCount: insertedCount,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
@@ -715,6 +770,89 @@ async function restoreData(backupData, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isIncremental && backupData.incrementalData) {
|
||||
console.log('\n恢复增量数据...');
|
||||
const incrementalResults = await restoreIncrementalData(backupData.incrementalData, options);
|
||||
results.tablesRestored += incrementalResults.tablesRestored;
|
||||
results.recordsRestored += incrementalResults.recordsRestored;
|
||||
results.errors.push(...incrementalResults.errors);
|
||||
Object.assign(results.tableDetails, incrementalResults.tableDetails);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function restoreIncrementalData(incrementalData, options = {}) {
|
||||
const results = {
|
||||
tablesRestored: 0,
|
||||
recordsRestored: 0,
|
||||
errors: [],
|
||||
tableDetails: {},
|
||||
};
|
||||
|
||||
for (const tableName of Object.keys(incrementalData)) {
|
||||
const tableIncrement = incrementalData[tableName];
|
||||
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);
|
||||
let updatedCount = 0;
|
||||
|
||||
if (tableIncrement.new && tableIncrement.new.length > 0) {
|
||||
for (const record of tableIncrement.new) {
|
||||
try {
|
||||
await Model.create(record, { validate: false, silent: true });
|
||||
updatedCount++;
|
||||
} catch (insertError) {
|
||||
if (insertError.name === 'SequelizeUniqueConstraintError') {
|
||||
await Model.upsert(record, { validate: false, silent: true });
|
||||
updatedCount++;
|
||||
} else {
|
||||
results.errors.push({
|
||||
table: tableName,
|
||||
record: record[Object.keys(record)[0]],
|
||||
error: `新增失败: ${insertError.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tableIncrement.updated && tableIncrement.updated.length > 0) {
|
||||
for (const record of tableIncrement.updated) {
|
||||
try {
|
||||
await Model.upsert(record, { validate: false, silent: true });
|
||||
updatedCount++;
|
||||
} catch (updateError) {
|
||||
results.errors.push({
|
||||
table: tableName,
|
||||
record: record[Object.keys(record)[0]],
|
||||
error: `更新失败: ${updateError.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
results.tablesRestored++;
|
||||
results.recordsRestored += updatedCount;
|
||||
results.tableDetails[tableName] = {
|
||||
recordCount: updatedCount,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
success: true,
|
||||
isIncremental: true,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push({ table: tableName, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -855,7 +993,7 @@ function cleanOldBackups(options = {}) {
|
||||
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')))
|
||||
.filter(f => (f.startsWith('backup_') || f.startsWith('uploaded_') || f.startsWith('incremental_')) && (f.endsWith('.json') || f.endsWith('.json.gz')))
|
||||
.map(f => {
|
||||
const filePath = path.join(backupPath, f);
|
||||
const stats = fs.statSync(filePath);
|
||||
@@ -928,6 +1066,7 @@ module.exports = {
|
||||
getLastBackupTime,
|
||||
validateBackupFile,
|
||||
restoreData,
|
||||
restoreIncrementalData,
|
||||
restoreFiles,
|
||||
restoreBackup,
|
||||
calculateChecksum,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
|
||||
const BackupLog = require('../models/BackupLog');
|
||||
const fs = require('fs');
|
||||
|
||||
async function createLogEntry(options) {
|
||||
const { logType, description, backupType, includeFiles, compressed } = options;
|
||||
|
||||
try {
|
||||
const log = await BackupLog.create({
|
||||
logType: logType || 'manual',
|
||||
status: 'pending',
|
||||
description: description || '',
|
||||
backupType: backupType || 'full',
|
||||
includeFiles: includeFiles || false,
|
||||
compressed: compressed || false,
|
||||
startTime: new Date()
|
||||
});
|
||||
return log;
|
||||
} catch (error) {
|
||||
console.error('创建备份日志失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLogStatus(logId, status, options = {}) {
|
||||
try {
|
||||
const updateData = { status };
|
||||
|
||||
if (status === 'running') {
|
||||
updateData.startTime = new Date();
|
||||
}
|
||||
|
||||
if (status === 'success' || status === 'failed') {
|
||||
updateData.endTime = new Date();
|
||||
|
||||
const log = await BackupLog.findByPk(logId);
|
||||
if (log && log.startTime) {
|
||||
updateData.duration = new Date() - new Date(log.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.filename) updateData.filename = options.filename;
|
||||
if (options.filePath) updateData.filePath = options.filePath;
|
||||
if (options.fileSize) updateData.fileSize = options.fileSize;
|
||||
if (options.errorMessage) updateData.errorMessage = options.errorMessage;
|
||||
if (options.remoteUploads) updateData.remoteUploads = options.remoteUploads;
|
||||
|
||||
await BackupLog.update(updateData, {
|
||||
where: { id: logId }
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('更新备份日志失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBackupLogs(options = {}) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, logType, status } = options;
|
||||
const where = {};
|
||||
|
||||
if (logType) where.logType = logType;
|
||||
if (status) where.status = status;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const { count, rows } = await BackupLog.findAndCountAll({
|
||||
where,
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: pageSize,
|
||||
offset
|
||||
});
|
||||
|
||||
return {
|
||||
logs: rows,
|
||||
total: count,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(count / pageSize)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取备份日志失败:', error);
|
||||
return { logs: [], total: 0, page: 1, pageSize: 20, totalPages: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function getBackupLogById(id) {
|
||||
try {
|
||||
return await BackupLog.findByPk(id);
|
||||
} catch (error) {
|
||||
console.error('获取备份日志详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOldLogs(days = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
const deletedCount = await BackupLog.destroy({
|
||||
where: {
|
||||
createdAt: {
|
||||
[require('sequelize').Op.lt]: cutoffDate
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`删除了 ${deletedCount} 条旧备份日志`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
console.error('删除旧备份日志失败:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLogEntry,
|
||||
updateLogStatus,
|
||||
getBackupLogs,
|
||||
getBackupLogById,
|
||||
deleteOldLogs
|
||||
};
|
||||
@@ -334,7 +334,7 @@ const AppLayout = ({ children }) => {
|
||||
{
|
||||
key: 'pending-devices',
|
||||
icon: <CloudUploadOutlined style={{ fontSize: '16px' }} />,
|
||||
label: <Link to="/pending-devices">暂存设备</Link>,
|
||||
label: <Link to="/pending-devices">盘盈设备</Link>,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import api from './index';
|
||||
|
||||
const cacheManager = (() => {
|
||||
const cache = new Map();
|
||||
const cacheTimestamps = new Map();
|
||||
@@ -271,6 +273,11 @@ export const consumableLogAPI = {
|
||||
import: data => cachedAPI.post('/consumables/logs/import', data),
|
||||
};
|
||||
|
||||
export const consumableRecordAPI = {
|
||||
list: params => cachedAPI.get('/consumable-records', params),
|
||||
statistics: params => api.get('/consumable-records/statistics', { params }),
|
||||
};
|
||||
|
||||
export const ticketCategoryAPI = {
|
||||
list: params => cachedAPI.get('/ticket-categories', params),
|
||||
create: data => cachedAPI.post('/ticket-categories', data),
|
||||
|
||||
@@ -168,6 +168,13 @@ export const backupAPI = {
|
||||
});
|
||||
},
|
||||
info: () => api.get('/backup/info'),
|
||||
getAutoStatus: () => api.get('/backup/auto/status'),
|
||||
updateAutoSettings: data => api.post('/backup/auto/settings', data),
|
||||
executeNow: data => api.post('/backup/auto/execute', data),
|
||||
testCron: data => api.post('/backup/auto/test-cron', data),
|
||||
getLogs: params => api.get('/backup/logs', { params }),
|
||||
getLogDetail: id => api.get(`/backup/logs/${id}`),
|
||||
cleanOldLogs: days => api.delete('/backup/logs/clean', { params: { days } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
CloudServerOutlined,
|
||||
FolderOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import api from '../api';
|
||||
import PortCreateModal from './PortCreateModal';
|
||||
import NetworkCardCreateModal from './NetworkCardCreateModal';
|
||||
import { designTokens } from '../config/theme';
|
||||
@@ -42,13 +42,13 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [cardsResponse, networkCardsResponse] = await Promise.all([
|
||||
axios.get(`/api/network-cards/device/${deviceId}/with-ports`),
|
||||
axios.get(`/api/network-cards/device/${deviceId}`),
|
||||
api.get(`/network-cards/device/${deviceId}/with-ports`),
|
||||
api.get(`/network-cards/device/${deviceId}`),
|
||||
]);
|
||||
|
||||
const cardsData = cardsResponse.data || [];
|
||||
const cardsData = cardsResponse.data || cardsResponse || [];
|
||||
setCards(cardsData);
|
||||
setNetworkCards(networkCardsResponse.data || []);
|
||||
setNetworkCards(networkCardsResponse.data || networkCardsResponse || []);
|
||||
|
||||
const initialExpanded = cardsData
|
||||
.filter(card => card.ports && card.ports.length > 0)
|
||||
@@ -70,7 +70,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
const handleDeleteCard = useCallback(
|
||||
async card => {
|
||||
try {
|
||||
await axios.delete(`/api/network-cards/${card.nicId}`);
|
||||
await api.delete(`/network-cards/${card.nicId}`);
|
||||
import('antd').then(({ message }) => message.success('网卡删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
@@ -86,7 +86,7 @@ function NetworkCardPanel({ deviceId, deviceName, onRefresh, refreshTrigger }) {
|
||||
const handleDeletePort = useCallback(
|
||||
async port => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${port.portId}`);
|
||||
await api.delete(`/device-ports/${port.portId}`);
|
||||
import('antd').then(({ message }) => message.success('端口删除成功'));
|
||||
fetchData();
|
||||
onRefresh?.();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PortPanel from './PortPanel';
|
||||
import axios from 'axios';
|
||||
import api from '../api';
|
||||
import { designTokens } from '../config/theme';
|
||||
import CloseButton from './CloseButton';
|
||||
|
||||
@@ -47,8 +47,8 @@ const ServerBackplanePanel = ({
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/network-cards/device/${deviceId}/with-ports`);
|
||||
const cardsData = response.data || [];
|
||||
const response = await api.get(`/network-cards/device/${deviceId}/with-ports`);
|
||||
const cardsData = response.data || response || [];
|
||||
setCards(cardsData);
|
||||
} catch (error) {
|
||||
console.error('获取网卡数据失败:', error);
|
||||
|
||||
@@ -120,10 +120,14 @@ const VirtualDeviceList = ({
|
||||
const toggleDeviceExpand = deviceId => {
|
||||
setExpandedDevices(prev => ({
|
||||
...prev,
|
||||
[deviceId]: !prev[deviceId],
|
||||
[deviceId]: prev[deviceId] === true ? false : true,
|
||||
}));
|
||||
};
|
||||
|
||||
const isDeviceExpanded = deviceId => {
|
||||
return expandedDevices[deviceId] === true;
|
||||
};
|
||||
|
||||
const visibleDevices = devices.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < devices.length;
|
||||
|
||||
@@ -172,7 +176,7 @@ const VirtualDeviceList = ({
|
||||
{visibleDevices.map(device => {
|
||||
const deviceId = device.deviceId;
|
||||
const data = groupedPorts[deviceId] || { device, ports: [] };
|
||||
const isExpanded = expandedDevices[deviceId];
|
||||
const isExpanded = isDeviceExpanded(deviceId);
|
||||
const portCount = data.ports?.length || 0;
|
||||
const occupiedCount = data.ports?.filter(p => p.status === 'occupied').length || 0;
|
||||
|
||||
@@ -300,15 +304,21 @@ const VirtualDeviceList = ({
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleDeviceExpand(deviceId);
|
||||
}}
|
||||
style={{ color: '#64748b' }}
|
||||
/>
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 面板内容 - 可折叠 */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '16px' }}>
|
||||
{device.type === 'switch' ? (
|
||||
{device.type?.toLowerCase()?.includes('switch') ? (
|
||||
// 交换机使用普通端口面板
|
||||
<PortPanel
|
||||
ports={data.ports || []}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
@@ -17,6 +18,11 @@ import {
|
||||
Typography,
|
||||
Spin,
|
||||
Radio,
|
||||
Table,
|
||||
Select,
|
||||
Pagination,
|
||||
Empty,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
@@ -31,11 +37,294 @@ import {
|
||||
SafetyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
FileProtectOutlined,
|
||||
HistoryOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import { backupAPI } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const TimePicker = ({ hour, minute, onChange, disabled }) => {
|
||||
const [hourInput, setHourInput] = useState(hour.toString());
|
||||
const [minuteInput, setMinuteInput] = useState(minute.toString());
|
||||
|
||||
useEffect(() => {
|
||||
setHourInput(hour.toString());
|
||||
setMinuteInput(minute.toString());
|
||||
}, [hour, minute]);
|
||||
|
||||
const handleHourChange = (newHour) => {
|
||||
const validHour = Math.max(0, Math.min(23, newHour));
|
||||
onChange(validHour, minute);
|
||||
};
|
||||
|
||||
const handleMinuteChange = (newMinute) => {
|
||||
const validMinute = Math.max(0, Math.min(59, newMinute));
|
||||
onChange(hour, validMinute);
|
||||
};
|
||||
|
||||
const handleHourInputBlur = () => {
|
||||
const value = parseInt(hourInput);
|
||||
if (!isNaN(value)) {
|
||||
handleHourChange(value);
|
||||
} else {
|
||||
setHourInput(hour.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinuteInputBlur = () => {
|
||||
const value = parseInt(minuteInput);
|
||||
if (!isNaN(value)) {
|
||||
handleMinuteChange(value);
|
||||
} else {
|
||||
setMinuteInput(minute.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleHourKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleHourInputBlur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinuteKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMinuteInputBlur();
|
||||
}
|
||||
};
|
||||
|
||||
const incrementHour = () => handleHourChange(hour + 1);
|
||||
const decrementHour = () => handleHourChange(hour - 1);
|
||||
const incrementMinute = () => handleMinuteChange(minute + 1);
|
||||
const decrementMinute = () => handleMinuteChange(minute - 1);
|
||||
|
||||
const timePeriods = [
|
||||
{ start: 0, end: 5, label: '深夜', icon: '🌙' },
|
||||
{ start: 6, end: 8, label: '清晨', icon: '🌅' },
|
||||
{ start: 9, end: 11, label: '上午', icon: '☀️' },
|
||||
{ start: 12, end: 13, label: '中午', icon: '🌞' },
|
||||
{ start: 14, end: 17, label: '下午', icon: '🌤️' },
|
||||
{ start: 18, end: 21, label: '傍晚', icon: '🌇' },
|
||||
{ start: 22, end: 23, label: '夜晚', icon: '🌙' },
|
||||
];
|
||||
|
||||
const currentPeriod = timePeriods.find(p => hour >= p.start && hour <= p.end);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 14,
|
||||
padding: '18px 24px',
|
||||
background: disabled ? '#f5f5f5' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '14px',
|
||||
transition: 'all 0.3s ease',
|
||||
minWidth: 280,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UpOutlined />}
|
||||
onClick={incrementHour}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
fontSize: 15,
|
||||
height: 30,
|
||||
width: 56,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={hourInput}
|
||||
onChange={(e) => setHourInput(e.target.value)}
|
||||
onBlur={handleHourInputBlur}
|
||||
onPressEnter={handleHourKeyPress}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
style={{
|
||||
width: 66,
|
||||
height: 48,
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
background: disabled ? 'transparent' : 'rgba(255, 255, 255, 0.18)',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownOutlined />}
|
||||
onClick={decrementHour}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
fontSize: 15,
|
||||
height: 30,
|
||||
width: 56,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: disabled ? '#bfbfbf' : 'rgba(255, 255, 255, 0.85)',
|
||||
marginTop: 3,
|
||||
}}>
|
||||
小时
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 34,
|
||||
fontWeight: 700,
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
|
||||
marginTop: -12,
|
||||
}}>
|
||||
:
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UpOutlined />}
|
||||
onClick={incrementMinute}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
fontSize: 15,
|
||||
height: 30,
|
||||
width: 56,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={minuteInput}
|
||||
onChange={(e) => setMinuteInput(e.target.value)}
|
||||
onBlur={handleMinuteInputBlur}
|
||||
onPressEnter={handleMinuteKeyPress}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
style={{
|
||||
width: 66,
|
||||
height: 48,
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
background: disabled ? 'transparent' : 'rgba(255, 255, 255, 0.18)',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownOutlined />}
|
||||
onClick={decrementMinute}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: disabled ? '#bfbfbf' : '#fff',
|
||||
fontSize: 15,
|
||||
height: 30,
|
||||
width: 56,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: disabled ? '#bfbfbf' : 'rgba(255, 255, 255, 0.85)',
|
||||
marginTop: 3,
|
||||
}}>
|
||||
分钟
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPeriod && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 20px',
|
||||
background: disabled ? '#f5f5f5' : '#eff6ff',
|
||||
borderRadius: '12px',
|
||||
border: disabled ? '1px solid #f0f0f0' : '1px solid #dbeafe',
|
||||
}}>
|
||||
<span style={{ fontSize: 22 }}>{currentPeriod.icon}</span>
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
color: disabled ? '#bfbfbf' : '#3b82f6',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{currentPeriod.label}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||
{[
|
||||
{ hour: 2, minute: 0, label: '凌晨 2:00', recommended: true },
|
||||
{ hour: 3, minute: 0, label: '凌晨 3:00' },
|
||||
{ hour: 4, minute: 0, label: '凌晨 4:00', recommended: true },
|
||||
{ hour: 20, minute: 0, label: '晚上 8:00' },
|
||||
{ hour: 22, minute: 0, label: '晚上 10:00' },
|
||||
].map((preset, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="small"
|
||||
onClick={() => !disabled && onChange(preset.hour, preset.minute)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: hour === preset.hour && minute === preset.minute
|
||||
? '1px solid #667eea'
|
||||
: disabled ? '1px solid #f0f0f0' : '1px solid #e5e7eb',
|
||||
background: hour === preset.hour && minute === preset.minute
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: '#fff',
|
||||
color: hour === preset.hour && minute === preset.minute
|
||||
? '#fff'
|
||||
: disabled ? '#bfbfbf' : '#374151',
|
||||
fontWeight: preset.recommended ? 600 : 400,
|
||||
fontSize: 13,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
{preset.recommended && <span style={{ marginLeft: 4, fontSize: 11 }}>⭐</span>}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoBackupSettings = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -49,14 +338,30 @@ const AutoBackupSettings = () => {
|
||||
compress: true,
|
||||
maxCount: 30,
|
||||
maxAgeDays: 90,
|
||||
backupType: 'full',
|
||||
});
|
||||
const [modified, setModified] = useState(false);
|
||||
const isInitialMount = useRef(true);
|
||||
const isFetching = useRef(false);
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [logsPagination, setLogsPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
const [logFilter, setLogFilter] = useState({
|
||||
logType: '',
|
||||
status: '',
|
||||
});
|
||||
const [logDetailModal, setLogDetailModal] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
fetchStatus();
|
||||
fetchLogs();
|
||||
isInitialMount.current = false;
|
||||
}
|
||||
}, []);
|
||||
@@ -67,12 +372,11 @@ const AutoBackupSettings = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
isFetching.current = true;
|
||||
const response = await api.get('/backup/auto/status');
|
||||
const response = await backupAPI.getAutoStatus();
|
||||
if (response?.success) {
|
||||
const data = response.data;
|
||||
setStatus(data);
|
||||
|
||||
// 解析 Cron 表达式获取小时和分钟
|
||||
if (data.cronExpression) {
|
||||
const parts = data.cronExpression.split(' ');
|
||||
const minute = parseInt(parts[0]);
|
||||
@@ -87,6 +391,7 @@ const AutoBackupSettings = () => {
|
||||
compress: data.compress !== undefined ? data.compress : true,
|
||||
maxCount: data.maxCount || 30,
|
||||
maxAgeDays: data.maxAgeDays || 90,
|
||||
backupType: data.backupType || 'full',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -98,10 +403,34 @@ const AutoBackupSettings = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async (page = 1, pageSize = 10) => {
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
const params = { page, pageSize };
|
||||
if (logFilter.logType) params.logType = logFilter.logType;
|
||||
if (logFilter.status) params.status = logFilter.status;
|
||||
|
||||
const response = await backupAPI.getLogs(params);
|
||||
if (response?.success) {
|
||||
setLogs(response.data.logs || []);
|
||||
setLogsPagination({
|
||||
current: response.data.page || 1,
|
||||
pageSize: response.data.pageSize || 10,
|
||||
total: response.data.total || 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取备份日志失败:', error);
|
||||
message.error('获取备份日志失败');
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [logFilter]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.post('/backup/auto/settings', {
|
||||
const response = await backupAPI.updateAutoSettings({
|
||||
enabled: settings.enabled,
|
||||
hour: settings.hour,
|
||||
minute: settings.minute,
|
||||
@@ -109,12 +438,13 @@ const AutoBackupSettings = () => {
|
||||
compress: settings.compress,
|
||||
maxCount: settings.maxCount,
|
||||
maxAgeDays: settings.maxAgeDays,
|
||||
backupType: settings.backupType,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
message.success('自动备份设置已保存');
|
||||
setModified(false);
|
||||
// 不需要立即刷新,因为保存成功后状态已经是最新的
|
||||
fetchStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存失败:' + (error.response?.data?.message || error.message));
|
||||
@@ -147,14 +477,16 @@ const AutoBackupSettings = () => {
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.post('/backup/auto/execute', {
|
||||
const response = await backupAPI.executeNow({
|
||||
description: `手动触发 - ${new Date().toLocaleString('zh-CN')}`,
|
||||
includeFiles: settings.includeFiles,
|
||||
compress: settings.compress,
|
||||
backupType: settings.backupType,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
message.success('备份执行成功!');
|
||||
fetchLogs(logsPagination.current, logsPagination.pageSize);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('执行失败:' + (error.response?.data?.message || error.message));
|
||||
@@ -180,6 +512,34 @@ const AutoBackupSettings = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return '-';
|
||||
return new Date(dateTime).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const formatDuration = (ms) => {
|
||||
if (!ms) return '-';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
}
|
||||
return `${remainingSeconds}秒`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status) return 'default';
|
||||
if (status.enabled && status.isActive) return 'success';
|
||||
@@ -194,7 +554,43 @@ const AutoBackupSettings = () => {
|
||||
return '自动备份已禁用';
|
||||
};
|
||||
|
||||
// 状态卡片组件
|
||||
const getLogStatusTag = (status) => {
|
||||
const statusMap = {
|
||||
pending: { color: 'default', text: '待执行' },
|
||||
running: { color: 'processing', text: '执行中' },
|
||||
success: { color: 'success', text: '成功' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
};
|
||||
const info = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={info.color}>{info.text}</Tag>;
|
||||
};
|
||||
|
||||
const getLogTypeTag = (type) => {
|
||||
const typeMap = {
|
||||
auto: { color: 'blue', text: '自动备份' },
|
||||
manual: { color: 'green', text: '手动备份' },
|
||||
};
|
||||
const info = typeMap[type] || { color: 'default', text: type };
|
||||
return <Tag color={info.color}>{info.text}</Tag>;
|
||||
};
|
||||
|
||||
const handleLogPageChange = (page, pageSize) => {
|
||||
fetchLogs(page, pageSize);
|
||||
};
|
||||
|
||||
const handleLogFilterChange = (key, value) => {
|
||||
setLogFilter(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleViewLogDetail = (log) => {
|
||||
setSelectedLog(log);
|
||||
setLogDetailModal(true);
|
||||
};
|
||||
|
||||
const handleRefreshLogs = () => {
|
||||
fetchLogs(logsPagination.current, logsPagination.pageSize);
|
||||
};
|
||||
|
||||
const StatusCard = ({ icon, title, value, subtitle, gradient }) => (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
@@ -219,28 +615,98 @@ const AutoBackupSettings = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 设置项组件
|
||||
const SettingItem = ({ title, description, children, bordered = true }) => (
|
||||
<div style={{
|
||||
padding: '16px 0',
|
||||
borderBottom: bordered ? '1px solid #f0f0f0' : 'none',
|
||||
padding: '20px 0',
|
||||
borderBottom: bordered ? '1px solid #f5f5f5' : '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 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 24 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: '#1f2937', marginBottom: 6 }}>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<Paragraph style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||
<Paragraph style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 400,
|
||||
}}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{children}</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const logColumns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'logType',
|
||||
key: 'logType',
|
||||
width: 120,
|
||||
render: (type) => getLogTypeTag(type),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => getLogStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSize',
|
||||
key: 'fileSize',
|
||||
width: 120,
|
||||
render: (size) => formatFileSize(size),
|
||||
},
|
||||
{
|
||||
title: '执行时间',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
width: 120,
|
||||
render: (duration) => formatDuration(duration),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date) => formatDateTime(date),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewLogDetail(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -262,7 +728,6 @@ const AutoBackupSettings = () => {
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
@@ -334,7 +799,6 @@ const AutoBackupSettings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Title level={4} style={{ margin: '0 0 16px 0', color: '#1f2937', fontWeight: 600 }}>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
@@ -378,9 +842,7 @@ const AutoBackupSettings = () => {
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 设置卡片 */}
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* 基本设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
@@ -425,37 +887,23 @@ const AutoBackupSettings = () => {
|
||||
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>
|
||||
<TimePicker
|
||||
hour={settings.hour}
|
||||
minute={settings.minute}
|
||||
onChange={(newHour, newMinute) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
hour: newHour,
|
||||
minute: newMinute,
|
||||
}));
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</SettingItem>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 高级设置 */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
@@ -480,6 +928,23 @@ const AutoBackupSettings = () => {
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<SettingItem
|
||||
title="备份类型"
|
||||
description="选择全量备份或增量备份。全量备份每次备份完整数据;增量备份仅备份变化数据,节省空间"
|
||||
>
|
||||
<Radio.Group
|
||||
value={settings.backupType}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, backupType: e.target.value });
|
||||
setModified(true);
|
||||
}}
|
||||
disabled={!settings.enabled}
|
||||
>
|
||||
<Radio value="full">全量备份</Radio>
|
||||
<Radio value="incremental">增量备份</Radio>
|
||||
</Radio.Group>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
title="包含文件"
|
||||
description="备份时包含上传的文件(如用户头像等)"
|
||||
@@ -552,7 +1017,101 @@ const AutoBackupSettings = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<HistoryOutlined 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)',
|
||||
marginTop: 24,
|
||||
}}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefreshLogs}
|
||||
loading={logsLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Text>类型:</Text>
|
||||
<Select
|
||||
placeholder="全部类型"
|
||||
style={{ width: 150 }}
|
||||
allowClear
|
||||
value={logFilter.logType || undefined}
|
||||
onChange={(value) => handleLogFilterChange('logType', value)}
|
||||
>
|
||||
<Option value="auto">自动备份</Option>
|
||||
<Option value="manual">手动备份</Option>
|
||||
</Select>
|
||||
<Text>状态:</Text>
|
||||
<Select
|
||||
placeholder="全部状态"
|
||||
style={{ width: 150 }}
|
||||
allowClear
|
||||
value={logFilter.status || undefined}
|
||||
onChange={(value) => handleLogFilterChange('status', value)}
|
||||
>
|
||||
<Option value="pending">待执行</Option>
|
||||
<Option value="running">执行中</Option>
|
||||
<Option value="success">成功</Option>
|
||||
<Option value="failed">失败</Option>
|
||||
</Select>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => fetchLogs(1, logsPagination.pageSize)}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={logColumns}
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
loading={logsLoading}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无备份日志"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={logsPagination.current}
|
||||
pageSize={logsPagination.pageSize}
|
||||
total={logsPagination.total}
|
||||
onChange={handleLogPageChange}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
@@ -601,7 +1160,6 @@ const AutoBackupSettings = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Alert
|
||||
message={
|
||||
@@ -621,8 +1179,83 @@ const AutoBackupSettings = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="备份日志详情"
|
||||
open={logDetailModal}
|
||||
onCancel={() => setLogDetailModal(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setLogDetailModal(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
{selectedLog && (
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="类型">
|
||||
{getLogTypeTag(selectedLog.logType)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{getLogStatusTag(selectedLog.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述">
|
||||
{selectedLog.description || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备份类型">
|
||||
{selectedLog.backupType === 'full' ? '全量备份' : '增量备份'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="文件名">
|
||||
{selectedLog.filename || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="文件大小">
|
||||
{formatFileSize(selectedLog.fileSize)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="包含文件">
|
||||
{selectedLog.includeFiles ? '是' : '否'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="压缩">
|
||||
{selectedLog.compressed ? '是' : '否'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{formatDateTime(selectedLog.startTime)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">
|
||||
{formatDateTime(selectedLog.endTime)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="执行时长">
|
||||
{formatDuration(selectedLog.duration)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{formatDateTime(selectedLog.createdAt)}
|
||||
</Descriptions.Item>
|
||||
{selectedLog.errorMessage && (
|
||||
<Descriptions.Item label="错误信息">
|
||||
<Text type="danger">{selectedLog.errorMessage}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedLog.remoteUploads && selectedLog.remoteUploads.length > 0 && (
|
||||
<Descriptions.Item label="远端上传">
|
||||
<div>
|
||||
{selectedLog.remoteUploads.map((upload, index) => (
|
||||
<div key={index} style={{ marginBottom: 8 }}>
|
||||
<Tag color={upload.success ? 'success' : 'error'}>
|
||||
{upload.targetName}
|
||||
</Tag>
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{upload.success ? '上传成功' : `失败: ${upload.error}`}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoBackupSettings;
|
||||
|
||||
|
||||
@@ -1079,7 +1079,7 @@ const BackupManagement = () => {
|
||||
style={{
|
||||
...buttonStyles.icon.base,
|
||||
background: designTokens.colors.background.accent,
|
||||
color: designTokens.colors.danger.main,
|
||||
color: designTokens.colors.error.main,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1195,7 +1195,7 @@ const BackupManagement = () => {
|
||||
},
|
||||
danger: {
|
||||
base: {
|
||||
background: designTokens.colors.danger.gradient,
|
||||
background: designTokens.colors.error.gradient,
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
padding: '8px 20px',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -473,7 +473,7 @@ const InventoryManagement = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}>
|
||||
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{statCards.map((stat, index) => (
|
||||
|
||||
@@ -353,7 +353,7 @@ const InventoryTaskExecution = () => {
|
||||
size="large"
|
||||
style={inputStyle}
|
||||
>
|
||||
{(options || [
|
||||
{(Array.isArray(options) && options.length > 0 ? options : [
|
||||
{ value: 'server', label: '服务器' },
|
||||
{ value: 'switch', label: '交换机' },
|
||||
{ value: 'router', label: '路由器' },
|
||||
@@ -396,7 +396,7 @@ const InventoryTaskExecution = () => {
|
||||
size="large"
|
||||
style={inputStyle}
|
||||
>
|
||||
{(options || []).map(opt => (
|
||||
{(Array.isArray(options) ? options : []).map(opt => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
@@ -868,7 +868,7 @@ const InventoryTaskExecution = () => {
|
||||
|
||||
const pageContainerStyle = {
|
||||
padding: '24px',
|
||||
background: designTokens.colors.bg,
|
||||
background: designTokens.colors.background.secondary,
|
||||
minHeight: '100vh',
|
||||
};
|
||||
|
||||
|
||||
@@ -645,7 +645,7 @@ const PendingDeviceManagement = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}>
|
||||
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Skeleton,
|
||||
Alert,
|
||||
Typography,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -45,8 +46,10 @@ import {
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
DisconnectOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import api from '../api';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -136,8 +139,8 @@ function PortManagement() {
|
||||
if (filters.portType !== 'all') params.portType = filters.portType;
|
||||
if (filters.portSpeed !== 'all') params.portSpeed = filters.portSpeed;
|
||||
|
||||
const response = await axios.get('/api/device-ports', { params });
|
||||
const portsData = response.data.ports || response.data || [];
|
||||
const response = await api.get('/device-ports', { params });
|
||||
const portsData = response.ports || response || [];
|
||||
|
||||
// 搜索过滤
|
||||
let filteredPorts = portsData;
|
||||
@@ -166,8 +169,8 @@ function PortManagement() {
|
||||
if (keyword && keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await axios.get('/api/devices', { params });
|
||||
setDevices(response.data.devices || response.data || []);
|
||||
const response = await api.get('/devices', { params });
|
||||
setDevices(response.devices || response || []);
|
||||
} catch (error) {
|
||||
message.error('获取设备列表失败');
|
||||
console.error('获取设备列表失败:', error);
|
||||
@@ -185,8 +188,8 @@ function PortManagement() {
|
||||
|
||||
const fetchCables = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/cables');
|
||||
setCables(response.data.cables || response.data || []);
|
||||
const response = await api.get('/cables');
|
||||
setCables(response.cables || response || []);
|
||||
} catch (error) {
|
||||
console.error('获取接线列表失败:', error);
|
||||
}
|
||||
@@ -296,7 +299,7 @@ function PortManagement() {
|
||||
|
||||
const handleDelete = async portId => {
|
||||
try {
|
||||
await axios.delete(`/api/device-ports/${portId}`);
|
||||
await api.delete(`/device-ports/${portId}`);
|
||||
message.success({
|
||||
content: '删除成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
@@ -327,7 +330,7 @@ function PortManagement() {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingPort) {
|
||||
await axios.put(`/api/device-ports/${editingPort.portId}`, values);
|
||||
await api.put(`/device-ports/${editingPort.portId}`, values);
|
||||
message.success({
|
||||
content: '更新成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
@@ -347,7 +350,7 @@ function PortManagement() {
|
||||
description: values.description,
|
||||
}));
|
||||
|
||||
const response = await axios.post('/api/device-ports/batch', { ports: portsData });
|
||||
const response = await api.post('/device-ports/batch', { ports: portsData });
|
||||
const { success, failed } = response.data;
|
||||
|
||||
if (failed > 0) {
|
||||
@@ -359,7 +362,7 @@ function PortManagement() {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await axios.post('/api/device-ports', values);
|
||||
await api.post('/device-ports', values);
|
||||
message.success({
|
||||
content: '创建成功',
|
||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||
@@ -498,8 +501,8 @@ function PortManagement() {
|
||||
description: row['描述'],
|
||||
}));
|
||||
|
||||
const response = await axios.post('/api/device-ports/batch', { ports: portsData });
|
||||
const { total, success, failed, errors } = response.data;
|
||||
const response = await api.post('/device-ports/batch', { ports: portsData });
|
||||
const { total, success, failed, errors } = response;
|
||||
|
||||
setImportProgress({ current: total, total: total });
|
||||
|
||||
@@ -972,138 +975,135 @@ function PortManagement() {
|
||||
loadMoreCount={5}
|
||||
/>
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={expandedKeys}
|
||||
onChange={setExpandedKeys}
|
||||
style={{ background: 'transparent', border: 'none' }}
|
||||
expandIconPosition="end"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{Object.entries(groupedPorts).map(([deviceId, data], index) => {
|
||||
const device = data.device;
|
||||
const devicePorts = data.ports || [];
|
||||
const freeCount = devicePorts.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{Object.entries(groupedPorts).map(([deviceId, data], index) => {
|
||||
const device = data.device;
|
||||
const devicePorts = data.ports || [];
|
||||
const freeCount = devicePorts.filter(p => p.status === 'free').length;
|
||||
const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length;
|
||||
const faultCount = devicePorts.filter(p => p.status === 'fault').length;
|
||||
const isExpanded = expandedKeys.includes(deviceId);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={deviceId}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
paddingRight: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '24px',
|
||||
boxShadow: designTokens.shadows.md,
|
||||
}}
|
||||
>
|
||||
{getDeviceIcon(device)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}>
|
||||
{device?.name || '未知设备'}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}>
|
||||
{device?.deviceId || '-'} · {device?.model || device?.type || '设备'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space size="middle">
|
||||
<Tooltip title="空闲">
|
||||
<Tag
|
||||
color="success"
|
||||
style={{ borderRadius: '4px', padding: '4px 12px' }}
|
||||
icon={<CheckCircleOutlined />}
|
||||
>
|
||||
{freeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title="占用">
|
||||
<Tag
|
||||
color="processing"
|
||||
style={{ borderRadius: '4px', padding: '4px 12px' }}
|
||||
icon={<AppstoreOutlined />}
|
||||
>
|
||||
{occupiedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
{faultCount > 0 && (
|
||||
<Tooltip title="故障">
|
||||
<Tag
|
||||
color="error"
|
||||
style={{ borderRadius: '4px', padding: '4px 12px' }}
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
>
|
||||
{faultCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}
|
||||
>
|
||||
总计: {devicePorts.length}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
return (
|
||||
<Card
|
||||
key={deviceId}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.lg,
|
||||
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 设备头部 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
background: isExpanded ? designTokens.colors.primary.light : '#fff',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isExpanded) {
|
||||
setExpandedKeys(prev => prev.filter(key => key !== deviceId));
|
||||
} else {
|
||||
setExpandedKeys(prev => [...prev, deviceId]);
|
||||
}
|
||||
extra={
|
||||
<Space size="small" onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="添加端口">
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '24px',
|
||||
boxShadow: designTokens.shadows.md,
|
||||
}}
|
||||
>
|
||||
{getDeviceIcon(device)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}>
|
||||
{device?.name || '未知设备'}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}>
|
||||
{device?.deviceId || '-'} · {device?.model || device?.type || '设备'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Space size="small">
|
||||
<Tooltip title="空闲">
|
||||
<Tag color="success" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<CheckCircleOutlined />}>
|
||||
{freeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title="占用">
|
||||
<Tag color="processing" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<AppstoreOutlined />}>
|
||||
{occupiedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
{faultCount > 0 && (
|
||||
<Tooltip title="故障">
|
||||
<Tag color="error" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<ExclamationCircleOutlined />}>
|
||||
{faultCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tag color="blue" style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}>
|
||||
总计: {devicePorts.length}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Divider type="vertical" style={{ height: '24px', margin: '0 8px' }} />
|
||||
<Space size="small">
|
||||
<Tooltip title="添加端口">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleAddPortForDevice(device);
|
||||
}}
|
||||
style={{ color: designTokens.colors.primary.main }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{device?.type?.toLowerCase()?.includes('server') && (
|
||||
<Tooltip title="网卡管理">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddPortForDevice(device)}
|
||||
icon={<CloudServerOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleManageNetworkCards(device);
|
||||
}}
|
||||
style={{ color: designTokens.colors.primary.main }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{device?.type?.toLowerCase()?.includes('server') && (
|
||||
<Tooltip title="网卡管理">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloudServerOutlined />}
|
||||
onClick={() => handleManageNetworkCards(device)}
|
||||
style={{ color: designTokens.colors.primary.main }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: designTokens.borderRadius.lg,
|
||||
marginBottom: '12px',
|
||||
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||
style={{ color: designTokens.colors.neutral[600], minWidth: '70px' }}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 端口列表 */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '16px 20px', borderTop: `1px solid ${designTokens.colors.neutral[200]}` }}>
|
||||
{devicePorts.length > 0 ? (
|
||||
<Table
|
||||
columns={portColumns}
|
||||
dataSource={devicePorts}
|
||||
@@ -1116,18 +1116,16 @@ function PortManagement() {
|
||||
}}
|
||||
size="middle"
|
||||
scroll={{ x: 1000 }}
|
||||
style={{
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Panel>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Collapse>
|
||||
) : (
|
||||
<Empty description="暂无端口数据" style={{ padding: '24px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
@@ -146,7 +146,7 @@ const buttonStyles = {
|
||||
transition: `all ${designTokens.transitions.normal}`,
|
||||
},
|
||||
danger: {
|
||||
background: designTokens.colors.danger.gradient,
|
||||
background: designTokens.colors.error.gradient,
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.md,
|
||||
fontWeight: 600,
|
||||
@@ -541,7 +541,7 @@ const RemoteBackupSettings = () => {
|
||||
background: designTokens.colors.background.accent,
|
||||
border: 'none',
|
||||
borderRadius: designTokens.borderRadius.sm,
|
||||
color: designTokens.colors.danger.main,
|
||||
color: designTokens.colors.error.main,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
|
||||
@@ -277,7 +277,7 @@ const SystemSettings = () => {
|
||||
return (
|
||||
<Form.Item key={key} label={data.description || key} name={key}>
|
||||
<Select>
|
||||
{options.map(opt => (
|
||||
{(Array.isArray(options) ? options : []).map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
|
||||
Reference in New Issue
Block a user