Files
dbx/apps/desktop/src/components/export/ExportProgressDialog.vue
T

206 lines
7.9 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, FolderOpen, Minimize2, Wrench, X } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import { formatDataTransferDuration } from "@/composables/useExportTracker";
import { isQueryTimeoutErrorMessage } from "@/lib/sql/queryError";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const isRevealing = ref(false);
const currentTime = ref(Date.now());
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
const props = defineProps<{
title: string;
tableName: string;
format: string;
rowsExported: number;
totalRows: number | null;
status: string;
errorMessage: string | null;
filePath?: string | null;
startedAt?: number;
finishedAt?: number;
disableCancel?: boolean;
canMinimize?: boolean;
connectionId?: string;
}>();
const emit = defineEmits<{
cancel: [];
minimize: [];
changeQueryTimeout: [];
"update:open": [value: boolean];
}>();
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 canChangeQueryTimeout = computed(() => props.status === "Error" && !!props.connectionId && !!props.errorMessage && isQueryTimeoutErrorMessage(props.errorMessage));
const canRevealFile = computed(() => props.status === "Done" && !!props.filePath && isTauriRuntime());
const progressPercent = computed(() => {
if (!props.totalRows || props.totalRows <= 0) return 0;
return Math.min(100, Math.round((props.rowsExported / props.totalRows) * 100));
});
const rowsText = computed(() => {
if (props.totalRows) {
return t("exportProgress.rowsCount", {
exported: props.rowsExported.toLocaleString(),
total: props.totalRows.toLocaleString(),
});
}
return t("exportProgress.rowsExported", { count: props.rowsExported.toLocaleString() });
});
const elapsedText = computed(() => {
if (props.startedAt === undefined) return "";
return formatDataTransferDuration((props.finishedAt ?? currentTime.value) - props.startedAt);
});
async function revealExportFile() {
if (!props.filePath || isRevealing.value) return;
isRevealing.value = true;
try {
await api.revealPathInFileManager(props.filePath);
} catch (error) {
toast(t("exportProgress.openFolderFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
isRevealing.value = false;
}
}
</script>
<template>
<Dialog
:open="open"
@update:open="
(v: boolean) => {
if (!isActive) emit('update:open', v);
}
"
>
<DialogContent class="sm:max-w-md" :class="{ 'pointer-events-none': isActive }" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
</DialogHeader>
<div class="py-4 space-y-4">
<!-- 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">
<template v-if="status === 'Running' || status === 'Writing'">
<div v-if="totalRows" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressPercent}%` }" />
<div v-else class="h-full w-full overflow-hidden rounded-full">
<div class="export-progress-indeterminate h-full rounded-full bg-primary" />
</div>
</template>
<div v-else-if="status === 'Done'" class="h-full bg-green-500 rounded-full" style="width: 100%" />
</div>
<!-- Status message -->
<div class="flex items-center gap-2 text-sm">
<template v-if="status === 'Running'">
<Loader2 class="h-4 w-4 animate-spin text-primary" />
<span>{{ t("exportProgress.fetching") }}</span>
</template>
<template v-else-if="status === 'Writing'">
<Loader2 class="h-4 w-4 animate-spin text-primary" />
<span>{{ t("exportProgress.writing") }}</span>
</template>
<template v-else-if="status === 'Done'">
<CheckCircle2 class="h-4 w-4 text-green-500" />
<span class="text-green-700 dark:text-green-400">{{ t("exportProgress.done") }}</span>
</template>
<template v-else-if="status === 'Error'">
<XCircle class="h-4 w-4 text-destructive" />
<span class="text-destructive">{{ translatedErrorMessage || t("exportProgress.error") }}</span>
</template>
<template v-else-if="status === 'Cancelled'">
<AlertCircle class="h-4 w-4 text-yellow-500" />
<span class="text-yellow-700 dark:text-yellow-400">{{ t("exportProgress.cancelled") }}</span>
</template>
</div>
<!-- Row count -->
<div class="text-xs text-muted-foreground tabular-nums">
{{ rowsText }}
<span v-if="elapsedText" class="ml-2">{{ t("exportProgress.elapsed", { duration: elapsedText }) }}</span>
</div>
</div>
<DialogFooter>
<template v-if="isActive">
<Button v-if="canMinimize" variant="ghost" size="sm" @click="emit('minimize')">
<Minimize2 class="h-3.5 w-3.5 mr-1" />
{{ t("exportProgress.minimize") }}
</Button>
<Button variant="outline" size="sm" :disabled="disableCancel" @click="emit('cancel')">
<X class="h-3.5 w-3.5 mr-1" />
{{ t("exportProgress.cancel") }}
</Button>
</template>
<template v-else-if="isFinished">
<Button v-if="canChangeQueryTimeout" variant="outline" size="sm" @click="emit('changeQueryTimeout')">
<Wrench class="mr-1 h-3.5 w-3.5" />
{{ t("editor.changeQueryTimeout") }}
</Button>
<Button v-if="canRevealFile" size="sm" :disabled="isRevealing" @click="revealExportFile">
<Loader2 v-if="isRevealing" class="mr-1 h-3.5 w-3.5 animate-spin" />
<FolderOpen v-else class="mr-1 h-3.5 w-3.5" />
{{ t("exportProgress.openFolder") }}
</Button>
<Button variant="outline" size="sm" @click="emit('update:open', false)">
{{ t("exportProgress.close") }}
</Button>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>
.export-progress-indeterminate {
width: 42%;
animation: export-progress-slide 1.15s ease-in-out infinite;
}
@keyframes export-progress-slide {
0% {
transform: translateX(-110%);
}
50% {
transform: translateX(70%);
}
100% {
transform: translateX(250%);
}
}
</style>