From e5d20820dcc7dad9f508ea9ce780c36a31876259 Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Mon, 16 Mar 2026 17:07:20 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8DBUG?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/config/auto-backup-settings.json | 2 +-
backend/models/BackupLog.js | 94 ++
backend/models/ConsumableRecord.js | 2 +
backend/routes/backup.js | 87 +-
backend/routes/consumableRecords.js | 44 +-
backend/routes/consumables.js | 29 +-
backend/routes/networkCards.js | 124 +-
backend/server.js | 7 +
backend/utils/autoBackupScheduler.js | 231 ++-
backend/utils/backup.js | 191 ++-
backend/utils/backupLog.js | 125 ++
frontend/src/App.jsx | 2 +-
frontend/src/api/cache.js | 7 +
frontend/src/api/index.js | 7 +
frontend/src/components/NetworkCardPanel.jsx | 14 +-
.../src/components/ServerBackplanePanel.jsx | 6 +-
frontend/src/components/VirtualDeviceList.jsx | 18 +-
frontend/src/pages/AutoBackupSettings.jsx | 729 ++++++++-
frontend/src/pages/BackupManagement.jsx | 4 +-
frontend/src/pages/ConsumableStatistics.jsx | 1332 +++++++++--------
frontend/src/pages/InventoryManagement.jsx | 2 +-
frontend/src/pages/InventoryTaskExecution.jsx | 6 +-
.../src/pages/PendingDeviceManagement.jsx | 2 +-
frontend/src/pages/PortManagement.jsx | 296 ++--
frontend/src/pages/RemoteBackupSettings.jsx | 4 +-
frontend/src/pages/SystemSettings.jsx | 2 +-
26 files changed, 2363 insertions(+), 1004 deletions(-)
create mode 100644 backend/models/BackupLog.js
create mode 100644 backend/utils/backupLog.js
diff --git a/backend/config/auto-backup-settings.json b/backend/config/auto-backup-settings.json
index ec84ab1..d8261cd 100644
--- a/backend/config/auto-backup-settings.json
+++ b/backend/config/auto-backup-settings.json
@@ -1,6 +1,6 @@
{
"enabled": true,
- "cronExpression": "0 20 * * *",
+ "cronExpression": "0 19 * * *",
"description": "自动备份",
"backupType": "full",
"includeFiles": true,
diff --git a/backend/models/BackupLog.js b/backend/models/BackupLog.js
new file mode 100644
index 0000000..2d1c0b6
--- /dev/null
+++ b/backend/models/BackupLog.js
@@ -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;
diff --git a/backend/models/ConsumableRecord.js b/backend/models/ConsumableRecord.js
index 4327051..770ef19 100644
--- a/backend/models/ConsumableRecord.js
+++ b/backend/models/ConsumableRecord.js
@@ -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'
});
diff --git a/backend/routes/backup.js b/backend/routes/backup.js
index 397a5c1..ee042d6 100644
--- a/backend/routes/backup.js
+++ b/backend/routes/backup.js
@@ -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;
diff --git a/backend/routes/consumableRecords.js b/backend/routes/consumableRecords.js
index b082702..c681063 100644
--- a/backend/routes/consumableRecords.js
+++ b/backend/routes/consumableRecords.js
@@ -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 });
diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js
index 54c7a18..f497f40 100644
--- a/backend/routes/consumables.js
+++ b/backend/routes/consumables.js
@@ -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']
}]
});
diff --git a/backend/routes/networkCards.js b/backend/routes/networkCards.js
index 619ca1c..c7c36e8 100644
--- a/backend/routes/networkCards.js
+++ b/backend/routes/networkCards.js
@@ -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;
diff --git a/backend/server.js b/backend/server.js
index 299d924..ebb130b 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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();
diff --git a/backend/utils/autoBackupScheduler.js b/backend/utils/autoBackupScheduler.js
index 72f898c..2b6f5d5 100644
--- a/backend/utils/autoBackupScheduler.js
+++ b/backend/utils/autoBackupScheduler.js
@@ -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,
};
+
diff --git a/backend/utils/backup.js b/backend/utils/backup.js
index ff062fe..ace932a 100644
--- a/backend/utils/backup.js
+++ b/backend/utils/backup.js
@@ -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,
diff --git a/backend/utils/backupLog.js b/backend/utils/backupLog.js
new file mode 100644
index 0000000..dc431a0
--- /dev/null
+++ b/backend/utils/backupLog.js
@@ -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
+};
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9b8c2bc..c614cc6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -334,7 +334,7 @@ const AppLayout = ({ children }) => {
{
key: 'pending-devices',
icon: ,
- label: 暂存设备,
+ label: 盘盈设备,
},
],
},
diff --git a/frontend/src/api/cache.js b/frontend/src/api/cache.js
index 41c5101..6fe24ea 100644
--- a/frontend/src/api/cache.js
+++ b/frontend/src/api/cache.js
@@ -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),
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 19e186c..80c704a 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -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;
diff --git a/frontend/src/components/NetworkCardPanel.jsx b/frontend/src/components/NetworkCardPanel.jsx
index c7cd29c..05f94db 100644
--- a/frontend/src/components/NetworkCardPanel.jsx
+++ b/frontend/src/components/NetworkCardPanel.jsx
@@ -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?.();
diff --git a/frontend/src/components/ServerBackplanePanel.jsx b/frontend/src/components/ServerBackplanePanel.jsx
index 74bbe25..88406bb 100644
--- a/frontend/src/components/ServerBackplanePanel.jsx
+++ b/frontend/src/components/ServerBackplanePanel.jsx
@@ -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);
diff --git a/frontend/src/components/VirtualDeviceList.jsx b/frontend/src/components/VirtualDeviceList.jsx
index 830e031..f7578ab 100644
--- a/frontend/src/components/VirtualDeviceList.jsx
+++ b/frontend/src/components/VirtualDeviceList.jsx
@@ -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 ? : }
+ onClick={e => {
+ e.stopPropagation();
+ toggleDeviceExpand(deviceId);
+ }}
style={{ color: '#64748b' }}
- />
+ >
+ {isExpanded ? '收起' : '展开'}
+
{/* 面板内容 - 可折叠 */}
{isExpanded && (
- {device.type === 'switch' ? (
+ {device.type?.toLowerCase()?.includes('switch') ? (
// 交换机使用普通端口面板
{
+ 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 (
+
+
+
+ }
+ onClick={incrementHour}
+ disabled={disabled}
+ style={{
+ color: disabled ? '#bfbfbf' : '#fff',
+ fontSize: 15,
+ height: 30,
+ width: 56,
+ padding: 0,
+ }}
+ />
+ 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',
+ }}
+ />
+ }
+ onClick={decrementHour}
+ disabled={disabled}
+ style={{
+ color: disabled ? '#bfbfbf' : '#fff',
+ fontSize: 15,
+ height: 30,
+ width: 56,
+ padding: 0,
+ }}
+ />
+
+ 小时
+
+
+
+
+ :
+
+
+
+ }
+ onClick={incrementMinute}
+ disabled={disabled}
+ style={{
+ color: disabled ? '#bfbfbf' : '#fff',
+ fontSize: 15,
+ height: 30,
+ width: 56,
+ padding: 0,
+ }}
+ />
+ 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',
+ }}
+ />
+ }
+ onClick={decrementMinute}
+ disabled={disabled}
+ style={{
+ color: disabled ? '#bfbfbf' : '#fff',
+ fontSize: 15,
+ height: 30,
+ width: 56,
+ padding: 0,
+ }}
+ />
+
+ 分钟
+
+
+
+
+ {currentPeriod && (
+
+ {currentPeriod.icon}
+
+ {currentPeriod.label}
+
+
+ )}
+
+
+ {[
+ { 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) => (
+
+ ))}
+
+
+ );
+};
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 {info.text};
+ };
+
+ const getLogTypeTag = (type) => {
+ const typeMap = {
+ auto: { color: 'blue', text: '自动备份' },
+ manual: { color: 'green', text: '手动备份' },
+ };
+ const info = typeMap[type] || { color: 'default', text: type };
+ return {info.text};
+ };
+
+ 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 }) => (
{
);
- // 设置项组件
const SettingItem = ({ title, description, children, bordered = true }) => (
-
-
-
+
+
+
{title}
{description && (
-
+
{description}
)}
-
{children}
+
+ {children}
+
);
+ 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) => (
+
+ }
+ onClick={() => handleViewLogDetail(record)}
+ />
+
+ ),
+ },
+ ];
+
if (loading && !status) {
return (
{
padding: '24px',
}}>
- {/* 页面头部 */}
}
@@ -334,7 +799,6 @@ const AutoBackupSettings = () => {
- {/* 状态概览 */}
@@ -378,9 +842,7 @@ const AutoBackupSettings = () => {
- {/* 设置卡片 */}
- {/* 基本设置 */}
{
title="备份时间"
description="每天自动执行备份的时间,建议设置在业务低峰期"
>
-
- {
- setSettings({ ...settings, hour: value });
- setModified(true);
- }}
- addonAfter="时"
- disabled={!settings.enabled}
- style={{ width: 100 }}
- />
- {
- setSettings({ ...settings, minute: value });
- setModified(true);
- }}
- addonAfter="分"
- disabled={!settings.enabled}
- style={{ width: 100 }}
- />
-
+ {
+ setSettings(prev => ({
+ ...prev,
+ hour: newHour,
+ minute: newMinute,
+ }));
+ setModified(true);
+ }}
+ disabled={!settings.enabled}
+ />
- {/* 高级设置 */}
{
height: '100%',
}}
>
+
+ {
+ setSettings({ ...settings, backupType: e.target.value });
+ setModified(true);
+ }}
+ disabled={!settings.enabled}
+ >
+ 全量备份
+ 增量备份
+
+
+
{
- {/* 提示信息 */}
+
+
+
+
+ 备份日志
+
+ }
+ style={{
+ borderRadius: '20px',
+ boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
+ marginTop: 24,
+ }}
+ extra={
+ }
+ onClick={handleRefreshLogs}
+ loading={logsLoading}
+ >
+ 刷新
+
+ }
+ >
+
+
+ 类型:
+
+ 状态:
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
- {/* 安全提示 */}
+
+
setLogDetailModal(false)}
+ footer={[
+
+ ]}
+ width={700}
+ >
+ {selectedLog && (
+
+
+ {getLogTypeTag(selectedLog.logType)}
+
+
+ {getLogStatusTag(selectedLog.status)}
+
+
+ {selectedLog.description || '-'}
+
+
+ {selectedLog.backupType === 'full' ? '全量备份' : '增量备份'}
+
+
+ {selectedLog.filename || '-'}
+
+
+ {formatFileSize(selectedLog.fileSize)}
+
+
+ {selectedLog.includeFiles ? '是' : '否'}
+
+
+ {selectedLog.compressed ? '是' : '否'}
+
+
+ {formatDateTime(selectedLog.startTime)}
+
+
+ {formatDateTime(selectedLog.endTime)}
+
+
+ {formatDuration(selectedLog.duration)}
+
+
+ {formatDateTime(selectedLog.createdAt)}
+
+ {selectedLog.errorMessage && (
+
+ {selectedLog.errorMessage}
+
+ )}
+ {selectedLog.remoteUploads && selectedLog.remoteUploads.length > 0 && (
+
+
+ {selectedLog.remoteUploads.map((upload, index) => (
+
+
+ {upload.targetName}
+
+
+ {upload.success ? '上传成功' : `失败: ${upload.error}`}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
);
};
export default AutoBackupSettings;
+
diff --git a/frontend/src/pages/BackupManagement.jsx b/frontend/src/pages/BackupManagement.jsx
index 932e483..55a2d6d 100644
--- a/frontend/src/pages/BackupManagement.jsx
+++ b/frontend/src/pages/BackupManagement.jsx
@@ -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,
}}
/>
@@ -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',
diff --git a/frontend/src/pages/ConsumableStatistics.jsx b/frontend/src/pages/ConsumableStatistics.jsx
index aad8dce..8e56379 100644
--- a/frontend/src/pages/ConsumableStatistics.jsx
+++ b/frontend/src/pages/ConsumableStatistics.jsx
@@ -1,9 +1,7 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useMemo } from 'react';
import {
- Card,
Row,
Col,
- Statistic,
Table,
Tag,
Progress,
@@ -13,10 +11,13 @@ import {
Empty,
Tooltip,
Badge,
- Divider,
Typography,
Space,
Avatar,
+ Spin,
+ Dropdown,
+ Menu,
+ Statistic,
} from 'antd';
import {
PieChartOutlined,
@@ -32,16 +33,24 @@ import {
ExclamationCircleOutlined,
ShoppingCartOutlined,
DatabaseOutlined,
+ ExportOutlined,
+ FallOutlined,
+ RiseOutlined,
+ MinusOutlined,
+ ThunderboltOutlined,
+ FileExcelOutlined,
+ MoreOutlined,
+ EyeOutlined,
} from '@ant-design/icons';
import { motion, AnimatePresence } from 'framer-motion';
import styled from 'styled-components';
import dayjs from 'dayjs';
import api from '../api';
+import { consumableRecordAPI, consumableCategoryAPI, consumableAPI } from '../api/cache';
import { message } from 'antd';
import {
selectStyles,
datePickerStyles,
- inputPlaceholders,
} from '../styles/deviceManagementStyles';
import { designTokens } from '../config/theme';
@@ -49,44 +58,23 @@ const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
-// 动画配置
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
- transition: {
- staggerChildren: 0.08,
- delayChildren: 0.1,
- },
+ transition: { staggerChildren: 0.06, delayChildren: 0.1 },
},
};
const itemVariants = {
- hidden: { opacity: 0, y: 20 },
+ hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
- transition: {
- type: 'spring',
- stiffness: 100,
- damping: 15,
- },
+ transition: { type: 'spring', stiffness: 120, damping: 18 },
},
};
-const cardHoverVariants = {
- rest: { scale: 1 },
- hover: {
- scale: 1.02,
- transition: {
- type: 'spring',
- stiffness: 400,
- damping: 25,
- },
- },
-};
-
-// 样式组件
const PageContainer = styled.div`
padding: 24px;
background: ${designTokens.colors.background.main};
@@ -112,17 +100,17 @@ const TitleSection = styled.div`
gap: 16px;
.icon-wrapper {
- width: 56px;
- height: 56px;
+ width: 52px;
+ height: 52px;
background: ${designTokens.colors.primary.gradient};
- border-radius: ${designTokens.borderRadius.large};
+ border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
- box-shadow: ${designTokens.shadows.glow};
+ box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
.anticon {
- font-size: 28px;
+ font-size: 26px;
color: white;
}
}
@@ -130,45 +118,103 @@ const TitleSection = styled.div`
.title-content {
h1 {
margin: 0;
- font-size: 28px;
+ font-size: 26px;
font-weight: 700;
- background: ${designTokens.colors.primary.gradient};
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
+ color: ${designTokens.colors.text.primary};
}
.subtitle {
color: ${designTokens.colors.text.secondary};
font-size: 14px;
- margin-top: 4px;
+ margin-top: 2px;
}
}
`;
-const FilterSection = styled(motion.div)`
+const QuickFilterBar = styled(motion.div)`
+ display: flex;
+ gap: 8px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+`;
+
+const QuickFilterBtn = styled(Button)`
+ border-radius: 20px;
+ height: 32px;
+ padding: 0 16px;
+ font-size: 13px;
+ font-weight: 500;
+ border: 1px solid ${props => props.$active ? designTokens.colors.primary.main : designTokens.colors.border};
+ background: ${props => props.$active ? designTokens.colors.primary.main : 'transparent'};
+ color: ${props => props.$active ? 'white' : designTokens.colors.text.secondary};
+
+ &:hover {
+ border-color: ${designTokens.colors.primary.main};
+ color: ${props => props.$active ? 'white' : designTokens.colors.primary.main};
+ background: ${props => props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'};
+ }
+`;
+
+const FilterCard = styled(motion.div)`
background: ${designTokens.colors.background.card};
padding: 20px 24px;
- border-radius: ${designTokens.borderRadius.large};
- box-shadow: ${designTokens.shadows.small};
+ border-radius: 16px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 24px;
border: 1px solid ${designTokens.colors.border};
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+
+ @media (max-width: 768px) {
+ padding: 16px;
+ }
+`;
+
+const FilterItem = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ .filter-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: ${designTokens.colors.text.secondary};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+`;
+
+const StatsGrid = styled(motion.div)`
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 20px;
+ margin-bottom: 24px;
+
+ @media (max-width: 1200px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (max-width: 576px) {
+ grid-template-columns: 1fr;
+ }
`;
const StatsCard = styled(motion.div)`
background: ${designTokens.colors.background.card};
- border-radius: ${designTokens.borderRadius.large};
+ border-radius: 16px;
padding: 24px;
- box-shadow: ${designTokens.shadows.small};
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid ${designTokens.colors.border};
- transition: all 0.3s ease;
- height: 100%;
position: relative;
overflow: hidden;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
- box-shadow: ${designTokens.shadows.large};
- transform: translateY(-2px);
+ transform: translateY(-4px);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
}
&::before {
@@ -177,63 +223,92 @@ const StatsCard = styled(motion.div)`
top: 0;
left: 0;
right: 0;
- height: 4px;
- background: ${props => props.accent || designTokens.colors.primary.gradient};
+ height: 3px;
+ background: ${props => props.$accent || designTokens.colors.primary.gradient};
}
- .card-header {
+ .card-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
display: flex;
align-items: center;
- gap: 12px;
+ justify-content: center;
+ font-size: 22px;
margin-bottom: 16px;
-
- .icon-box {
- width: 48px;
- height: 48px;
- border-radius: ${designTokens.borderRadius.medium};
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 24px;
- background: ${props => props.iconBg || 'rgba(99, 102, 241, 0.1)'};
- color: ${props => props.iconColor || designTokens.colors.primary.main};
- }
-
- .card-title {
- font-size: 16px;
- font-weight: 600;
- color: ${designTokens.colors.text.primary};
- }
+ background: ${props => props.$iconBg || 'rgba(99, 102, 241, 0.1)'};
+ color: ${props => props.$iconColor || designTokens.colors.primary.main};
}
- .stat-value {
- font-size: 32px;
+ .card-value {
+ font-size: 36px;
font-weight: 700;
color: ${designTokens.colors.text.primary};
- margin-bottom: 8px;
- background: ${props => props.valueGradient || 'none'};
- -webkit-background-clip: ${props => props.valueGradient ? 'text' : 'unset'};
- -webkit-text-fill-color: ${props => props.valueGradient ? 'transparent' : 'inherit'};
- background-clip: ${props => props.valueGradient ? 'text' : 'unset'};
+ line-height: 1.2;
+ margin-bottom: 4px;
}
- .stat-label {
- font-size: 13px;
+ .card-label {
+ font-size: 14px;
color: ${designTokens.colors.text.secondary};
}
+
+ .card-trend {
+ position: absolute;
+ top: 24px;
+ right: 24px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 4px 10px;
+ border-radius: 20px;
+
+ &.up {
+ color: ${designTokens.colors.success.main};
+ background: rgba(16, 185, 129, 0.1);
+ }
+
+ &.down {
+ color: ${designTokens.colors.error.main};
+ background: rgba(239, 68, 68, 0.1);
+ }
+
+ &.neutral {
+ color: ${designTokens.colors.text.secondary};
+ background: rgba(107, 114, 128, 0.1);
+ }
+ }
`;
-const ContentCard = styled(motion.div)`
+const BentoGrid = styled(motion.div)`
+ display: grid;
+ grid-template-columns: repeat(12, 1fr);
+ grid-template-rows: auto;
+ gap: 20px;
+ margin-bottom: 24px;
+
+ @media (max-width: 1200px) {
+ grid-template-columns: repeat(6, 1fr);
+ }
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+const BentoCard = styled(motion.div)`
background: ${designTokens.colors.background.card};
- border-radius: ${designTokens.borderRadius.large};
- box-shadow: ${designTokens.shadows.small};
+ border-radius: 16px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid ${designTokens.colors.border};
overflow: hidden;
- height: 100%;
+ grid-column: ${props => props.$col || 'span 6'};
transition: all 0.3s ease;
&:hover {
- box-shadow: ${designTokens.shadows.medium};
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.card-header {
@@ -242,7 +317,7 @@ const ContentCard = styled(motion.div)`
display: flex;
align-items: center;
justify-content: space-between;
- background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+ background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
.header-left {
display: flex;
@@ -250,19 +325,19 @@ const ContentCard = styled(motion.div)`
gap: 12px;
.header-icon {
- width: 40px;
- height: 40px;
- border-radius: ${designTokens.borderRadius.medium};
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
- font-size: 20px;
- background: ${props => props.iconBg || designTokens.colors.primary.gradient};
+ font-size: 18px;
+ background: ${props => props.$iconBg || designTokens.colors.primary.gradient};
color: white;
}
.header-title {
- font-size: 17px;
+ font-size: 16px;
font-weight: 600;
color: ${designTokens.colors.text.primary};
}
@@ -275,7 +350,116 @@ const ContentCard = styled(motion.div)`
}
.card-body {
- padding: 24px;
+ padding: 20px 24px;
+ }
+`;
+
+const InOutComparison = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+
+ @media (max-width: 576px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+const ComparisonItem = styled.div`
+ padding: 20px;
+ border-radius: 12px;
+ background: ${props => props.$bg || 'transparent'};
+ text-align: center;
+
+ .item-icon {
+ font-size: 28px;
+ margin-bottom: 12px;
+ color: ${props => props.$color};
+ }
+
+ .item-value {
+ font-size: 32px;
+ font-weight: 700;
+ color: ${props => props.$color};
+ margin-bottom: 4px;
+ }
+
+ .item-label {
+ font-size: 14px;
+ color: ${designTokens.colors.text.secondary};
+ margin-bottom: 8px;
+ }
+
+ .item-sub {
+ font-size: 13px;
+ color: ${designTokens.colors.text.secondary};
+
+ strong {
+ color: ${props => props.$color};
+ }
+ }
+`;
+
+const CategoryGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 16px;
+`;
+
+const CategoryCard = styled(motion.div)`
+ padding: 20px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
+ border: 1px solid ${designTokens.colors.border};
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
+ border-color: ${props => props.$color}40;
+ }
+
+ .category-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 12px;
+ background: ${props => props.$color}15;
+ color: ${props => props.$color};
+
+ .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: currentColor;
+ }
+ }
+
+ .category-count {
+ font-size: 28px;
+ font-weight: 700;
+ color: ${designTokens.colors.text.primary};
+ margin-bottom: 4px;
+ }
+
+ .category-label {
+ font-size: 13px;
+ color: ${designTokens.colors.text.secondary};
+ margin-bottom: 12px;
+ }
+
+ .category-stock {
+ font-size: 13px;
+ color: ${designTokens.colors.text.secondary};
+
+ strong {
+ color: ${designTokens.colors.text.primary};
+ }
}
`;
@@ -285,24 +469,21 @@ const StyledTable = styled(Table)`
}
.ant-table-thead > tr > th {
- background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+ background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
font-weight: 600;
- color: ${designTokens.colors.text.primary};
- border-bottom: 2px solid ${designTokens.colors.border};
- padding: 16px;
+ font-size: 13px;
+ color: ${designTokens.colors.text.secondary};
+ border-bottom: 1px solid ${designTokens.colors.border};
+ padding: 14px 16px;
}
.ant-table-tbody > tr > td {
- padding: 16px;
- border-bottom: 1px solid ${designTokens.colors.border};
+ padding: 14px 16px;
+ border-bottom: 1px solid ${designTokens.colors.border}40;
}
.ant-table-tbody > tr:hover > td {
- background: rgba(99, 102, 241, 0.04);
- }
-
- .ant-table-row {
- transition: all 0.2s ease;
+ background: rgba(99, 102, 241, 0.02);
}
`;
@@ -310,7 +491,7 @@ const ProgressBar = styled.div`
.progress-wrapper {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 10px;
.ant-progress {
flex: 1;
@@ -320,76 +501,27 @@ const ProgressBar = styled.div`
.progress-text {
font-size: 13px;
font-weight: 600;
- color: ${props => props.color || designTokens.colors.text.primary};
- min-width: 45px;
+ min-width: 42px;
text-align: right;
}
}
`;
-const CategoryTag = styled(Tag)`
- padding: 6px 14px;
- border-radius: ${designTokens.borderRadius.full};
- font-size: 13px;
- font-weight: 500;
- border: none;
- display: inline-flex;
- align-items: center;
- gap: 6px;
-
- .dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: currentColor;
- }
-`;
-
-const StatItem = styled.div`
- text-align: center;
- padding: 16px;
- background: ${props => props.bg || 'transparent'};
- border-radius: ${designTokens.borderRadius.medium};
- transition: all 0.3s ease;
-
- &:hover {
- transform: translateY(-2px);
- box-shadow: ${designTokens.shadows.small};
- }
-
- .stat-icon {
- font-size: 24px;
- margin-bottom: 8px;
- color: ${props => props.iconColor || designTokens.colors.primary.main};
- }
-
- .stat-number {
- font-size: 24px;
- font-weight: 700;
- color: ${designTokens.colors.text.primary};
- margin-bottom: 4px;
- }
-
- .stat-label {
- font-size: 12px;
- color: ${designTokens.colors.text.secondary};
- }
-`;
-
const EmptyState = styled.div`
text-align: center;
padding: 48px 24px;
color: ${designTokens.colors.text.secondary};
.empty-icon {
- font-size: 64px;
+ font-size: 56px;
margin-bottom: 16px;
- opacity: 0.5;
+ opacity: 0.4;
}
.empty-text {
- font-size: 16px;
- margin-bottom: 8px;
+ font-size: 15px;
+ margin-bottom: 6px;
+ font-weight: 500;
}
.empty-subtext {
@@ -398,6 +530,20 @@ const EmptyState = styled.div`
}
`;
+const LoadingOverlay = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(255, 255, 255, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 16px;
+ z-index: 10;
+`;
+
const ConsumableStatistics = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
@@ -414,63 +560,73 @@ const ConsumableStatistics = () => {
byCategory: [],
});
const [lowStockItems, setLowStockItems] = useState([]);
+ const [categories, setCategories] = useState([]);
const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]);
const [categoryFilter, setCategoryFilter] = useState('all');
+ const [quickFilter, setQuickFilter] = useState('30days');
+
+ const quickFilters = [
+ { key: 'today', label: '今日', days: 0 },
+ { key: '7days', label: '近7天', days: 7 },
+ { key: '30days', label: '近30天', days: 30 },
+ { key: '90days', label: '近90天', days: 90 },
+ ];
+
+ const loadCategories = async () => {
+ try {
+ const response = await consumableCategoryAPI.getList();
+ console.log('[分类] 返回数据:', response);
+ setCategories(response || []);
+ } catch (error) {
+ console.error('加载分类列表失败:', error);
+ }
+ };
- // 加载统计数据
const loadStatistics = async () => {
try {
setLoading(true);
const params = {
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
+ category: categoryFilter !== 'all' ? categoryFilter : undefined,
};
- // 加载记录统计
- const statsResponse = await api.get('/consumable-records/statistics', { params });
- setStats(statsResponse || {
- inCount: 0,
- outCount: 0,
- inQuantity: 0,
- outQuantity: 0,
- recentRecords: [],
+ const statsResponse = await consumableRecordAPI.statistics(params);
+ console.log('[统计] 返回:', statsResponse);
+ console.log('[统计] 最近记录:', statsResponse?.recentRecords);
+
+ setStats({
+ inCount: statsResponse?.inCount || 0,
+ outCount: statsResponse?.outCount || 0,
+ inQuantity: statsResponse?.inQuantity || 0,
+ outQuantity: statsResponse?.outQuantity || 0,
+ recentRecords: statsResponse?.recentRecords || [],
});
- // 加载汇总数据
- const summaryResponse = await api.get('/consumables/statistics/summary');
- setSummary(summaryResponse || {
- total: 0,
- lowStock: 0,
- totalValue: 0,
- byCategory: [],
+ const summaryResponse = await consumableAPI.getStatistics();
+ console.log('[汇总] 返回:', summaryResponse);
+
+ setSummary({
+ total: summaryResponse?.total || 0,
+ lowStock: summaryResponse?.lowStock || 0,
+ totalValue: summaryResponse?.totalValue || 0,
+ byCategory: summaryResponse?.byCategory || [],
});
} catch (error) {
const errorMsg = error?.message || error || '未知错误';
message.error('加载统计数据失败: ' + errorMsg);
console.error('加载统计数据失败:', error);
- // 使用默认数据
- setStats({
- inCount: 0,
- outCount: 0,
- inQuantity: 0,
- outQuantity: 0,
- recentRecords: [],
- });
- setSummary({
- total: 0,
- lowStock: 0,
- totalValue: 0,
- byCategory: [],
- });
+ setStats({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
+ setSummary({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
} finally {
setLoading(false);
}
};
- // 加载低库存预警
const loadLowStockItems = async () => {
try {
- const response = await api.get('/consumables/low-stock');
+ const response = await consumableAPI.getLowStock();
+ console.log('[低库存] 返回:', response);
setLowStockItems(response || []);
} catch (error) {
console.error('加载低库存预警失败:', error?.message || error);
@@ -479,11 +635,56 @@ const ConsumableStatistics = () => {
};
useEffect(() => {
+ loadCategories();
loadStatistics();
loadLowStockItems();
}, []);
- // 低库存表格列
+ const handleQuickFilter = (key) => {
+ setQuickFilter(key);
+ const filter = quickFilters.find(f => f.key === key);
+ if (filter) {
+ if (filter.days === 0) {
+ setDateRange([dayjs().startOf('day'), dayjs()]);
+ } else {
+ setDateRange([dayjs().subtract(filter.days, 'days'), dayjs()]);
+ }
+ }
+ };
+
+ const handleRefresh = () => {
+ loadStatistics();
+ loadLowStockItems();
+ message.success('数据已刷新');
+ };
+
+ const handleExport = () => {
+ message.info('导出功能开发中...');
+ };
+
+ const getCategoryColor = (category) => {
+ const predefinedColors = [
+ '#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6',
+ '#06b6d4', '#f97316', '#14b8a6', '#ef4444', '#3b82f6'
+ ];
+
+ const colorMap = {
+ '网络设备': '#6366f1',
+ '线缆': '#10b981',
+ '配件': '#f59e0b',
+ '工具': '#ec4899',
+ '其他': '#6b7280',
+ };
+
+ if (colorMap[category]) return colorMap[category];
+
+ let hash = 0;
+ for (let i = 0; i < category.length; i++) {
+ hash = category.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return predefinedColors[Math.abs(hash) % predefinedColors.length];
+ };
+
const lowStockColumns = [
{
title: '耗材名称',
@@ -492,20 +693,20 @@ const ConsumableStatistics = () => {
render: (text, record) => (
-
+
{text}
- {record.specification}
+ {record.specification || '-'}
@@ -516,90 +717,101 @@ const ConsumableStatistics = () => {
dataIndex: 'currentStock',
key: 'currentStock',
align: 'center',
+ width: 100,
render: (currentStock, record) => (
-
+
{currentStock} {record.unit}
),
},
{
- title: '最小库存',
+ title: '安全库存',
dataIndex: 'minStock',
key: 'minStock',
align: 'center',
+ width: 100,
render: (minStock, record) => (
-
- {minStock} {record.unit}
-
+ {minStock} {record.unit}
),
},
{
title: '充足率',
key: 'rate',
align: 'center',
+ width: 140,
render: (_, record) => {
- const rate = Math.min(100, Math.round((record.currentStock / (record.maxStock || 100)) * 100));
- const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
- const color = rate < 30 ? designTokens.colors.error.main :
- rate < 60 ? designTokens.colors.warning.main :
+ const minStock = record.minStock || 0;
+ const currentStock = record.currentStock || 0;
+
+ if (minStock <= 0) {
+ return 未设置;
+ }
+
+ const rate = Math.min(100, Math.round((currentStock / minStock) * 100));
+ const color = rate < 50 ? designTokens.colors.error.main :
+ rate < 100 ? designTokens.colors.warning.main :
designTokens.colors.success.main;
return (
-
+
);
},
},
- {
- title: '供应商',
- dataIndex: 'supplier',
- key: 'supplier',
- render: (value) => (
- {value || '-'}
- ),
- },
];
- // 最近记录表格列
const recentColumns = [
{
- title: '操作类型',
+ title: '类型',
dataIndex: 'type',
key: 'type',
+ width: 80,
render: (type) => (
: }
color={type === 'in' ? 'success' : 'processing'}
- style={{
- padding: '4px 12px',
- borderRadius: designTokens.borderRadius.full,
- fontWeight: 500,
- }}
+ style={{ borderRadius: 12, fontWeight: 500, border: 'none' }}
>
{type === 'in' ? '入库' : '出库'}
),
},
{
- title: '耗材名称',
- dataIndex: ['Consumable', 'name'],
+ title: '耗材',
+ dataIndex: 'consumableName',
key: 'consumableName',
- render: (text) => (
-
- {text}
-
+ width: 200,
+ render: (text, record) => (
+
+
+ {record.category?.charAt(0) || '耗'}
+
+
+
+ {text || '-'}
+
+ {record.category && (
+
+ {record.category}
+
+ )}
+
+
),
},
{
@@ -607,15 +819,16 @@ const ConsumableStatistics = () => {
dataIndex: 'quantity',
key: 'quantity',
align: 'center',
+ width: 90,
render: (quantity, record) => (
- {record.type === 'in' ? '+' : '-'}{quantity}
+ {record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'}
),
},
@@ -623,55 +836,40 @@ const ConsumableStatistics = () => {
title: '操作人',
dataIndex: 'operator',
key: 'operator',
+ width: 100,
render: (operator) => (
-
-
+
+
{operator?.charAt(0) || '?'}
- {operator}
+ {operator || '-'}
),
},
{
- title: '操作时间',
+ title: '时间',
dataIndex: 'createdAt',
key: 'createdAt',
+ width: 140,
render: (date) => (
-
-
- {dayjs(date).format('YYYY-MM-DD HH:mm')}
+
+ {dayjs(date).format('MM-DD HH:mm')}
),
},
];
- // 获取类别颜色
- const getCategoryColor = (category) => {
- const colors = {
- '网络设备': '#6366f1',
- '线缆': '#10b981',
- '配件': '#f59e0b',
- '工具': '#ec4899',
- '其他': '#6b7280',
- };
- return colors[category] || '#6366f1';
- };
-
- // 刷新数据
- const handleRefresh = () => {
- loadStatistics();
- loadLowStockItems();
- message.success('数据已刷新');
- };
+ const netQuantity = useMemo(() => {
+ return (stats?.inQuantity || 0) - (stats?.outQuantity || 0);
+ }, [stats]);
return (
- {/* 页面标题 */}
-
+
@@ -683,386 +881,312 @@ const ConsumableStatistics = () => {
}
+ onClick={handleExport}
+ style={{
+ height: 38,
+ borderRadius: 10,
+ borderColor: designTokens.colors.border,
+ }}
+ >
+ 导出报表
+
+ }
onClick={handleRefresh}
loading={loading}
style={{
- height: 40,
- borderRadius: designTokens.borderRadius.medium,
- borderColor: designTokens.colors.border,
+ height: 38,
+ borderRadius: 10,
+ background: designTokens.colors.primary.gradient,
+ border: 'none',
}}
>
- 刷新数据
+ 刷新
- {/* 筛选区域 */}
-
-
-
-
-
- 时间范围
-
-
-
-
-
-
- 耗材类别
-
-
-
-
-
-
-
-
+
+ {quickFilters.map(filter => (
+ handleQuickFilter(filter.key)}
+ >
+ {filter.label}
+
+ ))}
+
- {/* 统计卡片 */}
-
-
-
-
-
-
- {summary?.total || 0}
-
- 种不同类型耗材
-
-
+
+
+ 时间范围
+ {
+ setDateRange(dates);
+ setQuickFilter(null);
+ }}
+ style={{ ...datePickerStyles.range, width: 280 }}
+ allowClear={false}
+ />
+
+
+ 耗材类别
+
+
+
+
-
-
-
-
- {summary?.lowStock || 0}
-
- 低于安全库存
-
-
+
+
+
+ {summary?.total || 0}
+ 耗材种类
+
-
-
-
-
- {stats?.inCount || 0}
-
- 笔入库记录
-
-
+
+
+
+ {summary?.lowStock || 0}
+
+ 库存预警
+ {summary?.lowStock > 0 && (
+
+ 需关注
+
+ )}
+
-
-
-
-
- {stats?.outCount || 0}
-
- 笔出库记录
-
-
-
-
+
+
+
+ {stats?.inQuantity || 0}
+
+ 期间入库
+
- {/* 入库/出库统计 */}
-
-
+
+
+
+ {stats?.outQuantity || 0}
+
+ 期间出库
+
+
+
+
+
-
-
-
-
入库/出库统计
+
+
出入库统计
- {dateRange[0]?.format('YYYY-MM-DD')} 至 {dateRange[1]?.format('YYYY-MM-DD')}
+ {dateRange[0]?.format('MM/DD')} - {dateRange[1]?.format('MM/DD')}
-
-
-
-
-
- {stats?.inCount || 0}
-
- 入库次数
-
-
-
-
-
-
- {stats?.outCount || 0}
-
- 出库次数
-
-
-
-
-
-
-
-
- {stats?.inQuantity || 0}
-
- 入库数量
-
-
-
-
-
-
-
-
- {stats?.outQuantity || 0}
-
- 出库数量
-
-
-
+
+
+
+ {stats?.inCount || 0}
+ 入库次数
+ 共 {stats?.inQuantity || 0} 件
+
+
+
+ {stats?.outCount || 0}
+ 出库次数
+ 共 {stats?.outQuantity || 0} 件
+
+
+
= 0 ? 'rgba(16, 185, 129, 0.06)' : 'rgba(239, 68, 68, 0.06)',
+ borderRadius: 12,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ }}>
+ {netQuantity >= 0 ? (
+ <>
+
+
+ 净入库 +{netQuantity} 件
+
+ >
+ ) : (
+ <>
+
+
+ 净出库 {netQuantity} 件
+
+ >
+ )}
+
-
-
+
- {/* 类别统计 */}
-
-
+
{summary?.byCategory?.length > 0 ? (
-
- {summary.byCategory.map((item, index) => (
-
-
-
-
- {item.category}
-
-
- {item.count}
-
-
- 种耗材
-
-
-
- 总库存: {item.totalQuantity || 0}
-
-
-
+
+ {summary.byCategory.slice(0, 8).map((item, index) => (
+
+
+
+ {item.category}
+
+ {item.count}
+ 种耗材
+
+ 库存: {item.totalQuantity || 0}
+
+
))}
-
+
) : (
- 暂无类别统计数据
+ 暂无类别数据
添加耗材后将自动统计
)}
-
-
+
+
- {/* 低库存预警和最近记录 */}
-
-
-
-
-
-
-
-
- 库存充足
- 所有耗材库存均在安全范围内
-
- ),
- }}
- />
-
-
-
+
+
+
+
+
+
+ 库存充足
+ 所有耗材均在安全范围内
+
+ ),
+ }}
+ />
+
+
-
-
-
-
-
-
- 暂无记录
- 出入库操作后将显示在这里
-
- ),
- }}
- />
-
-
-
-
-
+
+
+
+
+
+ 暂无记录
+ 出入库操作后将显示
+
+ ),
+ }}
+ />
+
+
+
);
};
diff --git a/frontend/src/pages/InventoryManagement.jsx b/frontend/src/pages/InventoryManagement.jsx
index d74aa9c..c5ab5ad 100644
--- a/frontend/src/pages/InventoryManagement.jsx
+++ b/frontend/src/pages/InventoryManagement.jsx
@@ -473,7 +473,7 @@ const InventoryManagement = () => {
];
return (
-
+
{statCards.map((stat, index) => (
diff --git a/frontend/src/pages/InventoryTaskExecution.jsx b/frontend/src/pages/InventoryTaskExecution.jsx
index 43c5fef..11760b3 100644
--- a/frontend/src/pages/InventoryTaskExecution.jsx
+++ b/frontend/src/pages/InventoryTaskExecution.jsx
@@ -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 => (
{opt.label}
@@ -868,7 +868,7 @@ const InventoryTaskExecution = () => {
const pageContainerStyle = {
padding: '24px',
- background: designTokens.colors.bg,
+ background: designTokens.colors.background.secondary,
minHeight: '100vh',
};
diff --git a/frontend/src/pages/PendingDeviceManagement.jsx b/frontend/src/pages/PendingDeviceManagement.jsx
index 9215077..5f856cb 100644
--- a/frontend/src/pages/PendingDeviceManagement.jsx
+++ b/frontend/src/pages/PendingDeviceManagement.jsx
@@ -645,7 +645,7 @@ const PendingDeviceManagement = () => {
];
return (
-
+
diff --git a/frontend/src/pages/PortManagement.jsx b/frontend/src/pages/PortManagement.jsx
index 170928e..9fd7056 100644
--- a/frontend/src/pages/PortManagement.jsx
+++ b/frontend/src/pages/PortManagement.jsx
@@ -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: ,
@@ -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: ,
@@ -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: ,
@@ -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}
/>
) : (
-
-
- {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;
+
+ {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 (
-
-
-
-
- {getDeviceIcon(device)}
-
-
-
- {device?.name || '未知设备'}
-
-
- {device?.deviceId || '-'} · {device?.model || device?.type || '设备'}
-
-
-
-
-
- }
- >
- {freeCount}
-
-
-
- }
- >
- {occupiedCount}
-
-
- {faultCount > 0 && (
-
- }
- >
- {faultCount}
-
-
- )}
-
- 总计: {devicePorts.length}
-
-
-
+ return (
+
+ {/* 设备头部 */}
+ {
+ if (isExpanded) {
+ setExpandedKeys(prev => prev.filter(key => key !== deviceId));
+ } else {
+ setExpandedKeys(prev => [...prev, deviceId]);
}
- extra={
-
e.stopPropagation()}>
-
+ }}
+ >
+
+
+ {getDeviceIcon(device)}
+
+
+
+ {device?.name || '未知设备'}
+
+
+ {device?.deviceId || '-'} · {device?.model || device?.type || '设备'}
+
+
+
+
+
+
+ }>
+ {freeCount}
+
+
+
+ }>
+ {occupiedCount}
+
+
+ {faultCount > 0 && (
+
+ }>
+ {faultCount}
+
+
+ )}
+
+ 总计: {devicePorts.length}
+
+
+
+
+
+ }
+ onClick={e => {
+ e.stopPropagation();
+ handleAddPortForDevice(device);
+ }}
+ style={{ color: designTokens.colors.primary.main }}
+ />
+
+ {device?.type?.toLowerCase()?.includes('server') && (
+
}
- onClick={() => handleAddPortForDevice(device)}
+ icon={}
+ onClick={e => {
+ e.stopPropagation();
+ handleManageNetworkCards(device);
+ }}
style={{ color: designTokens.colors.primary.main }}
/>
- {device?.type?.toLowerCase()?.includes('server') && (
-
- }
- onClick={() => handleManageNetworkCards(device)}
- style={{ color: designTokens.colors.primary.main }}
- />
-
- )}
-
- }
- style={{
- background: '#fff',
- borderRadius: designTokens.borderRadius.lg,
- marginBottom: '12px',
- border: `1px solid ${designTokens.colors.neutral[200]}`,
- overflow: 'hidden',
- }}
- >
-
+ )}
+ : }
+ style={{ color: designTokens.colors.neutral[600], minWidth: '70px' }}
+ >
+ {isExpanded ? '收起' : '展开'}
+
+
+
+
+
+ {/* 端口列表 */}
+ {isExpanded && (
+
+ {devicePorts.length > 0 ? (
-
-
-
- );
- })}
-
-
+ ) : (
+
+ )}
+
+ )}
+
+ );
+ })}
+
)}
diff --git a/frontend/src/pages/RemoteBackupSettings.jsx b/frontend/src/pages/RemoteBackupSettings.jsx
index cd4d51f..34ffbf2 100644
--- a/frontend/src/pages/RemoteBackupSettings.jsx
+++ b/frontend/src/pages/RemoteBackupSettings.jsx
@@ -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',
}}
diff --git a/frontend/src/pages/SystemSettings.jsx b/frontend/src/pages/SystemSettings.jsx
index f5c6df5..7103a96 100644
--- a/frontend/src/pages/SystemSettings.jsx
+++ b/frontend/src/pages/SystemSettings.jsx
@@ -277,7 +277,7 @@ const SystemSettings = () => {
return (