feat(backup): add local backup import flow (#5669)

* feat(backup): add local backup import flow

* refactor(backup): robustify history listing and propagate import error details

* docs: Changelog.md
This commit is contained in:
Sline
2025-12-19 17:46:31 +08:00
committed by GitHub
parent b4e25951b4
commit bd8eccdcea
22 changed files with 240 additions and 29 deletions
@@ -57,7 +57,9 @@ interface BackupHistoryViewerProps {
interface BackupRow {
filename: string;
platform: string;
backup_time: dayjs.Dayjs;
backup_time: dayjs.Dayjs | null;
display_time: string;
sort_value: number;
}
const confirmAsync = async (message: string) => {
@@ -86,16 +88,44 @@ export const BackupHistoryViewer = ({
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 buildRow = useCallback(
(item: ILocalBackupFile | IWebDavFile): BackupRow | null => {
const { filename, last_modified } = item;
if (!filename.toLowerCase().endsWith(".zip")) return null;
const platform =
(filename.includes("-") && filename.split("-")[0]) ||
t("settings.modals.backup.history.unknownPlatform", {
defaultValue: "unknown",
});
const match = filename.match(FILENAME_PATTERN);
const parsedFromName = match ? dayjs(match[0], DATE_FORMAT, true) : null;
const parsedFromModified =
last_modified && dayjs(last_modified).isValid()
? dayjs(last_modified)
: null;
const backupTime = parsedFromName?.isValid()
? parsedFromName
: parsedFromModified;
return {
filename,
platform,
backup_time: backupTime ?? null,
display_time:
backupTime?.format("YYYY-MM-DD HH:mm") ??
parsedFromModified?.format("YYYY-MM-DD HH:mm") ??
t("settings.modals.backup.history.unknownTime", {
defaultValue: "Unknown time",
}),
sort_value:
backupTime?.valueOf() ??
parsedFromModified?.valueOf() ??
Number.NEGATIVE_INFINITY,
};
},
[t],
);
const fetchRows = useCallback(async () => {
if (!open) return;
@@ -108,9 +138,13 @@ export const BackupHistoryViewer = ({
const list = isLocal ? await listLocalBackup() : await listWebDavBackup();
setRows(
list
.map((item) => buildRow(item.filename))
.map((item) => buildRow(item))
.filter((item): item is BackupRow => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)),
.sort((a, b) =>
a.sort_value === b.sort_value
? b.filename.localeCompare(a.filename)
: b.sort_value - a.sort_value,
),
);
} catch (error) {
console.error(error);
@@ -138,7 +172,8 @@ export const BackupHistoryViewer = ({
return t("settings.modals.backup.manual.webdav");
}
if (!total) return t("settings.modals.backup.history.empty");
const recent = rows[0]?.backup_time.fromNow();
const recent =
rows[0]?.backup_time?.fromNow() ?? rows[0]?.display_time ?? "";
return t("settings.modals.backup.history.summary", {
count: total,
recent,
@@ -283,7 +318,7 @@ export const BackupHistoryViewer = ({
spacing={1.5}
>
<Typography variant="caption" color="text.secondary">
{`${row.platform} · ${row.backup_time.format("YYYY-MM-DD HH:mm")}`}
{`${row.platform} · ${row.display_time}`}
</Typography>
<Stack
direction="row"
+43 -1
View File
@@ -7,13 +7,18 @@ import {
Stack,
Typography,
} from "@mui/material";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { useLockFn } from "ahooks";
import type { ReactNode, Ref } from "react";
import { useCallback, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import { createLocalBackup, createWebdavBackup } from "@/services/cmds";
import {
createLocalBackup,
createWebdavBackup,
importLocalBackup,
} from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { AutoBackupSettings } from "./auto-backup-settings";
@@ -26,6 +31,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [busyAction, setBusyAction] = useState<BackupSource | null>(null);
const [localImporting, setLocalImporting] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historySource, setHistorySource] = useState<BackupSource>("local");
const [historyPage, setHistoryPage] = useState(0);
@@ -67,6 +73,28 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
});
const handleImport = useLockFn(async () => {
const selected = await openDialog({
multiple: false,
filters: [{ name: "Backup File", extensions: ["zip"] }],
});
if (!selected || Array.isArray(selected)) return;
try {
setLocalImporting(true);
await importLocalBackup(selected);
showNotice.success("settings.modals.backup.messages.localBackupImported");
openHistory("local");
} catch (error) {
console.error(error);
showNotice.error(
"settings.modals.backup.messages.localBackupImportFailed",
{ error },
);
} finally {
setLocalImporting(false);
}
});
const setWebdavBusy = useCallback(
(loading: boolean) => {
setBusyAction(loading ? "webdav" : null);
@@ -74,6 +102,8 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
[setBusyAction],
);
const isLocalBusy = busyAction === "local" || localImporting;
return (
<BaseDialog
open={open}
@@ -125,6 +155,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
variant="contained"
size="small"
loading={busyAction === "local"}
disabled={localImporting}
onClick={() => handleBackup("local")}
>
{t("settings.modals.backup.actions.backup")}
@@ -133,10 +164,21 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
key="history"
variant="outlined"
size="small"
disabled={isLocalBusy}
onClick={() => openHistory("local")}
>
{t("settings.modals.backup.actions.viewHistory")}
</Button>,
<LoadingButton
key="import"
variant="text"
size="small"
loading={localImporting}
disabled={busyAction === "local"}
onClick={() => handleImport()}
>
{t("settings.modals.backup.actions.importBackup")}
</LoadingButton>,
],
},
{