fix(export): use real file name and reveal for background table-export tasks

This commit is contained in:
一颗红心 2026-08-07 15:32:16 +08:00 committed by GitHub
parent 8fb7559310
commit 9893ac8734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 253 additions and 6 deletions

View File

@ -47,6 +47,16 @@ const emit = defineEmits<{
}>();
const translatedErrorMessage = computed(() => (props.errorMessage ? translateBackendError(t, props.errorMessage) : ""));
// Prefer the real saved file name from the save dialog path; the synthetic
// "Query Result" style label is only a fallback when no path is available.
const displayName = computed(() => {
const filePath = props.filePath?.trim() ?? "";
if (filePath) {
const segments = filePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
if (segments.length > 0) return segments[segments.length - 1];
}
return `${props.tableName} (.${props.format})`;
});
const isActive = computed(() => props.status === "Running" || props.status === "Writing");
const isFinished = computed(() => props.status === "Done" || props.status === "Error" || props.status === "Cancelled");
const canRevealFile = computed(() => props.status === "Done" && !!props.filePath && isTauriRuntime());
@ -96,8 +106,8 @@ async function revealExportFile() {
</DialogHeader>
<div class="py-4 space-y-4">
<!-- Table name and format info -->
<div class="text-sm text-muted-foreground">{{ tableName }} (.{{ format }})</div>
<!-- Real saved file name (falls back to table name and format) -->
<div class="text-sm text-muted-foreground" :title="filePath || undefined">{{ displayName }}</div>
<!-- Progress bar -->
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">

View File

@ -3,15 +3,20 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight } from "@lucide/vue";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight, FolderOpen } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
const { toast } = useToast();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
const open = ref(false);
const showAll = ref(false);
const expandedFailureTaskIds = ref<string[]>([]);
const revealingTaskIds = ref<string[]>([]);
const currentTime = ref(Date.now());
const MAX_VISIBLE = 5;
@ -67,14 +72,27 @@ const progressValue = (task: ExportTask) => {
return progressPercent(task.totalRows, task.rowsExported);
};
// The save dialog path is what the user recognizes the export by, so the real
// saved file name beats the synthetic "Query Result.xlsx" style label.
const taskFileName = (task: ExportTask) => {
const filePath = task.filePath.trim();
if (!filePath) return "";
const segments = filePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
return segments.length > 0 ? segments[segments.length - 1] : "";
};
const taskTitle = (task: ExportTask) => {
// database-export and sql-file keep their task labels on purpose: the former
// names the database (filePath may be a directory), the latter already names
// the executed script. Only table-export synthesizes a misleading
// "Query Result.xlsx" style name, so it prefers the real saved file name.
if (task.kind === "database-export") {
const key = task.databaseExportSource === "scheduled" ? "exportProgress.databaseBackupTitle" : "exportProgress.databaseExportTitle";
return t(key, { name: task.tableName });
}
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
return `${task.tableName}.${task.format}`;
return taskFileName(task) || `${task.tableName}.${task.format}`;
};
const rowsText = (task: ExportTask) => {
@ -169,6 +187,23 @@ function toggleShowAll() {
showAll.value = !showAll.value;
}
// Reveal is offered only for tasks that produce one local output file.
// sql-file filePath can be a "; "-joined list of input scripts (not an
// output), and data-transfer has no local file at all.
const canRevealTaskFile = (task: ExportTask) => (task.kind === "table-export" || task.kind === "database-export") && task.status === "Done" && !!task.filePath && isTauriRuntime();
async function revealTaskFile(task: ExportTask) {
if (!canRevealTaskFile(task) || revealingTaskIds.value.includes(task.exportId)) return;
revealingTaskIds.value = [...revealingTaskIds.value, task.exportId];
try {
await api.revealPathInFileManager(task.filePath);
} catch (error) {
toast(t("exportProgress.openFolderFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
revealingTaskIds.value = revealingTaskIds.value.filter((id) => id !== task.exportId);
}
}
function failureDetailsExpanded(exportId: string) {
return expandedFailureTaskIds.value.includes(exportId);
}
@ -204,7 +239,7 @@ function failureDetailCount(task: ExportTask) {
<div class="flex-1 min-w-0 flex flex-col gap-1.5">
<div class="flex items-center gap-1.5">
<component :is="statusIcon(task)" :class="[statusColor(task.status), task.kind === 'table-export' && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate font-medium">{{ taskTitle(task) }}</span>
<span class="truncate font-medium" :title="task.filePath || undefined">{{ taskTitle(task) }}</span>
</div>
<!-- Progress bar -->
@ -249,8 +284,11 @@ function failureDetailCount(task: ExportTask) {
</div>
</div>
<!-- Actions: stop/cancel for active, delete for finished -->
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
<div class="flex shrink-0 pt-4">
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.cancel')" @click="cancelTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>

View File

@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
setup(_props, { slots }) {
return () => h("button", slots.default?.());
},
}),
};
});
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.restoreAllMocks();
});
async function mountDialog(props: Record<string, unknown>) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () => h(ExportProgressDialog, props);
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
}
const baseProps = {
open: true,
title: "Export Table Data",
tableName: "Query Result",
format: "sql",
rowsExported: 10,
totalRows: 10,
status: "Done",
errorMessage: null,
};
describe("ExportProgressDialog display name", () => {
it("shows the real saved file name instead of the synthetic table label", async () => {
i18n.global.locale.value = "en";
await mountDialog({ ...baseProps, filePath: "C:\\exports\\自定义.sql" });
expect(document.body.textContent).toContain("自定义.sql");
expect(document.body.textContent).not.toContain("Query Result (.sql)");
const titleHolder = [...document.body.querySelectorAll("[title]")].find((el) => el.getAttribute("title") === "C:\\exports\\自定义.sql");
expect(titleHolder).toBeTruthy();
});
it("falls back to the table label when no file path is available", async () => {
i18n.global.locale.value = "en";
await mountDialog({ ...baseProps, tableName: "audit_log", format: "csv", filePath: null });
expect(document.body.textContent).toContain("audit_log (.csv)");
});
});

