feat: local backup (#5054)

* feat: local backup

* refactor(backup): make local backup helpers synchronous and clean up redundant checks

- Converted local backup helpers to synchronous functions to remove unused async warnings and align command signatures.
- Updated list/delete/export commands to call the sync feature functions directly without awaits while preserving behavior.
- Simplified destination directory creation to always ensure parent folders exist without redundant checks, satisfying Clippy.
This commit is contained in:
Sline
2025-10-14 14:52:04 +08:00
committed by GitHub
parent 4dd811330b
commit 51b08be87e
14 changed files with 666 additions and 61 deletions

View File

@@ -9,6 +9,7 @@
- 监听关机事件,自动关闭系统代理 - 监听关机事件,自动关闭系统代理
- 主界面“当前节点”卡片新增“延迟测试”按钮 - 主界面“当前节点”卡片新增“延迟测试”按钮
- 新增批量选择配置文件功能 - 新增批量选择配置文件功能
- 新增本地备份功能
### 🚀 优化改进 ### 🚀 优化改进
@@ -27,6 +28,7 @@
- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核 - 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核
- 修改内核默认日志级别为 Info - 修改内核默认日志级别为 Info
- 支持通过桌面快捷方式重新打开应用 - 支持通过桌面快捷方式重新打开应用
- 主界面“当前节点”卡片每 5 分钟自动测试延迟
### 🐞 修复问题 ### 🐞 修复问题

View File

@@ -0,0 +1,33 @@
use super::CmdResult;
use crate::{feat, wrap_err};
use feat::LocalBackupFile;
/// Create a local backup
#[tauri::command]
pub fn create_local_backup() -> CmdResult<()> {
wrap_err!(feat::create_local_backup())
}
/// List local backups
#[tauri::command]
pub fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
wrap_err!(feat::list_local_backup())
}
/// Delete local backup
#[tauri::command]
pub fn delete_local_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::delete_local_backup(filename))
}
/// Restore local backup
#[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::restore_local_backup(filename).await)
}
/// Export local backup to a user selected destination
#[tauri::command]
pub fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
wrap_err!(feat::export_local_backup(filename, destination))
}

View File

@@ -4,6 +4,7 @@ pub type CmdResult<T = ()> = Result<T, String>;
// Command modules // Command modules
pub mod app; pub mod app;
pub mod backup;
pub mod clash; pub mod clash;
pub mod lightweight; pub mod lightweight;
pub mod media_unlock_checker; pub mod media_unlock_checker;
@@ -21,6 +22,7 @@ pub mod webdav;
// Re-export all command functions for backwards compatibility // Re-export all command functions for backwards compatibility
pub use app::*; pub use app::*;
pub use backup::*;
pub use clash::*; pub use clash::*;
pub use lightweight::*; pub use lightweight::*;
pub use media_unlock_checker::*; pub use media_unlock_checker::*;

View File

