mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
refactor: split App.vue into composables and layout components
Extract 11 composables and 5 layout components from App.vue, reducing it from 1921 to 327 lines. Unify dialog overlay style across DialogContent and DialogScrollContent for consistent backdrop appearance.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue"));
|
||||
const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue"));
|
||||
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
|
||||
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue"));
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
|
||||
const props = defineProps<{
|
||||
showConnectionDialog: boolean;
|
||||
showSettingsDialog: boolean;
|
||||
showDangerDialog: boolean;
|
||||
dangerSql: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:showConnectionDialog": [value: boolean];
|
||||
"update:showSettingsDialog": [value: boolean];
|
||||
"update:showDangerDialog": [value: boolean];
|
||||
dangerConfirm: [];
|
||||
connectStarted: [name: string];
|
||||
connectSucceeded: [name: string];
|
||||
connectFailed: [message: string];
|
||||
structureEditorSaved: [];
|
||||
openLineageTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; columnName?: string }];
|
||||
openDatabaseSearchTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; whereInput?: string }];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const dialogs = useDialogSources();
|
||||
|
||||
const editConfig = computed(() => {
|
||||
const id = connectionStore.editingConnectionId;
|
||||
if (!id) return undefined;
|
||||
return connectionStore.getConfig(id);
|
||||
});
|
||||
|
||||
watch(editConfig, (v) => {
|
||||
if (v) emit("update:showConnectionDialog", true);
|
||||
});
|
||||
|
||||
watch(() => props.showConnectionDialog, (v) => {
|
||||
if (!v) connectionStore.stopEditing();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConnectionDialog
|
||||
:open="showConnectionDialog"
|
||||
:edit-config="editConfig"
|
||||
@update:open="emit('update:showConnectionDialog', $event)"
|
||||
@connect-started="emit('connectStarted', $event)"
|
||||
@connect-succeeded="emit('connectSucceeded', $event)"
|
||||
@connect-failed="emit('connectFailed', $event)"
|
||||
/>
|
||||
<EditorSettingsDialog
|
||||
:open="showSettingsDialog"
|
||||
@update:open="emit('update:showSettingsDialog', $event)"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
:open="showDangerDialog"
|
||||
:sql="dangerSql"
|
||||
@update:open="emit('update:showDangerDialog', $event)"
|
||||
@confirm="emit('dangerConfirm')"
|
||||
/>
|
||||
<DataTransferDialog
|
||||
v-model:open="dialogs.showTransferDialog.value"
|
||||
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.transferPrefillDatabase.value"
|
||||
/>
|
||||
<SchemaDiffDialog
|
||||
v-model:open="dialogs.showSchemaDiffDialog.value"
|
||||
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
|
||||
/>
|
||||
<SqlFileExecutionDialog
|
||||
v-model:open="dialogs.showSqlFileDialog.value"
|
||||
:prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.sqlFilePrefillDatabase.value"
|
||||
/>
|
||||
<SchemaDiagramDialog
|
||||
v-model:open="dialogs.showDiagramDialog.value"
|
||||
:prefill-connection-id="dialogs.diagramPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.diagramPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.diagramPrefillSchema.value"
|
||||
:focus-table-name="dialogs.diagramFocusTableName.value"
|
||||
/>
|
||||
<TableImportDialog
|
||||
v-model:open="dialogs.showTableImportDialog.value"
|
||||
:prefill-connection-id="dialogs.tableImportPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.tableImportPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.tableImportPrefillSchema.value"
|
||||
:prefill-table="dialogs.tableImportPrefillTable.value"
|
||||
/>
|
||||
<TableStructureEditorDialog
|
||||
v-model:open="dialogs.showStructureEditorDialog.value"
|
||||
:prefill-connection-id="dialogs.structurePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.structurePrefillDatabase.value"
|
||||
:prefill-schema="dialogs.structurePrefillSchema.value"
|
||||
:prefill-table="dialogs.structurePrefillTable.value"
|
||||
@saved="emit('structureEditorSaved')"
|
||||
/>
|
||||
<FieldLineageDialog
|
||||
v-model:open="dialogs.showFieldLineageDialog.value"
|
||||
:prefill-connection-id="dialogs.lineagePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.lineagePrefillDatabase.value"
|
||||
:prefill-schema="dialogs.lineagePrefillSchema.value"
|
||||
:prefill-table="dialogs.lineagePrefillTable.value"
|
||||
:prefill-column="dialogs.lineagePrefillColumn.value"
|
||||
@open-target="emit('openLineageTarget', $event)"
|
||||
/>
|
||||
<DatabaseSearchDialog
|
||||
v-model:open="dialogs.showDatabaseSearchDialog.value"
|
||||
:prefill-connection-id="dialogs.databaseSearchPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.databaseSearchPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.databaseSearchPrefillSchema.value"
|
||||
@open-target="emit('openDatabaseSearchTarget', $event)"
|
||||
/>
|
||||
<ConfigPassphraseDialog
|
||||
v-model:open="dialogs.showConfigPassphraseDialog.value"
|
||||
:mode="dialogs.configPassphraseMode.value"
|
||||
:external-error="dialogs.configPassphraseError.value"
|
||||
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
|
||||
/>
|
||||
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('configExport.importLayoutTitle') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('configExport.importLayoutConfirm') }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button @click="dialogs.showImportLayoutConfirm.value = false; dialogs.pendingImportLayout.value && connectionStore.applySidebarLayout(dialogs.pendingImportLayout.value)">{{ t('configExport.importLayoutApply') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Upload, Download } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
|
||||
defineProps<{
|
||||
sidebarWidth: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [];
|
||||
export: [];
|
||||
startResize: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full shrink-0 relative select-none" :style="{ width: sidebarWidth + 'px' }">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="h-9 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
|
||||
{{ t('sidebar.connections') }}
|
||||
<span class="flex-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('import')">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.import') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.export') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ConnectionTree />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-resize-handle panel-resize-handle--right" @mousedown="emit('startResize', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import {
|
||||
connectionColor,
|
||||
tabDisplayTitle,
|
||||
tabTooltipLines,
|
||||
tabModeLabel,
|
||||
} from "@/lib/tabPresentation";
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const tabsContainerRef = ref<HTMLElement | null>(null);
|
||||
const { canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs } = useTabScroll(tabsContainerRef);
|
||||
|
||||
watch(() => queryStore.tabs.length, () => {
|
||||
nextTick(updateScrollButtons);
|
||||
});
|
||||
|
||||
watch(() => queryStore.activeTabId, () => {
|
||||
nextTick(() => {
|
||||
const container = tabsContainerRef.value;
|
||||
if (!container) return;
|
||||
const activeEl = container.querySelector('[data-active-tab="true"]');
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
}
|
||||
updateScrollButtons();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="queryStore.tabs.length > 0" class="relative h-9 flex items-center border-b bg-muted/20 shrink-0">
|
||||
<button
|
||||
v-if="canScrollLeft"
|
||||
class="absolute left-0 z-10 h-full px-1 bg-linear-to-r from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground"
|
||||
:aria-label="t('tabs.scrollLeft')"
|
||||
@click="scrollTabs('left')"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4 rotate-180" />
|
||||
</button>
|
||||
<div
|
||||
ref="tabsContainerRef"
|
||||
class="flex-1 flex items-center overflow-x-auto min-w-0"
|
||||
style="-ms-overflow-style:none;scrollbar-width:none;-webkit-overflow-scrolling:touch"
|
||||
@scroll="updateScrollButtons"
|
||||
>
|
||||
<ContextMenu
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
>
|
||||
<ContextMenuTrigger as-child>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="group flex min-w-38 items-center gap-1 px-1 h-full text-xs cursor-pointer border-r transition-colors whitespace-nowrap"
|
||||
:class="tab.id === queryStore.activeTabId ? 'bg-background font-medium' : 'font-normal text-muted-foreground'"
|
||||
:data-active-tab="tab.id === queryStore.activeTabId"
|
||||
@click="queryStore.activeTabId = tab.id"
|
||||
>
|
||||
<span class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }" />
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
|
||||
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
|
||||
@click.stop="queryStore.togglePinnedTab(tab.id)"
|
||||
>
|
||||
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
class="shrink-0 rounded border px-1 text-[10px] leading-4"
|
||||
:class="tab.mode === 'data' ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300' : 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'"
|
||||
>
|
||||
{{ tabModeLabel(tab) }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-44">
|
||||
<ContextMenuItem @click="queryStore.togglePinnedTab(tab.id)">
|
||||
<Pin class="w-3.5 h-3.5 mr-2" :class="{ 'fill-current': tab.pinned }" />
|
||||
{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="queryStore.closeTab(tab.id)">
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeTab') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:disabled="queryStore.tabs.length <= 1"
|
||||
@click="queryStore.closeOtherTabs(tab.id)"
|
||||
>
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeOtherTabs') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="queryStore.closeAllTabs">
|
||||
<X class="w-3.5 h-3.5 mr-2" />
|
||||
{{ t('contextMenu.closeAllTabs') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
<button
|
||||
v-if="canScrollRight"
|
||||
class="absolute right-0 z-10 h-full px-1 bg-linear-to-l from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground"
|
||||
:aria-label="t('tabs.scrollRight')"
|
||||
@click="scrollTabs('right')"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Loader2, Globe, Moon, Sun, History, Bot, ArrowLeftRight, FileCode, Settings, CloudDownload } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
defineProps<{
|
||||
isDark: boolean
|
||||
showAiPanel: boolean
|
||||
showHistory: boolean
|
||||
checkingUpdates: boolean
|
||||
hasConnections: boolean
|
||||
hasSqlFileConnections: boolean
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'new-connection': []
|
||||
'new-query': []
|
||||
'toggle-theme': []
|
||||
'toggle-locale': []
|
||||
'toggle-ai': []
|
||||
'toggle-history': []
|
||||
'open-github': []
|
||||
'open-settings': []
|
||||
'check-updates': []
|
||||
'open-transfer': []
|
||||
'open-sql-file': []
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-connection')">
|
||||
<DatabaseZap class="h-3.5 w-3.5" />
|
||||
{{ t('toolbar.newConnection') }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-query')" :disabled="!hasConnections">
|
||||
<FilePlus2 class="h-3.5 w-3.5" />
|
||||
{{ t('toolbar.newQuery') }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-transfer')" :disabled="!hasConnections">
|
||||
<ArrowLeftRight class="h-3.5 w-3.5" />
|
||||
{{ t('transfer.dataTransfer') }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-sql-file')" :disabled="!hasSqlFileConnections">
|
||||
<FileCode class="h-3.5 w-3.5" />
|
||||
{{ t('sqlFile.title') }}
|
||||
</Button>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="checkingUpdates" @click="emit('check-updates')">
|
||||
<Loader2 v-if="checkingUpdates" class="h-4 w-4 animate-spin" />
|
||||
<CloudDownload v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('updates.check') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="emit('toggle-history')">
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('history.title') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
|
||||
<Bot class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>AI</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('toggle-theme')">
|
||||
<Moon v-if="!isDark" class="h-4 w-4" />
|
||||
<Sun v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isDark ? 'Light' : 'Dark' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('toggle-locale')">
|
||||
<Globe class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('common.language') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('open-github')">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z"/></svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('open-settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('settings.title') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Loader2, Square, Bot, Table2, GitBranch } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
|
||||
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
|
||||
import { databaseDisplayNameForTab } from "@/lib/tabPresentation";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
activeOutputView: "result" | "explain";
|
||||
formatSqlRequestId: number;
|
||||
selectedSql: string;
|
||||
cursorPos: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:activeOutputView": [value: "result" | "explain"];
|
||||
fixWithAi: [errorMessage: string];
|
||||
execute: [sqlOverride?: string];
|
||||
cancel: [];
|
||||
explain: [];
|
||||
editorUpdate: [value: string];
|
||||
editorSelectionChange: [value: string];
|
||||
editorCursorChange: [pos: number];
|
||||
formatError: [];
|
||||
reload: [];
|
||||
paginate: [offset: number, limit: number, whereInput?: string];
|
||||
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
|
||||
executeSql: [sql: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
switch (props.activeConnection?.db_type) {
|
||||
case "mysql":
|
||||
return "mysql";
|
||||
case "postgres":
|
||||
return "postgres";
|
||||
case "sqlite":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "sqlserver";
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
});
|
||||
|
||||
const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
props.activeConnection?.db_type === "postgres" ? "postgres" : "mysql"
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Query mode: editor + results -->
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="flex-1">
|
||||
<Pane :size="40" :min-size="15">
|
||||
<div class="h-full flex flex-col">
|
||||
<QueryEditor
|
||||
class="flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:dialect="editorDialect"
|
||||
:format-dialect="activeSqlFormatDialect"
|
||||
:format-request-id="formatSqlRequestId"
|
||||
@update:model-value="emit('editorUpdate', $event)"
|
||||
@selection-change="emit('editorSelectionChange', $event)"
|
||||
@cursor-change="emit('editorCursorChange', $event)"
|
||||
@format-error="emit('formatError')"
|
||||
@execute="emit('execute')"
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="60" :min-size="20">
|
||||
<div class="h-full flex flex-col">
|
||||
<div
|
||||
v-if="activeTab.result || activeTab.explainPlan || activeTab.explainError || activeTab.isExecuting || activeTab.isExplaining"
|
||||
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'result' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="!activeTab.result && !activeTab.isExecuting"
|
||||
@click="emit('update:activeOutputView', 'result')"
|
||||
>
|
||||
{{ t('tabs.tableData') }}
|
||||
</Button>
|
||||
<template v-if="activeOutputView === 'result' && activeTab.results && activeTab.results.length > 1">
|
||||
<span class="mx-1 h-4 w-px bg-border" />
|
||||
<Button
|
||||
v-for="(_, rIdx) in activeTab.results"
|
||||
:key="rIdx"
|
||||
size="sm"
|
||||
:variant="activeTab.activeResultIndex === rIdx ? 'default' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="queryStore.setActiveResultIndex(activeTab.id, rIdx)"
|
||||
>
|
||||
{{ t('tabs.resultN', { n: rIdx + 1 }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
|
||||
@click="emit('update:activeOutputView', 'explain')"
|
||||
>
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t('explain.title') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExplainPlanViewer
|
||||
v-if="activeOutputView === 'explain'"
|
||||
class="flex-1 min-h-0"
|
||||
:plan="activeTab.explainPlan"
|
||||
:error="activeTab.explainError"
|
||||
:loading="activeTab.isExplaining"
|
||||
:source-sql="activeTab.lastExplainedSql"
|
||||
:explain-sql="activeTab.explainSql"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<DataGrid v-if="activeTab.result" :key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
|
||||
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
|
||||
<Bot class="h-3.5 w-3.5 text-destructive" />
|
||||
<button class="text-xs text-destructive hover:underline" @click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
|
||||
{{ t('ai.fixWithAi') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('editor.pressToExecute') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
<!-- Data mode: full-height grid -->
|
||||
<template v-else-if="activeTab.mode === 'data'">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<Table2 class="h-3.5 w-3.5" />
|
||||
{{ t('tabs.tableData') }}
|
||||
</span>
|
||||
<span class="font-medium truncate">{{ activeTab.tableMeta?.tableName || activeTab.title }}</span>
|
||||
<span class="text-muted-foreground truncate">
|
||||
{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
|
||||
<template v-if="activeTab.tableMeta?.schema"> · {{ activeTab.tableMeta.schema }}</template>
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}
|
||||
</span>
|
||||
</div>
|
||||
<DataGrid
|
||||
v-if="activeTab.result"
|
||||
class="flex-1 min-h-0"
|
||||
:key="activeTab.id"
|
||||
:result="activeTab.result"
|
||||
:sql="activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
:editable="!!activeTab.tableMeta?.primaryKeys?.length"
|
||||
:database-type="activeConnection?.db_type"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:table-meta="activeTab.tableMeta"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
@reload="emit('reload')"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string) => emit('paginate', offset, limit, whereInput)"
|
||||
@sort="(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, direction, whereInput)"
|
||||
/>
|
||||
<div v-else-if="activeTab.isExecuting" class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
<div class="flex items-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
{{ t(queryExecutionLabelKey(activeTab)) }}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
:disabled="!canCancelQueryExecution(activeTab)"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else class="h-3.5 w-3.5 fill-current" />
|
||||
{{ t('toolbar.stopQuery') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:db="Number(activeTab.database)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB mode: document browser -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoDocBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:collection="activeTab.sql"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Play, Loader2, Square, Database, Table2, AlignLeft, GitBranch } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { connectionIconType } from "@/lib/connectionPresentation";
|
||||
import { connectionDisplayName } from "@/lib/tabPresentation";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
execute: [];
|
||||
cancel: [];
|
||||
explain: [];
|
||||
formatSql: [];
|
||||
changeConnection: [connectionId: string];
|
||||
changeDatabase: [database: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions } = useDatabaseOptions();
|
||||
|
||||
const activeDatabaseOptions = computed(() => {
|
||||
const connection = props.activeConnection;
|
||||
return connection ? databaseOptions.value[connection.id] ?? [] : [];
|
||||
});
|
||||
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
|
||||
function databaseDisplayName(database: string): string {
|
||||
const connection = props.activeConnection;
|
||||
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
|
||||
return database || t("editor.noDatabase");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
|
||||
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
|
||||
>
|
||||
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Square v-else-if="activeTab.isExecuting" class="h-3.5 w-3.5 fill-current" />
|
||||
<Play v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ activeTab.isExecuting ? t('toolbar.stopQuery') : t('toolbar.executeShortcut') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="activeTab.isExplaining ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())"
|
||||
@click="activeTab.isExplaining ? emit('cancel') : emit('explain')"
|
||||
>
|
||||
<Square v-if="activeTab.isExplaining" class="h-3.5 w-3.5 fill-current" />
|
||||
<GitBranch v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('formatSql')">
|
||||
<AlignLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span class="flex-1 min-w-0" />
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
|
||||
<Select
|
||||
:model-value="activeConnectionValue"
|
||||
@update:model-value="(v: any) => emit('changeConnection', v)"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0">
|
||||
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
|
||||
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ connectionDisplayName(activeConnectionValue) }}</span>
|
||||
</div>
|
||||
<SelectValue v-else :placeholder="t('editor.selectConnection')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem
|
||||
v-for="connection in connectionStore.connections"
|
||||
:key="connection.id"
|
||||
:value="connection.id"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<Select
|
||||
:model-value="activeDatabaseValue"
|
||||
@update:model-value="(v: any) => emit('changeDatabase', v)"
|
||||
@update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue :placeholder="loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')">
|
||||
{{ databaseDisplayName(activeDatabaseValue) }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem
|
||||
v-for="database in activeDatabaseOptions"
|
||||
:key="database"
|
||||
:value="database"
|
||||
>
|
||||
{{ databaseDisplayName(database) }}
|
||||
</SelectItem>
|
||||
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
|
||||
{{ databaseDisplayName(activeDatabaseValue) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeTab.tableMeta" class="flex min-w-0 items-center gap-1 ml-2">
|
||||
<Table2 class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { UpdateInfo } from "@/lib/api";
|
||||
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
|
||||
defineProps<{
|
||||
updateInfo: UpdateInfo | null
|
||||
updateCheckMessage: string
|
||||
isDownloadingUpdate: boolean
|
||||
downloadProgress: number
|
||||
updateReady: boolean
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-latest-release': []
|
||||
'download-and-install': []
|
||||
'restart': []
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-3 text-sm">
|
||||
<p v-if="updateInfo?.update_available">
|
||||
{{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }}
|
||||
</p>
|
||||
<p v-else class="text-muted-foreground">
|
||||
{{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }}
|
||||
</p>
|
||||
<div v-if="updateInfo?.update_available && updateInfo.release_notes" class="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
||||
{{ updateInfo.release_notes }}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<template v-if="updateInfo?.update_available">
|
||||
<Button variant="outline" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
|
||||
<Button v-if="updateReady" @click="emit('restart')">{{ t('updates.restart') }}</Button>
|
||||
<Button v-else-if="isDownloadingUpdate" disabled>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('updates.downloading', { progress: downloadProgress }) }}
|
||||
</Button>
|
||||
<Button v-else @click="emit('download-and-install')">{{ t('updates.downloadAndInstall') }}</Button>
|
||||
</template>
|
||||
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FilePlus2, Plus, History, Upload, Database, Search, ShieldCheck, Sparkles } from "lucide-vue-next";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { connectionDriverLabel, connectionIconType, connectionOptionSubtitle } from "@/lib/connectionPresentation";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
defineProps<{
|
||||
connectionStats: { total: number; connected: number; types: number }
|
||||
recentConnections: ConnectionConfig[]
|
||||
appVersion: string
|
||||
hasConnections: boolean
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-connection-query': [connectionId: string]
|
||||
'new-connection': []
|
||||
'new-query': []
|
||||
'show-history': []
|
||||
'import-config': []
|
||||
'open-github': []
|
||||
'open-mcp-guide': []
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto bg-background">
|
||||
<div class="mx-auto flex min-h-full w-full max-w-5xl flex-col justify-center gap-6 px-8 py-10">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database class="h-3.5 w-3.5" /> {{ t('welcome.connections') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.total }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck class="h-3.5 w-3.5" /> {{ t('welcome.connected') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.connected }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles class="h-3.5 w-3.5" /> {{ t('welcome.databaseTypes') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.types }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1.2fr_0.8fr] gap-4">
|
||||
<div class="rounded-lg border">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<div class="text-sm font-medium">{{ t('welcome.quickConnections') }}</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<button
|
||||
v-for="connection in recentConnections"
|
||||
:key="connection.id"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40"
|
||||
@click="emit('open-connection-query', connection.id)"
|
||||
>
|
||||
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-4 w-4" />
|
||||
<span class="h-5 w-1 rounded-full shrink-0" :style="{ backgroundColor: connection.color || '#9ca3af' }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ connection.name }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ connectionOptionSubtitle(connection) || connectionDriverLabel(connection) }}
|
||||
</div>
|
||||
</div>
|
||||
<FilePlus2 class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div v-if="recentConnections.length === 0" class="px-4 py-8 text-sm text-muted-foreground">
|
||||
{{ t('sidebar.noConnections') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border">
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="text-sm font-medium">{{ t('welcome.shortcuts') }}</div>
|
||||
</div>
|
||||
<div class="grid gap-1 p-2">
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('new-connection')">
|
||||
<Plus class="h-4 w-4" /> {{ t('toolbar.newConnection') }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!hasConnections" @click="emit('new-query')">
|
||||
<FilePlus2 class="h-4 w-4" /> {{ t('toolbar.newQuery') }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')">
|
||||
<History class="h-4 w-4" /> {{ t('history.title') }}
|
||||
</button>
|
||||
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')">
|
||||
<Upload class="h-4 w-4" /> {{ t('sidebar.import') }}
|
||||
</button>
|
||||
<div class="mt-2 rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
<Search class="mr-1 inline h-3.5 w-3.5" />
|
||||
{{ t('welcome.tip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Integration Hint -->
|
||||
<div class="rounded-lg border bg-muted/10 px-5 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Sparkles class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ t('welcome.mcpTitle') }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t('welcome.mcpDescription') }}</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<code class="rounded bg-muted px-2 py-0.5 text-[11px] select-all">npx @dbx-app/mcp-server</code>
|
||||
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{ t('welcome.mcpLearnMore') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Info -->
|
||||
<div class="mt-2 flex items-center justify-center gap-3 text-[11px] text-muted-foreground/60">
|
||||
<span>DBX {{ appVersion ? 'v' + appVersion : '' }}</span>
|
||||
<span>·</span>
|
||||
<a href="#" class="hover:text-foreground transition-colors" @click.prevent="emit('open-github')">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
|
||||
Reference in New Issue
Block a user