feat(export-tracker): 增强导出任务跟踪功能 (#1958)
- 添加了对数据库导出、SQL 文件导出和数据传输任务的支持 - 引入了新的状态和错误处理机制 - 实现了任务的重叠检查,防止同时进行相同目标表的数据传输 - 更新了 API 调用以支持新的任务类型 - 增加了相应的国际化文本 - 添加了单元测试以确保新功能的正确性
This commit is contained in:
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>
|
||||
|
||||
@@ -126,7 +126,7 @@ watch(
|
||||
@confirm="emit('dangerConfirm')"
|
||||
/>
|
||||
<SqlParameterDialog v-if="showSqlParameterDialog" :open="showSqlParameterDialog" :sql="sqlParameterSourceSql" :parameters="sqlParameterNames" @update:open="emit('update:showSqlParameterDialog', $event)" @execute="emit('sqlParametersConfirm', $event)" />
|
||||
<DataTransferDialog v-if="dialogs.showTransferDialog.value" v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
|
||||
<DataTransferDialog v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
|
||||
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
|
||||
<DataCompareDialog
|
||||
v-if="dialogs.showDataCompareDialog.value"
|
||||
@@ -136,7 +136,7 @@ watch(
|
||||
:prefill-schema="dialogs.dataComparePrefillSchema.value"
|
||||
:prefill-table="dialogs.dataComparePrefillTable.value"
|
||||
/>
|
||||
<SqlFileExecutionDialog v-if="dialogs.showSqlFileDialog.value" v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" />
|
||||
<SqlFileExecutionDialog v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" />
|
||||
<SchemaDiagramDialog
|
||||
v-if="dialogs.showDiagramDialog.value"
|
||||
v-model:open="dialogs.showDiagramDialog.value"
|
||||
|
||||
@@ -14,11 +14,13 @@ import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/api";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { highlight } = useSqlHighlighter();
|
||||
const { addSqlFileTask, updateSqlFileTask } = useExportTracker();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -282,6 +284,7 @@ async function startExecution() {
|
||||
terminalStatus.value = "running";
|
||||
terminalError.value = "";
|
||||
progress.value = null;
|
||||
addSqlFileTask(id, preview.value.fileName, preview.value.filePath);
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
try {
|
||||
@@ -296,6 +299,7 @@ async function startExecution() {
|
||||
progress.value = next;
|
||||
terminalStatus.value = next.status;
|
||||
terminalError.value = next.error ?? terminalError.value;
|
||||
updateSqlFileTask(id, next);
|
||||
if (isTerminalStatus(next.status)) {
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
@@ -320,6 +324,18 @@ async function startExecution() {
|
||||
});
|
||||
if (!isTerminalStatus(terminalStatus.value)) {
|
||||
terminalStatus.value = cancelRequested.value ? "cancelled" : "done";
|
||||
const lastProgress = progress.value as SqlFileProgress | null;
|
||||
updateSqlFileTask(id, {
|
||||
executionId: id,
|
||||
status: terminalStatus.value,
|
||||
statementIndex: lastProgress?.statementIndex ?? 0,
|
||||
successCount: lastProgress?.successCount ?? 0,
|
||||
failureCount: lastProgress?.failureCount ?? 0,
|
||||
affectedRows: lastProgress?.affectedRows ?? 0,
|
||||
elapsedMs: lastProgress?.elapsedMs ?? 0,
|
||||
statementSummary: lastProgress?.statementSummary ?? "",
|
||||
error: lastProgress?.error ?? null,
|
||||
});
|
||||
if (terminalStatus.value === "done") {
|
||||
await refreshTargetAfterImport();
|
||||
}
|
||||
@@ -327,6 +343,18 @@ async function startExecution() {
|
||||
} catch (e: any) {
|
||||
terminalStatus.value = cancelRequested.value ? "cancelled" : "error";
|
||||
terminalError.value = e?.message || String(e);
|
||||
const lastProgress = progress.value as SqlFileProgress | null;
|
||||
updateSqlFileTask(id, {
|
||||
executionId: id,
|
||||
status: terminalStatus.value,
|
||||
statementIndex: lastProgress?.statementIndex ?? 0,
|
||||
successCount: lastProgress?.successCount ?? 0,
|
||||
failureCount: lastProgress?.failureCount ?? 0,
|
||||
affectedRows: lastProgress?.affectedRows ?? 0,
|
||||
elapsedMs: lastProgress?.elapsedMs ?? 0,
|
||||
statementSummary: lastProgress?.statementSummary ?? "",
|
||||
error: terminalError.value,
|
||||
});
|
||||
if (!cancelRequested.value) {
|
||||
toast(terminalError.value, 5000);
|
||||
}
|
||||
@@ -356,7 +384,6 @@ async function cancelExecution() {
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && running.value) return;
|
||||
open.value = nextOpen;
|
||||
}
|
||||
|
||||
@@ -373,6 +400,7 @@ watch(
|
||||
open,
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
if (running.value) return;
|
||||
resetState();
|
||||
if (connectionId.value) {
|
||||
loadDatabasesForConnection(connectionId.value);
|
||||
@@ -543,6 +571,9 @@ watch(
|
||||
|
||||
<DialogFooter>
|
||||
<template v-if="running">
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t("sqlFile.runInBackground") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
|
||||
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<X v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
|
||||
@@ -10,14 +10,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { TransferProgress, TransferMode, TransferTableNameCase } from "@/lib/api";
|
||||
import type { TransferMode, TransferTableNameCase } from "@/lib/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { isSchemaAware, supportsTransfer } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import { ArrowRightLeft, Check, X, Loader2, Square, CheckSquare } from "@lucide/vue";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
import { ArrowRightLeft, Loader2, Square, CheckSquare } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { startDataTransferTask } = useExportTracker();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -52,15 +53,7 @@ const createTable = ref(true);
|
||||
const transferMode = ref<TransferMode>("append");
|
||||
const targetTableNameCase = ref<TransferTableNameCase>("preserve");
|
||||
const batchSize = ref(1000);
|
||||
|
||||
// Transfer state
|
||||
const isTransferring = ref(false);
|
||||
const transferProgress = ref<Map<string, TransferProgress>>(new Map());
|
||||
const currentTable = ref("");
|
||||
const overallDone = ref(false);
|
||||
const overallError = ref(false);
|
||||
const overallCancelled = ref(false);
|
||||
const transferId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const filteredTables = computed(() => {
|
||||
const q = tableSearch.value.toLowerCase();
|
||||
@@ -252,33 +245,27 @@ function resetState() {
|
||||
transferMode.value = "append";
|
||||
targetTableNameCase.value = "preserve";
|
||||
batchSize.value = 1000;
|
||||
isTransferring.value = false;
|
||||
transferProgress.value.clear();
|
||||
currentTable.value = "";
|
||||
overallDone.value = false;
|
||||
overallError.value = false;
|
||||
overallCancelled.value = false;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
|
||||
async function startTransfer() {
|
||||
isTransferring.value = true;
|
||||
overallDone.value = false;
|
||||
overallError.value = false;
|
||||
overallCancelled.value = false;
|
||||
transferProgress.value.clear();
|
||||
|
||||
transferId.value = uuid();
|
||||
if (!canStart.value || isSubmitting.value) return;
|
||||
isSubmitting.value = true;
|
||||
|
||||
const effectiveSourceSchema = sourceSchema.value || sourceDatabase.value;
|
||||
const effectiveTargetSchema = targetSchema.value || targetDatabase.value;
|
||||
const sourceDatabaseName = sourceDatabase.value;
|
||||
const targetConnection = targetConnectionId.value;
|
||||
const targetDatabaseName = targetDatabase.value;
|
||||
const shouldRefreshTargetTree = createTable.value;
|
||||
|
||||
const request: api.TransferRequest = {
|
||||
transferId: transferId.value,
|
||||
transferId: uuid(),
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceDatabase: sourceDatabaseName,
|
||||
sourceSchema: effectiveSourceSchema,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetConnectionId: targetConnection,
|
||||
targetDatabase: targetDatabaseName,
|
||||
targetSchema: effectiveTargetSchema,
|
||||
tables: [...selectedTables.value],
|
||||
createTable: createTable.value,
|
||||
@@ -287,38 +274,16 @@ async function startTransfer() {
|
||||
batchSize: batchSize.value,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.startTransfer(request, (progress) => {
|
||||
if (progress.table) {
|
||||
transferProgress.value.set(progress.table, progress);
|
||||
transferProgress.value = new Map(transferProgress.value);
|
||||
currentTable.value = progress.table;
|
||||
startDataTransferTask(request, `${sourceDatabaseName} → ${targetDatabaseName}`, {
|
||||
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
|
||||
onDone: async () => {
|
||||
if (shouldRefreshTargetTree) {
|
||||
await store.refreshObjectListTreeNode(targetConnection, targetDatabaseName, effectiveTargetSchema);
|
||||
}
|
||||
|
||||
const nextState = nextTransferTerminalState(
|
||||
{
|
||||
done: overallDone.value,
|
||||
cancelled: overallCancelled.value,
|
||||
error: overallError.value,
|
||||
},
|
||||
progress,
|
||||
);
|
||||
overallDone.value = nextState.done;
|
||||
overallCancelled.value = nextState.cancelled;
|
||||
overallError.value = nextState.error;
|
||||
});
|
||||
if (createTable.value && !overallError.value && !overallCancelled.value) {
|
||||
await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, targetSchema.value);
|
||||
}
|
||||
} catch (e: any) {
|
||||
overallError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTransfer() {
|
||||
if (transferId.value) {
|
||||
await api.cancelTransfer(transferId.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
open.value = false;
|
||||
resetState();
|
||||
}
|
||||
|
||||
function getConnectionName(id: string) {
|
||||
@@ -328,29 +293,6 @@ function getConnectionName(id: string) {
|
||||
function getConnectionType(id: string): DatabaseType {
|
||||
return store.connections.find((c) => c.id === id)?.db_type ?? "mysql";
|
||||
}
|
||||
|
||||
const processedStatuses = new Set<TransferProgress["status"]>(["tableDone", "done", "error", "cancelled"]);
|
||||
|
||||
function formatRowCount(count: number) {
|
||||
return count.toLocaleString();
|
||||
}
|
||||
|
||||
function formatTableRows(progress: TransferProgress) {
|
||||
if (typeof progress.totalRows === "number") {
|
||||
return `${formatRowCount(progress.rowsTransferred)} / ${formatRowCount(progress.totalRows)}`;
|
||||
}
|
||||
return formatRowCount(progress.rowsTransferred);
|
||||
}
|
||||
|
||||
const completedTables = computed(() => [...transferProgress.value.values()].filter((p) => processedStatuses.has(p.status)).length);
|
||||
|
||||
const failedTables = computed(() => [...transferProgress.value.values()].filter((p) => p.status === "error").length);
|
||||
|
||||
const totalTransferred = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0));
|
||||
|
||||
const knownTotalRows = computed(() => [...transferProgress.value.values()].reduce((sum, p) => sum + (typeof p.totalRows === "number" ? p.totalRows : 0), 0));
|
||||
|
||||
const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRowCount(totalTransferred.value)} / ${formatRowCount(knownTotalRows.value)}` : formatRowCount(totalTransferred.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -364,8 +306,7 @@ const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRow
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<!-- Config View -->
|
||||
<div v-if="!isTransferring" class="grid gap-4 py-3">
|
||||
<div class="grid gap-4 py-3">
|
||||
<!-- Source Section -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
@@ -546,86 +487,17 @@ const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress View -->
|
||||
<div v-else class="py-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }} {{ t("transfer.tables").toLowerCase() }} · {{ overallRowsLabel }}
|
||||
{{ t("grid.rows", { count: "" }).trim() }}
|
||||
</span>
|
||||
<span v-if="overallDone && !failedTables" class="text-green-600 font-medium">{{ t("transfer.completed") }}</span>
|
||||
<span v-else-if="overallDone && failedTables" class="text-amber-600 font-medium">
|
||||
{{ t("transfer.completedWithErrors", { count: failedTables }) }}
|
||||
</span>
|
||||
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t("transfer.cancelled") }}</span>
|
||||
<span v-else-if="overallError" class="text-destructive font-medium">{{ t("transfer.failed") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300"
|
||||
:class="overallError ? 'bg-destructive' : overallCancelled ? 'bg-yellow-500' : overallDone && failedTables ? 'bg-amber-500' : 'bg-primary'"
|
||||
:style="{
|
||||
width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md max-h-[280px] overflow-y-auto">
|
||||
<div v-for="table in [...selectedTables]" :key="table" class="flex items-center justify-between px-2.5 py-1.5 text-xs border-b last:border-b-0">
|
||||
<span class="truncate">{{ table }}</span>
|
||||
<div class="flex items-center gap-1.5 shrink-0 text-muted-foreground">
|
||||
<template v-if="transferProgress.get(table)">
|
||||
<template v-if="transferProgress.get(table)!.status === 'running'">
|
||||
<Loader2 class="w-3 h-3 animate-spin text-primary" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'tableDone' || transferProgress.get(table)!.status === 'done'">
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'error'">
|
||||
<X class="w-3 h-3 text-destructive" />
|
||||
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
|
||||
<span class="max-w-[520px] whitespace-normal break-words text-destructive" :title="transferProgress.get(table)!.error ?? ''">
|
||||
{{ transferProgress.get(table)!.error }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="transferProgress.get(table)!.status === 'cancelled'">
|
||||
<X class="w-3 h-3 text-yellow-500" />
|
||||
<span>{{ t("transfer.cancelled") }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground/40">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<template v-if="!isTransferring">
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canStart" @click="startTransfer">
|
||||
<ArrowRightLeft class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("transfer.start") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="overallDone || overallCancelled || overallError">
|
||||
<Button size="sm" @click="open = false">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="destructive" size="sm" @click="cancelTransfer">
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canStart || isSubmitting" @click="startTransfer">
|
||||
<Loader2 v-if="isSubmitting" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<ArrowRightLeft v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("transfer.start") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in new issue
Block a user