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

@@ -1,4 +1,5 @@
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import RestoreIcon from "@mui/icons-material/Restore";
import {
Box,
@@ -14,22 +15,20 @@ import {
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 {
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import { restartApp } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
export type BackupFile = IWebDavFile & {
export type BackupFile = {
platform: string;
backup_time: Dayjs;
allow_apply: boolean;
filename: string;
};
export const DEFAULT_ROWS_PER_PAGE = 5;
@@ -43,6 +42,9 @@ interface BackupTableViewerProps {
) => void;
total: number;
onRefresh: () => Promise<void>;
onDelete: (filename: string) => Promise<void>;
onRestore: (filename: string) => Promise<void>;
onExport?: (filename: string, destination: string) => Promise<void>;
}
export const BackupTableViewer = memo(
@@ -52,21 +54,43 @@ export const BackupTableViewer = memo(
onPageChange,
total,
onRefresh,
onDelete,
onRestore,
onExport,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onDelete(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
await onRestore(filename).then(() => {
showNotice("success", t("Restore Success, App will restart in 1s"));
});
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 (
<TableContainer component={Paper}>
<Table>
@@ -102,6 +126,27 @@ export const BackupTableViewer = memo(
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
color="secondary"
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 customParseFormat from "dayjs/plugin/customParseFormat";
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 { 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 {
@@ -14,19 +30,28 @@ import {
BackupTableViewer,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { LocalBackupActions } from "./local-backup-actions";
dayjs.extend(customParseFormat);
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";
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
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 [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [source, setSource] = useState<BackupSource>("local");
useImperativeHandle(ref, () => ({
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 {
setIsLoading(true);
const files = await getAllBackupFiles();
@@ -57,31 +126,109 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
} finally {
setIsLoading(false);
}
};
}, [getAllBackupFiles]);
const getAllBackupFiles = async () => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
useEffect(() => {
if (open) {
fetchAndSetBackupFiles();
const paper = contentRef.current?.closest(".MuiPaper-root");
setDialogPaper((paper as HTMLElement) ?? null);
} else {
setDialogPaper(null);
}
}, [open, fetchAndSetBackupFiles]);
if (fileBackupTimeStr === null) {
return null;
}
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]);
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = true;
return {
...file,
platform,
backup_time: backupTime,
allow_apply: allowApply,
} as BackupFile;
})
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
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<BackupFile[]>(
() =>
@@ -96,33 +243,105 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
<BaseDialog
open={open}
title={t("Backup Setting")}
// contentSx={{ width: 600, maxHeight: 800 }}
okBtn={t("")}
cancelBtn={t("Close")}
contentSx={{
minWidth: { xs: 320, sm: 620 },
maxWidth: "unset",
minHeight: 460,
}}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
disableOk
disableFooter
>
<Box>
<Box
ref={contentRef}
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}>
<BackupConfigViewer
setLoading={setIsLoading}
onBackupSuccess={fetchAndSetBackupFiles}
onSaveSuccess={fetchAndSetBackupFiles}
onRefresh={fetchAndSetBackupFiles}
onInit={fetchAndSetBackupFiles}
/>
<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
setLoading={setIsLoading}
onBackupSuccess={fetchAndSetBackupFiles}
onSaveSuccess={fetchAndSetBackupFiles}
onRefresh={fetchAndSetBackupFiles}
onInit={fetchAndSetBackupFiles}
/>
)}
<Divider sx={{ marginY: 2 }} />
<BackupTableViewer
datasource={dataSource}
page={page}
onPageChange={handleChangePage}
total={total}
onRefresh={fetchAndSetBackupFiles}
/>
<Box
sx={{
flexGrow: 1,
overflow: "auto",
minHeight: 0,
}}
>
<BackupTableViewer
datasource={dataSource}
page={page}
onPageChange={handleChangePage}
total={total}
onRefresh={fetchAndSetBackupFiles}
onDelete={handleDelete}
onRestore={handleRestore}
onExport={source === "local" ? handleExport : undefined}
/>
</Box>
</Paper>
</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>
);
}

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>
);
},
);