@@ -2,11 +2,24 @@ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core::backup, core::backup,
logging_error, logging_error,
utils::{dirs::app_home_dir, logging::Type}, utils::{
dirs::{app_home_dir, local_backup_dir},
logging::Type,
},
}; };
use anyhow::Result; use anyhow::{Result, anyhow};
use chrono::Utc;
use reqwest_dav::list_cmd::ListFile; use reqwest_dav::list_cmd::ListFile;
use std::fs; use serde::Serialize;
use std::{fs, path::PathBuf};
#[derive(Debug, Serialize)]
pub struct LocalBackupFile {
pub filename: String,
pub path: String,
pub last_modified: String,
pub content_length: u64,
}
/// Create a backup and upload to WebDAV /// Create a backup and upload to WebDAV
pub async fn create_backup_and_upload_webdav() -> Result<()> { pub async fn create_backup_and_upload_webdav() -> Result<()> {
@@ -90,3 +103,147 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
fs::remove_file(backup_storage_path)?; fs::remove_file(backup_storage_path)?;
Ok(()) Ok(())
} }
/// Create a backup and save to local storage
pub fn create_local_backup() -> Result<()> {
let (file_name, temp_file_path) = backup::create_backup().map_err(|err| {
log::error!(target: "app", "Failed to create local backup: {err:#?}");
err
})?;
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(&file_name);
if let Err(err) = move_file(temp_file_path.clone(), target_path.clone()) {
log::error!(target: "app", "Failed to move local backup file: {err:#?}");
// 清理临时文件
if let Err(clean_err) = std::fs::remove_file(&temp_file_path) {
log::warn!(
target: "app",
"Failed to remove temp backup file after move error: {clean_err:#?}"
);
}
return Err(err);
}
Ok(())
}
fn move_file(from: PathBuf, to: PathBuf) -> Result<()> {
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(&from, &to) {
Ok(_) => Ok(()),
Err(rename_err) => {
// Attempt copy + remove as fallback, covering cross-device moves
log::warn!(
target: "app",
"Failed to rename backup file directly, fallback to copy/remove: {rename_err:#?}"
);
fs::copy(&from, &to).map_err(|err| anyhow!("Failed to copy backup file: {err:#?}"))?;
fs::remove_file(&from)
.map_err(|err| anyhow!("Failed to remove temp backup file: {err:#?}"))?;
Ok(())
}
}
}
/// List local backups
pub fn list_local_backup() -> Result<Vec<LocalBackupFile>> {
let backup_dir = local_backup_dir()?;
if !backup_dir.exists() {
return Ok(vec![]);
}
let mut backups = Vec::new();
for entry in fs::read_dir(&backup_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let metadata = entry.metadata()?;
let last_modified = metadata
.modified()
.map(|time| chrono::DateTime::<Utc>::from(time).to_rfc3339())
.unwrap_or_default();
backups.push(LocalBackupFile {
filename: file_name.to_string(),
path: path.to_string_lossy().to_string(),
last_modified,
content_length: metadata.len(),
});
}
backups.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(backups)
}
/// Delete local backup
pub fn delete_local_backup(filename: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(&filename);
if !target_path.exists() {
log::warn!(target: "app", "Local backup file not found: {}", filename);
return Ok(());
}
fs::remove_file(target_path)?;
Ok(())
}
/// Restore local backup
pub async fn restore_local_backup(filename: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(&filename);
if !target_path.exists() {
return Err(anyhow!("Backup file not found: {}", filename));
}
let verge = Config::verge().await;
let verge_data = verge.latest_ref().clone();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let mut zip = zip::ZipArchive::new(fs::File::open(&target_path)?)?;
zip.extract(app_home_dir()?)?;
logging_error!(
Type::Backup,
super::patch_verge(
IVerge {
webdav_url,
webdav_username,
webdav_password,
..IVerge::default()
},
false
)
.await
);
Ok(())
}
/// Export local backup file to user selected destination
pub fn export_local_backup(filename: String, destination: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let source_path = backup_dir.join(&filename);
if !source_path.exists() {
return Err(anyhow!("Backup file not found: {}", filename));
}
let dest_path = PathBuf::from(destination);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&source_path, &dest_path)
.map(|_| ())
.map_err(|err| anyhow!("Failed to export backup file: {err:#?}"))?;
Ok(())
}

View File

