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:
Jason
2026-02-22 08:40:47 +08:00
parent 3afec8a10f
commit f8820aa22c
13 changed files with 488 additions and 73 deletions
+9
View File
@@ -141,3 +141,12 @@ pub async fn restore_db_backup(
.map_err(|e| format!("Restore failed: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// Rename a database backup file
#[tauri::command]
pub fn rename_db_backup(
#[allow(non_snake_case)] oldFilename: String,
#[allow(non_snake_case)] newName: String,
) -> Result<String, String> {
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
}
+76 -7
View File
@@ -2,7 +2,7 @@
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
use super::{lock_conn, Database};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use chrono::Utc;
@@ -129,8 +129,13 @@ impl Database {
))
}
/// Periodic backup: create a new backup if the latest one is older than 24 hours (or none exists)
/// Periodic backup: create a new backup if the latest one is older than the configured interval
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
let interval_hours = crate::settings::effective_backup_interval_hours();
if interval_hours == 0 {
return Ok(()); // Auto-backup disabled
}
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
self.backup_database_file()?;
@@ -145,17 +150,18 @@ impl Database {
.max()
});
let interval_secs = u64::from(interval_hours) * 3600;
let needs_backup = match latest {
None => true,
Some(last_modified) => {
last_modified.elapsed().unwrap_or_default()
> std::time::Duration::from_secs(24 * 3600)
> std::time::Duration::from_secs(interval_secs)
}
};
if needs_backup {
log::info!(
"Periodic backup: latest backup is older than 24 hours, creating new backup"
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
);
self.backup_database_file()?;
}
@@ -203,6 +209,7 @@ impl Database {
/// 清理旧的数据库备份,保留最新的 N 个
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
let retain = crate::settings::effective_backup_retain_count();
let entries = match fs::read_dir(dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
@@ -217,11 +224,11 @@ impl Database {
Err(_) => return Ok(()),
};
if entries.len() <= DB_BACKUP_RETAIN {
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
let remove_count = entries.len().saturating_sub(retain);
let mut sorted = entries;
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
@@ -418,7 +425,6 @@ impl Database {
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.starts_with("db_backup_")
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
@@ -462,4 +468,67 @@ impl Database {
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
Ok(safety_id)
}
/// Rename a backup file. Returns the new filename.
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
// Validate old filename (path traversal + .db suffix)
if old_filename.contains("..")
|| old_filename.contains('/')
|| old_filename.contains('\\')
|| !old_filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
// Clean new name
let trimmed = new_name.trim();
if trimmed.is_empty() {
return Err(AppError::InvalidInput(
"New name cannot be empty".to_string(),
));
}
// Length limit (without .db suffix)
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
if name_part.len() > 100 {
return Err(AppError::InvalidInput(
"Name too long (max 100 characters)".to_string(),
));
}
// Prevent path traversal in new name
if name_part.contains("..")
|| name_part.contains('/')
|| name_part.contains('\\')
|| name_part.contains('\0')
{
return Err(AppError::InvalidInput(
"Invalid characters in new name".to_string(),
));
}
let new_filename = format!("{name_part}.db");
let backup_dir = get_app_config_dir().join("backups");
let old_path = backup_dir.join(old_filename);
let new_path = backup_dir.join(&new_filename);
if !old_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {old_filename}"
)));
}
if new_path.exists() {
return Err(AppError::InvalidInput(format!(
"A backup named '{new_filename}' already exists"
)));
}
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
log::info!("Renamed backup: {old_filename} -> {new_filename}");
Ok(new_filename)
}
}
-3
View File
@@ -43,9 +43,6 @@ use std::sync::Mutex;
// DAO 方法通过 impl Database 提供,无需额外导出
/// 数据库备份保留数量
const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5;
+1
View File
@@ -903,6 +903,7 @@ pub fn run() {
commands::open_zip_file_dialog,
commands::list_db_backups,
commands::restore_db_backup,
commands::rename_db_backup,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
+37
View File
@@ -245,6 +245,14 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_backup: Option<serde_json::Value>,
// ===== 备份策略设置 =====
/// Auto-backup interval in hours (default 24, 0 = disabled)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup_interval_hours: Option<u32>,
/// Maximum number of backup files to retain (default 10)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup_retain_count: Option<u32>,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
@@ -289,6 +297,8 @@ impl Default for AppSettings {
skill_sync_method: SyncMethod::default(),
webdav_sync: None,
webdav_backup: None,
backup_interval_hours: None,
backup_retain_count: None,
preferred_terminal: None,
}
}
@@ -623,6 +633,33 @@ pub fn get_skill_sync_method() -> SyncMethod {
.skill_sync_method
}
// ===== 备份策略管理函数 =====
/// Get the effective auto-backup interval in hours (default 24)
pub fn effective_backup_interval_hours() -> u32 {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.backup_interval_hours
.unwrap_or(24)
}
/// Get the effective backup retain count (default 10, minimum 1)
pub fn effective_backup_retain_count() -> usize {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.backup_retain_count
.map(|n| (n as usize).max(1))
.unwrap_or(10)
}
// ===== 终端设置管理函数 =====
/// 获取首选终端应用
+263 -56
View File
@@ -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)} &middot;{" "}
{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
+34 -1
View File
@@ -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>
+13
View File
@@ -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,
};
}
+15 -2
View File
@@ -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",
+15 -2
View File
@@ -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 クラウド同期",
+15 -2
View File
@@ -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 云同步",
+4
View File
@@ -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 });
},
};
+6
View File
@@ -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"