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 ( +
+
+
+
+ +
+ : +
+ +
+
+
+ + {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) => ( + +
- {/* 状态概览 */}
<ThunderboltOutlined style={{ marginRight: 8 }} /> @@ -378,9 +842,7 @@ const AutoBackupSettings = () => { </Row> </div> - {/* 设置卡片 */} <Row gutter={[24, 24]}> - {/* 基本设置 */} <Col xs={24} lg={12}> <Card title={ @@ -425,37 +887,23 @@ const AutoBackupSettings = () => { title="备份时间" description="每天自动执行备份的时间,建议设置在业务低峰期" > - <Space> - <InputNumber - min={0} - max={23} - value={settings.hour} - onChange={(value) => { - setSettings({ ...settings, hour: value }); - setModified(true); - }} - addonAfter="时" - disabled={!settings.enabled} - style={{ width: 100 }} - /> - <InputNumber - min={0} - max={59} - value={settings.minute} - onChange={(value) => { - setSettings({ ...settings, minute: value }); - setModified(true); - }} - addonAfter="分" - disabled={!settings.enabled} - style={{ width: 100 }} - /> - </Space> + <TimePicker + hour={settings.hour} + minute={settings.minute} + onChange={(newHour, newMinute) => { + setSettings(prev => ({ + ...prev, + hour: newHour, + minute: newMinute, + })); + setModified(true); + }} + disabled={!settings.enabled} + /> </SettingItem> </Card> </Col> - {/* 高级设置 */} <Col xs={24} lg={12}> <Card title={ @@ -480,6 +928,23 @@ const AutoBackupSettings = () => { height: '100%', }} > + <SettingItem + title="备份类型" + description="选择全量备份或增量备份。全量备份每次备份完整数据;增量备份仅备份变化数据,节省空间" + > + <Radio.Group + value={settings.backupType} + onChange={(e) => { + setSettings({ ...settings, backupType: e.target.value }); + setModified(true); + }} + disabled={!settings.enabled} + > + <Radio value="full">全量备份</Radio> + <Radio value="incremental">增量备份</Radio> + </Radio.Group> + </SettingItem> + <SettingItem title="包含文件" description="备份时包含上传的文件(如用户头像等)" @@ -552,7 +1017,101 @@ const AutoBackupSettings = () => { </Col> </Row> - {/* 提示信息 */} + <Card + title={ + <Space> + <div style={{ + width: 36, + height: 36, + borderRadius: '10px', + background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }}> + <HistoryOutlined style={{ color: '#fff', fontSize: 18 }} /> + </div> + <span style={{ fontSize: 16, fontWeight: 600 }}>备份日志</span> + </Space> + } + style={{ + borderRadius: '20px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)', + marginTop: 24, + }} + extra={ + <Button + icon={<ReloadOutlined />} + onClick={handleRefreshLogs} + loading={logsLoading} + > + 刷新 + </Button> + } + > + <div style={{ marginBottom: 16 }}> + <Space> + <Text>类型:</Text> + <Select + placeholder="全部类型" + style={{ width: 150 }} + allowClear + value={logFilter.logType || undefined} + onChange={(value) => handleLogFilterChange('logType', value)} + > + <Option value="auto">自动备份</Option> + <Option value="manual">手动备份</Option> + </Select> + <Text>状态:</Text> + <Select + placeholder="全部状态" + style={{ width: 150 }} + allowClear + value={logFilter.status || undefined} + onChange={(value) => handleLogFilterChange('status', value)} + > + <Option value="pending">待执行</Option> + <Option value="running">执行中</Option> + <Option value="success">成功</Option> + <Option value="failed">失败</Option> + </Select> + <Button + type="primary" + onClick={() => fetchLogs(1, logsPagination.pageSize)} + > + 筛选 + </Button> + </Space> + </div> + + <Table + columns={logColumns} + dataSource={logs} + rowKey="id" + loading={logsLoading} + pagination={false} + locale={{ + emptyText: ( + <Empty + image={Empty.PRESENTED_IMAGE_SIMPLE} + description="暂无备份日志" + /> + ), + }} + /> + + <div style={{ marginTop: 16, textAlign: 'right' }}> + <Pagination + current={logsPagination.current} + pageSize={logsPagination.pageSize} + total={logsPagination.total} + onChange={handleLogPageChange} + showSizeChanger + showTotal={(total) => `共 ${total} 条`} + /> + </div> + </Card> + <div style={{ marginTop: 24 }}> <Alert message={ @@ -601,7 +1160,6 @@ const AutoBackupSettings = () => { /> </div> - {/* 安全提示 */} <div style={{ marginTop: 24 }}> <Alert message={ @@ -621,8 +1179,83 @@ const AutoBackupSettings = () => { /> </div> </div> + + <Modal + title="备份日志详情" + open={logDetailModal} + onCancel={() => setLogDetailModal(false)} + footer={[ + <Button key="close" onClick={() => setLogDetailModal(false)}> + 关闭 + </Button> + ]} + width={700} + > + {selectedLog && ( + <Descriptions column={1} bordered> + <Descriptions.Item label="类型"> + {getLogTypeTag(selectedLog.logType)} + </Descriptions.Item> + <Descriptions.Item label="状态"> + {getLogStatusTag(selectedLog.status)} + </Descriptions.Item> + <Descriptions.Item label="描述"> + {selectedLog.description || '-'} + </Descriptions.Item> + <Descriptions.Item label="备份类型"> + {selectedLog.backupType === 'full' ? '全量备份' : '增量备份'} + </Descriptions.Item> + <Descriptions.Item label="文件名"> + {selectedLog.filename || '-'} + </Descriptions.Item> + <Descriptions.Item label="文件大小"> + {formatFileSize(selectedLog.fileSize)} + </Descriptions.Item> + <Descriptions.Item label="包含文件"> + {selectedLog.includeFiles ? '是' : '否'} + </Descriptions.Item> + <Descriptions.Item label="压缩"> + {selectedLog.compressed ? '是' : '否'} + </Descriptions.Item> + <Descriptions.Item label="开始时间"> + {formatDateTime(selectedLog.startTime)} + </Descriptions.Item> + <Descriptions.Item label="结束时间"> + {formatDateTime(selectedLog.endTime)} + </Descriptions.Item> + <Descriptions.Item label="执行时长"> + {formatDuration(selectedLog.duration)} + </Descriptions.Item> + <Descriptions.Item label="创建时间"> + {formatDateTime(selectedLog.createdAt)} + </Descriptions.Item> + {selectedLog.errorMessage && ( + <Descriptions.Item label="错误信息"> + <Text type="danger">{selectedLog.errorMessage}</Text> + </Descriptions.Item> + )} + {selectedLog.remoteUploads && selectedLog.remoteUploads.length > 0 && ( + <Descriptions.Item label="远端上传"> + <div> + {selectedLog.remoteUploads.map((upload, index) => ( + <div key={index} style={{ marginBottom: 8 }}> + <Tag color={upload.success ? 'success' : 'error'}> + {upload.targetName} + </Tag> + <Text style={{ marginLeft: 8 }}> + {upload.success ? '上传成功' : `失败: ${upload.error}`} + </Text> + </div> + ))} + </div> + </Descriptions.Item> + )} + </Descriptions> + )} + </Modal> </div> ); }; export default AutoBackupSettings; + 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, }} /> </Tooltip> @@ -1195,7 +1195,7 @@ const BackupManagement = () => { }, danger: { base: { - background: designTokens.colors.danger.gradient, + background: designTokens.colors.error.gradient, border: 'none', borderRadius: designTokens.borderRadius.md, padding: '8px 20px', 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) => ( <Space> <Avatar - size={36} + size={34} style={{ - background: designTokens.colors.warning.gradient, - fontSize: '16px', + background: `linear-gradient(135deg, ${designTokens.colors.warning.main}, #fb923c)`, + fontSize: '14px', }} > <WarningOutlined /> </Avatar> <div> - <div style={{ fontWeight: 600, color: designTokens.colors.text.primary }}> + <div style={{ fontWeight: 600, color: designTokens.colors.text.primary, fontSize: '14px' }}> {text} </div> <div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}> - {record.specification} + {record.specification || '-'} </div> </div> </Space> @@ -516,90 +717,101 @@ const ConsumableStatistics = () => { dataIndex: 'currentStock', key: 'currentStock', align: 'center', + width: 100, render: (currentStock, record) => ( - <Text strong style={{ fontSize: '16px', color: designTokens.colors.error.main }}> + <Text strong style={{ fontSize: '15px', color: designTokens.colors.error.main }}> {currentStock} {record.unit} </Text> ), }, { - title: '最小库存', + title: '安全库存', dataIndex: 'minStock', key: 'minStock', align: 'center', + width: 100, render: (minStock, record) => ( - <Text type="secondary"> - {minStock} {record.unit} - </Text> + <Text type="secondary">{minStock} {record.unit}</Text> ), }, { 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 <Text type="secondary" style={{ fontSize: '12px' }}>未设置</Text>; + } + + 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 ( - <ProgressBar color={color}> + <ProgressBar> <div className="progress-wrapper"> <Progress percent={rate} size="small" - status={status} strokeColor={color} showInfo={false} /> - <span className="progress-text" style={{ color }}> - {rate}% - </span> + <span className="progress-text" style={{ color }}>{rate}%</span> </div> </ProgressBar> ); }, }, - { - title: '供应商', - dataIndex: 'supplier', - key: 'supplier', - render: (value) => ( - <Text type="secondary">{value || '-'}</Text> - ), - }, ]; - // 最近记录表格列 const recentColumns = [ { - title: '操作类型', + title: '类型', dataIndex: 'type', key: 'type', + width: 80, render: (type) => ( <Tag icon={type === 'in' ? <ArrowDownOutlined /> : <ArrowUpOutlined />} color={type === 'in' ? 'success' : 'processing'} - style={{ - padding: '4px 12px', - borderRadius: designTokens.borderRadius.full, - fontWeight: 500, - }} + style={{ borderRadius: 12, fontWeight: 500, border: 'none' }} > {type === 'in' ? '入库' : '出库'} </Tag> ), }, { - title: '耗材名称', - dataIndex: ['Consumable', 'name'], + title: '耗材', + dataIndex: 'consumableName', key: 'consumableName', - render: (text) => ( - <Text strong style={{ color: designTokens.colors.text.primary }}> - {text} - </Text> + width: 200, + render: (text, record) => ( + <Space> + <Avatar + size={30} + style={{ + background: record.category ? getCategoryColor(record.category) : designTokens.colors.info.main, + fontSize: '12px', + }} + > + {record.category?.charAt(0) || '耗'} + </Avatar> + <div> + <div style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}> + {text || '-'} + </div> + {record.category && ( + <div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}> + {record.category} + </div> + )} + </div> + </Space> ), }, { @@ -607,15 +819,16 @@ const ConsumableStatistics = () => { dataIndex: 'quantity', key: 'quantity', align: 'center', + width: 90, render: (quantity, record) => ( <Text strong style={{ - fontSize: '16px', - color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.primary.main, + fontSize: '15px', + color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main, }} > - {record.type === 'in' ? '+' : '-'}{quantity} + {record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'} </Text> ), }, @@ -623,55 +836,40 @@ const ConsumableStatistics = () => { title: '操作人', dataIndex: 'operator', key: 'operator', + width: 100, render: (operator) => ( - <Space> - <Avatar size={28} style={{ background: designTokens.colors.info.main }}> + <Space size={6}> + <Avatar size={24} style={{ background: designTokens.colors.info.main, fontSize: '12px' }}> {operator?.charAt(0) || '?'} </Avatar> - <Text>{operator}</Text> + <Text style={{ fontSize: '13px' }}>{operator || '-'}</Text> </Space> ), }, { - title: '操作时间', + title: '时间', dataIndex: 'createdAt', key: 'createdAt', + width: 140, render: (date) => ( - <Text type="secondary" style={{ fontSize: '13px' }}> - <CalendarOutlined style={{ marginRight: 6 }} /> - {dayjs(date).format('YYYY-MM-DD HH:mm')} + <Text type="secondary" style={{ fontSize: '12px' }}> + {dayjs(date).format('MM-DD HH:mm')} </Text> ), }, ]; - // 获取类别颜色 - 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 ( <PageContainer> - {/* 页面标题 */} - <PageHeader variants={containerVariants} initial="hidden" animate="visible"> + <PageHeader variants={itemVariants} initial="hidden" animate="visible"> <TitleSection> <motion.div className="icon-wrapper" - whileHover={{ scale: 1.05, rotate: 5 }} + whileHover={{ scale: 1.05, rotate: 3 }} transition={{ type: 'spring', stiffness: 400 }} > <BarChartOutlined /> @@ -683,386 +881,312 @@ const ConsumableStatistics = () => { </TitleSection> <Space> <Button + icon={<ExportOutlined />} + onClick={handleExport} + style={{ + height: 38, + borderRadius: 10, + borderColor: designTokens.colors.border, + }} + > + 导出报表 + </Button> + <Button + type="primary" icon={<ReloadOutlined />} 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', }} > - 刷新数据 + 刷新 </Button> </Space> </PageHeader> - {/* 筛选区域 */} - <FilterSection variants={itemVariants}> - <Row gutter={[16, 16]} align="middle"> - <Col xs={24} sm={12} md={8} lg={6}> - <Text strong style={{ display: 'block', marginBottom: 8, color: designTokens.colors.text.primary }}> - <CalendarOutlined style={{ marginRight: 8 }} /> - 时间范围 - </Text> - <RangePicker - value={dateRange} - onChange={setDateRange} - style={datePickerStyles.range} - allowClear={false} - placeholder={inputPlaceholders.dateRange} - /> - </Col> - <Col xs={24} sm={12} md={8} lg={6}> - <Text strong style={{ display: 'block', marginBottom: 8, color: designTokens.colors.text.primary }}> - <AppstoreOutlined style={{ marginRight: 8 }} /> - 耗材类别 - </Text> - <Select - value={categoryFilter} - onChange={setCategoryFilter} - style={selectStyles.base} - placeholder="选择类别" - > - <Option value="all">全部类别</Option> - <Option value="网络设备">网络设备</Option> - <Option value="线缆">线缆</Option> - <Option value="配件">配件</Option> - <Option value="工具">工具</Option> - <Option value="其他">其他</Option> - </Select> - </Col> - <Col xs={24} sm={24} md={8} lg={12} style={{ display: 'flex', alignItems: 'flex-end' }}> - <Button - type="primary" - onClick={loadStatistics} - loading={loading} - style={{ - height: '40px', - borderRadius: designTokens.borderRadius.medium, - background: designTokens.colors.primary.gradient, - border: 'none', - }} - > - 应用筛选 - </Button> - </Col> - </Row> - </FilterSection> + <QuickFilterBar variants={containerVariants} initial="hidden" animate="visible"> + {quickFilters.map(filter => ( + <QuickFilterBtn + key={filter.key} + $active={quickFilter === filter.key} + onClick={() => handleQuickFilter(filter.key)} + > + {filter.label} + </QuickFilterBtn> + ))} + </QuickFilterBar> - {/* 统计卡片 */} - <motion.div - variants={containerVariants} - initial="hidden" - animate="visible" - > - <Row gutter={[24, 24]} style={{ marginBottom: 24 }}> - <Col xs={24} sm={12} lg={6}> - <StatsCard - variants={itemVariants} - whileHover="hover" - accent={designTokens.colors.primary.gradient} - iconBg="rgba(99, 102, 241, 0.1)" - iconColor={designTokens.colors.primary.main} - valueGradient={designTokens.colors.primary.gradient} - > - <div className="card-header"> - <div className="icon-box"> - <DatabaseOutlined /> - </div> - <span className="card-title">耗材总数</span> - </div> - <div className="stat-value"> - {summary?.total || 0} - </div> - <div className="stat-label">种不同类型耗材</div> - </StatsCard> - </Col> + <FilterCard variants={itemVariants}> + <FilterItem> + <span className="filter-label">时间范围</span> + <RangePicker + value={dateRange} + onChange={(dates) => { + setDateRange(dates); + setQuickFilter(null); + }} + style={{ ...datePickerStyles.range, width: 280 }} + allowClear={false} + /> + </FilterItem> + <FilterItem> + <span className="filter-label">耗材类别</span> + <Select + value={categoryFilter} + onChange={setCategoryFilter} + style={{ ...selectStyles.base, width: 160 }} + > + <Option value="all">全部类别</Option> + {categories.map(cat => ( + <Option key={cat.id} value={cat.name}>{cat.name}</Option> + ))} + </Select> + </FilterItem> + <Button + type="primary" + onClick={loadStatistics} + loading={loading} + style={{ + height: 38, + borderRadius: 10, + background: designTokens.colors.primary.gradient, + border: 'none', + marginLeft: 'auto', + }} + > + 应用筛选 + </Button> + </FilterCard> - <Col xs={24} sm={12} lg={6}> - <StatsCard - variants={itemVariants} - whileHover="hover" - accent={designTokens.colors.warning.gradient} - iconBg="rgba(245, 158, 11, 0.1)" - iconColor={designTokens.colors.warning.main} - > - <div className="card-header"> - <div className="icon-box" style={{ color: designTokens.colors.warning.main, background: 'rgba(245, 158, 11, 0.1)' }}> - <WarningOutlined /> - </div> - <span className="card-title">库存预警</span> - </div> - <div className="stat-value" style={{ color: designTokens.colors.warning.main }}> - {summary?.lowStock || 0} - </div> - <div className="stat-label">低于安全库存</div> - </StatsCard> - </Col> + <StatsGrid variants={containerVariants} initial="hidden" animate="visible"> + <StatsCard + variants={itemVariants} + $accent={designTokens.colors.primary.gradient} + $iconBg="rgba(99, 102, 241, 0.1)" + $iconColor={designTokens.colors.primary.main} + whileHover={{ y: -4 }} + > + <div className="card-icon"><DatabaseOutlined /></div> + <div className="card-value">{summary?.total || 0}</div> + <div className="card-label">耗材种类</div> + </StatsCard> - <Col xs={24} sm={12} lg={6}> - <StatsCard - variants={itemVariants} - whileHover="hover" - accent={designTokens.colors.success.gradient} - iconBg="rgba(16, 185, 129, 0.1)" - iconColor={designTokens.colors.success.main} - > - <div className="card-header"> - <div className="icon-box" style={{ color: designTokens.colors.success.main, background: 'rgba(16, 185, 129, 0.1)' }}> - <ArrowDownOutlined /> - </div> - <span className="card-title">本月入库</span> - </div> - <div className="stat-value" style={{ color: designTokens.colors.success.main }}> - {stats?.inCount || 0} - </div> - <div className="stat-label">笔入库记录</div> - </StatsCard> - </Col> + <StatsCard + variants={itemVariants} + $accent={designTokens.colors.warning.gradient} + $iconBg="rgba(245, 158, 11, 0.1)" + $iconColor={designTokens.colors.warning.main} + whileHover={{ y: -4 }} + > + <div className="card-icon"><WarningOutlined /></div> + <div className="card-value" style={{ color: designTokens.colors.warning.main }}> + {summary?.lowStock || 0} + </div> + <div className="card-label">库存预警</div> + {summary?.lowStock > 0 && ( + <div className="card-trend down"> + <ExclamationCircleOutlined /> 需关注 + </div> + )} + </StatsCard> - <Col xs={24} sm={12} lg={6}> - <StatsCard - variants={itemVariants} - whileHover="hover" - accent={designTokens.colors.secondary.gradient} - iconBg="rgba(236, 72, 153, 0.1)" - iconColor={designTokens.colors.secondary.main} - > - <div className="card-header"> - <div className="icon-box" style={{ color: designTokens.colors.secondary.main, background: 'rgba(236, 72, 153, 0.1)' }}> - <ArrowUpOutlined /> - </div> - <span className="card-title">本月出库</span> - </div> - <div className="stat-value" style={{ color: designTokens.colors.secondary.main }}> - {stats?.outCount || 0} - </div> - <div className="stat-label">笔出库记录</div> - </StatsCard> - </Col> - </Row> - </motion.div> + <StatsCard + variants={itemVariants} + $accent={designTokens.colors.success.gradient} + $iconBg="rgba(16, 185, 129, 0.1)" + $iconColor={designTokens.colors.success.main} + whileHover={{ y: -4 }} + > + <div className="card-icon"><ArrowDownOutlined /></div> + <div className="card-value" style={{ color: designTokens.colors.success.main }}> + {stats?.inQuantity || 0} + </div> + <div className="card-label">期间入库</div> + </StatsCard> - {/* 入库/出库统计 */} - <motion.div - variants={containerVariants} - initial="hidden" - animate="visible" - style={{ marginBottom: 24 }} - > - <ContentCard variants={itemVariants} iconBg={designTokens.colors.info.gradient}> + <StatsCard + variants={itemVariants} + $accent={designTokens.colors.secondary.gradient} + $iconBg="rgba(236, 72, 153, 0.1)" + $iconColor={designTokens.colors.secondary.main} + whileHover={{ y: -4 }} + > + <div className="card-icon"><ArrowUpOutlined /></div> + <div className="card-value" style={{ color: designTokens.colors.secondary.main }}> + {stats?.outQuantity || 0} + </div> + <div className="card-label">期间出库</div> + </StatsCard> + </StatsGrid> + + <BentoGrid variants={containerVariants} initial="hidden" animate="visible"> + <BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.info.gradient}> <div className="card-header"> <div className="header-left"> - <div className="header-icon" style={{ background: designTokens.colors.info.gradient }}> - <ShoppingCartOutlined /> - </div> - <span className="header-title">入库/出库统计</span> + <div className="header-icon"><ShoppingCartOutlined /></div> + <span className="header-title">出入库统计</span> </div> <div className="header-extra"> - {dateRange[0]?.format('YYYY-MM-DD')} 至 {dateRange[1]?.format('YYYY-MM-DD')} + {dateRange[0]?.format('MM/DD')} - {dateRange[1]?.format('MM/DD')} </div> </div> <div className="card-body"> - <Row gutter={[48, 24]}> - <Col xs={24} md={12}> - <StatItem bg="rgba(16, 185, 129, 0.05)" iconColor={designTokens.colors.success.main}> - <div className="stat-icon"> - <ArrowDownOutlined /> - </div> - <div className="stat-number" style={{ color: designTokens.colors.success.main }}> - {stats?.inCount || 0} - </div> - <div className="stat-label">入库次数</div> - </StatItem> - </Col> - <Col xs={24} md={12}> - <StatItem bg="rgba(99, 102, 241, 0.05)" iconColor={designTokens.colors.primary.main}> - <div className="stat-icon"> - <ArrowUpOutlined /> - </div> - <div className="stat-number" style={{ color: designTokens.colors.primary.main }}> - {stats?.outCount || 0} - </div> - <div className="stat-label">出库次数</div> - </StatItem> - </Col> - <Col xs={24} md={12}> - <StatItem bg="rgba(16, 185, 129, 0.05)" iconColor={designTokens.colors.success.main}> - <div className="stat-icon"> - <DatabaseOutlined /> - </div> - <div className="stat-number" style={{ color: designTokens.colors.success.main }}> - {stats?.inQuantity || 0} - </div> - <div className="stat-label">入库数量</div> - </StatItem> - </Col> - <Col xs={24} md={12}> - <StatItem bg="rgba(99, 102, 241, 0.05)" iconColor={designTokens.colors.primary.main}> - <div className="stat-icon"> - <BoxPlotOutlined /> - </div> - <div className="stat-number" style={{ color: designTokens.colors.primary.main }}> - {stats?.outQuantity || 0} - </div> - <div className="stat-label">出库数量</div> - </StatItem> - </Col> - </Row> + <InOutComparison> + <ComparisonItem + $bg="rgba(16, 185, 129, 0.04)" + $color={designTokens.colors.success.main} + > + <div className="item-icon"><ArrowDownOutlined /></div> + <div className="item-value">{stats?.inCount || 0}</div> + <div className="item-label">入库次数</div> + <div className="item-sub">共 <strong>{stats?.inQuantity || 0}</strong> 件</div> + </ComparisonItem> + <ComparisonItem + $bg="rgba(99, 102, 241, 0.04)" + $color={designTokens.colors.primary.main} + > + <div className="item-icon"><ArrowUpOutlined /></div> + <div className="item-value">{stats?.outCount || 0}</div> + <div className="item-label">出库次数</div> + <div className="item-sub">共 <strong>{stats?.outQuantity || 0}</strong> 件</div> + </ComparisonItem> + </InOutComparison> + <div style={{ + marginTop: 20, + padding: '16px', + background: netQuantity >= 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 ? ( + <> + <RiseOutlined style={{ color: designTokens.colors.success.main, fontSize: 18 }} /> + <Text style={{ color: designTokens.colors.success.main, fontWeight: 600 }}> + 净入库 +{netQuantity} 件 + </Text> + </> + ) : ( + <> + <FallOutlined style={{ color: designTokens.colors.error.main, fontSize: 18 }} /> + <Text style={{ color: designTokens.colors.error.main, fontWeight: 600 }}> + 净出库 {netQuantity} 件 + </Text> + </> + )} + </div> </div> - </ContentCard> - </motion.div> + </BentoCard> - {/* 类别统计 */} - <motion.div - variants={containerVariants} - initial="hidden" - animate="visible" - style={{ marginBottom: 24 }} - > - <ContentCard variants={itemVariants} iconBg={designTokens.colors.secondary.gradient}> + <BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.secondary.gradient}> <div className="card-header"> <div className="header-left"> - <div className="header-icon" style={{ background: designTokens.colors.secondary.gradient }}> - <PieChartOutlined /> - </div> - <span className="header-title">类别统计</span> + <div className="header-icon"><PieChartOutlined /></div> + <span className="header-title">类别分布</span> </div> </div> <div className="card-body"> {summary?.byCategory?.length > 0 ? ( - <Row gutter={[24, 24]}> - {summary.byCategory.map((item, index) => ( - <Col xs={24} sm={12} md={8} lg={6} key={item.category}> - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ delay: index * 0.1 }} - whileHover={{ scale: 1.03 }} - style={{ - padding: 20, - background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)', - borderRadius: designTokens.borderRadius.medium, - border: `1px solid ${designTokens.colors.border}`, - textAlign: 'center', - }} - > - <CategoryTag - color={getCategoryColor(item.category)} - style={{ - background: `${getCategoryColor(item.category)}15`, - marginBottom: 16, - fontSize: '14px', - padding: '6px 16px', - }} - > - <span - className="dot" - style={{ background: getCategoryColor(item.category) }} - /> - {item.category} - </CategoryTag> - <div style={{ fontSize: '28px', fontWeight: 700, color: designTokens.colors.text.primary }}> - {item.count} - </div> - <div style={{ fontSize: '13px', color: designTokens.colors.text.secondary, marginTop: 4 }}> - 种耗材 - </div> - <Divider style={{ margin: '12px 0' }} /> - <div style={{ fontSize: '13px', color: designTokens.colors.text.secondary }}> - 总库存: <Text strong>{item.totalQuantity || 0}</Text> - </div> - </motion.div> - </Col> + <CategoryGrid> + {summary.byCategory.slice(0, 8).map((item, index) => ( + <CategoryCard + key={item.category} + $color={getCategoryColor(item.category)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ delay: index * 0.05 }} + > + <div className="category-badge"> + <span className="dot" /> + {item.category} + </div> + <div className="category-count">{item.count}</div> + <div className="category-label">种耗材</div> + <div className="category-stock"> + 库存: <strong>{item.totalQuantity || 0}</strong> + </div> + </CategoryCard> ))} - </Row> + </CategoryGrid> ) : ( <EmptyState> <PieChartOutlined className="empty-icon" /> - <div className="empty-text">暂无类别统计数据</div> + <div className="empty-text">暂无类别数据</div> <div className="empty-subtext">添加耗材后将自动统计</div> </EmptyState> )} </div> - </ContentCard> - </motion.div> + </BentoCard> + </BentoGrid> - {/* 低库存预警和最近记录 */} - <motion.div - variants={containerVariants} - initial="hidden" - animate="visible" - > - <Row gutter={[24, 24]}> - <Col xs={24} lg={12}> - <ContentCard variants={itemVariants} iconBg={designTokens.colors.warning.gradient}> - <div className="card-header"> - <div className="header-left"> - <div className="header-icon" style={{ background: designTokens.colors.warning.gradient }}> - <ExclamationCircleOutlined /> - </div> - <span className="header-title">库存预警</span> - </div> - <Badge - count={lowStockItems.length} - style={{ backgroundColor: designTokens.colors.warning.main }} - /> - </div> - <div className="card-body" style={{ padding: 0 }}> - <StyledTable - columns={lowStockColumns} - dataSource={lowStockItems} - rowKey="consumableId" - pagination={false} - scroll={{ x: 'max-content' }} - locale={{ - emptyText: ( - <EmptyState> - <BoxPlotOutlined className="empty-icon" style={{ color: designTokens.colors.success.main }} /> - <div className="empty-text">库存充足</div> - <div className="empty-subtext">所有耗材库存均在安全范围内</div> - </EmptyState> - ), - }} - /> - </div> - </ContentCard> - </Col> + <BentoGrid variants={containerVariants} initial="hidden" animate="visible"> + <BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.warning.gradient}> + <div className="card-header"> + <div className="header-left"> + <div className="header-icon"><ExclamationCircleOutlined /></div> + <span className="header-title">库存预警</span> + </div> + <Badge + count={lowStockItems.length} + style={{ backgroundColor: designTokens.colors.warning.main }} + /> + </div> + <div className="card-body" style={{ padding: 0 }}> + <StyledTable + columns={lowStockColumns} + dataSource={lowStockItems} + rowKey="consumableId" + pagination={false} + size="small" + scroll={{ x: 'max-content' }} + locale={{ + emptyText: ( + <EmptyState> + <BoxPlotOutlined className="empty-icon" style={{ color: designTokens.colors.success.main }} /> + <div className="empty-text">库存充足</div> + <div className="empty-subtext">所有耗材均在安全范围内</div> + </EmptyState> + ), + }} + /> + </div> + </BentoCard> - <Col xs={24} lg={12}> - <ContentCard variants={itemVariants} iconBg={designTokens.colors.success.gradient}> - <div className="card-header"> - <div className="header-left"> - <div className="header-icon" style={{ background: designTokens.colors.success.gradient }}> - <HistoryOutlined /> - </div> - <span className="header-title">最近记录</span> - </div> - <div className="header-extra">最近10条</div> - </div> - <div className="card-body" style={{ padding: 0 }}> - <StyledTable - columns={recentColumns} - dataSource={stats?.recentRecords || []} - rowKey="recordId" - pagination={false} - scroll={{ x: 'max-content' }} - locale={{ - emptyText: ( - <EmptyState> - <HistoryOutlined className="empty-icon" /> - <div className="empty-text">暂无记录</div> - <div className="empty-subtext">出入库操作后将显示在这里</div> - </EmptyState> - ), - }} - /> - </div> - </ContentCard> - </Col> - </Row> - </motion.div> + <BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.success.gradient}> + <div className="card-header"> + <div className="header-left"> + <div className="header-icon"><HistoryOutlined /></div> + <span className="header-title">最近记录</span> + </div> + <div className="header-extra">最近10条</div> + </div> + <div className="card-body" style={{ padding: 0 }}> + <StyledTable + columns={recentColumns} + dataSource={stats?.recentRecords || []} + rowKey="recordId" + pagination={false} + size="small" + scroll={{ x: 'max-content' }} + locale={{ + emptyText: ( + <EmptyState> + <HistoryOutlined className="empty-icon" /> + <div className="empty-text">暂无记录</div> + <div className="empty-subtext">出入库操作后将显示</div> + </EmptyState> + ), + }} + /> + </div> + </BentoCard> + </BentoGrid> </PageContainer> ); }; 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 ( - <div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}> + <div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}> <div style={{ marginBottom: 24 }}> <Row gutter={[16, 16]}> {statCards.map((stat, index) => ( 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 => ( <Select.Option key={opt.value} value={opt.value}> {opt.label} </Select.Option> @@ -868,7 +868,7 @@ const InventoryTaskExecution = () => { const pageContainerStyle = { padding: '24px', - background: designTokens.colors.bg, + background: designTokens.colors.background.secondary, minHeight: '100vh', }; 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 ( - <div style={{ padding: 24, background: designTokens.colors.bg, minHeight: '100vh' }}> + <div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}> <Row gutter={16} style={{ marginBottom: 24 }}> <Col span={6}> <Card bordered={false} style={{ borderRadius: 12 }}> 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: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />, @@ -327,7 +330,7 @@ function PortManagement() { const values = await form.validateFields(); if (editingPort) { - await axios.put(`/api/device-ports/${editingPort.portId}`, values); + await api.put(`/device-ports/${editingPort.portId}`, values); message.success({ content: '更新成功', icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />, @@ -347,7 +350,7 @@ function PortManagement() { description: values.description, })); - const response = await axios.post('/api/device-ports/batch', { ports: portsData }); + const response = await api.post('/device-ports/batch', { ports: portsData }); const { success, failed } = response.data; if (failed > 0) { @@ -359,7 +362,7 @@ function PortManagement() { }); } } else { - await axios.post('/api/device-ports', values); + await api.post('/device-ports', values); message.success({ content: '创建成功', icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />, @@ -498,8 +501,8 @@ function PortManagement() { description: row['描述'], })); - const response = await axios.post('/api/device-ports/batch', { ports: portsData }); - const { total, success, failed, errors } = response.data; + const response = await api.post('/device-ports/batch', { ports: portsData }); + const { total, success, failed, errors } = response; setImportProgress({ current: total, total: total }); @@ -972,138 +975,135 @@ function PortManagement() { loadMoreCount={5} /> ) : ( - <Collapse - activeKey={expandedKeys} - onChange={setExpandedKeys} - style={{ background: 'transparent', border: 'none' }} - expandIconPosition="end" - > - <AnimatePresence> - {Object.entries(groupedPorts).map(([deviceId, data], index) => { - const device = data.device; - const devicePorts = data.ports || []; - const freeCount = devicePorts.filter(p => p.status === 'free').length; - const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length; - const faultCount = devicePorts.filter(p => p.status === 'fault').length; + <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> + {Object.entries(groupedPorts).map(([deviceId, data], index) => { + const device = data.device; + const devicePorts = data.ports || []; + const freeCount = devicePorts.filter(p => p.status === 'free').length; + const occupiedCount = devicePorts.filter(p => p.status === 'occupied').length; + const faultCount = devicePorts.filter(p => p.status === 'fault').length; + const isExpanded = expandedKeys.includes(deviceId); - return ( - <motion.div - key={deviceId} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.3, delay: index * 0.05 }} - > - <Panel - header={ - <div - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - width: '100%', - paddingRight: '16px', - }} - > - <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> - <div - style={{ - width: '48px', - height: '48px', - borderRadius: designTokens.borderRadius.md, - background: designTokens.colors.primary.gradient, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: '#fff', - fontSize: '24px', - boxShadow: designTokens.shadows.md, - }} - > - {getDeviceIcon(device)} - </div> - <div> - <div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}> - {device?.name || '未知设备'} - </div> - <div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}> - {device?.deviceId || '-'} · {device?.model || device?.type || '设备'} - </div> - </div> - </div> - <Space size="middle"> - <Tooltip title="空闲"> - <Tag - color="success" - style={{ borderRadius: '4px', padding: '4px 12px' }} - icon={<CheckCircleOutlined />} - > - {freeCount} - </Tag> - </Tooltip> - <Tooltip title="占用"> - <Tag - color="processing" - style={{ borderRadius: '4px', padding: '4px 12px' }} - icon={<AppstoreOutlined />} - > - {occupiedCount} - </Tag> - </Tooltip> - {faultCount > 0 && ( - <Tooltip title="故障"> - <Tag - color="error" - style={{ borderRadius: '4px', padding: '4px 12px' }} - icon={<ExclamationCircleOutlined />} - > - {faultCount} - </Tag> - </Tooltip> - )} - <Tag - color="blue" - style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }} - > - 总计: {devicePorts.length} - </Tag> - </Space> - </div> + return ( + <Card + key={deviceId} + style={{ + borderRadius: designTokens.borderRadius.lg, + border: `1px solid ${designTokens.colors.neutral[200]}`, + overflow: 'hidden', + }} + bodyStyle={{ padding: 0 }} + > + {/* 设备头部 */} + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 20px', + background: isExpanded ? designTokens.colors.primary.light : '#fff', + cursor: 'pointer', + transition: 'background 0.2s', + }} + onClick={() => { + if (isExpanded) { + setExpandedKeys(prev => prev.filter(key => key !== deviceId)); + } else { + setExpandedKeys(prev => [...prev, deviceId]); } - extra={ - <Space size="small" onClick={e => e.stopPropagation()}> - <Tooltip title="添加端口"> + }} + > + <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> + <div + style={{ + width: '48px', + height: '48px', + borderRadius: designTokens.borderRadius.md, + background: designTokens.colors.primary.gradient, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#fff', + fontSize: '24px', + boxShadow: designTokens.shadows.md, + }} + > + {getDeviceIcon(device)} + </div> + <div> + <div style={{ fontWeight: 600, fontSize: '16px', color: designTokens.colors.neutral[800] }}> + {device?.name || '未知设备'} + </div> + <div style={{ fontSize: '13px', color: designTokens.colors.neutral[500], marginTop: '2px' }}> + {device?.deviceId || '-'} · {device?.model || device?.type || '设备'} + </div> + </div> + </div> + <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> + <Space size="small"> + <Tooltip title="空闲"> + <Tag color="success" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<CheckCircleOutlined />}> + {freeCount} + </Tag> + </Tooltip> + <Tooltip title="占用"> + <Tag color="processing" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<AppstoreOutlined />}> + {occupiedCount} + </Tag> + </Tooltip> + {faultCount > 0 && ( + <Tooltip title="故障"> + <Tag color="error" style={{ borderRadius: '4px', padding: '4px 12px' }} icon={<ExclamationCircleOutlined />}> + {faultCount} + </Tag> + </Tooltip> + )} + <Tag color="blue" style={{ borderRadius: '4px', padding: '4px 12px', fontWeight: 500 }}> + 总计: {devicePorts.length} + </Tag> + </Space> + <Divider type="vertical" style={{ height: '24px', margin: '0 8px' }} /> + <Space size="small"> + <Tooltip title="添加端口"> + <Button + type="text" + icon={<PlusOutlined />} + onClick={e => { + e.stopPropagation(); + handleAddPortForDevice(device); + }} + style={{ color: designTokens.colors.primary.main }} + /> + </Tooltip> + {device?.type?.toLowerCase()?.includes('server') && ( + <Tooltip title="网卡管理"> <Button type="text" - icon={<PlusOutlined />} - onClick={() => handleAddPortForDevice(device)} + icon={<CloudServerOutlined />} + onClick={e => { + e.stopPropagation(); + handleManageNetworkCards(device); + }} style={{ color: designTokens.colors.primary.main }} /> </Tooltip> - {device?.type?.toLowerCase()?.includes('server') && ( - <Tooltip title="网卡管理"> - <Button - type="text" - icon={<CloudServerOutlined />} - onClick={() => handleManageNetworkCards(device)} - style={{ color: designTokens.colors.primary.main }} - /> - </Tooltip> - )} - </Space> - } - style={{ - background: '#fff', - borderRadius: designTokens.borderRadius.lg, - marginBottom: '12px', - border: `1px solid ${designTokens.colors.neutral[200]}`, - overflow: 'hidden', - }} - > - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 0.2 }} - > + )} + <Button + type="text" + size="small" + icon={isExpanded ? <UpOutlined /> : <DownOutlined />} + style={{ color: designTokens.colors.neutral[600], minWidth: '70px' }} + > + {isExpanded ? '收起' : '展开'} + </Button> + </Space> + </div> + </div> + + {/* 端口列表 */} + {isExpanded && ( + <div style={{ padding: '16px 20px', borderTop: `1px solid ${designTokens.colors.neutral[200]}` }}> + {devicePorts.length > 0 ? ( <Table columns={portColumns} dataSource={devicePorts} @@ -1116,18 +1116,16 @@ function PortManagement() { }} size="middle" scroll={{ x: 1000 }} - style={{ - borderRadius: designTokens.borderRadius.md, - overflow: 'hidden', - }} /> - </motion.div> - </Panel> - </motion.div> - ); - })} - </AnimatePresence> - </Collapse> + ) : ( + <Empty description="暂无端口数据" style={{ padding: '24px 0' }} /> + )} + </div> + )} + </Card> + ); + })} + </div> )} </Card> </motion.div> 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 ( <Form.Item key={key} label={data.description || key} name={key}> <Select> - {options.map(opt => ( + {(Array.isArray(options) ? options : []).map(opt => ( <Option key={opt.value} value={opt.value}> {opt.label} </Option>