@@ -204,6 +204,11 @@ mod app_init {
cmd::script_validate_notice, cmd::script_validate_notice,
cmd::validate_script_file, cmd::validate_script_file,
// Backup and WebDAV // Backup and WebDAV
cmd::create_local_backup,
cmd::list_local_backup,
cmd::delete_local_backup,
cmd::restore_local_backup,
cmd::export_local_backup,
cmd::create_webdav_backup, cmd::create_webdav_backup,
cmd::save_webdav_config, cmd::save_webdav_config,
cmd::list_webdav_backup, cmd::list_webdav_backup,

View File

@@ -120,6 +120,13 @@ pub fn app_logs_dir() -> Result<PathBuf> {
Ok(app_home_dir()?.join("logs")) Ok(app_home_dir()?.join("logs"))
} }
/// local backups dir
pub fn local_backup_dir() -> Result<PathBuf> {
let dir = app_home_dir()?.join(BACKUP_DIR);
fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn clash_path() -> Result<PathBuf> { pub fn clash_path() -> Result<PathBuf> {
Ok(app_home_dir()?.join(CLASH_CONFIG)) Ok(app_home_dir()?.join(CLASH_CONFIG))
} }

View File

@@ -1,4 +1,5 @@
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import RestoreIcon from "@mui/icons-material/Restore"; import RestoreIcon from "@mui/icons-material/Restore";
import { import {
Box, Box,
@@ -14,22 +15,20 @@ import {
TablePagination, TablePagination,
} from "@mui/material"; } from "@mui/material";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import { save } from "@tauri-apps/plugin-dialog";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Dayjs } from "dayjs"; import { Dayjs } from "dayjs";
import { SVGProps, memo } from "react"; import { SVGProps, memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { restartApp } from "@/services/cmds";
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
export type BackupFile = IWebDavFile & { export type BackupFile = {
platform: string; platform: string;
backup_time: Dayjs; backup_time: Dayjs;
allow_apply: boolean; allow_apply: boolean;
filename: string;
}; };
export const DEFAULT_ROWS_PER_PAGE = 5; export const DEFAULT_ROWS_PER_PAGE = 5;
@@ -43,6 +42,9 @@ interface BackupTableViewerProps {
) => void; ) => void;
total: number; total: number;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
onDelete: (filename: string) => Promise<void>;
onRestore: (filename: string) => Promise<void>;
onExport?: (filename: string, destination: string) => Promise<void>;
} }
export const BackupTableViewer = memo( export const BackupTableViewer = memo(
@@ -52,21 +54,43 @@ export const BackupTableViewer = memo(
onPageChange, onPageChange,
total, total,
onRefresh, onRefresh,
onDelete,
onRestore,
onExport,
}: BackupTableViewerProps) => { }: BackupTableViewerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => { const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename); await onDelete(filename);
await onRefresh(); await onRefresh();
}); });
const handleRestore = useLockFn(async (filename: string) => { const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => { await onRestore(filename).then(() => {
showNotice("success", t("Restore Success, App will restart in 1s")); showNotice("success", t("Restore Success, App will restart in 1s"));
}); });
await restartApp(); 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", t("Local Backup Exported"));
} catch (error) {
console.error(error);
showNotice("error", t("Local Backup Export Failed"));
}
});
return ( return (
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table> <Table>
@@ -102,6 +126,27 @@ export const BackupTableViewer = memo(
justifyContent: "flex-end", justifyContent: "flex-end",
}} }}
> >
{onExport && (
<>
<IconButton
color="primary"
aria-label={t("Export")}
size="small"
title={t("Export Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
await handleExport(file.filename);
}}
>
<DownloadIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
</>
)}
<IconButton <IconButton
color="secondary" color="secondary"
aria-label={t("Delete")} aria-label={t("Delete")}

View File

