From 3afec8a10fd9cfb14cd6607a790fe2e195179caf Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 21 Feb 2026 23:56:56 +0800 Subject: [PATCH] feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI Four improvements to the database backup mechanism: 1. Auto backup before schema migration - creates a snapshot when upgrading from an older database version, providing a safety net beyond the existing SAVEPOINT rollback mechanism. 2. Periodic startup backup - checks on app launch whether the latest backup is older than 24 hours and creates a new one if needed, ensuring all users have recent backups regardless of usage patterns. 3. Backfill failure notification - switch now returns SwitchResult with warnings instead of silently ignoring backfill errors, so users are informed when their manual config changes may not have been saved. 4. Backup management UI - new BackupListSection in Settings > Data Management showing all backup snapshots with restore capability, including a confirmation dialog and automatic safety backup before restore. --- src-tauri/src/commands/import_export.rs | 23 +++ src-tauri/src/commands/provider.rs | 18 +- src-tauri/src/database/backup.rs | 131 +++++++++++++- src-tauri/src/database/mod.rs | 18 +- src-tauri/src/lib.rs | 7 + src-tauri/src/services/mod.rs | 2 +- src-tauri/src/services/provider/mod.rs | 31 +++- src/components/settings/BackupListSection.tsx | 166 ++++++++++++++++++ src/components/settings/SettingsPage.tsx | 2 + src/hooks/useBackupManager.ts | 32 ++++ src/hooks/useProviderActions.ts | 13 +- src/i18n/locales/en.json | 15 +- src/i18n/locales/ja.json | 15 +- src/i18n/locales/zh.json | 15 +- src/lib/api/index.ts | 1 + src/lib/api/providers.ts | 6 +- src/lib/api/settings.ts | 16 ++ src/lib/query/mutations.ts | 3 +- 18 files changed, 490 insertions(+), 24 deletions(-) create mode 100644 src/components/settings/BackupListSection.tsx create mode 100644 src/hooks/useBackupManager.ts diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index c4482629d..fc1ddb675 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt; use crate::commands::sync_support::{ post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning, }; +use crate::database::backup::BackupEntry; +use crate::database::Database; use crate::error::AppError; use crate::services::provider::ProviderService; use crate::store::AppState; @@ -118,3 +120,24 @@ pub async fn open_zip_file_dialog( Ok(result.map(|p| p.to_string())) } + +// ─── Database backup management ───────────────────────────── + +/// List all database backup files +#[tauri::command] +pub fn list_db_backups() -> Result, String> { + Database::list_backups().map_err(|e| e.to_string()) +} + +/// Restore database from a backup file +#[tauri::command] +pub async fn restore_db_backup( + state: State<'_, AppState>, + filename: String, +) -> Result { + let db = state.db.clone(); + tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename)) + .await + .map_err(|e| format!("Restore failed: {e}"))? + .map_err(|e: AppError| e.to_string()) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 329f1f5bd..ef9c1bb0e 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -4,7 +4,9 @@ use tauri::State; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; -use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService}; +use crate::services::{ + EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult, +}; use crate::store::AppState; use std::str::FromStr; @@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config( .map_err(|e| e.to_string()) } -fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { +fn switch_provider_internal( + state: &AppState, + app_type: AppType, + id: &str, +) -> Result { ProviderService::switch(state, app_type, id) } @@ -76,7 +82,7 @@ pub fn switch_provider_test_hook( state: &AppState, app_type: AppType, id: &str, -) -> Result<(), AppError> { +) -> Result { switch_provider_internal(state, app_type, id) } @@ -85,11 +91,9 @@ pub fn switch_provider( state: State<'_, AppState>, app: String, id: String, -) -> Result { +) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - switch_provider_internal(&state, app_type, &id) - .map(|_| true) - .map_err(|e| e.to_string()) + switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string()) } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 484b22569..febe31509 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -15,6 +15,15 @@ use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; +/// A database backup entry for the UI +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupEntry { + pub filename: String, + pub size_bytes: u64, + pub created_at: String, // ISO 8601 +} + impl Database { /// 导出为 SQLite 兼容的 SQL 文本(内存字符串) pub fn export_sql_string(&self) -> Result { @@ -120,8 +129,41 @@ impl Database { )) } + /// Periodic backup: create a new backup if the latest one is older than 24 hours (or none exists) + pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> { + let backup_dir = get_app_config_dir().join("backups"); + if !backup_dir.exists() { + self.backup_database_file()?; + return Ok(()); + } + + let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false)) + .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok())) + .max() + }); + + let needs_backup = match latest { + None => true, + Some(last_modified) => { + last_modified.elapsed().unwrap_or_default() + > std::time::Duration::from_secs(24 * 3600) + } + }; + + if needs_backup { + log::info!( + "Periodic backup: latest backup is older than 24 hours, creating new backup" + ); + self.backup_database_file()?; + } + Ok(()) + } + /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) - fn backup_database_file(&self) -> Result, AppError> { + pub(crate) fn backup_database_file(&self) -> Result, AppError> { let db_path = get_app_config_dir().join("cc-switch.db"); if !db_path.exists() { return Ok(None); @@ -333,4 +375,91 @@ impl Database { } } } + + /// List all database backup files, sorted by creation time (newest first) + pub fn list_backups() -> Result, AppError> { + let backup_dir = get_app_config_dir().join("backups"); + if !backup_dir.exists() { + return Ok(vec![]); + } + + let mut entries: Vec = fs::read_dir(&backup_dir) + .map_err(|e| AppError::io(&backup_dir, e))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false)) + .filter_map(|e| { + let metadata = e.metadata().ok()?; + let filename = e.file_name().to_string_lossy().to_string(); + let size_bytes = metadata.len(); + let created_at = metadata + .modified() + .ok() + .map(|t| { + let dt: chrono::DateTime = t.into(); + dt.to_rfc3339() + }) + .unwrap_or_default(); + Some(BackupEntry { + filename, + size_bytes, + created_at, + }) + }) + .collect(); + + // Sort by created_at descending (newest first) + entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(entries) + } + + /// Restore database from a backup file. Returns the safety backup ID. + pub fn restore_from_backup(&self, filename: &str) -> Result { + // Security: validate filename to prevent path traversal + if filename.contains("..") + || filename.contains('/') + || filename.contains('\\') + || !filename.starts_with("db_backup_") + || !filename.ends_with(".db") + { + return Err(AppError::InvalidInput( + "Invalid backup filename".to_string(), + )); + } + + let backup_dir = get_app_config_dir().join("backups"); + let backup_path = backup_dir.join(filename); + + if !backup_path.exists() { + return Err(AppError::InvalidInput(format!( + "Backup file not found: {filename}" + ))); + } + + // Step 1: Create safety backup of current database + let safety_backup = self.backup_database_file()?; + let safety_id = safety_backup + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default(); + + // Step 2: Open the backup file and restore it to the main database + let source_conn = + Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; + + { + let mut main_conn = lock_conn!(self.conn); + let backup = Backup::new(&source_conn, &mut main_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + // Step 3: Run schema migrations (backup may be from an older version) + self.create_tables()?; + self.apply_schema_migrations()?; + self.ensure_model_pricing_seeded()?; + + log::info!("Database restored from backup: {filename}, safety backup: {safety_id}"); + Ok(safety_id) + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 139c9093d..efd50a3bc 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -23,7 +23,7 @@ //! └── settings.rs //! ``` -mod backup; +pub(crate) mod backup; mod dao; mod migration; mod schema; @@ -110,6 +110,22 @@ impl Database { conn: Mutex::new(conn), }; db.create_tables()?; + + // Pre-migration backup: only when upgrading from an existing database + { + let conn = lock_conn!(db.conn); + let version = Self::get_user_version(&conn)?; + drop(conn); + if version > 0 && version < SCHEMA_VERSION { + log::info!( + "Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})" + ); + if let Err(e) = db.backup_database_file() { + log::warn!("Pre-migration backup failed, continuing migration: {e}"); + } + } + } + db.apply_schema_migrations()?; db.ensure_model_pricing_seeded()?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d159aae13..5c32e0f0f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -768,6 +768,11 @@ pub fn run() { // 检查 settings 表中的代理状态,自动恢复代理服务 restore_proxy_state_on_startup(&state).await; + + // Periodic backup check + if let Err(e) = state.db.periodic_backup_if_needed() { + log::warn!("Periodic backup failed on startup: {e}"); + } }); // Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏 @@ -896,6 +901,8 @@ pub fn run() { commands::save_file_dialog, commands::open_file_dialog, commands::open_zip_file_dialog, + commands::list_db_backups, + commands::restore_db_backup, commands::sync_current_providers_live, // Deep link import commands::parse_deeplink, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index e350372a0..c13d2880b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -18,7 +18,7 @@ pub use config::ConfigService; pub use mcp::McpService; pub use omo::OmoService; pub use prompt::PromptService; -pub use provider::{ProviderService, ProviderSortUpdate}; +pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult}; pub use proxy::ProxyService; #[allow(unused_imports)] pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a7324066c..7a35754a4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -38,6 +38,13 @@ use usage::validate_usage_script; /// Provider business logic service pub struct ProviderService; +/// Result of a provider switch operation, including any non-fatal warnings +#[derive(Debug, serde::Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SwitchResult { + pub warnings: Vec, +} + #[cfg(test)] mod tests { use super::*; @@ -447,7 +454,7 @@ impl ProviderService { /// c. Update database is_current (as default for new devices) /// d. Write target provider config to live files /// e. Sync MCP configuration - pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result { // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; let _provider = providers @@ -519,7 +526,7 @@ impl ProviderService { // Note: No Live config write, no MCP sync // The proxy server will route requests to the new provider via is_current - return Ok(()); + return Ok(SwitchResult::default()); } // Normal mode: full switch with Live config write @@ -532,7 +539,7 @@ impl ProviderService { app_type: AppType, id: &str, providers: &indexmap::IndexMap, - ) -> Result<(), AppError> { + ) -> Result { let provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; @@ -545,7 +552,7 @@ impl ProviderService { state, &crate::services::omo::STANDARD, )?; - return Ok(()); + return Ok(SwitchResult::default()); } if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim") @@ -554,9 +561,11 @@ impl ProviderService { .db .set_omo_provider_current(app_type.as_str(), id, "omo-slim")?; crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?; - return Ok(()); + return Ok(SwitchResult::default()); } + let mut result = SwitchResult::default(); + // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; @@ -570,8 +579,14 @@ impl ProviderService { if let Ok(live_config) = read_live_settings(app_type.clone()) { if let Some(mut current_provider) = providers.get(¤t_id).cloned() { current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + if let Err(e) = + state.db.save_provider(app_type.as_str(), ¤t_provider) + { + log::warn!("Backfill failed: {e}"); + result + .warnings + .push(format!("backfill_failed:{current_id}")); + } } } } @@ -593,7 +608,7 @@ impl ProviderService { // Sync MCP McpService::sync_all_enabled(state)?; - Ok(()) + Ok(result) } /// Sync current provider to live configuration (re-export) diff --git a/src/components/settings/BackupListSection.tsx b/src/components/settings/BackupListSection.tsx new file mode 100644 index 000000000..78b34beb7 --- /dev/null +++ b/src/components/settings/BackupListSection.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { HardDriveDownload, RotateCcw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useBackupManager } from "@/hooks/useBackupManager"; +import { extractErrorMessage } from "@/utils/errorUtils"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatBackupDate(isoString: string): string { + try { + const date = new Date(isoString); + return date.toLocaleString(); + } catch { + return isoString; + } +} + +export function BackupListSection() { + const { t } = useTranslation(); + const { backups, isLoading, restore, isRestoring } = useBackupManager(); + const [confirmFilename, setConfirmFilename] = useState(null); + + const handleRestore = async () => { + if (!confirmFilename) return; + try { + const safetyId = await restore(confirmFilename); + setConfirmFilename(null); + toast.success( + t("settings.backupManager.restoreSuccess", { + defaultValue: "Restore successful! Safety backup created", + }), + { + description: safetyId + ? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}` + : undefined, + duration: 6000, + closeButton: true, + }, + ); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("settings.backupManager.restoreFailed", { + defaultValue: "Restore failed", + }); + toast.error(detail); + } + }; + + return ( +
+
+ +

