From f8820aa22c46f6f9f3f900d4bebee0016b773553 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 22 Feb 2026 08:40:47 +0800 Subject: [PATCH] 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. --- src-tauri/src/commands/import_export.rs | 9 + src-tauri/src/database/backup.rs | 83 ++++- src-tauri/src/database/mod.rs | 3 - src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 37 ++ src/components/settings/BackupListSection.tsx | 319 +++++++++++++++--- src/components/settings/SettingsPage.tsx | 35 +- src/hooks/useBackupManager.ts | 13 + src/i18n/locales/en.json | 17 +- src/i18n/locales/ja.json | 17 +- src/i18n/locales/zh.json | 17 +- src/lib/api/settings.ts | 4 + src/types.ts | 6 + 13 files changed, 488 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index fc1ddb675..42ee6b209 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -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 { + Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index febe31509..b3d98df89 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -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 { + // 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) + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index efd50a3bc..9ec7ea608 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c32e0f0f..464d85f03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9d59e95e1..8ee07e60b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -245,6 +245,14 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub webdav_backup: Option, + // ===== 备份策略设置 ===== + /// Auto-backup interval in hours (default 24, 0 = disabled) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backup_interval_hours: Option, + /// Maximum number of backup files to retain (default 10) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backup_retain_count: Option, + // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) /// - 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) +} + // ===== 终端设置管理函数 ===== /// 获取首选终端应用 diff --git a/src/components/settings/BackupListSection.tsx b/src/components/settings/BackupListSection.tsx index 78b34beb7..b5c587ba2 100644 --- a/src/components/settings/BackupListSection.tsx +++ b/src/components/settings/BackupListSection.tsx @@ -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(null); + const [editingFilename, setEditingFilename] = useState(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 ( -
-
- -

+
+ {/* Backup policy settings */} +
+
+ + +
+ +
+ + +
+
+ + {/* Backup list */} +
+

{t("settings.backupManager.title", { defaultValue: "Database Backups", })}

-
-

- {t("settings.backupManager.description", { - defaultValue: - "Automatic database snapshots for restoring to a previous state", - })} -

- {isLoading ? ( -
Loading...
- ) : backups.length === 0 ? ( -
- {t("settings.backupManager.empty", { - defaultValue: "No backups yet", - })} -
- ) : ( -
- {backups.map((backup) => ( -
-
-
- {formatBackupDate(backup.createdAt)} -
-
- {formatBytes(backup.sizeBytes)} -
-
- -
- ))} -
- )} +
+ {editingFilename === backup.filename ? ( +
+ 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} + /> + + +
+ ) : ( + <> +
+ {getDisplayName(backup.filename)} +
+
+ {formatBackupDate(backup.createdAt)} ·{" "} + {formatBytes(backup.sizeBytes)} +
+ + )} +
+ {editingFilename !== backup.filename && ( +
+ + +
+ )} +
+ ))} +

+ )} +
{/* Confirmation Dialog */} - + + + + + +
+ +
+

+ {t("settings.advanced.backup.title", { + defaultValue: "Backup & Restore", + })} +

+

+ {t("settings.advanced.backup.description", { + defaultValue: + "Manage automatic backups, view and restore database snapshots", + })} +

+
+
+
+ + + handleAutoSave(updates) + } + />
diff --git a/src/hooks/useBackupManager.ts b/src/hooks/useBackupManager.ts index c6b3aaf97..f6b2a8c6e 100644 --- a/src/hooks/useBackupManager.ts +++ b/src/hooks/useBackupManager.ts @@ -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, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 76e4526e9..48833b75c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8a3e202d3..391142f12 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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 クラウド同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 14a5e0632..512234c33 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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 云同步", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 87ab9655f..567e78f70 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -230,4 +230,8 @@ export const backupsApi = { async restoreDbBackup(filename: string): Promise { return await invoke("restore_db_backup", { filename }); }, + + async renameDbBackup(oldFilename: string, newName: string): Promise { + return await invoke("rename_db_backup", { oldFilename, newName }); + }, }; diff --git a/src/types.ts b/src/types.ts index 77d97c4fa..a62bb2c8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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"