feat(export-tracker): 增强导出任务跟踪功能 (#1958)

- 添加了对数据库导出、SQL 文件导出和数据传输任务的支持
- 引入了新的状态和错误处理机制
- 实现了任务的重叠检查,防止同时进行相同目标表的数据传输
- 更新了 API 调用以支持新的任务类型
- 增加了相应的国际化文本
- 添加了单元测试以确保新功能的正确性
This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-06-26 18:50:25 +08:00
1 parent 79c2cd8bdc
commit 10aff74072
15 files changed
+838 -208

No files matched your search

@@ -17,9 +17,11 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Input } from "@/components/ui/input";
import { Download, Square, CheckSquare, Search, X } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
const { t } = useI18n();
const { toast } = useToast();
const { addDatabaseExportTask, updateDatabaseExportTask } = useExportTracker();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
@@ -203,9 +205,12 @@ async function startExport() {
batchSize: 1000,
};
addDatabaseExportTask(exportId.value, database.value || "database", filePath);
try {
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
updateDatabaseExportTask(progress.exportId, progress);
if (progress.status === "Done") {
exportDone.value = true;
isExporting.value = false;
@@ -220,6 +225,18 @@ async function startExport() {
});
} catch (e: any) {
exportError.value = e?.message || String(e);
const lastProgress = exportProgress.value as api.ExportProgress | null;
const fallbackProgress: api.ExportProgress = {
exportId: exportId.value,
currentObject: database.value || "database",
objectIndex: lastProgress?.objectIndex ?? 0,
totalObjects: lastProgress?.totalObjects ?? 0,
rowsExported: lastProgress?.rowsExported ?? 0,
totalRows: lastProgress?.totalRows ?? null,
status: "Error",
error: exportError.value,
};
updateDatabaseExportTask(exportId.value, fallbackProgress);
isExporting.value = false;
}
}
@@ -489,6 +506,9 @@ watch(
</Button>
</template>
<template v-else-if="isExporting">
<Button variant="outline" size="sm" @click="open = false">
{{ t("databaseExport.runInBackground") }}
</Button>
<Button variant="destructive" size="sm" @click="cancelExport">
<X class="w-3.5 h-3.5 mr-1.5" />
{{ t("transfer.cancel") }}
@@ -3,8 +3,8 @@ import { computed, 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 } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft } from "@lucide/vue";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
const { t } = useI18n();
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
@@ -26,21 +26,77 @@ const isActive = (status: string) => status === "Running" || status === "Writing
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
const failedCount = computed(() => tasks.value.filter((t) => t.status === "Error").length);
const triggerTitle = computed(() => (failedCount.value > 0 ? t("exportProgress.failedTooltip", { count: failedCount.value }) : t("exportProgress.tooltip")));
const progressPercent = (totalRows: number | null, rowsExported: number) => {
if (!totalRows || totalRows <= 0) return 0;
return Math.min(100, Math.round((rowsExported / totalRows) * 100));
};
const rowsText = (task: { totalRows: number | null; rowsExported: number }) => {
if (task.totalRows) {
return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
const progressValue = (task: ExportTask) => {
if (task.kind === "database-export") {
if (!task.totalObjects || task.totalObjects <= 0) return 0;
return Math.min(100, Math.round(((task.objectIndex ?? 0) / task.totalObjects) * 100));
}
if (task.kind === "sql-file") {
const total = task.totalRows ?? task.statementIndex ?? 0;
if (task.status === "Done") return 100;
if (total <= 0) return 0;
return Math.min(95, Math.round((task.rowsExported / total) * 100));
}
if (task.kind === "data-transfer") {
if (!task.totalTables || task.totalTables <= 0) return 0;
if (task.status === "Done") return 100;
return Math.min(95, Math.round(((task.tableIndex ?? 0) / task.totalTables) * 100));
}
return progressPercent(task.totalRows, task.rowsExported);
};
const taskTitle = (task: ExportTask) => {
if (task.kind === "database-export") return t("exportProgress.databaseExportTitle", { 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}`;
};
const rowsText = (task: ExportTask) => {
if (task.kind === "database-export") {
if (task.totalObjects) {
return t("exportProgress.objectsCount", {
current: (task.objectIndex ?? 0).toLocaleString(),
total: task.totalObjects.toLocaleString(),
});
}
return t("exportProgress.rowsExported", { count: task.rowsExported.toLocaleString() });
}
if (task.kind === "sql-file") {
return t("exportProgress.statementsCount", {
done: (task.successCount ?? 0).toLocaleString(),
failed: (task.failureCount ?? 0).toLocaleString(),
});
}
if (task.kind === "data-transfer") {
const tableText = task.totalTables
? t("exportProgress.tablesCount", {
current: (task.tableIndex ?? 0).toLocaleString(),
total: task.totalTables.toLocaleString(),
})
: task.currentTable || "";
const rowText = task.totalRows ? `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()} ${t("exportProgress.rowsShort")}` : `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText}` : tableText;
}
if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const statusIcon = (status: string) => {
switch (status) {
const statusIcon = (task: ExportTask) => {
if (isActive(task.status)) {
if (task.kind === "database-export") return DatabaseBackup;
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
}
switch (task.status) {
case "Running":
case "Writing":
return Loader2;
@@ -79,30 +135,31 @@ function toggleShowAll() {
<template>
<Popover v-if="tasks.length > 0" v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="t('exportProgress.tooltip')" :class="{ 'bg-accent text-primary': hasActive }">
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="triggerTitle" :class="{ 'bg-destructive/10 text-destructive hover:bg-destructive/15': failedCount > 0, 'bg-accent text-primary': failedCount === 0 && hasActive }">
<FileDown class="h-4 w-4" />
<span v-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
<span v-if="failedCount > 0" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold leading-none text-destructive-foreground"> ! </span>
<span v-else-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
{{ activeCount > 9 ? "9+" : activeCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-0 gap-0 overflow-hidden" :side-offset="8">
<div class="border-b bg-muted/40 px-3 py-2">
<PopoverContent align="start" class="w-[min(92vw,30rem)] p-0 gap-0 overflow-hidden" :side-offset="8">
<div class="border-b bg-muted/40 px-4 py-3">
<div class="text-sm font-semibold">{{ t("exportProgress.popoverTitle") }}</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-center gap-2 border-b px-3 py-2.5 text-xs last:border-b-0">
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="max-h-96 overflow-y-auto">
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-start gap-3 border-b px-4 py-3 text-xs last:border-b-0">
<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.status)" :class="[statusColor(task.status), isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate font-medium">{{ task.tableName }}.{{ task.format }}</span>
<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>
</div>
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div v-if="task.totalRows" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressPercent(task.totalRows, task.rowsExported)}%` }" />
<div v-if="task.totalRows || (task.kind === 'database-export' && task.totalObjects) || (task.kind === 'data-transfer' && task.totalTables)" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressValue(task)}%` }" />
<div v-else class="h-full w-full overflow-hidden rounded-full">
<div class="export-progress-indeterminate h-full rounded-full bg-primary" />
</div>
@@ -111,16 +168,16 @@ function toggleShowAll() {
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<div class="flex items-center justify-between text-muted-foreground">
<span class="tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="truncate ml-2 text-destructive" :title="task.errorMessage">
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="task.errorMessage">
{{ task.errorMessage }}
</span>
</div>
</div>
<!-- Actions: stop/cancel for active, delete for finished -->
<div class="flex shrink-0 self-center">
<div class="flex shrink-0 pt-4">
<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>