Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from 'react' | |
| import { backupApi } from '../../api/client' | |
| import { useToast } from '../shared/Toast' | |
| import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' | |
| import { useTranslation } from '../../i18n' | |
| import { useSettingsStore } from '../../store/settingsStore' | |
| import CustomSelect from '../shared/CustomSelect' | |
| import { getApiErrorMessage } from '../../types' | |
| const INTERVAL_OPTIONS = [ | |
| { value: 'hourly', labelKey: 'backup.interval.hourly' }, | |
| { value: 'daily', labelKey: 'backup.interval.daily' }, | |
| { value: 'weekly', labelKey: 'backup.interval.weekly' }, | |
| { value: 'monthly', labelKey: 'backup.interval.monthly' }, | |
| ] | |
| const KEEP_OPTIONS = [ | |
| { value: 1, labelKey: 'backup.keep.1day' }, | |
| { value: 3, labelKey: 'backup.keep.3days' }, | |
| { value: 7, labelKey: 'backup.keep.7days' }, | |
| { value: 14, labelKey: 'backup.keep.14days' }, | |
| { value: 30, labelKey: 'backup.keep.30days' }, | |
| { value: 0, labelKey: 'backup.keep.forever' }, | |
| ] | |
| const DAYS_OF_WEEK = [ | |
| { value: 0, labelKey: 'backup.dow.sunday' }, | |
| { value: 1, labelKey: 'backup.dow.monday' }, | |
| { value: 2, labelKey: 'backup.dow.tuesday' }, | |
| { value: 3, labelKey: 'backup.dow.wednesday' }, | |
| { value: 4, labelKey: 'backup.dow.thursday' }, | |
| { value: 5, labelKey: 'backup.dow.friday' }, | |
| { value: 6, labelKey: 'backup.dow.saturday' }, | |
| ] | |
| const HOURS = Array.from({ length: 24 }, (_, i) => i) | |
| const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1) | |
| export default function BackupPanel() { | |
| const [backups, setBackups] = useState([]) | |
| const [isLoading, setIsLoading] = useState(false) | |
| const [isCreating, setIsCreating] = useState(false) | |
| const [restoringFile, setRestoringFile] = useState(null) | |
| const [isUploading, setIsUploading] = useState(false) | |
| const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }) | |
| const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) | |
| const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) | |
| const [serverTimezone, setServerTimezone] = useState('') | |
| const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } | |
| const fileInputRef = useRef(null) | |
| const toast = useToast() | |
| const { t, language, locale } = useTranslation() | |
| const is12h = useSettingsStore(s => s.settings.time_format) === '12h' | |
| const loadBackups = async () => { | |
| setIsLoading(true) | |
| try { | |
| const data = await backupApi.list() | |
| setBackups(data.backups || []) | |
| } catch { | |
| toast.error(t('backup.toast.loadError')) | |
| } finally { | |
| setIsLoading(false) | |
| } | |
| } | |
| const loadAutoSettings = async () => { | |
| try { | |
| const data = await backupApi.getAutoSettings() | |
| setAutoSettings(data.settings) | |
| if (data.timezone) setServerTimezone(data.timezone) | |
| } catch {} | |
| } | |
| useEffect(() => { loadBackups(); loadAutoSettings() }, []) | |
| const handleCreate = async () => { | |
| setIsCreating(true) | |
| try { | |
| await backupApi.create() | |
| toast.success(t('backup.toast.created')) | |
| await loadBackups() | |
| } catch { | |
| toast.error(t('backup.toast.createError')) | |
| } finally { | |
| setIsCreating(false) | |
| } | |
| } | |
| const handleRestore = (filename) => { | |
| setRestoreConfirm({ type: 'file', filename }) | |
| } | |
| const handleUploadRestore = (e) => { | |
| const file = (e.target as HTMLInputElement).files?.[0] | |
| if (!file) return | |
| e.target.value = '' | |
| setRestoreConfirm({ type: 'upload', filename: file.name, file }) | |
| } | |
| const executeRestore = async () => { | |
| if (!restoreConfirm) return | |
| const { type, filename, file } = restoreConfirm | |
| setRestoreConfirm(null) | |
| if (type === 'file') { | |
| setRestoringFile(filename) | |
| try { | |
| await backupApi.restore(filename) | |
| toast.success(t('backup.toast.restored')) | |
| setTimeout(() => window.location.reload(), 1500) | |
| } catch (err: unknown) { | |
| toast.error(getApiErrorMessage(err, t('backup.toast.restoreError'))) | |
| setRestoringFile(null) | |
| } | |
| } else { | |
| setIsUploading(true) | |
| try { | |
| await backupApi.uploadRestore(file) | |
| toast.success(t('backup.toast.restored')) | |
| setTimeout(() => window.location.reload(), 1500) | |
| } catch (err: unknown) { | |
| toast.error(getApiErrorMessage(err, t('backup.toast.uploadError'))) | |
| setIsUploading(false) | |
| } | |
| } | |
| } | |
| const handleDelete = async (filename) => { | |
| if (!confirm(t('backup.confirm.delete', { name: filename }))) return | |
| try { | |
| await backupApi.delete(filename) | |
| toast.success(t('backup.toast.deleted')) | |
| setBackups(prev => prev.filter(b => b.filename !== filename)) | |
| } catch { | |
| toast.error(t('backup.toast.deleteError')) | |
| } | |
| } | |
| const handleAutoSettingsChange = (key, value) => { | |
| setAutoSettings(prev => ({ ...prev, [key]: value })) | |
| setAutoSettingsDirty(true) | |
| } | |
| const handleSaveAutoSettings = async () => { | |
| setAutoSettingsSaving(true) | |
| try { | |
| const data = await backupApi.setAutoSettings(autoSettings) | |
| setAutoSettings(data.settings) | |
| setAutoSettingsDirty(false) | |
| toast.success(t('backup.toast.settingsSaved')) | |
| } catch { | |
| toast.error(t('backup.toast.settingsError')) | |
| } finally { | |
| setAutoSettingsSaving(false) | |
| } | |
| } | |
| const formatSize = (bytes) => { | |
| if (!bytes) return '-' | |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` | |
| return `${(bytes / 1024 / 1024).toFixed(1)} MB` | |
| } | |
| const formatDate = (dateStr) => { | |
| if (!dateStr) return '-' | |
| try { | |
| const opts: Intl.DateTimeFormatOptions = { | |
| day: '2-digit', month: '2-digit', year: 'numeric', | |
| hour: '2-digit', minute: '2-digit', | |
| } | |
| if (serverTimezone) opts.timeZone = serverTimezone | |
| return new Date(dateStr).toLocaleString(locale, opts) | |
| } catch { return dateStr } | |
| } | |
| const isAuto = (filename) => filename.startsWith('auto-backup-') | |
| return ( | |
| <div className="flex flex-col gap-6"> | |
| {/* Manual Backups */} | |
| <div className="bg-white rounded-2xl border border-gray-200 p-6"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div className="flex items-center gap-3"> | |
| <HardDrive className="w-5 h-5 text-gray-400" /> | |
| <div> | |
| <h2 className="font-semibold text-content">{t('backup.title')}</h2> | |
| <p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={loadBackups} | |
| disabled={isLoading} | |
| className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors" | |
| title={t('backup.refresh')} | |
| > | |
| <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> | |
| </button> | |
| {/* Upload & Restore */} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".zip" | |
| className="hidden" | |
| onChange={handleUploadRestore} | |
| /> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={isUploading} | |
| className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60" | |
| title={isUploading ? t('backup.uploading') : t('backup.upload')} | |
| > | |
| {isUploading ? ( | |
| <div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <Upload className="w-4 h-4" /> | |
| )} | |
| <span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span> | |
| </button> | |
| <button | |
| onClick={handleCreate} | |
| disabled={isCreating} | |
| className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60" | |
| title={isCreating ? t('backup.creating') : t('backup.create')} | |
| > | |
| {isCreating ? ( | |
| <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <Plus className="w-4 h-4" /> | |
| )} | |
| <span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span> | |
| </button> | |
| </div> | |
| </div> | |
| {isLoading && backups.length === 0 ? ( | |
| <div className="flex items-center justify-center py-12 text-gray-400"> | |
| <div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" /> | |
| {t('common.loading')} | |
| </div> | |
| ) : backups.length === 0 ? ( | |
| <div className="text-center py-12 text-gray-400"> | |
| <HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" /> | |
| <p className="text-sm">{t('backup.empty')}</p> | |
| <button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline"> | |
| {t('backup.createFirst')} | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="divide-y divide-gray-100"> | |
| {backups.map(backup => ( | |
| <div key={backup.filename} className="flex items-center gap-4 py-3"> | |
| <div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0"> | |
| {isAuto(backup.filename) | |
| ? <RefreshCw className="w-4 h-4 text-blue-500" /> | |
| : <HardDrive className="w-4 h-4 text-gray-500" /> | |
| } | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p> | |
| {isAuto(backup.filename) && ( | |
| <span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-3 mt-0.5"> | |
| <span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span> | |
| <span className="text-xs text-gray-400">{formatSize(backup.size)}</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1.5 flex-shrink-0"> | |
| <button | |
| onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))} | |
| className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50" | |
| > | |
| <Download className="w-3.5 h-3.5" /> | |
| {t('backup.download')} | |
| </button> | |
| <button | |
| onClick={() => handleRestore(backup.filename)} | |
| disabled={restoringFile === backup.filename} | |
| className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60" | |
| > | |
| {restoringFile === backup.filename | |
| ? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" /> | |
| : <RotateCcw className="w-3.5 h-3.5" /> | |
| } | |
| {t('backup.restore')} | |
| </button> | |
| <button | |
| onClick={() => handleDelete(backup.filename)} | |
| className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Auto-Backup Settings */} | |
| <div className="bg-white rounded-2xl border border-gray-200 p-6"> | |
| <div className="flex items-center gap-3 mb-6"> | |
| <Clock className="w-5 h-5 text-gray-400" /> | |
| <div> | |
| <h2 className="font-semibold text-content">{t('backup.auto.title')}</h2> | |
| <p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-5"> | |
| {/* Enable toggle */} | |
| <label className="flex items-center justify-between gap-4 cursor-pointer"> | |
| <div className="min-w-0"> | |
| <span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span> | |
| <p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p> | |
| </div> | |
| <button | |
| onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)} | |
| className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors" | |
| style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }} | |
| > | |
| <span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} /> | |
| </button> | |
| </label> | |
| {autoSettings.enabled && ( | |
| <> | |
| {/* Interval */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label> | |
| <div className="flex flex-wrap gap-2"> | |
| {INTERVAL_OPTIONS.map(opt => ( | |
| <button | |
| key={opt.value} | |
| onClick={() => handleAutoSettingsChange('interval', opt.value)} | |
| className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${ | |
| autoSettings.interval === opt.value | |
| ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700' | |
| : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' | |
| }`} | |
| > | |
| {t(opt.labelKey)} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Hour picker (for daily, weekly, monthly) */} | |
| {autoSettings.interval !== 'hourly' && ( | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label> | |
| <CustomSelect | |
| value={String(autoSettings.hour)} | |
| onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))} | |
| size="sm" | |
| options={HOURS.map(h => { | |
| let label: string | |
| if (is12h) { | |
| const period = h >= 12 ? 'PM' : 'AM' | |
| const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h | |
| label = `${h12}:00 ${period}` | |
| } else { | |
| label = `${String(h).padStart(2, '0')}:00` | |
| } | |
| return { value: String(h), label } | |
| })} | |
| /> | |
| <p className="text-xs text-gray-400 mt-1"> | |
| {t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''} | |
| </p> | |
| </div> | |
| )} | |
| {/* Day of week (for weekly) */} | |
| {autoSettings.interval === 'weekly' && ( | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label> | |
| <div className="flex flex-wrap gap-2"> | |
| {DAYS_OF_WEEK.map(opt => ( | |
| <button | |
| key={opt.value} | |
| onClick={() => handleAutoSettingsChange('day_of_week', opt.value)} | |
| className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${ | |
| autoSettings.day_of_week === opt.value | |
| ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700' | |
| : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' | |
| }`} | |
| > | |
| {t(opt.labelKey)} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Day of month (for monthly) */} | |
| {autoSettings.interval === 'monthly' && ( | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label> | |
| <CustomSelect | |
| value={String(autoSettings.day_of_month)} | |
| onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))} | |
| size="sm" | |
| options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))} | |
| /> | |
| <p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p> | |
| </div> | |
| )} | |
| {/* Keep duration */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label> | |
| <div className="flex flex-wrap gap-2"> | |
| {KEEP_OPTIONS.map(opt => ( | |
| <button | |
| key={opt.value} | |
| onClick={() => handleAutoSettingsChange('keep_days', opt.value)} | |
| className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${ | |
| autoSettings.keep_days === opt.value | |
| ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700' | |
| : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' | |
| }`} | |
| > | |
| {t(opt.labelKey)} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| {/* Save button */} | |
| <div className="flex justify-end pt-2 border-t border-gray-100"> | |
| <button | |
| onClick={handleSaveAutoSettings} | |
| disabled={autoSettingsSaving || !autoSettingsDirty} | |
| className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors" | |
| > | |
| {autoSettingsSaving | |
| ? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| : <Check className="w-4 h-4" /> | |
| } | |
| {autoSettingsSaving ? t('common.saving') : t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Restore Warning Modal */} | |
| {restoreConfirm && ( | |
| <div | |
| className="bg-[rgba(0,0,0,0.5)]" | |
| style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} | |
| onClick={() => setRestoreConfirm(null)} | |
| > | |
| <div | |
| onClick={e => e.stopPropagation()} | |
| style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} | |
| className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" | |
| > | |
| {/* Red header */} | |
| <div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}> | |
| <div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> | |
| <AlertTriangle size={20} className="text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}> | |
| {t('backup.restoreConfirmTitle')} | |
| </h3> | |
| <p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}> | |
| {restoreConfirm.filename} | |
| </p> | |
| </div> | |
| </div> | |
| {/* Body */} | |
| <div style={{ padding: '20px 24px' }}> | |
| <p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}> | |
| {t('backup.restoreWarning')} | |
| </p> | |
| <div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }} | |
| className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800" | |
| > | |
| {t('backup.restoreTip')} | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}> | |
| <button | |
| onClick={() => setRestoreConfirm(null)} | |
| className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700" | |
| style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} | |
| > | |
| {t('common.cancel')} | |
| </button> | |
| <button | |
| onClick={executeRestore} | |
| className="bg-[#dc2626] text-white" | |
| style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} | |
| onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'} | |
| onMouseLeave={e => e.currentTarget.style.background = '#dc2626'} | |
| > | |
| {t('backup.restoreConfirm')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |