Merge pull request #83 from wu-clan/refactor-new-connection

feat: refactor the new connection dialog
This commit is contained in:
skyler 2026-05-04 17:22:54 +08:00 committed by GitHub
commit ae34d33a1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 327 additions and 120 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
@ -8,19 +8,24 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/tauri";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { FolderOpen } from "lucide-vue-next";
import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, List, Search } from "lucide-vue-next";
type DbOption = { value: string; label: string };
type DbCategory = { key: string; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
type ConfigTab = "connection" | "ssh";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@ -38,6 +43,7 @@ const isTesting = ref(false);
const isSaving = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const editingId = ref<string | null>(null);
let testRunId = 0;
const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
name: "",
@ -67,6 +73,10 @@ const form = ref(defaultForm());
const selectedType = ref("mysql");
const customDriverName = ref("");
const mongoUseUrl = ref(false);
const dialogStep = ref<DialogStep>("select");
const dbPickerView = ref<DbPickerView>("icon");
const dbSearchQuery = ref("");
const configTab = ref<ConfigTab>("connection");
const colorOptions = [
{ value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" },
@ -169,24 +179,26 @@ watch(() => props.editConfig, (config) => {
selectedType.value = profile;
mongoUseUrl.value = !!config.connection_string;
customDriverName.value = isCustomCompatibleProfile() ? (config.driver_label || "") : "";
dialogStep.value = "config";
configTab.value = "connection";
} else {
editingId.value = null;
form.value = defaultForm();
selectedType.value = "mysql";
customDriverName.value = "";
dialogStep.value = "select";
configTab.value = "connection";
}
testResult.value = null;
resetTestState();
});
const isEditing = ref(false);
watch(() => editingId.value, (v) => { isEditing.value = !!v; });
const dbTypeMenuOpen = ref(false);
function onDbTypeChange(val: string) {
customDriverName.value = "";
applyProfile(val, !!editingId.value);
dbTypeMenuOpen.value = false;
resetTestState();
}
const iconTypeMap: Record<string, string> = {
@ -237,6 +249,55 @@ const pgCompat = [
{ value: "custom_postgres", label: "Custom" },
];
const dbCategories = computed<DbCategory[]>(() => [
{ key: "mainstream", title: t("connection.mainstream"), options: dbOptions },
{ key: "mysql", title: `MySQL ${t("connection.compatible")}`, options: mysqlCompat },
{ key: "postgres", title: `PostgreSQL ${t("connection.compatible")}`, options: pgCompat },
]);
const filteredDbCategories = computed<DbCategory[]>(() => {
const keyword = dbSearchQuery.value.trim().toLowerCase();
if (!keyword) return dbCategories.value;
return dbCategories.value
.map((category) => ({
...category,
options: category.options.filter((option) => {
const profile = driverProfiles[option.value];
return [
option.label,
option.value,
profile?.label,
profile?.type,
category.title,
].some((value) => String(value || "").toLowerCase().includes(keyword));
}),
}))
.filter((category) => category.options.length > 0);
});
const hasDbPickerResults = computed(() => filteredDbCategories.value.some((category) => category.options.length > 0));
const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value);
const canUseSsh = computed(() => form.value.db_type !== "sqlite");
const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
});
function goToConnectionStep(value = selectedType.value) {
if (value !== selectedType.value) {
onDbTypeChange(value);
}
dialogStep.value = "config";
configTab.value = "connection";
dbSearchQuery.value = "";
}
function backToDatabasePicker() {
dialogStep.value = "select";
resetTestState();
}
watch(customDriverName, (value) => {
if (isCustomCompatibleProfile()) {
form.value.driver_label = value.trim() || selectedProfile().label;
@ -244,35 +305,69 @@ watch(customDriverName, (value) => {
});
async function testConnection() {
const runId = ++testRunId;
isTesting.value = true;
testResult.value = null;
try {
const config: ConnectionConfig = { ...form.value, id: editingId.value || crypto.randomUUID() };
const msg = await api.testConnection(config);
if (runId !== testRunId) return;
testResult.value = { ok: true, message: msg };
} catch (e: any) {
if (runId !== testRunId) return;
testResult.value = { ok: false, message: String(e) };
} finally {
isTesting.value = false;
if (runId === testRunId) {
isTesting.value = false;
}
}
}
function resetTestState() {
testRunId += 1;
isTesting.value = false;
testResult.value = null;
}
async function copyTestResult() {
if (!testResultMessage.value) return;
await navigator.clipboard.writeText(testResultMessage.value);
toast(t("grid.copied"));
}
function resetForm() {
editingId.value = null;
form.value = defaultForm();
selectedType.value = "mysql";
customDriverName.value = "";
mongoUseUrl.value = false;
testResult.value = null;
dialogStep.value = "select";
dbPickerView.value = "icon";
dbSearchQuery.value = "";
configTab.value = "connection";
resetTestState();
}
watch(open, (value) => {
if (!value) resetForm();
if (!value) {
resetForm();
return;
}
if (!props.editConfig) {
resetForm();
}
});
watch(canUseSsh, (value) => {
if (!value && configTab.value === "ssh") {
configTab.value = "connection";
}
});
async function save() {
if (isSaving.value) return;
isSaving.value = true;
testResult.value = null;
resetTestState();
try {
if (editingId.value) {
const updated: ConnectionConfig = { ...form.value, id: editingId.value };
@ -319,11 +414,119 @@ async function browseSshKeyPath() {
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[480px]">
<DialogContent :class="dialogStep === 'select' ? 'sm:max-w-[760px]' : 'sm:max-w-[560px]'">
<DialogHeader>
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
</DialogHeader>
<template v-if="dialogStep === 'select'">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<div class="flex items-center gap-2">
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
<Button
type="button"
size="icon-sm"
:variant="dbPickerView === 'icon' ? 'secondary' : 'ghost'"
:title="t('connection.iconView')"
:aria-label="t('connection.iconView')"
@click="dbPickerView = 'icon'"
>
<Grid3X3 class="h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
:variant="dbPickerView === 'list' ? 'secondary' : 'ghost'"
:title="t('connection.listView')"
:aria-label="t('connection.listView')"
@click="dbPickerView = 'list'"
>
<List class="h-3.5 w-3.5" />
</Button>
</div>
<div class="relative w-full sm:w-64">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="dbSearchQuery"
class="h-9 pl-8"
:placeholder="t('connection.searchDatabasePlaceholder')"
/>
</div>
</div>
</div>
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
<div class="flex items-center">
<h3 class="text-sm font-medium">{{ category.title }}</h3>
</div>
<div v-if="dbPickerView === 'icon'" class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-5">
<button
v-for="opt in category.options"
:key="opt.value"
type="button"
class="group flex min-h-24 flex-col items-center justify-center gap-2 rounded-xl border bg-background/70 p-3 text-center transition hover:-translate-y-0.5 hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="selectedType === opt.value ? 'border-primary bg-primary/10 shadow-sm ring-1 ring-primary/30' : 'border-border'"
:aria-pressed="selectedType === opt.value"
@click="onDbTypeChange(opt.value)"
@dblclick="goToConnectionStep(opt.value)"
>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-muted/60 transition group-hover:bg-background">
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="h-6 w-6" />
</span>
<span class="max-w-full truncate text-sm font-medium">{{ opt.label }}</span>
</button>
</div>
<div v-else class="grid gap-2">
<button
v-for="opt in category.options"
:key="opt.value"
type="button"
class="flex items-center gap-3 rounded-lg border bg-background px-3 py-2 text-left transition hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="selectedType === opt.value ? 'border-primary bg-primary/10 ring-1 ring-primary/30' : 'border-border'"
:aria-pressed="selectedType === opt.value"
@click="onDbTypeChange(opt.value)"
@dblclick="goToConnectionStep(opt.value)"
>
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="h-5 w-5 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ opt.label }}</span>
<span class="text-xs text-muted-foreground">{{ category.title }}</span>
</button>
</div>
</section>
<div v-if="!hasDbPickerResults" class="rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
{{ t('connection.noDatabaseMatches') }}
</div>
</div>
</div>
<DialogFooter class="flex items-center gap-2">
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ t('connection.selectedDatabase') }}: {{ selectedProfile().label }}</span>
</div>
<Button :disabled="!hasDbPickerResults" @click="goToConnectionStep()">
{{ t('connection.next') }}
<ChevronRight class="h-4 w-4" />
</Button>
</DialogFooter>
</template>
<template v-else>
<div class="space-y-3">
<Tabs v-model="configTab" class="min-h-0">
<div class="flex items-center justify-between border-b pb-2">
<TabsList>
<TabsTrigger value="connection">{{ t('connection.basicTab') }}</TabsTrigger>
<TabsTrigger v-if="canUseSsh" value="ssh">{{ t('connection.sshTunnel') }}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="connection" class="m-0">
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.name') }}</Label>
@ -332,57 +535,10 @@ async function browseSshKeyPath() {
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.type') }}</Label>
<DropdownMenu v-model:open="dbTypeMenuOpen">
<DropdownMenuTrigger as-child>
<Button variant="outline" class="col-span-3 justify-start gap-2 font-normal">
<DatabaseIcon :db-type="iconTypeMap[selectedType] || selectedType" class="w-4 h-4" />
{{ selectedProfile().label }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-auto p-3" align="start">
<div class="grid grid-cols-3 gap-3">
<div>
<DropdownMenuLabel class="text-xs text-muted-foreground px-1 pb-1">{{ t('connection.mainstream') }}</DropdownMenuLabel>
<button
v-for="opt in dbOptions"
:key="opt.value"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] hover:bg-accent cursor-default"
:class="{ 'bg-accent': selectedType === opt.value }"
@click="onDbTypeChange(opt.value)"
>
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="w-3.5 h-3.5" />
{{ opt.label }}
</button>
</div>
<div>
<DropdownMenuLabel class="text-xs text-muted-foreground px-1 pb-1">MySQL {{ t('connection.compatible') }}</DropdownMenuLabel>
<button
v-for="opt in mysqlCompat"
:key="opt.value"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] hover:bg-accent cursor-default"
:class="{ 'bg-accent': selectedType === opt.value }"
@click="onDbTypeChange(opt.value)"
>
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="w-3.5 h-3.5" />
{{ opt.label }}
</button>
</div>
<div>
<DropdownMenuLabel class="text-xs text-muted-foreground px-1 pb-1">PostgreSQL {{ t('connection.compatible') }}</DropdownMenuLabel>
<button
v-for="opt in pgCompat"
:key="opt.value"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] hover:bg-accent cursor-default"
:class="{ 'bg-accent': selectedType === opt.value }"
@click="onDbTypeChange(opt.value)"
>
<DatabaseIcon :db-type="iconTypeMap[opt.value]" class="w-3.5 h-3.5" />
{{ opt.label }}
</button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
<div class="col-span-3 flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2">
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm">{{ selectedProfile().label }}</span>
</div>
</div>
<div v-if="isCustomCompatibleProfile()" class="grid grid-cols-4 items-center gap-4">
@ -511,69 +667,100 @@ async function browseSshKeyPath() {
</div>
</template>
<!-- SSH Tunnel (not for SQLite) -->
<template v-if="form.db_type !== 'sqlite'">
<div class="grid grid-cols-4 items-center gap-4 pt-2 border-t">
<Label class="text-right text-xs">{{ t('connection.sshTunnel') }}</Label>
<div class="col-span-3">
<input type="checkbox" v-model="form.ssh_enabled" class="mr-2" />
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
</div>
</div>
<template v-if="form.ssh_enabled">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshHost') }}</Label>
<Input v-model="form.ssh_host" class="col-span-2" placeholder="ssh.example.com" />
<Input v-model.number="form.ssh_port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshUser') }}</Label>
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshPassword') }}</Label>
<Input v-model="form.ssh_password" type="password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="form.ssh_key_path" class="flex-1" placeholder="~/.ssh/id_rsa" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseSshKeyPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('connection.sshKeyPathBrowse') }}</TooltipContent>
</Tooltip>
</div>
</TabsContent>
<TabsContent v-if="canUseSsh" value="ssh" class="m-0">
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshTunnel') }}</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.ssh_enabled" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshHost') }}</Label>
<Input v-model="form.ssh_host" class="col-span-2" placeholder="ssh.example.com" :disabled="!form.ssh_enabled" />
<Input v-model.number="form.ssh_port" type="number" class="col-span-1" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshUser') }}</Label>
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshPassword') }}</Label>
<Input v-model="form.ssh_password" type="password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="form.ssh_key_path" class="flex-1" placeholder="~/.ssh/id_rsa" :disabled="!form.ssh_enabled" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!form.ssh_enabled" @click="browseSshKeyPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('connection.sshKeyPathBrowse') }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPassphrase') }}</Label>
<Input v-model="form.ssh_key_passphrase" type="password" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label
class="col-span-3 flex items-center gap-2"
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" :disabled="!form.ssh_enabled" />
<span class="text-xs text-muted-foreground">{{ t('connection.sshExposeLan') }}</span>
</label>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPassphrase') }}</Label>
<Input v-model="form.ssh_key_passphrase" type="password" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t('connection.sshExposeLan') }}</span>
</label>
</div>
</template>
</template>
</TabsContent>
</Tabs>
</div>
<DialogFooter class="flex items-center gap-2">
<span v-if="testResult" :class="testResult.ok ? 'text-green-500' : 'text-red-500'" class="text-sm mr-auto">
{{ testResult.ok ? t('connection.testSuccess') : testResult.message }}
</span>
<Button variant="outline" :disabled="isTesting || isSaving" @click="testConnection">
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
<ArrowLeft class="h-4 w-4" />
{{ t('connection.back') }}
</Button>
<template v-if="testResult">
<span
class="block min-w-0 flex-1 basis-0 truncate text-xs"
:class="testResult.ok ? 'text-green-600' : 'text-red-600'"
:title="testResultMessage"
role="status"
aria-live="polite"
>
{{ testResultMessage }}
</span>
<Button
variant="ghost"
size="icon-xs"
class="h-5 w-5 shrink-0"
:title="t('connection.copyTestResult')"
:aria-label="t('connection.copyTestResult')"
@click="copyTestResult"
>
<Copy class="h-3 w-3" />
</Button>
</template>
</div>
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
{{ isTesting ? t('connection.testing') : t('connection.test') }}
</Button>
<Button @click="save" :disabled="isSaving || !form.name || (!form.host && !(mongoUseUrl && form.connection_string))">
<Button class="shrink-0" @click="save" :disabled="isSaving || !form.name || (!form.host && !(mongoUseUrl && form.connection_string))">
{{ isSaving ? t('common.loading') : (editingId ? t('connection.save') : t('connection.saveAndConnect')) }}
</Button>
</DialogFooter>
</template>
</DialogContent>
</Dialog>
</template>

View File

@ -46,8 +46,19 @@ export default {
driverName: "Driver Name",
driverNamePlaceholder: "Vendor or environment name",
urlParams: "URL Params",
mode: "Connection Mode",
modeForm: "Form",
searchDatabasePlaceholder: "Search database types",
iconView: "Icon view",
listView: "List view",
selectedDatabase: "Selected",
noDatabaseMatches: "No matching databases",
next: "Next",
back: "Previous",
basicTab: "Connection",
test: "Test",
testing: "Testing...",
copyTestResult: "Copy test result",
saveAndConnect: "Save & Connect",
save: "Save",
editTitle: "Edit Connection",

View File

@ -48,8 +48,17 @@ export default {
urlParams: "URL 参数",
mode: "连接方式",
modeForm: "表单",
searchDatabasePlaceholder: "搜索数据库类型",
iconView: "图标视图",
listView: "列表视图",
selectedDatabase: "已选择",
noDatabaseMatches: "没有匹配的数据库",
next: "下一步",
back: "上一步",
basicTab: "连接信息",
test: "测试",
testing: "测试中...",
copyTestResult: "复制测试结果",
saveAndConnect: "保存并连接",
save: "保存",
editTitle: "编辑连接",