feat(backup): 实现备份恢复进度实时显示功能

This commit is contained in:
zhang1106
2026-03-17 11:19:15 +08:00
parent 124cca65ba
commit 971ecd62d6
6 changed files with 589 additions and 341 deletions
+11 -3
View File
@@ -89,16 +89,24 @@ const verifyToken = (token) => {
const authMiddleware = async (req, res, next) => { const authMiddleware = async (req, res, next) => {
try { try {
const authHeader = req.headers.authorization; let token = null;
if (!authHeader || !authHeader.startsWith('Bearer ')) { const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
if (!token && req.query.token) {
token = req.query.token;
}
if (!token) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
message: '未提供认证令牌' message: '未提供认证令牌'
}); });
} }
const token = authHeader.substring(7);
const decoded = verifyToken(token); const decoded = verifyToken(token);
if (!decoded) { if (!decoded) {
+109
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const zlib = require('zlib'); const zlib = require('zlib');
const { authMiddleware } = require('../middleware/auth');
const { const {
getBackupPath, getBackupPath,
ensureBackupDir, ensureBackupDir,
@@ -174,6 +175,114 @@ router.get('/validate/:filename', async (req, res) => {
} }
}); });
router.get('/restore-progress/:filename', authMiddleware, async (req, res) => {
const { filename } = req.params;
const options = req.query.options ? JSON.parse(req.query.options) : {};
if (!filename) {
return res.status(400).json({
success: false,
message: '请提供备份文件名',
});
}
const backupPath = getBackupPath();
const filePath = path.join(backupPath, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({
success: false,
message: '备份文件不存在',
});
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const sendProgress = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
console.log(`开始恢复备份: ${filename}`);
sendProgress({ stage: 'start', message: '正在验证备份文件...', progress: 5 });
const validation = await validateBackupFile(filePath);
if (!validation.valid) {
sendProgress({ stage: 'error', message: `备份文件验证失败: ${validation.error}`, progress: 0 });
res.end();
return;
}
sendProgress({ stage: 'validate', message: '备份文件验证通过', progress: 10, metadata: validation.metadata });
const buffer = fs.readFileSync(filePath);
const isCompressed = filePath.endsWith('.gz');
let backupData;
if (isCompressed) {
sendProgress({ stage: 'decompress', message: '正在解压备份文件...', progress: 15 });
const decompressed = zlib.gunzipSync(buffer);
backupData = JSON.parse(decompressed.toString('utf8'));
} else {
backupData = JSON.parse(buffer.toString('utf8'));
}
sendProgress({ stage: 'parse', message: '正在解析备份数据...', progress: 20 });
const totalTables = require('../utils/backup').RESTORE_ORDER.length;
let processedTables = 0;
const result = await restoreBackup(filePath, {
overwriteExisting: options.overwriteExisting !== false,
skipTables: options.skipTables || [],
skipFiles: options.skipFiles === true,
onProgress: (tableName, status, count) => {
processedTables++;
const progress = 20 + Math.floor((processedTables / totalTables) * 70);
const statusMap = {
'restored': '已恢复',
'skipped': '已跳过',
'empty': '无数据',
'error': '错误',
};
sendProgress({
stage: 'restore',
message: `正在恢复: ${tableName} (${statusMap[status] || status}${count ? ` - ${count}` : ''})`,
progress,
currentTable: tableName,
status,
count,
processedTables,
totalTables,
});
},
});
sendProgress({
stage: 'complete',
message: '恢复完成!',
progress: 100,
result: {
tablesRestored: result.tablesRestored,
recordsRestored: result.recordsRestored,
filesRestored: result.filesRestored,
restoredAt: result.restoredAt,
tableDetails: result.tableDetails,
fileDetails: result.fileDetails,
}
});
res.end();
} catch (error) {
console.error('恢复备份失败:', error);
sendProgress({ stage: 'error', message: `恢复失败: ${error.message}`, progress: 0 });
res.end();
}
});
router.post('/restore', async (req, res) => { router.post('/restore', async (req, res) => {
try { try {
const { filename, options = {} } = req.body; const { filename, options = {} } = req.body;
+99 -76
View File
@@ -8,9 +8,26 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const zlib = require('zlib'); const zlib = require('zlib');
const { pipeline } = require('stream/promises'); const { pipeline } = require('stream/promises');
const { sequelize, dbDialect } = require('../db');
const BACKUP_VERSION = '2.0.0'; const BACKUP_VERSION = '2.0.0';
async function disableForeignKeyChecks() {
if (dbDialect === 'sqlite') {
await sequelize.query('PRAGMA foreign_keys = OFF');
} else if (dbDialect === 'mysql') {
await sequelize.query('SET FOREIGN_KEY_CHECKS = 0');
}
}
async function enableForeignKeyChecks() {
if (dbDialect === 'sqlite') {
await sequelize.query('PRAGMA foreign_keys = ON');
} else if (dbDialect === 'mysql') {
await sequelize.query('SET FOREIGN_KEY_CHECKS = 1');
}
}
// 数据表名称中英文映射 // 数据表名称中英文映射
const TABLE_NAME_MAPPING = { const TABLE_NAME_MAPPING = {
'User': '用户', 'User': '用户',
@@ -684,99 +701,105 @@ async function restoreData(backupData, options = {}) {
return results; return results;
} }
for (const tableName of RESTORE_ORDER) { await disableForeignKeyChecks();
if (skipTables.includes(tableName)) {
results.skipped.push(tableName);
onProgress(tableName, 'skipped');
continue;
}
const tableData = dataToRestore[tableName]; try {
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) { for (const tableName of RESTORE_ORDER) {
onProgress(tableName, 'empty'); if (skipTables.includes(tableName)) {
continue; results.skipped.push(tableName);
} onProgress(tableName, 'skipped');
continue;
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
if (!config) {
results.errors.push({ table: tableName, error: '未找到模型配置' });
continue;
}
try {
const Model = require(config.modelPath);
if (overwriteExisting) {
await Model.destroy({ where: {}, truncate: true });
} }
const processedRecords = tableData.map(record => { const tableData = dataToRestore[tableName];
const processed = { ...record }; if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
onProgress(tableName, 'empty');
continue;
}
if (tableName === 'Device' && processed.customFields !== undefined && processed.customFields !== null) { const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
if (typeof processed.customFields === 'string') { if (!config) {
try { results.errors.push({ table: tableName, error: '未找到模型配置' });
processed.customFields = JSON.parse(processed.customFields); continue;
} catch (e) { }
console.warn(`解析 Device.customFields 失败:${processed.deviceId}, 错误:${e.message}`);
processed.customFields = {}; try {
} const Model = require(config.modelPath);
}
if (overwriteExisting) {
await Model.destroy({ where: {}, truncate: true });
} }
return processed; const processedRecords = tableData.map(record => {
}); const processed = { ...record };
let insertedCount = 0; if (tableName === 'Device' && processed.customFields !== undefined && processed.customFields !== null) {
for (const record of processedRecords) { if (typeof processed.customFields === 'string') {
try { try {
await Model.create(record, { validate: false, silent: true }); processed.customFields = JSON.parse(processed.customFields);
insertedCount++; } catch (e) {
} catch (insertError) { console.warn(`解析 Device.customFields 失败:${processed.deviceId}, 错误:${e.message}`);
if (insertError.name === 'SequelizeUniqueConstraintError') { processed.customFields = {};
try { }
await Model.upsert(record, { validate: false, silent: true }); }
insertedCount++; }
} catch (upsertError) {
return processed;
});
let insertedCount = 0;
for (const record of processedRecords) {
try {
await Model.create(record, { validate: false, silent: true });
insertedCount++;
} catch (insertError) {
if (insertError.name === 'SequelizeUniqueConstraintError') {
try {
await Model.upsert(record, { validate: false, silent: true });
insertedCount++;
} catch (upsertError) {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: upsertError.message,
});
}
} else {
results.errors.push({ results.errors.push({
table: tableName, table: tableName,
record: record[Object.keys(record)[0]], record: record[Object.keys(record)[0]],
error: upsertError.message, error: insertError.message,
}); });
} }
} else {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: insertError.message,
});
} }
} }
results.tablesRestored++;
results.recordsRestored += insertedCount;
results.tableDetails[tableName] = {
recordCount: insertedCount,
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
success: insertedCount > 0,
};
onProgress(tableName, 'restored', insertedCount);
} catch (error) {
results.errors.push({ table: tableName, error: error.message });
onProgress(tableName, 'error', error.message);
} }
results.tablesRestored++;
results.recordsRestored += insertedCount;
results.tableDetails[tableName] = {
recordCount: insertedCount,
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
success: insertedCount > 0,
};
onProgress(tableName, 'restored', insertedCount);
} catch (error) {
results.errors.push({ table: tableName, error: error.message });
onProgress(tableName, 'error', error.message);
} }
}
if (isIncremental && backupData.incrementalData) { if (isIncremental && backupData.incrementalData) {
console.log('\n恢复增量数据...'); console.log('\n恢复增量数据...');
const incrementalResults = await restoreIncrementalData(backupData.incrementalData, options); const incrementalResults = await restoreIncrementalData(backupData.incrementalData, options);
results.tablesRestored += incrementalResults.tablesRestored; results.tablesRestored += incrementalResults.tablesRestored;
results.recordsRestored += incrementalResults.recordsRestored; results.recordsRestored += incrementalResults.recordsRestored;
results.errors.push(...incrementalResults.errors); results.errors.push(...incrementalResults.errors);
Object.assign(results.tableDetails, incrementalResults.tableDetails); Object.assign(results.tableDetails, incrementalResults.tableDetails);
}
} finally {
await enableForeignKeyChecks();
} }
return results; return results;
+356 -257
View File
@@ -40,6 +40,7 @@ import {
PlusOutlined, PlusOutlined,
ClearOutlined, ClearOutlined,
EyeOutlined, EyeOutlined,
TableOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import api, { backupAPI } from '../api'; import api, { backupAPI } from '../api';
import CloseButton from '../components/CloseButton'; import CloseButton from '../components/CloseButton';
@@ -542,290 +543,388 @@ const BackupManagement = () => {
const handleRestore = async (filename) => { const handleRestore = async (filename) => {
setRestoreLoading(true); setRestoreLoading(true);
setRestoreProgress(0); setRestoreProgress(0);
setRestoreStatus('正在验证备份文件...'); setRestoreStatus('正在初始化...');
try { const token = localStorage.getItem('token');
setRestoreProgress(10); const options = {
setRestoreStatus('正在读取备份数据...'); overwriteExisting: true,
skipFiles: false,
};
const response = await api.post('/backup/restore', { const eventSource = new EventSource(
filename, `/api/backup/restore-progress/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}&options=${encodeURIComponent(JSON.stringify(options))}`
options: { );
overwriteExisting: true,
skipFiles: false,
},
});
setRestoreProgress(90); let resultData = null;
setRestoreStatus('正在完成恢复...');
if (response?.success) { eventSource.onmessage = (event) => {
const data = response.data; try {
setRestoreProgress(100); const data = JSON.parse(event.data);
setRestoreStatus('恢复完成!');
setTimeout(() => { setRestoreProgress(data.progress);
setRestoreVisible(false); setRestoreStatus(data.message);
setRestoreLoading(false);
Modal.success({ if (data.stage === 'complete') {
title: ( resultData = data.result;
<Space> eventSource.close();
<div style={{
width: 40, setTimeout(() => {
height: 40, setRestoreVisible(false);
borderRadius: '12px', setRestoreLoading(false);
background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
display: 'flex', const successIconStyle = {
alignItems: 'center', width: 56,
justifyContent: 'center', height: 56,
}}> borderRadius: '16px',
<CheckCircleOutlined style={{ color: '#fff', fontSize: 24 }} /> background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
</div> display: 'flex',
<span style={{ fontSize: '20px', fontWeight: 700 }}>数据恢复成功</span> alignItems: 'center',
</Space> justifyContent: 'center',
), boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
width: 700, animation: 'successPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
content: ( };
<div style={{ marginTop: 16 }}>
{/* 汇总信息 */} const statCardStyle = (gradient, shadowColor) => ({
<div style={{ background: gradient,
padding: '16px', borderRadius: '16px',
background: designTokens.colors.primary.main + '08', padding: '20px 16px',
borderRadius: designTokens.borderRadius.md, textAlign: 'center',
border: `1px solid ${designTokens.colors.primary.main}20`, boxShadow: `0 4px 16px ${shadowColor}`,
marginBottom: 16, transition: 'transform 0.3s ease, box-shadow 0.3s ease',
}}> cursor: 'default',
<Row gutter={[16, 16]}> position: 'relative',
<Col span={6}> overflow: 'hidden',
<div style={{ textAlign: 'center' }}> });
<p style={{
margin: 0, const statValueStyle = {
fontSize: '24px', margin: 0,
fontWeight: 700, fontSize: '32px',
background: designTokens.colors.primary.gradient, fontWeight: 700,
WebkitBackgroundClip: 'text', color: '#ffffff',
WebkitTextFillColor: 'transparent', lineHeight: 1.2,
backgroundClip: 'text', textShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}> };
{data.tablesRestored}
</p> const statLabelStyle = {
<p style={{ margin: '8px 0 0 0',
margin: '4px 0 0 0', fontSize: '13px',
fontSize: 12, fontWeight: 500,
color: designTokens.colors.text.muted color: 'rgba(255,255,255,0.9)',
}}> letterSpacing: '0.5px',
恢复表数 };
</p>
</div> const tableCardStyle = {
</Col> padding: '12px 16px',
<Col span={6}> background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
<div style={{ textAlign: 'center' }}> borderRadius: '12px',
<p style={{ display: 'flex',
margin: 0, justifyContent: 'space-between',
fontSize: '24px', alignItems: 'center',
fontWeight: 700, border: '1px solid #e2e8f0',
background: designTokens.colors.success.gradient, transition: 'all 0.25s ease',
WebkitBackgroundClip: 'text', cursor: 'default',
WebkitTextFillColor: 'transparent', };
backgroundClip: 'text',
}}> Modal.success({
{data.recordsRestored} icon: null,
</p> title: null,
<p style={{ width: 720,
margin: '4px 0 0 0', content: (
fontSize: 12, <div style={{ marginTop: 8 }}>
color: designTokens.colors.text.muted <style>{`
}}> @keyframes successPop {
恢复记录 0% { transform: scale(0); opacity: 0; }
</p> 50% { transform: scale(1.1); }
</div> 100% { transform: scale(1); opacity: 1; }
</Col> }
<Col span={6}> @keyframes slideUp {
<div style={{ textAlign: 'center' }}> from { transform: translateY(20px); opacity: 0; }
<p style={{ to { transform: translateY(0); opacity: 1; }
margin: 0, }
fontSize: '24px', @keyframes shimmer {
fontWeight: 700, 0% { background-position: -200% 0; }
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)', 100% { background-position: 200% 0; }
WebkitBackgroundClip: 'text', }
WebkitTextFillColor: 'transparent', .stat-card:hover {
backgroundClip: 'text', transform: translateY(-4px) scale(1.02);
}}> }
{data.filesRestored} .table-card:hover {
</p> border-color: #6366f1;
<p style={{ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
margin: '4px 0 0 0', transform: translateX(4px);
fontSize: 12, }
color: designTokens.colors.text.muted .success-badge {
}}> background: linear-gradient(90deg, #10b981, #059669, #10b981);
恢复文件 background-size: 200% 100%;
</p> animation: shimmer 2s infinite linear;
</div> }
</Col> `}</style>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<p style={{
margin: 0,
fontSize: '24px',
fontWeight: 700,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>
{formatDateTime(data.restoredAt)}
</p>
<p style={{
margin: '4px 0 0 0',
fontSize: 12,
color: designTokens.colors.text.muted
}}>
恢复时间
</p>
</div>
</Col>
</Row>
</div>
{/* 数据表恢复详情 */}
{data.tableDetails && Object.keys(data.tableDetails).length > 0 && (
<div style={{ <div style={{
padding: '16px', display: 'flex',
background: designTokens.colors.background.accent, flexDirection: 'column',
borderRadius: designTokens.borderRadius.md, alignItems: 'center',
marginBottom: 16, marginBottom: 24,
animation: 'slideUp 0.5s ease forwards',
}}> }}>
<p style={{ <div style={successIconStyle}>
margin: '0 0 12px 0', <CheckCircleOutlined style={{ color: '#fff', fontSize: 28 }} />
fontWeight: 600, </div>
color: designTokens.colors.text.primary, <h2 style={{
display: 'flex', margin: '16px 0 4px 0',
alignItems: 'center', fontSize: '24px',
fontWeight: 700,
color: '#1e293b',
}}> }}>
<DatabaseOutlined style={{ marginRight: 8 }} /> 数据恢复成功
数据表恢复详情 </h2>
</p>
<div style={{ <div style={{
display: 'grid', display: 'inline-flex',
gridTemplateColumns: 'repeat(2, 1fr)', alignItems: 'center',
gap: '8px', gap: 6,
maxHeight: '300px', padding: '4px 12px',
overflowY: 'auto', borderRadius: '20px',
padding: '8px', background: '#ecfdf5',
background: designTokens.colors.background.secondary, border: '1px solid #a7f3d0',
borderRadius: designTokens.borderRadius.sm,
}}> }}>
{Object.entries(data.tableDetails).map(([tableName, tableInfo]) => ( <span style={{
<div width: 6,
key={tableName} height: 6,
style={{ borderRadius: '50%',
padding: '10px 12px', background: '#10b981',
background: designTokens.colors.background.secondary, }} />
borderRadius: designTokens.borderRadius.sm, <span style={{ fontSize: 12, color: '#059669', fontWeight: 500 }}>
display: 'flex', 所有数据已安全恢复
justifyContent: 'space-between', </span>
alignItems: 'center',
border: `1px solid ${designTokens.colors.text.muted}10`,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{
fontWeight: 600,
color: designTokens.colors.text.primary,
fontSize: 14,
}}>
{tableInfo.displayName || tableName}
</span>
{tableInfo.displayName !== tableName && (
<span style={{
fontSize: 11,
color: designTokens.colors.text.muted,
}}>
{tableName}
</span>
)}
</div>
<Tag color={tableInfo.success ? 'green' : 'default'} style={{ borderRadius: '4px' }}>
{tableInfo.recordCount}
</Tag>
</div>
))}
</div> </div>
</div> </div>
)}
{/* 文件恢复详情 */}
{data.fileDetails && (data.fileDetails.avatars > 0 || data.fileDetails.others > 0) && (
<div style={{ <div style={{
padding: '16px', display: 'grid',
background: designTokens.colors.background.accent, gridTemplateColumns: 'repeat(4, 1fr)',
borderRadius: designTokens.borderRadius.md, gap: 12,
marginBottom: 16, marginBottom: 20,
animation: 'slideUp 0.5s ease 0.1s forwards',
opacity: 0,
}}> }}>
<p style={{ <div className="stat-card" style={statCardStyle(
margin: '0 0 12px 0', 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
fontWeight: 600, 'rgba(99, 102, 241, 0.25)'
color: designTokens.colors.text.primary, )}>
<DatabaseOutlined style={{ fontSize: 20, color: 'rgba(255,255,255,0.8)', marginBottom: 8 }} />
<p style={statValueStyle}>{resultData?.tablesRestored || 0}</p>
<p style={statLabelStyle}>恢复表数</p>
</div>
<div className="stat-card" style={statCardStyle(
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
'rgba(16, 185, 129, 0.25)'
)}>
<TableOutlined style={{ fontSize: 20, color: 'rgba(255,255,255,0.8)', marginBottom: 8 }} />
<p style={statValueStyle}>{resultData?.recordsRestored || 0}</p>
<p style={statLabelStyle}>恢复记录</p>
</div>
<div className="stat-card" style={statCardStyle(
'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
'rgba(245, 158, 11, 0.25)'
)}>
<FileTextOutlined style={{ fontSize: 20, color: 'rgba(255,255,255,0.8)', marginBottom: 8 }} />
<p style={statValueStyle}>{resultData?.filesRestored || 0}</p>
<p style={statLabelStyle}>恢复文件</p>
</div>
<div className="stat-card" style={statCardStyle(
'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
'rgba(139, 92, 246, 0.25)'
)}>
<ClockCircleOutlined style={{ fontSize: 20, color: 'rgba(255,255,255,0.8)', marginBottom: 8 }} />
<p style={{...statValueStyle, fontSize: '18px'}}>{formatDateTime(resultData?.restoredAt)}</p>
<p style={statLabelStyle}>恢复时间</p>
</div>
</div>
{resultData?.tableDetails && Object.keys(resultData.tableDetails).length > 0 && (
<div style={{
padding: '16px',
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
borderRadius: '16px',
border: '1px solid #e2e8f0',
marginBottom: 16,
animation: 'slideUp 0.5s ease 0.2s forwards',
opacity: 0,
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 32,
height: 32,
borderRadius: '8px',
background: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<DatabaseOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
<span style={{ fontWeight: 600, color: '#1e293b', fontSize: 15 }}>
数据表恢复详情
</span>
</div>
<Tag style={{
background: '#eef2ff',
border: '1px solid #c7d2fe',
color: '#4f46e5',
borderRadius: '6px',
padding: '2px 8px',
}}>
{Object.keys(resultData.tableDetails).length} 个表
</Tag>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 8,
maxHeight: '240px',
overflowY: 'auto',
padding: '4px',
}}>
{Object.entries(resultData.tableDetails).map(([tableName, tableInfo], index) => (
<div
key={tableName}
className="table-card"
style={{
...tableCardStyle,
animationDelay: `${index * 0.03}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: tableInfo.success ? '#10b981' : '#94a3b8',
boxShadow: tableInfo.success ? '0 0 8px rgba(16, 185, 129, 0.5)' : 'none',
}} />
<span style={{
fontWeight: 600,
color: '#334155',
fontSize: 13,
}}>
{tableInfo.displayName || tableName}
</span>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: '6px',
background: tableInfo.success ? '#ecfdf5' : '#f1f5f9',
border: `1px solid ${tableInfo.success ? '#a7f3d0' : '#e2e8f0'}`,
}}>
<span style={{
fontSize: 13,
fontWeight: 600,
color: tableInfo.success ? '#059669' : '#64748b',
}}>
{tableInfo.recordCount}
</span>
<span style={{
fontSize: 11,
color: tableInfo.success ? '#10b981' : '#94a3b8',
}}>
</span>
</div>
</div>
))}
</div>
</div>
)}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '14px 16px',
background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)',
borderRadius: '12px',
border: '1px solid #bfdbfe',
animation: 'slideUp 0.5s ease 0.3s forwards',
opacity: 0,
}}>
<div style={{
width: 36,
height: 36,
borderRadius: '10px',
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}> }}>
<FileTextOutlined style={{ marginRight: 8 }} /> <InfoCircleOutlined style={{ color: '#fff', fontSize: 18 }} />
文件恢复详情 </div>
</p> <div>
<p style={{ margin: 0, fontWeight: 600, color: '#1e40af', fontSize: 14 }}>
{data.fileDetails.avatars > 0 && ( 建议刷新页面以确保所有数据生效
<div style={{ marginBottom: 12 }}> </p>
<p style={{ <p style={{ margin: '4px 0 0 0', fontSize: 12, color: '#3b82f6' }}>
margin: '0 0 8px 0', 刷新后可查看最新恢复的数据内容
fontSize: 13, </p>
fontWeight: 500, </div>
color: designTokens.colors.text.secondary,
}}>
头像文件{data.fileDetails.avatars}
</p>
</div>
)}
{data.fileDetails.others > 0 && (
<div>
<p style={{
margin: '0 0 8px 0',
fontSize: 13,
fontWeight: 500,
color: designTokens.colors.text.secondary,
}}>
其他文件{data.fileDetails.others}
</p>
</div>
)}
</div> </div>
)} </div>
),
centered: true,
maskClosable: false,
okText: '完成',
cancelButtonProps: { style: { display: 'none' } },
okButtonProps: {
style: {
background: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
border: 'none',
borderRadius: '10px',
height: '44px',
padding: '0 32px',
fontSize: '15px',
fontWeight: 600,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
},
},
});
}, 500);
}
<Alert if (data.stage === 'error') {
message="建议刷新页面以确保所有数据生效" eventSource.close();
type="info" setRestoreLoading(false);
showIcon Modal.error({
style={{ marginTop: 16 }} title: '数据恢复失败',
/> content: data.message,
</div>
),
maskClosable: false,
okText: '完成',
okButtonProps: {
style: primaryButtonStyle,
},
}); });
}, 500); }
} else { } catch (e) {
throw new Error(response.data?.message || '恢复失败'); console.error('解析 SSE 数据失败:', e);
} }
} catch (error) { };
eventSource.onerror = (error) => {
console.error('SSE 连接错误:', error);
eventSource.close();
setRestoreLoading(false); setRestoreLoading(false);
Modal.error({ Modal.error({
title: '数据恢复失败', title: '数据恢复失败',
content: error.response?.data?.message || error.message || '未知错误', content: '连接中断,请重试',
}); });
} };
}; };
const handleDelete = async (filename) => { const handleDelete = async (filename) => {
@@ -648,7 +648,7 @@ const PendingDeviceManagement = () => {
<div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}> <div style={{ padding: 24, background: designTokens.colors.background.secondary, minHeight: '100vh' }}>
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}> <Col span={6}>
<Card bordered={false} style={{ borderRadius: 12 }}> <Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic <Statistic
title="暂存设备总数" title="暂存设备总数"
value={stats.total} value={stats.total}
@@ -657,7 +657,7 @@ const PendingDeviceManagement = () => {
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card bordered={false} style={{ borderRadius: 12 }}> <Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic <Statistic
title="待同步" title="待同步"
value={stats.pending} value={stats.pending}
@@ -667,7 +667,7 @@ const PendingDeviceManagement = () => {
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card bordered={false} style={{ borderRadius: 12 }}> <Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic <Statistic
title="已同步" title="已同步"
value={stats.synced} value={stats.synced}
@@ -677,7 +677,7 @@ const PendingDeviceManagement = () => {
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card bordered={false} style={{ borderRadius: 12 }}> <Card bordered={false} style={{ borderRadius: 12, height: '100%', minHeight: 100 }}>
<Statistic <Statistic
title="同步进度" title="同步进度"
value={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0} value={stats.total > 0 ? Math.round((stats.synced / stats.total) * 100) : 0}
+9
View File
@@ -51,6 +51,15 @@ export default defineConfig({
host: '0.0.0.0', host: '0.0.0.0',
port: port, port: port,
proxy: { proxy: {
'/api/backup/restore-progress': {
target: 'http://localhost:8000',
changeOrigin: true,
configure: (proxy, _options) => {
proxy.on('proxyReq', (proxyReq, req, _res) => {
proxyReq.setHeader('Connection', 'keep-alive');
});
}
},
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true changeOrigin: true