perf(ui): 优化数据导出的ui体验和性能
This commit is contained in:
1 parent
175d1def0d
commit
4a12a575ed
13 files changed
+500
-139
No files matched your search
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } = useExportTracker();
|
||||
const open = ref(false);
|
||||
const showAll = ref(false);
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
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";
|
||||
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
|
||||
|
||||
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
|
||||
|
||||
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()}`;
|
||||
}
|
||||
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
|
||||
};
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Running":
|
||||
case "Writing":
|
||||
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":
|
||||
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;
|
||||
}
|
||||
</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="t('exportProgress.tooltip')"
|
||||
:class="{ 'bg-accent text-primary': 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"
|
||||
>
|
||||
{{ 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">
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
:class="{ 'animate-pulse': !task.totalRows }"
|
||||
:style="{ width: task.totalRows ? `${progressPercent(task.totalRows, task.rowsExported)}%` : '50%' }"
|
||||
/>
|
||||
</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 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"
|
||||
>
|
||||
{{ task.errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions: stop/cancel for active, delete for finished -->
|
||||
<div class="flex shrink-0 self-center">
|
||||
<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>
|
||||
<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>
|
||||
Reference in new issue
Block a user