From 838e40179640e71efae194459ce81e21cd05595d Mon Sep 17 00:00:00 2001 From: Sline Date: Mon, 10 Nov 2025 13:49:14 +0800 Subject: [PATCH] feat(auto-backup): implement centralized auto-backup manager and UI (#5374) * feat(auto-backup): implement centralized auto-backup manager and UI - Introduced AutoBackupManager to handle verge settings, run a background scheduler, debounce change-driven backups, and trim auto-labeled archives (keeps 20); wired into startup and config refresh hooks (src-tauri/src/module/auto_backup.rs:28-209, src-tauri/src/utils/resolve/mod.rs:64-136, src-tauri/src/feat/config.rs:102-238) - Extended verge schema and backup helpers so scheduled/change-based settings persist, create_local_backup can rename archives, and profile/global-extend mutations now trigger backups (src-tauri/src/config/verge.rs:162-536, src/types/types.d.ts:857-859, src-tauri/src/feat/backup.rs:125-189, src-tauri/src/cmd/profile.rs:66-476, src-tauri/src/cmd/save_profile.rs:21-82) - Added Auto Backup settings panel in backup dialog with dual toggles + interval selector; localized new strings across all locales (src/components/setting/mods/auto-backup-settings.tsx:1-138, src/components/setting/mods/backup-viewer.tsx:28-309, src/locales/en/settings.json:312-326 and mirrored entries) - Regenerated typed i18n resources for strong typing in React (src/types/generated/i18n-keys.ts, src/types/generated/i18n-resources.ts) * refactor(setting/backup): restructure backup dialog for consistent layout * refactor(ui): unify settings dialog style * fix(backup): only trigger auto-backup on valid saves & restore restarts app safely * fix(backup): scrub console.log leak and rewire WebDAV dialog to actually probe server * refactor: rename SubscriptionChange to ProfileChange * chore: update i18n * chore: WebDAV i18n improvements * refactor(backup): error handling * refactor(auto-backup): wrap scheduler startup with maybe_start_runner * refactor: remove the redundant throw in handleExport * feat(backup-history-viewer): improve WebDAV handling and UI fallback * feat(auto-backup): trigger backups on all profile edits & improve interval input UX * refactor: use InputAdornment * docs: Changelog.md --- Changelog.md | 2 + src-tauri/src/cmd/profile.rs | 5 + src-tauri/src/cmd/save_profile.rs | 31 +- src-tauri/src/config/verge.rs | 15 + src-tauri/src/feat/backup.rs | 14 +- src-tauri/src/feat/config.rs | 6 +- src-tauri/src/module/auto_backup.rs | 332 ++++++++++++ src-tauri/src/module/mod.rs | 1 + src-tauri/src/utils/resolve/mod.rs | 7 +- .../setting/mods/auto-backup-settings.tsx | 205 +++++++ .../setting/mods/backup-config-viewer.tsx | 8 - .../setting/mods/backup-history-viewer.tsx | 344 ++++++++++++ .../setting/mods/backup-table-viewer.tsx | 347 ------------ src/components/setting/mods/backup-viewer.tsx | 499 +++++++----------- .../setting/mods/backup-webdav-dialog.tsx | 87 +++ .../setting/mods/local-backup-actions.tsx | 80 --- src/locales/ar/settings.json | 31 +- src/locales/ar/shared.json | 5 +- src/locales/de/settings.json | 31 +- src/locales/de/shared.json | 5 +- src/locales/en/settings.json | 31 +- src/locales/en/shared.json | 5 +- src/locales/es/settings.json | 31 +- src/locales/es/shared.json | 5 +- src/locales/fa/settings.json | 31 +- src/locales/fa/shared.json | 5 +- src/locales/id/settings.json | 31 +- src/locales/id/shared.json | 5 +- src/locales/jp/settings.json | 31 +- src/locales/jp/shared.json | 5 +- src/locales/ko/settings.json | 31 +- src/locales/ko/shared.json | 5 +- src/locales/ru/settings.json | 31 +- src/locales/ru/shared.json | 5 +- src/locales/tr/settings.json | 31 +- src/locales/tr/shared.json | 5 +- src/locales/tt/settings.json | 31 +- src/locales/tt/shared.json | 5 +- src/locales/zh/settings.json | 31 +- src/locales/zh/shared.json | 5 +- src/locales/zhtw/settings.json | 31 +- src/locales/zhtw/shared.json | 5 +- src/types/generated/i18n-keys.ts | 22 + src/types/generated/i18n-resources.ts | 32 ++ src/types/types.d.ts | 3 + 45 files changed, 1714 insertions(+), 794 deletions(-) create mode 100644 src-tauri/src/module/auto_backup.rs create mode 100644 src/components/setting/mods/auto-backup-settings.tsx create mode 100644 src/components/setting/mods/backup-history-viewer.tsx delete mode 100644 src/components/setting/mods/backup-table-viewer.tsx create mode 100644 src/components/setting/mods/backup-webdav-dialog.tsx delete mode 100644 src/components/setting/mods/local-backup-actions.tsx diff --git a/Changelog.md b/Changelog.md index 6abbdba65..fa1ab4072 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ - **Mihomo(Meta) 内核升级至 v1.19.16** - 支持连接页面各个项目的排序 +- 实现可选的自动备份 @@ -17,6 +18,7 @@ - 优化后端内存和性能表现 - 防止退出时可能的禁用 TUN 失败 - i18n 支持 +- 优化备份设置布局 diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 8f2a0f207..edb94795a 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -11,6 +11,7 @@ use crate::{ }, core::{CoreManager, handle, timer::Timer, tray::Tray}, feat, logging, + module::auto_backup::{AutoBackupManager, AutoBackupTrigger}, process::AsyncHandler, ret_err, utils::{dirs, help, logging::Type}, @@ -90,6 +91,7 @@ pub async fn import_profile(url: std::string::String, option: Option) } logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); Ok(()) } @@ -122,6 +124,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResu handle::Handle::notify_profile_changed(uid.clone()); } Config::profiles().await.apply(); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); Ok(()) } Err(err) => { @@ -164,6 +167,7 @@ pub async fn delete_profile(index: String) -> CmdResult { // 发送配置变更通知 logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index); handle::Handle::notify_profile_changed(index); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); } Err(e) => { logging!(error, Type::Cmd, "{}", e); @@ -460,6 +464,7 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { }); } + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); Ok(()) } diff --git a/src-tauri/src/cmd/save_profile.rs b/src-tauri/src/cmd/save_profile.rs index 21f2c4040..ed271504b 100644 --- a/src-tauri/src/cmd/save_profile.rs +++ b/src-tauri/src/cmd/save_profile.rs @@ -4,6 +4,7 @@ use crate::{ config::{Config, PrfItem}, core::{CoreManager, handle, validate::CoreConfigValidator}, logging, + module::auto_backup::{AutoBackupManager, AutoBackupTrigger}, utils::{dirs, logging::Type}, }; use smartstring::alias::String; @@ -17,6 +18,12 @@ pub async fn save_profile_file(index: String, file_data: Option) -> CmdR None => return Ok(()), }; + let backup_trigger = match index.as_str() { + "Merge" => Some(AutoBackupTrigger::GlobalMerge), + "Script" => Some(AutoBackupTrigger::GlobalScript), + _ => Some(AutoBackupTrigger::ProfileChange), + }; + // 在异步操作前获取必要元数据并释放锁 let (rel_path, is_merge_file) = { let profiles = Config::profiles().await; @@ -51,11 +58,17 @@ pub async fn save_profile_file(index: String, file_data: Option) -> CmdR is_merge_file ); - if is_merge_file { - return handle_merge_file(&file_path_str, &file_path, &original_content).await; + let changes_applied = if is_merge_file { + handle_merge_file(&file_path_str, &file_path, &original_content).await? + } else { + handle_full_validation(&file_path_str, &file_path, &original_content).await? + }; + + if changes_applied && let Some(trigger) = backup_trigger { + AutoBackupManager::trigger_backup(trigger); } - handle_full_validation(&file_path_str, &file_path, &original_content).await + Ok(()) } async fn restore_original( @@ -76,7 +89,7 @@ async fn handle_merge_file( file_path_str: &str, file_path: &std::path::Path, original_content: &str, -) -> CmdResult { +) -> CmdResult { logging!( info, Type::Config, @@ -96,7 +109,7 @@ async fn handle_merge_file( } else { handle::Handle::refresh_clash(); } - Ok(()) + Ok(true) } Ok((false, error_msg)) => { logging!( @@ -108,7 +121,7 @@ async fn handle_merge_file( restore_original(file_path, original_content).await?; let result = (false, error_msg.clone()); crate::cmd::validate::handle_yaml_validation_notice(&result, "合并配置文件"); - Ok(()) + Ok(false) } Err(e) => { logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e); @@ -122,11 +135,11 @@ async fn handle_full_validation( file_path_str: &str, file_path: &std::path::Path, original_content: &str, -) -> CmdResult { +) -> CmdResult { match CoreConfigValidator::validate_config_file(file_path_str, None).await { Ok((true, _)) => { logging!(info, Type::Config, "[cmd配置save] 验证成功"); - Ok(()) + Ok(true) } Ok((false, error_msg)) => { logging!(warn, Type::Config, "[cmd配置save] 验证失败: {}", error_msg); @@ -160,7 +173,7 @@ async fn handle_full_validation( handle::Handle::notice_message("config_validate::error", error_msg.to_owned()); } - Ok(()) + Ok(false) } Err(e) => { logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e); diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 3a19e451e..9a25cf34c 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -158,6 +158,15 @@ pub struct IVerge { /// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 pub auto_log_clean: Option, + /// Enable scheduled automatic backups + pub enable_auto_backup_schedule: Option, + + /// Automatic backup interval in hours + pub auto_backup_interval_hours: Option, + + /// Create backups automatically when critical configs change + pub auto_backup_on_change: Option, + /// verge 的各种 port 用于覆盖 clash 的各种 port #[cfg(not(target_os = "windows"))] pub verge_redir_port: Option, @@ -422,6 +431,9 @@ impl IVerge { auto_check_update: Some(true), enable_builtin_enhanced: Some(true), auto_log_clean: Some(2), // 1: 1天, 2: 7天, 3: 30天, 4: 90天 + enable_auto_backup_schedule: Some(false), + auto_backup_interval_hours: Some(24), + auto_backup_on_change: Some(true), webdav_url: None, webdav_username: None, webdav_password: None, @@ -517,6 +529,9 @@ impl IVerge { patch!(proxy_layout_column); patch!(test_list); patch!(auto_log_clean); + patch!(enable_auto_backup_schedule); + patch!(auto_backup_interval_hours); + patch!(auto_backup_on_change); patch!(webdav_url); patch!(webdav_username); diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index d55b1eaf1..055b1d680 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -123,6 +123,15 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> { /// Create a backup and save to local storage pub async fn create_local_backup() -> Result<()> { + create_local_backup_with_namer(|name| name.to_string().into()) + .await + .map(|_| ()) +} + +pub async fn create_local_backup_with_namer(namer: F) -> Result +where + F: FnOnce(&str) -> String, +{ let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| { logging!( error, @@ -133,7 +142,8 @@ pub async fn create_local_backup() -> Result<()> { })?; let backup_dir = local_backup_dir()?; - let target_path = backup_dir.join(file_name.as_str()); + let final_name = namer(file_name.as_str()); + let target_path = backup_dir.join(final_name.as_str()); if let Err(err) = move_file(temp_file_path.clone(), target_path.clone()).await { logging!( @@ -152,7 +162,7 @@ pub async fn create_local_backup() -> Result<()> { return Err(err); } - Ok(()) + Ok(final_name) } async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> { diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 2bd2cfa87..e7aec2a1f 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -2,7 +2,7 @@ use crate::{ config::{Config, IVerge}, core::{CoreManager, handle, hotkey, sysopt, tray}, logging_error, - module::lightweight, + module::{auto_backup::AutoBackupManager, lightweight}, utils::{draft::SharedBox, logging::Type}, }; use anyhow::Result; @@ -243,6 +243,10 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> { return Err(err); } Config::verge().await.apply(); + logging_error!( + Type::Backup, + AutoBackupManager::global().refresh_settings().await + ); if !not_save_file { // 分离数据获取和异步调用 let verge_data = Config::verge().await.data_arc(); diff --git a/src-tauri/src/module/auto_backup.rs b/src-tauri/src/module/auto_backup.rs new file mode 100644 index 000000000..2cb9c89bb --- /dev/null +++ b/src-tauri/src/module/auto_backup.rs @@ -0,0 +1,332 @@ +use crate::{ + config::{Config, IVerge}, + feat::create_local_backup_with_namer, + logging, + process::AsyncHandler, + utils::{dirs::local_backup_dir, logging::Type}, +}; +use anyhow::Result; +use chrono::Local; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, AtomicI64, Ordering}, + }, + time::{Duration, UNIX_EPOCH}, +}; +use tokio::{ + fs, + sync::{Mutex, watch}, +}; + +const DEFAULT_INTERVAL_HOURS: u64 = 24; +const MIN_INTERVAL_HOURS: u64 = 1; +const MAX_INTERVAL_HOURS: u64 = 168; +const MIN_BACKUP_INTERVAL_SECS: i64 = 60; +const AUTO_BACKUP_KEEP: usize = 20; +const AUTO_MARKER: &str = "-auto-"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AutoBackupTrigger { + Scheduled, + GlobalMerge, + GlobalScript, + ProfileChange, +} + +impl AutoBackupTrigger { + const fn slug(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::GlobalMerge => "merge", + Self::GlobalScript => "script", + Self::ProfileChange => "profile", + } + } + + const fn is_schedule(self) -> bool { + matches!(self, Self::Scheduled) + } +} + +#[derive(Clone, Copy, Debug)] +struct AutoBackupSettings { + schedule_enabled: bool, + interval_hours: u64, + change_enabled: bool, +} + +impl AutoBackupSettings { + fn from_verge(verge: &IVerge) -> Self { + let interval = verge + .auto_backup_interval_hours + .unwrap_or(DEFAULT_INTERVAL_HOURS) + .clamp(MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS); + + Self { + schedule_enabled: verge.enable_auto_backup_schedule.unwrap_or(false), + interval_hours: interval, + change_enabled: verge.auto_backup_on_change.unwrap_or(true), + } + } +} + +impl Default for AutoBackupSettings { + fn default() -> Self { + Self { + schedule_enabled: false, + interval_hours: DEFAULT_INTERVAL_HOURS, + change_enabled: true, + } + } +} + +pub struct AutoBackupManager { + settings: Arc>, + settings_tx: watch::Sender, + runner_started: AtomicBool, + exec_lock: Mutex<()>, + last_backup: AtomicI64, +} + +impl AutoBackupManager { + pub fn global() -> &'static Self { + static INSTANCE: OnceCell = OnceCell::new(); + INSTANCE.get_or_init(|| { + let (tx, _rx) = watch::channel(AutoBackupSettings::default()); + Self { + settings: Arc::new(RwLock::new(AutoBackupSettings::default())), + settings_tx: tx, + runner_started: AtomicBool::new(false), + exec_lock: Mutex::new(()), + last_backup: AtomicI64::new(0), + } + }) + } + + pub async fn init(&self) -> Result<()> { + let settings = Self::load_settings().await; + { + *self.settings.write() = settings; + } + let _ = self.settings_tx.send(settings); + self.maybe_start_runner(settings); + Ok(()) + } + + pub async fn refresh_settings(&self) -> Result<()> { + let settings = Self::load_settings().await; + { + *self.settings.write() = settings; + } + let _ = self.settings_tx.send(settings); + self.maybe_start_runner(settings); + Ok(()) + } + + pub fn trigger_backup(trigger: AutoBackupTrigger) { + AsyncHandler::spawn(move || async move { + if let Err(err) = Self::global().execute_trigger(trigger).await { + logging!( + warn, + Type::Backup, + "Auto backup execution failed ({:?}): {err:#?}", + trigger + ); + } + }); + } + + fn maybe_start_runner(&self, settings: AutoBackupSettings) { + if settings.schedule_enabled { + self.ensure_runner(); + } + } + + fn ensure_runner(&self) { + if self.runner_started.swap(true, Ordering::SeqCst) { + return; + } + + let mut rx = self.settings_tx.subscribe(); + AsyncHandler::spawn(move || async move { + Self::run_scheduler(&mut rx).await; + }); + } + + async fn run_scheduler(rx: &mut watch::Receiver) { + let mut current = *rx.borrow(); + loop { + if !current.schedule_enabled { + if rx.changed().await.is_err() { + break; + } + current = *rx.borrow(); + continue; + } + + let duration = Duration::from_secs(current.interval_hours.saturating_mul(3600)); + let sleeper = tokio::time::sleep(duration); + tokio::pin!(sleeper); + + tokio::select! { + _ = &mut sleeper => { + if let Err(err) = Self::global() + .execute_trigger(AutoBackupTrigger::Scheduled) + .await + { + logging!( + warn, + Type::Backup, + "Scheduled auto backup failed: {err:#?}" + ); + } + } + changed = rx.changed() => { + if changed.is_err() { + break; + } + current = *rx.borrow(); + } + } + } + } + + async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> { + let snapshot = *self.settings.read(); + + if trigger.is_schedule() && !snapshot.schedule_enabled { + return Ok(()); + } + if !trigger.is_schedule() && !snapshot.change_enabled { + return Ok(()); + } + + if !self.should_run_now() { + return Ok(()); + } + + let _guard = self.exec_lock.lock().await; + if !self.should_run_now() { + return Ok(()); + } + + let file_name = + create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into()) + .await?; + self.last_backup + .store(Local::now().timestamp(), Ordering::Release); + + if let Err(err) = cleanup_auto_backups().await { + logging!( + warn, + Type::Backup, + "Failed to cleanup old auto backups: {err:#?}" + ); + } + + logging!( + info, + Type::Backup, + "Auto backup created ({:?}): {}", + trigger, + file_name + ); + Ok(()) + } + + fn should_run_now(&self) -> bool { + let last = self.last_backup.load(Ordering::Acquire); + if last == 0 { + return true; + } + let now = Local::now().timestamp(); + now.saturating_sub(last) >= MIN_BACKUP_INTERVAL_SECS + } + + async fn load_settings() -> AutoBackupSettings { + let verge = Config::verge().await; + AutoBackupSettings::from_verge(&verge.latest_arc()) + } +} + +fn append_auto_suffix(file_name: &str, slug: &str) -> String { + match file_name.rsplit_once('.') { + Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"), + None => format!("{file_name}{AUTO_MARKER}{slug}"), + } +} + +async fn cleanup_auto_backups() -> Result<()> { + if AUTO_BACKUP_KEEP == 0 { + return Ok(()); + } + + let backup_dir = local_backup_dir()?; + if !backup_dir.exists() { + return Ok(()); + } + + let mut entries = match fs::read_dir(&backup_dir).await { + Ok(dir) => dir, + Err(err) => { + logging!( + warn, + Type::Backup, + "Failed to read backup directory: {err:#?}" + ); + return Ok(()); + } + }; + + let mut files: Vec<(PathBuf, u64)> = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let file_name = match entry.file_name().into_string() { + Ok(name) => name, + Err(_) => continue, + }; + + if !file_name.contains(AUTO_MARKER) { + continue; + } + + let modified = entry + .metadata() + .await + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|dur| dur.as_secs()) + .unwrap_or(0); + + files.push((path, modified)); + } + + if files.len() <= AUTO_BACKUP_KEEP { + return Ok(()); + } + + files.sort_by_key(|(_, ts)| *ts); + let remove_count = files.len() - AUTO_BACKUP_KEEP; + for (path, _) in files.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(&path).await { + logging!( + warn, + Type::Backup, + "Failed to remove auto backup {}: {err:#?}", + path.display() + ); + } + } + + Ok(()) +} diff --git a/src-tauri/src/module/mod.rs b/src-tauri/src/module/mod.rs index 372ad5ba0..87055c6ca 100644 --- a/src-tauri/src/module/mod.rs +++ b/src-tauri/src/module/mod.rs @@ -1,3 +1,4 @@ +pub mod auto_backup; pub mod lightweight; pub mod signal; pub mod sysinfo; diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 542cac466..b855b35c4 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -10,7 +10,7 @@ use crate::{ tray::Tray, }, logging, logging_error, - module::{lightweight::auto_lightweight_boot, signal}, + module::{auto_backup::AutoBackupManager, lightweight::auto_lightweight_boot, signal}, process::AsyncHandler, utils::{init, logging::Type, server, window_manager::WindowManager}, }; @@ -68,6 +68,7 @@ pub fn resolve_setup_async() { init_timer(), init_hotkey(), init_auto_lightweight_boot(), + init_auto_backup(), ); }); } @@ -127,6 +128,10 @@ pub(super) async fn init_auto_lightweight_boot() { logging_error!(Type::Setup, auto_lightweight_boot().await); } +pub(super) async fn init_auto_backup() { + logging_error!(Type::Setup, AutoBackupManager::global().init().await); +} + pub(super) fn init_signal() { logging!(info, Type::Setup, "Initializing signal handlers..."); signal::register(); diff --git a/src/components/setting/mods/auto-backup-settings.tsx b/src/components/setting/mods/auto-backup-settings.tsx new file mode 100644 index 000000000..c9736ec7a --- /dev/null +++ b/src/components/setting/mods/auto-backup-settings.tsx @@ -0,0 +1,205 @@ +import { + InputAdornment, + ListItem, + ListItemText, + Stack, + TextField, +} from "@mui/material"; +import { useLockFn } from "ahooks"; +import { Fragment, useMemo, useState, type ChangeEvent } from "react"; +import { useTranslation } from "react-i18next"; + +import { Switch } from "@/components/base"; +import { useVerge } from "@/hooks/use-verge"; +import { showNotice } from "@/services/noticeService"; + +const MIN_INTERVAL_HOURS = 1; +const MAX_INTERVAL_HOURS = 168; + +interface AutoBackupState { + scheduleEnabled: boolean; + intervalHours: number; + changeEnabled: boolean; +} + +export function AutoBackupSettings() { + const { t } = useTranslation(); + const { verge, patchVerge } = useVerge(); + const derivedValues = useMemo(() => { + return { + scheduleEnabled: verge?.enable_auto_backup_schedule ?? false, + intervalHours: verge?.auto_backup_interval_hours ?? 24, + changeEnabled: verge?.auto_backup_on_change ?? true, + }; + }, [ + verge?.enable_auto_backup_schedule, + verge?.auto_backup_interval_hours, + verge?.auto_backup_on_change, + ]); + const [pendingValues, setPendingValues] = useState( + null, + ); + const values = useMemo(() => { + if (!pendingValues) { + return derivedValues; + } + if ( + pendingValues.scheduleEnabled === derivedValues.scheduleEnabled && + pendingValues.intervalHours === derivedValues.intervalHours && + pendingValues.changeEnabled === derivedValues.changeEnabled + ) { + return derivedValues; + } + return pendingValues; + }, [pendingValues, derivedValues]); + const [intervalInputDraft, setIntervalInputDraft] = useState( + null, + ); + + const applyPatch = useLockFn( + async ( + partial: Partial, + payload: Partial, + ) => { + const nextValues = { ...values, ...partial }; + setPendingValues(nextValues); + try { + await patchVerge(payload); + } catch (error) { + showNotice.error(error); + setPendingValues(null); + } + }, + ); + + const disabled = !verge; + + const handleScheduleToggle = ( + _: ChangeEvent, + checked: boolean, + ) => { + applyPatch( + { scheduleEnabled: checked }, + { + enable_auto_backup_schedule: checked, + auto_backup_interval_hours: values.intervalHours, + }, + ); + }; + + const handleChangeToggle = ( + _: ChangeEvent, + checked: boolean, + ) => { + applyPatch({ changeEnabled: checked }, { auto_backup_on_change: checked }); + }; + + const handleIntervalInputChange = (event: ChangeEvent) => { + setIntervalInputDraft(event.target.value); + }; + + const commitIntervalInput = () => { + const rawValue = intervalInputDraft ?? values.intervalHours.toString(); + const trimmed = rawValue.trim(); + if (trimmed === "") { + setIntervalInputDraft(null); + return; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setIntervalInputDraft(null); + return; + } + + const clamped = Math.min( + MAX_INTERVAL_HOURS, + Math.max(MIN_INTERVAL_HOURS, Math.round(parsed)), + ); + + if (clamped === values.intervalHours) { + setIntervalInputDraft(null); + return; + } + + applyPatch( + { intervalHours: clamped }, + { auto_backup_interval_hours: clamped }, + ); + setIntervalInputDraft(null); + }; + + const scheduleDisabled = disabled || !values.scheduleEnabled; + + return ( + + + + + + + + + + + + { + if (event.key === "Enter") { + event.preventDefault(); + commitIntervalInput(); + } + }} + sx={{ minWidth: 160 }} + slotProps={{ + input: { + endAdornment: ( + + {t("shared.units.hours")} + + ), + }, + htmlInput: { + min: MIN_INTERVAL_HOURS, + max: MAX_INTERVAL_HOURS, + inputMode: "numeric", + }, + }} + /> + + + + + + + + + + + ); +} diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index b1323db6f..21ce89c14 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -58,14 +58,6 @@ export const BackupConfigViewer = memo( webdav_username !== username || webdav_password !== password; - console.log( - "webdavChanged", - webdavChanged, - webdav_url, - webdav_username, - webdav_password, - ); - const handleClickShowPassword = () => { setShowPassword((prev) => !prev); }; diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx new file mode 100644 index 000000000..4a63fc743 --- /dev/null +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -0,0 +1,344 @@ +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import DownloadRounded from "@mui/icons-material/DownloadRounded"; +import RefreshRounded from "@mui/icons-material/RefreshRounded"; +import RestoreRounded from "@mui/icons-material/RestoreRounded"; +import { + Box, + Button, + IconButton, + List, + ListItem, + ListItemText, + ListSubheader, + Stack, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { save } from "@tauri-apps/plugin-dialog"; +import { useLockFn } from "ahooks"; +import dayjs from "dayjs"; +import customParseFormat from "dayjs/plugin/customParseFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { BaseDialog, BaseLoadingOverlay } from "@/components/base"; +import { useVerge } from "@/hooks/use-verge"; +import { + deleteLocalBackup, + deleteWebdavBackup, + exportLocalBackup, + listLocalBackup, + listWebDavBackup, + restartApp, + restoreLocalBackup, + restoreWebDavBackup, +} from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; + +dayjs.extend(customParseFormat); +dayjs.extend(relativeTime); + +const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; +const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; + +type BackupSource = "local" | "webdav"; + +interface BackupHistoryViewerProps { + open: boolean; + source: BackupSource; + page: number; + onSourceChange: (source: BackupSource) => void; + onPageChange: (page: number) => void; + onClose: () => void; +} + +interface BackupRow { + filename: string; + platform: string; + backup_time: dayjs.Dayjs; +} + +const confirmAsync = async (message: string) => { + const fn = window.confirm as (msg?: string) => boolean; + return fn(message); +}; + +export const BackupHistoryViewer = ({ + open, + source, + page, + onSourceChange, + onPageChange, + onClose, +}: BackupHistoryViewerProps) => { + const { t } = useTranslation(); + const { verge } = useVerge(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [isRestarting, setIsRestarting] = useState(false); + const isLocal = source === "local"; + const isWebDavConfigured = Boolean( + verge?.webdav_url && verge?.webdav_username && verge?.webdav_password, + ); + const shouldSkipWebDav = !isLocal && !isWebDavConfigured; + const pageSize = 8; + const isBusy = loading || isRestarting; + + const buildRow = useCallback((filename: string): BackupRow | null => { + const platform = filename.split("-")[0]; + const match = filename.match(FILENAME_PATTERN); + if (!match) return null; + return { + filename, + platform, + backup_time: dayjs(match[0], DATE_FORMAT), + }; + }, []); + + const fetchRows = useCallback(async () => { + if (!open) return; + if (shouldSkipWebDav) { + setRows([]); + return; + } + setLoading(true); + try { + const list = isLocal ? await listLocalBackup() : await listWebDavBackup(); + setRows( + list + .map((item) => buildRow(item.filename)) + .filter((item): item is BackupRow => item !== null) + .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)), + ); + } catch (error) { + console.error(error); + setRows([]); + showNotice.error(error); + } finally { + setLoading(false); + } + }, [buildRow, isLocal, open, shouldSkipWebDav]); + + useEffect(() => { + void fetchRows(); + }, [fetchRows]); + + const total = rows.length; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + const pagedRows = rows.slice( + currentPage * pageSize, + currentPage * pageSize + pageSize, + ); + + const summary = useMemo(() => { + if (shouldSkipWebDav) { + return t("settings.modals.backup.manual.webdav"); + } + if (!total) return t("settings.modals.backup.history.empty"); + const recent = rows[0]?.backup_time.fromNow(); + return t("settings.modals.backup.history.summary", { + count: total, + recent, + }); + }, [rows, shouldSkipWebDav, t, total]); + + const handleDelete = useLockFn(async (filename: string) => { + if (isRestarting) return; + if ( + !(await confirmAsync(t("settings.modals.backup.messages.confirmDelete"))) + ) + return; + if (isLocal) { + await deleteLocalBackup(filename); + } else { + await deleteWebdavBackup(filename); + } + await fetchRows(); + }); + + const handleRestore = useLockFn(async (filename: string) => { + if (isRestarting) return; + if ( + !(await confirmAsync(t("settings.modals.backup.messages.confirmRestore"))) + ) + return; + if (isLocal) { + await restoreLocalBackup(filename); + } else { + await restoreWebDavBackup(filename); + } + showNotice.success("settings.modals.backup.messages.restoreSuccess"); + setIsRestarting(true); + window.setTimeout(() => { + void restartApp().catch((err: unknown) => { + setIsRestarting(false); + showNotice.error(err); + }); + }, 1000); + }); + + const handleExport = useLockFn(async (filename: string) => { + if (isRestarting) return; + if (!isLocal) return; + const savePath = await save({ defaultPath: filename }); + if (!savePath || Array.isArray(savePath)) return; + try { + await exportLocalBackup(filename, savePath); + showNotice.success("settings.modals.backup.messages.localBackupExported"); + } catch (ignoreError: unknown) { + showNotice.error( + "settings.modals.backup.messages.localBackupExportFailed", + ); + } + }); + + const handleRefresh = () => { + if (isRestarting) return; + void fetchRows(); + }; + + return ( + + + + + + { + if (isBusy) return; + onSourceChange(val as BackupSource); + onPageChange(0); + }} + textColor="primary" + indicatorColor="primary" + > + + + + + + + + + {summary} + + + + {t("settings.modals.backup.history.title")} + + } + > + {pagedRows.length === 0 ? ( + + + + ) : ( + pagedRows.map((row) => ( + + {isLocal && ( + handleExport(row.filename)} + > + + + )} + handleDelete(row.filename)} + > + + + handleRestore(row.filename)} + > + + + + } + > + + + )) + )} + + + {pageCount > 1 && ( + + + {currentPage + 1} / {pageCount} + + + + + + + )} + + + + ); +}; diff --git a/src/components/setting/mods/backup-table-viewer.tsx b/src/components/setting/mods/backup-table-viewer.tsx deleted file mode 100644 index 13c9f9824..000000000 --- a/src/components/setting/mods/backup-table-viewer.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import DeleteIcon from "@mui/icons-material/Delete"; -import DownloadIcon from "@mui/icons-material/Download"; -import RestoreIcon from "@mui/icons-material/Restore"; -import { - Box, - Paper, - IconButton, - Divider, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TablePagination, -} from "@mui/material"; -import { Typography } from "@mui/material"; -import { save } from "@tauri-apps/plugin-dialog"; -import { useLockFn } from "ahooks"; -import { Dayjs } from "dayjs"; -import { SVGProps, memo } from "react"; -import { useTranslation } from "react-i18next"; - -import { restartApp } from "@/services/cmds"; -import { showNotice } from "@/services/noticeService"; - -export type BackupFile = { - platform: string; - backup_time: Dayjs; - allow_apply: boolean; - filename: string; -}; - -export const DEFAULT_ROWS_PER_PAGE = 5; - -type ConfirmFn = (message?: string) => boolean | Promise; - -// Normalizes synchronous and async confirm implementations. -const confirmAsync = async (message: string): Promise => { - const confirmFn = window.confirm as unknown as ConfirmFn; - return await confirmFn.call(window, message); -}; - -interface BackupTableViewerProps { - datasource: BackupFile[]; - page: number; - onPageChange: ( - event: React.MouseEvent | null, - page: number, - ) => void; - total: number; - onRefresh: () => Promise; - onDelete: (filename: string) => Promise; - onRestore: (filename: string) => Promise; - onExport?: (filename: string, destination: string) => Promise; -} - -export const BackupTableViewer = memo( - ({ - datasource, - page, - onPageChange, - total, - onRefresh, - onDelete, - onRestore, - onExport, - }: BackupTableViewerProps) => { - const { t } = useTranslation(); - - const handleDelete = useLockFn(async (filename: string) => { - await onDelete(filename); - await onRefresh(); - }); - - const handleRestore = useLockFn(async (filename: string) => { - await onRestore(filename).then(() => { - showNotice.success("settings.modals.backup.messages.restoreSuccess"); - }); - await restartApp(); - }); - - const handleExport = useLockFn(async (filename: string) => { - if (!onExport) { - return; - } - try { - const savePath = await save({ - defaultPath: filename, - }); - if (!savePath || Array.isArray(savePath)) { - return; - } - await onExport(filename, savePath); - showNotice.success( - "settings.modals.backup.messages.localBackupExported", - ); - } catch (error) { - console.error(error); - showNotice.error( - "settings.modals.backup.messages.localBackupExportFailed", - ); - } - }); - - return ( - - - - - - {t("settings.modals.backup.table.filename")} - - - {t("settings.modals.backup.table.backupTime")} - - - {t("settings.modals.backup.table.actions")} - - - - - {datasource.length > 0 ? ( - datasource.map((file) => { - const rowKey = `${file.platform}-${file.filename}-${file.backup_time.valueOf()}`; - return ( - - - {file.platform === "windows" ? ( - - ) : file.platform === "linux" ? ( - - ) : ( - - )} - {file.filename} - - - {file.backup_time.fromNow()} - - - - {onExport && ( - <> - { - e.preventDefault(); - await handleExport(file.filename); - }} - > - - - - - )} - { - e.preventDefault(); - const confirmed = await confirmAsync( - t( - "settings.modals.backup.messages.confirmDelete", - ), - ); - if (confirmed) { - await handleDelete(file.filename); - } - }} - > - - - - { - e.preventDefault(); - const confirmed = await confirmAsync( - t( - "settings.modals.backup.messages.confirmRestore", - ), - ); - if (confirmed) { - await handleRestore(file.filename); - } - }} - > - - - - - - ); - }) - ) : ( - - - - - {t("settings.modals.backup.table.noBackups")} - - - - - )} - -
- -
- ); - }, -); - -function LinuxIcon(props: SVGProps) { - return ( - - - - - - - - - - - - - - - - ); -} - -function WindowsIcon(props: SVGProps) { - return ( - - - - ); -} - -function MacIcon(props: SVGProps) { - return ( - - - - ); -} diff --git a/src/components/setting/mods/backup-viewer.tsx b/src/components/setting/mods/backup-viewer.tsx index 3a1c1ecca..b9b62b76c 100644 --- a/src/components/setting/mods/backup-viewer.tsx +++ b/src/components/setting/mods/backup-viewer.tsx @@ -1,354 +1,213 @@ -import { Box, Button, Divider, Paper, Tab, Tabs } from "@mui/material"; -import dayjs from "dayjs"; -import customParseFormat from "dayjs/plugin/customParseFormat"; -import type { Ref } from "react"; +import { LoadingButton } from "@mui/lab"; import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useReducer, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; + Button, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from "@mui/material"; +import { useLockFn } from "ahooks"; +import type { ReactNode, Ref } from "react"; +import { useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base"; -import { - deleteLocalBackup, - deleteWebdavBackup, - listLocalBackup, - listWebDavBackup, - exportLocalBackup, - restoreLocalBackup, - restoreWebDavBackup, -} from "@/services/cmds"; +import { BaseDialog, DialogRef } from "@/components/base"; +import { createLocalBackup, createWebdavBackup } from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; -import { BackupConfigViewer } from "./backup-config-viewer"; -import { - BackupFile, - BackupTableViewer, - DEFAULT_ROWS_PER_PAGE, -} from "./backup-table-viewer"; -import { LocalBackupActions } from "./local-backup-actions"; -dayjs.extend(customParseFormat); +import { AutoBackupSettings } from "./auto-backup-settings"; +import { BackupHistoryViewer } from "./backup-history-viewer"; +import { BackupWebdavDialog } from "./backup-webdav-dialog"; -const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; -const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; type BackupSource = "local" | "webdav"; -type CloseButtonPosition = { top: number; left: number } | null; export function BackupViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const contentRef = useRef(null); - const [dialogPaper, setDialogPaper] = useReducer( - (_: HTMLElement | null, next: HTMLElement | null) => next, - null as HTMLElement | null, - ); - const [closeButtonPosition, setCloseButtonPosition] = useReducer( - (_: CloseButtonPosition, next: CloseButtonPosition) => next, - null as CloseButtonPosition, - ); - - const [isLoading, setIsLoading] = useState(false); - const [backupFiles, setBackupFiles] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); - const [source, setSource] = useState("local"); + const [busyAction, setBusyAction] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historySource, setHistorySource] = useState("local"); + const [historyPage, setHistoryPage] = useState(0); + const [webdavDialogOpen, setWebdavDialogOpen] = useState(false); useImperativeHandle(ref, () => ({ - open: () => { - setOpen(true); - }, + open: () => setOpen(true), close: () => setOpen(false), })); - // Handle page change - const handleChangePage = useCallback( - (_: React.MouseEvent | null, page: number) => { - setPage(page); - }, - [], - ); + const openHistory = (target: BackupSource) => { + setHistorySource(target); + setHistoryPage(0); + setHistoryOpen(true); + }; - const handleChangeSource = useCallback( - (_event: React.SyntheticEvent, newSource: string) => { - setSource(newSource as BackupSource); - setPage(0); - }, - [], - ); - - const buildBackupFile = useCallback((filename: string) => { - const platform = filename.split("-")[0]; - const fileBackupTimeStr = filename.match(FILENAME_PATTERN); - - if (fileBackupTimeStr === null) { - return null; - } - - const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT); - const allowApply = true; - return { - filename, - platform, - backup_time: backupTime, - allow_apply: allowApply, - } as BackupFile; - }, []); - - const getAllBackupFiles = useCallback(async (): Promise => { - if (source === "local") { - const files = await listLocalBackup(); - return files - .map((file) => buildBackupFile(file.filename)) - .filter((item): item is BackupFile => item !== null) - .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); - } - - const files = await listWebDavBackup(); - return files - .map((file) => { - return buildBackupFile(file.filename); - }) - .filter((item): item is BackupFile => item !== null) - .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); - }, [buildBackupFile, source]); - - const fetchAndSetBackupFiles = useCallback(async () => { + const handleBackup = useLockFn(async (target: BackupSource) => { try { - setIsLoading(true); - const files = await getAllBackupFiles(); - setBackupFiles(files); - setTotal(files.length); + setBusyAction(target); + if (target === "local") { + await createLocalBackup(); + showNotice.success( + "settings.modals.backup.messages.localBackupCreated", + ); + } else { + await createWebdavBackup(); + showNotice.success("settings.modals.backup.messages.backupCreated"); + } } catch (error) { - setBackupFiles([]); - setTotal(0); console.error(error); + showNotice.error( + target === "local" + ? "settings.modals.backup.messages.localBackupFailed" + : "settings.modals.backup.messages.backupFailed", + target === "local" ? undefined : { error }, + ); } finally { - setIsLoading(false); + setBusyAction(null); } - }, [getAllBackupFiles]); - - useEffect(() => { - if (open) { - fetchAndSetBackupFiles(); - const paper = contentRef.current?.closest(".MuiPaper-root"); - setDialogPaper((paper as HTMLElement) ?? null); - } else { - setDialogPaper(null); - } - }, [open, fetchAndSetBackupFiles]); - - useEffect(() => { - if (!open || dialogPaper) { - return; - } - const frame = requestAnimationFrame(() => { - const paper = contentRef.current?.closest(".MuiPaper-root"); - setDialogPaper((paper as HTMLElement) ?? null); - }); - return () => cancelAnimationFrame(frame); - }, [open, dialogPaper]); - - useEffect(() => { - if (!dialogPaper) { - setCloseButtonPosition(null); - return; - } - if (typeof window === "undefined") { - return; - } - - const updatePosition = () => { - const rect = dialogPaper.getBoundingClientRect(); - setCloseButtonPosition({ - top: rect.bottom - 16, - left: rect.right - 24, - }); - }; - - updatePosition(); - - let resizeObserver: ResizeObserver | null = null; - if (typeof ResizeObserver !== "undefined") { - resizeObserver = new ResizeObserver(() => { - updatePosition(); - }); - resizeObserver.observe(dialogPaper); - } - - const scrollTargets: EventTarget[] = []; - const addScrollListener = (target: EventTarget | null) => { - if (!target) { - return; - } - target.addEventListener("scroll", updatePosition, true); - scrollTargets.push(target); - }; - - addScrollListener(window); - addScrollListener(dialogPaper); - const dialogContent = dialogPaper.querySelector(".MuiDialogContent-root"); - addScrollListener(dialogContent); - - window.addEventListener("resize", updatePosition); - - return () => { - resizeObserver?.disconnect(); - scrollTargets.forEach((target) => { - target.removeEventListener("scroll", updatePosition, true); - }); - window.removeEventListener("resize", updatePosition); - }; - }, [dialogPaper]); - - const handleDelete = useCallback( - async (filename: string) => { - if (source === "local") { - await deleteLocalBackup(filename); - } else { - await deleteWebdavBackup(filename); - } - }, - [source], - ); - - const handleRestore = useCallback( - async (filename: string) => { - if (source === "local") { - await restoreLocalBackup(filename); - } else { - await restoreWebDavBackup(filename); - } - }, - [source], - ); - - const handleExport = useCallback( - async (filename: string, destination: string) => { - await exportLocalBackup(filename, destination); - }, - [], - ); - - const dataSource = useMemo( - () => - backupFiles.slice( - page * DEFAULT_ROWS_PER_PAGE, - page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE, - ), - [backupFiles, page], - ); + }); return ( setOpen(false)} onClose={() => setOpen(false)} - disableFooter > - - - + `1px solid ${theme.palette.divider}`, + borderRadius: 2, + p: 2, }} > - - - - - {source === "local" ? ( - - ) : ( - - )} - - - - - - - {dialogPaper && - closeButtonPosition && - createPortal( - theme.zIndex.modal + 1, - }} - > - - , - dialogPaper, - )} + + {t("settings.modals.backup.auto.title")} + + + + + + + `1px solid ${theme.palette.divider}`, + borderRadius: 2, + p: 2, + }} + > + + {t("settings.modals.backup.manual.title")} + + + {( + [ + { + key: "local" as BackupSource, + title: t("settings.modals.backup.tabs.local"), + description: t("settings.modals.backup.manual.local"), + actions: [ + handleBackup("local")} + > + {t("settings.modals.backup.actions.backup")} + , + , + ], + }, + { + key: "webdav" as BackupSource, + title: t("settings.modals.backup.tabs.webdav"), + description: t("settings.modals.backup.manual.webdav"), + actions: [ + handleBackup("webdav")} + > + {t("settings.modals.backup.actions.backup")} + , + , + , + ], + }, + ] satisfies Array<{ + key: BackupSource; + title: string; + description: string; + actions: ReactNode[]; + }> + ).map((item, idx) => ( + + + + + {item.actions} + + + + ))} + + + + + setHistoryOpen(false)} + /> + setWebdavDialogOpen(false)} + onBackupSuccess={() => openHistory("webdav")} + setBusy={(loading) => setBusyAction(loading ? "webdav" : null)} + /> ); } diff --git a/src/components/setting/mods/backup-webdav-dialog.tsx b/src/components/setting/mods/backup-webdav-dialog.tsx new file mode 100644 index 000000000..8ad0e17a0 --- /dev/null +++ b/src/components/setting/mods/backup-webdav-dialog.tsx @@ -0,0 +1,87 @@ +import { Box } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { BaseDialog, BaseLoadingOverlay } from "@/components/base"; +import { listWebDavBackup } from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; + +import { BackupConfigViewer } from "./backup-config-viewer"; + +interface BackupWebdavDialogProps { + open: boolean; + onClose: () => void; + onBackupSuccess?: () => void; + setBusy?: (loading: boolean) => void; +} + +export const BackupWebdavDialog = ({ + open, + onClose, + onBackupSuccess, + setBusy, +}: BackupWebdavDialogProps) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleLoading = useCallback( + (value: boolean) => { + setLoading(value); + setBusy?.(value); + }, + [setBusy], + ); + + const refreshWebdav = useCallback( + async (options?: { silent?: boolean }) => { + handleLoading(true); + try { + await listWebDavBackup(); + if (!options?.silent) { + showNotice.success( + "settings.modals.backup.messages.webdavRefreshSuccess", + ); + } + } catch (error) { + showNotice.error( + "settings.modals.backup.messages.webdavRefreshFailed", + { error }, + ); + } finally { + handleLoading(false); + } + }, + [handleLoading], + ); + + const refreshSilently = useCallback( + () => refreshWebdav({ silent: true }), + [refreshWebdav], + ); + + return ( + + + + { + await refreshSilently(); + onBackupSuccess?.(); + }} + onSaveSuccess={refreshSilently} + onRefresh={refreshWebdav} + onInit={refreshSilently} + /> + + + ); +}; diff --git a/src/components/setting/mods/local-backup-actions.tsx b/src/components/setting/mods/local-backup-actions.tsx deleted file mode 100644 index e2dd5bd50..000000000 --- a/src/components/setting/mods/local-backup-actions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Button, Grid, Stack, Typography } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { memo } from "react"; -import { useTranslation } from "react-i18next"; - -import { createLocalBackup } from "@/services/cmds"; -import { showNotice } from "@/services/noticeService"; - -interface LocalBackupActionsProps { - onBackupSuccess: () => Promise; - onRefresh: () => Promise; - setLoading: (loading: boolean) => void; -} - -export const LocalBackupActions = memo( - ({ onBackupSuccess, onRefresh, setLoading }: LocalBackupActionsProps) => { - const { t } = useTranslation(); - - const handleBackup = useLockFn(async () => { - try { - setLoading(true); - await createLocalBackup(); - showNotice.success( - "settings.modals.backup.messages.localBackupCreated", - ); - await onBackupSuccess(); - } catch (error) { - console.error(error); - showNotice.error("settings.modals.backup.messages.localBackupFailed"); - } finally { - setLoading(false); - } - }); - - const handleRefresh = useLockFn(async () => { - setLoading(true); - try { - await onRefresh(); - } finally { - setLoading(false); - } - }); - - return ( - - - - {t("settings.modals.backup.fields.info")} - - - - - - - - - - ); - }, -); diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 4c01bff35..6a9ffcac3 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "حذف النسخة الاحتياطية", "restore": "استعادة", - "restoreBackup": "استعادة النسخة الاحتياطية" + "restoreBackup": "استعادة النسخة الاحتياطية", + "viewHistory": "View history" }, "fields": { "webdavUrl": "عنوان خادم WebDAV", @@ -306,9 +307,37 @@ "restoreSuccess": "تمت الاستعادة بنجاح، سيعاد تشغيل التطبيق خلال ثانية واحدة", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "هل تريد بالتأكيد حذف ملف النسخة الاحتياطية هذا؟", "confirmRestore": "هل تريد بالتأكيد استعادة ملف النسخة الاحتياطية هذا؟" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "اسم الملف", "backupTime": "وقت النسخ الاحتياطي", diff --git a/src/locales/ar/shared.json b/src/locales/ar/shared.json index 604109371..9c4d4489b 100644 --- a/src/locales/ar/shared.json +++ b/src/locales/ar/shared.json @@ -21,7 +21,9 @@ "pause": "إيقاف مؤقت", "resume": "استأنف", "closeAll": "إغلاق الكل", - "clear": "مسح" + "clear": "مسح", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "التحديث عند", @@ -48,6 +50,7 @@ "milliseconds": "ميلي ثانية", "seconds": "ثواني", "minutes": "دقائق", + "hours": "ساعات", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 29a3cc39b..1cfa4c01f 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Sicherung löschen", "restore": "Wiederherstellen", - "restoreBackup": "Sicherung wiederherstellen" + "restoreBackup": "Sicherung wiederherstellen", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAV-Serveradresse http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "Wiederherstellung erfolgreich. Die App wird in 1 Sekunde neu starten.", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", "confirmRestore": "Confirm to restore this backup file?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Dateiname", "backupTime": "Sicherungszeit", diff --git a/src/locales/de/shared.json b/src/locales/de/shared.json index 22684cae0..eb2e34d7d 100644 --- a/src/locales/de/shared.json +++ b/src/locales/de/shared.json @@ -21,7 +21,9 @@ "pause": "Pausieren", "resume": "Fortsetzen", "closeAll": "Alle schließen", - "clear": "Löschen" + "clear": "Löschen", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Aktualisiert am", @@ -48,6 +50,7 @@ "milliseconds": "Millisekunden", "seconds": "Sekunden", "minutes": "Minuten", + "hours": "Stunden", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 661ab2f72..08cce1d21 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Delete Backup", "restore": "Restore", - "restoreBackup": "Restore Backup" + "restoreBackup": "Restore Backup", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAV Server URL", @@ -306,9 +307,37 @@ "restoreSuccess": "Restore Success, App will restart in 1s", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", "confirmRestore": "Confirm to restore this backup file?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Filename", "backupTime": "Backup Time", diff --git a/src/locales/en/shared.json b/src/locales/en/shared.json index 436164218..1e2f61317 100644 --- a/src/locales/en/shared.json +++ b/src/locales/en/shared.json @@ -21,7 +21,9 @@ "pause": "Pause", "resume": "Resume", "closeAll": "Close All", - "clear": "Clear" + "clear": "Clear", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Update At", @@ -48,6 +50,7 @@ "milliseconds": "ms", "seconds": "seconds", "minutes": "mins", + "hours": "hrs", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index cbc1d05ab..a687ed588 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Eliminar copia de seguridad", "restore": "Restaurar", - "restoreBackup": "Restaurar copia de seguridad" + "restoreBackup": "Restaurar copia de seguridad", + "viewHistory": "View history" }, "fields": { "webdavUrl": "Dirección del servidor WebDAV http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "Restauración exitosa. La aplicación se reiniciará en 1 segundo", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", "confirmRestore": "Confirm to restore this backup file?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Nombre del archivo", "backupTime": "Tiempo de copia de seguridad", diff --git a/src/locales/es/shared.json b/src/locales/es/shared.json index cb06326a8..7ed7494c6 100644 --- a/src/locales/es/shared.json +++ b/src/locales/es/shared.json @@ -21,7 +21,9 @@ "pause": "Pausar", "resume": "Reanudar", "closeAll": "Cerrar todas", - "clear": "Limpiar" + "clear": "Limpiar", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Actualizado el", @@ -48,6 +50,7 @@ "milliseconds": "Milisegundos", "seconds": "Segundos", "minutes": "Minutos", + "hours": "Horas", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 36a41769d..b99ec661e 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "حذف پشتیبان", "restore": "بازیابی", - "restoreBackup": "بازیابی پشتیبان" + "restoreBackup": "بازیابی پشتیبان", + "viewHistory": "View history" }, "fields": { "webdavUrl": "http(s):// URL سرور WebDAV", @@ -306,9 +307,37 @@ "restoreSuccess": "بازیابی با موفقیت انجام شد، برنامه در 1 ثانیه راه‌اندازی مجدد می‌شود", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "آیا از حذف این فایل پشتیبان اطمینان دارید؟", "confirmRestore": "آیا از بازیابی این فایل پشتیبان اطمینان دارید؟" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "نام فایل", "backupTime": "زمان پشتیبان‌گیری", diff --git a/src/locales/fa/shared.json b/src/locales/fa/shared.json index 374707ac0..6ab4e72d0 100644 --- a/src/locales/fa/shared.json +++ b/src/locales/fa/shared.json @@ -21,7 +21,9 @@ "pause": "توقف", "resume": "از سرگیری", "closeAll": "بستن همه", - "clear": "پاک کردن" + "clear": "پاک کردن", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "به‌روزرسانی در", @@ -48,6 +50,7 @@ "milliseconds": "میلی‌ثانیه", "seconds": "ثانیه‌ها", "minutes": "دقیقه", + "hours": "ساعت", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 4196f7fa4..abdf1bea5 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Hapus Cadangan", "restore": "Pulihkan", - "restoreBackup": "Pulihkan Cadangan" + "restoreBackup": "Pulihkan Cadangan", + "viewHistory": "View history" }, "fields": { "webdavUrl": "URL Server WebDAV", @@ -306,9 +307,37 @@ "restoreSuccess": "Pemulihan Berhasil, Aplikasi akan dimulai ulang dalam 1 detik", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Konfirmasi untuk menghapus file cadangan ini?", "confirmRestore": "Konfirmasi untuk memulihkan file cadangan ini?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Nama Berkas", "backupTime": "Waktu Cadangan", diff --git a/src/locales/id/shared.json b/src/locales/id/shared.json index 14eeec27a..057e9f74b 100644 --- a/src/locales/id/shared.json +++ b/src/locales/id/shared.json @@ -21,7 +21,9 @@ "pause": "Jeda", "resume": "Lanjut", "closeAll": "Tutup Semua", - "clear": "Bersihkan" + "clear": "Bersihkan", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Diperbarui Pada", @@ -48,6 +50,7 @@ "milliseconds": "milidetik", "seconds": "detik", "minutes": "menit", + "hours": "jam", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 51635416e..f02ec9b55 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "バックアップを削除", "restore": "復元", - "restoreBackup": "バックアップを復元" + "restoreBackup": "バックアップを復元", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAVサーバーのURL http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "復元に成功しました。アプリケーションは1秒後に再起動します。", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Confirm to delete this backup file?", "confirmRestore": "Confirm to restore this backup file?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "ファイル名", "backupTime": "バックアップ時間", diff --git a/src/locales/jp/shared.json b/src/locales/jp/shared.json index ac83ba54d..292e7c1b2 100644 --- a/src/locales/jp/shared.json +++ b/src/locales/jp/shared.json @@ -21,7 +21,9 @@ "pause": "一時停止", "resume": "再開", "closeAll": "すべて閉じる", - "clear": "クリア" + "clear": "クリア", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "更新日時", @@ -48,6 +50,7 @@ "milliseconds": "ミリ秒", "seconds": "秒", "minutes": "分", + "hours": "時間", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 1fe918129..faa61a57c 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -285,7 +285,8 @@ "exportBackup": "백업 내보내기", "deleteBackup": "백업 삭제", "restore": "복원", - "restoreBackup": "백업 복원" + "restoreBackup": "백업 복원", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAV 서버 URL", @@ -306,9 +307,37 @@ "restoreSuccess": "복원 성공, 1초 후 앱이 재시작됩니다", "localBackupExported": "로컬 백업이 내보내졌습니다", "localBackupExportFailed": "로컬 백업 내보내기 실패", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "이 백업 파일을 삭제하시겠습니까?", "confirmRestore": "이 백업 파일을 복원하시겠습니까?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "파일명", "backupTime": "백업 시간", diff --git a/src/locales/ko/shared.json b/src/locales/ko/shared.json index ca8e69842..4309e5f9c 100644 --- a/src/locales/ko/shared.json +++ b/src/locales/ko/shared.json @@ -21,7 +21,9 @@ "pause": "일시 정지", "resume": "재개", "closeAll": "모두 닫기", - "clear": "지우기" + "clear": "지우기", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "업데이트 시간", @@ -48,6 +50,7 @@ "milliseconds": "밀리초", "seconds": "초", "minutes": "분", + "hours": "시간", "kilobytes": "KB", "files": "파일" }, diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 3b6551ea2..2a73cbf61 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Удалить резервную копию", "restore": "Восстановить", - "restoreBackup": "Восстановить резервную копию" + "restoreBackup": "Восстановить резервную копию", + "viewHistory": "View history" }, "fields": { "webdavUrl": "URL-адрес сервера WebDAV http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "Восстановление успешно выполнено, приложение перезапустится через 1 секунду", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Вы уверены, что хотите удалить этот файл резервной копии?", "confirmRestore": "Вы уверены, что хотите восстановить этот файл резервной копии?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Имя файла", "backupTime": "Время резервного копирования", diff --git a/src/locales/ru/shared.json b/src/locales/ru/shared.json index e7c5c8617..be79ca589 100644 --- a/src/locales/ru/shared.json +++ b/src/locales/ru/shared.json @@ -21,7 +21,9 @@ "pause": "Пауза", "resume": "Возобновить", "closeAll": "Закрыть всё", - "clear": "Очистить" + "clear": "Очистить", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Обновлено в", @@ -48,6 +50,7 @@ "milliseconds": "миллисекунды", "seconds": "секунды", "minutes": "минуты", + "hours": "часы", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 6c0adc844..2dee69253 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Yedeği Sil", "restore": "Geri Yükle", - "restoreBackup": "Yedeği Geri Yükle" + "restoreBackup": "Yedeği Geri Yükle", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAV Sunucu URL'si", @@ -306,9 +307,37 @@ "restoreSuccess": "Geri Yükleme Başarılı, Uygulama 1 saniye içinde yeniden başlatılacak", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Bu yedek dosyasını silmeyi onaylıyor musunuz?", "confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Dosya Adı", "backupTime": "Yedekleme Zamanı", diff --git a/src/locales/tr/shared.json b/src/locales/tr/shared.json index f1ce80268..e094bdbc2 100644 --- a/src/locales/tr/shared.json +++ b/src/locales/tr/shared.json @@ -21,7 +21,9 @@ "pause": "Duraklat", "resume": "Sürdür", "closeAll": "Tümünü Kapat", - "clear": "Temizle" + "clear": "Temizle", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Güncelleme Zamanı", @@ -48,6 +50,7 @@ "milliseconds": "ms", "seconds": "saniye", "minutes": "dakika", + "hours": "saat", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 692c0b550..231779998 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -285,7 +285,8 @@ "exportBackup": "Export Backup", "deleteBackup": "Резерв копияне бетерү", "restore": "Кайтару", - "restoreBackup": "Резерв копияне кайтару" + "restoreBackup": "Резерв копияне кайтару", + "viewHistory": "View history" }, "fields": { "webdavUrl": "WebDAV сервер URL-ы (http(s)://)", @@ -306,9 +307,37 @@ "restoreSuccess": "Уңышлы кайтарылды, кушымта 1 секундтан яңадан башланачак", "localBackupExported": "Local backup exported successfully", "localBackupExportFailed": "Failed to export local backup", + "webdavRefreshSuccess": "WebDAV refresh succeeded", + "webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "confirmDelete": "Бу резерв копия файлын бетерергә телисезме?", "confirmRestore": "Бу резерв копия файлын кире кайтарырга телисезме?" }, + "auto": { + "title": "Automatic backup", + "scheduleLabel": "Enable scheduled backup", + "scheduleHelper": "Create local backups in the background at the configured interval.", + "intervalLabel": "Backup frequency", + "changeLabel": "Backup on critical changes", + "changeHelper": "Automatically back up when the global extend config/script or subscriptions are added, removed, or edited.", + "options": { + "hours": "Every {{n}} hours", + "days": "Every {{n}} days" + } + }, + "manual": { + "title": "Manual backup", + "local": "Creates a snapshot on this device, stored under the app data directory.", + "webdav": "Upload a snapshot to your WebDAV server once credentials are set.", + "configureWebdav": "Configure WebDAV" + }, + "history": { + "title": "Backup history", + "summary": "{{count}} backups • latest {{recent}}", + "empty": "No backups available" + }, + "webdav": { + "title": "WebDAV settings" + }, "table": { "filename": "Файл исеме", "backupTime": "Резерв копия вакыты", diff --git a/src/locales/tt/shared.json b/src/locales/tt/shared.json index 88199bd5f..9405b43ac 100644 --- a/src/locales/tt/shared.json +++ b/src/locales/tt/shared.json @@ -21,7 +21,9 @@ "pause": "Туктау", "resume": "Дәвам", "closeAll": "Барысын да ябу", - "clear": "Чистарту" + "clear": "Чистарту", + "previous": "Previous", + "next": "Next" }, "labels": { "updateAt": "Яңартылган вакыт", @@ -48,6 +50,7 @@ "milliseconds": "Миллисекундлар", "seconds": "Секундлар", "minutes": "Минутлар", + "hours": "Сәгатьләр", "kilobytes": "KB", "files": "Files" }, diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 368cdc4e4..3677fe1cf 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -285,7 +285,8 @@ "exportBackup": "导出备份", "deleteBackup": "删除备份", "restore": "恢复", - "restoreBackup": "恢复备份" + "restoreBackup": "恢复备份", + "viewHistory": "查看记录" }, "fields": { "webdavUrl": "WebDAV 服务器地址 http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "恢复成功,应用将在 1 秒后重启", "localBackupExported": "本地备份导出成功", "localBackupExportFailed": "本地备份导出失败", + "webdavRefreshSuccess": "WebDAV 刷新成功", + "webdavRefreshFailed": "WebDAV 刷新失败: {{error}}", "confirmDelete": "确认删除此备份文件吗?", "confirmRestore": "确认恢复此份文件吗?" }, + "auto": { + "title": "自动备份", + "scheduleLabel": "启用定时备份", + "scheduleHelper": "按设定频率在后台创建本地备份文件。", + "intervalLabel": "备份频率", + "changeLabel": "关键变更时自动备份", + "changeHelper": "全局扩展配置/脚本或订阅增删改后会自动备份。", + "options": { + "hours": "每 {{n}} 小时", + "days": "每 {{n}} 天" + } + }, + "manual": { + "title": "手动备份", + "local": "在本设备的应用数据目录中创建备份。", + "webdav": "配置 WebDAV 后,可直接上传备份到服务器。", + "configureWebdav": "配置 WebDAV" + }, + "history": { + "title": "备份记录", + "summary": "共 {{count}} 份备份 · 最近 {{recent}}", + "empty": "暂无备份记录" + }, + "webdav": { + "title": "WebDAV 设置" + }, "table": { "filename": "文件名称", "backupTime": "备份时间", diff --git a/src/locales/zh/shared.json b/src/locales/zh/shared.json index 788e1d79e..7db791f85 100644 --- a/src/locales/zh/shared.json +++ b/src/locales/zh/shared.json @@ -21,7 +21,9 @@ "pause": "暂停", "resume": "继续", "closeAll": "关闭全部", - "clear": "清除" + "clear": "清除", + "previous": "上一页", + "next": "下一页" }, "labels": { "updateAt": "更新于", @@ -48,6 +50,7 @@ "milliseconds": "毫秒", "seconds": "秒", "minutes": "分钟", + "hours": "小时", "kilobytes": "KB", "files": "文件" }, diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index e5c1abcba..d78440f29 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -285,7 +285,8 @@ "exportBackup": "匯出備份", "deleteBackup": "刪除備份", "restore": "還原", - "restoreBackup": "還原備份" + "restoreBackup": "還原備份", + "viewHistory": "檢視紀錄" }, "fields": { "webdavUrl": "WebDAV 伺服器位址 http(s)://", @@ -306,9 +307,37 @@ "restoreSuccess": "還原成功,應用程式將在 1 秒後重啟", "localBackupExported": "本機備份匯出成功", "localBackupExportFailed": "本機備份匯出失敗", + "webdavRefreshSuccess": "WebDAV 更新成功", + "webdavRefreshFailed": "WebDAV 更新失敗: {{error}}", "confirmDelete": "確認是否刪除此備份檔案嗎?", "confirmRestore": "確認還原此份檔案嗎?" }, + "auto": { + "title": "自動備份", + "scheduleLabel": "啟用定時備份", + "scheduleHelper": "依設定頻率在背景建立本機備份檔案。", + "intervalLabel": "備份頻率", + "changeLabel": "關鍵變更時自動備份", + "changeHelper": "全域擴充配置/腳本或訂閱新增、刪除、修改時自動備份。", + "options": { + "hours": "每 {{n}} 小時", + "days": "每 {{n}} 天" + } + }, + "manual": { + "title": "手動備份", + "local": "在本機應用資料資料夾建立備份檔。", + "webdav": "設定 WebDAV 後,可直接上傳備份至伺服器。", + "configureWebdav": "設定 WebDAV" + }, + "history": { + "title": "備份紀錄", + "summary": "共 {{count}} 份備份 · 最近 {{recent}}", + "empty": "尚無備份紀錄" + }, + "webdav": { + "title": "WebDAV 設定" + }, "table": { "filename": "檔案名稱", "backupTime": "備份時間", diff --git a/src/locales/zhtw/shared.json b/src/locales/zhtw/shared.json index 48cb83c2f..c7d5fe72c 100644 --- a/src/locales/zhtw/shared.json +++ b/src/locales/zhtw/shared.json @@ -21,7 +21,9 @@ "pause": "暫停", "resume": "繼續", "closeAll": "關閉全部", - "clear": "清除" + "clear": "清除", + "previous": "上一頁", + "next": "下一頁" }, "labels": { "updateAt": "更新於", @@ -48,6 +50,7 @@ "milliseconds": "毫秒", "seconds": "秒", "minutes": "分鐘", + "hours": "小時", "kilobytes": "KB", "files": "檔案" }, diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 78ebff265..99abd3fe6 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -462,6 +462,7 @@ export const translationKeys = [ "settings.modals.backup.actions.deleteBackup", "settings.modals.backup.actions.restore", "settings.modals.backup.actions.restoreBackup", + "settings.modals.backup.actions.viewHistory", "settings.modals.backup.fields.webdavUrl", "settings.modals.backup.fields.username", "settings.modals.backup.fields.info", @@ -478,8 +479,26 @@ export const translationKeys = [ "settings.modals.backup.messages.restoreSuccess", "settings.modals.backup.messages.localBackupExported", "settings.modals.backup.messages.localBackupExportFailed", + "settings.modals.backup.messages.webdavRefreshSuccess", + "settings.modals.backup.messages.webdavRefreshFailed", "settings.modals.backup.messages.confirmDelete", "settings.modals.backup.messages.confirmRestore", + "settings.modals.backup.auto.title", + "settings.modals.backup.auto.scheduleLabel", + "settings.modals.backup.auto.scheduleHelper", + "settings.modals.backup.auto.intervalLabel", + "settings.modals.backup.auto.changeLabel", + "settings.modals.backup.auto.changeHelper", + "settings.modals.backup.auto.options.hours", + "settings.modals.backup.auto.options.days", + "settings.modals.backup.manual.title", + "settings.modals.backup.manual.local", + "settings.modals.backup.manual.webdav", + "settings.modals.backup.manual.configureWebdav", + "settings.modals.backup.history.title", + "settings.modals.backup.history.summary", + "settings.modals.backup.history.empty", + "settings.modals.backup.webdav.title", "settings.modals.backup.table.filename", "settings.modals.backup.table.backupTime", "settings.modals.backup.table.actions", @@ -639,6 +658,8 @@ export const translationKeys = [ "shared.actions.resume", "shared.actions.closeAll", "shared.actions.clear", + "shared.actions.previous", + "shared.actions.next", "shared.labels.updateAt", "shared.labels.timeout", "shared.labels.icon", @@ -659,6 +680,7 @@ export const translationKeys = [ "shared.units.milliseconds", "shared.units.seconds", "shared.units.minutes", + "shared.units.hours", "shared.units.kilobytes", "shared.units.files", "shared.placeholders.filter", diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 497ec0331..3c23476eb 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -690,12 +690,36 @@ export interface TranslationResources { restore: string; restoreBackup: string; selectTarget: string; + viewHistory: string; + }; + auto: { + changeHelper: string; + changeLabel: string; + intervalLabel: string; + options: { + days: string; + hours: string; + }; + scheduleHelper: string; + scheduleLabel: string; + title: string; }; fields: { info: string; username: string; webdavUrl: string; }; + history: { + empty: string; + summary: string; + title: string; + }; + manual: { + configureWebdav: string; + local: string; + title: string; + webdav: string; + }; messages: { backupCreated: string; backupFailed: string; @@ -711,6 +735,8 @@ export interface TranslationResources { usernameRequired: string; webdavConfigSaved: string; webdavConfigSaveFailed: string; + webdavRefreshFailed: string; + webdavRefreshSuccess: string; webdavUrlRequired: string; }; table: { @@ -725,6 +751,9 @@ export interface TranslationResources { webdav: string; }; title: string; + webdav: { + title: string; + }; }; clashCore: { variants: { @@ -1139,7 +1168,9 @@ export interface TranslationResources { hideDetails: string; listView: string; new: string; + next: string; pause: string; + previous: string; refresh: string; refreshPage: string; resetToDefault: string; @@ -1242,6 +1273,7 @@ export interface TranslationResources { }; units: { files: string; + hours: string; kilobytes: string; milliseconds: string; minutes: string; diff --git a/src/types/types.d.ts b/src/types/types.d.ts index cc9eaac37..317eaf5bf 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -854,6 +854,9 @@ interface IVergeConfig { enable_auto_delay_detection?: boolean; enable_builtin_enhanced?: boolean; auto_log_clean?: 0 | 1 | 2 | 3 | 4; + enable_auto_backup_schedule?: boolean; + auto_backup_interval_hours?: number; + auto_backup_on_change?: boolean; proxy_layout_column?: number; test_list?: IVergeTestItem[]; webdav_url?: string;