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

1670 lines
59 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} from 'antd';
import {
CloudDownloadOutlined,
CloudUploadOutlined,
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
ReloadOutlined,
FileTextOutlined,
DatabaseOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
SettingOutlined,
} from '@ant-design/icons';
import api, { backupAPI } from '../api';
import CloseButton from '../components/CloseButton';
import { useNavigate } from 'react-router-dom';
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',
},
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',
},
},
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('正在验证备份文件...');
try {
setRestoreProgress(10);
setRestoreStatus('正在读取备份数据...');
const response = await api.post('/backup/restore', {
filename,
options: {
overwriteExisting: true,
skipFiles: false,
},
});
setRestoreProgress(90);
setRestoreStatus('正在完成恢复...');
if (response?.success) {
const data = response.data;
setRestoreProgress(100);
setRestoreStatus('恢复完成!');
setTimeout(() => {
setRestoreVisible(false);
setRestoreLoading(false);
Modal.success({
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.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>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<p style={{
margin: 0,
fontSize: '24px',
fontWeight: 700,
background: designTokens.colors.success.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>
{data.recordsRestored}
</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, #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>
{/* 数据表恢复详情 */}
{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={{
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.tableDetails).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.success ? 'green' : 'default'} style={{ borderRadius: '4px' }}>
{tableInfo.recordCount}
</Tag>
</div>
))}
</div>
</div>
)}
{/* 文件恢复详情 */}
{data.fileDetails && (data.fileDetails.avatars > 0 || data.fileDetails.others > 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 }} />
文件恢复详情
</p>
{data.fileDetails.avatars > 0 && (
<div style={{ marginBottom: 12 }}>
<p style={{
margin: '0 0 8px 0',
fontSize: 13,
fontWeight: 500,
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>
)}
<Alert
message="建议刷新页面以确保所有数据生效"
type="info"
showIcon
style={{ marginTop: 16 }}
/>
</div>
),
maskClosable: false,
okText: '完成',
okButtonProps: {
style: primaryButtonStyle,
},
});
}, 500);
} else {
throw new Error(response.data?.message || '恢复失败');
}
} catch (error) {
setRestoreLoading(false);
Modal.error({
title: '数据恢复失败',
content: error.response?.data?.message || error.message || '未知错误',
});
}
};
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: 320,
render: (_, record) => (
<Space size="small">
<Tooltip title="验证备份">
<Button
shape="circle"
icon={<CheckCircleOutlined style={{ fontSize: 16 }} />}
onClick={() => handleValidate(record.filename)}
style={{
border: 'none',
background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
color: '#fff',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
disabled={record.invalid}
/>
</Tooltip>
<Tooltip title="下载备份">
<Button
shape="circle"
icon={<DownloadOutlined style={{ fontSize: 16 }} />}
onClick={() => handleDownload(record.filename)}
style={{
border: 'none',
background: designTokens.colors.primary.gradient,
color: '#fff',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
disabled={record.invalid}
/>
</Tooltip>
<Tooltip title="恢复数据">
<Button
shape="circle"
icon={<CloudUploadOutlined style={{ fontSize: 16 }} />}
onClick={() => showRestoreConfirm(record)}
style={{
border: 'none',
background: 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)',
color: '#fff',
boxShadow: '0 2px 8px rgba(245, 158, 11, 0.3)',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
disabled={record.invalid}
/>
</Tooltip>
<Popconfirm
title="确定要删除此备份文件吗?"
onConfirm={() => handleDelete(record.filename)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除备份">
<Button
shape="circle"
icon={<DeleteOutlined style={{ fontSize: 16 }} />}
style={{
border: 'none',
background: designTokens.colors.danger.gradient,
color: '#fff',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.3)',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</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: 'center',
flexWrap: 'wrap',
gap: '20px',
padding: `${designTokens.spacing.md} ${designTokens.spacing.lg}`,
background: designTokens.colors.background.secondary,
borderRadius: designTokens.borderRadius.xl,
boxShadow: designTokens.shadows.lg,
};
const titleStyle = {
fontSize: '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',
};
// 主按钮样式
const primaryButtonStyle = {
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.3s ease',
color: '#fff',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
};
// 次要按钮样式
const secondaryButtonStyle = {
background: designTokens.colors.background.secondary,
border: `1px solid ${designTokens.colors.text.muted}`,
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 500,
color: designTokens.colors.text.primary,
transition: 'all 0.3s ease',
};
// 危险按钮样式
const dangerButtonStyle = {
background: designTokens.colors.danger.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
padding: '8px 20px',
fontWeight: 600,
boxShadow: designTokens.shadows.md,
transition: 'all 0.3s ease',
color: '#fff',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
};
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}>
<Button
icon={<SettingOutlined />}
onClick={() => navigate('/auto-backup-settings')}
style={{
...secondaryButtonStyle,
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
自动备份设置
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => { fetchBackups(); fetchBackupInfo(); }}
style={secondaryButtonStyle}
>
刷新
</Button>
<Popconfirm
title="清理旧备份"
description={
<div>
<p>将删除超过 30 个或超过 90 天的旧备份</p>
<p style={{ color: designTokens.colors.text.muted, fontSize: 12 }}>建议先点击"预览"查看将删除的文件</p>
</div>
}
onConfirm={() => handleCleanBackups(30, 90, false)}
okText="确认清理"
cancelText="取消"
>
<Button
icon={<DeleteOutlined />}
danger
style={dangerButtonStyle}
>
清理旧备份
</Button>
</Popconfirm>
<Button
onClick={() => handleCleanBackups(30, 90, true)}
style={secondaryButtonStyle}
>
预览清理
</Button>
<Upload
customRequest={handleUpload}
showUploadList={false}
accept=".json,.gz,.json.gz"
>
<Button
icon={<UploadOutlined />}
style={secondaryButtonStyle}
>
上传备份
</Button>
</Upload>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
loading={backupLoading}
onClick={handleCreateBackup}
style={primaryButtonStyle}
>
创建备份
</Button>
</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' }} />
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
onClick={() => setRestoreVisible(false)}
style={secondaryButtonStyle}
disabled={restoreLoading}
>
取消
</Button>
<Button
type="primary"
danger
onClick={() => handleRestore(selectedBackup?.filename)}
style={{
...dangerButtonStyle,
padding: '8px 32px',
}}
>
确认恢复
</Button>
</Space>
</>
)}
</div>
</Modal>
</div>
);
};
export default BackupManagement;