feat(backup): 实现备份恢复进度实时显示功能
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +701,9 @@ async function restoreData(backupData, options = {}) {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await disableForeignKeyChecks();
|
||||||
|
|
||||||
|
try {
|
||||||
for (const tableName of RESTORE_ORDER) {
|
for (const tableName of RESTORE_ORDER) {
|
||||||
if (skipTables.includes(tableName)) {
|
if (skipTables.includes(tableName)) {
|
||||||
results.skipped.push(tableName);
|
results.skipped.push(tableName);
|
||||||
@@ -778,6 +798,9 @@ async function restoreData(backupData, options = {}) {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('正在读取备份数据...');
|
|
||||||
|
|
||||||
const response = await api.post('/backup/restore', {
|
|
||||||
filename,
|
|
||||||
options: {
|
|
||||||
overwriteExisting: true,
|
overwriteExisting: true,
|
||||||
skipFiles: false,
|
skipFiles: false,
|
||||||
},
|
};
|
||||||
});
|
|
||||||
|
|
||||||
setRestoreProgress(90);
|
const eventSource = new EventSource(
|
||||||
setRestoreStatus('正在完成恢复...');
|
`/api/backup/restore-progress/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}&options=${encodeURIComponent(JSON.stringify(options))}`
|
||||||
|
);
|
||||||
|
|
||||||
if (response?.success) {
|
let resultData = null;
|
||||||
const data = response.data;
|
|
||||||
setRestoreProgress(100);
|
eventSource.onmessage = (event) => {
|
||||||
setRestoreStatus('恢复完成!');
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
setRestoreProgress(data.progress);
|
||||||
|
setRestoreStatus(data.message);
|
||||||
|
|
||||||
|
if (data.stage === 'complete') {
|
||||||
|
resultData = data.result;
|
||||||
|
eventSource.close();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRestoreVisible(false);
|
setRestoreVisible(false);
|
||||||
setRestoreLoading(false);
|
setRestoreLoading(false);
|
||||||
Modal.success({
|
|
||||||
title: (
|
const successIconStyle = {
|
||||||
<Space>
|
width: 56,
|
||||||
<div style={{
|
height: 56,
|
||||||
width: 40,
|
borderRadius: '16px',
|
||||||
height: 40,
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
|
||||||
|
animation: 'successPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCardStyle = (gradient, shadowColor) => ({
|
||||||
|
background: gradient,
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '20px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
boxShadow: `0 4px 16px ${shadowColor}`,
|
||||||
|
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||||
|
cursor: 'default',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
const statValueStyle = {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statLabelStyle = {
|
||||||
|
margin: '8px 0 0 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableCardStyle = {
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
cursor: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
Modal.success({
|
||||||
|
icon: null,
|
||||||
|
title: null,
|
||||||
|
width: 720,
|
||||||
|
content: (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<style>{`
|
||||||
|
@keyframes successPop {
|
||||||
|
0% { transform: scale(0); opacity: 0; }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
}
|
||||||
|
.table-card:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
.success-badge {
|
||||||
|
background: linear-gradient(90deg, #10b981, #059669, #10b981);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s infinite linear;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
animation: 'slideUp 0.5s ease forwards',
|
||||||
|
}}>
|
||||||
|
<div style={successIconStyle}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#fff', fontSize: 28 }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{
|
||||||
|
margin: '16px 0 4px 0',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1e293b',
|
||||||
|
}}>
|
||||||
|
数据恢复成功
|
||||||
|
</h2>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: '#ecfdf5',
|
||||||
|
border: '1px solid #a7f3d0',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#10b981',
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 12, color: '#059669', fontWeight: 500 }}>
|
||||||
|
所有数据已安全恢复
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
animation: 'slideUp 0.5s ease 0.1s forwards',
|
||||||
|
opacity: 0,
|
||||||
|
}}>
|
||||||
|
<div className="stat-card" style={statCardStyle(
|
||||||
|
'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
|
||||||
|
'rgba(99, 102, 241, 0.25)'
|
||||||
|
)}>
|
||||||
|
<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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
<CheckCircleOutlined style={{ color: '#fff', fontSize: 24 }} />
|
<DatabaseOutlined style={{ color: '#fff', fontSize: 16 }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '20px', fontWeight: 700 }}>数据恢复成功</span>
|
<span style={{ fontWeight: 600, color: '#1e293b', fontSize: 15 }}>
|
||||||
</Space>
|
数据表恢复详情
|
||||||
),
|
</span>
|
||||||
width: 700,
|
|
||||||
content: (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
{/* 汇总信息 */}
|
|
||||||
<div style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: designTokens.colors.primary.main + '08',
|
|
||||||
borderRadius: designTokens.borderRadius.md,
|
|
||||||
border: `1px solid ${designTokens.colors.primary.main}20`,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={6}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<p style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 700,
|
|
||||||
background: designTokens.colors.primary.gradient,
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
}}>
|
|
||||||
{data.tablesRestored}
|
|
||||||
</p>
|
|
||||||
<p style={{
|
|
||||||
margin: '4px 0 0 0',
|
|
||||||
fontSize: 12,
|
|
||||||
color: designTokens.colors.text.muted
|
|
||||||
}}>
|
|
||||||
恢复表数
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
<Tag style={{
|
||||||
<Col span={6}>
|
background: '#eef2ff',
|
||||||
<div style={{ textAlign: 'center' }}>
|
border: '1px solid #c7d2fe',
|
||||||
<p style={{
|
color: '#4f46e5',
|
||||||
margin: 0,
|
borderRadius: '6px',
|
||||||
fontSize: '24px',
|
padding: '2px 8px',
|
||||||
fontWeight: 700,
|
|
||||||
background: designTokens.colors.success.gradient,
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
}}>
|
}}>
|
||||||
{data.recordsRestored}
|
共 {Object.keys(resultData.tableDetails).length} 个表
|
||||||
</p>
|
</Tag>
|
||||||
<p style={{
|
|
||||||
margin: '4px 0 0 0',
|
|
||||||
fontSize: 12,
|
|
||||||
color: designTokens.colors.text.muted
|
|
||||||
}}>
|
|
||||||
恢复记录
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<p style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 700,
|
|
||||||
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
}}>
|
|
||||||
{data.filesRestored}
|
|
||||||
</p>
|
|
||||||
<p style={{
|
|
||||||
margin: '4px 0 0 0',
|
|
||||||
fontSize: 12,
|
|
||||||
color: designTokens.colors.text.muted
|
|
||||||
}}>
|
|
||||||
恢复文件
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* 数据表恢复详情 */}
|
|
||||||
{data.tableDetails && Object.keys(data.tableDetails).length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: designTokens.colors.background.accent,
|
|
||||||
borderRadius: designTokens.borderRadius.md,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
<p style={{
|
|
||||||
margin: '0 0 12px 0',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: designTokens.colors.text.primary,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
|
||||||
数据表恢复详情
|
|
||||||
</p>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
gap: '8px',
|
gap: 8,
|
||||||
maxHeight: '300px',
|
maxHeight: '240px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '8px',
|
padding: '4px',
|
||||||
background: designTokens.colors.background.secondary,
|
|
||||||
borderRadius: designTokens.borderRadius.sm,
|
|
||||||
}}>
|
}}>
|
||||||
{Object.entries(data.tableDetails).map(([tableName, tableInfo]) => (
|
{Object.entries(resultData.tableDetails).map(([tableName, tableInfo], index) => (
|
||||||
<div
|
<div
|
||||||
key={tableName}
|
key={tableName}
|
||||||
|
className="table-card"
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 12px',
|
...tableCardStyle,
|
||||||
background: designTokens.colors.background.secondary,
|
animationDelay: `${index * 0.03}s`,
|
||||||
borderRadius: designTokens.borderRadius.sm,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
border: `1px solid ${designTokens.colors.text.muted}10`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<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={{
|
<span style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: designTokens.colors.text.primary,
|
color: '#334155',
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
}}>
|
}}>
|
||||||
{tableInfo.displayName || tableName}
|
{tableInfo.displayName || tableName}
|
||||||
</span>
|
</span>
|
||||||
{tableInfo.displayName !== tableName && (
|
</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={{
|
<span style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: designTokens.colors.text.muted,
|
color: tableInfo.success ? '#10b981' : '#94a3b8',
|
||||||
}}>
|
}}>
|
||||||
{tableName}
|
条
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Tag color={tableInfo.success ? 'green' : 'default'} style={{ borderRadius: '4px' }}>
|
|
||||||
{tableInfo.recordCount} 条
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 文件恢复详情 */}
|
|
||||||
{data.fileDetails && (data.fileDetails.avatars > 0 || data.fileDetails.others > 0) && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px',
|
|
||||||
background: designTokens.colors.background.accent,
|
|
||||||
borderRadius: designTokens.borderRadius.md,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
<p style={{
|
|
||||||
margin: '0 0 12px 0',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: designTokens.colors.text.primary,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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,
|
||||||
}}>
|
}}>
|
||||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
<div style={{
|
||||||
文件恢复详情
|
width: 36,
|
||||||
</p>
|
height: 36,
|
||||||
|
borderRadius: '10px',
|
||||||
{data.fileDetails.avatars > 0 && (
|
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||||
<div style={{ marginBottom: 12 }}>
|
display: 'flex',
|
||||||
<p style={{
|
alignItems: 'center',
|
||||||
margin: '0 0 8px 0',
|
justifyContent: 'center',
|
||||||
fontSize: 13,
|
flexShrink: 0,
|
||||||
fontWeight: 500,
|
|
||||||
color: designTokens.colors.text.secondary,
|
|
||||||
}}>
|
}}>
|
||||||
头像文件:{data.fileDetails.avatars} 个
|
<InfoCircleOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{data.fileDetails.others > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{
|
<p style={{ margin: 0, fontWeight: 600, color: '#1e40af', fontSize: 14 }}>
|
||||||
margin: '0 0 8px 0',
|
建议刷新页面以确保所有数据生效
|
||||||
fontSize: 13,
|
</p>
|
||||||
fontWeight: 500,
|
<p style={{ margin: '4px 0 0 0', fontSize: 12, color: '#3b82f6' }}>
|
||||||
color: designTokens.colors.text.secondary,
|
刷新后可查看最新恢复的数据内容
|
||||||
}}>
|
|
||||||
其他文件:{data.fileDetails.others} 个
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
message="建议刷新页面以确保所有数据生效"
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
centered: true,
|
||||||
maskClosable: false,
|
maskClosable: false,
|
||||||
okText: '完成',
|
okText: '完成',
|
||||||
|
cancelButtonProps: { style: { display: 'none' } },
|
||||||
okButtonProps: {
|
okButtonProps: {
|
||||||
style: primaryButtonStyle,
|
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);
|
}, 500);
|
||||||
} else {
|
|
||||||
throw new Error(response.data?.message || '恢复失败');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
|
if (data.stage === 'error') {
|
||||||
|
eventSource.close();
|
||||||
setRestoreLoading(false);
|
setRestoreLoading(false);
|
||||||
Modal.error({
|
Modal.error({
|
||||||
title: '数据恢复失败',
|
title: '数据恢复失败',
|
||||||
content: error.response?.data?.message || error.message || '未知错误',
|
content: data.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析 SSE 数据失败:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE 连接错误:', error);
|
||||||
|
eventSource.close();
|
||||||
|
setRestoreLoading(false);
|
||||||
|
Modal.error({
|
||||||
|
title: '数据恢复失败',
|
||||||
|
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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user