From 2f354d22673ed9c80e435e966c64154222bfc789 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 15 Jun 2026 14:12:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(saved-query):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=B7=B2=E5=AD=98=E6=9F=A5=E8=AF=A2=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧边栏新增“全部已存查询”根节点,不依赖连接实例或加载数据库 - 按连接、数据库和未匹配状态分组展示后端已加载查询 - 使用独立树节点 key,避免与数据库节点下的同一查询冲突 - 重命名和删除按真实 query id 同步更新所有展示副本 - 补充独立入口分组结构测试,覆盖已匹配和未匹配查询 --- .../Sidebar.locate-toolbar.test.tsx | 62 ++++++++ frontend/src/components/Sidebar.tsx | 143 +++++++++++++++--- frontend/src/components/sidebarV2Utils.ts | 2 + frontend/wailsjs/go/models.ts | 11 +- 4 files changed, 190 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index d4de19b..0d121f1 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import Sidebar, { + buildAllSavedQueriesTreeNode, buildSidebarTableChildrenForUi, buildV2SidebarTableSectionedChildren, buildV2RailConnectionGroups, @@ -499,6 +500,67 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('}> = React.memo(({'); }); + it('builds a standalone saved-query tree without loading database nodes', () => { + const tree = buildAllSavedQueriesTreeNode( + [ + { + id: 'saved-1', + name: 'Orders', + sql: 'select * from orders', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 100, + }, + { + id: 'saved-orphan', + name: 'Legacy Report', + sql: 'select 1', + connectionId: 'legacy-1', + originalConnectionId: 'legacy-1', + dbName: 'legacy_db', + createdAt: 200, + bindingStatus: 'orphan', + }, + ], + [{ + id: 'conn-1', + name: 'Primary', + config: { + type: 'mysql', + host: 'db.local', + port: 3306, + }, + }] as any, + ); + + expect(tree?.key).toBe('all-saved-queries'); + expect(tree?.title).toBe('全部已存查询'); + expect(tree?.children?.[0]).toMatchObject({ + key: 'all-saved-queries-connection-conn-1', + title: 'Primary', + type: 'saved-query-group', + }); + expect(tree?.children?.[0].children?.[0]).toMatchObject({ + key: 'all-saved-queries-connection-conn-1-db-app', + title: 'app', + }); + expect(tree?.children?.[0].children?.[0].children?.[0]).toMatchObject({ + key: 'all-saved-query-saved-1', + title: 'Orders', + type: 'saved-query', + }); + const unmatchedGroup = tree?.children?.find((child) => child.key === 'all-saved-queries-unmatched'); + expect(unmatchedGroup?.title).toBe('未匹配'); + expect(unmatchedGroup?.children?.[0]).toMatchObject({ + key: 'all-saved-queries-unmatched-legacy-1', + title: 'legacy-1', + }); + expect(unmatchedGroup?.children?.[0].children?.[0].children?.[0]).toMatchObject({ + key: 'all-saved-query-saved-orphan', + title: 'Legacy Report', + }); + }); + it('releases backend database connections when disconnecting a sidebar connection', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); const disconnectSource = source.slice( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 336adc9..22c1155 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -226,7 +226,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; + type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'all-saved-queries' | 'saved-query-group' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -257,6 +257,115 @@ const SEARCH_SCOPE_ICON_MAP: Record = { tag: , }; +const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set): boolean => ( + query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId) +); + +export const buildAllSavedQueriesTreeNode = ( + savedQueries: SavedQuery[], + connections: SavedConnection[], +): TreeNode | null => { + if (savedQueries.length === 0) { + return null; + } + + const connectionIds = new Set(connections.map((conn) => conn.id)); + const unmatchedSavedQueries = savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds)); + const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id)); + const createQueryNode = (query: SavedQuery): TreeNode => ({ + title: query.name || '未命名查询', + key: `all-saved-query-${query.id}`, + icon: , + type: 'saved-query', + dataRef: query, + isLeaf: true, + }); + const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => { + const groupedByDatabase = new Map(); + queries.forEach((query) => { + const dbName = String(query.dbName || '').trim() || '默认数据库'; + groupedByDatabase.set(dbName, [...(groupedByDatabase.get(dbName) || []), query]); + }); + return Array.from(groupedByDatabase.entries()).map(([dbName, items]) => ({ + title: dbName, + key: `${keyPrefix}-db-${encodeURIComponent(dbName)}`, + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: items.map(createQueryNode), + })); + }; + + const groupedByConnection = new Map(); + savedQueries.forEach((query) => { + if (unmatchedIds.has(query.id)) { + return; + } + groupedByConnection.set(query.connectionId, [ + ...(groupedByConnection.get(query.connectionId) || []), + query, + ]); + }); + + const children: TreeNode[] = []; + connections.forEach((conn) => { + const connectionQueries = groupedByConnection.get(conn.id); + if (!connectionQueries || connectionQueries.length === 0) { + return; + } + const iconType = resolveConnectionIconType(conn); + const iconColor = resolveConnectionAccentColor(conn); + children.push({ + title: conn.name || conn.id, + key: `all-saved-queries-connection-${conn.id}`, + icon: getDbIcon(iconType, iconColor, 22), + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`), + }); + }); + + if (unmatchedSavedQueries.length > 0) { + const groupedByOriginalConnection = new Map(); + unmatchedSavedQueries.forEach((query) => { + const originalConnectionId = String(query.originalConnectionId || query.connectionId || '未知连接').trim() || '未知连接'; + groupedByOriginalConnection.set(originalConnectionId, [ + ...(groupedByOriginalConnection.get(originalConnectionId) || []), + query, + ]); + }); + children.push({ + title: '未匹配', + key: 'all-saved-queries-unmatched', + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({ + title: connectionLabel, + key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`, + icon: , + type: 'saved-query-group', + selectable: false, + isLeaf: false, + children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`), + })), + }); + } + + return { + title: '全部已存查询', + key: 'all-saved-queries', + icon: , + type: 'all-saved-queries', + isLeaf: false, + selectable: false, + children, + }; +}; + const Sidebar: React.FC<{ onCreateConnection?: () => void; onEditConnection?: (conn: SavedConnection) => void; @@ -425,9 +534,12 @@ const Sidebar: React.FC<{ const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]); const unmatchedSavedQueries = useMemo( - () => savedQueries.filter((query) => query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId)), + () => savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIdSet)), [connectionIdSet, savedQueries], ); + const allSavedQueriesNode = useMemo(() => { + return buildAllSavedQueriesTreeNode(savedQueries, connections); + }, [connections, savedQueries]); const v2RailConnectionGroups = useMemo( () => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder), [connections, connectionTags, sidebarRootOrder], @@ -843,28 +955,13 @@ const Sidebar: React.FC<{ orderedNodes.push(...Array.from(tagNodesById.values())); orderedNodes.push(...Array.from(ungroupedNodesById.values())); - if (unmatchedSavedQueries.length > 0) { - orderedNodes.push({ - title: '未匹配已存查询', - key: 'unmatched-saved-queries', - icon: , - type: 'unmatched-saved-queries', - isLeaf: false, - selectable: false, - children: unmatchedSavedQueries.map((query) => ({ - title: query.name, - key: query.id, - icon: , - type: 'saved-query', - dataRef: query, - isLeaf: true, - })), - }); + if (allSavedQueriesNode) { + orderedNodes.push(allSavedQueriesNode); } const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root'); return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes; }); - }, [connections, connectionTags, sidebarRootOrder, unmatchedSavedQueries]); + }, [connections, connectionTags, sidebarRootOrder, allSavedQueriesNode]); const handleDuplicateConnection = async (conn: SavedConnection) => { if (!conn?.id) return; @@ -2528,7 +2625,7 @@ const Sidebar: React.FC<{ }, []); const onLoadData = async ({ key, children, dataRef, type }: any) => { - if (type === 'tag' || type === 'unmatched-saved-queries') return; + if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return; if (hasSidebarLazyChildren(children)) return; if (type === 'connection') { @@ -4671,7 +4768,7 @@ const Sidebar: React.FC<{ }); const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] => list.map(node => { - if (node.key === renameSavedQueryTarget.id) { + if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) { return { ...node, title: persisted.name, @@ -7557,7 +7654,7 @@ const Sidebar: React.FC<{ // 从树中移除节点 const removeNode = (list: TreeNode[]): TreeNode[] => list - .filter(n => n.key !== node.key) + .filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id)) .map(n => n.children ? { ...n, children: removeNode(n.children) } : n); const nextTreeData = removeNode(treeDataRef.current); treeDataRef.current = nextTreeData; diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index ed9afcb..e96ace6 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -21,6 +21,8 @@ export type SidebarTreeNodeType = | 'v2-table-section' | 'queries-folder' | 'saved-query' + | 'all-saved-queries' + | 'saved-query-group' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1a09d53..1db922e 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1210,11 +1210,11 @@ export namespace connection { fingerprintVersion?: string; bindingStatus?: string; originalConnectionId?: string; - + static createFrom(source: any = {}) { return new SavedQuery(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; @@ -1232,17 +1232,17 @@ export namespace connection { export class SavedQueryImportPayload { queries: SavedQuery[]; legacyConnections?: SavedConnectionInput[]; - + static createFrom(source: any = {}) { return new SavedQueryImportPayload(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.queries = this.convertValues(source["queries"], SavedQuery); this.legacyConnections = this.convertValues(source["legacyConnections"], SavedConnectionInput); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -1454,3 +1454,4 @@ export namespace sync { } } +