+ {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)} +
+
+ +
+ ))} +
+ )} + + {/* Confirmation Dialog */} + !open && setConfirmFilename(null)} + > + + + + {t("settings.backupManager.confirmTitle", { + defaultValue: "Confirm Restore", + })} + + + {t("settings.backupManager.confirmMessage", { + defaultValue: + "Restoring this backup will overwrite the current database. A safety backup will be created first.", + })} + + + + + + + + +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 7640bfcb7..51adf1878 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -33,6 +33,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; +import { BackupListSection } from "@/components/settings/BackupListSection"; import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection"; import { AboutSection } from "@/components/settings/AboutSection"; import { ProxyTabContent } from "@/components/settings/ProxyTabContent"; @@ -324,6 +325,7 @@ export function SettingsPage({ onExport={exportConfig} onClear={clearSelection} /> + diff --git a/src/hooks/useBackupManager.ts b/src/hooks/useBackupManager.ts new file mode 100644 index 000000000..c6b3aaf97 --- /dev/null +++ b/src/hooks/useBackupManager.ts @@ -0,0 +1,32 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { backupsApi } from "@/lib/api"; + +export function useBackupManager() { + const queryClient = useQueryClient(); + + const { + data: backups = [], + isLoading, + refetch, + } = useQuery({ + queryKey: ["db-backups"], + queryFn: () => backupsApi.listDbBackups(), + }); + + const restoreMutation = useMutation({ + mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename), + onSuccess: async () => { + // Invalidate all queries to refresh data from restored database + await queryClient.invalidateQueries(); + // Refetch backup list + await refetch(); + }, + }); + + return { + backups, + isLoading, + restore: restoreMutation.mutateAsync, + isRestoring: restoreMutation.isPending, + }; +} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 42a5c4120..d6b3cd178 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -134,9 +134,20 @@ export function useProviderActions(activeApp: AppId) { const switchProvider = useCallback( async (provider: Provider) => { try { - await switchProviderMutation.mutateAsync(provider.id); + const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); + // Show backfill warning if present + if (result?.warnings?.length) { + toast.warning( + t("notifications.backfillWarning", { + defaultValue: + "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存", + }), + { duration: 5000 }, + ); + } + // 根据供应商类型显示不同的成功提示 if ( activeApp === "claude" && diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c19e4026c..76e4526e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -174,7 +174,8 @@ "openclawModelsRegistered": "Models have been registered to /model list", "openclawDefaultModelSet": "Set as default model", "openclawDefaultModelSetFailed": "Failed to set default model", - "openclawNoModels": "No models configured" + "openclawNoModels": "No models configured", + "backfillWarning": "Switched successfully, but failed to save changes back to the previous provider" }, "confirm": { "deleteProvider": "Delete Provider", @@ -295,6 +296,18 @@ "selectFileFailed": "Please choose a valid SQL backup file", "configCorrupted": "SQL file may be corrupted or invalid", "backupId": "Backup ID", + "backupManager": { + "title": "Database Backups", + "description": "Automatic database snapshots for restoring to a previous state", + "empty": "No backups yet", + "restore": "Restore", + "restoring": "Restoring...", + "confirmTitle": "Confirm Restore", + "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" + }, "webdavSync": { "title": "WebDAV Cloud Sync", "description": "Sync database and skill configurations across devices via WebDAV.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7cdab09df..8a3e202d3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -174,7 +174,8 @@ "openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawDefaultModelSet": "デフォルトモデルに設定しました", "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", - "openclawNoModels": "モデルが設定されていません" + "openclawNoModels": "モデルが設定されていません", + "backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました" }, "confirm": { "deleteProvider": "プロバイダーを削除", @@ -295,6 +296,18 @@ "selectFileFailed": "有効な SQL バックアップファイルを選択してください", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "backupId": "バックアップ ID", + "backupManager": { + "title": "データベースバックアップ", + "description": "以前の状態に復元するための自動データベーススナップショット", + "empty": "バックアップはまだありません", + "restore": "復元", + "restoring": "復元中...", + "confirmTitle": "バックアップの復元を確認", + "confirmMessage": "このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。", + "restoreSuccess": "復元成功!安全バックアップが作成されました", + "restoreFailed": "復元に失敗しました", + "safetyBackupId": "安全バックアップID" + }, "webdavSync": { "title": "WebDAV クラウド同期", "description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 459ba2f2d..14a5e0632 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -174,7 +174,8 @@ "openclawModelsRegistered": "模型已注册到 /model 列表", "openclawDefaultModelSet": "已设为默认模型", "openclawDefaultModelSetFailed": "设置默认模型失败", - "openclawNoModels": "该供应商没有配置模型" + "openclawNoModels": "该供应商没有配置模型", + "backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存" }, "confirm": { "deleteProvider": "删除供应商", @@ -295,6 +296,18 @@ "selectFileFailed": "请选择有效的 SQL 备份文件", "configCorrupted": "SQL 文件可能已损坏或格式不正确", "backupId": "备份ID", + "backupManager": { + "title": "数据库备份", + "description": "自动备份的数据库快照,可用于恢复到之前的状态", + "empty": "暂无备份", + "restore": "恢复", + "restoring": "恢复中...", + "confirmTitle": "确认恢复备份", + "confirmMessage": "恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。", + "restoreSuccess": "恢复成功!安全备份已创建", + "restoreFailed": "恢复失败", + "safetyBackupId": "安全备份ID" + }, "webdavSync": { "title": "WebDAV 云同步", "description": "通过 WebDAV 在多设备间同步数据库和技能配置。", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7a0bfd1c2..bf00f1617 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,6 +1,7 @@ export type { AppId } from "./types"; export { providersApi, universalProvidersApi } from "./providers"; export { settingsApi } from "./settings"; +export { backupsApi } from "./settings"; export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; export { skillsApi } from "./skills"; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index d78ed0224..b0a51a9e5 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -17,6 +17,10 @@ export interface ProviderSwitchEvent { providerId: string; } +export interface SwitchResult { + warnings: string[]; +} + export const providersApi = { async getAll(appId: AppId): Promise> { return await invoke("get_providers", { app: appId }); @@ -46,7 +50,7 @@ export const providersApi = { return await invoke("remove_provider_from_live_config", { id, app: appId }); }, - async switch(id: string, appId: AppId): Promise { + async switch(id: string, appId: AppId): Promise { return await invoke("switch_provider", { id, app: appId }); }, diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 5649dce7a..87ab9655f 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -215,3 +215,19 @@ export interface LogConfig { enabled: boolean; level: "error" | "warn" | "info" | "debug" | "trace"; } + +export interface BackupEntry { + filename: string; + sizeBytes: number; + createdAt: string; +} + +export const backupsApi = { + async listDbBackups(): Promise { + return await invoke("list_db_backups"); + }, + + async restoreDbBackup(filename: string): Promise { + return await invoke("restore_db_backup", { filename }); + }, +}; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 2b21ab330..5aace9d1e 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; +import type { SwitchResult } from "@/lib/api/providers"; import type { Provider, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; @@ -171,7 +172,7 @@ export const useSwitchProviderMutation = (appId: AppId) => { const { t } = useTranslation(); return useMutation({ - mutationFn: async (providerId: string) => { + mutationFn: async (providerId: string): Promise => { return await providersApi.switch(providerId, appId); }, onSuccess: async () => {