From 9f4e5643ceac422ea4a802e40f7628b690c28281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E6=A1=A5=E5=87=89=E4=BB=8B?= <113153113+HighBigSky@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:58:43 +0800 Subject: [PATCH] fix(backup): remember the last backup directory Closes #11317 --- .../ScheduledDatabaseBackupSettings.vue | 29 ++++++++- .../ScheduledDatabaseBackupSettings.spec.ts | 59 +++++++++++++++++++ .../lib/export/__tests__/exportPath.spec.ts | 30 ++++++++++ apps/desktop/src/lib/export/exportPath.ts | 35 +++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index efaaddee36..0cba04cb70 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -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, diff --git a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts index 3e04eb0e8b..21fee4adcb 100644 --- a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts +++ b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts @@ -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(".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('[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(); diff --git a/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts b/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts index 0affd3d75c..613155cf71 100644 --- a/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts +++ b/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/export/exportPath.ts b/apps/desktop/src/lib/export/exportPath.ts index e118f61431..6633c85695 100644 --- a/apps/desktop/src/lib/export/exportPath.ts +++ b/apps/desktop/src/lib/export/exportPath.ts @@ -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)) {