@@ -1,12 +1,28 @@
import { Box, Divider, Paper } from "@mui/material"; import { Box, Button, Divider, Paper, Tab, Tabs } from "@mui/material";
import dayjs from "dayjs"; import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import type { Ref } from "react"; import type { Ref } from "react";
import { useCallback, useImperativeHandle, useMemo, useState } from "react"; import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base"; import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base";
import { listWebDavBackup } from "@/services/cmds"; import {
deleteLocalBackup,
deleteWebdavBackup,
listLocalBackup,
listWebDavBackup,
exportLocalBackup,
restoreLocalBackup,
restoreWebDavBackup,
} from "@/services/cmds";
import { BackupConfigViewer } from "./backup-config-viewer"; import { BackupConfigViewer } from "./backup-config-viewer";
import { import {
@@ -14,19 +30,28 @@ import {
BackupTableViewer, BackupTableViewer,
DEFAULT_ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer"; } from "./backup-table-viewer";
import { LocalBackupActions } from "./local-backup-actions";
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
type BackupSource = "local" | "webdav";
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) { export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const [dialogPaper, setDialogPaper] = useState<HTMLElement | null>(null);
const [closeButtonPosition, setCloseButtonPosition] = useState<{
top: number;
left: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]); const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [source, setSource] = useState<BackupSource>("local");
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
@@ -43,7 +68,51 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
[], [],
); );
const fetchAndSetBackupFiles = async () => { 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<BackupFile[]> => {
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 () => {
try { try {
setIsLoading(true); setIsLoading(true);
const files = await getAllBackupFiles(); const files = await getAllBackupFiles();
@@ -57,32 +126,110 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [getAllBackupFiles]);
const getAllBackupFiles = async () => { useEffect(() => {
const files = await listWebDavBackup(); if (open) {
return files fetchAndSetBackupFiles();
.map((file) => { const paper = contentRef.current?.closest(".MuiPaper-root");
const platform = file.filename.split("-")[0]; setDialogPaper((paper as HTMLElement) ?? null);
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!; } else {
setDialogPaper(null);
}
}, [open, fetchAndSetBackupFiles]);
if (fileBackupTimeStr === null) { useEffect(() => {
return null; 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 backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT); const updatePosition = () => {
const allowApply = true; const rect = dialogPaper.getBoundingClientRect();
return { setCloseButtonPosition({
...file, top: rect.bottom - 16,
platform, left: rect.right - 24,
backup_time: backupTime, });
allow_apply: allowApply,
} as BackupFile;
})
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
}; };
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<BackupFile[]>( const dataSource = useMemo<BackupFile[]>(
() => () =>
backupFiles.slice( backupFiles.slice(
@@ -96,16 +243,49 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
<BaseDialog <BaseDialog
open={open} open={open}
title={t("Backup Setting")} title={t("Backup Setting")}
// contentSx={{ width: 600, maxHeight: 800 }} contentSx={{
okBtn={t("")} minWidth: { xs: 320, sm: 620 },
cancelBtn={t("Close")} maxWidth: "unset",
minHeight: 460,
}}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} disableFooter
disableOk >
<Box
ref={contentRef}
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
> >
<Box>
<BaseLoadingOverlay isLoading={isLoading} /> <BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}> <Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
}}
>
<Tabs
value={source}
onChange={handleChangeSource}
aria-label={t("Select Backup Target")}
sx={{ mb: 2 }}
>
<Tab value="local" label={t("Local Backup")} />
<Tab value="webdav" label={t("WebDAV Backup")} />
</Tabs>
{source === "local" ? (
<LocalBackupActions
setLoading={setIsLoading}
onBackupSuccess={fetchAndSetBackupFiles}
onRefresh={fetchAndSetBackupFiles}
/>
) : (
<BackupConfigViewer <BackupConfigViewer
setLoading={setIsLoading} setLoading={setIsLoading}
onBackupSuccess={fetchAndSetBackupFiles} onBackupSuccess={fetchAndSetBackupFiles}
@@ -113,16 +293,55 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
onRefresh={fetchAndSetBackupFiles} onRefresh={fetchAndSetBackupFiles}
onInit={fetchAndSetBackupFiles} onInit={fetchAndSetBackupFiles}
/> />
)}
<Divider sx={{ marginY: 2 }} /> <Divider sx={{ marginY: 2 }} />
<Box
sx={{
flexGrow: 1,
overflow: "auto",
minHeight: 0,
}}
>
<BackupTableViewer <BackupTableViewer
datasource={dataSource} datasource={dataSource}
page={page} page={page}
onPageChange={handleChangePage} onPageChange={handleChangePage}
total={total} total={total}
onRefresh={fetchAndSetBackupFiles} onRefresh={fetchAndSetBackupFiles}
onDelete={handleDelete}
onRestore={handleRestore}
onExport={source === "local" ? handleExport : undefined}
/> />
</Box>
</Paper> </Paper>
</Box> </Box>
{dialogPaper &&
closeButtonPosition &&
createPortal(
<Box
sx={{
position: "fixed",
top: closeButtonPosition.top,
left: closeButtonPosition.left,
transform: "translate(-100%, -100%)",
pointerEvents: "none",
zIndex: (theme) => theme.zIndex.modal + 1,
}}
>
<Button
variant="outlined"
onClick={() => setOpen(false)}
sx={{
pointerEvents: "auto",
boxShadow: (theme) => theme.shadows[3],
backgroundColor: (theme) => theme.palette.background.paper,
}}
>
{t("Close")}
</Button>
</Box>,
dialogPaper,
)}
</BaseDialog> </BaseDialog>
); );
} }

