mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 08:23:34 +00:00
feat: add data export functionality and version display in UI
This commit is contained in:
@@ -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,
|
||||
)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user