Sline 838e401796
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
2025-11-10 13:49:14 +08:00

214 lines
7.0 KiB
TypeScript

import { LoadingButton } from "@mui/lab";
import {
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, DialogRef } from "@/components/base";
import { createLocalBackup, createWebdavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { AutoBackupSettings } from "./auto-backup-settings";
import { BackupHistoryViewer } from "./backup-history-viewer";
import { BackupWebdavDialog } from "./backup-webdav-dialog";
type BackupSource = "local" | "webdav";
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [busyAction, setBusyAction] = useState<BackupSource | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historySource, setHistorySource] = useState<BackupSource>("local");
const [historyPage, setHistoryPage] = useState(0);
const [webdavDialogOpen, setWebdavDialogOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const openHistory = (target: BackupSource) => {
setHistorySource(target);
setHistoryPage(0);
setHistoryOpen(true);
};
const handleBackup = useLockFn(async (target: BackupSource) => {
try {
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) {
console.error(error);
showNotice.error(
target === "local"
? "settings.modals.backup.messages.localBackupFailed"
: "settings.modals.backup.messages.backupFailed",
target === "local" ? undefined : { error },
);
} finally {
setBusyAction(null);
}
});
return (
<BaseDialog
open={open}
title={t("settings.modals.backup.title")}
contentSx={{ width: { xs: 360, sm: 520 } }}
disableOk
cancelBtn={t("shared.actions.close")}
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
>
<Stack spacing={2}>
<Stack
spacing={1}
sx={{
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 2,
p: 2,
}}
>
<Typography variant="subtitle1">
{t("settings.modals.backup.auto.title")}
</Typography>
<List disablePadding sx={{ ".MuiListItem-root": { px: 0 } }}>
<AutoBackupSettings />
</List>
</Stack>
<Stack
spacing={1}
sx={{
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 2,
p: 2,
}}
>
<Typography variant="subtitle1">
{t("settings.modals.backup.manual.title")}
</Typography>
<List disablePadding sx={{ ".MuiListItem-root": { px: 0 } }}>
{(
[
{
key: "local" as BackupSource,
title: t("settings.modals.backup.tabs.local"),
description: t("settings.modals.backup.manual.local"),
actions: [
<LoadingButton
key="backup"
variant="contained"
size="small"
loading={busyAction === "local"}
onClick={() => handleBackup("local")}
>
{t("settings.modals.backup.actions.backup")}
</LoadingButton>,
<Button
key="history"
variant="outlined"
size="small"
onClick={() => openHistory("local")}
>
{t("settings.modals.backup.actions.viewHistory")}
</Button>,
],
},
{
key: "webdav" as BackupSource,
title: t("settings.modals.backup.tabs.webdav"),
description: t("settings.modals.backup.manual.webdav"),
actions: [
<LoadingButton
key="backup"
variant="contained"
size="small"
loading={busyAction === "webdav"}
onClick={() => handleBackup("webdav")}
>
{t("settings.modals.backup.actions.backup")}
</LoadingButton>,
<Button
key="history"
variant="outlined"
size="small"
onClick={() => openHistory("webdav")}
>
{t("settings.modals.backup.actions.viewHistory")}
</Button>,
<Button
key="configure"
variant="text"
size="small"
onClick={() => setWebdavDialogOpen(true)}
>
{t("settings.modals.backup.manual.configureWebdav")}
</Button>,
],
},
] satisfies Array<{
key: BackupSource;
title: string;
description: string;
actions: ReactNode[];
}>
).map((item, idx) => (
<ListItem key={item.key} disableGutters divider={idx === 0}>
<Stack spacing={1} sx={{ width: "100%" }}>
<ListItemText
primary={item.title}
slotProps={{ secondary: { component: "span" } }}
secondary={item.description}
/>
<Stack
direction="row"
spacing={1}
useFlexGap
flexWrap="wrap"
alignItems="center"
>
{item.actions}
</Stack>
</Stack>
</ListItem>
))}
</List>
</Stack>
</Stack>
<BackupHistoryViewer
open={historyOpen}
source={historySource}
page={historyPage}
onSourceChange={setHistorySource}
onPageChange={setHistoryPage}
onClose={() => setHistoryOpen(false)}
/>
<BackupWebdavDialog
open={webdavDialogOpen}
onClose={() => setWebdavDialogOpen(false)}
onBackupSuccess={() => openHistory("webdav")}
setBusy={(loading) => setBusyAction(loading ? "webdav" : null)}
/>
</BaseDialog>
);
}