From 41758e6bb14979d678c3f05b8327cc9ab67341f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Tue, 9 Jun 2026 20:56:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(connection):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8F=AF=E8=A7=81=E6=95=B0=E6=8D=AE=E5=BA=93=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=8A=9F=E8=83=BD=20(#944)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 感谢 @eryajf 的贡献!此功能允许用户在创建或编辑连接时选择可见的数据库,支持多种数据库类型。 --- .../connection/ConnectionDialog.vue | 294 ++++++++++++++++++ .../diagram/SchemaDiagramDialog.vue | 6 +- .../src/components/diff/DataCompareDialog.vue | 6 +- .../src/components/diff/SchemaDiffDialog.vue | 6 +- .../export/DatabaseExportDialog.vue | 6 +- .../src/components/sidebar/TreeItem.vue | 3 +- .../sidebar/VisibleDatabasesDialog.vue | 8 +- .../sql-file/SqlFileExecutionDialog.vue | 6 +- .../transfer/DataTransferDialog.vue | 4 +- .../src/composables/useDatabaseOptions.ts | 5 +- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/connectionVisibleDatabases.ts | 71 +++++ apps/desktop/src/lib/visibleDatabases.ts | 4 + apps/desktop/src/stores/connectionStore.ts | 1 + crates/dbx-web/src/routes/connection.rs | 25 ++ .../connectionVisibleDatabases.test.ts | 73 +++++ packages/app-tests/databaseOptions.test.ts | 20 ++ packages/app-tests/visibleDatabases.test.ts | 6 + src-tauri/src/commands/connection.rs | 3 + 24 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/lib/connectionVisibleDatabases.ts create mode 100644 packages/app-tests/connectionVisibleDatabases.test.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index a57d7f4bdf..8a2e2d2346 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -40,6 +40,7 @@ import { ArrowLeft, ArrowDown, ArrowUp, + CheckSquare, ChevronRight, Copy, ExternalLink, @@ -50,12 +51,27 @@ import { KeyRound, Link2, List, + ListFilter, + Loader2, Pipette, Plus, Search, ShieldCheck, + Square, Trash2, } from "@lucide/vue"; +import { + buildDraftVisibleDatabasesConnectionId, + connectionCanChooseVisibleDatabases, + initialVisibleDatabaseSelection, + visibleDatabaseSelectionIsStale, +} from "@/lib/connectionVisibleDatabases"; +import { + canSaveVisibleDatabaseSelection, + filterDatabaseNamesForConnection, + isSystemDatabaseName, + normalizeVisibleDatabaseSelection, +} from "@/lib/visibleDatabases"; type DbOption = { value: string; label: string }; type DbCategory = { key: string; title: string; options: DbOption[] }; @@ -107,6 +123,13 @@ const isTesting = ref(false); const isSaving = ref(false); const testResult = ref<{ ok: boolean; message: string } | null>(null); const editingId = ref(null); +const showVisibleDatabasesDialog = ref(false); +const isLoadingVisibleDatabases = ref(false); +const visibleDatabaseNames = ref([]); +const visibleDatabaseSelection = ref>(new Set()); +const visibleDatabaseSearchText = ref(""); +const visibleDatabaseError = ref(""); +const visibleDatabaseShowSystem = ref(false); let testRunId = 0; const defaultForm = (): ConnectionForm => ({ @@ -142,6 +165,7 @@ const defaultForm = (): ConnectionForm => ({ redis_sentinel_tls: false, redis_cluster_nodes: "", etcd_endpoints: "", + visible_databases: undefined, }); function defaultSshTunnel(): SshTunnelConfig { @@ -573,6 +597,7 @@ watch( redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", etcd_endpoints: config.etcd_endpoints || "", + visible_databases: config.visible_databases, }; h2ConnectionMode.value = h2ConnectionModeForConfig(config); customColorInput.value = config.color || ""; @@ -950,6 +975,30 @@ const canUseTransportLayers = computed( const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile), ); +const canChooseVisibleDatabases = computed(() => connectionCanChooseVisibleDatabases(form.value)); +const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases)); +const visibleDatabaseSummary = computed(() => { + const configured = form.value.visible_databases; + if (!Array.isArray(configured)) return t("visibleDatabases.showAll"); + return t("visibleDatabases.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length }); +}); +const listedVisibleDatabaseNames = computed(() => { + const connection = connectionConfigSnapshotForVisibleDatabases(); + if (visibleDatabaseShowSystem.value) return visibleDatabaseNames.value; + return filterDatabaseNamesForConnection(visibleDatabaseNames.value, connection); +}); +const filteredVisibleDatabaseNames = computed(() => { + const query = visibleDatabaseSearchText.value.trim().toLowerCase(); + if (!query) return listedVisibleDatabaseNames.value; + return listedVisibleDatabaseNames.value.filter((name) => name.toLowerCase().includes(query)); +}); +const visibleDatabaseSelectedCount = computed(() => visibleDatabaseSelection.value.size); +const visibleDatabaseTotalCount = computed(() => listedVisibleDatabaseNames.value.length); +const visibleDatabaseCanSave = computed(() => canSaveVisibleDatabaseSelection([...visibleDatabaseSelection.value])); +const visibleDatabaseHasSystemDatabases = computed(() => { + const connection = connectionConfigSnapshotForVisibleDatabases(); + return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database)); +}); const testResultMessage = computed(() => { if (!testResult.value) return ""; return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message; @@ -1190,9 +1239,21 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { delete legacy.proxy_port; delete legacy.proxy_username; delete legacy.proxy_password; + config.visible_databases = + Array.isArray(config.visible_databases) && config.visible_databases.length > 0 + ? config.visible_databases + : undefined; return config as ConnectionConfig; } +function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig { + return { + ...(form.value as ConnectionConfig), + id: editingId.value || "draft", + visible_databases: form.value.visible_databases, + }; +} + function getUrlParam(params: string | undefined, key: string): string { const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, "")); return parsed.get(key) || ""; @@ -1401,6 +1462,95 @@ function resetTestState() { testResult.value = null; } +function resetVisibleDatabaseDraftState() { + showVisibleDatabasesDialog.value = false; + isLoadingVisibleDatabases.value = false; + visibleDatabaseNames.value = []; + visibleDatabaseSelection.value = new Set(); + visibleDatabaseSearchText.value = ""; + visibleDatabaseError.value = ""; + visibleDatabaseShowSystem.value = false; +} + +async function openVisibleDatabasesPicker() { + if (!ensureConnectionHostResolvedFromUrl()) return; + if (!canChooseVisibleDatabases.value || isLoadingVisibleDatabases.value) return; + + isLoadingVisibleDatabases.value = true; + visibleDatabaseError.value = ""; + visibleDatabaseSearchText.value = ""; + const draftId = buildDraftVisibleDatabasesConnectionId(uuid()); + const draftConfig = { + ...connectionConfigForSubmit(draftId), + id: draftId, + one_time: true, + }; + + try { + await api.connectDb(draftConfig); + const names = await loadVisibleDatabaseNames(draftId, draftConfig); + visibleDatabaseNames.value = names; + const initialSelection = initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig); + visibleDatabaseSelection.value = new Set(initialSelection); + visibleDatabaseShowSystem.value = initialSelection.some((database) => + isSystemDatabaseName(draftConfig.db_type, database), + ); + showVisibleDatabasesDialog.value = true; + } catch (e: any) { + visibleDatabaseNames.value = []; + visibleDatabaseSelection.value = new Set(); + visibleDatabaseError.value = mongodbAuthFailureHint(String(e?.message || e)); + testResult.value = { ok: false, message: visibleDatabaseError.value }; + } finally { + await api.disconnectDb(draftId).catch(() => undefined); + isLoadingVisibleDatabases.value = false; + } +} + +async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig): Promise { + if (config.db_type === "oracle" || config.db_type === "dameng") { + return api.listSchemas(connectionId, config.database || ""); + } + if (config.db_type === "redis") { + return (await api.redisListDatabases(connectionId)).map((database) => String(database.db)); + } + if (config.db_type === "mongodb") { + return api.mongoListDatabases(connectionId); + } + return (await api.listDatabases(connectionId)).map((database) => database.name); +} + +function toggleVisibleDatabase(database: string) { + const next = new Set(visibleDatabaseSelection.value); + if (next.has(database)) next.delete(database); + else next.add(database); + visibleDatabaseSelection.value = next; +} + +function selectAllVisibleDatabases() { + visibleDatabaseSelection.value = new Set(listedVisibleDatabaseNames.value); +} + +function clearVisibleDatabaseSelection() { + visibleDatabaseSelection.value = new Set(); +} + +function showAllVisibleDatabases() { + form.value.visible_databases = undefined; + visibleDatabaseSelection.value = new Set(); + visibleDatabaseNames.value = []; + showVisibleDatabasesDialog.value = false; +} + +function saveVisibleDatabaseSelection() { + if (!visibleDatabaseCanSave.value) return; + form.value.visible_databases = normalizeVisibleDatabaseSelection( + [...visibleDatabaseSelection.value], + visibleDatabaseNames.value, + ); + showVisibleDatabasesDialog.value = false; +} + function applyConnectionUrl() { if (applyConnectionUrlToForm(connectionUrlInput.value)) { toast(t("connection.parseConnectionUrlApplied"), 2000); @@ -1433,6 +1583,7 @@ function resetForm() { dbPickerView.value = "icon"; dbSearchQuery.value = ""; configTab.value = "connection"; + resetVisibleDatabaseDraftState(); resetTestState(); } @@ -1536,6 +1687,25 @@ watch([() => form.value.db_type, () => form.value.username], () => { if (isOracleSysUser(form.value)) form.value.sysdba = true; }); +watch( + () => connectionConfigSnapshotForVisibleDatabases(), + (current, previous) => { + if (!previous || !form.value.visible_databases?.length) return; + if (!visibleDatabaseSelectionIsStale(previous, current)) return; + form.value.visible_databases = undefined; + visibleDatabaseNames.value = []; + visibleDatabaseSelection.value = new Set(); + }, +); + +watch(visibleDatabaseShowSystem, (show) => { + if (show) return; + const connection = connectionConfigSnapshotForVisibleDatabases(); + visibleDatabaseSelection.value = new Set( + [...visibleDatabaseSelection.value].filter((database) => !isSystemDatabaseName(connection.db_type, database)), + ); +}); + watch(canUseTransportLayers, (value) => { if (!value && configTab.value === "transport") { configTab.value = "connection"; @@ -3447,6 +3617,17 @@ function openExternalUrl(url: string) { + @@ -3463,4 +3644,117 @@ function openExternalUrl(url: string) { + + + + + {{ t("visibleDatabases.title") }} +

+ {{ t("visibleDatabases.description", { connection: form.name || selectedProfile().label }) }} +

+
+ +
+ + +
+ +
+ + {{ + t("visibleDatabases.selectedCount", { + selected: visibleDatabaseSelectedCount, + total: visibleDatabaseTotalCount, + }) + }} + +
+ + + +
+
+

+ {{ t("visibleDatabases.emptySelection") }} +

+ + + +
+
+ + {{ t("common.loading") }} +
+
+ {{ t("visibleDatabases.loadFailed", { message: visibleDatabaseError }) }} +
+
+ {{ t("grid.noSearchResults") }} +
+ +
+ + + + + +
+
diff --git a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue index 60d22d4cfd..827cc057c6 100644 --- a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue +++ b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue @@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities"; +import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; import { buildDiagramRelationships, filterDiagramTables, @@ -372,7 +373,10 @@ async function loadDatabases(id: string) { try { await store.ensureConnected(id); const dbs = await api.listDatabases(id); - databases.value = dbs.map((db) => db.name); + databases.value = databaseOptionsForConnection( + dbs.map((db) => db.name), + store.getConfig(id), + ); } catch (e: any) { toast(e?.message || String(e), 5000); } finally { diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index 35cbc67c8b..7b9eb1f4c2 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; +import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; import { isSchemaAware } from "@/lib/databaseCapabilities"; import { copyToClipboard } from "@/lib/clipboard"; import type { @@ -375,7 +376,10 @@ async function loadSchemas(side: "source" | "target", preferredSchema = "") { async function loadDatabases(connectionId: string, side: "source" | "target") { if (!connectionId) return; await store.ensureConnected(connectionId); - const names = (await api.listDatabases(connectionId)).map((database) => database.name); + const names = databaseOptionsForConnection( + (await api.listDatabases(connectionId)).map((database) => database.name), + store.getConfig(connectionId), + ); if (side === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 4740610954..46b66466a1 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { isSchemaAware } from "@/lib/databaseCapabilities"; +import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; import { copyToClipboard } from "@/lib/clipboard"; import type { TableDiff, TableSchemaDetail } from "@/lib/schemaDiff"; import type { TableInfo } from "@/types/database"; @@ -110,7 +111,10 @@ async function loadDatabases(connectionId: string, side: "source" | "target") { try { await store.ensureConnected(connectionId); const dbs = await api.listDatabases(connectionId); - const names = dbs.map((d) => d.name); + const names = databaseOptionsForConnection( + dbs.map((d) => d.name), + store.getConfig(connectionId), + ); if (side === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index e6bdf3d15b..ac6b17bfac 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -10,6 +10,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import type { ExportProgress } from "@/lib/api"; import { isSchemaAware } from "@/lib/databaseCapabilities"; +import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; import { generateDatabaseExportId } from "@/lib/databaseExport"; import { buildSelectedTablesPayload } from "@/lib/databaseExportSelection"; import { isTauriRuntime } from "@/lib/tauriRuntime"; @@ -93,7 +94,10 @@ async function loadDatabases(connId: string) { try { await store.ensureConnected(connId); const dbs = await api.listDatabases(connId); - const names = dbs.map((d) => d.name); + const names = databaseOptionsForConnection( + dbs.map((d) => d.name), + store.getConfig(connId), + ); databases.value = names; database.value = names.length === 1 ? names[0] : ""; schemas.value = []; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 79fd672d7c..f74127d620 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -2556,7 +2556,8 @@ const nodeIconClass = computed(() => { }); const canConfigureVisibleDatabases = computed(() => { if (props.node.type !== "connection" || !props.node.connectionId) return false; - return connectionStore.getConfig(props.node.connectionId)?.db_type !== "elasticsearch"; + const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type; + return dbType !== "elasticsearch" && dbType !== "etcd"; }); const canCopyFinalProxyPort = computed(() => { if (props.node.type !== "connection" || !props.node.connectionId) return false; diff --git a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue index 47a67ab00f..72e4daef3f 100644 --- a/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue +++ b/apps/desktop/src/components/sidebar/VisibleDatabasesDialog.vue @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " import { Input } from "@/components/ui/input"; import { useConnectionStore } from "@/stores/connectionStore"; import { + canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection, @@ -45,6 +46,7 @@ const filteredDatabaseNames = computed(() => { }); const selectedCount = computed(() => selectedNames.value.size); const totalCount = computed(() => listedDatabaseNames.value.length); +const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value])); const hasSystemDatabases = computed(() => databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)), ); @@ -126,6 +128,7 @@ async function showAllDatabases() { } async function saveSelection() { + if (!canSaveSelection.value) return; await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]); emit("update:open", false); } @@ -169,6 +172,9 @@ async function saveSelection() { +

+ {{ t("visibleDatabases.emptySelection") }} +