From 90ec8c13066dbe676fdaad2d874c726e734fe7dc Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 1 May 2026 01:03:13 +0800 Subject: [PATCH] feat: add data export functionality and version display in UI --- src/App.vue | 10 +++ src/components/sidebar/TreeItem.vue | 86 ++++++++++++++++++- .../ui/context-menu/ContextMenuSubContent.vue | 27 +++--- .../ui/context-menu/ContextMenuSubTrigger.vue | 2 +- src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/App.vue b/src/App.vue index d53df74b0..d3ca75034 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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>({}); const checkingUpdates = ref(false); const updateInfo = ref(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() { + + +
+ DBX {{ appVersion ? 'v' + appVersion : '' }} + · + GitHub +
diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 26e83c1ea..e415affb4 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -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 = {}; + 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() { {{ t('contextMenu.newQuery') }} + + + {{ t('contextMenu.exportData') }} + + + CSV + JSON + SQL INSERT + + + + {{ t('contextMenu.exportStructure') }} + + {{ t('contextMenu.refreshChildren') }} diff --git a/src/components/ui/context-menu/ContextMenuSubContent.vue b/src/components/ui/context-menu/ContextMenuSubContent.vue index 61b26c9b2..35ee1cd36 100644 --- a/src/components/ui/context-menu/ContextMenuSubContent.vue +++ b/src/components/ui/context-menu/ContextMenuSubContent.vue @@ -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) diff --git a/src/components/ui/context-menu/ContextMenuSubTrigger.vue b/src/components/ui/context-menu/ContextMenuSubTrigger.vue index 8f84c271d..c70d7162e 100644 --- a/src/components/ui/context-menu/ContextMenuSubTrigger.vue +++ b/src/components/ui/context-menu/ContextMenuSubTrigger.vue @@ -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, )" > diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3011f19fa..7ef23be1d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b2f5d5b88..c525ca203 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -183,6 +183,8 @@ export default { closeOtherTabs: "关闭其他标签页", closeAllTabs: "关闭全部标签页", copyName: "复制名称", + exportData: "导出数据", + exportStructure: "导出表结构", }, tree: { columns: "字段",