View File

@@ -0,0 +1,78 @@
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<void>;
onRefresh: () => Promise<void>;
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", t("Local Backup Created"));
await onBackupSuccess();
} catch (error) {
console.error(error);
showNotice("error", t("Local Backup Failed"));
} finally {
setLoading(false);
}
});
const handleRefresh = useLockFn(async () => {
setLoading(true);
try {
await onRefresh();
} finally {
setLoading(false);
}
});
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 9 }}>
<Typography variant="body2" color="text.secondary">
{t("Local Backup Info")}
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<Stack
direction="column"
alignItems="stretch"
spacing={1.5}
sx={{ height: "100%" }}
>
<Button
variant="contained"
color="success"
onClick={handleBackup}
type="button"
size="large"
>
{t("Backup")}
</Button>
<Button
variant="outlined"
onClick={handleRefresh}
type="button"
size="large"
>
{t("Refresh")}
</Button>
</Stack>
</Grid>
</Grid>
);
},
);

View File

@@ -387,7 +387,7 @@
"toggle_tun_mode": "Enable/Disable Tun Mode", "toggle_tun_mode": "Enable/Disable Tun Mode",
"entry_lightweight_mode": "Entry Lightweight Mode", "entry_lightweight_mode": "Entry Lightweight Mode",
"Backup Setting": "Backup Setting", "Backup Setting": "Backup Setting",
"Backup Setting Info": "Support WebDAV backup configuration files", "Backup Setting Info": "Support local or WebDAV backup of configuration files",
"Runtime Config": "Runtime Config", "Runtime Config": "Runtime Config",
"Open Conf Dir": "Open Conf Dir", "Open Conf Dir": "Open Conf Dir",
"Open Conf Dir Info": "If the software runs abnormally, BACKUP and delete all files in this folder then restart the software", "Open Conf Dir Info": "If the software runs abnormally, BACKUP and delete all files in this folder then restart the software",
@@ -448,9 +448,13 @@
"Username": "Username", "Username": "Username",
"Password": "Password", "Password": "Password",
"Backup": "Backup", "Backup": "Backup",
"Local Backup": "Local backup",
"WebDAV Backup": "WebDAV backup",
"Select Backup Target": "Select backup target",
"Filename": "Filename", "Filename": "Filename",
"Actions": "Actions", "Actions": "Actions",
"Restore": "Restore", "Restore": "Restore",
"Export": "Export",
"No Backups": "No backups available", "No Backups": "No backups available",
"WebDAV URL Required": "WebDAV URL cannot be empty", "WebDAV URL Required": "WebDAV URL cannot be empty",
"Invalid WebDAV URL": "Invalid WebDAV URL format", "Invalid WebDAV URL": "Invalid WebDAV URL format",
@@ -461,7 +465,13 @@
"WebDAV Config Save Failed": "Failed to save WebDAV configuration: {{error}}", "WebDAV Config Save Failed": "Failed to save WebDAV configuration: {{error}}",
"Backup Created": "Backup created successfully", "Backup Created": "Backup created successfully",
"Backup Failed": "Backup failed: {{error}}", "Backup Failed": "Backup failed: {{error}}",
"Local Backup Created": "Local backup created successfully",
"Local Backup Failed": "Local backup failed",
"Local Backup Exported": "Local backup exported successfully",
"Local Backup Export Failed": "Failed to export local backup",
"Local Backup Info": "Backups are stored locally in the application data directory. Use the list below to restore or delete backups.",
"Delete Backup": "Delete Backup", "Delete Backup": "Delete Backup",
"Export Backup": "Export Backup",
"Restore Backup": "Restore Backup", "Restore Backup": "Restore Backup",
"Backup Time": "Backup Time", "Backup Time": "Backup Time",
"Confirm to delete this backup file?": "Confirm to delete this backup file?", "Confirm to delete this backup file?": "Confirm to delete this backup file?",

