feat: initial release

Multi-database management tool built with Tauri + Vue 3.
Supports MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server.
This commit is contained in:
t8y2
2026-04-29 18:09:23 +08:00
commit b2e6dfad7e
156 changed files with 21979 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Sparkles, Loader2, Settings } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useSettingsStore, type AiProvider } from "@/stores/settingsStore";
import { generateSql } from "@/lib/ai";
const { t } = useI18n();
const settings = useSettingsStore();
const props = defineProps<{
tableContext: string;
}>();
const emit = defineEmits<{
insertSql: [sql: string];
}>();
const prompt = ref("");
const isGenerating = ref(false);
const showSettings = ref(false);
const error = ref("");
const tempProvider = ref<AiProvider>(settings.aiConfig.provider);
const tempApiKey = ref(settings.aiConfig.apiKey);
const tempEndpoint = ref(settings.aiConfig.endpoint);
const tempModel = ref(settings.aiConfig.model);
function openSettings() {
tempProvider.value = settings.aiConfig.provider;
tempApiKey.value = settings.aiConfig.apiKey;
tempEndpoint.value = settings.aiConfig.endpoint;
tempModel.value = settings.aiConfig.model;
showSettings.value = true;
}
function saveSettings() {
settings.updateAiConfig({
provider: tempProvider.value,
apiKey: tempApiKey.value,
endpoint: tempEndpoint.value,
model: tempModel.value,
});
showSettings.value = false;
}
async function generate() {
if (!prompt.value.trim()) return;
if (!settings.isConfigured()) {
openSettings();
return;
}
isGenerating.value = true;
error.value = "";
try {
const sql = await generateSql(settings.aiConfig, prompt.value, props.tableContext);
emit("insertSql", sql.trim());
prompt.value = "";
} catch (e: any) {
error.value = String(e.message || e);
} finally {
isGenerating.value = false;
}
}
</script>
<template>
<div class="flex items-center gap-1 px-2 py-1 border-t bg-muted/20">
<Sparkles class="w-3.5 h-3.5 text-purple-500 shrink-0" />
<Input
v-model="prompt"
class="h-6 text-xs flex-1 border-0 shadow-none focus-visible:ring-0"
:placeholder="t('ai.placeholder')"
@keydown.enter="generate"
/>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="isGenerating" @click="generate">
<Loader2 v-if="isGenerating" class="h-3 w-3 animate-spin" />
<Sparkles v-else class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="openSettings">
<Settings class="h-3 w-3" />
</Button>
<span v-if="error" class="text-destructive text-xs truncate max-w-40">{{ error }}</span>
</div>
<!-- Settings Dialog -->
<Dialog v-model:open="showSettings">
<DialogContent class="sm:max-w-96">
<DialogHeader>
<DialogTitle>{{ t('ai.settings') }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-3">
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t('ai.provider') }}</Label>
<Select :model-value="tempProvider" @update:model-value="(v: any) => tempProvider = v">
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API Key</Label>
<Input v-model="tempApiKey" type="password" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Endpoint</Label>
<Input v-model="tempEndpoint" class="col-span-2 h-8 text-xs" />
</div>
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Model</Label>
<Input v-model="tempModel" class="col-span-2 h-8 text-xs" />
</div>
</div>
<DialogFooter>
<Button size="sm" @click="saveSettings">{{ t('grid.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { AlertTriangle } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
defineProps<{
sql: string;
}>();
const emit = defineEmits<{
confirm: [];
}>();
function onConfirm() {
open.value = false;
emit("confirm");
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
{{ t('dangerDialog.title') }}
</DialogTitle>
</DialogHeader>
<div class="py-4">
<p class="text-sm text-muted-foreground mb-3">{{ t('dangerDialog.message') }}</p>
<pre class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ sql }}</pre>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
<Button variant="destructive" @click="onConfirm">{{ t('dangerDialog.confirm') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
const props = defineProps<{
modelValue: string;
dialect?: "mysql" | "postgres";
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
execute: [];
}>();
const editorRef = ref<HTMLDivElement>();
const view = shallowRef<EditorViewType | null>(null);
onMounted(async () => {
if (!editorRef.value) return;
const [
{ EditorView, keymap },
{ EditorState },
{ sql, MySQL, PostgreSQL },
{ basicSetup },
{ oneDark },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-sql"),
import("codemirror"),
import("@codemirror/theme-one-dark"),
]);
const dialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
const runKeymap = keymap.of([
{
key: "Mod-Enter",
run: () => {
emit("execute");
return true;
},
},
]);
const state = EditorState.create({
doc: props.modelValue,
extensions: [
basicSetup,
sql({ dialect }),
oneDark,
runKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
emit("update:modelValue", update.state.doc.toString());
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "13px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
}),
],
});
view.value = new EditorView({ state, parent: editorRef.value });
});
watch(
() => props.modelValue,
(val) => {
if (view.value && val !== view.value.state.doc.toString()) {
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
}
}
);
onBeforeUnmount(() => {
view.value?.destroy();
});
</script>
<template>
<div ref="editorRef" class="h-full w-full overflow-hidden" />
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, Trash2, Clock, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useHistoryStore } from "@/stores/historyStore";
const { t } = useI18n();
const store = useHistoryStore();
const emit = defineEmits<{
restore: [sql: string];
close: [];
}>();
const searchText = ref("");
const filtered = computed(() => {
if (!searchText.value) return store.entries;
const q = searchText.value.toLowerCase();
return store.entries.filter((e) =>
e.sql.toLowerCase().includes(q) || e.connection_name.toLowerCase().includes(q) || e.database.toLowerCase().includes(q)
);
});
function restore(sql: string) {
emit("restore", sql);
}
function copySql(sql: string) {
navigator.clipboard.writeText(sql);
}
function formatTime(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function truncateSql(sql: string): string {
const line = sql.replace(/\s+/g, " ").trim();
return line.length > 120 ? line.slice(0, 120) + "..." : line;
}
onMounted(() => store.load());
</script>
<template>
<div class="h-full flex flex-col overflow-hidden border-l">
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 bg-muted/20">
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-medium">{{ t('history.title') }}</span>
<span class="flex-1" />
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="store.clear()">
<Trash2 class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
<X class="h-3 w-3" />
</Button>
</div>
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
v-model="searchText"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('history.search')"
/>
</div>
<div class="flex-1 overflow-y-auto">
<ContextMenu v-for="entry in filtered" :key="entry.id">
<ContextMenuTrigger as-child>
<div
class="px-3 py-2 border-b border-border/50 cursor-pointer hover:bg-accent/50 text-xs"
@click="restore(entry.sql)"
>
<div class="flex items-center gap-1 mb-0.5">
<span class="font-medium truncate">{{ entry.connection_name }}</span>
<span v-if="entry.database" class="text-muted-foreground">/ {{ entry.database }}</span>
<span class="ml-auto text-muted-foreground shrink-0">{{ formatTime(entry.executed_at) }}</span>
</div>
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
{{ entry.success ? `${entry.execution_time_ms}ms` : t('history.failed') }}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-40">
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="store.remove(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('history.empty') }}
</div>
</div>
</div>
</template>