mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
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
This commit is contained in:
@@ -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<AutoBackupState>(() => {
|
||||
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<AutoBackupState | null>(
|
||||
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<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const applyPatch = useLockFn(
|
||||
async (
|
||||
partial: Partial<AutoBackupState>,
|
||||
payload: Partial<IVergeConfig>,
|
||||
) => {
|
||||
const nextValues = { ...values, ...partial };
|
||||
setPendingValues(nextValues);
|
||||
try {
|
||||
await patchVerge(payload);
|
||||
} catch (error) {
|
||||
showNotice.error(error);
|
||||
setPendingValues(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const disabled = !verge;
|
||||
|
||||
const handleScheduleToggle = (
|
||||
_: ChangeEvent<HTMLInputElement>,
|
||||
checked: boolean,
|
||||
) => {
|
||||
applyPatch(
|
||||
{ scheduleEnabled: checked },
|
||||
{
|
||||
enable_auto_backup_schedule: checked,
|
||||
auto_backup_interval_hours: values.intervalHours,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeToggle = (
|
||||
_: ChangeEvent<HTMLInputElement>,
|
||||
checked: boolean,
|
||||
) => {
|
||||
applyPatch({ changeEnabled: checked }, { auto_backup_on_change: checked });
|
||||
};
|
||||
|
||||
const handleIntervalInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Fragment>
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={1} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.scheduleLabel")}
|
||||
secondary={t("settings.modals.backup.auto.scheduleHelper")}
|
||||
/>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={values.scheduleEnabled}
|
||||
onChange={handleScheduleToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={2} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.intervalLabel")}
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.modals.backup.auto.intervalLabel")}
|
||||
size="small"
|
||||
type="number"
|
||||
value={intervalInputDraft ?? values.intervalHours.toString()}
|
||||
disabled={scheduleDisabled}
|
||||
onChange={handleIntervalInputChange}
|
||||
onBlur={commitIntervalInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitIntervalInput();
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 160 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{t("shared.units.hours")}
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
htmlInput: {
|
||||
min: MIN_INTERVAL_HOURS,
|
||||
max: MAX_INTERVAL_HOURS,
|
||||
inputMode: "numeric",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
|
||||
<ListItem divider disableGutters>
|
||||
<Stack direction="row" alignItems="center" spacing={1} width="100%">
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.auto.changeLabel")}
|
||||
secondary={t("settings.modals.backup.auto.changeHelper")}
|
||||
/>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={values.changeEnabled}
|
||||
onChange={handleChangeToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<BackupRow[]>([]);
|
||||
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 (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.history.title")}
|
||||
contentSx={{ width: 520 }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={onClose}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={{ position: "relative", minHeight: 320 }}>
|
||||
<BaseLoadingOverlay isLoading={isBusy} />
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Tabs
|
||||
value={source}
|
||||
onChange={(_, val) => {
|
||||
if (isBusy) return;
|
||||
onSourceChange(val as BackupSource);
|
||||
onPageChange(0);
|
||||
}}
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
<Tab
|
||||
value="local"
|
||||
label={t("settings.modals.backup.tabs.local")}
|
||||
disabled={isBusy}
|
||||
sx={{ px: 2 }}
|
||||
/>
|
||||
<Tab
|
||||
value="webdav"
|
||||
label={t("settings.modals.backup.tabs.webdav")}
|
||||
disabled={isBusy}
|
||||
sx={{ px: 2 }}
|
||||
/>
|
||||
</Tabs>
|
||||
<IconButton size="small" onClick={handleRefresh} disabled={isBusy}>
|
||||
<RefreshRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{summary}
|
||||
</Typography>
|
||||
|
||||
<List
|
||||
disablePadding
|
||||
subheader={
|
||||
<ListSubheader disableSticky>
|
||||
{t("settings.modals.backup.history.title")}
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{pagedRows.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t("settings.modals.backup.history.empty") || ""}
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
pagedRows.map((row) => (
|
||||
<ListItem
|
||||
key={`${row.platform}-${row.filename}`}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{isLocal && (
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleExport(row.filename)}
|
||||
>
|
||||
<DownloadRounded fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleDelete(row.filename)}
|
||||
>
|
||||
<DeleteOutline fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleRestore(row.filename)}
|
||||
>
|
||||
<RestoreRounded fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={row.filename}
|
||||
secondary={`${row.platform} · ${row.backup_time.format("YYYY-MM-DD HH:mm")}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography variant="caption">
|
||||
{currentPage + 1} / {pageCount}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={isBusy || currentPage === 0}
|
||||
onClick={() => onPageChange(Math.max(0, currentPage - 1))}
|
||||
>
|
||||
{t("shared.actions.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={isBusy || currentPage >= pageCount - 1}
|
||||
onClick={() =>
|
||||
onPageChange(Math.min(pageCount - 1, currentPage + 1))
|
||||
}
|
||||
>
|
||||
{t("shared.actions.next")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
@@ -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<boolean>;
|
||||
|
||||
// Normalizes synchronous and async confirm implementations.
|
||||
const confirmAsync = async (message: string): Promise<boolean> => {
|
||||
const confirmFn = window.confirm as unknown as ConfirmFn;
|
||||
return await confirmFn.call(window, message);
|
||||
};
|
||||
|
||||
interface BackupTableViewerProps {
|
||||
datasource: BackupFile[];
|
||||
page: number;
|
||||
onPageChange: (
|
||||
event: React.MouseEvent<HTMLButtonElement> | null,
|
||||
page: number,
|
||||
) => 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(
|
||||
({
|
||||
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 (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{t("settings.modals.backup.table.filename")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t("settings.modals.backup.table.backupTime")}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{t("settings.modals.backup.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{datasource.length > 0 ? (
|
||||
datasource.map((file) => {
|
||||
const rowKey = `${file.platform}-${file.filename}-${file.backup_time.valueOf()}`;
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell component="th" scope="row">
|
||||
{file.platform === "windows" ? (
|
||||
<WindowsIcon className="h-full w-full" />
|
||||
) : file.platform === "linux" ? (
|
||||
<LinuxIcon className="h-full w-full" />
|
||||
) : (
|
||||
<MacIcon className="h-full w-full" />
|
||||
)}
|
||||
{file.filename}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{file.backup_time.fromNow()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{onExport && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t(
|
||||
"settings.modals.backup.actions.export",
|
||||
)}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.exportBackup",
|
||||
)}
|
||||
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("shared.actions.delete")}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.deleteBackup",
|
||||
)}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await confirmAsync(
|
||||
t(
|
||||
"settings.modals.backup.messages.confirmDelete",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t(
|
||||
"settings.modals.backup.actions.restore",
|
||||
)}
|
||||
size="small"
|
||||
title={t(
|
||||
"settings.modals.backup.actions.restoreBackup",
|
||||
)}
|
||||
disabled={!file.allow_apply}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const confirmed = await confirmAsync(
|
||||
t(
|
||||
"settings.modals.backup.messages.confirmRestore",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleRestore(file.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RestoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center">
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 150,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="textSecondary"
|
||||
align="center"
|
||||
>
|
||||
{t("settings.modals.backup.table.noBackups")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[]}
|
||||
component="div"
|
||||
count={total}
|
||||
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
|
||||
page={page}
|
||||
onPageChange={onPageChange}
|
||||
labelRowsPerPage={t("settings.modals.backup.table.rowsPerPage")}
|
||||
/>
|
||||
</TableContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function LinuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 48 48"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#ECEFF1"
|
||||
d="m20.1 16.2l.1 2.3l-1.6 3l-2.5 4.9l-.5 4.1l1.8 5.8l4.1 2.3h6.2l5.8-4.4l2.6-6.9l-6-7.3l-1.7-4.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#263238"
|
||||
d="M34.3 21.9c-1.6-2.3-2.9-3.7-3.6-6.6s.2-2.1-.4-4.6c-.3-1.3-.8-2.2-1.3-2.9c-.6-.7-1.3-1.1-1.7-1.2c-.9-.5-3-1.3-5.6.1c-2.7 1.4-2.4 4.4-1.9 10.5c0 .4-.1.9-.3 1.3c-.4.9-1.1 1.7-1.7 2.4c-.7 1-1.4 2-1.9 3.1c-1.2 2.3-2.3 5.2-2 6.3c.5-.1 6.8 9.5 6.8 9.7c.4-.1 2.1-.1 3.6-.1c2.1-.1 3.3-.2 5 .2c0-.3-.1-.6-.1-.9c0-.6.1-1.1.2-1.8c.1-.5.2-1 .3-1.6c-1 .9-2.8 1.9-4.5 2.2c-1.5.3-4-.2-5.2-1.7c.1 0 .3 0 .4-.1c.3-.1.6-.2.7-.4c.3-.5.1-1-.1-1.3s-1.7-1.4-2.4-2s-1.1-.9-1.5-1.3l-.8-.8c-.2-.2-.3-.4-.4-.5c-.2-.5-.3-1.1-.2-1.9c.1-1.1.5-2 1-3c.2-.4.7-1.2.7-1.2s-1.7 4.2-.8 5.5c0 0 .1-1.3.5-2.6c.3-.9.8-2.2 1.4-2.9s2.1-3.3 2.2-4.9c0-.7.1-1.4.1-1.9c-.4-.4 6.6-1.4 7-.3c.1.4 1.5 4 2.3 5.9c.4.9.9 1.7 1.2 2.7c.3 1.1.5 2.6.5 4.1c0 .3 0 .8-.1 1.3c.2 0 4.1-4.2-.5-7.7c0 0 2.8 1.3 2.9 3.9c.1 2.1-.8 3.8-1 4.1c.1 0 2.1.9 2.2.9c.4 0 1.2-.3 1.2-.3c.1-.3.4-1.1.4-1.4c.7-2.3-1-6-2.6-8.3"
|
||||
/>
|
||||
<g fill="#ECEFF1" transform="translate(0 -2)">
|
||||
<ellipse cx="21.6" cy="15.3" rx="1.3" ry="2" />
|
||||
<ellipse cx="26.1" cy="15.2" rx="1.7" ry="2.3" />
|
||||
</g>
|
||||
<g fill="#212121" transform="translate(0 -2)">
|
||||
<ellipse
|
||||
cx="21.7"
|
||||
cy="15.5"
|
||||
rx="1.2"
|
||||
ry=".7"
|
||||
transform="rotate(-97.204 21.677 15.542)"
|
||||
/>
|
||||
<ellipse cx="26" cy="15.6" rx="1" ry="1.3" />
|
||||
</g>
|
||||
<path
|
||||
fill="#FFC107"
|
||||
d="M39.3 35.6c-.4-.2-1.1-.5-1.7-1.4c-.3-.5-.2-1.9-.7-2.5c-.3-.4-.7-.2-.8-.2c-.9.2-3 1.6-4.4 0c-.2-.2-.5-.5-1-.5s-.7.2-.9.6s-.2.7-.2 1.7c0 .8 0 1.7-.1 2.4c-.2 1.7-.5 2.7-.5 3.7c0 1.1.3 1.8.7 2.1c.3.3.8.5 1.9.5s1.8-.4 2.5-1.1c.5-.5.9-.7 2.3-1.7c1.1-.7 2.8-1.6 3.1-1.9c.2-.2.5-.3.5-.9c0-.5-.4-.7-.7-.8m-20.1.3c-1-1.6-1.1-1.9-1.8-2.9c-.6-1-1.9-2.9-2.7-2.9c-.6 0-.9.3-1.3.7s-.8 1.3-1.5 1.8c-.6.5-2.3.4-2.7 1s.4 1.5.4 3c0 .6-.5 1-.6 1.4c-.1.5-.2.8 0 1.2c.4.6.9.8 4.3 1.5c1.8.4 3.5 1.4 4.6 1.5s3 0 3-2.7c.1-1.6-.8-2-1.7-3.6m1.9-18.1c-.6-.4-1.1-.8-1.1-1.4s.4-.8 1-1.3c.1-.1 1.2-1.1 2.3-1.1s2.4.7 2.9.9c.9.2 1.8.4 1.7 1.1c-.1 1-.2 1.2-1.2 1.7c-.7.2-2 1.3-2.9 1.3c-.4 0-1 0-1.4-.1c-.3-.1-.8-.6-1.3-1.1"
|
||||
/>
|
||||
<path
|
||||
fill="#634703"
|
||||
d="M20.9 17c.2.2.5.4.8.5c.2.1.5.2.5.2h.9c.5 0 1.2-.2 1.9-.6c.7-.3.8-.5 1.3-.7c.5-.3 1-.6.8-.7s-.4 0-1.1.4c-.6.4-1.1.6-1.7.9c-.3.1-.7.3-1 .3h-.9c-.3 0-.5-.1-.8-.2c-.2-.1-.3-.2-.4-.2c-.2-.1-.6-.5-.8-.6c0 0-.2 0-.1.1zm3-2.2c.1.2.3.2.4.3s.2.1.2.1c.1-.1 0-.3-.1-.3c0-.2-.5-.2-.5-.1m-1.6.2c0 .1.2.2.2.1c.1-.1.2-.2.3-.2c.2-.1.1-.2-.2-.2c-.2.1-.2.2-.3.3"
|
||||
/>
|
||||
<path
|
||||
fill="#455A64"
|
||||
d="M32 32.7v.3c.2.4.7.5 1.1.5c.6 0 1.2-.4 1.5-.8c0-.1.1-.2.2-.3c.2-.3.3-.5.4-.6c0 0-.1-.1-.1-.2c-.1-.2-.4-.4-.8-.5c-.3-.1-.8-.2-1-.2c-.9-.1-1.4.2-1.7.5c0 0 .1 0 .1.1c.2.2.3.4.3.7c.1.2 0 .3 0 .5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 16 16"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#0284c7"
|
||||
d="M6.555 1.375L0 2.237v5.45h6.555zM0 13.795l6.555.933V8.313H0zm7.278-5.4l.026 6.378L16 16V8.395zM16 0L7.33 1.244v6.414H16z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MacIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 26 26"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M23.934 18.947c-.598 1.324-.884 1.916-1.652 3.086c-1.073 1.634-2.588 3.673-4.461 3.687c-1.666.014-2.096-1.087-4.357-1.069c-2.261.011-2.732 1.089-4.4 1.072c-1.873-.017-3.307-1.854-4.381-3.485c-3.003-4.575-3.32-9.937-1.464-12.79C4.532 7.425 6.61 6.237 8.561 6.237c1.987 0 3.236 1.092 4.879 1.092c1.594 0 2.565-1.095 4.863-1.095c1.738 0 3.576.947 4.889 2.581c-4.296 2.354-3.598 8.49.742 10.132M16.559 4.408c.836-1.073 1.47-2.587 1.24-4.131c-1.364.093-2.959.964-3.891 2.092c-.844 1.027-1.544 2.553-1.271 4.029c1.488.048 3.028-.839 3.922-1.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<DialogRef> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(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<BackupFile[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [source, setSource] = useState<BackupSource>("local");
|
||||
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);
|
||||
},
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = useCallback(
|
||||
(_: React.MouseEvent<HTMLButtonElement> | 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<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 () => {
|
||||
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<BackupFile[]>(
|
||||
() =>
|
||||
backupFiles.slice(
|
||||
page * DEFAULT_ROWS_PER_PAGE,
|
||||
page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE,
|
||||
),
|
||||
[backupFiles, page],
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.title")}
|
||||
contentSx={{
|
||||
minWidth: { xs: 320, sm: 620 },
|
||||
maxWidth: "unset",
|
||||
minHeight: 460,
|
||||
}}
|
||||
contentSx={{ width: { xs: 360, sm: 520 } }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={() => setOpen(false)}
|
||||
onClose={() => setOpen(false)}
|
||||
disableFooter
|
||||
>
|
||||
<Box
|
||||
ref={contentRef}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<BaseLoadingOverlay isLoading={isLoading} />
|
||||
<Paper
|
||||
elevation={2}
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={source}
|
||||
onChange={handleChangeSource}
|
||||
aria-label={t("settings.modals.backup.actions.selectTarget")}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab value="local" label={t("settings.modals.backup.tabs.local")} />
|
||||
<Tab
|
||||
value="webdav"
|
||||
label={t("settings.modals.backup.tabs.webdav")}
|
||||
/>
|
||||
</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 }} />
|
||||
<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("shared.actions.close")}
|
||||
</Button>
|
||||
</Box>,
|
||||
dialogPaper,
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("settings.modals.backup.webdav.title")}
|
||||
contentSx={{ width: { xs: 360, sm: 520 } }}
|
||||
disableOk
|
||||
cancelBtn={t("shared.actions.close")}
|
||||
onCancel={onClose}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<BaseLoadingOverlay isLoading={loading} />
|
||||
<BackupConfigViewer
|
||||
setLoading={handleLoading}
|
||||
onBackupSuccess={async () => {
|
||||
await refreshSilently();
|
||||
onBackupSuccess?.();
|
||||
}}
|
||||
onSaveSuccess={refreshSilently}
|
||||
onRefresh={refreshWebdav}
|
||||
onInit={refreshSilently}
|
||||
/>
|
||||
</Box>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
@@ -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<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(
|
||||
"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 (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 9 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("settings.modals.backup.fields.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("settings.modals.backup.actions.backup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
type="button"
|
||||
size="large"
|
||||
>
|
||||
{t("shared.actions.refresh")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user