View File

@ -25,8 +25,17 @@ vi.mock("@/components/ui/button", async () => {
};
});
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/lib/backend/api", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/backend/api")>();
return { ...original, revealPathInFileManager: vi.fn() };
});
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import { useExportTracker } from "@/composables/useExportTracker";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
const mountedApps: App[] = [];
let now = 0;
@ -250,3 +259,111 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).not.toContain("failure 100");
});
});
describe("ExportProgressPopover file name and reveal action", () => {
it("shows the saved file name instead of the synthetic query-result label", async () => {
const tracker = useExportTracker();
tracker.addTask("Query Result", "sql", "C:\\exports\\自定义.sql");
tracker.addTask("Query Result", "xlsx", "/home/dbx/downloads/report-final.xlsx");
await mountPopover();
expect(document.body.textContent).toContain("自定义.sql");
expect(document.body.textContent).toContain("report-final.xlsx");
expect(document.body.textContent).not.toContain("Query Result.sql");
expect(document.body.textContent).not.toContain("Query Result.xlsx");
});
it("falls back to the table label when the task has no file path", async () => {
const tracker = useExportTracker();
tracker.addTask("audit_log", "csv", "");
await mountPopover();
expect(document.body.textContent).toContain("audit_log.csv");
});
it("reveals the containing folder for a finished export from the task row", async () => {
const revealMock = vi.mocked(api.revealPathInFileManager);
revealMock.mockReset();
revealMock.mockResolvedValue(undefined);
const tracker = useExportTracker();
const task = tracker.addTask("Query Result", "sql", "C:\\exports\\自定义.sql");
tracker.updateTableExportTask(task.exportId, {
exportId: task.exportId,
tableName: "Query Result",
rowsExported: 10,
totalRows: 10,
status: "Done",
errorMessage: undefined,
});
await mountPopover();
const revealButton = document.body.querySelector<HTMLButtonElement>('button[title="Open containing folder"]');
expect(revealButton).not.toBeNull();
revealButton?.click();
await nextTick();
await vi.waitFor(() => expect(revealMock).toHaveBeenCalledWith("C:\\exports\\自定义.sql"));
});
it("hides the reveal button for active tasks and tasks without a file path", async () => {
const tracker = useExportTracker();
tracker.addTask("running_export", "csv", "C:\\exports\\running.csv");
const doneTask = tracker.addTask("finished_export", "csv", "");
tracker.updateTableExportTask(doneTask.exportId, {
exportId: doneTask.exportId,
tableName: "finished_export",
rowsExported: 1,
totalRows: 1,
status: "Done",
errorMessage: undefined,
});
await mountPopover();
expect(document.body.querySelector('button[title="Open containing folder"]')).toBeNull();
});
it("hides the reveal button for sql-file tasks whose path is a joined list of input scripts", async () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("sql-batch", "a.sql (+1)", "C:\\scripts\\a.sql; C:\\scripts\\b.sql");
tracker.updateSqlFileTask(task.exportId, {
executionId: task.exportId,
status: "done",
statementIndex: 2,
successCount: 2,
failureCount: 0,
affectedRows: 0,
elapsedMs: 10,
statementSummary: "",
});
await mountPopover();
expect(document.body.querySelector('button[title="Open containing folder"]')).toBeNull();
});
it("shows a toast when revealing the folder fails", async () => {
const revealMock = vi.mocked(api.revealPathInFileManager);
revealMock.mockReset();
revealMock.mockRejectedValue(new Error("file does not exist: C:\\exports\\gone.sql"));
const tracker = useExportTracker();
const task = tracker.addTask("Query Result", "sql", "C:\\exports\\gone.sql");
tracker.updateTableExportTask(task.exportId, {
exportId: task.exportId,
tableName: "Query Result",
rowsExported: 10,
totalRows: 10,
status: "Done",
errorMessage: undefined,
});
await mountPopover();
document.body.querySelector<HTMLButtonElement>('button[title="Open containing folder"]')?.click();
await vi.waitFor(() => {
expect(useToast().message.value).toContain("Failed to open folder");
});
});
});