mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
feat(backup): add independent backup panel, configurable policy, and rename support
Extract backup & restore into a standalone AccordionItem in Advanced settings. Add configurable auto-backup interval (disabled/6h/12h/24h/48h/7d) and retention count (3-50) via settings. Add per-backup rename with inline editing UI.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { HardDriveDownload, RotateCcw } from "lucide-react";
|
||||
import { Pencil, RotateCcw, Check, X } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,10 +10,28 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useBackupManager } from "@/hooks/useBackupManager";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
interface BackupListSectionProps {
|
||||
backupIntervalHours?: number;
|
||||
backupRetainCount?: number;
|
||||
onSettingsChange: (updates: {
|
||||
backupIntervalHours?: number;
|
||||
backupRetainCount?: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -29,10 +47,31 @@ function formatBackupDate(isoString: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupListSection() {
|
||||
/** Parse display name from backup filename */
|
||||
function getDisplayName(filename: string): string {
|
||||
// Try to parse db_backup_YYYYMMDD_HHMMSS format
|
||||
const match = filename.match(
|
||||
/^db_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_\d+)?\.db$/,
|
||||
);
|
||||
if (match) {
|
||||
const [, y, m, d, hh, mm, ss] = match;
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
// Otherwise show filename without .db suffix
|
||||
return filename.replace(/\.db$/, "");
|
||||
}
|
||||
|
||||
export function BackupListSection({
|
||||
backupIntervalHours,
|
||||
backupRetainCount,
|
||||
onSettingsChange,
|
||||
}: BackupListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { backups, isLoading, restore, isRestoring } = useBackupManager();
|
||||
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
|
||||
useBackupManager();
|
||||
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!confirmFilename) return;
|
||||
@@ -61,66 +100,234 @@ export function BackupListSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRename = (filename: string) => {
|
||||
setEditingFilename(filename);
|
||||
setEditValue(getDisplayName(filename));
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingFilename(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const handleConfirmRename = async () => {
|
||||
if (!editingFilename || !editValue.trim()) return;
|
||||
try {
|
||||
await rename({ oldFilename: editingFilename, newName: editValue.trim() });
|
||||
setEditingFilename(null);
|
||||
setEditValue("");
|
||||
toast.success(
|
||||
t("settings.backupManager.renameSuccess", {
|
||||
defaultValue: "Backup renamed",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("settings.backupManager.renameFailed", {
|
||||
defaultValue: "Rename failed",
|
||||
});
|
||||
toast.error(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalValue = String(backupIntervalHours ?? 24);
|
||||
const retainValue = String(backupRetainCount ?? 10);
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDriveDownload className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">
|
||||
<div className="space-y-4">
|
||||
{/* Backup policy settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
{t("settings.backupManager.intervalLabel", {
|
||||
defaultValue: "Auto-backup Interval",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={intervalValue}
|
||||
onValueChange={(v) =>
|
||||
onSettingsChange({ backupIntervalHours: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">
|
||||
{t("settings.backupManager.intervalDisabled", {
|
||||
defaultValue: "Disabled",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 6,
|
||||
defaultValue: "6 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 12,
|
||||
defaultValue: "12 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 24,
|
||||
defaultValue: "24 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 48,
|
||||
defaultValue: "48 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="168">
|
||||
{t("settings.backupManager.intervalDays", {
|
||||
days: 7,
|
||||
defaultValue: "7 days",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
{t("settings.backupManager.retainLabel", {
|
||||
defaultValue: "Backup Retention",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={retainValue}
|
||||
onValueChange={(v) =>
|
||||
onSettingsChange({ backupRetainCount: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[3, 5, 10, 15, 20, 30, 50].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup list */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
{t("settings.backupManager.title", {
|
||||
defaultValue: "Database Backups",
|
||||
})}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t("settings.backupManager.description", {
|
||||
defaultValue:
|
||||
"Automatic database snapshots for restoring to a previous state",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
{t("settings.backupManager.empty", {
|
||||
defaultValue: "No backups yet",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.filename}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs truncate">
|
||||
{formatBackupDate(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(backup.sizeBytes)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs shrink-0"
|
||||
disabled={isRestoring}
|
||||
onClick={() => setConfirmFilename(backup.filename)}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
{t("settings.backupManager.empty", {
|
||||
defaultValue: "No backups yet",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.filename}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
{isRestoring
|
||||
? t("settings.backupManager.restoring", {
|
||||
defaultValue: "Restoring...",
|
||||
})
|
||||
: t("settings.backupManager.restore", {
|
||||
defaultValue: "Restore",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingFilename === backup.filename ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
placeholder={t(
|
||||
"settings.backupManager.namePlaceholder",
|
||||
{ defaultValue: "Enter new name" },
|
||||
)}
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !editValue.trim()}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleCancelRename}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-mono text-xs truncate">
|
||||
{getDisplayName(backup.filename)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBackupDate(backup.createdAt)} ·{" "}
|
||||
{formatBytes(backup.sizeBytes)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingFilename !== backup.filename && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleStartRename(backup.filename)}
|
||||
disabled={isRestoring || isRenaming}
|
||||
title={t("settings.backupManager.rename", {
|
||||
defaultValue: "Rename",
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={isRestoring}
|
||||
onClick={() => setConfirmFilename(backup.filename)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
{isRestoring
|
||||
? t("settings.backupManager.restoring", {
|
||||
defaultValue: "Restoring...",
|
||||
})
|
||||
: t("settings.backupManager.restore", {
|
||||
defaultValue: "Restore",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Database,
|
||||
Cloud,
|
||||
ScrollText,
|
||||
HardDriveDownload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -325,7 +326,39 @@ export function SettingsPage({
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
<BackupListSection />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="backup"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDriveDownload className="h-5 w-5 text-amber-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.backup.title", {
|
||||
defaultValue: "Backup & Restore",
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.backup.description", {
|
||||
defaultValue:
|
||||
"Manage automatic backups, view and restore database snapshots",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<BackupListSection
|
||||
backupIntervalHours={settings.backupIntervalHours}
|
||||
backupRetainCount={settings.backupRetainCount}
|
||||
onSettingsChange={(updates) =>
|
||||
handleAutoSave(updates)
|
||||
}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -23,10 +23,23 @@ export function useBackupManager() {
|
||||
},
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({
|
||||
oldFilename,
|
||||
newName,
|
||||
}: {
|
||||
oldFilename: string;
|
||||
newName: string;
|
||||
}) => backupsApi.renameDbBackup(oldFilename, newName),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
return {
|
||||
backups,
|
||||
isLoading,
|
||||
restore: restoreMutation.mutateAsync,
|
||||
isRestoring: restoreMutation.isPending,
|
||||
rename: renameMutation.mutateAsync,
|
||||
isRenaming: renameMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +230,11 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "Data Management",
|
||||
"description": "Import/export local configurations and backup/restore"
|
||||
"description": "Import and export local configuration data"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Restore",
|
||||
"description": "Manage automatic backups, view and restore database snapshots"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Cloud Sync",
|
||||
@@ -306,7 +310,16 @@
|
||||
"confirmMessage": "Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||
"restoreSuccess": "Restore successful! Safety backup created",
|
||||
"restoreFailed": "Restore failed",
|
||||
"safetyBackupId": "Safety Backup ID"
|
||||
"safetyBackupId": "Safety Backup ID",
|
||||
"intervalLabel": "Auto-backup Interval",
|
||||
"retainLabel": "Backup Retention",
|
||||
"intervalDisabled": "Disabled",
|
||||
"intervalHours": "{{hours}} hours",
|
||||
"intervalDays": "{{days}} days",
|
||||
"rename": "Rename",
|
||||
"renameSuccess": "Backup renamed",
|
||||
"renameFailed": "Rename failed",
|
||||
"namePlaceholder": "Enter new name"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV Cloud Sync",
|
||||
|
||||
@@ -230,7 +230,11 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
"description": "ローカル設定のインポート/エクスポートとバックアップ/復元"
|
||||
"description": "ローカル設定データのインポートとエクスポート"
|
||||
},
|
||||
"backup": {
|
||||
"title": "バックアップと復元",
|
||||
"description": "自動バックアップの管理、データベーススナップショットの表示と復元"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "クラウド同期",
|
||||
@@ -306,7 +310,16 @@
|
||||
"confirmMessage": "このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。",
|
||||
"restoreSuccess": "復元成功!安全バックアップが作成されました",
|
||||
"restoreFailed": "復元に失敗しました",
|
||||
"safetyBackupId": "安全バックアップID"
|
||||
"safetyBackupId": "安全バックアップID",
|
||||
"intervalLabel": "自動バックアップ間隔",
|
||||
"retainLabel": "バックアップ保持数",
|
||||
"intervalDisabled": "無効",
|
||||
"intervalHours": "{{hours}} 時間",
|
||||
"intervalDays": "{{days}} 日",
|
||||
"rename": "名前変更",
|
||||
"renameSuccess": "バックアップの名前を変更しました",
|
||||
"renameFailed": "名前変更に失敗しました",
|
||||
"namePlaceholder": "新しい名前を入力"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV クラウド同期",
|
||||
|
||||
@@ -230,7 +230,11 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"description": "导入导出本地配置与备份恢复"
|
||||
"description": "导入和导出本地配置数据"
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份与恢复",
|
||||
"description": "管理自动备份,查看和恢复数据库快照"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "云同步",
|
||||
@@ -306,7 +310,16 @@
|
||||
"confirmMessage": "恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。",
|
||||
"restoreSuccess": "恢复成功!安全备份已创建",
|
||||
"restoreFailed": "恢复失败",
|
||||
"safetyBackupId": "安全备份ID"
|
||||
"safetyBackupId": "安全备份ID",
|
||||
"intervalLabel": "自动备份间隔",
|
||||
"retainLabel": "备份保留数量",
|
||||
"intervalDisabled": "禁用",
|
||||
"intervalHours": "{{hours}} 小时",
|
||||
"intervalDays": "{{days}} 天",
|
||||
"rename": "重命名",
|
||||
"renameSuccess": "备份重命名成功",
|
||||
"renameFailed": "重命名失败",
|
||||
"namePlaceholder": "输入新名称"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV 云同步",
|
||||
|
||||
@@ -230,4 +230,8 @@ export const backupsApi = {
|
||||
async restoreDbBackup(filename: string): Promise<string> {
|
||||
return await invoke("restore_db_backup", { filename });
|
||||
},
|
||||
|
||||
async renameDbBackup(oldFilename: string, newName: string): Promise<string> {
|
||||
return await invoke("rename_db_backup", { oldFilename, newName });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -251,6 +251,12 @@ export interface Settings {
|
||||
// ===== WebDAV v2 同步设置 =====
|
||||
webdavSync?: WebDavSyncSettings;
|
||||
|
||||
// ===== 备份策略设置 =====
|
||||
// Auto-backup interval in hours (0=disabled, default 24)
|
||||
backupIntervalHours?: number;
|
||||
// Maximum backup files to retain (default 10)
|
||||
backupRetainCount?: number;
|
||||
|
||||
// ===== 终端设置 =====
|
||||
// 首选终端应用(可选,默认使用系统默认终端)
|
||||
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
|
||||
Reference in New Issue
Block a user