Files
yunrui_asset/frontend/src/pages/BackupManagement.jsx
T

2087 lines
71 KiB
React
Raw Normal View History

import React, { useState, useEffect, useCallback } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
message,
Popconfirm,
Tag,
Tooltip,
Progress,
Descriptions,
Alert,
Divider,
Typography,
Upload,
Statistic,
Row,
Col,
Dropdown,
} from 'antd';
import {
CloudDownloadOutlined,
CloudUploadOutlined,
CloudOutlined,
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
ReloadOutlined,
FileTextOutlined,
DatabaseOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
SettingOutlined,
MoreOutlined,
SafetyOutlined,
PlusOutlined,
ClearOutlined,
EyeOutlined,
TableOutlined,
} from '@ant-design/icons';
import api, { backupAPI } from '../api';
import CloseButton from '../components/CloseButton';
import { useNavigate } from 'react-router-dom';
import secureStorage, { TOKEN_KEY } from '../utils/secureStorage';
const { Title, Text } = Typography;
const formatBytes = bytes => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDateTime = dateStr => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('zh-CN');
};
// 现代化设计系统
const designTokens = {
colors: {
primary: {
main: '#667eea',
light: '#764ba2',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
gradientHover: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)',
},
success: {
main: '#10b981',
light: '#34d399',
gradient: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
},
danger: {
main: '#ef4444',
light: '#f87171',
gradient: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
},
background: {
primary: '#f8fafc',
secondary: '#ffffff',
accent: '#f1f5f9',
},
text: {
primary: '#1e293b',
secondary: '#64748b',
muted: '#94a3b8',
},
border: {
light: '#e2e8f0',
medium: '#cbd5e1',
dark: '#94a3b8',
},
},
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
glow: '0 0 20px rgba(102, 126, 234, 0.3)',
},
borderRadius: {
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
},
spacing: {
xs: '8px',
sm: '16px',
md: '24px',
lg: '32px',
xl: '40px',
},
};
const BackupManagement = () => {
const navigate = useNavigate();
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [selectedBackup, setSelectedBackup] = useState(null);
const [detailVisible, setDetailVisible] = useState(false);
const [restoreVisible, setRestoreVisible] = useState(false);
const [backupInfo, setBackupInfo] = useState(null);
const [restoreProgress, setRestoreProgress] = useState(0);
const [restoreStatus, setRestoreStatus] = useState('');
const fetchBackups = useCallback(async () => {
setLoading(true);
try {
const response = await api.get('/backup/list');
if (response?.success) {
setBackups(response.data?.backups || []);
}
} catch (error) {
message.error('获取备份列表失败');
} finally {
setLoading(false);
}
}, []);
const fetchBackupInfo = useCallback(async () => {
try {
const response = await api.get('/backup/info');
if (response?.success) {
setBackupInfo(response.data);
}
} catch (error) {
console.error('获取备份信息失败:', error);
}
}, []);
useEffect(() => {
fetchBackups();
fetchBackupInfo();
}, [fetchBackups, fetchBackupInfo]);
const handleCreateBackup = async () => {
setBackupLoading(true);
try {
const response = await api.post('/backup', {
description: '手动备份',
includeFiles: true,
});
if (response?.success) {
message.success('备份创建成功');
fetchBackups();
fetchBackupInfo();
} else {
message.error(response.data?.message || '备份创建失败');
}
} catch (error) {
message.error(
'备份创建失败: ' + (error.response?.data?.message || error.message || '未知错误')
);
} finally {
setBackupLoading(false);
}
};
const handleDownload = filename => {
window.open(`/api/backup/download/${filename}`, '_blank');
};
const handleValidate = async filename => {
try {
const response = await api.get(`/backup/validate/${filename}`);
if (response?.success && response.data?.valid) {
const data = response.data;
Modal.info({
title: (
<Space>
<div
style={{
width: 40,
height: 40,
borderRadius: '12px',
background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CheckCircleOutlined style={{ color: '#fff', fontSize: 24 }} />
</div>
<span style={{ fontSize: '20px', fontWeight: 700 }}>备份文件验证通过</span>
</Space>
),
width: 700,
content: (
<div style={{ marginTop: 16 }}>
{/* 基本信息 */}
<div
style={{
padding: '16px',
background: designTokens.colors.background.accent,
borderRadius: designTokens.borderRadius.md,
marginBottom: 16,
}}
>
<Descriptions
column={2}
size="small"
colon={false}
labelStyle={{ fontWeight: 600, color: designTokens.colors.text.muted }}
contentStyle={{ fontWeight: 500, color: designTokens.colors.text.primary }}
>
<Descriptions.Item label="版本">{data.version}</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag color="blue">{data.backupType === 'full' ? '完整备份' : '部分备份'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatDateTime(data.timestamp)}
</Descriptions.Item>
<Descriptions.Item label="压缩">
{data.compressed ? <Tag color="green"></Tag> : <Tag></Tag>}
</Descriptions.Item>
{data.description && (
<Descriptions.Item label="描述" span={2}>
{data.description}
</Descriptions.Item>
)}
</Descriptions>
</div>
{/* 系统信息 */}
{data.systemInfo && (
<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>
<Descriptions
column={2}
size="small"
colon={false}
labelStyle={{
fontWeight: 500,
color: designTokens.colors.text.muted,
fontSize: 12,
}}
contentStyle={{
fontWeight: 500,
color: designTokens.colors.text.secondary,
fontSize: 13,
}}
>
<Descriptions.Item label="Node.js 版本">
{data.systemInfo.nodeVersion}
</Descriptions.Item>
<Descriptions.Item label="操作系统">
{data.systemInfo.platform}
</Descriptions.Item>
<Descriptions.Item label="系统架构">{data.systemInfo.arch}</Descriptions.Item>
<Descriptions.Item label="数据库类型">
{data.systemInfo.dbType}
</Descriptions.Item>
</Descriptions>
</div>
)}
{/* 数据表统计 */}
<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 }} />
数据表详情 {data.metadata?.tableCount} 个表{data.metadata?.totalRecords}{' '}
条记录
</p>
{data.details?.tables && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
maxHeight: '300px',
overflowY: 'auto',
padding: '8px',
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.sm,
}}
>
{Object.entries(data.details.tables).map(([tableName, tableInfo]) => (
<div
key={tableName}
style={{
padding: '10px 12px',
background: designTokens.colors.background.secondary,
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' }}>
<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.hasData ? 'green' : 'default'}
style={{ borderRadius: '4px' }}
>
{tableInfo.recordCount}
</Tag>
</div>
))}
</div>
)}
</div>
{/* 文件备份详情 */}
{data.details?.files && data.details.files.total > 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',
}}
>
<FileTextOutlined style={{ marginRight: 8 }} />
备份文件详情 {data.details.files.total} 个文件
</p>
{data.details.files.avatars > 0 && (
<div style={{ marginBottom: 12 }}>
<p
style={{
margin: '0 0 8px 0',
fontSize: 13,
fontWeight: 500,
color: designTokens.colors.text.secondary,
}}
>
头像文件 ({data.details.files.avatars} ):
</p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
padding: '8px',
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.sm,
}}
>
{data.details.files.avatarList.map((file, index) => (
<Tag key={index} color="blue" style={{ borderRadius: '4px', margin: 0 }}>
<FileTextOutlined style={{ marginRight: 4 }} />
{file.filename} ({formatBytes(file.size)})
</Tag>
))}
</div>
</div>
)}
{data.details.files.others > 0 && (
<div>
<p
style={{
margin: '0 0 8px 0',
fontSize: 13,
fontWeight: 500,
color: designTokens.colors.text.secondary,
}}
>
其他文件 ({data.details.files.others} ):
</p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
padding: '8px',
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.sm,
}}
>
{data.details.files.otherList.map((file, index) => (
<Tag
key={index}
color="purple"
style={{ borderRadius: '4px', margin: 0 }}
>
<FileTextOutlined style={{ marginRight: 4 }} />
{file.filename} ({formatBytes(file.size)})
</Tag>
))}
</div>
</div>
)}
</div>
)}
{/* 汇总信息 */}
<div
style={{
padding: '16px',
background: designTokens.colors.primary.main + '08',
borderRadius: designTokens.borderRadius.md,
border: `1px solid ${designTokens.colors.primary.main}20`,
}}
>
<p
style={{
margin: '0 0 12px 0',
fontWeight: 600,
color: designTokens.colors.text.primary,
display: 'flex',
alignItems: 'center',
}}
>
<InfoCircleOutlined style={{ marginRight: 8 }} />
备份汇总
</p>
<Row gutter={[16, 16]}>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<p
style={{
margin: 0,
fontSize: '24px',
fontWeight: 700,
background: designTokens.colors.primary.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
{data.metadata?.tableCount}
</p>
<p
style={{
margin: '4px 0 0 0',
fontSize: 12,
color: designTokens.colors.text.muted,
}}
>
数据表
</p>
</div>
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<p
style={{
margin: 0,
fontSize: '24px',
fontWeight: 700,
background: designTokens.colors.success.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
{data.metadata?.totalRecords}
</p>
<p
style={{
margin: '4px 0 0 0',
fontSize: 12,
color: designTokens.colors.text.muted,
}}
>
记录数
</p>
</div>
</Col>
<Col span={8}>
<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.metadata?.fileCount}
</p>
<p
style={{
margin: '4px 0 0 0',
fontSize: 12,
color: designTokens.colors.text.muted,
}}
>
文件数
</p>
</div>
</Col>
</Row>
</div>
</div>
),
maskClosable: false,
okText: '关闭',
okButtonProps: {
style: primaryButtonStyle,
},
});
} else {
Modal.error({
title: '备份文件验证失败',
content: response.data?.data?.error || '文件可能已损坏',
});
}
} catch (error) {
message.error('验证失败: ' + (error.response?.data?.message || error.message || '未知错误'));
}
};
const handleRestore = async filename => {
setRestoreLoading(true);
setRestoreProgress(0);
setRestoreStatus('正在初始化...');
const token = secureStorage.get(TOKEN_KEY);
const options = {
overwriteExisting: true,
skipFiles: false,
};
const eventSource = new EventSource(
`/api/backup/restore-progress/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}&options=${encodeURIComponent(JSON.stringify(options))}`
);
let resultData = null;
eventSource.onmessage = event => {
try {
const data = JSON.parse(event.data);
setRestoreProgress(data.progress);
setRestoreStatus(data.message);
if (data.stage === 'complete') {
resultData = data.result;
eventSource.close();
setTimeout(() => {
setRestoreVisible(false);
setRestoreLoading(false);
const successIconStyle = {
width: 56,
height: 56,
borderRadius: '16px',
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',
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',
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',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<InfoCircleOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<div>
<p style={{ margin: 0, fontWeight: 600, color: '#1e40af', fontSize: 14 }}>
建议刷新页面以确保所有数据生效
</p>
<p style={{ margin: '4px 0 0 0', fontSize: 12, color: '#3b82f6' }}>
刷新后可查看最新恢复的数据内容
</p>
</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);
}
if (data.stage === 'error') {
eventSource.close();
setRestoreLoading(false);
Modal.error({
title: '数据恢复失败',
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 => {
try {
const response = await api.delete(`/backup/${filename}`);
if (response?.success) {
message.success('删除成功');
fetchBackups();
fetchBackupInfo();
} else {
message.error(response?.message || '删除失败');
}
} catch (error) {
message.error('删除失败: ' + (error.response?.data?.message || error.message || '未知错误'));
}
};
const handleUpload = async options => {
const { file, onSuccess, onError } = options;
const formData = new FormData();
formData.append('backup', file);
try {
const response = await api.post('/backup/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response?.success) {
message.success('备份文件上传成功');
fetchBackups();
fetchBackupInfo();
onSuccess(response.data);
} else {
message.error(response?.message || '上传失败');
onError(new Error(response?.message));
}
} catch (error) {
message.error('上传失败: ' + (error.response?.data?.message || error.message || '未知错误'));
onError(error);
}
};
const handleCleanBackups = async (maxCount = 30, maxAgeDays = 90, dryRun = false) => {
try {
const response = await api.post('/backup/clean', {
maxCount,
maxAgeDays,
dryRun,
});
if (response?.success) {
const data = response.data;
if (dryRun) {
Modal.info({
title: '清理预览',
width: 500,
content: (
<div style={{ marginTop: 16 }}>
<p>
将删除 <strong>{data.deletedCount}</strong> 个旧备份文件
</p>
<p>
将保留 <strong>{data.keptCount}</strong> 个备份文件
</p>
<p>
预计释放空间: <strong>{data.freedSizeFormatted}</strong>
</p>
{data.deleted.length > 0 && (
<div style={{ marginTop: 16 }}>
<Text type="secondary">待删除文件:</Text>
<ul style={{ margin: '8px 0', paddingLeft: 20 }}>
{data.deleted.slice(0, 5).map(f => (
<li key={f}>{f}</li>
))}
{data.deleted.length > 5 && <li>... 还有 {data.deleted.length - 5} </li>}
</ul>
</div>
)}
</div>
),
});
} else {
message.success(`已清理 ${data.deletedCount} 个旧备份,释放 ${data.freedSizeFormatted}`);
fetchBackups();
fetchBackupInfo();
}
return data;
}
} catch (error) {
message.error('清理失败: ' + (error.response?.data?.message || error.message || '未知错误'));
}
};
const showRestoreConfirm = record => {
setSelectedBackup(record);
setRestoreVisible(true);
};
const columns = [
{
title: (
<span style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>
<FileTextOutlined style={{ marginRight: 4 }} />
文件名
</span>
),
dataIndex: 'filename',
key: 'filename',
width: 280,
render: (text, record) => (
<Space>
<div
style={{
width: 32,
height: 32,
borderRadius: '8px',
background: record.compressed
? designTokens.colors.success.gradient
: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FileTextOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text copyable={{ text }} style={{ fontWeight: 500, display: 'block' }}>
{text}
</Text>
{record.description && (
<div
style={{
fontSize: 12,
color: designTokens.colors.text.muted,
marginTop: 4,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{record.description}
</div>
)}
{record.isUploaded && (
<Tag color="blue" style={{ marginTop: 4, borderRadius: '4px' }}>
上传文件
</Tag>
)}
{record.invalid && (
<Tag color="red" style={{ marginTop: 4, borderRadius: '4px' }}>
无法识别
</Tag>
)}
</div>
</Space>
),
},
{
title: (
<span style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>文件大小</span>
),
dataIndex: 'size',
key: 'size',
width: 140,
render: size => (
<span
style={{
fontFamily: 'monospace',
fontWeight: 500,
color: designTokens.colors.text.secondary,
}}
>
{formatBytes(size)}
</span>
),
},
{
title: (
<span style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
创建时间
</span>
),
dataIndex: 'createdAt',
key: 'createdAt',
width: 200,
render: (time, record) => {
// 优先使用备份文件中的 timestamp,如果没有则使用文件创建时间
const displayTime = record.timestamp ? new Date(record.timestamp) : new Date(time);
return (
<span style={{ color: designTokens.colors.text.secondary }}>
{formatDateTime(displayTime.toISOString())}
</span>
);
},
sorter: (a, b) => {
const timeA = a.timestamp ? new Date(a.timestamp) : new Date(a.createdAt);
const timeB = b.timestamp ? new Date(b.timestamp) : new Date(b.createdAt);
return timeA - timeB;
},
},
{
title: <span style={{ fontWeight: 600, color: designTokens.colors.text.primary }}>操作</span>,
key: 'action',
width: 280,
render: (_, record) => (
<Space size={4}>
<Tooltip title="验证备份完整性">
<Button
size="small"
icon={<SafetyOutlined />}
onClick={() => handleValidate(record.filename)}
style={{
...buttonStyles.success.base,
padding: '4px 12px',
height: '32px',
fontSize: '13px',
background: record.invalid
? designTokens.colors.text.muted
: designTokens.colors.success.gradient,
}}
disabled={record.invalid}
>
验证
</Button>
</Tooltip>
<Tooltip title="下载备份文件">
<Button
size="small"
icon={<DownloadOutlined />}
onClick={() => handleDownload(record.filename)}
style={{
...buttonStyles.secondary.base,
padding: '4px 12px',
height: '32px',
fontSize: '13px',
}}
disabled={record.invalid}
>
下载
</Button>
</Tooltip>
<Tooltip title="恢复备份数据">
<Button
size="small"
icon={<CloudUploadOutlined />}
onClick={() => showRestoreConfirm(record)}
style={{
...buttonStyles.warning.base,
padding: '4px 12px',
height: '32px',
fontSize: '13px',
background: record.invalid
? designTokens.colors.text.muted
: designTokens.colors.warning.gradient,
}}
disabled={record.invalid}
>
恢复
</Button>
</Tooltip>
<Popconfirm
title="确定要删除此备份文件吗?"
description="此操作不可撤销"
onConfirm={() => handleDelete(record.filename)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除备份文件">
<Button
size="small"
icon={<DeleteOutlined />}
style={{
...buttonStyles.icon.base,
background: designTokens.colors.background.accent,
color: designTokens.colors.danger.main,
}}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
// 页面容器样式
const containerStyle = {
minHeight: '100vh',
background: `linear-gradient(135deg, ${designTokens.colors.background.primary} 0%, ${designTokens.colors.background.accent} 100%)`,
padding: `${designTokens.spacing.lg} ${designTokens.spacing.lg}`,
};
// 页面标题样式 - 响应式适配
const pageHeaderStyle = {
marginBottom: '32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: '20px',
padding: `${designTokens.spacing.md} ${designTokens.spacing.lg}`,
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.xl,
boxShadow: designTokens.shadows.lg,
'@media (max-width: 768px)': {
flexDirection: 'column',
alignItems: 'stretch',
},
};
const titleStyle = {
fontSize: 'clamp(20px, 4vw, 28px)',
fontWeight: '800',
margin: 0,
background: designTokens.colors.primary.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
letterSpacing: '0.5px',
};
// 卡片通用样式
const cardStyle = {
borderRadius: designTokens.borderRadius.lg,
border: 'none',
boxShadow: designTokens.shadows.lg,
overflow: 'hidden',
background: designTokens.colors.background.secondary,
transition: 'all 0.3s ease',
};
// 统计卡片样式
const statCardStyle = {
...cardStyle,
background: designTokens.colors.background.secondary,
position: 'relative',
overflow: 'hidden',
};
// 按钮组样式
const buttonGroupStyle = {
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
alignItems: 'center',
};
// 按钮设计系统 - 统一风格
const buttonStyles = {
primary: {
base: {
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#fff',
height: '40px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
},
hover: {
transform: 'translateY(-1px)',
boxShadow: designTokens.shadows.lg,
},
},
secondary: {
base: {
background: designTokens.colors.background.secondary,
border: `1px solid ${designTokens.colors.border.light}`,
borderRadius: designTokens.borderRadius.md,
padding: '8px 16px',
fontWeight: 500,
color: designTokens.colors.text.primary,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
height: '40px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
},
hover: {
borderColor: designTokens.colors.primary.main,
color: designTokens.colors.primary.main,
background: `${designTokens.colors.primary.main}08`,
},
},
danger: {
base: {
background: designTokens.colors.danger.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#fff',
height: '40px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
},
hover: {
transform: 'translateY(-1px)',
boxShadow: '0 8px 16px rgba(239, 68, 68, 0.3)',
},
},
success: {
base: {
background: designTokens.colors.success.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#fff',
height: '40px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
},
hover: {
transform: 'translateY(-1px)',
boxShadow: '0 8px 16px rgba(16, 185, 129, 0.3)',
},
},
warning: {
base: {
background: designTokens.colors.warning.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#fff',
height: '40px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
},
hover: {
transform: 'translateY(-1px)',
boxShadow: '0 8px 16px rgba(245, 158, 11, 0.3)',
},
},
icon: {
base: {
border: 'none',
borderRadius: designTokens.borderRadius.md,
width: '36px',
height: '36px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'pointer',
},
},
};
const primaryButtonStyle = buttonStyles.primary.base;
const secondaryButtonStyle = buttonStyles.secondary.base;
const dangerButtonStyle = buttonStyles.danger.base;
return (
<div style={containerStyle}>
<div style={pageHeaderStyle}>
<div>
<h1 style={titleStyle}>
<DatabaseOutlined style={{ marginRight: 12, verticalAlign: 'middle' }} />
数据备份管理
</h1>
<p
style={{
margin: '8px 0 0 0',
color: designTokens.colors.text.secondary,
fontSize: '14px',
}}
>
管理系统备份确保数据安全与可恢复性
</p>
</div>
<div style={buttonGroupStyle}>
<Space size="middle" wrap>
<Dropdown
menu={{
items: [
{
key: 'auto',
icon: <SettingOutlined />,
label: '自动备份设置',
onClick: () => navigate('/auto-backup-settings'),
},
{
key: 'remote',
icon: <CloudOutlined />,
label: '远端备份配置',
onClick: () => navigate('/remote-backup-settings'),
},
],
}}
placement="bottomRight"
>
<Button icon={<SettingOutlined />} style={secondaryButtonStyle}>
设置
</Button>
</Dropdown>
<Button
icon={<ReloadOutlined />}
onClick={() => {
fetchBackups();
fetchBackupInfo();
}}
style={secondaryButtonStyle}
>
刷新
</Button>
<Dropdown
menu={{
items: [
{
key: 'preview',
icon: <EyeOutlined />,
label: '预览清理',
onClick: () => handleCleanBackups(30, 90, true),
},
{
key: 'clean',
icon: <ClearOutlined />,
label: '清理旧备份',
danger: true,
onClick: () => {
Modal.confirm({
title: '确认清理旧备份',
content: '将删除超过 30 个或超过 90 天的旧备份文件,此操作不可撤销',
okText: '确认清理',
cancelText: '取消',
okButtonProps: { style: dangerButtonStyle },
onOk: () => handleCleanBackups(30, 90, false),
});
},
},
],
}}
placement="bottomRight"
>
<Button icon={<ClearOutlined />} style={secondaryButtonStyle}>
清理
</Button>
</Dropdown>
<Upload customRequest={handleUpload} showUploadList={false} accept=".json,.gz,.json.gz">
<Button icon={<UploadOutlined />} style={secondaryButtonStyle}>
上传备份
</Button>
</Upload>
<Button
type="primary"
icon={<PlusOutlined />}
loading={backupLoading}
onClick={handleCreateBackup}
style={primaryButtonStyle}
>
创建备份
</Button>
</Space>
</div>
</div>
{backupInfo && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={8}>
<Card style={statCardStyle}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '120px',
}}
>
<div style={{ flex: 1 }}>
<p
style={{
margin: 0,
color: designTokens.colors.text.muted,
fontSize: '14px',
fontWeight: 500,
}}
>
备份文件数量
</p>
<p
style={{
margin: '8px 0 0 0',
fontSize: '32px',
fontWeight: '700',
background: designTokens.colors.primary.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
lineHeight: 1.2,
}}
>
{backupInfo.backupCount}
<span style={{ fontSize: '16px', marginLeft: 4 }}></span>
</p>
</div>
<div
style={{
width: 64,
height: 64,
borderRadius: '16px',
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: designTokens.shadows.lg,
flexShrink: 0,
}}
>
<DatabaseOutlined style={{ color: '#fff', fontSize: 32 }} />
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card style={statCardStyle}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '120px',
}}
>
<div style={{ flex: 1 }}>
<p
style={{
margin: 0,
color: designTokens.colors.text.muted,
fontSize: '14px',
fontWeight: 500,
}}
>
备份总大小
</p>
<p
style={{
margin: '8px 0 0 0',
fontSize: '32px',
fontWeight: '700',
background: designTokens.colors.success.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
lineHeight: 1.2,
}}
>
{backupInfo.totalSizeFormatted}
</p>
</div>
<div
style={{
width: 64,
height: 64,
borderRadius: '16px',
background: designTokens.colors.success.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: designTokens.shadows.lg,
flexShrink: 0,
}}
>
<FileTextOutlined style={{ color: '#fff', fontSize: 32 }} />
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card style={statCardStyle}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '120px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
margin: 0,
color: designTokens.colors.text.muted,
fontSize: '14px',
fontWeight: 500,
}}
>
备份存储路径
</p>
<p
style={{
margin: '8px 0 0 0',
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.text.secondary,
wordBreak: 'break-all',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: 1.4,
}}
>
{backupInfo.backupPath}
</p>
</div>
<div
style={{
width: 64,
height: 64,
borderRadius: '16px',
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: designTokens.shadows.lg,
flexShrink: 0,
}}
>
<InfoCircleOutlined style={{ color: '#fff', fontSize: 32 }} />
</div>
</div>
</Card>
</Col>
</Row>
)}
<Card style={cardStyle} styles={{ body: { padding: '24px' } }}>
<Alert
message={
<span style={{ fontWeight: 600 }}>
<InfoCircleOutlined style={{ marginRight: 8 }} />
备份说明
</span>
}
description={
<div style={{ marginTop: 12 }}>
<p style={{ margin: '8px 0', color: designTokens.colors.text.secondary }}>
备份包含所有数据库表数据设备机柜用户工单耗材等
</p>
<p style={{ margin: '8px 0', color: designTokens.colors.text.secondary }}>
备份包含上传的文件用户头像等
</p>
<p style={{ margin: '8px 0', color: designTokens.colors.text.secondary }}>
可通过下载备份文件在新环境中恢复所有数据
</p>
<p style={{ margin: '8px 0', color: designTokens.colors.text.secondary }}>
建议定期备份以确保数据安全
</p>
</div>
}
type="info"
showIcon
style={{
marginBottom: 24,
borderRadius: designTokens.borderRadius.md,
border: `1px solid ${designTokens.colors.text.muted}20`,
background: `${designTokens.colors.primary.main}08`,
}}
/>
<Table
columns={columns}
dataSource={backups}
rowKey="filename"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: total => `共 ${total} 个备份文件`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
locale={{
emptyText: (
<div
style={{
padding: '60px 0',
textAlign: 'center',
}}
>
<div
style={{
width: 80,
height: 80,
margin: '0 auto 16px',
borderRadius: '50%',
background: designTokens.colors.background.accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DatabaseOutlined
style={{ fontSize: 40, color: designTokens.colors.text.muted }}
/>
</div>
<p
style={{
margin: 0,
fontSize: 16,
fontWeight: 500,
color: designTokens.colors.text.primary,
}}
>
暂无备份文件
</p>
<p
style={{
margin: '8px 0 0 0',
fontSize: 14,
color: designTokens.colors.text.muted,
}}
>
点击"创建备份"按钮开始备份
</p>
</div>
),
}}
rowClassName={(record, index) => (index % 2 === 0 ? 'table-row-light' : 'table-row-dark')}
style={{
borderRadius: designTokens.borderRadius.md,
overflow: 'hidden',
}}
/>
</Card>
<Modal
title={
<Space>
<div
style={{
width: 40,
height: 40,
borderRadius: '12px',
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ExclamationCircleOutlined style={{ color: '#fff', fontSize: 24 }} />
</div>
<span
style={{
fontSize: '20px',
fontWeight: 700,
color: designTokens.colors.text.primary,
}}
>
确认恢复数据
</span>
</Space>
}
open={restoreVisible}
closeIcon={<CloseButton />}
onCancel={() => {
if (!restoreLoading) {
setRestoreVisible(false);
}
}}
footer={null}
width={560}
maskClosable={false}
styles={{
body: { padding: '24px' },
header: {
padding: '20px 24px',
borderBottom: `1px solid ${designTokens.colors.text.muted}20`,
},
}}
>
<div style={{ marginTop: 8 }}>
{restoreLoading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<div
style={{
width: 80,
height: 80,
margin: '0 auto 24px',
borderRadius: '50%',
background: designTokens.colors.background.accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CloudUploadOutlined
style={{ fontSize: 40, color: designTokens.colors.primary.main }}
/>
</div>
<Progress
percent={restoreProgress}
status="active"
strokeColor={{
'0%': '#667eea',
'100%': '#764ba2',
}}
trailColor={designTokens.colors.background.accent}
/>
<Text
style={{
marginTop: 16,
display: 'block',
fontSize: 14,
color: designTokens.colors.text.secondary,
}}
>
{restoreStatus}
</Text>
</div>
) : (
<>
<Alert
message={
<span style={{ fontWeight: 600 }}>
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
警告恢复操作将覆盖当前所有数据
</span>
}
description="此操作不可撤销,建议先创建当前数据的备份"
type="warning"
showIcon
style={{
marginBottom: 20,
borderRadius: designTokens.borderRadius.md,
border: `1px solid ${designTokens.colors.warning.main}30`,
background: `${designTokens.colors.warning.main}08`,
}}
/>
{selectedBackup && (
<div
style={{
background: designTokens.colors.background.accent,
borderRadius: designTokens.borderRadius.md,
padding: '16px',
marginBottom: 20,
}}
>
<Descriptions
column={1}
size="small"
colon={false}
labelStyle={{
fontWeight: 600,
color: designTokens.colors.text.muted,
marginBottom: 8,
}}
contentStyle={{
fontWeight: 500,
color: designTokens.colors.text.primary,
}}
>
<Descriptions.Item label="备份文件">
<Space>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
{selectedBackup.filename}
</Space>
</Descriptions.Item>
<Descriptions.Item label="文件大小">
<span
style={{ fontFamily: 'monospace', color: designTokens.colors.success.main }}
>
{formatBytes(selectedBackup.size)}
</span>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
<Space>
<ClockCircleOutlined style={{ color: designTokens.colors.text.muted }} />
{formatDateTime(selectedBackup.createdAt)}
</Space>
</Descriptions.Item>
</Descriptions>
</div>
)}
<Divider style={{ margin: '20px 0' }} />
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '8px',
}}
>
<Button
onClick={() => setRestoreVisible(false)}
style={secondaryButtonStyle}
disabled={restoreLoading}
>
取消
</Button>
<Button
type="primary"
danger
icon={<CloudUploadOutlined />}
onClick={() => handleRestore(selectedBackup?.filename)}
style={{
...buttonStyles.danger.base,
padding: '8px 24px',
}}
>
确认恢复
</Button>
</div>
</>
)}
</div>
</Modal>
</div>
);
};
export default BackupManagement;