View File

@@ -387,7 +387,7 @@
"toggle_tun_mode": "打开/关闭 TUN 模式", "toggle_tun_mode": "打开/关闭 TUN 模式",
"entry_lightweight_mode": "进入轻量模式", "entry_lightweight_mode": "进入轻量模式",
"Backup Setting": "备份设置", "Backup Setting": "备份设置",
"Backup Setting Info": "支持 WebDAV 备份配置文件", "Backup Setting Info": "支持本地或 WebDAV 方式备份配置文件",
"Runtime Config": "当前配置", "Runtime Config": "当前配置",
"Open Conf Dir": "配置目录", "Open Conf Dir": "配置目录",
"Open Conf Dir Info": "如果软件运行异常,!备份!并删除此文件夹下的所有文件,重启软件", "Open Conf Dir Info": "如果软件运行异常,!备份!并删除此文件夹下的所有文件,重启软件",
@@ -448,9 +448,13 @@
"Username": "用户名", "Username": "用户名",
"Password": "密码", "Password": "密码",
"Backup": "备份", "Backup": "备份",
"Local Backup": "本地备份",
"WebDAV Backup": "WebDAV 备份",
"Select Backup Target": "选择备份目标",
"Filename": "文件名称", "Filename": "文件名称",
"Actions": "操作", "Actions": "操作",
"Restore": "恢复", "Restore": "恢复",
"Export": "导出",
"No Backups": "暂无备份", "No Backups": "暂无备份",
"WebDAV URL Required": "WebDAV 服务器地址不能为空", "WebDAV URL Required": "WebDAV 服务器地址不能为空",
"Invalid WebDAV URL": "无效的 WebDAV 服务器地址格式", "Invalid WebDAV URL": "无效的 WebDAV 服务器地址格式",
@@ -461,7 +465,13 @@
"WebDAV Config Save Failed": "保存 WebDAV 配置失败: {{error}}", "WebDAV Config Save Failed": "保存 WebDAV 配置失败: {{error}}",
"Backup Created": "备份创建成功", "Backup Created": "备份创建成功",
"Backup Failed": "备份失败: {{error}}", "Backup Failed": "备份失败: {{error}}",
"Local Backup Created": "本地备份创建成功",
"Local Backup Failed": "本地备份失败",
"Local Backup Exported": "本地备份导出成功",
"Local Backup Export Failed": "本地备份导出失败",
"Local Backup Info": "在应用数据目录中创建本地备份,您可以通过下方列表进行恢复或删除。",
"Delete Backup": "删除备份", "Delete Backup": "删除备份",
"Export Backup": "导出备份",
"Restore Backup": "恢复备份", "Restore Backup": "恢复备份",
"Backup Time": "备份时间", "Backup Time": "备份时间",
"Confirm to delete this backup file?": "确认删除此备份文件吗?", "Confirm to delete this backup file?": "确认删除此备份文件吗?",

View File

