import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Card,
Row,
Col,
Button,
Switch,
InputNumber,
Input,
Space,
Tag,
Alert,
Modal,
message,
Descriptions,
Divider,
Typography,
Spin,
Radio,
Table,
Select,
Pagination,
Empty,
Tooltip,
} from 'antd';
import {
ClockCircleOutlined,
DatabaseOutlined,
CheckCircleOutlined,
PlayCircleOutlined,
SaveOutlined,
SettingOutlined,
InfoCircleOutlined,
ArrowLeftOutlined,
ThunderboltOutlined,
SafetyOutlined,
CloudDownloadOutlined,
FileProtectOutlined,
HistoryOutlined,
EyeOutlined,
ReloadOutlined,
UpOutlined,
DownOutlined,
} from '@ant-design/icons';
import { backupAPI } from '../api';
import { useNavigate } from 'react-router-dom';
const { Title, Text, Paragraph } = Typography;
const { Option } = Select;
const TimePicker = ({ hour, minute, onChange, disabled }) => {
const [hourInput, setHourInput] = useState(hour.toString());
const [minuteInput, setMinuteInput] = useState(minute.toString());
useEffect(() => {
setHourInput(hour.toString());
setMinuteInput(minute.toString());
}, [hour, minute]);
const handleHourChange = newHour => {
const validHour = Math.max(0, Math.min(23, newHour));
onChange(validHour, minute);
};
const handleMinuteChange = newMinute => {
const validMinute = Math.max(0, Math.min(59, newMinute));
onChange(hour, validMinute);
};
const handleHourInputBlur = () => {
const value = parseInt(hourInput);
if (!isNaN(value)) {
handleHourChange(value);
} else {
setHourInput(hour.toString());
}
};
const handleMinuteInputBlur = () => {
const value = parseInt(minuteInput);
if (!isNaN(value)) {
handleMinuteChange(value);
} else {
setMinuteInput(minute.toString());
}
};
const handleHourKeyPress = e => {
if (e.key === 'Enter') {
handleHourInputBlur();
}
};
const handleMinuteKeyPress = e => {
if (e.key === 'Enter') {
handleMinuteInputBlur();
}
};
const incrementHour = () => handleHourChange(hour + 1);
const decrementHour = () => handleHourChange(hour - 1);
const incrementMinute = () => handleMinuteChange(minute + 1);
const decrementMinute = () => handleMinuteChange(minute - 1);
const timePeriods = [
{ start: 0, end: 5, label: '深夜', icon: '🌙' },
{ start: 6, end: 8, label: '清晨', icon: '🌅' },
{ start: 9, end: 11, label: '上午', icon: '☀️' },
{ start: 12, end: 13, label: '中午', icon: '🌞' },
{ start: 14, end: 17, label: '下午', icon: '🌤️' },
{ start: 18, end: 21, label: '傍晚', icon: '🌇' },
{ start: 22, end: 23, label: '夜晚', icon: '🌙' },
];
const currentPeriod = timePeriods.find(p => hour >= p.start && hour <= p.end);
return (
}
onClick={incrementHour}
disabled={disabled}
style={{
color: disabled ? '#bfbfbf' : '#fff',
fontSize: 15,
height: 30,
width: 56,
padding: 0,
}}
/>
setHourInput(e.target.value)}
onBlur={handleHourInputBlur}
onPressEnter={handleHourKeyPress}
disabled={disabled}
maxLength={2}
style={{
width: 66,
height: 48,
fontSize: 30,
fontWeight: 700,
textAlign: 'center',
color: disabled ? '#bfbfbf' : '#fff',
background: disabled ? 'transparent' : 'rgba(255, 255, 255, 0.18)',
border: 'none',
borderRadius: '10px',
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
outline: 'none',
boxShadow: 'none',
}}
/>
}
onClick={decrementHour}
disabled={disabled}
style={{
color: disabled ? '#bfbfbf' : '#fff',
fontSize: 15,
height: 30,
width: 56,
padding: 0,
}}
/>
小时
:
}
onClick={incrementMinute}
disabled={disabled}
style={{
color: disabled ? '#bfbfbf' : '#fff',
fontSize: 15,
height: 30,
width: 56,
padding: 0,
}}
/>
setMinuteInput(e.target.value)}
onBlur={handleMinuteInputBlur}
onPressEnter={handleMinuteKeyPress}
disabled={disabled}
maxLength={2}
style={{
width: 66,
height: 48,
fontSize: 30,
fontWeight: 700,
textAlign: 'center',
color: disabled ? '#bfbfbf' : '#fff',
background: disabled ? 'transparent' : 'rgba(255, 255, 255, 0.18)',
border: 'none',
borderRadius: '10px',
fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
outline: 'none',
boxShadow: 'none',
}}
/>
}
onClick={decrementMinute}
disabled={disabled}
style={{
color: disabled ? '#bfbfbf' : '#fff',
fontSize: 15,
height: 30,
width: 56,
padding: 0,
}}
/>
分钟
{currentPeriod && (
{currentPeriod.icon}
{currentPeriod.label}
)}
{[
{ hour: 2, minute: 0, label: '凌晨 2:00', recommended: true },
{ hour: 3, minute: 0, label: '凌晨 3:00' },
{ hour: 4, minute: 0, label: '凌晨 4:00', recommended: true },
{ hour: 20, minute: 0, label: '晚上 8:00' },
{ hour: 22, minute: 0, label: '晚上 10:00' },
].map((preset, index) => (
))}
);
};
const AutoBackupSettings = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(null);
const [settings, setSettings] = useState({
enabled: false,
hour: 2,
minute: 0,
includeFiles: true,
compress: true,
maxCount: 30,
maxAgeDays: 90,
backupType: 'full',
});
const [modified, setModified] = useState(false);
const isInitialMount = useRef(true);
const isFetching = useRef(false);
const [logs, setLogs] = useState([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsPagination, setLogsPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [logFilter, setLogFilter] = useState({
logType: '',
status: '',
});
const [logDetailModal, setLogDetailModal] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
useEffect(() => {
if (isInitialMount.current) {
fetchStatus();
fetchLogs();
isInitialMount.current = false;
}
}, []);
const fetchStatus = useCallback(async () => {
if (isFetching.current) return;
try {
setLoading(true);
isFetching.current = true;
const response = await backupAPI.getAutoStatus();
if (response?.success) {
const data = response.data;
setStatus(data);
if (data.cronExpression) {
const parts = data.cronExpression.split(' ');
const minute = parseInt(parts[0]);
const hour = parseInt(parts[1]);
setSettings(prev => ({
...prev,
enabled: data.enabled || false,
hour: isNaN(hour) ? 2 : hour,
minute: isNaN(minute) ? 0 : minute,
includeFiles: data.includeFiles !== undefined ? data.includeFiles : true,
compress: data.compress !== undefined ? data.compress : true,
maxCount: data.maxCount || 30,
maxAgeDays: data.maxAgeDays || 90,
backupType: data.backupType || 'full',
}));
}
}
} catch (error) {
console.error('获取自动备份状态失败:', error);
} finally {
setLoading(false);
isFetching.current = false;
}
}, []);
const fetchLogs = useCallback(
async (page = 1, pageSize = 10) => {
try {
setLogsLoading(true);
const params = { page, pageSize };
if (logFilter.logType) params.logType = logFilter.logType;
if (logFilter.status) params.status = logFilter.status;
const response = await backupAPI.getLogs(params);
if (response?.success) {
setLogs(response.data.logs || []);
setLogsPagination({
current: response.data.page || 1,
pageSize: response.data.pageSize || 10,
total: response.data.total || 0,
});
}
} catch (error) {
console.error('获取备份日志失败:', error);
message.error('获取备份日志失败');
} finally {
setLogsLoading(false);
}
},
[logFilter]
);
const handleSave = async () => {
try {
setLoading(true);
const response = await backupAPI.updateAutoSettings({
enabled: settings.enabled,
hour: settings.hour,
minute: settings.minute,
includeFiles: settings.includeFiles,
compress: settings.compress,
maxCount: settings.maxCount,
maxAgeDays: settings.maxAgeDays,
backupType: settings.backupType,
});
if (response?.success) {
message.success('自动备份设置已保存');
setModified(false);
fetchStatus();
}
} catch (error) {
message.error('保存失败:' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const handleExecuteNow = async () => {
Modal.confirm({
title: (
立即执行备份
),
content: '确定要立即执行一次备份吗?这将在后台创建一个新的备份文件。',
okText: '立即备份',
cancelText: '取消',
onOk: async () => {
try {
setLoading(true);
const response = await backupAPI.executeNow({
description: `手动触发 - ${new Date().toLocaleString('zh-CN')}`,
includeFiles: settings.includeFiles,
compress: settings.compress,
backupType: settings.backupType,
});
if (response?.success) {
message.success('备份执行成功!');
fetchLogs(logsPagination.current, logsPagination.pageSize);
}
} catch (error) {
message.error('执行失败:' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
},
});
};
const formatTime = (hour, minute) => {
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
};
const formatNextRun = nextRun => {
if (!nextRun) return '未知';
return new Date(nextRun).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDateTime = dateTime => {
if (!dateTime) return '-';
return new Date(dateTime).toLocaleString('zh-CN');
};
const formatDuration = ms => {
if (!ms) return '-';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}分${remainingSeconds}秒`;
}
return `${remainingSeconds}秒`;
};
const formatFileSize = bytes => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const getStatusColor = () => {
if (!status) return 'default';
if (status.enabled && status.isActive) return 'success';
if (status.enabled && !status.isActive) return 'warning';
return 'default';
};
const getStatusText = () => {
if (!status) return '加载中...';
if (status.enabled && status.isActive) return '自动备份运行中';
if (status.enabled && !status.isActive) return '自动备份已启用但未运行';
return '自动备份已禁用';
};
const getLogStatusTag = status => {
const statusMap = {
pending: { color: 'default', text: '待执行' },
running: { color: 'processing', text: '执行中' },
success: { color: 'success', text: '成功' },
failed: { color: 'error', text: '失败' },
};
const info = statusMap[status] || { color: 'default', text: status };
return {info.text};
};
const getLogTypeTag = type => {
const typeMap = {
auto: { color: 'blue', text: '自动备份' },
manual: { color: 'green', text: '手动备份' },
};
const info = typeMap[type] || { color: 'default', text: type };
return {info.text};
};
const handleLogPageChange = (page, pageSize) => {
fetchLogs(page, pageSize);
};
const handleLogFilterChange = (key, value) => {
setLogFilter(prev => ({ ...prev, [key]: value }));
};
const handleViewLogDetail = log => {
setSelectedLog(log);
setLogDetailModal(true);
};
const handleRefreshLogs = () => {
fetchLogs(logsPagination.current, logsPagination.pageSize);
};
const StatusCard = ({ icon, title, value, subtitle, gradient }) => (
{React.cloneElement(icon, { style: { fontSize: 120 } })}
{subtitle &&
{subtitle}}
);
const SettingItem = ({ title, description, children, bordered = true }) => (
{title}
{description && (
{description}
)}
{children}
);
const logColumns = [
{
title: '类型',
dataIndex: 'logType',
key: 'logType',
width: 120,
render: type => getLogTypeTag(type),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: status => getLogStatusTag(status),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '文件大小',
dataIndex: 'fileSize',
key: 'fileSize',
width: 120,
render: size => formatFileSize(size),
},
{
title: '执行时间',
dataIndex: 'duration',
key: 'duration',
width: 120,
render: duration => formatDuration(duration),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: date => formatDateTime(date),
},
{
title: '操作',
key: 'actions',
width: 100,
render: (_, record) => (
} onClick={() => handleViewLogDetail(record)} />
),
},
];
if (loading && !status) {
return (
);
}
return (
}
onClick={() => navigate('/backup')}
style={{ marginBottom: 16 }}
>
返回备份管理
}
onClick={handleExecuteNow}
loading={loading}
size="large"
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
color: '#fff',
fontWeight: 500,
borderRadius: '12px',
padding: '8px 24px',
}}
>
立即备份
}
type="primary"
onClick={handleSave}
disabled={!modified || loading}
loading={loading}
size="large"
style={{
borderRadius: '12px',
padding: '8px 24px',
fontWeight: 500,
}}
>
保存设置
状态概览
}
title="当前状态"
value={getStatusText()}
gradient={
status?.enabled && status.isActive
? 'linear-gradient(135deg, #10b981 0%, #34d399 100%)'
: 'linear-gradient(135deg, #6b7280 0%, #9ca3af 100%)'
}
/>
}
title="下次执行时间"
value={formatNextRun(status?.nextRun)}
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
/>
}
title="备份时间"
value={formatTime(settings.hour, settings.minute)}
gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)"
/>
}
title="保留策略"
value={`${settings.maxCount}个 / ${settings.maxAgeDays}天`}
gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)"
/>
基本设置
}
style={{
borderRadius: '20px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
height: '100%',
}}
>
{
setSettings({ ...settings, enabled: checked });
setModified(true);
}}
size="default"
checkedChildren="开启"
unCheckedChildren="关闭"
/>
{
setSettings(prev => ({
...prev,
hour: newHour,
minute: newMinute,
}));
setModified(true);
}}
disabled={!settings.enabled}
/>
高级设置
}
style={{
borderRadius: '20px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
height: '100%',
}}
>
{
setSettings({ ...settings, backupType: e.target.value });
setModified(true);
}}
disabled={!settings.enabled}
>
全量备份
增量备份
{
setSettings({ ...settings, includeFiles: checked });
setModified(true);
}}
disabled={!settings.enabled}
checkedChildren="包含"
unCheckedChildren="不包含"
/>
{
setSettings({ ...settings, compress: checked });
setModified(true);
}}
disabled={!settings.enabled}
checkedChildren="压缩"
unCheckedChildren="不压缩"
/>
{
setSettings({ ...settings, maxCount: value });
setModified(true);
}}
addonAfter="个"
disabled={!settings.enabled}
style={{ width: 120 }}
/>
{
setSettings({ ...settings, maxAgeDays: value });
setModified(true);
}}
addonAfter="天"
disabled={!settings.enabled}
style={{ width: 120 }}
/>
备份日志
}
style={{
borderRadius: '20px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.05)',
marginTop: 24,
}}
extra={
} onClick={handleRefreshLogs} loading={logsLoading}>
刷新
}
>
类型:
状态:
,
}}
/>
最佳实践建议
}
description={
备份时间:建议设置在凌晨 2-4 点业务低峰期,避免影响正常使用
保留策略:开发环境建议 7 个/7 天,生产环境建议 30 个/90 天
压缩备份:强烈建议开启,可显著减少磁盘空间占用
定期测试:每月至少手动执行一次备份,验证功能正常
}
type="info"
showIcon={false}
style={{
borderRadius: '16px',
border: '1px solid #dbeafe',
background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)',
}}
/>
安全提示
}
description="自动备份功能需要服务器持续运行。如果服务器重启,自动备份任务会自动恢复。请确保服务器时间设置正确。"
type="warning"
showIcon={false}
style={{
borderRadius: '16px',
border: '1px solid #fef3c7',
background: 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)',
}}
/>
setLogDetailModal(false)}
footer={[
,
]}
width={700}
>
{selectedLog && (
{getLogTypeTag(selectedLog.logType)}
{getLogStatusTag(selectedLog.status)}
{selectedLog.description || '-'}
{selectedLog.backupType === 'full' ? '全量备份' : '增量备份'}
{selectedLog.filename || '-'}
{formatFileSize(selectedLog.fileSize)}
{selectedLog.includeFiles ? '是' : '否'}
{selectedLog.compressed ? '是' : '否'}
{formatDateTime(selectedLog.startTime)}
{formatDateTime(selectedLog.endTime)}
{formatDuration(selectedLog.duration)}
{formatDateTime(selectedLog.createdAt)}
{selectedLog.errorMessage && (
{selectedLog.errorMessage}
)}
{selectedLog.remoteUploads && selectedLog.remoteUploads.length > 0 && (
{selectedLog.remoteUploads.map((upload, index) => (
{upload.targetName}
{upload.success ? '上传成功' : `失败: ${upload.error}`}
))}
)}
)}
);
};
export default AutoBackupSettings;