add sql file execution dialog

This commit is contained in:
SuLe
2026-05-02 05:15:41 +08:00
parent 6407e13ef3
commit 08c26203e4
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { useI18n } from "vue-i18n";
import {
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import {
cancelSqlFileExecution,
executeSqlFile,
listenSqlFileProgress,
previewSqlFile,
type SqlFilePreview,
type SqlFileProgress,
type SqlFileStatus,
} from "@/lib/tauri";
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "lucide-vue-next";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
}>();
const store = useConnectionStore();
const filePath = ref("");
const preview = ref<SqlFilePreview | null>(null);
const selectingFile = ref(false);
const loadingPreview = ref(false);
const connectionId = ref("");
const database = ref("");
const continueOnError = ref(false);
const running = ref(false);
const cancelling = ref(false);
const executionId = ref("");
const progress = ref<SqlFileProgress | null>(null);
const terminalStatus = ref<SqlFileStatus | "idle">("idle");
const terminalError = ref("");
const sqlConnections = computed(() =>
store.connections.filter((c) =>
!["redis", "mongodb", "elasticsearch"].includes(c.db_type),
),
);
const selectedConnection = computed(() =>
sqlConnections.value.find((c) => c.id === connectionId.value),
);
const canStart = computed(() =>
Boolean(preview.value && connectionId.value && database.value.trim() && !running.value && !loadingPreview.value),
);
const statusTone = computed(() => {
if (terminalStatus.value === "done") return "text-green-600";
if (terminalStatus.value === "error") return "text-destructive";
if (terminalStatus.value === "cancelled") return "text-yellow-600";
if (running.value) return "text-primary";
return "text-muted-foreground";
});
const statusIcon = computed(() => {
if (running.value) return Loader2;
if (terminalStatus.value === "done") return Check;
if (terminalStatus.value === "error" || terminalStatus.value === "cancelled") return X;
return FileCode;
});
const progressPercent = computed(() => {
if (!progress.value) return 0;
if (terminalStatus.value === "done") return 100;
const attempted = progress.value.successCount + progress.value.failureCount;
const current = Math.max(progress.value.statementIndex, attempted);
if (current <= 0) return running.value ? 8 : 0;
return Math.min(95, Math.max(8, Math.round((attempted / current) * 100)));
});
function connectionIconType(id: string) {
const config = store.getConfig(id);
return config?.driver_profile || config?.db_type || "mysql";
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = units[0];
for (let i = 1; i < units.length && value >= 1024; i += 1) {
value /= 1024;
unit = units[i];
}
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${unit}`;
}
function formatElapsed(ms: number) {
if (ms < 1000) return `${ms} ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)} s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
}
function statusLabel(status: SqlFileStatus | "idle") {
return t(`sqlFile.status.${status}`);
}
function isTerminalStatus(status: SqlFileStatus | "idle") {
return status === "done" || status === "error" || status === "cancelled";
}
function resetExecution() {
running.value = false;
cancelling.value = false;
executionId.value = "";
progress.value = null;
terminalStatus.value = "idle";
terminalError.value = "";
}
function resetState() {
filePath.value = "";
preview.value = null;
selectingFile.value = false;
loadingPreview.value = false;
connectionId.value = props.prefillConnectionId ?? "";
database.value = props.prefillDatabase ?? selectedConnection.value?.database ?? "";
continueOnError.value = false;
resetExecution();
}
async function loadPreview(path: string) {
loadingPreview.value = true;
preview.value = null;
try {
preview.value = await previewSqlFile(path);
filePath.value = preview.value.filePath;
resetExecution();
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
loadingPreview.value = false;
}
}
async function selectFile() {
if (running.value) return;
selectingFile.value = true;
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (typeof selected === "string") {
await loadPreview(selected);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
selectingFile.value = false;
}
}
async function startExecution() {
if (!canStart.value || !preview.value) return;
const id = crypto.randomUUID();
executionId.value = id;
running.value = true;
cancelling.value = false;
terminalStatus.value = "running";
terminalError.value = "";
progress.value = null;
let unlisten: (() => void) | undefined;
try {
await store.ensureConnected(connectionId.value);
unlisten = await listenSqlFileProgress((next) => {
if (next.executionId !== id) return;
progress.value = next;
terminalStatus.value = next.status;
terminalError.value = next.error ?? terminalError.value;
if (isTerminalStatus(next.status)) {
running.value = false;
cancelling.value = false;
}
});
await executeSqlFile({
executionId: id,
connectionId: connectionId.value,
database: database.value.trim(),
filePath: preview.value.filePath,
continueOnError: continueOnError.value,
});
if (!isTerminalStatus(terminalStatus.value)) {
terminalStatus.value = "done";
}
} catch (e: any) {
terminalStatus.value = cancelling.value ? "cancelled" : "error";
terminalError.value = e?.message || String(e);
toast(terminalError.value, 5000);
} finally {
unlisten?.();
running.value = false;
cancelling.value = false;
}
}
async function cancelExecution() {
if (!executionId.value || !running.value || cancelling.value) return;
cancelling.value = true;
try {
await cancelSqlFileExecution(executionId.value);
} catch (e: any) {
cancelling.value = false;
toast(e?.message || String(e), 5000);
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && running.value) return;
open.value = nextOpen;
}
watch(connectionId, (id) => {
const config = store.getConfig(id);
database.value = props.prefillDatabase ?? config?.database ?? "";
});
watch(open, (value) => {
if (value) resetState();
});
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogScrollContent class="sm:max-w-[620px]" :trap-focus="false" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<FileCode class="w-4 h-4" />
{{ t('sqlFile.title') }}
</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-3">
<div class="space-y-3">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.file') }}
</div>
<div class="flex items-center gap-2">
<Input
:model-value="filePath"
readonly
class="h-8 text-xs font-mono"
:placeholder="t('sqlFile.selectSqlFile')"
/>
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
{{ t('sqlFile.browse') }}
</Button>
</div>
<div v-if="preview" class="border rounded-md overflow-hidden">
<div class="flex items-center justify-between gap-3 px-3 py-2 text-xs border-b bg-muted/40">
<div class="min-w-0 flex items-center gap-2">
<FileCode class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{{ preview.fileName }}</span>
</div>
<span class="text-muted-foreground shrink-0">{{ formatBytes(preview.sizeBytes) }}</span>
</div>
<pre class="max-h-40 overflow-auto p-3 text-xs font-mono whitespace-pre-wrap bg-muted/15">{{ preview.preview }}</pre>
</div>
</div>
<div class="space-y-3">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.target') }}
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t('sqlFile.connection') }}</Label>
<Select v-model="connectionId" :disabled="running">
<SelectTrigger class="h-8 text-xs">
<div v-if="connectionId" class="flex items-center gap-1.5 min-w-0">
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ selectedConnection?.name ?? connectionId }}</span>
</div>
<SelectValue v-else :placeholder="t('sqlFile.selectConnection')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
<div class="flex items-center gap-1.5">
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
{{ c.name }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t('sqlFile.database') }}</Label>
<Input
v-model="database"
class="h-8 text-xs"
:disabled="running"
:placeholder="t('sqlFile.databasePlaceholder')"
/>
</div>
</div>
</div>
<div class="space-y-2.5">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t('sqlFile.options') }}
</div>
<button
type="button"
class="flex items-center gap-2 text-xs text-left"
:disabled="running"
@click="continueOnError = !continueOnError"
>
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t('sqlFile.continueOnError') }}
</button>
</div>
<div v-if="running || terminalStatus !== 'idle' || progress" class="space-y-3">
<div class="flex items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-1.5 min-w-0" :class="statusTone">
<component :is="statusIcon" class="w-3.5 h-3.5 shrink-0" :class="{ 'animate-spin': running }" />
<span class="font-medium truncate">
{{ cancelling ? t('sqlFile.cancelling') : statusLabel(terminalStatus) }}
</span>
</div>
<span v-if="progress" class="text-muted-foreground shrink-0">
{{ formatElapsed(progress.elapsedMs) }}
</span>
</div>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="terminalStatus === 'error' ? 'bg-destructive' : terminalStatus === 'cancelled' ? 'bg-yellow-500' : 'bg-primary'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="grid grid-cols-4 gap-2 text-xs">
<div class="border rounded-md px-2 py-1.5">
<div class="text-muted-foreground">{{ t('sqlFile.statement') }}</div>
<div class="font-medium">{{ progress?.statementIndex ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5">
<div class="text-muted-foreground">{{ t('sqlFile.succeeded') }}</div>
<div class="font-medium text-green-600">{{ progress?.successCount ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5">
<div class="text-muted-foreground">{{ t('sqlFile.failed') }}</div>
<div class="font-medium text-destructive">{{ progress?.failureCount ?? 0 }}</div>
</div>
<div class="border rounded-md px-2 py-1.5">
<div class="text-muted-foreground">{{ t('sqlFile.affectedRows') }}</div>
<div class="font-medium">{{ (progress?.affectedRows ?? 0).toLocaleString() }}</div>
</div>
</div>
<div v-if="progress?.statementSummary" class="space-y-1">
<Label class="text-xs">{{ t('sqlFile.currentStatement') }}</Label>
<div class="border rounded-md p-2 text-xs font-mono bg-muted/15 max-h-20 overflow-auto whitespace-pre-wrap">
{{ progress.statementSummary }}
</div>
</div>
<div v-if="progress?.error || terminalError" class="border rounded-md p-2 text-xs text-destructive bg-destructive/5">
{{ progress?.error || terminalError }}
</div>
</div>
</div>
<DialogFooter>
<template v-if="running">
<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" />
{{ cancelling ? t('sqlFile.cancelling') : t('sqlFile.cancel') }}
</Button>
</template>
<template v-else>
<Button variant="outline" size="sm" @click="open = false">
{{ t('common.close') }}
</Button>
<Button size="sm" :disabled="!canStart" @click="startExecution">
<Play class="w-3.5 h-3.5 mr-1.5" />
{{ t('sqlFile.execute') }}
</Button>
</template>
</DialogFooter>
</DialogScrollContent>
</Dialog>
</template>