fix(backup): remember the last backup directory

Closes #11317
This commit is contained in:
高桥凉介
2026-10-09 14:58:43 +08:00
committed by GitHub
parent 4ad6410ca3
commit 9f4e5643ce
4 changed files with 150 additions and 3 deletions
@@ -33,12 +33,15 @@ import {
type DatabaseBackupSchedule,
} from "@/lib/backup/scheduledDatabaseBackup";
import { databaseBackupTableSelectionScopeKey, normalizeDatabaseBackupTableTargets, type DatabaseBackupTableSelectionState } from "@/lib/backup/scheduledDatabaseBackup";
import { getLastBackupDirectory, setLastBackupDirectory } from "@/lib/export/exportPath";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, heartbeat, destinationRoot, error: backupError, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const desktop = isTauriRuntime();
const backgroundEnabled = ref(false);
@@ -155,13 +158,26 @@ watch(historyConnectionPickerOpen, (open) => {
if (!open) historyConnectionSearch.value = "";
});
/**
* 解析新建备份时的默认「备份目录」。
* 优先级:服务端根目录(Web 端) > 上次备份目录 > 设置中的「首选导出路径」。
* Desktop 端后端 root 恒为 null(见 src-tauri/background_backup.rs),
* 此前默认值恒为空串,导致每次新建备份都要重新选择目录(issue #11317)。
*/
function defaultBackupDestinationDirectory(): string {
if (destinationRoot.value) return destinationRoot.value;
const remembered = getLastBackupDirectory();
if (remembered) return remembered;
return settingsStore.editorSettings.preferredExportPath?.trim() || "";
}
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
return {
connectionId,
databases: [],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: destinationRoot.value || "",
destinationDirectory: defaultBackupDestinationDirectory(),
includeStructure: true,
includeData: true,
includeObjects: true,
@@ -455,8 +471,13 @@ function toggleDatabase(database: string) {
async function chooseDestination() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
if (typeof selected === "string") activeDraft.value.destinationDirectory = selected;
// 传入当前目录,让系统选择框直接定位到上次使用的位置(issue #11317)
const selected = await open({ directory: true, multiple: false, defaultPath: activeDraft.value.destinationDirectory || undefined, title: t("databaseBackup.selectDestination") });
if (typeof selected === "string") {
activeDraft.value.destinationDirectory = selected;
// 记住本次选择,作为下次新建备份的默认目录
setLastBackupDirectory(selected);
}
}
async function submitSchedule() {
@@ -464,6 +485,7 @@ async function submitSchedule() {
saving.value = true;
try {
if (desktop) await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
if (desktop) setLastBackupDirectory(draft.value.destinationDirectory);
await saveSchedule({
...draft.value,
databases: allDatabases.value ? [] : [...selectedDatabases.value],
@@ -492,6 +514,7 @@ async function startOneShotBackup() {
oneShotStarting.value = true;
try {
if (desktop) await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
if (desktop) setLastBackupDirectory(oneShotDraft.value.destinationDirectory);
const run = await runOneShot(
{
...oneShotDraft.value,
@@ -5,9 +5,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "../../../i18n";
import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.vue";
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
import { LAST_BACKUP_DIRECTORY_STORAGE_KEY } from "../../../lib/export/exportPath";
const mocks = vi.hoisted(() => ({
desktop: true,
preferredExportPath: "",
sqlFileSource: null as any,
prepareDatabaseBackupRestore: vi.fn(),
connections: [] as Array<{ id: string; name: string; db_type: string }>,
@@ -52,6 +54,10 @@ vi.mock("@/stores/connectionStore", () => ({
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { preferredExportPath: mocks.preferredExportPath } }),
}));
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
schedules: { __v_isRef: true, value: mocks.schedules },
@@ -140,6 +146,12 @@ function currentDialog(): HTMLElement {
return dialog;
}
function destinationInput(): HTMLInputElement {
const input = currentDialog().querySelector<HTMLInputElement>(".backup-destination-field input");
if (!input) throw new Error("Backup destination input not found");
return input;
}
async function selectDialogOption(triggerIndex: number, optionText: string) {
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
@@ -279,6 +291,9 @@ afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
mocks.desktop = true;
mocks.preferredExportPath = "";
// 上次备份目录存于 localStorage,需在用例间清理,避免互相污染
window.localStorage.clear();
mocks.sqlFileSource = null;
mocks.prepareDatabaseBackupRestore.mockReset();
mocks.connections.splice(0);
@@ -607,6 +622,50 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.runOneShot).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "mysql-1", destinationDirectory: "/backups", databases: [] }), String(i18n.global.t("databaseBackup.oneShotName")));
});
it("defaults a new backup directory to the last used backup directory", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
window.localStorage.setItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY, "/previous/backups");
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
expect(destinationInput().value).toBe("/previous/backups");
});
it("falls back to the preferred export path when no backup directory was used yet", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.preferredExportPath = "/preferred/exports";
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
expect(destinationInput().value).toBe("/preferred/exports");
});
it("opens the picker at the current directory and remembers the choice", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
window.localStorage.setItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY, "/previous/backups");
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/previous/backups" }));
expect(window.localStorage.getItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY)).toBe("/backups");
// 重新挂载后再次打开「立即备份」,默认目录应为上次选择的 /backups
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
expect(destinationInput().value).toBe("/backups");
});
it("sends an exact one-shot table whitelist and prevents an empty selection from starting", async () => {
mocks.connections.push({ id: "pg-1", name: "Local PostgreSQL", db_type: "postgres" });
await mountSettings();
@@ -4,16 +4,20 @@ import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
autoRevealExportedPathIfConfigured,
clearLastBackupDirectory,
clearLastExportDirectory,
getLastBackupDirectory,
getLastExportDirectory,
getParentDirectory,
isAbsolutePath,
joinExportPath,
LAST_BACKUP_DIRECTORY_STORAGE_KEY,
LAST_EXPORT_DIRECTORY_STORAGE_KEY,
promptExportSavePath,
rememberLastExportPath,
resolveExportDefaultPath,
revealExportedPath,
setLastBackupDirectory,
setLastExportDirectory,
} from "../exportPath";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -141,6 +145,32 @@ describe("exportPath", () => {
});
});
describe("backup directory storage", () => {
it("reads empty string when no backup directory is stored", () => {
expect(getLastBackupDirectory()).toBe("");
});
it("stores, retrieves and clears the last backup directory", () => {
setLastBackupDirectory("/mnt/backups");
expect(getLastBackupDirectory()).toBe("/mnt/backups");
expect(localStorage.getItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY)).toBe("/mnt/backups");
clearLastBackupDirectory();
expect(getLastBackupDirectory()).toBe("");
});
it("ignores blank values and stays independent from the export directory", () => {
setLastExportDirectory("/export/dir");
setLastBackupDirectory(" ");
expect(getLastBackupDirectory()).toBe("");
setLastBackupDirectory("/backup/dir");
expect(getLastExportDirectory()).toBe("/export/dir");
expect(localStorage.getItem(LAST_EXPORT_DIRECTORY_STORAGE_KEY)).toBe("/export/dir");
expect(localStorage.getItem(LAST_BACKUP_DIRECTORY_STORAGE_KEY)).toBe("/backup/dir");
});
});
describe("resolveExportDefaultPath", () => {
it("returns filename unchanged if already absolute", () => {
setLastExportDirectory("/last/dir");
+35
View File
@@ -57,6 +57,41 @@ export function clearLastExportDirectory(): void {
}
}
/**
* 数据库备份专用 key。备份目录通常和「数据导出」目录不同(常是挂载盘或专用备份盘),
* 因此与 LAST_EXPORT_DIRECTORY_STORAGE_KEY 分开存储,避免两者互相覆盖。
*/
export const LAST_BACKUP_DIRECTORY_STORAGE_KEY = "dbx-last-backup-directory";
/** 读取上次使用过的数据库备份目录,未记录时返回空串。 */
export function getLastBackupDirectory(): string {
try {
return safeLocalStorageGet(LAST_BACKUP_DIRECTORY_STORAGE_KEY)?.trim() || "";
} catch {
return "";
}
}
/** 记住数据库备份目录,供下次新建备份时作为默认值(issue #11317)。 */
export function setLastBackupDirectory(directory: string): void {
const trimmed = directory.trim();
if (!trimmed) return;
try {
safeLocalStorageSet(LAST_BACKUP_DIRECTORY_STORAGE_KEY, trimmed);
} catch {
// Ignore storage write errors
}
}
/** 清除已记录的备份目录。 */
export function clearLastBackupDirectory(): void {
try {
safeLocalStorageRemove(LAST_BACKUP_DIRECTORY_STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
export function resolveExportDefaultPath(fileName: string, options?: { preferredPath?: string }): string {
const trimmedName = fileName.trim();
if (isAbsolutePath(trimmedName)) {