feat(saved-query): 新增已存查询独立查看入口

- 侧边栏新增“全部已存查询”根节点,不依赖连接实例或加载数据库
- 按连接、数据库和未匹配状态分组展示后端已加载查询
- 使用独立树节点 key,避免与数据库节点下的同一查询冲突
- 重命名和删除按真实 query id 同步更新所有展示副本
- 补充独立入口分组结构测试,覆盖已匹配和未匹配查询
This commit is contained in:
Syngnat
2026-06-15 14:12:39 +08:00
parent eca9601ab0
commit 2f354d2267
4 changed files with 190 additions and 28 deletions

View File

@@ -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(

View File

@@ -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<SearchScope, React.ReactNode> = {
tag: <TagOutlined />,
};
const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set<string>): 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: <FileTextOutlined />,
type: 'saved-query',
dataRef: query,
isLeaf: true,
});
const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => {
const groupedByDatabase = new Map<string, SavedQuery[]>();
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: <DatabaseOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: items.map(createQueryNode),
}));
};
const groupedByConnection = new Map<string, SavedQuery[]>();
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<string, SavedQuery[]>();
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: <WarningOutlined />,
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: <FolderOpenOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`),
})),
});
}
return {
title: '全部已存查询',
key: 'all-saved-queries',
icon: <FolderOpenOutlined />,
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<TreeNode | null>(() => {
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: <FolderOpenOutlined />,
type: 'unmatched-saved-queries',
isLeaf: false,
selectable: false,
children: unmatchedSavedQueries.map((query) => ({
title: query.name,
key: query.id,
icon: <FileTextOutlined />,
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;

View File

@@ -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'

View File

@@ -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 {
}
}