447 lines
24 KiB
Vue
447 lines
24 KiB
Vue
<script setup lang="ts">
|
|
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, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
|
|
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
|
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
|
|
import { translateBackendError } from "@/i18n/backend-errors";
|
|
import { useToast } from "@/composables/useToast";
|
|
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
|
import { copyToClipboard } from "@/lib/common/clipboard";
|
|
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 copiedFailureDetailKey = ref("");
|
|
const currentTime = ref(Date.now());
|
|
const MAX_VISIBLE = 5;
|
|
|
|
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
|
let copiedFailureDetailTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let unmounted = false;
|
|
onMounted(() => {
|
|
elapsedTimer = setInterval(() => {
|
|
currentTime.value = Date.now();
|
|
}, 1000);
|
|
});
|
|
onBeforeUnmount(() => {
|
|
unmounted = true;
|
|
if (elapsedTimer) clearInterval(elapsedTimer);
|
|
if (copiedFailureDetailTimer) clearTimeout(copiedFailureDetailTimer);
|
|
});
|
|
|
|
const reversedTasks = computed(() => {
|
|
return [...tasks.value].reverse();
|
|
});
|
|
|
|
const visibleTasks = computed(() => {
|
|
return showAll.value ? reversedTasks.value : reversedTasks.value.slice(0, MAX_VISIBLE);
|
|
});
|
|
|
|
const hasMore = computed(() => tasks.value.length > MAX_VISIBLE);
|
|
|
|
const isActive = (status: string) => status === "Running" || status === "Writing" || status === "Cancelling";
|
|
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 progressValue = (task: ExportTask) => {
|
|
if (task.kind === "database-export") {
|
|
if (task.overallPercent !== undefined) return task.overallPercent;
|
|
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));
|
|
}
|
|
if (task.kind === "multi-db-execution") {
|
|
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return 100;
|
|
if (!task.multiDbTotal) return 0;
|
|
return Math.min(95, Math.round(((task.multiDbCompleted ?? 0) / task.multiDbTotal) * 100));
|
|
}
|
|
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 });
|
|
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
|
|
return taskFileName(task) || `${task.tableName}.${task.format}`;
|
|
};
|
|
|
|
const rowsText = (task: ExportTask) => {
|
|
if (task.kind === "database-export") {
|
|
if (task.overallPercent !== undefined) return `${task.overallPercent}%`;
|
|
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")}`;
|
|
const finishedAt = task.finishedAt ?? Date.now();
|
|
const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) });
|
|
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`;
|
|
}
|
|
if (task.kind === "multi-db-execution") {
|
|
return t("exportProgress.multiDbTargets", {
|
|
completed: (task.multiDbCompleted ?? 0).toLocaleString(),
|
|
total: (task.multiDbTotal ?? 0).toLocaleString(),
|
|
success: (task.multiDbSuccessCount ?? 0).toLocaleString(),
|
|
failed: (task.multiDbFailureCount ?? 0).toLocaleString(),
|
|
});
|
|
}
|
|
if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
|
|
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
|
|
};
|
|
|
|
const taskStatusText = (task: ExportTask) => (task.status === "Cancelling" ? t("databaseBackup.cancelling") : "");
|
|
|
|
const elapsedText = (task: ExportTask) => {
|
|
if (task.startedAt === undefined) return "";
|
|
const finishedAt = task.finishedAt ?? currentTime.value;
|
|
return t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - task.startedAt) });
|
|
};
|
|
|
|
const databaseObjectText = (task: ExportTask) => {
|
|
if (task.kind !== "database-export" || !isActive(task.status) || !task.currentObject) return "";
|
|
if (task.preparing || !task.totalObjects) {
|
|
return t("databaseExport.preparingObject", { object: task.currentObject });
|
|
}
|
|
return t("databaseExport.currentTable", {
|
|
table: task.currentObject,
|
|
current: (task.objectIndex ?? 0).toLocaleString(),
|
|
total: task.totalObjects.toLocaleString(),
|
|
});
|
|
};
|
|
|
|
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;
|
|
if (task.kind === "multi-db-execution") return Layers3;
|
|
}
|
|
switch (task.status) {
|
|
case "Running":
|
|
case "Writing":
|
|
case "Cancelling":
|
|
return Loader2;
|
|
case "Done":
|
|
return CheckCircle2;
|
|
case "Error":
|
|
return XCircle;
|
|
case "Cancelled":
|
|
return AlertCircle;
|
|
default:
|
|
return Loader2;
|
|
}
|
|
};
|
|
|
|
const statusColor = (status: string) => {
|
|
switch (status) {
|
|
case "Running":
|
|
case "Writing":
|
|
case "Cancelling":
|
|
return "text-primary";
|
|
case "Done":
|
|
return "text-green-500";
|
|
case "Error":
|
|
return "text-destructive";
|
|
case "Cancelled":
|
|
return "text-yellow-500";
|
|
default:
|
|
return "text-muted-foreground";
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
function toggleFailureDetails(exportId: string) {
|
|
expandedFailureTaskIds.value = failureDetailsExpanded(exportId) ? expandedFailureTaskIds.value.filter((id) => id !== exportId) : [...expandedFailureTaskIds.value, exportId];
|
|
}
|
|
|
|
function failureDetailCount(task: ExportTask) {
|
|
if (task.kind === "sql-file") return (task.sqlFileFailures?.length ?? 0) + (task.sqlFileFailuresOmitted ?? 0);
|
|
return (task.transferFailures?.length ?? 0) + (task.transferFailuresOmitted ?? 0);
|
|
}
|
|
|
|
function hasUnlistedTaskError(task: ExportTask) {
|
|
if (task.status !== "Error" || !task.errorMessage) return false;
|
|
if (task.kind === "sql-file") return !(task.sqlFileFailures ?? []).some((failure) => failure.error === task.errorMessage);
|
|
if (task.kind === "data-transfer") return !(task.transferFailures ?? []).some((failure) => failure.error === task.errorMessage);
|
|
return true;
|
|
}
|
|
|
|
function dataTransferFailureDetailKey(taskId: string, table: string): string {
|
|
return `data-transfer:${taskId}:${table}`;
|
|
}
|
|
|
|
function sqlFileFailureDetailKey(taskId: string, fileIndex: number | undefined, statementIndex: number): string {
|
|
return `sql-file:${taskId}:${fileIndex ?? -1}:${statementIndex}`;
|
|
}
|
|
|
|
async function copyFailureDetail(text: string, key: string): Promise<void> {
|
|
try {
|
|
await copyToClipboard(text);
|
|
if (unmounted) return;
|
|
copiedFailureDetailKey.value = key;
|
|
if (copiedFailureDetailTimer) clearTimeout(copiedFailureDetailTimer);
|
|
copiedFailureDetailTimer = setTimeout(() => {
|
|
if (copiedFailureDetailKey.value === key) copiedFailureDetailKey.value = "";
|
|
copiedFailureDetailTimer = undefined;
|
|
}, 2000);
|
|
toast(t("exportProgress.failureDetailCopied"));
|
|
} catch (error: unknown) {
|
|
if (unmounted) return;
|
|
toast(t("exportProgress.failureDetailCopyFailed", { message: translateBackendError(t, error) }), 5000);
|
|
}
|
|
}
|
|
|
|
function openTask(task: ExportTask): void {
|
|
task.onOpen?.();
|
|
}
|
|
</script>
|
|
|
|
<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="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="failedCount > 0" class="absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
|
|
<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-[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-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)" :class="[statusColor(task.status), (task.kind === 'table-export' || task.status === 'Cancelling') && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
|
|
<span class="truncate font-medium" :title="task.filePath || undefined">{{ 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 || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables) || (task.kind === 'multi-db-execution' && task.multiDbTotal)"
|
|
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>
|
|
</div>
|
|
<div v-else-if="task.status === 'Done'" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
|
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
|
</div>
|
|
|
|
<div v-if="databaseObjectText(task)" class="min-w-0 truncate text-muted-foreground" :title="task.currentObject">
|
|
{{ databaseObjectText(task) }}
|
|
</div>
|
|
|
|
<div class="min-w-0 text-muted-foreground">
|
|
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
|
|
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
|
|
<span v-if="taskStatusText(task)" class="ml-1 font-medium text-primary">{{ taskStatusText(task) }}</span>
|
|
<span v-if="hasUnlistedTaskError(task)" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage!)">
|
|
{{ translateBackendError(t, task.errorMessage!) }}
|
|
</span>
|
|
<template v-if="(task.kind === 'data-transfer' || task.kind === 'sql-file') && failureDetailCount(task) > 0">
|
|
<button class="mt-1.5 flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary" :aria-expanded="failureDetailsExpanded(task.exportId)" @click="toggleFailureDetails(task.exportId)">
|
|
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': failureDetailsExpanded(task.exportId) }" />
|
|
{{ failureDetailsExpanded(task.exportId) ? t("exportProgress.hideFailureDetails") : t("exportProgress.showFailureDetails", { count: failureDetailCount(task) }) }}
|
|
</button>
|
|
<div v-if="failureDetailsExpanded(task.exportId)" class="mt-1.5 max-h-44 overflow-y-auto rounded border border-destructive/20 bg-destructive/5">
|
|
<template v-if="task.kind === 'data-transfer'">
|
|
<div v-for="failure in task.transferFailures" :key="failure.table" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
|
|
<div class="flex min-w-0 items-start justify-between gap-2">
|
|
<div class="min-w-0 break-all font-mono font-medium text-foreground">{{ failure.table }}</div>
|
|
<button
|
|
type="button"
|
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted"
|
|
:title="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
|
|
:aria-label="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
|
|
@click.stop="copyFailureDetail(dataTransferFailureCopyText(failure), dataTransferFailureDetailKey(task.exportId, failure.table))"
|
|
>
|
|
<Check v-if="copiedFailureDetailKey === dataTransferFailureDetailKey(task.exportId, failure.table)" class="h-3.5 w-3.5 text-green-500" aria-hidden="true" />
|
|
<Copy v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ failure.error }}</div>
|
|
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
|
|
</div>
|
|
<div v-if="task.transferFailuresOmitted" class="px-2.5 py-2 text-muted-foreground">{{ t("exportProgress.failureDetailsOmitted", { count: task.transferFailuresOmitted }) }}</div>
|
|
</template>
|
|
<template v-else>
|
|
<div v-for="failure in task.sqlFileFailures" :key="`${failure.fileIndex ?? -1}:${failure.statementIndex}`" class="border-b border-destructive/15 px-2.5 py-2 last:border-b-0">
|
|
<div class="flex min-w-0 items-start justify-between gap-2">
|
|
<div class="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
|
|
<span class="shrink-0">#{{ failure.statementIndex }}</span>
|
|
<span v-if="failure.fileName" class="truncate text-muted-foreground" :title="failure.fileName">{{ failure.fileName }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted"
|
|
:title="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
|
|
:aria-label="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex) ? t('exportProgress.failureDetailCopied') : t('exportProgress.copyFailureDetail')"
|
|
@click.stop="copyFailureDetail(sqlFileFailureCopyText(failure, translateBackendError(t, failure.error)), sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex))"
|
|
>
|
|
<Check v-if="copiedFailureDetailKey === sqlFileFailureDetailKey(task.exportId, failure.fileIndex, failure.statementIndex)" class="h-3.5 w-3.5 text-green-500" aria-hidden="true" />
|
|
<Copy v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div v-if="failure.statementSummary" class="mt-0.5 select-text whitespace-pre-wrap break-words font-mono text-foreground">{{ failure.statementSummary }}</div>
|
|
<div class="mt-0.5 select-text whitespace-pre-wrap break-words text-destructive">{{ translateBackendError(t, failure.error) }}</div>
|
|
<div v-if="failure.truncated" class="mt-0.5 text-muted-foreground">{{ t("exportProgress.failureDetailTruncated") }}</div>
|
|
</div>
|
|
<div v-if="task.sqlFileFailuresOmitted" class="px-2.5 py-2 text-muted-foreground">{{ t("exportProgress.failureDetailsOmitted", { count: task.sqlFileFailuresOmitted }) }}</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
|
|
<div class="flex shrink-0 pt-4">
|
|
<button v-if="task.kind === 'multi-db-execution' && task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
|
|
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
<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 disabled:cursor-not-allowed"
|
|
:disabled="task.status === 'Cancelling'"
|
|
:title="task.status === 'Cancelling' ? t('databaseBackup.cancelling') : t('exportProgress.cancel')"
|
|
@click="cancelTask(task.exportId)"
|
|
>
|
|
<Loader2 v-if="task.status === 'Cancelling'" class="h-3.5 w-3.5 animate-spin text-primary" />
|
|
<X v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
|
</button>
|
|
<button v-else class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
|
|
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="hasMore" class="border-t bg-muted/30 px-3 py-1.5">
|
|
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="toggleShowAll">
|
|
{{ showAll ? t("exportProgress.showLess") : t("exportProgress.showMore", { count: tasks.length - MAX_VISIBLE }) }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="finishedCount > 0" class="border-t bg-muted/30 px-3 py-1.5">
|
|
<button class="w-full text-center text-xs text-muted-foreground hover:text-foreground" @click="clearFinished">
|
|
{{ t("exportProgress.clearFinished") }}
|
|
</button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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>
|