mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
@@ -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,
|
||||
|
||||
+59
@@ -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");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user