@@ -344,7 +344,7 @@
"toggle_tun_mode": "打開/關閉 TUN 模式", "toggle_tun_mode": "打開/關閉 TUN 模式",
"entry_lightweight_mode": "進入輕量模式", "entry_lightweight_mode": "進入輕量模式",
"Backup Setting": "備份設置", "Backup Setting": "備份設置",
"Backup Setting Info": "支持 WebDAV 備份配置文件", "Backup Setting Info": "支持本地或 WebDAV 方式備份配置文件",
"Runtime Config": "當前配置", "Runtime Config": "當前配置",
"Open Conf Dir": "配置目錄", "Open Conf Dir": "配置目錄",
"Open Conf Dir Info": "如果軟件運行異常,!備份!並刪除此文件夾下的所有文件,重啟軟件", "Open Conf Dir Info": "如果軟件運行異常,!備份!並刪除此文件夾下的所有文件,重啟軟件",
@@ -393,9 +393,13 @@
"Username": "用戶名", "Username": "用戶名",
"Password": "密碼", "Password": "密碼",
"Backup": "備份", "Backup": "備份",
"Local Backup": "本地備份",
"WebDAV Backup": "WebDAV 備份",
"Select Backup Target": "選擇備份目標",
"Filename": "文件名稱", "Filename": "文件名稱",
"Actions": "操作", "Actions": "操作",
"Restore": "恢復", "Restore": "恢復",
"Export": "匯出",
"No Backups": "暫無備份", "No Backups": "暫無備份",
"WebDAV URL Required": "WebDAV 服務器地址不能為空", "WebDAV URL Required": "WebDAV 服務器地址不能為空",
"Invalid WebDAV URL": "無效的 WebDAV 服務器地址格式", "Invalid WebDAV URL": "無效的 WebDAV 服務器地址格式",
@@ -405,7 +409,13 @@
"WebDAV Config Save Failed": "保存 WebDAV 配置失敗: {{error}}", "WebDAV Config Save Failed": "保存 WebDAV 配置失敗: {{error}}",
"Backup Created": "備份創建成功", "Backup Created": "備份創建成功",
"Backup Failed": "備份失敗: {{error}}", "Backup Failed": "備份失敗: {{error}}",
"Local Backup Created": "本地備份創建成功",
"Local Backup Failed": "本地備份失敗",
"Local Backup Exported": "本地備份匯出成功",
"Local Backup Export Failed": "本地備份匯出失敗",
"Local Backup Info": "在應用資料目錄中建立本地備份,您可以透過下方列表進行恢復或刪除。",
"Delete Backup": "刪除備份", "Delete Backup": "刪除備份",
"Export Backup": "匯出備份",
"Restore Backup": "恢復備份", "Restore Backup": "恢復備份",
"Backup Time": "備份時間", "Backup Time": "備份時間",
"Restore Success, App will restart in 1s": "恢復成功,應用將在 1 秒後重啟", "Restore Success, App will restart in 1s": "恢復成功,應用將在 1 秒後重啟",

View File

@@ -440,14 +440,30 @@ export async function createWebdavBackup() {
return invoke<void>("create_webdav_backup"); return invoke<void>("create_webdav_backup");
} }
export async function createLocalBackup() {
return invoke<void>("create_local_backup");
}
export async function deleteWebdavBackup(filename: string) { export async function deleteWebdavBackup(filename: string) {
return invoke<void>("delete_webdav_backup", { filename }); return invoke<void>("delete_webdav_backup", { filename });
} }
export async function deleteLocalBackup(filename: string) {
return invoke<void>("delete_local_backup", { filename });
}
export async function restoreWebDavBackup(filename: string) { export async function restoreWebDavBackup(filename: string) {
return invoke<void>("restore_webdav_backup", { filename }); return invoke<void>("restore_webdav_backup", { filename });
} }
export async function restoreLocalBackup(filename: string) {
return invoke<void>("restore_local_backup", { filename });
}
export async function exportLocalBackup(filename: string, destination: string) {
return invoke<void>("export_local_backup", { filename, destination });
}
export async function saveWebdavConfig( export async function saveWebdavConfig(
url: string, url: string,
username: string, username: string,
@@ -468,6 +484,10 @@ export async function listWebDavBackup() {
return list; return list;
} }
export async function listLocalBackup() {
return invoke<ILocalBackupFile[]>("list_local_backup");
}
export async function scriptValidateNotice(status: string, msg: string) { export async function scriptValidateNotice(status: string, msg: string) {
return invoke<void>("script_validate_notice", { status, msg }); return invoke<void>("script_validate_notice", { status, msg });
} }

View File

@@ -870,6 +870,13 @@ interface IWebDavFile {
tag: string; tag: string;
} }
interface ILocalBackupFile {
filename: string;
path: string;
last_modified: string;
content_length: number;
}
interface IWebDavConfig { interface IWebDavConfig {
url: string; url: string;
username: string; username: string;