feat: add data export functionality and version display in UI

This commit is contained in:
t8y2 2026-05-01 01:03:13 +08:00
parent e6ecb216d5
commit 90ec8c1306
6 changed files with 115 additions and 14 deletions

View File

@ -43,6 +43,7 @@ import { useToast } from "@/composables/useToast";
import { setLocale, currentLocale, type Locale } from "@/i18n";
import { getCurrentWindow, type Theme } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { getVersion } from "@tauri-apps/api/app";
import * as api from "@/lib/tauri";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
@ -108,6 +109,7 @@ const loadingDatabaseOptions = ref<Record<string, boolean>>({});
const checkingUpdates = ref(false);
const updateInfo = ref<api.UpdateInfo | null>(null);
const updateCheckMessage = ref("");
const appVersion = ref("");
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
const editConfig = computed(() => {
@ -575,6 +577,7 @@ onMounted(() => {
window.addEventListener("keydown", handleKeydown, true);
setupFileDrop();
checkUpdates({ silent: true });
getVersion().then((v) => { appVersion.value = v; });
});
onUnmounted(() => {
@ -1090,6 +1093,13 @@ async function setupFileDrop() {
</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="openGitHub">GitHub</a>
</div>
</div>
</div>
</div>

View File

@ -5,11 +5,12 @@ import {
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
Pin, ArrowRightLeft,
Pin, ArrowRightLeft, Download, FileCode,
} from "lucide-vue-next";
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
ContextMenuSeparator, ContextMenuTrigger,
ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
} from "@/components/ui/context-menu";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@ -225,6 +226,75 @@ function copyName() {
navigator.clipboard.writeText(props.node.label);
}
async function exportStructure() {
const node = props.node;
if (!node.connectionId || !node.database) return;
try {
await connectionStore.ensureConnected(node.connectionId);
const ddl = await api.getTableDdl(node.connectionId, node.database, node.schema || node.database, node.label);
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({ defaultPath: `${node.label}.sql`, filters: [{ name: "SQL", extensions: ["sql"] }] });
if (path) await writeTextFile(path, ddl + "\n");
} catch (e: any) {
console.error("Export structure failed:", e);
}
}
async function exportData(format: "csv" | "json" | "sql") {
const node = props.node;
if (!node.connectionId || !node.database) return;
const config = connectionStore.getConfig(node.connectionId);
if (!config) return;
try {
await connectionStore.ensureConnected(node.connectionId);
const qualifiedName = (config.db_type === "postgres" || config.db_type === "oracle" || config.db_type === "sqlserver") && node.schema
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
: quoteIdent(node.label);
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
let content: string;
let ext: string;
if (format === "csv") {
ext = "csv";
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const header = result.columns.map(esc).join(",");
const body = result.rows.map((row) => row.map((c) => esc(c === null ? "" : String(c))).join(",")).join("\n");
content = `${header}\n${body}`;
} else if (format === "json") {
ext = "json";
const data = result.rows.map((row) => {
const obj: Record<string, unknown> = {};
result.columns.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
content = JSON.stringify(data, null, 2);
} else {
ext = "sql";
const cols = result.columns.map((c) => quoteIdent(c)).join(", ");
const lines = result.rows.map((row) => {
const vals = row.map((v) => {
if (v === null) return "NULL";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
}).join(", ");
return `INSERT INTO ${qualifiedName} (${cols}) VALUES (${vals});`;
});
content = lines.join("\n");
}
const path = await save({ defaultPath: `${node.label}.${ext}`, filters: [{ name: ext.toUpperCase(), extensions: [ext] }] });
if (path) await writeTextFile(path, content);
} catch (e: any) {
console.error("Export data failed:", e);
}
}
function editConnection() {
if (props.node.connectionId) {
connectionStore.startEditing(props.node.connectionId);
@ -393,6 +463,20 @@ async function showMore() {
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download class="w-4 h-4" /> {{ t('contextMenu.exportData') }}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem @click="exportData('csv')">CSV</ContextMenuItem>
<ContextMenuItem @click="exportData('json')">JSON</ContextMenuItem>
<ContextMenuItem @click="exportData('sql')">SQL INSERT</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem @click="exportStructure">
<FileCode class="w-4 h-4" /> {{ t('contextMenu.exportStructure') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
</ContextMenuItem>

View File

@ -3,6 +3,7 @@ import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'r
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuPortal,
ContextMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
@ -17,16 +18,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-popover-foreground min-w-32 rounded-lg ring-foreground/10 ring-1 p-1 duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
<ContextMenuPortal>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-popover-foreground min-w-32 rounded-lg ring-foreground/10 ring-1 p-1 duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</ContextMenuPortal>
</template>

View File

@ -23,7 +23,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>

View File

@ -178,6 +178,8 @@ export default {
closeOtherTabs: "Close Other Tabs",
closeAllTabs: "Close All Tabs",
copyName: "Copy Name",
exportData: "Export Data",
exportStructure: "Export Structure",
},
tree: {
columns: "Columns",

View File

@ -183,6 +183,8 @@ export default {
closeOtherTabs: "关闭其他标签页",
closeAllTabs: "关闭全部标签页",
copyName: "复制名称",
exportData: "导出数据",
exportStructure: "导出表结构",
},
tree: {
columns: "字段",