feat: add data export functionality and version display in UI
This commit is contained in:
parent
e6ecb216d5
commit
90ec8c1306
10
src/App.vue
10
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<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ export default {
|
|||
closeOtherTabs: "关闭其他标签页",
|
||||
closeAllTabs: "关闭全部标签页",
|
||||
copyName: "复制名称",
|
||||
exportData: "导出数据",
|
||||
exportStructure: "导出表结构",
|
||||
},
|
||||
tree: {
|
||||
columns: "字段",
|
||||
|
|
|
|||
Loading…
Reference in New Issue