mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 13:30:31 +08:00
* 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
206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|