From 7961004a2c4969117181de7e8db90deb26ec6799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 22 Jul 2026 23:25:22 +0800 Subject: [PATCH] feat(connection): categorize database types --- .../connection/ConnectionDialog.vue | 202 ++++++++++++++---- apps/desktop/src/i18n/locales/en.ts | 11 + apps/desktop/src/i18n/locales/es.ts | 11 + apps/desktop/src/i18n/locales/it.ts | 11 + apps/desktop/src/i18n/locales/ja.ts | 11 + apps/desktop/src/i18n/locales/pt-BR.ts | 11 + apps/desktop/src/i18n/locales/zh-CN.ts | 11 + apps/desktop/src/i18n/locales/zh-TW.ts | 11 + .../lib/connection/databaseCategoryOptions.ts | 16 ++ .../app-tests/databaseCategoryOptions.test.ts | 16 ++ 10 files changed, 264 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/lib/connection/databaseCategoryOptions.ts create mode 100644 packages/app-tests/databaseCategoryOptions.test.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 8819c1972a..3c16133637 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -47,6 +47,7 @@ import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints"; import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode"; import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers"; +import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions"; import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv"; import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; @@ -92,7 +93,8 @@ import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudfla import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy"; type DbOption = { value: string; label: string }; -type DbCategory = { key: string; title: string; options: DbOption[] }; +type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config"; +type DbCategory = { key: DbCategoryKey; title: string; options: DbOption[] }; type DialogStep = "select" | "config"; type DbPickerView = "icon" | "list"; export type ConfigTab = "connection" | "advanced" | "tls" | "transport"; @@ -509,6 +511,7 @@ const hiveExtraJavaOptions = ref(""); const dialogStep = ref("select"); const dbPickerView = ref("icon"); const dbSearchQuery = ref(""); +const selectedDbCategory = ref("sql"); const configTab = ref("connection"); const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto"; const mqAdminUrl = ref("http://127.0.0.1:8080"); @@ -1900,6 +1903,8 @@ function defaultDatabaseForProfile() { } function onDbTypeChange(val: string) { + const category = dbCategoryForOption(val); + if (category) selectedDbCategory.value = category; customDriverName.value = ""; applyProfile(val, !!editingId.value); resetTestState(); @@ -2087,7 +2092,71 @@ const dbOptions: DbOption[] = [ { value: "dremio", label: "Dremio" }, ]; -const dbCategories = computed(() => [{ key: "all", title: "", options: dbOptions }]); +const dbCategoryDefinitions: Array<{ + key: DbCategoryKey; + titleKey: string; + optionValues: readonly string[]; +}> = [ + { + key: "sql", + titleKey: "connection.databaseCategorySql", + optionValues: ["postgres", "mysql", "oracle", "sqlserver", "mariadb", "cockroachdb", "db2", "informix", "firebird", "iris", "jdbcx", "custom_mysql", "custom_postgres"], + }, + { + key: "analytics", + titleKey: "connection.databaseCategoryAnalytics", + optionValues: ["clickhouse", "doris", "starrocks", "databend", "selectdb", "databricks", "saphana", "teradata", "vertica", "exasol", "redshift", "snowflake", "trino", "prestosql", "hive", "spark", "bigquery", "kylin", "dremio"], + }, + { + key: "domestic", + titleKey: "connection.databaseCategoryDomestic", + optionValues: ["dm", "opengauss", "gaussdb", "kwdb", "tidb", "oceanbase", "goldendb", "tdsql", "polardb", "greatsql", "gbase", "kingbase", "highgo", "yashandb", "vastbase", "sundb", "oscar", "xugu"], + }, + { + key: "lightweight", + titleKey: "connection.databaseCategoryLightweight", + optionValues: ["sqlite", "turso", "cloudflare-d1", "duckdb", "rqlite", "access", "h2"], + }, + { + key: "document", + titleKey: "connection.databaseCategoryDocument", + optionValues: ["mongodb", "redis", "elasticsearch", "manticoresearch", "cassandra"], + }, + { + key: "graph_ai", + titleKey: "connection.databaseCategoryGraphAi", + optionValues: ["neo4j", "qdrant", "milvus", "weaviate", "chromadb"], + }, + { + key: "timeseries", + titleKey: "connection.databaseCategoryTimeseries", + optionValues: ["questdb", "tdengine", "iotdb", "influxdb"], + }, + { + key: "mq", + titleKey: "connection.databaseCategoryMq", + optionValues: ["mq", "kafka", "rocketmq"], + }, + { + key: "registry_config", + titleKey: "connection.databaseCategoryRegistryConfig", + optionValues: ["etcd", "zookeeper", "nacos"], + }, +]; + +// Keep the picker exhaustive as database drivers are added or reorganized. +assertCompleteDatabaseCategories( + dbOptions.map((option) => option.value), + dbCategoryDefinitions.map((category) => category.optionValues), +); + +const dbCategories = computed(() => { + return dbCategoryDefinitions.map((category) => ({ + key: category.key, + title: t(category.titleKey), + options: dbOptions.filter((option) => category.optionValues.includes(option.value)), + })); +}); function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") { const profile = driverProfiles[option.value]; @@ -2098,9 +2167,11 @@ function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") ); } +const isDbSearchActive = computed(() => !!dbSearchQuery.value.trim()); + const filteredDbCategories = computed(() => { const keyword = dbSearchQuery.value.trim().toLowerCase(); - if (!keyword) return dbCategories.value; + if (!isDbSearchActive.value) return dbCategories.value; return dbCategories.value .map((category) => ({ @@ -2110,7 +2181,25 @@ const filteredDbCategories = computed(() => { .filter((category) => category.options.length > 0); }); -const hasDbPickerResults = computed(() => filteredDbCategories.value.some((category) => category.options.length > 0)); +const visibleDbCategories = computed(() => { + if (isDbSearchActive.value) return filteredDbCategories.value; + return filteredDbCategories.value.filter((category) => category.key === selectedDbCategory.value); +}); +const hasDbPickerResults = computed(() => visibleDbCategories.value.some((category) => category.options.length > 0)); +const selectedDbOptionIsVisible = computed(() => visibleDbCategories.value.some((category) => category.options.some((option) => option.value === selectedType.value))); + +function selectDbCategory(category: DbCategoryKey) { + selectedDbCategory.value = category; + dbSearchQuery.value = ""; + const categoryOptions = dbCategoryDefinitions.find((definition) => definition.key === category)?.optionValues ?? []; + const nextSelection = databaseSelectionForCategory(selectedType.value, categoryOptions); + if (nextSelection && nextSelection !== selectedType.value) onDbTypeChange(nextSelection); +} + +function dbCategoryForOption(value: string): DbCategoryKey | undefined { + return dbCategories.value.find((category) => category.options.some((option) => option.value === value))?.key; +} + const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value); const jdbcBackedDatabaseTypes = new Set(["jdbc", "prestosql"]); const isJdbcConnection = computed(() => form.value.db_type === "jdbc"); @@ -2395,7 +2484,7 @@ const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto")); const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value))); const connectionDialogContentClass = computed(() => { - if (dialogStep.value === "select") return "sm:max-w-[760px]"; + if (dialogStep.value === "select") return "sm:h-[720px] sm:max-w-[880px]"; return shouldUseWideConnectionDialog.value ? "sm:max-w-[660px]" : "sm:max-w-[560px]"; }); const connectionLabelClass = "justify-self-start text-left"; @@ -2458,6 +2547,8 @@ function goToConnectionStep(value = selectedType.value) { } function backToDatabasePicker() { + const category = dbCategoryForOption(selectedType.value); + if (category) selectedDbCategory.value = category; dialogStep.value = "select"; resetTestState(); } @@ -3490,6 +3581,7 @@ function resetForm() { dialogStep.value = "select"; dbPickerView.value = "icon"; dbSearchQuery.value = ""; + selectedDbCategory.value = "sql"; configTab.value = "connection"; resetVisibleDatabaseDraftState(); resetProductionDatabaseDraftState(); @@ -4202,50 +4294,66 @@ function openExternalUrl(url: string) { -
-
-
-

{{ category.title }}

-
+
+ -
- -
+
+
{{ t("connection.searchResults") }}
-
- -
-
+
+

{{ category.title }}

-
- {{ t("connection.noDatabaseMatches") }} +
+ +
+ +
+ +
+
+ +
+ {{ t("connection.noDatabaseMatches") }} +
@@ -4255,7 +4363,7 @@ function openExternalUrl(url: string) { {{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }} - diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fa1a04d54e..2c8c4e1c8f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -391,6 +391,17 @@ export default { mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----", mqTokenSigningHint: "Choose based on the broker jwt.broker.token.mode: SECRET uses HS256, PRIVATE uses RS256. The key is stored with connection secrets.", searchDatabasePlaceholder: "Search database types", + searchResults: "Search results", + databaseCategories: "Categories", + databaseCategorySql: "Relational", + databaseCategoryAnalytics: "Analytics", + databaseCategoryDomestic: "Chinese DBs", + databaseCategoryLightweight: "Lightweight", + databaseCategoryDocument: "Docs/Cache/Search", + databaseCategoryGraphAi: "Graph/Vector", + databaseCategoryTimeseries: "Time series", + databaseCategoryMq: "Message queues", + databaseCategoryRegistryConfig: "Registry/Config", jdbcConnection: "JDBC connection", iconView: "Icon view", listView: "List view", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ace5f76766..afbb04c653 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -316,6 +316,17 @@ export default withEnglishFallback({ nacosTlsSkipVerify: "Omitir verificación de certificado", nacosPageSize: "Tamaño de página", searchDatabasePlaceholder: "Buscar tipos de bases de datos", + searchResults: "Resultados de búsqueda", + databaseCategories: "Categorías", + databaseCategorySql: "Relacionales", + databaseCategoryAnalytics: "Analítica", + databaseCategoryDomestic: "BD chinas", + databaseCategoryLightweight: "Ligeras", + databaseCategoryDocument: "Docs/caché/búsqueda", + databaseCategoryGraphAi: "Grafos/vectores", + databaseCategoryTimeseries: "Temporal", + databaseCategoryMq: "Colas", + databaseCategoryRegistryConfig: "Registro/config.", jdbcConnection: "Conexión JDBC", iconView: "Vista de íconos", listView: "Vista de lista", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 76357474b4..8fbd6c9f87 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -315,6 +315,17 @@ export default withEnglishFallback({ nacosTlsSkipVerify: "Salta verifica certificato", nacosPageSize: "Dimensione pagina", searchDatabasePlaceholder: "Cerca tipi di database", + searchResults: "Risultati della ricerca", + databaseCategories: "Categorie", + databaseCategorySql: "Relazionali", + databaseCategoryAnalytics: "Analisi", + databaseCategoryDomestic: "DB cinesi", + databaseCategoryLightweight: "Leggeri", + databaseCategoryDocument: "Doc./cache/ricerca", + databaseCategoryGraphAi: "Grafi/vettori", + databaseCategoryTimeseries: "Temporali", + databaseCategoryMq: "Code messaggi", + databaseCategoryRegistryConfig: "Registro/config.", jdbcConnection: "Connessione JDBC", iconView: "Vista icone", listView: "Vista elenco", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index c564dcbb92..381ac83d85 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -309,6 +309,17 @@ export default withEnglishFallback({ nacosTlsSkipVerify: "証明書検証をスキップ", nacosPageSize: "ページサイズ", searchDatabasePlaceholder: "データベースタイプを検索", + searchResults: "検索結果", + databaseCategories: "カテゴリ", + databaseCategorySql: "リレーショナル", + databaseCategoryAnalytics: "分析", + databaseCategoryDomestic: "中国系DB", + databaseCategoryLightweight: "軽量DB", + databaseCategoryDocument: "文書/キャッシュ/検索", + databaseCategoryGraphAi: "グラフ/ベクトル", + databaseCategoryTimeseries: "時系列DB", + databaseCategoryMq: "メッセージキュー", + databaseCategoryRegistryConfig: "登録/設定", jdbcConnection: "JDBC 接続", iconView: "アイコン表示", listView: "リスト表示", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3ce20aeebd..503f8077c5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -316,6 +316,17 @@ export default withEnglishFallback({ nacosTlsSkipVerify: "Ignorar verificação do certificado", nacosPageSize: "Tamanho da página", searchDatabasePlaceholder: "Pesquisar tipos de banco de dados", + searchResults: "Resultados da busca", + databaseCategories: "Categorias", + databaseCategorySql: "Relacionais", + databaseCategoryAnalytics: "Análises", + databaseCategoryDomestic: "BD chineses", + databaseCategoryLightweight: "Leves", + databaseCategoryDocument: "Docs/cache/busca", + databaseCategoryGraphAi: "Grafos/vetores", + databaseCategoryTimeseries: "Séries", + databaseCategoryMq: "Filas", + databaseCategoryRegistryConfig: "Registro/config.", jdbcConnection: "Conexão JDBC", iconView: "Visualização em ícones", listView: "Visualização em lista", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d62ad17a46..9a06ecc714 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -393,6 +393,17 @@ export default withEnglishFallback({ mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----", mqTokenSigningHint: "按 Broker 的 jwt.broker.token.mode 选择:SECRET 使用 HS256,PRIVATE 使用 RS256。密钥会走连接 secret 存储。", searchDatabasePlaceholder: "搜索数据库类型", + searchResults: "搜索结果", + databaseCategories: "分类", + databaseCategorySql: "关系型数据库", + databaseCategoryAnalytics: "数仓分析", + databaseCategoryDomestic: "国产数据库", + databaseCategoryLightweight: "轻量数据库", + databaseCategoryDocument: "文档/缓存/检索", + databaseCategoryGraphAi: "图谱/向量", + databaseCategoryTimeseries: "时序数据库", + databaseCategoryMq: "消息队列", + databaseCategoryRegistryConfig: "注册与配置", jdbcConnection: "JDBC 连接", iconView: "图标视图", listView: "列表视图", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d606718abe..3523518596 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -316,6 +316,17 @@ export default withEnglishFallback({ nacosTlsSkipVerify: "跳過憑證驗證", nacosPageSize: "分頁大小", searchDatabasePlaceholder: "搜尋資料庫類型", + searchResults: "搜尋結果", + databaseCategories: "分類", + databaseCategorySql: "關聯式資料庫", + databaseCategoryAnalytics: "數倉分析", + databaseCategoryDomestic: "國產資料庫", + databaseCategoryLightweight: "輕量資料庫", + databaseCategoryDocument: "文件/快取/檢索", + databaseCategoryGraphAi: "圖譜/向量", + databaseCategoryTimeseries: "時序資料庫", + databaseCategoryMq: "訊息佇列", + databaseCategoryRegistryConfig: "註冊與設定", jdbcConnection: "JDBC 連線", iconView: "圖示檢視", listView: "清單檢視", diff --git a/apps/desktop/src/lib/connection/databaseCategoryOptions.ts b/apps/desktop/src/lib/connection/databaseCategoryOptions.ts new file mode 100644 index 0000000000..c88266e211 --- /dev/null +++ b/apps/desktop/src/lib/connection/databaseCategoryOptions.ts @@ -0,0 +1,16 @@ +export function assertCompleteDatabaseCategories(optionValues: readonly string[], categoryOptionValues: readonly (readonly string[])[]) { + const available = new Set(optionValues); + const categorized = categoryOptionValues.flat(); + const categorizedSet = new Set(categorized); + const duplicates = categorized.filter((value, index) => categorized.indexOf(value) !== index); + const missing = optionValues.filter((value) => !categorizedSet.has(value)); + const unknown = categorized.filter((value) => !available.has(value)); + + if (duplicates.length || missing.length || unknown.length) { + throw new Error(`Invalid database categories: duplicates=${[...new Set(duplicates)].join(",")}; missing=${missing.join(",")}; unknown=${[...new Set(unknown)].join(",")}`); + } +} + +export function databaseSelectionForCategory(currentValue: string, categoryOptionValues: readonly string[]): string | undefined { + return categoryOptionValues.includes(currentValue) ? currentValue : categoryOptionValues[0]; +} diff --git a/packages/app-tests/databaseCategoryOptions.test.ts b/packages/app-tests/databaseCategoryOptions.test.ts new file mode 100644 index 0000000000..4ef5c18d54 --- /dev/null +++ b/packages/app-tests/databaseCategoryOptions.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "../../apps/desktop/src/lib/connection/databaseCategoryOptions.ts"; + +test("database categories cover every option exactly once", () => { + assert.doesNotThrow(() => assertCompleteDatabaseCategories(["mysql", "redis", "kafka"], [["mysql"], ["redis", "kafka"]])); + assert.throws(() => assertCompleteDatabaseCategories(["mysql", "redis"], [["mysql"]]), /missing=redis/); + assert.throws(() => assertCompleteDatabaseCategories(["mysql"], [["mysql"], ["mysql"]]), /duplicates=mysql/); + assert.throws(() => assertCompleteDatabaseCategories(["mysql"], [["mysql", "unknown"]]), /unknown=unknown/); +}); + +test("database category changes keep only visible selections", () => { + assert.equal(databaseSelectionForCategory("mysql", ["mysql", "postgres"]), "mysql"); + assert.equal(databaseSelectionForCategory("mysql", ["questdb", "tdengine"]), "questdb"); + assert.equal(databaseSelectionForCategory("mysql", []), undefined); +});