mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-31 13:29:48 +08:00
- 后端新增 CreateSchema 接口,支持在选中 PostgreSQL 数据库下创建 schema - 侧边栏旧版菜单和新版菜单均增加新建模式入口 - 创建成功后刷新对象树,并支持空模式显示 - 补充 Wails 绑定与创建模式相关测试 Refs #480
7553 lines
322 KiB
TypeScript
7553 lines
322 KiB
TypeScript
import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react';
|
||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
|
||
import {
|
||
DatabaseOutlined,
|
||
TableOutlined,
|
||
EyeOutlined,
|
||
ConsoleSqlOutlined,
|
||
HddOutlined,
|
||
FolderOutlined,
|
||
FolderOpenOutlined,
|
||
FileTextOutlined,
|
||
CopyOutlined,
|
||
ExportOutlined,
|
||
FolderAddOutlined,
|
||
SaveOutlined,
|
||
EditOutlined,
|
||
DownOutlined,
|
||
SearchOutlined,
|
||
KeyOutlined,
|
||
ThunderboltOutlined,
|
||
UnorderedListOutlined,
|
||
FunctionOutlined,
|
||
LinkOutlined,
|
||
FileAddOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
DeleteOutlined,
|
||
DisconnectOutlined,
|
||
CloudOutlined,
|
||
CheckSquareOutlined,
|
||
CodeOutlined,
|
||
TagOutlined,
|
||
CheckOutlined,
|
||
FilterOutlined,
|
||
DashboardOutlined,
|
||
WarningOutlined,
|
||
ClockCircleOutlined,
|
||
RobotOutlined,
|
||
AimOutlined,
|
||
MoreOutlined,
|
||
ToolOutlined,
|
||
SettingOutlined,
|
||
BarsOutlined,
|
||
PushpinOutlined
|
||
} from '@ant-design/icons';
|
||
import { buildSidebarTablePinKey, useStore } from '../store';
|
||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||
import { SavedConnection, ConnectionTag, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
|
||
import { getDbIcon } from './DatabaseIcons';
|
||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
|
||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||
import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql';
|
||
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
|
||
import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||
import {
|
||
findSidebarNodePathByKey,
|
||
findSidebarNodePathForLocate,
|
||
normalizeSidebarLocateObjectRequest,
|
||
normalizeSidebarLocateObjectRequestFromTab,
|
||
resolveSidebarLocateTarget,
|
||
type SidebarLocateTreeNodeLike,
|
||
} from '../utils/sidebarLocate';
|
||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
|
||
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
|
||
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
|
||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
|
||
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||
import JVMModeBadge from './jvm/JVMModeBadge';
|
||
import {
|
||
V2DatabaseContextMenuView,
|
||
V2ConnectionGroupContextMenuView,
|
||
V2ConnectionContextMenuView,
|
||
V2TableContextMenuView,
|
||
V2TableGroupContextMenuView,
|
||
type V2DatabaseContextMenuActionKey,
|
||
type V2ConnectionGroupContextMenuActionKey,
|
||
type V2ConnectionContextMenuActionKey,
|
||
type V2TableContextMenuActionKey,
|
||
type V2TableContextMenuStats,
|
||
type V2TableGroupContextMenuActionKey,
|
||
} from './V2TableContextMenu';
|
||
|
||
const { Search } = Input;
|
||
|
||
type SidebarContextMenuState = {
|
||
x: number;
|
||
y: number;
|
||
items: MenuProps['items'];
|
||
kind?: 'v2-table' | 'v2-database' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group';
|
||
node?: any;
|
||
rootClassName?: string;
|
||
overlayStyle?: React.CSSProperties;
|
||
};
|
||
interface TreeNode {
|
||
title: string;
|
||
key: string;
|
||
isLeaf?: boolean;
|
||
children?: TreeNode[];
|
||
icon?: React.ReactNode;
|
||
dataRef?: any;
|
||
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | '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';
|
||
}
|
||
|
||
const isV2SidebarObjectNode = (node: Pick<TreeNode, 'type'> | null | undefined): boolean => {
|
||
return node?.type === 'table'
|
||
|| node?.type === 'view'
|
||
|| node?.type === 'materialized-view'
|
||
|| node?.type === 'db-trigger'
|
||
|| node?.type === 'routine';
|
||
};
|
||
|
||
export const hasSidebarLazyChildren = (children: unknown): boolean => {
|
||
return Array.isArray(children) && children.length > 0;
|
||
};
|
||
|
||
export const shouldLoadSidebarNodeOnExpand = (
|
||
node: Pick<TreeNode, 'type' | 'children' | 'isLeaf'> | null | undefined,
|
||
): boolean => {
|
||
if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false;
|
||
return node.type === 'connection'
|
||
|| node.type === 'database'
|
||
|| node.type === 'table'
|
||
|| node.type === 'jvm-mode'
|
||
|| node.type === 'jvm-resource';
|
||
};
|
||
|
||
export const resolveSidebarTableNameForCopy = (node: Pick<TreeNode, 'title' | 'dataRef'> | null | undefined): string => {
|
||
return String(node?.dataRef?.tableName || node?.title || '').trim();
|
||
};
|
||
|
||
type SidebarTableSortPreference = 'name' | 'frequency';
|
||
|
||
type SidebarTableEntryForSort = {
|
||
tableName: string;
|
||
schemaName?: string;
|
||
displayName: string;
|
||
};
|
||
|
||
export const isSidebarTablePinned = (
|
||
pinnedKeys: string[],
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
schemaName = '',
|
||
): boolean => {
|
||
const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName);
|
||
return !!key && pinnedKeys.includes(key);
|
||
};
|
||
|
||
export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
|
||
entries: T[],
|
||
options: {
|
||
connectionId: string;
|
||
dbName: string;
|
||
sortBy: SidebarTableSortPreference;
|
||
tableAccessCount?: Record<string, number>;
|
||
pinnedSidebarTables?: string[];
|
||
},
|
||
): T[] => {
|
||
const pinnedKeys = options.pinnedSidebarTables || [];
|
||
const accessCount = options.tableAccessCount || {};
|
||
const compareByName = (a: T, b: T) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase());
|
||
const compareWithinPinnedGroup = (a: T, b: T) => {
|
||
if (options.sortBy === 'frequency') {
|
||
const keyA = `${options.connectionId}-${options.dbName}-${a.tableName}`;
|
||
const keyB = `${options.connectionId}-${options.dbName}-${b.tableName}`;
|
||
const countA = accessCount[keyA] || 0;
|
||
const countB = accessCount[keyB] || 0;
|
||
if (countA !== countB) {
|
||
return countB - countA;
|
||
}
|
||
}
|
||
return compareByName(a, b);
|
||
};
|
||
|
||
return [...entries].sort((a, b) => {
|
||
const pinnedA = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, a.tableName, a.schemaName || '');
|
||
const pinnedB = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, b.tableName, b.schemaName || '');
|
||
if (pinnedA !== pinnedB) {
|
||
return pinnedA ? -1 : 1;
|
||
}
|
||
return compareWithinPinnedGroup(a, b);
|
||
});
|
||
};
|
||
|
||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||
type BatchObjectType = 'table' | 'view';
|
||
type BatchObjectFilterType = 'all' | BatchObjectType;
|
||
type BatchSelectionScope = 'filtered' | 'all';
|
||
type SearchScope = 'smart' | 'object' | 'database' | 'host' | 'tag';
|
||
type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines';
|
||
|
||
export const V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID = '__gonavi-v2-ungrouped-connections__';
|
||
|
||
export interface V2RailConnectionGroup {
|
||
id: string;
|
||
name: string;
|
||
connections: SavedConnection[];
|
||
isUngrouped?: boolean;
|
||
}
|
||
|
||
export const buildV2RailConnectionGroups = (
|
||
connections: SavedConnection[],
|
||
connectionTags: ConnectionTag[],
|
||
): V2RailConnectionGroup[] => {
|
||
const connectionById = new Map(connections.map((conn) => [conn.id, conn]));
|
||
const groupedConnectionIds = new Set<string>();
|
||
const groups: V2RailConnectionGroup[] = [];
|
||
|
||
connectionTags.forEach((tag) => {
|
||
const tagConnections: SavedConnection[] = [];
|
||
tag.connectionIds.forEach((connectionId) => {
|
||
const conn = connectionById.get(connectionId);
|
||
if (!conn || groupedConnectionIds.has(conn.id)) return;
|
||
groupedConnectionIds.add(conn.id);
|
||
tagConnections.push(conn);
|
||
});
|
||
if (tagConnections.length === 0) return;
|
||
groups.push({
|
||
id: tag.id,
|
||
name: tag.name || '未命名分组',
|
||
connections: tagConnections,
|
||
});
|
||
});
|
||
|
||
const ungroupedConnections = connections.filter((conn) => !groupedConnectionIds.has(conn.id));
|
||
if (ungroupedConnections.length > 0) {
|
||
groups.push({
|
||
id: V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID,
|
||
name: groups.length > 0 ? '未分组' : '',
|
||
connections: ungroupedConnections,
|
||
isUngrouped: true,
|
||
});
|
||
}
|
||
|
||
return groups;
|
||
};
|
||
|
||
export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = '组'): string => {
|
||
const trimmed = String(name ?? '').trim();
|
||
if (!trimmed) return fallback;
|
||
const ascii = trimmed.replace(/[^a-z0-9]/gi, '');
|
||
if (ascii.length >= 2) return ascii.slice(0, 2).toUpperCase();
|
||
return trimmed.slice(0, 1);
|
||
};
|
||
|
||
const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'tables', label: '表' },
|
||
{ key: 'views', label: '视图' },
|
||
{ key: 'routines', label: '函数' },
|
||
];
|
||
|
||
const V2_EXPLORER_FILTER_GROUP_KEYS: Record<Exclude<V2ExplorerFilter, 'all'>, string[]> = {
|
||
tables: ['tables'],
|
||
views: ['views', 'materializedViews'],
|
||
routines: ['routines'],
|
||
};
|
||
|
||
export const filterV2ExplorerTreeByKind = (
|
||
nodes: TreeNode[],
|
||
filter: V2ExplorerFilter,
|
||
): TreeNode[] => {
|
||
if (filter === 'all') return nodes;
|
||
const allowedGroupKeys = new Set(V2_EXPLORER_FILTER_GROUP_KEYS[filter]);
|
||
const objectTypeMatches = (node: TreeNode): boolean => {
|
||
if (filter === 'tables') return node.type === 'table';
|
||
if (filter === 'views') return node.type === 'view' || node.type === 'materialized-view';
|
||
if (filter === 'routines') return node.type === 'routine';
|
||
return false;
|
||
};
|
||
|
||
const visit = (node: TreeNode): TreeNode | null => {
|
||
const groupKey = String(node?.dataRef?.groupKey || '');
|
||
if (node.type === 'object-group') {
|
||
if (allowedGroupKeys.has(groupKey)) {
|
||
return node;
|
||
}
|
||
if (groupKey === 'schema') {
|
||
const schemaChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[];
|
||
return schemaChildren.length > 0 ? { ...node, children: schemaChildren, isLeaf: false } : null;
|
||
}
|
||
return null;
|
||
}
|
||
if (objectTypeMatches(node)) {
|
||
return node;
|
||
}
|
||
if (node.type === 'database') {
|
||
const filteredChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[];
|
||
return filteredChildren.length > 0 ? { ...node, children: filteredChildren, isLeaf: false } : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return nodes.map(visit).filter(Boolean) as TreeNode[];
|
||
};
|
||
|
||
interface BatchObjectItem {
|
||
title: string;
|
||
key: string;
|
||
objectName: string;
|
||
objectType: BatchObjectType;
|
||
dataRef: any;
|
||
}
|
||
|
||
type V2CommandSearchItem =
|
||
| {
|
||
key: string;
|
||
kind: 'node';
|
||
title: string;
|
||
meta: string;
|
||
icon: React.ReactNode;
|
||
node: TreeNode;
|
||
}
|
||
| {
|
||
key: string;
|
||
kind: 'action';
|
||
title: string;
|
||
meta: string;
|
||
shortcut?: string;
|
||
icon: React.ReactNode;
|
||
onRun: () => void;
|
||
}
|
||
| {
|
||
key: string;
|
||
kind: 'recent';
|
||
title: string;
|
||
meta: string;
|
||
icon: React.ReactNode;
|
||
sql: string;
|
||
connectionId?: string;
|
||
dbName?: string;
|
||
};
|
||
|
||
export type V2CommandSearchMode = 'default' | 'object' | 'ai';
|
||
|
||
export interface V2CommandSearchQuery {
|
||
mode: V2CommandSearchMode;
|
||
rawValue: string;
|
||
keyword: string;
|
||
normalizedKeyword: string;
|
||
aiPrompt: string;
|
||
}
|
||
|
||
export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery => {
|
||
const rawValue = String(value ?? '');
|
||
const trimmedValue = rawValue.trim();
|
||
const firstChar = trimmedValue.charAt(0);
|
||
|
||
if (firstChar === '@' || firstChar === '@') {
|
||
const keyword = trimmedValue.slice(1).trim();
|
||
return {
|
||
mode: 'object',
|
||
rawValue,
|
||
keyword,
|
||
normalizedKeyword: keyword.toLowerCase(),
|
||
aiPrompt: '',
|
||
};
|
||
}
|
||
|
||
if (firstChar === '?' || firstChar === '?') {
|
||
const aiPrompt = trimmedValue.slice(1).trim();
|
||
return {
|
||
mode: 'ai',
|
||
rawValue,
|
||
keyword: aiPrompt,
|
||
normalizedKeyword: aiPrompt.toLowerCase(),
|
||
aiPrompt,
|
||
};
|
||
}
|
||
|
||
return {
|
||
mode: 'default',
|
||
rawValue,
|
||
keyword: trimmedValue,
|
||
normalizedKeyword: trimmedValue.toLowerCase(),
|
||
aiPrompt: '',
|
||
};
|
||
};
|
||
|
||
export const resolveSidebarConnectionIdFromKey = (
|
||
key: unknown,
|
||
connectionIds: string[],
|
||
): string => {
|
||
const keyText = String(key ?? '').trim();
|
||
if (!keyText) return '';
|
||
|
||
const sortedIds = Array.from(new Set(connectionIds.filter(Boolean)))
|
||
.sort((a, b) => b.length - a.length);
|
||
return sortedIds.find((id) => keyText === id || keyText.startsWith(`${id}-`)) || '';
|
||
};
|
||
|
||
export const resolveSidebarNodeConnectionId = (
|
||
node: { key?: unknown; dataRef?: Record<string, unknown> } | null | undefined,
|
||
connectionIds: string[],
|
||
): string => {
|
||
const directId = String(node?.dataRef?.id || node?.dataRef?.connectionId || '').trim();
|
||
if (directId && connectionIds.includes(directId)) return directId;
|
||
return resolveSidebarConnectionIdFromKey(node?.key, connectionIds);
|
||
};
|
||
|
||
export const resolveV2ActiveConnectionId = ({
|
||
activeContextConnectionId,
|
||
activeTabConnectionId,
|
||
selectedKeys,
|
||
connectionIds,
|
||
fallbackConnectionId,
|
||
}: {
|
||
activeContextConnectionId?: unknown;
|
||
activeTabConnectionId?: unknown;
|
||
selectedKeys: unknown[];
|
||
connectionIds: string[];
|
||
fallbackConnectionId?: unknown;
|
||
}): string => {
|
||
const connectionIdSet = new Set(connectionIds);
|
||
const normalizeDirectId = (value: unknown): string => {
|
||
const text = String(value || '').trim();
|
||
return text && connectionIdSet.has(text) ? text : '';
|
||
};
|
||
const selectedConnectionId = selectedKeys
|
||
.map((key) => resolveSidebarConnectionIdFromKey(key, connectionIds))
|
||
.find(Boolean) || '';
|
||
|
||
return normalizeDirectId(activeContextConnectionId)
|
||
|| selectedConnectionId
|
||
|| normalizeDirectId(fallbackConnectionId)
|
||
|| normalizeDirectId(activeTabConnectionId)
|
||
|| connectionIds[0]
|
||
|| '';
|
||
};
|
||
|
||
export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui;
|
||
|
||
type DriverStatusSnapshot = {
|
||
type: string;
|
||
name: string;
|
||
connectable: boolean;
|
||
expectedRevision?: string;
|
||
needsUpdate?: boolean;
|
||
updateReason?: string;
|
||
message?: string;
|
||
};
|
||
|
||
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
|
||
if (!conn) return '';
|
||
return JSON.stringify({
|
||
config: conn.config || {},
|
||
includeDatabases: conn.includeDatabases || [],
|
||
includeRedisDatabases: conn.includeRedisDatabases || [],
|
||
});
|
||
};
|
||
|
||
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
|
||
const text = String(key);
|
||
return text === connectionId || text.startsWith(`${connectionId}-`);
|
||
};
|
||
|
||
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
|
||
|
||
const normalizeDriverType = (value: string): string => {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
|
||
if (normalized === 'doris') return 'diros';
|
||
if (
|
||
normalized === 'open_gauss' ||
|
||
normalized === 'open-gauss' ||
|
||
normalized === 'opengauss'
|
||
) return 'opengauss';
|
||
if (
|
||
normalized === 'intersystems' ||
|
||
normalized === 'intersystemsiris' ||
|
||
normalized === 'inter-systems' ||
|
||
normalized === 'inter-systems-iris'
|
||
) return 'iris';
|
||
return normalized;
|
||
};
|
||
|
||
const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
|
||
const type = normalizeDriverType(conn?.config?.type || '');
|
||
if (type !== 'custom') {
|
||
return type;
|
||
}
|
||
return normalizeDriverType(conn?.config?.driver || '');
|
||
};
|
||
|
||
const isPostgresSchemaDialect = (dialect: string): boolean => (
|
||
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect))
|
||
);
|
||
|
||
const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [
|
||
{ value: 'smart', label: '智能' },
|
||
{ value: 'object', label: '表对象' },
|
||
{ value: 'database', label: '库' },
|
||
{ value: 'host', label: 'Host' },
|
||
{ value: 'tag', label: '标签' },
|
||
];
|
||
|
||
const SEARCH_SCOPE_LABEL_MAP: Record<SearchScope, string> = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => {
|
||
acc[option.value] = option.label;
|
||
return acc;
|
||
}, {} as Record<SearchScope, string>);
|
||
|
||
|
||
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||
smart: <ThunderboltOutlined />,
|
||
object: <TableOutlined />,
|
||
database: <DatabaseOutlined />,
|
||
host: <CloudOutlined />,
|
||
tag: <TagOutlined />,
|
||
};
|
||
|
||
const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknown): string => {
|
||
const text = String(rawDefinition || '').trim();
|
||
if (!text) return '';
|
||
|
||
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
|
||
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
|
||
if (createViewPrefixPattern.test(normalized)) {
|
||
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
|
||
}
|
||
|
||
if (/^\s*(select|with)\b/i.test(normalized)) {
|
||
return `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`;
|
||
}
|
||
|
||
return `${normalized};`;
|
||
};
|
||
|
||
const Sidebar: React.FC<{
|
||
onCreateConnection?: () => void;
|
||
onEditConnection?: (conn: SavedConnection) => void;
|
||
onOpenTools?: () => void;
|
||
onOpenSettings?: () => void;
|
||
onToggleAI?: () => void;
|
||
onToggleLogPanel?: () => void;
|
||
sqlLogCount?: number;
|
||
uiVersion?: 'legacy' | 'v2';
|
||
onFocusCommandSearch?: () => void;
|
||
}> = React.memo(({
|
||
onCreateConnection,
|
||
onEditConnection,
|
||
onOpenTools,
|
||
onOpenSettings,
|
||
onToggleAI,
|
||
onToggleLogPanel,
|
||
sqlLogCount = 0,
|
||
uiVersion,
|
||
onFocusCommandSearch,
|
||
}) => {
|
||
const connections = useStore(state => state.connections);
|
||
const savedQueries = useStore(state => state.savedQueries);
|
||
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
|
||
const deleteQuery = useStore(state => state.deleteQuery);
|
||
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
|
||
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
|
||
const addConnection = useStore(state => state.addConnection);
|
||
const addTab = useStore(state => state.addTab);
|
||
const tabs = useStore(state => state.tabs);
|
||
const activeTabId = useStore(state => state.activeTabId);
|
||
const setActiveContext = useStore(state => state.setActiveContext);
|
||
const removeConnection = useStore(state => state.removeConnection);
|
||
const connectionTags = useStore(state => state.connectionTags);
|
||
const addConnectionTag = useStore(state => state.addConnectionTag);
|
||
const updateConnectionTag = useStore(state => state.updateConnectionTag);
|
||
const removeConnectionTag = useStore(state => state.removeConnectionTag);
|
||
const moveConnectionToTag = useStore(state => state.moveConnectionToTag);
|
||
const reorderTags = useStore(state => state.reorderTags);
|
||
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
|
||
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
|
||
const theme = useStore(state => state.theme);
|
||
const appearance = useStore(state => state.appearance);
|
||
const activeContext = useStore(state => state.activeContext);
|
||
const tableAccessCount = useStore(state => state.tableAccessCount);
|
||
const tableSortPreference = useStore(state => state.tableSortPreference);
|
||
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
|
||
const recordTableAccess = useStore(state => state.recordTableAccess);
|
||
const setTableSortPreference = useStore(state => state.setTableSortPreference);
|
||
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
|
||
const addSqlLog = useStore(state => state.addSqlLog);
|
||
const sqlLogs = useStore(state => state.sqlLogs) || [];
|
||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||
const addAIContext = useStore(state => state.addAIContext);
|
||
const darkMode = theme === 'dark';
|
||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||
const autoFetchVisible = useAutoFetchVisibility();
|
||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||
const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]);
|
||
const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]);
|
||
const canLocateActiveTab = !!activeTabLocateRequest;
|
||
|
||
// Background Helper (Duplicate logic for now, ideally shared)
|
||
const getBg = (darkHex: string) => {
|
||
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
|
||
const hex = darkHex.replace('#', '');
|
||
const r = parseInt(hex.substring(0, 2), 16);
|
||
const g = parseInt(hex.substring(2, 4), 16);
|
||
const b = parseInt(hex.substring(4, 6), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||
};
|
||
const bgMain = getBg('#141414');
|
||
const overlayTheme = useMemo(
|
||
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||
[darkMode, disableLocalBackdropFilter, appearance.uiVersion],
|
||
);
|
||
const modalPanelStyle = useMemo(() => ({
|
||
background: overlayTheme.shellBg,
|
||
border: overlayTheme.shellBorder,
|
||
boxShadow: overlayTheme.shellShadow,
|
||
backdropFilter: overlayTheme.shellBackdropFilter,
|
||
}), [overlayTheme]);
|
||
const modalSectionStyle = useMemo(() => ({
|
||
padding: 14,
|
||
borderRadius: 14,
|
||
border: overlayTheme.sectionBorder,
|
||
background: overlayTheme.sectionBg,
|
||
}), [overlayTheme]);
|
||
const modalScrollSectionStyle = useMemo(() => ({
|
||
maxHeight: 400,
|
||
overflow: 'auto' as const,
|
||
border: overlayTheme.sectionBorder,
|
||
borderRadius: 14,
|
||
padding: 12,
|
||
background: overlayTheme.sectionBg,
|
||
}), [overlayTheme]);
|
||
const modalHintTextStyle = useMemo(() => ({
|
||
color: overlayTheme.mutedText,
|
||
fontSize: 12,
|
||
lineHeight: 1.6,
|
||
}), [overlayTheme]);
|
||
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||
{icon}
|
||
</div>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
|
||
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const deferredSearchValue = useDeferredValue(searchValue);
|
||
const [searchScopes, setSearchScopes] = useState<SearchScope[]>(['smart']);
|
||
const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('all');
|
||
const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false);
|
||
const searchInputRef = useRef<any>(null);
|
||
const commandSearchInputRef = useRef<any>(null);
|
||
const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false);
|
||
const [v2CommandSearchValue, setV2CommandSearchValue] = useState('');
|
||
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
|
||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||
const selectedNodesRef = useRef<any[]>([]);
|
||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
|
||
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
|
||
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
|
||
const v2RailConnectionGroups = useMemo(
|
||
() => buildV2RailConnectionGroups(connections, connectionTags),
|
||
[connections, connectionTags],
|
||
);
|
||
const [collapsedV2RailGroupIds, setCollapsedV2RailGroupIds] = useState<string[]>([]);
|
||
const collapsedV2RailGroupIdSet = useMemo(
|
||
() => new Set(collapsedV2RailGroupIds),
|
||
[collapsedV2RailGroupIds],
|
||
);
|
||
const hasV2RailConnectionGroups = v2RailConnectionGroups.some((group) => !group.isUngrouped);
|
||
|
||
const openV2CommandSearch = useCallback(() => {
|
||
setIsV2CommandSearchOpen(true);
|
||
setV2CommandActiveIndex(0);
|
||
}, []);
|
||
|
||
const closeV2CommandSearch = useCallback(() => {
|
||
setIsV2CommandSearchOpen(false);
|
||
setV2CommandSearchValue('');
|
||
setV2CommandActiveIndex(0);
|
||
}, []);
|
||
|
||
// Virtual Scroll State
|
||
const [treeHeight, setTreeHeight] = useState(500);
|
||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||
const treeRef = useRef<any>(null);
|
||
const treeDataRef = useRef<TreeNode[]>([]);
|
||
const findTreeNodeByKeyRef = useRef<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>(() => null);
|
||
const expandConnectionFromRailRef = useRef<(connectionId: string) => void>(() => {});
|
||
useEffect(() => {
|
||
treeDataRef.current = treeData;
|
||
}, [treeData]);
|
||
|
||
useEffect(() => {
|
||
if (!treeContainerRef.current) return;
|
||
const resizeObserver = new ResizeObserver(entries => {
|
||
for (let entry of entries) {
|
||
setTreeHeight(entry.contentRect.height);
|
||
}
|
||
});
|
||
resizeObserver.observe(treeContainerRef.current);
|
||
return () => resizeObserver.disconnect();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handleFocusSidebarSearch = () => {
|
||
if ((uiVersion ?? appearance.uiVersion) === 'v2') {
|
||
openV2CommandSearch();
|
||
return;
|
||
}
|
||
const inputEl = searchInputRef.current?.input as HTMLInputElement | undefined;
|
||
if (!inputEl) {
|
||
return;
|
||
}
|
||
inputEl.focus();
|
||
inputEl.select();
|
||
};
|
||
window.addEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
|
||
return () => {
|
||
window.removeEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
|
||
};
|
||
}, [appearance.uiVersion, openV2CommandSearch, uiVersion]);
|
||
|
||
useEffect(() => {
|
||
if (!isV2CommandSearchOpen) return;
|
||
const timer = window.setTimeout(() => {
|
||
const inputEl = commandSearchInputRef.current?.input as HTMLInputElement | undefined;
|
||
inputEl?.focus();
|
||
inputEl?.select();
|
||
}, 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [isV2CommandSearchOpen]);
|
||
|
||
// Connection Status State: key -> 'success' | 'error'
|
||
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
|
||
|
||
// Create Database Modal
|
||
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
|
||
const [createDbForm] = Form.useForm();
|
||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||
const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false);
|
||
const [createSchemaForm] = Form.useForm();
|
||
const [createSchemaTarget, setCreateSchemaTarget] = useState<any>(null);
|
||
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
|
||
const [renameDbForm] = Form.useForm();
|
||
const [renameDbTarget, setRenameDbTarget] = useState<any>(null);
|
||
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
|
||
const [renameTableForm] = Form.useForm();
|
||
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
|
||
const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false);
|
||
const [renameViewForm] = Form.useForm();
|
||
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
|
||
|
||
// Connection Tag Modals
|
||
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
|
||
const [createTagForm] = Form.useForm();
|
||
|
||
// Batch Operations Modal
|
||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
|
||
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
|
||
const [batchDbContext, setBatchDbContext] = useState<any>(null);
|
||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
|
||
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
|
||
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('filtered');
|
||
const filteredBatchObjects = useMemo(() => {
|
||
const keyword = batchFilterKeyword.trim().toLowerCase();
|
||
return batchTables.filter((item) => {
|
||
if (batchFilterType !== 'all' && item.objectType !== batchFilterType) {
|
||
return false;
|
||
}
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword);
|
||
});
|
||
}, [batchFilterKeyword, batchFilterType, batchTables]);
|
||
const groupedBatchObjects = useMemo(() => {
|
||
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
|
||
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
|
||
return { tables, views };
|
||
}, [filteredBatchObjects]);
|
||
const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]);
|
||
const allBatchObjectKeysByType = useMemo(() => {
|
||
if (batchFilterType === 'all') {
|
||
return allBatchObjectKeys;
|
||
}
|
||
return batchTables
|
||
.filter((item) => item.objectType === batchFilterType)
|
||
.map((item) => item.key);
|
||
}, [allBatchObjectKeys, batchFilterType, batchTables]);
|
||
const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]);
|
||
const selectionScopeTargetKeys = useMemo(
|
||
() => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType),
|
||
[allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys]
|
||
);
|
||
useEffect(() => {
|
||
if (batchFilterType === 'all') {
|
||
return;
|
||
}
|
||
const allowed = new Set(allBatchObjectKeysByType);
|
||
setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key)));
|
||
}, [allBatchObjectKeysByType, batchFilterType]);
|
||
|
||
// Batch Database Operations Modal
|
||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||
const [batchDatabases, setBatchDatabases] = useState<any[]>([]);
|
||
const [checkedDbKeys, setCheckedDbKeys] = useState<string[]>([]);
|
||
const [batchConnContext, setBatchConnContext] = useState<any>(null);
|
||
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
|
||
|
||
// Find in Database Modal
|
||
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
|
||
|
||
useEffect(() => {
|
||
if (!autoFetchVisible) {
|
||
return;
|
||
}
|
||
|
||
expandedKeys.forEach(key => {
|
||
const node = findTreeNodeByKey(treeData, key);
|
||
if (node && node.type === 'database') {
|
||
loadTables(node);
|
||
}
|
||
});
|
||
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
|
||
|
||
useEffect(() => {
|
||
const previousSignatures = connectionReloadSignaturesRef.current;
|
||
const nextSignatures: Record<string, string> = {};
|
||
const staleConnectionIds = new Set<string>();
|
||
|
||
connections.forEach((conn) => {
|
||
const signature = buildConnectionReloadSignature(conn);
|
||
nextSignatures[conn.id] = signature;
|
||
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
|
||
staleConnectionIds.add(conn.id);
|
||
}
|
||
});
|
||
connectionReloadSignaturesRef.current = nextSignatures;
|
||
|
||
if (staleConnectionIds.size > 0) {
|
||
const staleIds = Array.from(staleConnectionIds);
|
||
setLoadedKeys((prev) =>
|
||
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
|
||
);
|
||
setExpandedKeys((prev) =>
|
||
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
|
||
);
|
||
setConnectionStates((prev) => {
|
||
const next = { ...prev };
|
||
staleIds.forEach((id) => {
|
||
Object.keys(next).forEach((key) => {
|
||
if (isConnectionTreeKey(key, id)) {
|
||
delete next[key];
|
||
}
|
||
});
|
||
});
|
||
return next;
|
||
});
|
||
staleIds.forEach((id) => {
|
||
Array.from(loadingNodesRef.current).forEach((key) => {
|
||
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
|
||
loadingNodesRef.current.delete(key);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
setTreeData((prev) => {
|
||
const prevMap = new Map<string, TreeNode>();
|
||
|
||
// We need to recursively extract connections from old tag structures
|
||
// so if a user expands a connection that was tagged, the state remains
|
||
const recurseCollect = (nodes: TreeNode[]) => {
|
||
nodes.forEach((node) => {
|
||
if (node.type === 'tag') {
|
||
if (node.children) recurseCollect(node.children);
|
||
} else if (node.type === 'connection') {
|
||
prevMap.set(String(node.key), node);
|
||
}
|
||
});
|
||
};
|
||
recurseCollect(prev);
|
||
|
||
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
|
||
const existing = prevMap.get(conn.id);
|
||
const iconType = resolveConnectionIconType(conn);
|
||
const iconColor = resolveConnectionAccentColor(conn);
|
||
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
|
||
return {
|
||
title: conn.name,
|
||
key: conn.id,
|
||
icon: getDbIcon(iconType, iconColor, 22),
|
||
type: 'connection',
|
||
dataRef: conn,
|
||
isLeaf: false,
|
||
children: preserveChildren ? existing.children : undefined,
|
||
} as TreeNode;
|
||
};
|
||
|
||
const taggedConnIds = new Set<string>();
|
||
const tagNodes: TreeNode[] = connectionTags.map((tag) => {
|
||
tag.connectionIds.forEach(id => taggedConnIds.add(id));
|
||
return {
|
||
title: tag.name,
|
||
key: `tag-${tag.id}`,
|
||
icon: <FolderOutlined style={{ color: '#faad14' }} />,
|
||
type: 'tag',
|
||
dataRef: tag,
|
||
isLeaf: false,
|
||
children: tag.connectionIds
|
||
.map(cid => connections.find(c => c.id === cid))
|
||
.filter(Boolean)
|
||
.map(conn => buildConnectionNode(conn!)),
|
||
} as TreeNode;
|
||
});
|
||
|
||
const ungroupedNodes: TreeNode[] = connections
|
||
.filter(c => !taggedConnIds.has(c.id))
|
||
.map(conn => buildConnectionNode(conn));
|
||
|
||
return [...tagNodes, ...ungroupedNodes];
|
||
});
|
||
}, [connections, connectionTags]);
|
||
|
||
const handleDuplicateConnection = async (conn: SavedConnection) => {
|
||
if (!conn?.id) return;
|
||
|
||
const backendApp = (window as any).go?.app?.App;
|
||
if (typeof backendApp?.DuplicateConnection !== 'function') {
|
||
message.error('复制连接失败:后端接口不可用');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
|
||
if (!duplicatedConnection) {
|
||
throw new Error('复制连接失败:后端未返回结果');
|
||
}
|
||
addConnection(duplicatedConnection);
|
||
message.success(`已复制连接: ${duplicatedConnection.name}`);
|
||
} catch (error: any) {
|
||
message.error(error?.message || '复制连接失败');
|
||
}
|
||
};
|
||
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||
return list.map(node => {
|
||
if (node.key === key) {
|
||
return { ...node, children };
|
||
}
|
||
if (node.children) {
|
||
return { ...node, children: updateTreeData(node.children, key, children) };
|
||
}
|
||
return node;
|
||
});
|
||
};
|
||
|
||
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
|
||
for (const node of nodes) {
|
||
if (node.key === targetKey) {
|
||
return node;
|
||
}
|
||
if (node.children) {
|
||
const child = findTreeNodeByKey(node.children, targetKey);
|
||
if (child) {
|
||
return child;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
findTreeNodeByKeyRef.current = findTreeNodeByKey;
|
||
|
||
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
|
||
treeDataRef.current = nextTreeData;
|
||
setTreeData(nextTreeData);
|
||
return nextTreeData;
|
||
};
|
||
|
||
const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => {
|
||
setExpandedKeys(prev => {
|
||
const merged = [...prev];
|
||
requiredKeys.forEach(key => {
|
||
if (!merged.includes(key)) merged.push(key);
|
||
});
|
||
return merged;
|
||
});
|
||
setAutoExpandParent(true);
|
||
};
|
||
|
||
const scrollSidebarTreeToKey = (key: React.Key) => {
|
||
const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
|
||
? window.requestAnimationFrame.bind(window)
|
||
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0);
|
||
|
||
runAfterFrame(() => {
|
||
treeRef.current?.scrollTo?.({ key, align: 'auto' });
|
||
runAfterFrame(() => {
|
||
const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null;
|
||
selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||
});
|
||
});
|
||
};
|
||
|
||
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
|
||
const icon = (() => {
|
||
switch (node.type) {
|
||
case 'external-sql-root':
|
||
return <FolderOpenOutlined />;
|
||
case 'external-sql-directory':
|
||
return <HddOutlined />;
|
||
case 'external-sql-folder':
|
||
return <FolderOutlined />;
|
||
default:
|
||
return <FileTextOutlined />;
|
||
}
|
||
})();
|
||
|
||
return {
|
||
...node,
|
||
icon,
|
||
children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
|
||
};
|
||
};
|
||
|
||
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
|
||
if (!node) return null;
|
||
if (node.type === 'database') {
|
||
return {
|
||
connectionId: String(node?.dataRef?.id || '').trim(),
|
||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||
dbNodeKey: String(node.key || '').trim(),
|
||
};
|
||
}
|
||
|
||
if (
|
||
node.type === 'external-sql-root'
|
||
|| node.type === 'external-sql-directory'
|
||
|| node.type === 'external-sql-folder'
|
||
|| node.type === 'external-sql-file'
|
||
) {
|
||
return {
|
||
connectionId: String(node?.dataRef?.connectionId || '').trim(),
|
||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
|
||
};
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
|
||
'postgres',
|
||
'kingbase',
|
||
'highgo',
|
||
'vastbase',
|
||
'opengauss',
|
||
'open_gauss',
|
||
'open-gauss',
|
||
'sqlserver',
|
||
'iris',
|
||
'oracle',
|
||
'dameng',
|
||
]);
|
||
|
||
const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([
|
||
'postgres',
|
||
'kingbase',
|
||
'highgo',
|
||
'vastbase',
|
||
'opengauss',
|
||
'open_gauss',
|
||
'open-gauss',
|
||
'sqlserver',
|
||
'iris',
|
||
'oracle',
|
||
'dm',
|
||
]);
|
||
|
||
const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => {
|
||
const dbType = String(conn?.config?.type || '').trim().toLowerCase();
|
||
if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true;
|
||
if (dbType !== 'custom') return false;
|
||
|
||
const customDriver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||
return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver);
|
||
};
|
||
|
||
const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => {
|
||
const rawName = String(tableName || '').trim();
|
||
if (!rawName) return rawName;
|
||
if (!shouldHideSchemaPrefix(conn)) return rawName;
|
||
const lastDotIndex = rawName.lastIndexOf('.');
|
||
if (lastDotIndex <= 0 || lastDotIndex >= rawName.length - 1) return rawName;
|
||
return rawName.substring(lastDotIndex + 1);
|
||
};
|
||
|
||
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
|
||
const type = normalizeDriverType(String(conn?.config?.type || '').trim());
|
||
if (type === 'custom') {
|
||
const driver = normalizeDriverType(String(conn?.config?.driver || '').trim());
|
||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||
if (driver === 'oceanbase') return normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql';
|
||
return driver;
|
||
}
|
||
if (type === 'oceanbase' && normalizeOceanBaseProtocol(conn?.config?.oceanBaseProtocol) === 'oracle') return 'oracle';
|
||
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||
if (type === 'dameng') return 'dm';
|
||
return type;
|
||
};
|
||
|
||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||
|
||
type MetadataQuerySpec = {
|
||
sql: string;
|
||
inferredType?: 'FUNCTION' | 'PROCEDURE';
|
||
};
|
||
|
||
type MetadataQueryResult = {
|
||
rows: Record<string, any>[];
|
||
inferredType?: 'FUNCTION' | 'PROCEDURE';
|
||
};
|
||
|
||
const isSphinxConnection = (conn: SavedConnection | undefined): boolean => {
|
||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||
if (type === 'sphinx') return true;
|
||
if (type !== 'custom') return false;
|
||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||
return driver === 'sphinx' || driver === 'sphinxql';
|
||
};
|
||
|
||
const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => {
|
||
const seen = new Set<string>();
|
||
const normalized: MetadataQuerySpec[] = [];
|
||
specs.forEach((spec) => {
|
||
const sql = String(spec.sql || '').trim();
|
||
if (!sql) return;
|
||
const key = `${spec.inferredType || ''}@@${sql}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
normalized.push({ sql, inferredType: spec.inferredType });
|
||
});
|
||
return normalized;
|
||
};
|
||
|
||
const getCaseInsensitiveValue = (row: Record<string, any>, candidateKeys: string[]): string => {
|
||
const keyMap = new Map<string, any>();
|
||
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
|
||
for (const key of candidateKeys) {
|
||
const value = keyMap.get(key.toLowerCase());
|
||
if (value !== undefined && value !== null) {
|
||
const normalized = String(value).trim();
|
||
if (normalized !== '') return normalized;
|
||
}
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
|
||
const keyMap = new Map<string, any>();
|
||
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
|
||
for (const key of candidateKeys) {
|
||
const value = keyMap.get(key.toLowerCase());
|
||
if (value !== undefined && value !== null) {
|
||
return value;
|
||
}
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const getFirstRowValue = (row: Record<string, any>): string => {
|
||
for (const value of Object.values(row || {})) {
|
||
if (value !== undefined && value !== null) {
|
||
const normalized = String(value).trim();
|
||
if (normalized !== '') return normalized;
|
||
}
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const getMySQLShowTablesName = (row: Record<string, any>): string => {
|
||
for (const key of Object.keys(row || {})) {
|
||
if (!key.toLowerCase().startsWith('tables_in_')) continue;
|
||
const value = row[key];
|
||
if (value === undefined || value === null) continue;
|
||
const normalized = String(value).trim();
|
||
if (normalized !== '') return normalized;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const buildQualifiedName = (schemaName: string, objectName: string): string => {
|
||
const schema = String(schemaName || '').trim();
|
||
const name = String(objectName || '').trim();
|
||
if (!name) return '';
|
||
if (!schema) return name;
|
||
if (name.includes('.')) return name;
|
||
return `${schema}.${name}`;
|
||
};
|
||
|
||
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||
const raw = String(qualifiedName || '').trim();
|
||
if (!raw) return { schemaName: '', objectName: '' };
|
||
const idx = raw.lastIndexOf('.');
|
||
if (idx <= 0 || idx >= raw.length - 1) {
|
||
return { schemaName: '', objectName: raw };
|
||
}
|
||
return {
|
||
schemaName: raw.substring(0, idx),
|
||
objectName: raw.substring(idx + 1),
|
||
};
|
||
};
|
||
|
||
const parseDuckDBParameterNames = (raw: any): string[] => {
|
||
if (Array.isArray(raw)) {
|
||
return raw
|
||
.map((item) => String(item ?? '').trim())
|
||
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
|
||
}
|
||
|
||
const text = String(raw ?? '').trim();
|
||
if (!text) return [];
|
||
const normalized = text.startsWith('[') && text.endsWith(']')
|
||
? text.slice(1, -1)
|
||
: text;
|
||
return normalized
|
||
.split(',')
|
||
.map((part) => part.trim())
|
||
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
|
||
};
|
||
|
||
const buildDuckDBMacroDDL = (
|
||
schemaName: string,
|
||
functionName: string,
|
||
parametersRaw: any,
|
||
macroDefinitionRaw: any
|
||
): string => {
|
||
const schema = String(schemaName || '').trim();
|
||
const name = String(functionName || '').trim();
|
||
const macroDefinition = String(macroDefinitionRaw || '').trim();
|
||
if (!name || !macroDefinition) return '';
|
||
|
||
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
|
||
const qualifiedName = schema ? `${schema}.${name}` : name;
|
||
const isTableMacro = !macroDefinition.startsWith('(');
|
||
if (isTableMacro) {
|
||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
|
||
}
|
||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
|
||
};
|
||
|
||
const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
|
||
const safeDbName = escapeSQLLiteral(dbName);
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks': {
|
||
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
|
||
return normalizeMetadataQuerySpecs([
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT TABLE_NAME AS view_name, TABLE_SCHEMA AS schema_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`
|
||
: '',
|
||
},
|
||
{ sql: dbIdent ? `SHOW FULL TABLES FROM \`${dbIdent}\`` : '' },
|
||
{ sql: `SHOW FULL TABLES` },
|
||
]);
|
||
}
|
||
case 'postgres':
|
||
case 'kingbase':
|
||
case 'highgo':
|
||
case 'vastbase':
|
||
case 'opengauss':
|
||
return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }];
|
||
case 'sqlserver': {
|
||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||
return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }];
|
||
}
|
||
case 'oracle':
|
||
case 'dm':
|
||
return normalizeMetadataQuerySpecs([
|
||
{ sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` },
|
||
{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` },
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`
|
||
: '',
|
||
},
|
||
]);
|
||
case 'sqlite':
|
||
return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }];
|
||
case 'duckdb':
|
||
return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }];
|
||
default:
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
|
||
const safeDbName = escapeSQLLiteral(dbName);
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks': {
|
||
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
|
||
return normalizeMetadataQuerySpecs([
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`
|
||
: '',
|
||
},
|
||
{ sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' },
|
||
{ sql: `SHOW TRIGGERS` },
|
||
]);
|
||
}
|
||
case 'postgres':
|
||
case 'kingbase':
|
||
case 'highgo':
|
||
case 'vastbase':
|
||
case 'opengauss':
|
||
return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }];
|
||
case 'sqlserver': {
|
||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||
return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }];
|
||
}
|
||
case 'oracle':
|
||
case 'dm':
|
||
if (!safeDbName) {
|
||
return [{ sql: `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME` }];
|
||
}
|
||
return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }];
|
||
case 'sqlite':
|
||
return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }];
|
||
case 'duckdb':
|
||
return [];
|
||
default:
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const buildFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
|
||
const safeDbName = escapeSQLLiteral(dbName);
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
return normalizeMetadataQuerySpecs([
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME`
|
||
: '',
|
||
},
|
||
{
|
||
sql: safeDbName
|
||
? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'`
|
||
: `SHOW FUNCTION STATUS`,
|
||
inferredType: 'FUNCTION',
|
||
},
|
||
{
|
||
sql: safeDbName
|
||
? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'`
|
||
: `SHOW PROCEDURE STATUS`,
|
||
inferredType: 'PROCEDURE',
|
||
},
|
||
]);
|
||
case 'postgres':
|
||
case 'kingbase':
|
||
case 'highgo':
|
||
case 'vastbase':
|
||
case 'opengauss':
|
||
return normalizeMetadataQuerySpecs([
|
||
{
|
||
// PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE
|
||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`,
|
||
},
|
||
{
|
||
// PostgreSQL 10 / 不支持 prokind 的兼容路径
|
||
sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`,
|
||
},
|
||
{
|
||
// 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示
|
||
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`,
|
||
},
|
||
]);
|
||
case 'sqlserver': {
|
||
const safeDb = quoteSqlServerIdentifier(dbName || 'master');
|
||
return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }];
|
||
}
|
||
case 'oracle':
|
||
case 'dm':
|
||
return normalizeMetadataQuerySpecs([
|
||
{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
|
||
{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`
|
||
: '',
|
||
},
|
||
]);
|
||
case 'duckdb':
|
||
return [{
|
||
sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`,
|
||
inferredType: 'FUNCTION',
|
||
}];
|
||
default:
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => {
|
||
if (!isPostgresSchemaDialect(dialect)) {
|
||
return [];
|
||
}
|
||
return [{
|
||
sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`,
|
||
}];
|
||
};
|
||
|
||
const queryMetadataRowsBySpecs = async (
|
||
conn: any,
|
||
dbName: string,
|
||
specs: MetadataQuerySpec[]
|
||
): Promise<{ results: MetadataQueryResult[]; hasSuccessfulQuery: boolean }> => {
|
||
const normalizedSpecs = normalizeMetadataQuerySpecs(specs);
|
||
if (normalizedSpecs.length === 0) {
|
||
return { results: [], hasSuccessfulQuery: false };
|
||
}
|
||
const config = buildRuntimeConfig(conn, dbName);
|
||
const results: MetadataQueryResult[] = [];
|
||
let hasSuccessfulQuery = false;
|
||
|
||
for (const spec of normalizedSpecs) {
|
||
try {
|
||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql);
|
||
if (!result.success || !Array.isArray(result.data)) {
|
||
continue;
|
||
}
|
||
hasSuccessfulQuery = true;
|
||
results.push({
|
||
rows: result.data as Record<string, any>[],
|
||
inferredType: spec.inferredType,
|
||
});
|
||
} catch {
|
||
// 忽略单条查询失败,继续尝试后续回退语句
|
||
}
|
||
}
|
||
return { results, hasSuccessfulQuery };
|
||
};
|
||
|
||
const loadViews = async (conn: any, dbName: string): Promise<{ views: string[]; supported: boolean }> => {
|
||
const savedConn = conn as SavedConnection;
|
||
const dialect = getMetadataDialect(savedConn);
|
||
const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName);
|
||
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
|
||
const seen = new Set<string>();
|
||
const views: string[] = [];
|
||
|
||
results.forEach((queryResult) => {
|
||
queryResult.rows.forEach((row) => {
|
||
const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']);
|
||
if (tableType && tableType.toUpperCase() !== 'VIEW') return;
|
||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']);
|
||
const viewName =
|
||
getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name'])
|
||
|| getMySQLShowTablesName(row)
|
||
|| getFirstRowValue(row);
|
||
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
|
||
if (!fullName || seen.has(fullName)) return;
|
||
seen.add(fullName);
|
||
views.push(fullName);
|
||
});
|
||
});
|
||
return { views, supported: hasSuccessfulQuery };
|
||
};
|
||
|
||
const loadStarRocksMaterializedViews = async (
|
||
conn: any,
|
||
dbName: string
|
||
): Promise<{ views: string[]; supported: boolean }> => {
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
if (dialect !== 'starrocks') {
|
||
return { views: [], supported: false };
|
||
}
|
||
|
||
const safeDbName = escapeSQLLiteral(dbName);
|
||
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
|
||
const querySpecs = normalizeMetadataQuerySpecs([
|
||
{
|
||
sql: safeDbName
|
||
? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME`
|
||
: '',
|
||
},
|
||
{ sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' },
|
||
{ sql: `SHOW MATERIALIZED VIEWS` },
|
||
]);
|
||
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
|
||
const seen = new Set<string>();
|
||
const views: string[] = [];
|
||
|
||
results.forEach((queryResult) => {
|
||
queryResult.rows.forEach((row) => {
|
||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']);
|
||
const viewName =
|
||
getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name'])
|
||
|| getFirstRowValue(row);
|
||
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
|
||
if (!fullName || seen.has(fullName)) return;
|
||
seen.add(fullName);
|
||
views.push(fullName);
|
||
});
|
||
});
|
||
|
||
return { views, supported: hasSuccessfulQuery };
|
||
};
|
||
|
||
const loadDatabaseTriggers = async (
|
||
conn: any,
|
||
dbName: string
|
||
): Promise<{ triggers: Array<{ displayName: string; triggerName: string; tableName: string }>; supported: boolean }> => {
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const querySpecs = buildTriggersMetadataQuerySpecs(dialect, dbName);
|
||
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
|
||
const seen = new Set<string>();
|
||
const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = [];
|
||
|
||
results.forEach((queryResult) => {
|
||
queryResult.rows.forEach((row) => {
|
||
const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
|
||
if (!rawTriggerName) return;
|
||
|
||
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
|
||
const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
|
||
|
||
const triggerParts = splitQualifiedName(rawTriggerName);
|
||
const tableParts = splitQualifiedName(rawTableName);
|
||
|
||
const resolvedSchema = (
|
||
rawSchemaName
|
||
|| tableParts.schemaName
|
||
|| triggerParts.schemaName
|
||
|| dbName
|
||
).trim();
|
||
const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim();
|
||
const resolvedTableName = (tableParts.objectName || rawTableName).trim();
|
||
const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName);
|
||
|
||
// MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复
|
||
const uniqueKey = dialect === 'mysql'
|
||
? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}`
|
||
: `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`;
|
||
if (seen.has(uniqueKey)) return;
|
||
seen.add(uniqueKey);
|
||
const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName;
|
||
triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName });
|
||
});
|
||
});
|
||
return { triggers, supported: hasSuccessfulQuery };
|
||
};
|
||
|
||
const loadFunctions = async (
|
||
conn: any,
|
||
dbName: string
|
||
): Promise<{ routines: Array<{ displayName: string; routineName: string; routineType: string }>; supported: boolean }> => {
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const querySpecs = buildFunctionsMetadataQuerySpecs(dialect, dbName);
|
||
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
|
||
const seen = new Set<string>();
|
||
const routines: Array<{ displayName: string; routineName: string; routineType: string }> = [];
|
||
|
||
results.forEach((queryResult) => {
|
||
queryResult.rows.forEach((row) => {
|
||
const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']);
|
||
if (!routineName) return;
|
||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']);
|
||
const rawType = getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION';
|
||
const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION';
|
||
const fullName = buildQualifiedName(schemaName, routineName);
|
||
const uniqueKey = `${fullName}@@${normalizedType}`;
|
||
if (!fullName || seen.has(uniqueKey)) return;
|
||
seen.add(uniqueKey);
|
||
const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F';
|
||
routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType });
|
||
});
|
||
});
|
||
return { routines, supported: hasSuccessfulQuery };
|
||
};
|
||
|
||
const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => {
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const querySpecs = buildSchemasMetadataQuerySpecs(dialect);
|
||
const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs);
|
||
const seen = new Set<string>();
|
||
const schemas: string[] = [];
|
||
|
||
results.forEach((queryResult) => {
|
||
queryResult.rows.forEach((row) => {
|
||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row);
|
||
if (!schemaName) return;
|
||
const key = schemaName.toLowerCase();
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
schemas.push(schemaName);
|
||
});
|
||
});
|
||
|
||
return { schemas, supported: hasSuccessfulQuery };
|
||
};
|
||
|
||
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
|
||
const cached = driverStatusCacheRef.current;
|
||
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
|
||
return cached.items;
|
||
}
|
||
const result: Record<string, DriverStatusSnapshot> = {};
|
||
const res = await GetDriverStatusList('', '');
|
||
if (!res?.success) {
|
||
return result;
|
||
}
|
||
const data = (res.data || {}) as any;
|
||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||
drivers.forEach((item: any) => {
|
||
const type = normalizeDriverType(String(item.type || '').trim());
|
||
if (!type) return;
|
||
result[type] = {
|
||
type,
|
||
name: String(item.name || item.type || type).trim(),
|
||
connectable: !!item.connectable,
|
||
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
|
||
needsUpdate: !!item.needsUpdate,
|
||
updateReason: String(item.updateReason || '').trim() || undefined,
|
||
message: String(item.message || '').trim() || undefined,
|
||
};
|
||
});
|
||
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
|
||
return result;
|
||
};
|
||
|
||
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
|
||
try {
|
||
const driverType = resolveSavedConnectionDriverType(conn);
|
||
if (!driverType || driverType === 'custom') {
|
||
return;
|
||
}
|
||
const statusMap = await fetchDriverStatusMap();
|
||
const status = statusMap[driverType];
|
||
if (!status?.connectable || !status.needsUpdate) {
|
||
return;
|
||
}
|
||
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
|
||
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
|
||
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
|
||
return;
|
||
}
|
||
driverUpdateWarningKeysRef.current.add(warningKey);
|
||
const driverName = status.name || driverType;
|
||
const reason = status.message || status.updateReason || `${driverName} driver-agent 与当前 GoNavi 版本要求不一致`;
|
||
message.warning({
|
||
content: `${driverName} 驱动代理需要重装:${reason}`,
|
||
key: `driver-agent-update-${conn.id}`,
|
||
duration: 10,
|
||
});
|
||
} catch (error) {
|
||
console.warn('检查驱动代理更新状态失败', error);
|
||
}
|
||
};
|
||
|
||
const loadDatabases = async (node: any) => {
|
||
const conn = node.dataRef as SavedConnection;
|
||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||
const loadKey = `dbs-${conn.id}`;
|
||
if (loadingNodesRef.current.has(loadKey)) return;
|
||
loadingNodesRef.current.add(loadKey);
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: conn.config.database || "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
|
||
if (conn.config.type === 'jvm') {
|
||
try {
|
||
const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any);
|
||
if (res.success) {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||
const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : [];
|
||
const modeNodes: TreeNode[] = capabilities.map((capability) => ({
|
||
title: capability.displayLabel || capability.mode,
|
||
key: `${conn.id}-jvm-mode-${capability.mode}`,
|
||
icon: <HddOutlined />,
|
||
type: 'jvm-mode',
|
||
dataRef: {
|
||
...conn,
|
||
providerMode: capability.mode,
|
||
canBrowse: capability.canBrowse,
|
||
canWrite: capability.canWrite,
|
||
reason: capability.reason,
|
||
displayLabel: capability.displayLabel,
|
||
},
|
||
isLeaf: capability.canBrowse !== true,
|
||
}));
|
||
const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({
|
||
title: item.title,
|
||
key: item.key,
|
||
icon: <DashboardOutlined />,
|
||
type: 'jvm-monitoring',
|
||
dataRef: {
|
||
...conn,
|
||
providerMode: item.providerMode,
|
||
},
|
||
isLeaf: true,
|
||
}));
|
||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
|
||
} else {
|
||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
if (diagnosticNode.length > 0) {
|
||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||
} else {
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
if (diagnosticNode.length > 0) {
|
||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||
} else {
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-jvm-caps` });
|
||
}
|
||
} finally {
|
||
loadingNodesRef.current.delete(loadKey);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Handle Redis connections differently
|
||
if (conn.config.type === 'redis') {
|
||
try {
|
||
const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config));
|
||
if (res.success) {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
let dbs = redisRows.map((db: any) => ({
|
||
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
|
||
key: `${conn.id}-db${db.index}`,
|
||
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
|
||
type: 'redis-db' as const,
|
||
dataRef: { ...conn, redisDB: db.index },
|
||
isLeaf: true,
|
||
dbIndex: db.index,
|
||
}));
|
||
// Filter Redis databases if configured
|
||
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
|
||
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
|
||
}
|
||
replaceTreeNodeChildren(node.key, dbs);
|
||
} else {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||
}
|
||
} catch (e: any) {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
|
||
} finally {
|
||
loadingNodesRef.current.delete(loadKey);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||
if (res.success) {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
let dbs = dbRows.map((row: any) => ({
|
||
title: row.Database || row.database,
|
||
key: `${conn.id}-${row.Database || row.database}`,
|
||
icon: <DatabaseOutlined />,
|
||
type: 'database' as const,
|
||
dataRef: { ...conn, dbName: row.Database || row.database },
|
||
isLeaf: false,
|
||
}));
|
||
|
||
// Filter databases if configured
|
||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
|
||
}
|
||
|
||
if (dbs.length > 0) {
|
||
replaceTreeNodeChildren(node.key, dbs);
|
||
} else {
|
||
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.warning({ content: '未获取到可见数据库/schema,请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` });
|
||
}
|
||
} else {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||
}
|
||
} catch (e: any) {
|
||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
|
||
} finally {
|
||
loadingNodesRef.current.delete(loadKey);
|
||
}
|
||
};
|
||
|
||
const loadJVMResources = async (node: any) => {
|
||
const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string };
|
||
const providerMode = String(conn.providerMode || '').trim().toLowerCase();
|
||
const parentPath = String(conn.resourcePath || '').trim();
|
||
const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`;
|
||
if (loadingNodesRef.current.has(loadKey)) return;
|
||
loadingNodesRef.current.add(loadKey);
|
||
|
||
try {
|
||
const backendApp = (window as any).go?.app?.App;
|
||
if (typeof backendApp?.JVMListResources !== 'function') {
|
||
throw new Error('JVMListResources 后端方法不可用');
|
||
}
|
||
|
||
const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath);
|
||
if (res.success) {
|
||
const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : [];
|
||
const resourceNodes: TreeNode[] = resourceRows.map((item) => ({
|
||
title: item.name || item.path || item.id,
|
||
key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`,
|
||
icon: item.hasChildren ? <FolderOpenOutlined /> : <HddOutlined />,
|
||
type: 'jvm-resource',
|
||
dataRef: {
|
||
...conn,
|
||
providerMode: item.providerMode || providerMode,
|
||
resourcePath: item.path,
|
||
resourceKind: item.kind,
|
||
canRead: item.canRead,
|
||
canWrite: item.canWrite,
|
||
hasChildren: item.hasChildren,
|
||
sensitive: item.sensitive,
|
||
},
|
||
isLeaf: item.hasChildren !== true,
|
||
}));
|
||
replaceTreeNodeChildren(node.key, resourceNodes);
|
||
} else {
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
|
||
}
|
||
} catch (e: any) {
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||
message.error({ content: '加载 JVM 资源失败: ' + (e?.message || String(e)), key: `jvm-resource-${node.key}` });
|
||
} finally {
|
||
loadingNodesRef.current.delete(loadKey);
|
||
}
|
||
};
|
||
|
||
const loadTables = async (node: any) => {
|
||
const conn = node.dataRef; // has dbName
|
||
const dbName = conn.dbName;
|
||
const key = node.key;
|
||
const loadKey = `tables-${conn.id}-${dbName}`;
|
||
if (loadingNodesRef.current.has(loadKey)) return;
|
||
loadingNodesRef.current.add(loadKey);
|
||
|
||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||
const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName);
|
||
|
||
const queriesNode: TreeNode = {
|
||
title: '已存查询',
|
||
key: `${key}-queries`,
|
||
icon: <FolderOpenOutlined />,
|
||
type: 'queries-folder',
|
||
isLeaf: dbQueries.length === 0,
|
||
children: dbQueries.map(q => ({
|
||
title: q.name,
|
||
key: q.id,
|
||
icon: <FileTextOutlined />,
|
||
type: 'saved-query',
|
||
dataRef: q,
|
||
isLeaf: true
|
||
}))
|
||
};
|
||
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: conn.config.database || "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
try {
|
||
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName);
|
||
if (res.success) {
|
||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||
|
||
const tableRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
const tableEntries = tableRows.map((row: any) => {
|
||
const tableName = Object.values(row)[0] as string;
|
||
const parsed = splitQualifiedName(tableName);
|
||
return {
|
||
tableName,
|
||
schemaName: parsed.schemaName,
|
||
displayName: getSidebarTableDisplayName(conn, tableName),
|
||
};
|
||
});
|
||
|
||
const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult] = await Promise.all([
|
||
loadSchemas(conn, conn.dbName),
|
||
loadViews(conn, conn.dbName),
|
||
loadStarRocksMaterializedViews(conn, conn.dbName),
|
||
loadDatabaseTriggers(conn, conn.dbName),
|
||
loadFunctions(conn, conn.dbName),
|
||
]);
|
||
const externalSQLDirectoryResults = await Promise.all(
|
||
dbExternalSQLDirectories.map(async (directory) => {
|
||
const directoryRes = await ListSQLDirectory(directory.path);
|
||
if (!directoryRes.success) {
|
||
message.warning({
|
||
key: `external-sql-${directory.id}`,
|
||
content: `SQL 目录读取失败: ${directory.name} (${directoryRes.message})`,
|
||
});
|
||
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
|
||
}
|
||
return {
|
||
id: directory.id,
|
||
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
|
||
};
|
||
}),
|
||
);
|
||
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
|
||
accumulator[item.id] = item.entries;
|
||
return accumulator;
|
||
}, {});
|
||
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
|
||
dbNodeKey: String(key),
|
||
connectionId: String(conn.id),
|
||
dbName: String(conn.dbName),
|
||
directories: dbExternalSQLDirectories,
|
||
directoryTrees: externalSQLTrees,
|
||
}));
|
||
|
||
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
|
||
const materializedViewRows: string[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : [];
|
||
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
|
||
const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : [];
|
||
const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : [];
|
||
|
||
const viewEntries = viewRows.map((viewName: string) => {
|
||
const parsed = splitQualifiedName(viewName);
|
||
return {
|
||
viewName,
|
||
schemaName: parsed.schemaName,
|
||
displayName: getSidebarTableDisplayName(conn, viewName),
|
||
};
|
||
});
|
||
|
||
const materializedViewEntries = materializedViewRows.map((viewName: string) => {
|
||
const parsed = splitQualifiedName(viewName);
|
||
return {
|
||
viewName,
|
||
schemaName: parsed.schemaName,
|
||
displayName: getSidebarTableDisplayName(conn, viewName),
|
||
};
|
||
});
|
||
|
||
const triggerEntries = (() => {
|
||
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
|
||
const triggerSeen = new Set<string>();
|
||
const metadataDialect = getMetadataDialect(conn as SavedConnection);
|
||
|
||
triggerRows.forEach((trigger: any) => {
|
||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
|
||
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
|
||
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
|
||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||
const dedupeKey = metadataDialect === 'mysql'
|
||
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
|
||
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
|
||
|
||
if (triggerSeen.has(dedupeKey)) return;
|
||
triggerSeen.add(dedupeKey);
|
||
deduped.push({
|
||
...trigger,
|
||
schemaName,
|
||
triggerName: triggerObjectName,
|
||
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
|
||
displayName,
|
||
});
|
||
});
|
||
|
||
return deduped;
|
||
})();
|
||
|
||
const routineEntries = routineRows.map((routine: any) => {
|
||
const parsed = splitQualifiedName(routine.routineName);
|
||
const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F';
|
||
return {
|
||
...routine,
|
||
schemaName: parsed.schemaName,
|
||
displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`,
|
||
};
|
||
});
|
||
|
||
if (isSphinxConnection(conn as SavedConnection)) {
|
||
const unsupportedObjects: string[] = [];
|
||
if (!viewsResult.supported) unsupportedObjects.push('视图');
|
||
if (!routinesResult.supported) unsupportedObjects.push('函数/存储过程');
|
||
if (!triggersResult.supported) unsupportedObjects.push('触发器');
|
||
if (unsupportedObjects.length > 0) {
|
||
message.info({
|
||
key: `sphinx-capability-${conn.id}-${conn.dbName}`,
|
||
content: `当前 Sphinx 实例未开放以下对象能力:${unsupportedObjects.join('、')}(已自动降级兼容)`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const currentStoreState = useStore.getState();
|
||
const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference;
|
||
const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount;
|
||
const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables;
|
||
|
||
// 获取当前数据库的排序偏好
|
||
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
|
||
const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name';
|
||
|
||
const sortedTableEntries = sortSidebarTableEntries(tableEntries, {
|
||
connectionId: conn.id,
|
||
dbName: conn.dbName,
|
||
sortBy,
|
||
tableAccessCount: currentTableAccessCount,
|
||
pinnedSidebarTables: currentPinnedSidebarTables,
|
||
});
|
||
|
||
// Sort views by name (case-insensitive)
|
||
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||
|
||
materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||
|
||
// Sort triggers by display name (case-insensitive)
|
||
triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||
|
||
// Sort routines by display name (case-insensitive)
|
||
routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||
|
||
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => {
|
||
const isPinned = isSidebarTablePinned(currentPinnedSidebarTables, conn.id, conn.dbName, entry.tableName, entry.schemaName);
|
||
return {
|
||
title: entry.displayName,
|
||
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
|
||
icon: isPinned ? <PushpinOutlined /> : <TableOutlined />,
|
||
type: 'table',
|
||
dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName, pinnedSidebarTable: isPinned },
|
||
isLeaf: false,
|
||
};
|
||
};
|
||
|
||
const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||
title: entry.displayName,
|
||
key: `${conn.id}-${conn.dbName}-view-${entry.viewName}`,
|
||
icon: <EyeOutlined />,
|
||
type: 'view',
|
||
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName },
|
||
isLeaf: true,
|
||
});
|
||
|
||
const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||
title: entry.displayName,
|
||
key: `${conn.id}-${conn.dbName}-materialized-view-${entry.viewName}`,
|
||
icon: <ThunderboltOutlined />,
|
||
type: 'materialized-view',
|
||
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' },
|
||
isLeaf: true,
|
||
});
|
||
|
||
const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({
|
||
title: entry.displayName,
|
||
key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`,
|
||
icon: <FunctionOutlined />,
|
||
type: 'db-trigger',
|
||
dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, schemaName: entry.schemaName },
|
||
isLeaf: true,
|
||
});
|
||
|
||
const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({
|
||
title: entry.displayName,
|
||
key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`,
|
||
icon: <CodeOutlined />,
|
||
type: 'routine',
|
||
dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName },
|
||
isLeaf: true,
|
||
});
|
||
|
||
const buildObjectGroup = (
|
||
parentKey: string,
|
||
groupKey: string,
|
||
groupTitle: string,
|
||
groupIcon: React.ReactNode,
|
||
children: TreeNode[],
|
||
extraData: Record<string, any> = {}
|
||
): TreeNode => ({
|
||
title: groupTitle,
|
||
key: `${parentKey}-${groupKey}`,
|
||
icon: groupIcon,
|
||
type: 'object-group',
|
||
isLeaf: children.length === 0,
|
||
children: children.length > 0 ? children : undefined,
|
||
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
|
||
});
|
||
|
||
const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection);
|
||
if (shouldGroupBySchema) {
|
||
type SchemaBucket = {
|
||
schemaName: string;
|
||
tables: TreeNode[];
|
||
views: TreeNode[];
|
||
materializedViews: TreeNode[];
|
||
routines: TreeNode[];
|
||
triggers: TreeNode[];
|
||
};
|
||
|
||
const schemaMap = new Map<string, SchemaBucket>();
|
||
const getSchemaBucket = (rawSchemaName: string): SchemaBucket => {
|
||
const schemaName = String(rawSchemaName || '').trim();
|
||
const schemaKey = schemaName || '__default__';
|
||
let bucket = schemaMap.get(schemaKey);
|
||
if (!bucket) {
|
||
bucket = {
|
||
schemaName,
|
||
tables: [],
|
||
views: [],
|
||
materializedViews: [],
|
||
routines: [],
|
||
triggers: [],
|
||
};
|
||
schemaMap.set(schemaKey, bucket);
|
||
}
|
||
return bucket;
|
||
};
|
||
|
||
schemaRows.forEach((schemaName) => getSchemaBucket(schemaName));
|
||
sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry)));
|
||
viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry)));
|
||
materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry)));
|
||
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
|
||
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
|
||
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
|
||
const includeMaterializedViews = dialect === 'starrocks';
|
||
|
||
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
|
||
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
|
||
.sort((a, b) => {
|
||
if (!a.schemaName && !b.schemaName) return 0;
|
||
if (!a.schemaName) return -1;
|
||
if (!b.schemaName) return 1;
|
||
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
|
||
})
|
||
.map((bucket) => {
|
||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||
const schemaTitle = bucket.schemaName || '默认模式';
|
||
const groupedNodes: TreeNode[] = [
|
||
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
|
||
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
|
||
...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', '物化视图', <ThunderboltOutlined />, bucket.materializedViews, { schemaName: bucket.schemaName })] : []),
|
||
buildObjectGroup(schemaNodeKey, 'routines', '函数', <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
|
||
buildObjectGroup(schemaNodeKey, 'triggers', '触发器', <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
|
||
];
|
||
|
||
return {
|
||
title: schemaTitle,
|
||
key: schemaNodeKey,
|
||
icon: <FolderOpenOutlined />,
|
||
type: 'object-group' as const,
|
||
isLeaf: groupedNodes.length === 0,
|
||
children: groupedNodes,
|
||
dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName }
|
||
};
|
||
});
|
||
|
||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]);
|
||
} else {
|
||
const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks';
|
||
const groupedNodes: TreeNode[] = [
|
||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, sortedTableEntries.map(buildTableNode)),
|
||
buildObjectGroup(key as string, 'views', '视图', <EyeOutlined />, viewEntries.map(buildViewNode)),
|
||
...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', '物化视图', <ThunderboltOutlined />, materializedViewEntries.map(buildMaterializedViewNode))] : []),
|
||
buildObjectGroup(key as string, 'routines', '函数', <CodeOutlined />, routineEntries.map(buildRoutineNode)),
|
||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||
];
|
||
|
||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...groupedNodes]);
|
||
}
|
||
} else {
|
||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||
message.error({ content: res.message, key: `db-${key}-tables` });
|
||
}
|
||
} catch (e: any) {
|
||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||
message.error({ content: '加载表失败: ' + (e?.message || String(e)), key: `db-${key}-tables` });
|
||
} finally {
|
||
loadingNodesRef.current.delete(loadKey);
|
||
}
|
||
};
|
||
|
||
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
|
||
|
||
const waitForSidebarLoadKey = async (loadKey: string) => {
|
||
for (let attempt = 0; attempt < 30 && loadingNodesRef.current.has(loadKey); attempt += 1) {
|
||
await new Promise(resolve => window.setTimeout(resolve, 50));
|
||
}
|
||
};
|
||
|
||
const locateObjectInSidebar = async (detail: unknown) => {
|
||
const request = normalizeSidebarLocateObjectRequest(detail);
|
||
if (!request) {
|
||
message.warning('当前标签页没有可定位的表上下文');
|
||
return;
|
||
}
|
||
|
||
const conn = connections.find(item => item.id === request.connectionId);
|
||
if (!conn) {
|
||
message.warning('未找到当前表对应的连接');
|
||
return;
|
||
}
|
||
|
||
const target = resolveSidebarLocateTarget(request, {
|
||
groupBySchema: shouldHideSchemaPrefix(conn),
|
||
});
|
||
const objectLabel = request.objectGroup === 'materializedViews' ? '物化视图' : (request.objectGroup === 'views' ? '视图' : '表');
|
||
|
||
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||
const dbLoadKey = `dbs-${request.connectionId}`;
|
||
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
|
||
|
||
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
|
||
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
|
||
if (!connectionNode) {
|
||
message.warning('未在左侧树找到当前连接');
|
||
return;
|
||
}
|
||
if (loadingNodesRef.current.has(dbLoadKey)) {
|
||
await waitForSidebarLoadKey(dbLoadKey);
|
||
} else {
|
||
await loadDatabases(connectionNode);
|
||
}
|
||
}
|
||
|
||
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
|
||
if (!dbNode) {
|
||
message.warning(`未在左侧树找到数据库:${request.dbName}`);
|
||
return;
|
||
}
|
||
|
||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||
if (!path) {
|
||
if (loadingNodesRef.current.has(tableLoadKey)) {
|
||
await waitForSidebarLoadKey(tableLoadKey);
|
||
} else {
|
||
await loadTables(dbNode);
|
||
}
|
||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||
}
|
||
|
||
if (!path) {
|
||
message.warning(`${objectLabel}未在左侧树中找到:${request.tableName},请刷新数据库节点后重试`);
|
||
return;
|
||
}
|
||
|
||
const targetKey = path[path.length - 1];
|
||
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
|
||
setSearchValue('');
|
||
mergeExpandedTreeKeys(path.slice(0, -1));
|
||
setSelectedKeys([targetKey]);
|
||
selectedNodesRef.current = targetNode ? [targetNode] : [];
|
||
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
|
||
scrollSidebarTreeToKey(targetKey);
|
||
};
|
||
|
||
const handleLocateActiveTabInSidebar = () => {
|
||
if (!activeTabLocateRequest) {
|
||
message.warning('当前标签页没有可定位的表上下文');
|
||
return;
|
||
}
|
||
void locateObjectInSidebar(activeTabLocateRequest);
|
||
};
|
||
|
||
useEffect(() => {
|
||
locateObjectInSidebarRef.current = locateObjectInSidebar;
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handleLocateSidebarObject = (event: Event) => {
|
||
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
|
||
};
|
||
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||
return () => {
|
||
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||
};
|
||
}, []);
|
||
|
||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||
if (type === 'tag') return;
|
||
if (hasSidebarLazyChildren(children)) return;
|
||
|
||
if (type === 'connection') {
|
||
await loadDatabases({ key, dataRef });
|
||
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
|
||
await loadJVMResources({ key, dataRef });
|
||
} else if (type === 'database') {
|
||
await loadTables({ key, dataRef });
|
||
} else if (type === 'table') {
|
||
// Expand table to show object categories
|
||
const conn = dataRef;
|
||
|
||
const folders: TreeNode[] = [
|
||
{
|
||
title: '列',
|
||
key: `${key}-columns`,
|
||
icon: <UnorderedListOutlined />,
|
||
type: 'folder-columns',
|
||
isLeaf: true,
|
||
dataRef: conn
|
||
},
|
||
{
|
||
title: '索引',
|
||
key: `${key}-indexes`,
|
||
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
|
||
type: 'folder-indexes',
|
||
isLeaf: true,
|
||
dataRef: conn
|
||
},
|
||
{
|
||
title: '外键',
|
||
key: `${key}-fks`,
|
||
icon: <LinkOutlined />,
|
||
type: 'folder-fks',
|
||
isLeaf: true,
|
||
dataRef: conn
|
||
},
|
||
{
|
||
title: '触发器',
|
||
key: `${key}-triggers`,
|
||
icon: <ThunderboltOutlined />,
|
||
type: 'folder-triggers',
|
||
isLeaf: true,
|
||
dataRef: conn
|
||
}
|
||
];
|
||
|
||
replaceTreeNodeChildren(key, folders);
|
||
}
|
||
};
|
||
|
||
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
|
||
const { tableName, dbName, id } = node.dataRef;
|
||
addTab({
|
||
id: `design-${id}-${dbName}-${tableName}`,
|
||
title: `${readOnly ? '表结构' : '设计表'} (${tableName})`,
|
||
type: 'design',
|
||
connectionId: id,
|
||
dbName: dbName,
|
||
tableName: tableName,
|
||
initialTab: initialTab,
|
||
readOnly: readOnly
|
||
});
|
||
};
|
||
|
||
const openNewTableDesign = (node: any) => {
|
||
const { dbName, id } = node.dataRef;
|
||
addTab({
|
||
id: `new-table-${id}-${dbName}-${Date.now()}`,
|
||
title: `新建表 - ${dbName}`,
|
||
type: 'design',
|
||
connectionId: id,
|
||
dbName: dbName,
|
||
tableName: '', // Empty tableName signals creation mode
|
||
initialTab: 'columns',
|
||
readOnly: false
|
||
});
|
||
};
|
||
|
||
const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2';
|
||
|
||
const onSelect = (keys: React.Key[], info: any) => {
|
||
setSelectedKeys(keys);
|
||
selectedNodesRef.current = info.selectedNodes || [];
|
||
|
||
if (keys.length === 0) {
|
||
if (shouldClearSidebarActiveContextOnEmptySelect(isV2Ui)) {
|
||
setActiveContext(null);
|
||
}
|
||
return;
|
||
}
|
||
if (!info.selected) return;
|
||
|
||
const { type, dataRef, key, title } = info.node;
|
||
const nodeConnectionId = resolveSidebarNodeConnectionId(info.node, connectionIds);
|
||
|
||
// Update active context
|
||
if (type === 'connection') {
|
||
setActiveContext({ connectionId: key, dbName: '' });
|
||
} else if (type === 'database') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
|
||
} else if (type === 'table') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
|
||
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' });
|
||
} else if (type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'routine') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
|
||
} else if (type === 'saved-query') {
|
||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||
} else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') {
|
||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||
} else if (type === 'redis-db') {
|
||
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||
}
|
||
|
||
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
|
||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
|
||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
|
||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
|
||
else if (type === 'object-group' && dataRef?.groupKey === 'tables') {
|
||
// 单击延迟打开表概览,双击时会取消此定时器
|
||
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
||
const { id, dbName: gDbName, schemaName } = dataRef;
|
||
clickTimerRef.current = setTimeout(() => {
|
||
clickTimerRef.current = null;
|
||
addTab({
|
||
id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`,
|
||
title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`,
|
||
type: 'table-overview' as any,
|
||
connectionId: id,
|
||
dbName: gDbName,
|
||
schemaName,
|
||
} as any);
|
||
}, 250);
|
||
}
|
||
};
|
||
|
||
const onExpand = (newExpandedKeys: React.Key[], info?: any) => {
|
||
setExpandedKeys(newExpandedKeys);
|
||
setAutoExpandParent(false);
|
||
if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node)) {
|
||
void onLoadData(info.node);
|
||
}
|
||
};
|
||
|
||
const onDoubleClick = (e: any, node: any) => {
|
||
// 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠
|
||
if (clickTimerRef.current) {
|
||
clearTimeout(clickTimerRef.current);
|
||
clickTimerRef.current = null;
|
||
}
|
||
const { type, dataRef, key: nodeKey } = node;
|
||
const nodeConnectionId = resolveSidebarNodeConnectionId(node, connectionIds);
|
||
if (type === 'connection') {
|
||
setSelectedKeys([nodeKey]);
|
||
selectedNodesRef.current = [node];
|
||
setActiveContext({ connectionId: nodeKey, dbName: '' });
|
||
} else if (type === 'database') {
|
||
setSelectedKeys([nodeKey]);
|
||
selectedNodesRef.current = [node];
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
|
||
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' });
|
||
} else if (type === 'table' || type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'routine') {
|
||
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
|
||
} else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||
|
||
if (node.type === 'table') {
|
||
const { tableName, dbName, id } = node.dataRef;
|
||
// 记录表访问
|
||
recordTableAccess(id, dbName, tableName);
|
||
addTab({
|
||
id: node.key,
|
||
title: tableName,
|
||
type: 'table',
|
||
connectionId: id,
|
||
dbName,
|
||
tableName,
|
||
});
|
||
return;
|
||
} else if (node.type === 'view' || node.type === 'materialized-view') {
|
||
const { viewName, dbName, id } = node.dataRef;
|
||
addTab({
|
||
id: node.key,
|
||
title: viewName,
|
||
type: 'table',
|
||
connectionId: id,
|
||
dbName,
|
||
tableName: viewName,
|
||
});
|
||
return;
|
||
} else if (node.type === 'saved-query') {
|
||
const q = node.dataRef;
|
||
addTab({
|
||
id: q.id,
|
||
title: q.name,
|
||
type: 'query',
|
||
connectionId: q.connectionId,
|
||
dbName: q.dbName,
|
||
query: q.sql,
|
||
savedQueryId: q.id,
|
||
});
|
||
return;
|
||
} else if (node.type === 'external-sql-file') {
|
||
void openExternalSQLFile(node);
|
||
return;
|
||
} else if (node.type === 'redis-db') {
|
||
const { id, redisDB } = node.dataRef;
|
||
addTab({
|
||
id: `redis-keys-${id}-db${redisDB}`,
|
||
title: `db${redisDB}`,
|
||
type: 'redis-keys',
|
||
connectionId: id,
|
||
redisDB: redisDB
|
||
});
|
||
return;
|
||
} else if (node.type === 'db-trigger') {
|
||
const { triggerName, dbName, id } = node.dataRef;
|
||
addTab({
|
||
id: `trigger-${node.key}`,
|
||
title: `触发器: ${triggerName}`,
|
||
type: 'trigger',
|
||
connectionId: id,
|
||
dbName,
|
||
triggerName
|
||
});
|
||
return;
|
||
} else if (node.type === 'routine') {
|
||
const { routineName, routineType, dbName, id } = node.dataRef;
|
||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||
addTab({
|
||
id: `routine-def-${node.key}`,
|
||
title: `${typeLabel}: ${routineName}`,
|
||
type: 'routine-def',
|
||
connectionId: id,
|
||
dbName,
|
||
routineName,
|
||
routineType
|
||
});
|
||
return;
|
||
} else if (node.type === 'jvm-mode') {
|
||
const { providerMode, id } = node.dataRef;
|
||
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
|
||
openJVMOverviewTab(conn, providerMode);
|
||
return;
|
||
} else if (node.type === 'jvm-resource') {
|
||
const { providerMode, resourcePath, resourceKind, id } = node.dataRef;
|
||
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
|
||
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
|
||
return;
|
||
} else if (node.type === 'jvm-monitoring') {
|
||
const { providerMode, id } = node.dataRef;
|
||
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
|
||
openJVMMonitoringTab(conn, providerMode);
|
||
return;
|
||
} else if (node.type === 'jvm-diagnostic') {
|
||
const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection;
|
||
openJVMDiagnosticTab(conn);
|
||
return;
|
||
}
|
||
|
||
const key = node.key;
|
||
const isExpanded = expandedKeys.includes(key);
|
||
const newExpandedKeys = isExpanded
|
||
? expandedKeys.filter(k => k !== key)
|
||
: [...expandedKeys, key];
|
||
|
||
setExpandedKeys(newExpandedKeys);
|
||
if (!isExpanded) {
|
||
setAutoExpandParent(false);
|
||
if (shouldLoadSidebarNodeOnExpand(node)) {
|
||
void onLoadData(node);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCopyStructure = async (node: any) => {
|
||
const { config, dbName, tableName } = node.dataRef;
|
||
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||
if (res.success) {
|
||
navigator.clipboard.writeText(res.data as string);
|
||
message.success('表结构已复制到剪贴板');
|
||
} else {
|
||
message.error(res.message);
|
||
}
|
||
};
|
||
|
||
const handleCopyTableName = async (node: any) => {
|
||
const tableName = resolveSidebarTableNameForCopy(node);
|
||
if (!tableName) {
|
||
message.warning('表名为空,无法复制');
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(tableName);
|
||
message.success('表名已复制到剪贴板');
|
||
} catch (e: any) {
|
||
message.error('复制表名失败: ' + (e?.message || String(e)));
|
||
}
|
||
};
|
||
|
||
const handleExport = async (node: any, format: string) => {
|
||
const { config, dbName, tableName } = node.dataRef;
|
||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||
const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName, tableName, format);
|
||
hide();
|
||
if (res.success) {
|
||
message.success('导出成功');
|
||
} else if (res.message !== '已取消') {
|
||
message.error('导出失败: ' + res.message);
|
||
}
|
||
};
|
||
|
||
const handleCopyTableAsInsert = async (node: any) => {
|
||
await handleExport(node, 'sql');
|
||
};
|
||
|
||
const openTableDdlInDesigner = (node: any) => {
|
||
openDesign(node, 'ddl', true);
|
||
};
|
||
|
||
const openTableInERView = (node: any) => {
|
||
onDoubleClick(null, node);
|
||
setTimeout(() => {
|
||
window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', {
|
||
detail: {
|
||
connectionId: node.dataRef?.id,
|
||
dbName: node.dataRef?.dbName,
|
||
tableName: node.dataRef?.tableName,
|
||
viewMode: 'er',
|
||
},
|
||
}));
|
||
}, 0);
|
||
};
|
||
|
||
const injectTablePromptToAI = async (node: any, promptKind: 'explain' | 'query') => {
|
||
const conn = node.dataRef;
|
||
const tableName = String(conn?.tableName || node?.title || '').trim();
|
||
if (!conn?.id || !conn?.dbName || !tableName) {
|
||
message.warning('当前表缺少连接上下文,无法发送给 AI');
|
||
return;
|
||
}
|
||
|
||
let ddl = '';
|
||
try {
|
||
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, conn.dbName, tableName);
|
||
if (res.success) {
|
||
ddl = String(res.data || '').trim();
|
||
addAIContext(conn.id, { dbName: conn.dbName, tableName, ddl });
|
||
}
|
||
} catch {
|
||
// AI 入口仍可基于表名工作,DDL 获取失败不阻断打开面板。
|
||
}
|
||
|
||
const prompt = promptKind === 'explain'
|
||
? [
|
||
`请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。`,
|
||
'重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。',
|
||
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
|
||
].filter(Boolean).join('\n')
|
||
: [
|
||
`请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。`,
|
||
'要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。',
|
||
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
|
||
].filter(Boolean).join('\n');
|
||
|
||
const wasClosed = !useStore.getState().aiPanelVisible;
|
||
if (wasClosed) setAIPanelVisible(true);
|
||
setTimeout(() => {
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||
}, wasClosed ? 350 : 0);
|
||
};
|
||
|
||
const normalizeConnConfig = (raw: any) => (
|
||
buildRpcConnectionConfig(raw)
|
||
);
|
||
|
||
const handleExportDatabaseSQL = async (node: any, includeData: boolean) => {
|
||
const conn = node.dataRef;
|
||
const dbName = conn.dbName || node.title;
|
||
const hide = message.loading(includeData ? `正在备份数据库 ${dbName} (结构+数据)...` : `正在导出数据库 ${dbName} 表结构...`, 0);
|
||
try {
|
||
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData);
|
||
hide();
|
||
if (res.success) {
|
||
message.success('导出成功');
|
||
} else if (res.message !== '已取消') {
|
||
message.error('导出失败: ' + res.message);
|
||
}
|
||
} catch (e: any) {
|
||
hide();
|
||
message.error('导出失败: ' + (e?.message || String(e)));
|
||
}
|
||
};
|
||
|
||
const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => {
|
||
if (!nodes || nodes.length === 0) return;
|
||
const first = nodes[0].dataRef;
|
||
const dbName = first.dbName;
|
||
const connId = first.id;
|
||
const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName);
|
||
if (!allSame) {
|
||
message.error('请在同一连接、同一数据库下选择多张表进行导出');
|
||
return;
|
||
}
|
||
|
||
const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean);
|
||
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
|
||
try {
|
||
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData);
|
||
hide();
|
||
if (res.success) {
|
||
message.success('导出成功');
|
||
} else if (res.message !== '已取消') {
|
||
message.error('导出失败: ' + res.message);
|
||
}
|
||
} catch (e: any) {
|
||
hide();
|
||
message.error('导出失败: ' + (e?.message || String(e)));
|
||
}
|
||
};
|
||
|
||
const openBatchOperationModal = async () => {
|
||
// Check if current selected node is database or table
|
||
let connId = '';
|
||
let dbName = '';
|
||
|
||
if (selectedNodesRef.current.length > 0) {
|
||
const node = selectedNodesRef.current[0];
|
||
if (node.type === 'database') {
|
||
connId = node.dataRef.id;
|
||
dbName = node.title;
|
||
} else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
|
||
connId = node.dataRef.id;
|
||
dbName = node.dataRef.dbName;
|
||
}
|
||
}
|
||
|
||
setSelectedConnection(connId);
|
||
setSelectedDatabase(dbName);
|
||
setBatchTables([]);
|
||
setCheckedTableKeys([]);
|
||
setAvailableDatabases([]);
|
||
setBatchFilterKeyword('');
|
||
setBatchFilterType('all');
|
||
setBatchSelectionScope('filtered');
|
||
|
||
if (connId) {
|
||
const conn = connections.find(c => c.id === connId);
|
||
if (conn) {
|
||
await loadDatabasesForBatch(conn);
|
||
if (dbName) {
|
||
await loadTablesForBatch(conn, dbName);
|
||
}
|
||
}
|
||
}
|
||
|
||
setIsBatchModalOpen(true);
|
||
};
|
||
|
||
const loadDatabasesForBatch = async (conn: SavedConnection) => {
|
||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: conn.config.database || "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
|
||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||
if (res.success) {
|
||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
let dbs = dbRows.map((row: any) => {
|
||
const dbName = row.Database || row.database;
|
||
return {
|
||
title: dbName,
|
||
key: `${conn.id}-${dbName}`,
|
||
dbName: dbName
|
||
};
|
||
});
|
||
|
||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
|
||
}
|
||
|
||
setAvailableDatabases(dbs);
|
||
} else {
|
||
message.error('获取数据库列表失败: ' + res.message);
|
||
}
|
||
};
|
||
|
||
const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => {
|
||
setBatchDbContext({ conn, dbName });
|
||
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: conn.config.database || "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
|
||
const [res, viewResult] = await Promise.all([
|
||
DBGetTables(buildRpcConnectionConfig(config) as any, dbName),
|
||
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
|
||
]);
|
||
|
||
if (!res.success) {
|
||
message.error('获取表列表失败: ' + res.message);
|
||
return;
|
||
}
|
||
|
||
const tableRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
const viewRows: string[] = Array.isArray(viewResult.views) ? viewResult.views : [];
|
||
const viewSet = new Set(viewRows.map((view: string) => view.toLowerCase()));
|
||
|
||
const tableObjects: BatchObjectItem[] = tableRows
|
||
.map((row: any) => Object.values(row)[0] as string)
|
||
.filter((tableName: string) => !viewSet.has(tableName.toLowerCase()))
|
||
.map((tableName: string) => ({
|
||
title: getSidebarTableDisplayName(conn, tableName),
|
||
key: `${conn.id}-${dbName}-table-${tableName}`,
|
||
objectName: tableName,
|
||
objectType: 'table' as const,
|
||
dataRef: { ...conn, tableName, dbName, objectType: 'table' },
|
||
}));
|
||
|
||
const viewObjects: BatchObjectItem[] = viewRows.map((viewName: string) => ({
|
||
title: getSidebarTableDisplayName(conn, viewName),
|
||
key: `${conn.id}-${dbName}-view-${viewName}`,
|
||
objectName: viewName,
|
||
objectType: 'view' as const,
|
||
dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' },
|
||
}));
|
||
|
||
tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||
viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||
|
||
setBatchTables([...tableObjects, ...viewObjects]);
|
||
setCheckedTableKeys([]);
|
||
};
|
||
|
||
const handleConnectionChange = async (connId: string) => {
|
||
setSelectedConnection(connId);
|
||
setSelectedDatabase('');
|
||
setBatchTables([]);
|
||
setCheckedTableKeys([]);
|
||
setBatchFilterKeyword('');
|
||
setBatchFilterType('all');
|
||
setBatchSelectionScope('filtered');
|
||
|
||
const conn = connections.find(c => c.id === connId);
|
||
if (conn) {
|
||
await loadDatabasesForBatch(conn);
|
||
}
|
||
};
|
||
|
||
const handleDatabaseChange = async (dbName: string) => {
|
||
setSelectedDatabase(dbName);
|
||
setBatchFilterKeyword('');
|
||
setBatchFilterType('all');
|
||
setBatchSelectionScope('filtered');
|
||
|
||
const conn = connections.find(c => c.id === selectedConnection);
|
||
if (conn && dbName) {
|
||
await loadTablesForBatch(conn, dbName);
|
||
}
|
||
};
|
||
|
||
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||
if (selectedObjects.length === 0) {
|
||
message.warning('请至少选择一个对象');
|
||
return;
|
||
}
|
||
|
||
setIsBatchModalOpen(false);
|
||
|
||
const { conn, dbName } = batchDbContext;
|
||
const objectNames = selectedObjects.map(t => t.objectName);
|
||
const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length;
|
||
|
||
const loadingText = mode === 'backup'
|
||
? `正在备份选中对象 (${objectNames.length})...`
|
||
: mode === 'dataOnly'
|
||
? `正在导出选中对象数据 (INSERT) (${objectNames.length})...`
|
||
: `正在导出选中对象结构 (${objectNames.length})...`;
|
||
const hide = message.loading(loadingText, 0);
|
||
try {
|
||
const app = (window as any).go.app.App;
|
||
const res = mode === 'dataOnly'
|
||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames)
|
||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup');
|
||
hide();
|
||
if (res.success) {
|
||
if (mode !== 'schema' && selectedViewCount > 0) {
|
||
message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`);
|
||
} else {
|
||
message.success('导出成功');
|
||
}
|
||
} else if (res.message !== '已取消') {
|
||
message.error('导出失败: ' + res.message);
|
||
}
|
||
} catch (e: any) {
|
||
hide();
|
||
message.error('导出失败: ' + (e?.message || String(e)));
|
||
}
|
||
};
|
||
|
||
const handleBatchClear = async () => {
|
||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||
if (selectedObjects.length === 0) {
|
||
message.warning('请至少选择一个对象');
|
||
return;
|
||
}
|
||
|
||
const { conn, dbName } = batchDbContext;
|
||
const objectNames = selectedObjects.map(t => t.objectName);
|
||
|
||
const ok = await new Promise<boolean>((resolve) => {
|
||
Modal.confirm({
|
||
title: '确认清空选中表',
|
||
content: `清空选中表会永久删除表中所有数据,操作不可逆,是否继续?\r\n\r\n连接: ${conn.name}\n数据库: ${dbName}`,
|
||
okText: '继续',
|
||
cancelText: '取消',
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
});
|
||
});
|
||
if (!ok) return;
|
||
|
||
setIsBatchModalOpen(false);
|
||
const hide = message.loading(`正在清空选中表 (${objectNames.length})...`, 0);
|
||
const startTime = Date.now();
|
||
try {
|
||
const app = (window as any).go.app.App;
|
||
const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||
hide();
|
||
const duration = Date.now() - startTime;
|
||
if (res.success) {
|
||
message.success('清空成功');
|
||
// 构造 SQL 日志
|
||
let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`;
|
||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||
} else {
|
||
logSql += objectNames.map(name => name).join('; ');
|
||
}
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: logSql,
|
||
status: 'success',
|
||
duration,
|
||
message: res.message,
|
||
dbName,
|
||
affectedRows: res.data?.count || 0
|
||
});
|
||
} else if (res.message !== '已取消') {
|
||
message.error('清空失败: ' + res.message);
|
||
// 记录失败的日志
|
||
let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`;
|
||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||
} else {
|
||
logSql += objectNames.map(name => name).join('; ');
|
||
}
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: logSql,
|
||
status: 'error',
|
||
duration,
|
||
message: res.message,
|
||
dbName
|
||
});
|
||
}
|
||
} catch (e: any) {
|
||
const duration = Date.now() - startTime;
|
||
hide();
|
||
const errMsg = e?.message || String(e);
|
||
message.error('清空失败: ' + errMsg);
|
||
// 记录异常的日志
|
||
let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`;
|
||
logSql += objectNames.map(name => name).join('; ');
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: logSql,
|
||
status: 'error',
|
||
duration,
|
||
message: errMsg,
|
||
dbName
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleCheckAll = (checked: boolean) => {
|
||
if (batchSelectionScope === 'all') {
|
||
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
|
||
return;
|
||
}
|
||
if (filteredBatchObjectKeys.length === 0) {
|
||
return;
|
||
}
|
||
if (checked) {
|
||
setCheckedTableKeys(prev => {
|
||
const nextSet = new Set(prev);
|
||
filteredBatchObjectKeys.forEach((key) => nextSet.add(key));
|
||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||
});
|
||
return;
|
||
}
|
||
const filteredKeySet = new Set(filteredBatchObjectKeys);
|
||
setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key)));
|
||
};
|
||
|
||
const handleInvertSelection = () => {
|
||
if (batchSelectionScope === 'all') {
|
||
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
|
||
return;
|
||
}
|
||
if (filteredBatchObjectKeys.length === 0) {
|
||
return;
|
||
}
|
||
setCheckedTableKeys(prev => {
|
||
const nextSet = new Set(prev);
|
||
filteredBatchObjectKeys.forEach((key) => {
|
||
if (nextSet.has(key)) {
|
||
nextSet.delete(key);
|
||
} else {
|
||
nextSet.add(key);
|
||
}
|
||
});
|
||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||
});
|
||
};
|
||
|
||
const openBatchDatabaseModal = async () => {
|
||
// Check if current selected node is connection or database
|
||
let connId = '';
|
||
|
||
if (selectedNodesRef.current.length > 0) {
|
||
const node = selectedNodesRef.current[0];
|
||
if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') {
|
||
connId = node.key as string;
|
||
} else if (node.type === 'database') {
|
||
connId = node.dataRef.id;
|
||
} else if (node.type === 'table') {
|
||
connId = node.dataRef.id;
|
||
}
|
||
}
|
||
|
||
setSelectedDbConnection(connId);
|
||
setBatchDatabases([]);
|
||
setCheckedDbKeys([]);
|
||
|
||
if (connId) {
|
||
const conn = connections.find(c => c.id === connId);
|
||
if (conn) {
|
||
await loadDatabasesForDbBatch(conn);
|
||
}
|
||
}
|
||
|
||
setIsBatchDbModalOpen(true);
|
||
};
|
||
|
||
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
|
||
setBatchConnContext(conn);
|
||
void warnIfConnectionDriverAgentNeedsUpdate(conn);
|
||
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: conn.config.database || "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
|
||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||
if (res.success) {
|
||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||
let dbs = dbRows.map((row: any) => {
|
||
const dbName = row.Database || row.database;
|
||
return {
|
||
title: dbName,
|
||
key: `${conn.id}-${dbName}`,
|
||
dbName: dbName,
|
||
dataRef: { ...conn, dbName }
|
||
};
|
||
});
|
||
|
||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
|
||
}
|
||
|
||
setBatchDatabases(dbs);
|
||
setCheckedDbKeys([]);
|
||
} else {
|
||
message.error('获取数据库列表失败: ' + res.message);
|
||
}
|
||
};
|
||
|
||
const handleDbConnectionChange = async (connId: string) => {
|
||
setSelectedDbConnection(connId);
|
||
|
||
const conn = connections.find(c => c.id === connId);
|
||
if (conn) {
|
||
await loadDatabasesForDbBatch(conn);
|
||
}
|
||
};
|
||
|
||
const handleBatchDbExport = async (includeData: boolean) => {
|
||
const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key));
|
||
if (selectedDbs.length === 0) {
|
||
message.warning('请至少选择一个数据库');
|
||
return;
|
||
}
|
||
|
||
setIsBatchDbModalOpen(false);
|
||
|
||
for (const db of selectedDbs) {
|
||
const hide = message.loading(includeData ? `正在备份数据库 ${db.dbName} (结构+数据)...` : `正在导出数据库 ${db.dbName} 表结构...`, 0);
|
||
try {
|
||
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData);
|
||
hide();
|
||
if (res.success) {
|
||
message.success(`${db.dbName} 导出成功`);
|
||
} else if (res.message !== '已取消') {
|
||
message.error(`${db.dbName} 导出失败: ` + res.message);
|
||
break;
|
||
} else {
|
||
break; // User cancelled
|
||
}
|
||
} catch (e: any) {
|
||
hide();
|
||
message.error(`${db.dbName} 导出失败: ` + (e?.message || String(e)));
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCheckAllDb = (checked: boolean) => {
|
||
if (checked) {
|
||
setCheckedDbKeys(batchDatabases.map(db => db.key));
|
||
} else {
|
||
setCheckedDbKeys([]);
|
||
}
|
||
};
|
||
|
||
const handleInvertSelectionDb = () => {
|
||
const allKeys = batchDatabases.map(db => db.key);
|
||
const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k));
|
||
setCheckedDbKeys(newChecked);
|
||
};
|
||
|
||
const handleRunSQLFile = async (node: any) => {
|
||
const res = await OpenSQLFile();
|
||
if (res.success) {
|
||
const data = res.data;
|
||
// 大文件:后端返回文件路径,走流式执行
|
||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||
const connId = node.type === 'connection' ? node.key : node.dataRef?.id;
|
||
const dbName = node.dataRef?.dbName || '';
|
||
const conn = connections.find(c => c.id === connId);
|
||
if (!conn) {
|
||
message.error('未找到对应的连接配置');
|
||
return;
|
||
}
|
||
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
|
||
return;
|
||
}
|
||
// 小文件:加载到编辑器
|
||
const sqlContent = data;
|
||
const { dbName, id } = node.dataRef;
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `运行外部SQL文件`,
|
||
type: 'query',
|
||
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
|
||
dbName: dbName,
|
||
query: sqlContent
|
||
});
|
||
} else if (res.message !== '已取消') {
|
||
message.error('读取文件失败: ' + res.message);
|
||
}
|
||
};
|
||
|
||
const handleOpenSQLFileFromToolbar = async () => {
|
||
const ctx = useStore.getState().activeContext;
|
||
if (!ctx?.connectionId) {
|
||
message.warning('请先选择一个连接或数据库');
|
||
return;
|
||
}
|
||
const res = await OpenSQLFile();
|
||
if (res.success) {
|
||
const data = res.data;
|
||
// 大文件:后端流式执行
|
||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||
const conn = connections.find(c => c.id === ctx.connectionId);
|
||
if (!conn) {
|
||
message.error('未找到对应的连接配置');
|
||
return;
|
||
}
|
||
startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB);
|
||
return;
|
||
}
|
||
// 小文件
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `运行外部SQL文件`,
|
||
type: 'query',
|
||
connectionId: ctx.connectionId,
|
||
dbName: ctx.dbName || undefined,
|
||
query: data
|
||
});
|
||
} else if (res.message !== '已取消') {
|
||
message.error('读取文件失败: ' + res.message);
|
||
}
|
||
};
|
||
|
||
// SQL 文件流式执行状态
|
||
const [sqlFileExecState, setSqlFileExecState] = useState<{
|
||
open: boolean;
|
||
jobId: string;
|
||
fileSizeMB: string;
|
||
status: 'running' | 'done' | 'cancelled' | 'error';
|
||
executed: number;
|
||
failed: number;
|
||
total: number;
|
||
percent: number;
|
||
currentSQL: string;
|
||
resultMessage: string;
|
||
}>({
|
||
open: false, jobId: '', fileSizeMB: '', status: 'running',
|
||
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
|
||
});
|
||
|
||
const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => {
|
||
const jobId = `sqlfile-${Date.now()}`;
|
||
setSqlFileExecState({
|
||
open: true, jobId, fileSizeMB, status: 'running',
|
||
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
|
||
});
|
||
|
||
// 监听进度事件
|
||
const offProgress = EventsOn('sqlfile:progress', (event: any) => {
|
||
if (!event || event.jobId !== jobId) return;
|
||
setSqlFileExecState(prev => ({
|
||
...prev,
|
||
status: event.status || prev.status,
|
||
executed: typeof event.executed === 'number' ? event.executed : prev.executed,
|
||
failed: typeof event.failed === 'number' ? event.failed : prev.failed,
|
||
total: typeof event.total === 'number' ? event.total : prev.total,
|
||
percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent,
|
||
currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL,
|
||
}));
|
||
});
|
||
|
||
// 异步执行
|
||
ExecuteSQLFile(config, dbName, filePath, jobId).then(res => {
|
||
offProgress();
|
||
setSqlFileExecState(prev => ({
|
||
...prev,
|
||
status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'),
|
||
percent: 100,
|
||
resultMessage: res.message || '',
|
||
}));
|
||
}).catch(err => {
|
||
offProgress();
|
||
setSqlFileExecState(prev => ({
|
||
...prev,
|
||
status: 'error',
|
||
resultMessage: String(err?.message || err),
|
||
}));
|
||
});
|
||
};
|
||
|
||
const refreshDatabaseNode = async (dbNodeKey: string) => {
|
||
if (!dbNodeKey) {
|
||
return;
|
||
}
|
||
const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
|
||
if (dbNode && dbNode.type === 'database') {
|
||
await loadTables(dbNode);
|
||
}
|
||
};
|
||
|
||
const openExternalSQLFile = async (fileNode: any) => {
|
||
const connectionId = String(fileNode?.dataRef?.connectionId || '').trim();
|
||
const dbName = String(fileNode?.dataRef?.dbName || '').trim();
|
||
const filePath = String(fileNode?.dataRef?.path || '').trim();
|
||
const fileName = String(fileNode?.dataRef?.name || fileNode?.title || 'SQL文件').trim() || 'SQL文件';
|
||
if (!connectionId || !dbName || !filePath) {
|
||
message.error('SQL 文件上下文不完整,无法打开');
|
||
return;
|
||
}
|
||
|
||
const res = await ReadSQLFile(filePath);
|
||
if (!res.success) {
|
||
if (res.message !== '已取消') {
|
||
message.error('读取 SQL 文件失败: ' + res.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const data = res.data;
|
||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||
const conn = connections.find((item) => item.id === connectionId);
|
||
if (!conn) {
|
||
message.error('未找到对应的连接配置');
|
||
return;
|
||
}
|
||
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
|
||
return;
|
||
}
|
||
|
||
addTab({
|
||
id: buildExternalSQLTabId(connectionId, dbName, filePath),
|
||
title: fileName,
|
||
type: 'query',
|
||
connectionId,
|
||
dbName,
|
||
query: String(data || ''),
|
||
filePath,
|
||
});
|
||
};
|
||
|
||
const handleAddExternalSQLDirectory = async (node: any) => {
|
||
const context = getNodeDatabaseContext(node);
|
||
if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) {
|
||
message.warning('请在具体数据库下添加外部 SQL 目录');
|
||
return;
|
||
}
|
||
|
||
const currentDirectory = externalSQLDirectories.find((item) =>
|
||
item.connectionId === context.connectionId && item.dbName === context.dbName,
|
||
)?.path || '';
|
||
const selection = await SelectSQLDirectory(currentDirectory);
|
||
if (!selection.success) {
|
||
if (selection.message !== '已取消') {
|
||
message.error('选择 SQL 目录失败: ' + selection.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record<string, unknown> : {};
|
||
const path = String(payload.path || '').trim();
|
||
const name = String(payload.name || '').trim();
|
||
if (!path) {
|
||
message.error('未获取到有效的 SQL 目录路径');
|
||
return;
|
||
}
|
||
|
||
const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path);
|
||
saveExternalSQLDirectory({
|
||
id: directoryId,
|
||
name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录',
|
||
path,
|
||
connectionId: context.connectionId,
|
||
dbName: context.dbName,
|
||
createdAt: Date.now(),
|
||
});
|
||
|
||
setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`])));
|
||
setAutoExpandParent(false);
|
||
await refreshDatabaseNode(context.dbNodeKey);
|
||
message.success('外部 SQL 目录已添加');
|
||
};
|
||
|
||
const handleRemoveExternalSQLDirectory = async (node: any) => {
|
||
const directoryId = String(node?.dataRef?.id || '').trim();
|
||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||
if (!directoryId) {
|
||
message.error('未找到可移除的 SQL 目录');
|
||
return;
|
||
}
|
||
deleteExternalSQLDirectory(directoryId);
|
||
await refreshDatabaseNode(dbNodeKey);
|
||
message.success('外部 SQL 目录已移除');
|
||
};
|
||
|
||
const handleRefreshExternalSQLDirectory = async (node: any) => {
|
||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||
if (!dbNodeKey) {
|
||
message.warning('当前目录缺少数据库上下文,无法刷新');
|
||
return;
|
||
}
|
||
await refreshDatabaseNode(dbNodeKey);
|
||
message.success('外部 SQL 目录已刷新');
|
||
};
|
||
|
||
const handleCreateDatabase = async () => {
|
||
try {
|
||
const values = await createDbForm.validateFields();
|
||
const conn = targetConnection.dataRef;
|
||
const config = {
|
||
...conn.config,
|
||
port: Number(conn.config.port),
|
||
password: conn.config.password || "",
|
||
database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "",
|
||
useSSH: conn.config.useSSH || false,
|
||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||
};
|
||
|
||
const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name);
|
||
if (res.success) {
|
||
message.success("数据库创建成功");
|
||
setIsCreateDbModalOpen(false);
|
||
createDbForm.resetFields();
|
||
// Refresh node
|
||
loadDatabases(targetConnection);
|
||
} else {
|
||
message.error("创建失败: " + res.message);
|
||
}
|
||
} catch (e) {
|
||
// Validate failed
|
||
}
|
||
};
|
||
|
||
const openCreateSchemaModal = (node: any) => {
|
||
const dialect = getMetadataDialect(node?.dataRef as SavedConnection);
|
||
if (!isPostgresSchemaDialect(dialect)) {
|
||
message.warning('当前数据源暂不支持通过此入口新建模式');
|
||
return;
|
||
}
|
||
setCreateSchemaTarget(node);
|
||
createSchemaForm.resetFields();
|
||
setIsCreateSchemaModalOpen(true);
|
||
};
|
||
|
||
const handleCreateSchema = async () => {
|
||
try {
|
||
const values = await createSchemaForm.validateFields();
|
||
const node = createSchemaTarget;
|
||
const conn = node?.dataRef;
|
||
const dbName = String(conn?.dbName || node?.title || '').trim();
|
||
if (!conn || !dbName) {
|
||
message.error('未找到目标数据库,无法新建模式');
|
||
return;
|
||
}
|
||
|
||
const res = await CreateSchema(buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, values.name);
|
||
if (res.success) {
|
||
message.success('模式创建成功');
|
||
setIsCreateSchemaModalOpen(false);
|
||
setCreateSchemaTarget(null);
|
||
createSchemaForm.resetFields();
|
||
await loadTables(node);
|
||
} else {
|
||
message.error('创建失败: ' + res.message);
|
||
}
|
||
} catch (e) {
|
||
// Validate failed
|
||
}
|
||
};
|
||
|
||
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||
return buildRpcConnectionConfig(conn.config, {
|
||
database: resolveSidebarRuntimeDatabase(
|
||
conn?.config?.type,
|
||
conn?.config?.driver,
|
||
conn?.config?.database,
|
||
overrideDatabase,
|
||
clearDatabase,
|
||
conn?.config?.oceanBaseProtocol,
|
||
),
|
||
});
|
||
};
|
||
|
||
const buildJVMRuntimeConfig = (conn: SavedConnection & { dbName?: string }, providerMode: string) => {
|
||
const sourceJVM = conn.config.jvm || {};
|
||
return buildRpcConnectionConfig(conn.config, {
|
||
database: '',
|
||
jvm: {
|
||
...sourceJVM,
|
||
preferredMode: providerMode as 'jmx' | 'endpoint' | 'agent',
|
||
allowedModes: [providerMode as 'jmx' | 'endpoint' | 'agent'],
|
||
},
|
||
});
|
||
};
|
||
|
||
const openJVMOverviewTab = (conn: SavedConnection, providerMode: string) => {
|
||
addTab({
|
||
id: `jvm-overview-${conn.id}-${providerMode}`,
|
||
title: buildJVMTabTitle(conn.name, 'overview', providerMode),
|
||
type: 'jvm-overview',
|
||
connectionId: conn.id,
|
||
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
|
||
});
|
||
};
|
||
|
||
const openJVMMonitoringTab = (conn: SavedConnection, providerMode: string) => {
|
||
addTab({
|
||
id: `jvm-monitoring-${conn.id}-${providerMode}`,
|
||
title: buildJVMTabTitle(conn.name, 'monitoring', providerMode),
|
||
type: 'jvm-monitoring',
|
||
connectionId: conn.id,
|
||
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
|
||
});
|
||
};
|
||
|
||
const buildJVMDiagnosticTreeNodes = (conn: SavedConnection): TreeNode[] => {
|
||
const descriptor = buildJVMDiagnosticActionDescriptor(conn.id, conn.config.jvm?.diagnostic);
|
||
if (!descriptor) {
|
||
return [];
|
||
}
|
||
return [{
|
||
title: descriptor.title,
|
||
key: descriptor.key,
|
||
icon: <DashboardOutlined />,
|
||
type: 'jvm-diagnostic',
|
||
dataRef: {
|
||
...conn,
|
||
diagnosticTransport: descriptor.transport,
|
||
},
|
||
isLeaf: true,
|
||
}];
|
||
};
|
||
|
||
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
|
||
const trimmedResourcePath = String(resourcePath || '').trim();
|
||
addTab({
|
||
id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`,
|
||
title: trimmedResourcePath
|
||
? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}`
|
||
: buildJVMTabTitle(conn.name, 'resource', providerMode),
|
||
type: 'jvm-resource',
|
||
connectionId: conn.id,
|
||
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
|
||
resourcePath: trimmedResourcePath,
|
||
resourceKind,
|
||
});
|
||
};
|
||
|
||
const openJVMDiagnosticTab = (conn: SavedConnection) => {
|
||
const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge';
|
||
addTab({
|
||
id: `jvm-diagnostic-${conn.id}`,
|
||
title: buildJVMTabTitle(conn.name, 'diagnostic', transport),
|
||
type: 'jvm-diagnostic',
|
||
connectionId: conn.id,
|
||
});
|
||
};
|
||
|
||
const getConnectionNodeRef = (connRef: any) => {
|
||
const latestConn = connections.find(c => c.id === connRef.id);
|
||
return { key: connRef.id, dataRef: latestConn || connRef };
|
||
};
|
||
|
||
const getDatabaseNodeRef = (connRef: any, dbName: string) => {
|
||
const latestConn = connections.find(c => c.id === connRef.id);
|
||
return {
|
||
key: `${connRef.id}-${dbName}`,
|
||
dataRef: { ...(latestConn || connRef), dbName }
|
||
};
|
||
};
|
||
|
||
const extractObjectName = (fullName: string) => {
|
||
const raw = String(fullName || '').trim();
|
||
const idx = raw.lastIndexOf('.');
|
||
if (idx >= 0 && idx < raw.length - 1) {
|
||
return raw.substring(idx + 1);
|
||
}
|
||
return raw;
|
||
};
|
||
|
||
const handleRenameDatabase = async () => {
|
||
if (!renameDbTarget) return;
|
||
try {
|
||
const values = await renameDbForm.validateFields();
|
||
const conn = renameDbTarget.dataRef;
|
||
const oldDbName = String(conn.dbName || '').trim();
|
||
const newDbName = String(values.newName || '').trim();
|
||
if (!oldDbName || !newDbName) {
|
||
message.error("数据库名称不能为空");
|
||
return;
|
||
}
|
||
if (oldDbName === newDbName) {
|
||
message.warning("新旧数据库名称相同,无需修改");
|
||
return;
|
||
}
|
||
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName);
|
||
if (res.success) {
|
||
message.success("数据库重命名成功");
|
||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
|
||
await loadDatabases(getConnectionNodeRef(conn));
|
||
setIsRenameDbModalOpen(false);
|
||
setRenameDbTarget(null);
|
||
renameDbForm.resetFields();
|
||
} else {
|
||
message.error("重命名失败: " + res.message);
|
||
}
|
||
} catch (e) {
|
||
// Validate failed
|
||
}
|
||
};
|
||
|
||
const handleDeleteDatabase = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const dbName = String(conn.dbName || '').trim();
|
||
if (!dbName) return;
|
||
Modal.confirm({
|
||
title: '确认删除数据库',
|
||
content: `确定删除数据库 "${dbName}" 吗?该操作不可恢复。`,
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName);
|
||
if (res.success) {
|
||
message.success("数据库删除成功");
|
||
closeTabsByDatabase(conn.id, dbName);
|
||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
|
||
await loadDatabases(getConnectionNodeRef(conn));
|
||
} else {
|
||
message.error("删除失败: " + res.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleRenameTable = async () => {
|
||
if (!renameTableTarget) return;
|
||
try {
|
||
const values = await renameTableForm.validateFields();
|
||
const conn = renameTableTarget.dataRef;
|
||
const oldTableName = String(conn.tableName || '').trim();
|
||
const newTableName = String(values.newName || '').trim();
|
||
if (!oldTableName || !newTableName) {
|
||
message.error("表名不能为空");
|
||
return;
|
||
}
|
||
if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) {
|
||
message.warning("新旧表名相同,无需修改");
|
||
return;
|
||
}
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName);
|
||
if (res.success) {
|
||
message.success("表重命名成功");
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
setIsRenameTableModalOpen(false);
|
||
setRenameTableTarget(null);
|
||
renameTableForm.resetFields();
|
||
} else {
|
||
message.error("重命名失败: " + res.message);
|
||
}
|
||
} catch (e) {
|
||
// Validate failed
|
||
}
|
||
};
|
||
|
||
const handleDeleteTable = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const tableName = String(conn.tableName || '').trim();
|
||
if (!tableName) return;
|
||
Modal.confirm({
|
||
title: '确认删除表',
|
||
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
|
||
if (res.success) {
|
||
message.success("表删除成功");
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
} else {
|
||
message.error("删除失败: " + res.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
|
||
const conn = node.dataRef;
|
||
const tableName = String(conn.tableName || '').trim();
|
||
if (!tableName) return;
|
||
|
||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||
const confirmed = await new Promise<boolean>((resolve) => {
|
||
Modal.confirm({
|
||
title: `确认${label}`,
|
||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||
okText: '继续',
|
||
cancelText: '取消',
|
||
okButtonProps: { danger: true },
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
});
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const app = (window as any).go.app.App;
|
||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||
const startTime = Date.now();
|
||
try {
|
||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
|
||
hide();
|
||
const duration = Date.now() - startTime;
|
||
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
|
||
const logSql = executedSQLs.length > 0
|
||
? executedSQLs.join(';\n') + ';'
|
||
: `/* ${label} ${tableName} */`;
|
||
|
||
if (res.success) {
|
||
message.success(`${progressLabel}成功`);
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: logSql,
|
||
status: 'success',
|
||
duration,
|
||
message: res.message,
|
||
dbName: conn.dbName,
|
||
affectedRows: res.data?.count || 0,
|
||
});
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
return;
|
||
}
|
||
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: logSql,
|
||
status: 'error',
|
||
duration,
|
||
message: res.message,
|
||
dbName: conn.dbName,
|
||
});
|
||
if (res.message !== '已取消') {
|
||
message.error(`${progressLabel}失败: ${res.message}`);
|
||
}
|
||
} catch (e: any) {
|
||
const duration = Date.now() - startTime;
|
||
const errMsg = e?.message || String(e);
|
||
hide();
|
||
addSqlLog({
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
sql: `/* ${label} ${tableName} - ERROR */`,
|
||
status: 'error',
|
||
duration,
|
||
message: errMsg,
|
||
dbName: conn.dbName,
|
||
});
|
||
message.error(`${progressLabel}失败: ${errMsg}`);
|
||
}
|
||
};
|
||
|
||
// --- 视图操作 ---
|
||
const openViewDefinition = (node: any) => {
|
||
const { viewName, dbName, id } = node.dataRef;
|
||
const isMaterialized = node.type === 'materialized-view' || node.dataRef?.objectKind === 'materialized-view';
|
||
addTab({
|
||
id: `view-def-${id}-${dbName}-${viewName}`,
|
||
title: `${isMaterialized ? '物化视图' : '视图'}: ${viewName}`,
|
||
type: 'view-def',
|
||
connectionId: id,
|
||
dbName,
|
||
viewName,
|
||
viewKind: isMaterialized ? 'materialized' : 'view',
|
||
});
|
||
};
|
||
|
||
const openEditView = async (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { viewName, dbName, id } = conn;
|
||
// 获取视图定义后打开查询编辑器
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
let template = `-- 编辑视图 ${viewName}\n-- 请修改后执行\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`;
|
||
|
||
try {
|
||
const config = buildRuntimeConfig(conn, dbName);
|
||
let query = '';
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
|
||
break;
|
||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
|
||
const parts = viewName.split('.');
|
||
const schema = parts.length > 1 ? parts[0] : 'public';
|
||
const name = parts.length > 1 ? parts[1] : viewName;
|
||
query = `SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`;
|
||
break;
|
||
}
|
||
case 'sqlserver':
|
||
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
|
||
break;
|
||
case 'sqlite':
|
||
query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`;
|
||
break;
|
||
case 'duckdb': {
|
||
const parts = splitQualifiedName(viewName);
|
||
const viewSchema = escapeSQLLiteral(parts.schemaName || 'main');
|
||
const viewObject = escapeSQLLiteral(parts.objectName || viewName);
|
||
query = `SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`;
|
||
break;
|
||
}
|
||
}
|
||
if (query) {
|
||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||
const row = result.data[0] as Record<string, any>;
|
||
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||
if (def) {
|
||
if (dialect === 'mysql') {
|
||
template = `-- 编辑视图 ${viewName}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`;
|
||
} else {
|
||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch { /* 降级使用模板 */ }
|
||
|
||
addTab({
|
||
id: `query-edit-view-${Date.now()}`,
|
||
title: `编辑视图: ${viewName}`,
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: template
|
||
});
|
||
};
|
||
|
||
const openCreateView = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { dbName, id } = conn;
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
let template: string;
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
break;
|
||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
|
||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
break;
|
||
case 'sqlserver':
|
||
template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
break;
|
||
case 'oracle': case 'dm':
|
||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
break;
|
||
case 'sqlite':
|
||
case 'duckdb':
|
||
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
break;
|
||
default:
|
||
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||
}
|
||
addTab({
|
||
id: `query-create-view-${Date.now()}`,
|
||
title: `新建视图`,
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: template
|
||
});
|
||
};
|
||
|
||
const openCreateStarRocksMaterializedView = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { dbName, id } = conn;
|
||
const schemaPrefix = String(conn.schemaName || dbName || '').trim();
|
||
const mvName = schemaPrefix ? `${schemaPrefix}.mv_name` : 'mv_name';
|
||
const template = buildStarRocksMaterializedViewPreviewSql({
|
||
name: mvName,
|
||
query: 'SELECT\n column1,\n COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1',
|
||
distributionColumnNames: ['column1'],
|
||
refreshClause: 'REFRESH ASYNC',
|
||
properties: '"replication_num" = "1"',
|
||
});
|
||
addTab({
|
||
id: `query-create-starrocks-mv-${Date.now()}`,
|
||
title: '新建物化视图',
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: template,
|
||
});
|
||
};
|
||
|
||
const openCreateStarRocksExternalCatalog = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { dbName, id } = conn;
|
||
addTab({
|
||
id: `query-create-starrocks-catalog-${Date.now()}`,
|
||
title: '新建外部 Catalog',
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: `CREATE EXTERNAL CATALOG catalog_name\nPROPERTIES (\n "type" = "hive",\n "hive.metastore.uris" = "thrift://127.0.0.1:9083"\n);`,
|
||
});
|
||
};
|
||
|
||
const openCreateStarRocksRollup = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { tableName, dbName, id } = conn;
|
||
const safeTable = String(tableName || 'table_name').trim();
|
||
const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
|
||
addTab({
|
||
id: `query-create-starrocks-rollup-${Date.now()}`,
|
||
title: '新增 Rollup',
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
|
||
});
|
||
};
|
||
|
||
const handleDropView = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const viewName = String(conn.viewName || '').trim();
|
||
if (!viewName) return;
|
||
Modal.confirm({
|
||
title: '确认删除视图',
|
||
content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`,
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName);
|
||
if (res.success) {
|
||
message.success("视图删除成功");
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
} else {
|
||
message.error("删除失败: " + res.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleRenameView = async () => {
|
||
if (!renameViewTarget) return;
|
||
try {
|
||
const values = await renameViewForm.validateFields();
|
||
const conn = renameViewTarget.dataRef;
|
||
const oldViewName = String(conn.viewName || '').trim();
|
||
const newViewName = String(values.newName || '').trim();
|
||
if (!oldViewName || !newViewName) {
|
||
message.error("视图名称不能为空");
|
||
return;
|
||
}
|
||
if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) {
|
||
message.warning("新旧视图名相同,无需修改");
|
||
return;
|
||
}
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName);
|
||
if (res.success) {
|
||
message.success("视图重命名成功");
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
setIsRenameViewModalOpen(false);
|
||
setRenameViewTarget(null);
|
||
renameViewForm.resetFields();
|
||
} else {
|
||
message.error("重命名失败: " + res.message);
|
||
}
|
||
} catch (e) {
|
||
// Validate failed
|
||
}
|
||
};
|
||
|
||
// --- 函数/存储过程操作 ---
|
||
const openRoutineDefinition = (node: any) => {
|
||
const { routineName, routineType, dbName, id } = node.dataRef;
|
||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||
addTab({
|
||
id: `routine-def-${id}-${dbName}-${routineName}`,
|
||
title: `${typeLabel}: ${routineName}`,
|
||
type: 'routine-def',
|
||
connectionId: id,
|
||
dbName,
|
||
routineName,
|
||
routineType
|
||
});
|
||
};
|
||
|
||
const openEditRoutine = async (node: any) => {
|
||
const conn = node.dataRef;
|
||
const { routineName, routineType, dbName, id } = conn;
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||
let template = `-- 编辑${typeLabel} ${routineName}`;
|
||
|
||
try {
|
||
const config = buildRuntimeConfig(conn, dbName);
|
||
let query = '';
|
||
const parsedRoutine = splitQualifiedName(routineName);
|
||
const name = parsedRoutine.objectName || routineName;
|
||
const schema = parsedRoutine.schemaName;
|
||
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
|
||
break;
|
||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
|
||
const schemaRef = schema || 'public';
|
||
query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`;
|
||
break;
|
||
}
|
||
case 'sqlserver':
|
||
query = `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
|
||
break;
|
||
case 'oracle': case 'dm': {
|
||
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : '';
|
||
if (owner) {
|
||
query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
|
||
} else {
|
||
query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
|
||
}
|
||
break;
|
||
}
|
||
case 'duckdb': {
|
||
const schemaRef = schema || 'main';
|
||
query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`;
|
||
break;
|
||
}
|
||
}
|
||
if (query) {
|
||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||
if (dialect === 'oracle' || dialect === 'dm') {
|
||
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||
if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`;
|
||
} else if (dialect === 'duckdb') {
|
||
const row = result.data[0] as Record<string, any>;
|
||
const ddl = buildDuckDBMacroDDL(
|
||
String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(),
|
||
String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(),
|
||
getCaseInsensitiveRawValue(row, ['parameters']),
|
||
getCaseInsensitiveRawValue(row, ['macro_definition'])
|
||
);
|
||
if (ddl) template = `-- 编辑${typeLabel} ${routineName}\n${ddl}`;
|
||
} else {
|
||
const row = result.data[0] as Record<string, any>;
|
||
const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||
if (def) template = `-- 编辑${typeLabel} ${routineName}\n${def}`;
|
||
}
|
||
}
|
||
}
|
||
} catch { /* 降级使用模板 */ }
|
||
|
||
addTab({
|
||
id: `query-edit-routine-${Date.now()}`,
|
||
title: `编辑${typeLabel}: ${routineName}`,
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: template
|
||
});
|
||
};
|
||
|
||
const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => {
|
||
const conn = node.dataRef;
|
||
const { dbName, id } = conn;
|
||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||
const isProc = type === 'PROCEDURE';
|
||
let template: string;
|
||
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
template = isProc
|
||
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
|
||
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
|
||
break;
|
||
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
|
||
template = isProc
|
||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;`
|
||
: `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`;
|
||
break;
|
||
case 'sqlserver':
|
||
template = isProc
|
||
? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;`
|
||
: `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`;
|
||
break;
|
||
case 'oracle': case 'dm':
|
||
template = isProc
|
||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
|
||
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
|
||
break;
|
||
case 'duckdb':
|
||
template = isProc
|
||
? `-- DuckDB 暂不支持存储过程\n-- 请使用 SQL Macro 作为函数能力\nCREATE MACRO func_name(param1) AS (param1 * 2);`
|
||
: `CREATE MACRO func_name(param1) AS (param1 * 2);`;
|
||
break;
|
||
default:
|
||
template = isProc
|
||
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
|
||
: `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`;
|
||
}
|
||
|
||
addTab({
|
||
id: `query-create-routine-${Date.now()}`,
|
||
title: isProc ? '新建存储过程' : '新建函数',
|
||
type: 'query',
|
||
connectionId: id,
|
||
dbName,
|
||
query: template
|
||
});
|
||
};
|
||
|
||
const handleDropRoutine = (node: any) => {
|
||
const conn = node.dataRef;
|
||
const routineName = String(conn.routineName || '').trim();
|
||
const routineType = String(conn.routineType || 'FUNCTION').trim();
|
||
if (!routineName) return;
|
||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||
Modal.confirm({
|
||
title: `确认删除${typeLabel}`,
|
||
content: `确定删除${typeLabel} "${routineName}" 吗?该操作不可恢复。`,
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||
const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType);
|
||
if (res.success) {
|
||
message.success(`${typeLabel}删除成功`);
|
||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||
} else {
|
||
message.error("删除失败: " + res.message);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleV2TableContextMenuAction = (node: any, action: V2TableContextMenuActionKey) => {
|
||
switch (action) {
|
||
case 'pin-table':
|
||
case 'unpin-table': {
|
||
const conn = node.dataRef || {};
|
||
const tableName = String(conn.tableName || '').trim();
|
||
const dbName = String(conn.dbName || '').trim();
|
||
if (!conn.id || !dbName || !tableName) return;
|
||
const shouldPin = action === 'pin-table';
|
||
setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin);
|
||
void loadTables(getDatabaseNodeRef(conn, dbName));
|
||
message.success(shouldPin ? '已置顶表' : '已取消置顶');
|
||
return;
|
||
}
|
||
case 'open-data':
|
||
case 'open-new-tab':
|
||
onDoubleClick(null, node);
|
||
return;
|
||
case 'design-table':
|
||
openDesign(node, 'columns', false);
|
||
return;
|
||
case 'new-query': {
|
||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: node.dataRef.id,
|
||
dbName: node.dataRef.dbName,
|
||
query: queryTemplate
|
||
});
|
||
return;
|
||
}
|
||
case 'view-ddl':
|
||
openTableDdlInDesigner(node);
|
||
return;
|
||
case 'view-er':
|
||
openTableInERView(node);
|
||
return;
|
||
case 'copy-table-name':
|
||
void handleCopyTableName(node);
|
||
return;
|
||
case 'copy-structure':
|
||
void handleCopyStructure(node);
|
||
return;
|
||
case 'copy-insert':
|
||
void handleCopyTableAsInsert(node);
|
||
return;
|
||
case 'rename-table':
|
||
setRenameTableTarget(node);
|
||
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
|
||
setIsRenameTableModalOpen(true);
|
||
return;
|
||
case 'new-rollup':
|
||
openCreateStarRocksRollup(node);
|
||
return;
|
||
case 'backup-table':
|
||
void handleExport(node, 'sql');
|
||
return;
|
||
case 'refresh-stats':
|
||
refreshV2TableContextMenuStats(node);
|
||
return;
|
||
case 'export-xlsx':
|
||
void handleExport(node, 'xlsx');
|
||
return;
|
||
case 'export-csv':
|
||
void handleExport(node, 'csv');
|
||
return;
|
||
case 'export-json':
|
||
void handleExport(node, 'json');
|
||
return;
|
||
case 'ai-explain':
|
||
void injectTablePromptToAI(node, 'explain');
|
||
return;
|
||
case 'ai-generate-query':
|
||
void injectTablePromptToAI(node, 'query');
|
||
return;
|
||
case 'truncate-table':
|
||
void handleTableDataDangerAction(node, 'truncate');
|
||
return;
|
||
case 'drop-table':
|
||
handleDeleteTable(node);
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
};
|
||
|
||
const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {
|
||
const groupData = node.dataRef;
|
||
setTableSortPreference(groupData.id, groupData.dbName, sortBy);
|
||
const dbNode = {
|
||
key: `${groupData.id}-${groupData.dbName}`,
|
||
dataRef: groupData
|
||
};
|
||
loadTables(dbNode);
|
||
};
|
||
|
||
const handleV2TableGroupContextMenuAction = (node: any, action: V2TableGroupContextMenuActionKey) => {
|
||
switch (action) {
|
||
case 'new-table':
|
||
openNewTableDesign(node);
|
||
return;
|
||
case 'sort-by-name':
|
||
handleTableGroupSortAction(node, 'name');
|
||
return;
|
||
case 'sort-by-frequency':
|
||
handleTableGroupSortAction(node, 'frequency');
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
};
|
||
|
||
const closeDatabaseNode = (node: any) => {
|
||
const dbConnId = String(node.dataRef?.id || '');
|
||
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
|
||
loadingNodesRef.current.delete(`tables-${dbConnId}-${dbName}`);
|
||
setConnectionStates(prev => {
|
||
const next = { ...prev };
|
||
delete next[node.key];
|
||
return next;
|
||
});
|
||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
replaceTreeNodeChildren(node.key, undefined);
|
||
if (dbConnId && dbName) {
|
||
closeTabsByDatabase(dbConnId, dbName);
|
||
}
|
||
message.success("已关闭数据库");
|
||
};
|
||
|
||
const openDatabaseQuery = (node: any) => {
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询 (${node.title})`,
|
||
type: 'query',
|
||
connectionId: node.dataRef.id,
|
||
dbName: node.title,
|
||
query: ''
|
||
});
|
||
};
|
||
|
||
const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => {
|
||
switch (action) {
|
||
case 'new-table':
|
||
openNewTableDesign(node);
|
||
return;
|
||
case 'new-schema':
|
||
openCreateSchemaModal(node);
|
||
return;
|
||
case 'new-materialized-view':
|
||
openCreateStarRocksMaterializedView(node);
|
||
return;
|
||
case 'new-external-catalog':
|
||
openCreateStarRocksExternalCatalog(node);
|
||
return;
|
||
case 'rename-db':
|
||
setRenameDbTarget(node);
|
||
renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' });
|
||
setIsRenameDbModalOpen(true);
|
||
return;
|
||
case 'refresh':
|
||
loadTables(node);
|
||
return;
|
||
case 'export-db-schema':
|
||
void handleExportDatabaseSQL(node, false);
|
||
return;
|
||
case 'backup-db-sql':
|
||
void handleExportDatabaseSQL(node, true);
|
||
return;
|
||
case 'disconnect-db':
|
||
closeDatabaseNode(node);
|
||
return;
|
||
case 'new-query':
|
||
openDatabaseQuery(node);
|
||
return;
|
||
case 'run-sql':
|
||
handleRunSQLFile(node);
|
||
return;
|
||
case 'drop-db':
|
||
handleDeleteDatabase(node);
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
};
|
||
|
||
const refreshConnectionNode = (node: any) => {
|
||
const connKey = String(node?.key || node?.dataRef?.id || '');
|
||
if (!connKey) return;
|
||
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
|
||
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
|
||
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
|
||
loadingNodesRef.current.delete(loadingKey);
|
||
}
|
||
});
|
||
loadDatabases(node);
|
||
};
|
||
|
||
const disconnectConnectionNode = (node: any) => {
|
||
const connKey = String(node?.key || node?.dataRef?.id || '');
|
||
if (!connKey) return;
|
||
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
|
||
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
|
||
loadingNodesRef.current.delete(loadingKey);
|
||
}
|
||
});
|
||
setConnectionStates(prev => {
|
||
const next = { ...prev };
|
||
Object.keys(next).forEach(k => {
|
||
if (k === connKey || k.startsWith(`${connKey}-`)) {
|
||
delete next[k];
|
||
}
|
||
});
|
||
return next;
|
||
});
|
||
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
|
||
replaceTreeNodeChildren(connKey, undefined);
|
||
closeTabsByConnection(connKey);
|
||
message.success("已断开连接");
|
||
};
|
||
|
||
const deleteConnectionNode = (node: any) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||
onOk: async () => {
|
||
const connId = String(node.key);
|
||
const backendApp = (window as any).go?.app?.App;
|
||
if (typeof backendApp?.DeleteConnection !== 'function') {
|
||
message.error('删除连接失败:后端接口不可用');
|
||
throw new Error('DeleteConnection unavailable');
|
||
}
|
||
try {
|
||
await backendApp.DeleteConnection(connId);
|
||
closeTabsByConnection(connId);
|
||
removeConnection(connId);
|
||
message.success('已删除连接');
|
||
} catch (error: any) {
|
||
message.error(error?.message || '删除连接失败');
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const createConnectionTreeNode = (conn: SavedConnection): TreeNode => ({
|
||
title: conn.name,
|
||
key: conn.id,
|
||
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 22),
|
||
type: 'connection',
|
||
dataRef: conn,
|
||
isLeaf: false,
|
||
});
|
||
|
||
const getConnectionNodeForAction = (conn: SavedConnection): TreeNode => {
|
||
return findTreeNodeByKeyRef.current(treeDataRef.current, conn.id) || createConnectionTreeNode(conn);
|
||
};
|
||
|
||
const handleV2ConnectionContextMenuAction = (node: any, action: V2ConnectionContextMenuActionKey) => {
|
||
const connId = String(node?.key || node?.dataRef?.id || '');
|
||
if (!connId) return;
|
||
switch (action) {
|
||
case 'new-db':
|
||
setTargetConnection(node);
|
||
setIsCreateDbModalOpen(true);
|
||
return;
|
||
case 'refresh':
|
||
refreshConnectionNode(node);
|
||
return;
|
||
case 'new-query':
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: connId,
|
||
dbName: undefined,
|
||
query: ''
|
||
});
|
||
return;
|
||
case 'open-sql-file':
|
||
handleRunSQLFile(node);
|
||
return;
|
||
case 'new-command':
|
||
addTab({
|
||
id: `redis-cmd-${connId}-${Date.now()}`,
|
||
title: '命令 - db0',
|
||
type: 'redis-command',
|
||
connectionId: connId,
|
||
redisDB: 0
|
||
});
|
||
return;
|
||
case 'open-monitor':
|
||
addTab({
|
||
id: `redis-monitor-${connId}-${Date.now()}`,
|
||
title: '监控 - db0',
|
||
type: 'redis-monitor',
|
||
connectionId: connId,
|
||
redisDB: 0
|
||
});
|
||
return;
|
||
case 'edit':
|
||
if (onEditConnection) onEditConnection(node.dataRef);
|
||
return;
|
||
case 'copy-connection':
|
||
void handleDuplicateConnection(node.dataRef as SavedConnection);
|
||
return;
|
||
case 'disconnect':
|
||
disconnectConnectionNode(node);
|
||
return;
|
||
case 'delete':
|
||
deleteConnectionNode(node);
|
||
return;
|
||
case 'move-to-ungrouped':
|
||
moveConnectionToTag(connId, null);
|
||
return;
|
||
default:
|
||
if (action.startsWith('move-to-tag:')) {
|
||
moveConnectionToTag(connId, action.slice('move-to-tag:'.length));
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleV2ConnectionGroupContextMenuAction = (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => {
|
||
const tag = connectionTags.find((item) => item.id === group.id);
|
||
if (!tag) return;
|
||
if (action === 'edit-group') {
|
||
createTagForm.setFieldsValue({ name: tag.name, connectionIds: tag.connectionIds });
|
||
setRenameViewTarget({
|
||
title: tag.name,
|
||
key: `tag-${tag.id}`,
|
||
type: 'tag',
|
||
dataRef: tag,
|
||
});
|
||
setIsCreateTagModalOpen(true);
|
||
return;
|
||
}
|
||
if (action === 'delete-group') {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除分组 "${tag.name}" 吗?这不会删除里面的连接。`,
|
||
onOk: () => {
|
||
removeConnectionTag(tag.id);
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const { value } = e.target;
|
||
setSearchValue(value);
|
||
};
|
||
|
||
const toggleSearchScope = (scope: SearchScope) => {
|
||
setSearchScopes((prev) => {
|
||
if (scope === 'smart') {
|
||
return ['smart'];
|
||
}
|
||
const withoutSmart = prev.filter((item) => item !== 'smart');
|
||
if (withoutSmart.includes(scope)) {
|
||
const next = withoutSmart.filter((item) => item !== scope);
|
||
return next.length > 0 ? next : ['smart'];
|
||
}
|
||
return [...withoutSmart, scope];
|
||
});
|
||
};
|
||
|
||
const setSearchScopeChecked = (scope: SearchScope, checked: boolean) => {
|
||
if (scope === 'smart') {
|
||
if (checked) {
|
||
setSearchScopes(['smart']);
|
||
} else if (searchScopes.length === 1 && searchScopes[0] === 'smart') {
|
||
setSearchScopes(['smart']);
|
||
} else {
|
||
setSearchScopes((prev) => {
|
||
const next = prev.filter((item) => item !== 'smart');
|
||
return next.length > 0 ? next : ['smart'];
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (checked) {
|
||
setSearchScopes((prev) => {
|
||
const withoutSmart = prev.filter((item) => item !== 'smart');
|
||
if (withoutSmart.includes(scope)) {
|
||
return withoutSmart;
|
||
}
|
||
return [...withoutSmart, scope];
|
||
});
|
||
} else {
|
||
setSearchScopes((prev) => {
|
||
const next = prev.filter((item) => item !== scope && item !== 'smart');
|
||
return next.length > 0 ? next : ['smart'];
|
||
});
|
||
}
|
||
};
|
||
|
||
const searchScopeSummary = useMemo(() => {
|
||
if (searchScopes.includes('smart')) {
|
||
return '智能';
|
||
}
|
||
return searchScopes.map((scope) => SEARCH_SCOPE_LABEL_MAP[scope]).join(' + ');
|
||
}, [searchScopes]);
|
||
|
||
const searchScopePopoverContent = useMemo(() => {
|
||
const smartSelected = searchScopes.includes('smart');
|
||
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
|
||
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
|
||
const mutedTextColor = overlayTheme.mutedText;
|
||
const titleColor = overlayTheme.titleText;
|
||
const panelBg = overlayTheme.shellBg;
|
||
const smartBg = smartSelected
|
||
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
|
||
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
|
||
const smartBorder = smartSelected
|
||
? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)')
|
||
: borderColor;
|
||
const getOptionCardStyle = (checked: boolean) => ({
|
||
display: 'flex',
|
||
alignItems: 'center' as const,
|
||
justifyContent: 'space-between' as const,
|
||
gap: 12,
|
||
padding: '10px 12px',
|
||
borderRadius: 12,
|
||
border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`,
|
||
background: checked
|
||
? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)')
|
||
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'),
|
||
transition: 'all 120ms ease',
|
||
});
|
||
return (
|
||
<div style={{ minWidth: 280, display: 'flex', flexDirection: 'column', background: panelBg, padding: 14, gap: 12 }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: mutedTextColor, textTransform: 'uppercase' }}>搜索范围</div>
|
||
<div style={{ marginTop: 4, fontSize: 13, lineHeight: 1.5, color: mutedTextColor }}>“智能”自动匹配最可能的命中项;手动模式支持按维度组合筛选。</div>
|
||
</div>
|
||
<div style={{ width: 32, height: 32, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||
<FilterOutlined />
|
||
</div>
|
||
</div>
|
||
|
||
<label style={{ display: 'block', cursor: 'pointer' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 14, border: `1px solid ${smartBorder}`, background: smartBg, boxShadow: smartSelected ? (darkMode ? '0 10px 24px rgba(0,0,0,0.24)' : '0 10px 24px rgba(245,176,65,0.14)') : 'none' }}>
|
||
<Checkbox
|
||
checked={smartSelected}
|
||
onChange={(e) => setSearchScopeChecked('smart', e.target.checked)}
|
||
/>
|
||
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.3)', color: darkMode ? '#ffd666' : '#ad6800', flexShrink: 0 }}>
|
||
{SEARCH_SCOPE_ICON_MAP.smart}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 14, fontWeight: 700, color: titleColor }}>智能</span>
|
||
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, color: darkMode ? '#ffe58f' : '#ad6800', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.35)' }}>推荐</span>
|
||
</div>
|
||
<div style={{ marginTop: 3, fontSize: 12, lineHeight: 1.5, color: mutedTextColor }}>适合日常检索,自动覆盖名称、库、Host 和标签等高频维度。</div>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
|
||
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}>手动范围</div>
|
||
<div style={{ fontSize: 12, color: mutedTextColor }}>支持多选组合</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gap: 8 }}>
|
||
{scopedOptions.map((option) => {
|
||
const checked = searchScopes.includes(option.value);
|
||
return (
|
||
<label key={option.value} style={{ display: 'block', cursor: 'pointer' }}>
|
||
<div style={getOptionCardStyle(checked)}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||
<Checkbox
|
||
checked={checked}
|
||
onChange={(e) => setSearchScopeChecked(option.value, e.target.checked)}
|
||
/>
|
||
<div style={{ width: 28, height: 28, borderRadius: 9, display: 'grid', placeItems: 'center', background: checked ? (darkMode ? 'rgba(118,169,250,0.2)' : 'rgba(24,144,255,0.12)') : (darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)'), color: checked ? (darkMode ? '#91caff' : '#1677ff') : mutedTextColor, flexShrink: 0 }}>
|
||
{SEARCH_SCOPE_ICON_MAP[option.value]}
|
||
</div>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: titleColor, whiteSpace: 'nowrap' }}>{option.label}</span>
|
||
</div>
|
||
<div style={{ width: 18, display: 'flex', justifyContent: 'center', color: checked ? (darkMode ? '#91caff' : '#1677ff') : 'transparent', flexShrink: 0 }}>
|
||
<CheckOutlined />
|
||
</div>
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div style={{ padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(17,24,39,0.04)', color: mutedTextColor, fontSize: 12, lineHeight: 1.6 }}>
|
||
智能与其他项互斥。若你明确知道要搜的是对象、库、Host 或标签,建议切到手动范围以减少噪音结果。
|
||
</div>
|
||
</div>
|
||
);
|
||
}, [darkMode, overlayTheme, searchScopes]);
|
||
|
||
const getConnectionHostSearchText = (node: TreeNode): string => {
|
||
if (node.type !== 'connection') return '';
|
||
const config = node.dataRef?.config || {};
|
||
return resolveConnectionHostTokens(config).join(' ');
|
||
};
|
||
|
||
const getConnectionNameSearchText = (node: TreeNode): string => {
|
||
if (node.type !== 'connection') return '';
|
||
const name = node.dataRef?.name ?? node.title;
|
||
return String(name || '').toLowerCase();
|
||
};
|
||
|
||
const matchByScopes = (node: TreeNode, keyword: string, scopes: SearchScope[]): boolean => {
|
||
const title = String(node.title || '').toLowerCase();
|
||
if (scopes.includes('database') && node.type === 'database' && title.includes(keyword)) {
|
||
return true;
|
||
}
|
||
if (scopes.includes('tag') && node.type === 'tag' && title.includes(keyword)) {
|
||
return true;
|
||
}
|
||
if (scopes.includes('host') && node.type === 'connection' && getConnectionHostSearchText(node).includes(keyword)) {
|
||
return true;
|
||
}
|
||
if (scopes.includes('object') && (isV2SidebarObjectNode(node) || node.type === 'object-group') && title.includes(keyword)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const loop = (data: TreeNode[], keyword: string): TreeNode[] => {
|
||
const isSmartMode = searchScopes.includes('smart');
|
||
const result: TreeNode[] = [];
|
||
data.forEach((item) => {
|
||
const titleMatch = String(item.title || '').toLowerCase().includes(keyword);
|
||
const smartMatch = item.type === 'connection'
|
||
? getConnectionNameSearchText(item).includes(keyword) || getConnectionHostSearchText(item).includes(keyword)
|
||
: titleMatch;
|
||
const scopedMatch = matchByScopes(item, keyword, searchScopes);
|
||
const selfMatch = isSmartMode ? smartMatch : scopedMatch;
|
||
const filteredChildren = item.children ? loop(item.children, keyword) : [];
|
||
|
||
if (selfMatch) {
|
||
const shouldKeepFullSubtree = isSmartMode
|
||
|| item.type === 'connection'
|
||
|| item.type === 'database'
|
||
|| item.type === 'tag';
|
||
if (item.children && shouldKeepFullSubtree) {
|
||
result.push(item);
|
||
} else if (item.children && filteredChildren.length > 0) {
|
||
result.push({ ...item, children: filteredChildren });
|
||
} else {
|
||
result.push(item);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (filteredChildren.length > 0) {
|
||
result.push({ ...item, children: filteredChildren });
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const displayTreeData = useMemo(() => {
|
||
const keyword = deferredSearchValue.trim().toLowerCase();
|
||
if (!keyword) return treeData;
|
||
return loop(treeData, keyword);
|
||
}, [deferredSearchValue, searchScopes, treeData]);
|
||
|
||
const commandSearchTreeItems = useMemo(() => {
|
||
const result: V2CommandSearchItem[] = [];
|
||
const visit = (nodes: TreeNode[]) => {
|
||
nodes.forEach((node) => {
|
||
const dataRef = node.dataRef || {};
|
||
if (node.type === 'connection') {
|
||
const conn = dataRef as SavedConnection;
|
||
result.push({
|
||
key: `node-${node.key}`,
|
||
kind: 'node',
|
||
title: String(node.title || conn.name || '未命名连接'),
|
||
meta: resolveConnectionHostSummary(conn.config) || conn.config?.type || '连接',
|
||
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 16),
|
||
node,
|
||
});
|
||
} else if (node.type === 'database') {
|
||
const conn = connections.find((item) => item.id === dataRef.id);
|
||
result.push({
|
||
key: `node-${node.key}`,
|
||
kind: 'node',
|
||
title: String(node.title || dataRef.dbName || '未命名数据库'),
|
||
meta: conn?.name || dataRef.id || '数据库',
|
||
icon: <DatabaseOutlined />,
|
||
node,
|
||
});
|
||
} else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
|
||
const conn = connections.find((item) => item.id === dataRef.id);
|
||
const objectName = String(dataRef.tableName || dataRef.viewName || node.title || '').trim();
|
||
const displayName = String(node.title || extractObjectName(objectName) || objectName).trim();
|
||
result.push({
|
||
key: `node-${node.key}`,
|
||
kind: 'node',
|
||
title: displayName,
|
||
meta: [conn?.name || dataRef.id, dataRef.dbName].filter(Boolean).join(' · '),
|
||
icon: node.type === 'table' ? <TableOutlined /> : <EyeOutlined />,
|
||
node,
|
||
});
|
||
}
|
||
if (node.children) visit(node.children);
|
||
});
|
||
};
|
||
|
||
visit(treeData);
|
||
return result;
|
||
}, [connections, treeData]);
|
||
|
||
const commandSearchRecentItems = useMemo<V2CommandSearchItem[]>(() => {
|
||
return sqlLogs.slice(0, 5).map((log) => ({
|
||
key: `recent-${log.id}`,
|
||
kind: 'recent',
|
||
title: log.sql.replace(/\s+/g, ' ').trim() || 'SQL 记录',
|
||
meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`,
|
||
icon: <ClockCircleOutlined />,
|
||
sql: log.sql,
|
||
dbName: log.dbName,
|
||
}));
|
||
}, [sqlLogs]);
|
||
|
||
const commandSearchActionItems = useMemo<V2CommandSearchItem[]>(() => [
|
||
{
|
||
key: 'action-new-query',
|
||
kind: 'action',
|
||
title: '新建查询',
|
||
meta: '打开一个新的 SQL 编辑页',
|
||
shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform),
|
||
icon: <PlusOutlined />,
|
||
onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')),
|
||
},
|
||
{
|
||
key: 'action-new-connection',
|
||
kind: 'action',
|
||
title: '新建数据源',
|
||
meta: '创建数据库、运行时或其他数据源连接',
|
||
shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform),
|
||
icon: <ThunderboltOutlined />,
|
||
onRun: () => onCreateConnection?.(),
|
||
},
|
||
{
|
||
key: 'action-open-ai',
|
||
kind: 'action',
|
||
title: '打开 AI 数据洞察',
|
||
meta: '让 AI 分析当前数据库上下文',
|
||
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform),
|
||
icon: <RobotOutlined />,
|
||
onRun: () => onToggleAI?.(),
|
||
},
|
||
{
|
||
key: 'action-open-sql-log',
|
||
kind: 'action',
|
||
title: '查看 SQL 执行日志',
|
||
meta: '打开最近执行记录面板',
|
||
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform),
|
||
icon: <BarsOutlined />,
|
||
onRun: () => onToggleLogPanel?.(),
|
||
},
|
||
], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]);
|
||
|
||
const v2CommandSearchQuery = useMemo(
|
||
() => parseV2CommandSearchQuery(v2CommandSearchValue),
|
||
[v2CommandSearchValue],
|
||
);
|
||
const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword;
|
||
const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';
|
||
const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';
|
||
const filteredCommandSearchTreeItems = useMemo(() => {
|
||
if (v2CommandSearchAiMode) return [];
|
||
const matchLimit = v2CommandSearchObjectMode ? 16 : 8;
|
||
if (!normalizedV2CommandSearchValue) {
|
||
return commandSearchTreeItems
|
||
.filter((item) => !v2CommandSearchObjectMode || (
|
||
item.kind === 'node'
|
||
&& (item.node.type === 'table' || item.node.type === 'view' || item.node.type === 'materialized-view')
|
||
))
|
||
.slice(0, matchLimit);
|
||
}
|
||
return commandSearchTreeItems
|
||
.filter((item) => {
|
||
if (item.kind !== 'node') return false;
|
||
const node = item.node;
|
||
const dataRef = node.dataRef || {};
|
||
if (v2CommandSearchObjectMode && node.type !== 'table' && node.type !== 'view' && node.type !== 'materialized-view') {
|
||
return false;
|
||
}
|
||
const tableName = String(dataRef.tableName || dataRef.viewName || item.title || '').toLowerCase();
|
||
if (v2CommandSearchObjectMode) {
|
||
return tableName.includes(normalizedV2CommandSearchValue)
|
||
|| String(item.title || '').toLowerCase().includes(normalizedV2CommandSearchValue);
|
||
}
|
||
const haystack = [
|
||
item.title,
|
||
item.meta,
|
||
dataRef.tableName,
|
||
dataRef.viewName,
|
||
dataRef.dbName,
|
||
dataRef.name,
|
||
dataRef.config?.host,
|
||
].filter(Boolean).join(' ').toLowerCase();
|
||
return haystack.includes(normalizedV2CommandSearchValue);
|
||
})
|
||
.slice(0, matchLimit);
|
||
}, [commandSearchTreeItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
|
||
|
||
const filteredCommandSearchActionItems = useMemo(() => {
|
||
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
|
||
if (!normalizedV2CommandSearchValue) return commandSearchActionItems;
|
||
return commandSearchActionItems.filter((item) => {
|
||
const haystack = `${item.title} ${item.meta}`.toLowerCase();
|
||
return haystack.includes(normalizedV2CommandSearchValue);
|
||
});
|
||
}, [commandSearchActionItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
|
||
|
||
const filteredCommandSearchRecentItems = useMemo(() => {
|
||
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
|
||
if (!normalizedV2CommandSearchValue) return commandSearchRecentItems;
|
||
return commandSearchRecentItems.filter((item) => {
|
||
const haystack = `${item.title} ${item.meta}`.toLowerCase();
|
||
return haystack.includes(normalizedV2CommandSearchValue);
|
||
});
|
||
}, [commandSearchRecentItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
|
||
|
||
const commandSearchAiItem = useMemo<V2CommandSearchItem[]>(() => {
|
||
if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return [];
|
||
return [{
|
||
key: 'action-ask-ai',
|
||
kind: 'action',
|
||
title: '让 AI 回答',
|
||
meta: v2CommandSearchQuery.aiPrompt,
|
||
shortcut: '↵',
|
||
icon: <RobotOutlined />,
|
||
onRun: () => {
|
||
const wasClosed = !useStore.getState().aiPanelVisible;
|
||
if (wasClosed) setAIPanelVisible(true);
|
||
window.setTimeout(() => {
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', {
|
||
detail: { prompt: v2CommandSearchQuery.aiPrompt },
|
||
}));
|
||
}, wasClosed ? 350 : 0);
|
||
},
|
||
}];
|
||
}, [setAIPanelVisible, v2CommandSearchAiMode, v2CommandSearchQuery.aiPrompt]);
|
||
|
||
const commandSearchFlatItems = useMemo(
|
||
() => [
|
||
...commandSearchAiItem,
|
||
...filteredCommandSearchTreeItems,
|
||
...filteredCommandSearchActionItems,
|
||
...filteredCommandSearchRecentItems,
|
||
],
|
||
[commandSearchAiItem, filteredCommandSearchActionItems, filteredCommandSearchRecentItems, filteredCommandSearchTreeItems],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setV2CommandActiveIndex(0);
|
||
}, [v2CommandSearchValue, commandSearchFlatItems.length]);
|
||
|
||
const flattenConnectionNodes = useCallback((nodes: TreeNode[]): TreeNode[] => {
|
||
const result: TreeNode[] = [];
|
||
nodes.forEach((node) => {
|
||
if (node.type === 'connection') {
|
||
result.push(node);
|
||
}
|
||
if (node.children) {
|
||
result.push(...flattenConnectionNodes(node.children));
|
||
}
|
||
});
|
||
return result;
|
||
}, []);
|
||
|
||
const activeConnectionId = resolveV2ActiveConnectionId({
|
||
activeContextConnectionId: activeContext?.connectionId,
|
||
activeTabConnectionId: activeTab?.connectionId,
|
||
selectedKeys,
|
||
connectionIds,
|
||
fallbackConnectionId: selectedNodesRef.current
|
||
.map((node) => resolveSidebarNodeConnectionId(node, connectionIds))
|
||
.find(Boolean),
|
||
});
|
||
const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || connections[0] || null;
|
||
const activeConnectionHostSummary = resolveConnectionHostSummary(activeConnection?.config) || '未配置地址';
|
||
const activeConnectionTreeData = useMemo(() => {
|
||
if (!activeConnection) return displayTreeData;
|
||
const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id);
|
||
if (activeConnectionNode) {
|
||
return activeConnectionNode.children && activeConnectionNode.children.length > 0
|
||
? activeConnectionNode.children
|
||
: [];
|
||
}
|
||
const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => {
|
||
if (node.type === 'tag') {
|
||
return filterTree(node.children || []);
|
||
}
|
||
if (node.type === 'connection') {
|
||
if (node.key !== activeConnection.id) return [];
|
||
return node.children && node.children.length > 0 ? filterTree(node.children) : [];
|
||
}
|
||
return [{ ...node, children: node.children ? filterTree(node.children) : undefined }];
|
||
});
|
||
|
||
const filtered = filterTree(displayTreeData);
|
||
return filtered;
|
||
}, [activeConnection, displayTreeData]);
|
||
const v2VisibleTreeData = useMemo(
|
||
() => filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter),
|
||
[activeConnectionTreeData, v2ExplorerFilter],
|
||
);
|
||
const v2TreeMetrics = useMemo(() => {
|
||
const databaseObjectCounts = new Map<React.Key, number>();
|
||
const objectGroupCounts = new Map<React.Key, number>();
|
||
let activeObjectCount = 0;
|
||
|
||
const visitAndCount = (node: TreeNode): number => {
|
||
const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0);
|
||
const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount;
|
||
if (node.type === 'database') {
|
||
databaseObjectCounts.set(node.key, childCount);
|
||
} else if (node.type === 'object-group') {
|
||
objectGroupCounts.set(node.key, Array.isArray(node.children) ? node.children.length : 0);
|
||
}
|
||
return totalCount;
|
||
};
|
||
|
||
activeObjectCount = v2VisibleTreeData.reduce((total, node) => total + visitAndCount(node), 0);
|
||
|
||
return {
|
||
activeObjectCount,
|
||
databaseObjectCounts,
|
||
objectGroupCounts,
|
||
};
|
||
}, [v2VisibleTreeData]);
|
||
const activeConnectionObjectCount = v2TreeMetrics.activeObjectCount;
|
||
|
||
const connectionStatusMap = useMemo(() => {
|
||
const statusMap = new Map<string, 'live' | 'error' | 'idle'>();
|
||
const sortedConnectionIds = connections
|
||
.map((conn) => conn.id)
|
||
.sort((a, b) => b.length - a.length);
|
||
connections.forEach((conn) => {
|
||
statusMap.set(conn.id, 'idle');
|
||
});
|
||
Object.entries(connectionStates).forEach(([key, value]) => {
|
||
const ownState = statusMap.get(key);
|
||
if (ownState !== undefined) {
|
||
statusMap.set(key, value === 'success' ? 'live' : 'error');
|
||
return;
|
||
}
|
||
if (value !== 'success') return;
|
||
const ownerId = sortedConnectionIds.find((id) => key.startsWith(`${id}-`));
|
||
if (ownerId && statusMap.get(ownerId) === 'idle') {
|
||
statusMap.set(ownerId, 'live');
|
||
}
|
||
});
|
||
return statusMap;
|
||
}, [connectionStates, connections]);
|
||
|
||
const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => {
|
||
return connectionStatusMap.get(connectionId) || 'idle';
|
||
}, [connectionStatusMap]);
|
||
|
||
const toggleV2RailConnectionGroup = useCallback((groupId: string) => {
|
||
setCollapsedV2RailGroupIds((prev) => (
|
||
prev.includes(groupId)
|
||
? prev.filter((id) => id !== groupId)
|
||
: [...prev, groupId]
|
||
));
|
||
}, []);
|
||
|
||
const getRailConnectionLabel = (conn: SavedConnection): string => {
|
||
const iconType = resolveConnectionIconType(conn);
|
||
if (iconType === 'mysql' || iconType === 'mariadb' || iconType === 'oceanbase') return 'MY';
|
||
if (iconType === 'postgres') return 'PG';
|
||
if (iconType === 'redis') return 'R';
|
||
if (iconType === 'mongodb') return 'MO';
|
||
if (iconType === 'oracle') return 'OR';
|
||
if (iconType === 'sqlserver') return 'SS';
|
||
if (iconType === 'starrocks') return 'SR';
|
||
if (iconType === 'sqlite') return 'SQ';
|
||
if (iconType === 'jvm') return 'JV';
|
||
return iconType.slice(0, 2).toUpperCase() || 'DB';
|
||
};
|
||
|
||
const openV2ConnectionContextMenu = (
|
||
event: React.MouseEvent,
|
||
connOrNode: SavedConnection | TreeNode,
|
||
) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const node = (connOrNode as TreeNode).type === 'connection'
|
||
? connOrNode as TreeNode
|
||
: getConnectionNodeForAction(connOrNode as SavedConnection);
|
||
if (!node?.key || !node?.dataRef) return;
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items: [],
|
||
kind: 'v2-connection',
|
||
node,
|
||
rootClassName: 'gn-v2-table-context-menu-popup',
|
||
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }
|
||
});
|
||
};
|
||
|
||
const getV2TreeMetaText = (node: any): string => {
|
||
if (node.type === 'database') {
|
||
const count = v2TreeMetrics.databaseObjectCounts.get(node.key) || 0;
|
||
return count > 0 ? count.toLocaleString() : '';
|
||
}
|
||
if (node.type === 'object-group') {
|
||
const count = v2TreeMetrics.objectGroupCounts.get(node.key) || 0;
|
||
return count > 0 ? count.toLocaleString() : '';
|
||
}
|
||
if (node.type === 'redis-db') {
|
||
const match = String(node.title || '').match(/\((\d+)\)/);
|
||
return match?.[1] || '';
|
||
}
|
||
if (node.type === 'table') {
|
||
const key = `${node?.dataRef?.id}-${node?.dataRef?.dbName}-${node?.dataRef?.tableName}`;
|
||
const count = tableAccessCount[key] || 0;
|
||
return count > 0 ? count.toLocaleString() : '';
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const getV2TableContextMenuStatsKey = (node: any): string => {
|
||
const id = String(node?.dataRef?.id || '');
|
||
const dbName = String(node?.dataRef?.dbName || '');
|
||
const tableName = String(node?.dataRef?.tableName || node?.title || '');
|
||
return `${id}::${dbName}::${tableName}`;
|
||
};
|
||
|
||
const readNumericMetadataValue = (row: Record<string, any>, keys: string[]): number | undefined => {
|
||
const value = getCaseInsensitiveRawValue(row, keys);
|
||
if (value === undefined || value === null || value === '') return undefined;
|
||
const normalized = Number(String(value).replace(/,/g, ''));
|
||
return Number.isFinite(normalized) ? normalized : undefined;
|
||
};
|
||
|
||
const buildV2TableStatusSQL = (node: any): string => {
|
||
const conn = node.dataRef as SavedConnection & { dbName?: string; tableName?: string; schemaName?: string };
|
||
const dialect = getMetadataDialect(conn);
|
||
const dbName = String(conn?.dbName || '').trim();
|
||
const tableName = String(conn?.tableName || node?.title || '').trim();
|
||
const objectName = extractObjectName(tableName);
|
||
const schemaName = String(conn?.schemaName || (tableName.includes('.') ? tableName.split('.').slice(0, -1).join('.') : '')).trim();
|
||
switch (dialect) {
|
||
case 'mysql':
|
||
case 'starrocks':
|
||
return [
|
||
'SELECT TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine',
|
||
'FROM information_schema.tables',
|
||
`WHERE table_schema = '${escapeSQLLiteral(dbName)}'`,
|
||
`AND table_name = '${escapeSQLLiteral(objectName)}'`,
|
||
'LIMIT 1',
|
||
].join('\n');
|
||
case 'postgres':
|
||
case 'kingbase':
|
||
case 'vastbase':
|
||
case 'highgo':
|
||
case 'opengauss': {
|
||
const schema = schemaName || 'public';
|
||
return [
|
||
"SELECT c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length, 'heap' AS engine",
|
||
'FROM pg_class c',
|
||
'JOIN pg_namespace n ON n.oid = c.relnamespace',
|
||
"WHERE c.relkind = 'r'",
|
||
`AND n.nspname = '${escapeSQLLiteral(schema)}'`,
|
||
`AND c.relname = '${escapeSQLLiteral(objectName)}'`,
|
||
'LIMIT 1',
|
||
].join('\n');
|
||
}
|
||
case 'sqlserver': {
|
||
const safeTable = tableName.replace(/'/g, "''");
|
||
return [
|
||
'SELECT SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length, NULL AS engine',
|
||
'FROM sys.tables t',
|
||
'JOIN sys.indexes i ON t.object_id = i.object_id',
|
||
'JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id',
|
||
'JOIN sys.allocation_units a ON p.partition_id = a.container_id',
|
||
`WHERE t.object_id = OBJECT_ID('${safeTable}')`,
|
||
].join('\n');
|
||
}
|
||
case 'clickhouse':
|
||
return [
|
||
'SELECT total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length, engine AS engine',
|
||
'FROM system.tables',
|
||
`WHERE database = '${escapeSQLLiteral(dbName)}'`,
|
||
`AND name = '${escapeSQLLiteral(objectName)}'`,
|
||
'LIMIT 1',
|
||
].join('\n');
|
||
case 'oracle':
|
||
case 'dm': {
|
||
const owner = (schemaName || dbName || '').toUpperCase();
|
||
return [
|
||
'SELECT num_rows AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine',
|
||
'FROM all_tables',
|
||
`WHERE owner = '${escapeSQLLiteral(owner)}'`,
|
||
`AND table_name = '${escapeSQLLiteral(objectName.toUpperCase())}'`,
|
||
'FETCH FIRST 1 ROWS ONLY',
|
||
].join('\n');
|
||
}
|
||
case 'sqlite':
|
||
case 'duckdb':
|
||
return `SELECT COUNT(*) AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine FROM ${tableName}`;
|
||
default:
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const renderV2TableContextMenu = (node: any) => {
|
||
const tableName = String(node?.dataRef?.tableName || node?.title || '').trim();
|
||
const statsKey = getV2TableContextMenuStatsKey(node);
|
||
const stats = v2TableContextMenuStats[statsKey];
|
||
const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks';
|
||
const isPinned = isSidebarTablePinned(
|
||
pinnedSidebarTables,
|
||
String(node?.dataRef?.id || ''),
|
||
String(node?.dataRef?.dbName || ''),
|
||
tableName,
|
||
String(node?.dataRef?.schemaName || ''),
|
||
);
|
||
return (
|
||
<V2TableContextMenuView
|
||
tableName={tableName}
|
||
stats={stats}
|
||
isPinned={isPinned}
|
||
supportsTruncate={supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver)}
|
||
supportsStarRocksRollup={isStarRocks}
|
||
onAction={(action) => {
|
||
setContextMenu(null);
|
||
handleV2TableContextMenuAction(node, action);
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderV2TableGroupContextMenu = (node: any) => {
|
||
const groupData = node.dataRef || {};
|
||
const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`;
|
||
const currentSort = tableSortPreference[sortPreferenceKey] || 'name';
|
||
return (
|
||
<V2TableGroupContextMenuView
|
||
title="表 · tables"
|
||
dbName={String(groupData.dbName || '')}
|
||
count={Array.isArray(node.children) ? node.children.length : 0}
|
||
currentSort={currentSort}
|
||
onAction={(action) => {
|
||
setContextMenu(null);
|
||
handleV2TableGroupContextMenuAction(node, action);
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderV2DatabaseContextMenu = (node: any) => {
|
||
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
|
||
return (
|
||
<V2DatabaseContextMenuView
|
||
dbName={String(node.dataRef?.dbName || node.title || '')}
|
||
dialect={dialect}
|
||
supportsSchemaActions={isPostgresSchemaDialect(dialect)}
|
||
supportsStarRocksActions={dialect === 'starrocks'}
|
||
onAction={(action) => {
|
||
setContextMenu(null);
|
||
handleV2DatabaseContextMenuAction(node, action);
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderV2ConnectionContextMenu = (node: any) => {
|
||
const conn = node.dataRef as SavedConnection;
|
||
const currentTagId = connectionTags.find((tag) => tag.connectionIds.includes(String(conn.id || node.key)))?.id || '';
|
||
return (
|
||
<V2ConnectionContextMenuView
|
||
connectionName={String(conn?.name || node.title || '未命名连接')}
|
||
hostSummary={resolveConnectionHostSummary(conn?.config)}
|
||
driverLabel={resolveConnectionIconType(conn)}
|
||
isRedis={conn?.config?.type === 'redis'}
|
||
tags={connectionTags.map((tag) => ({
|
||
id: tag.id,
|
||
name: tag.name,
|
||
selected: tag.id === currentTagId,
|
||
}))}
|
||
onAction={(action) => {
|
||
setContextMenu(null);
|
||
handleV2ConnectionContextMenuAction(node, action);
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => (
|
||
<V2ConnectionGroupContextMenuView
|
||
groupName={group.name}
|
||
count={group.connections.length}
|
||
onAction={(action) => {
|
||
setContextMenu(null);
|
||
handleV2ConnectionGroupContextMenuAction(group, action);
|
||
}}
|
||
/>
|
||
);
|
||
|
||
const fetchV2TableContextMenuStats = async (node: any) => {
|
||
const statsKey = getV2TableContextMenuStatsKey(node);
|
||
if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return;
|
||
const sql = buildV2TableStatusSQL(node);
|
||
if (!sql) {
|
||
setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } }));
|
||
return;
|
||
}
|
||
|
||
setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { ...prev[statsKey], loading: true } }));
|
||
const startTime = Date.now();
|
||
try {
|
||
const conn = node.dataRef;
|
||
const res = await DBQuery(buildRuntimeConfig(conn, conn.dbName) as any, conn.dbName || '', sql);
|
||
if (!res.success || !Array.isArray(res.data) || res.data.length === 0) {
|
||
setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } }));
|
||
return;
|
||
}
|
||
const row = res.data[0] as Record<string, any>;
|
||
setV2TableContextMenuStats(prev => ({
|
||
...prev,
|
||
[statsKey]: {
|
||
rowCount: readNumericMetadataValue(row, ['table_rows', 'TABLE_ROWS', 'rows', 'num_rows', 'reltuples', 'total_rows']),
|
||
dataLength: readNumericMetadataValue(row, ['data_length', 'DATA_LENGTH', 'total_bytes']),
|
||
indexLength: readNumericMetadataValue(row, ['index_length', 'INDEX_LENGTH']),
|
||
engine: getCaseInsensitiveValue(row, ['engine', 'ENGINE']),
|
||
},
|
||
}));
|
||
addSqlLog({
|
||
id: `${Date.now()}-table-stats`,
|
||
timestamp: Date.now(),
|
||
sql,
|
||
status: 'success',
|
||
duration: Date.now() - startTime,
|
||
dbName: conn.dbName,
|
||
});
|
||
} catch (error: any) {
|
||
setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } }));
|
||
addSqlLog({
|
||
id: `${Date.now()}-table-stats-error`,
|
||
timestamp: Date.now(),
|
||
sql,
|
||
status: 'error',
|
||
duration: Date.now() - startTime,
|
||
message: error?.message || String(error),
|
||
dbName: node?.dataRef?.dbName,
|
||
});
|
||
}
|
||
};
|
||
|
||
const refreshV2TableContextMenuStats = (node: any) => {
|
||
const statsKey = getV2TableContextMenuStatsKey(node);
|
||
setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { loading: true } }));
|
||
void fetchV2TableContextMenuStats(node);
|
||
};
|
||
|
||
const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {
|
||
const rawTitle = String(node.title ?? '');
|
||
const groupKey = String(node?.dataRef?.groupKey || '');
|
||
const displayTitle = (() => {
|
||
if (node.type === 'queries-folder') return '已存查询 · saved';
|
||
if (node.type === 'external-sql-root') return '外部 SQL 目录';
|
||
if (node.type === 'object-group') {
|
||
if (groupKey === 'tables') return '表 · tables';
|
||
if (groupKey === 'views') return '视图 · views';
|
||
if (groupKey === 'routines') return '函数 · functions';
|
||
if (groupKey === 'triggers') return '触发器 · triggers';
|
||
if (groupKey === 'materializedViews') return '物化视图 · materialized';
|
||
}
|
||
return rawTitle;
|
||
})();
|
||
const metaText = getV2TreeMetaText(node);
|
||
const isMono = node.type === 'table'
|
||
|| node.type === 'view'
|
||
|| node.type === 'materialized-view'
|
||
|| node.type === 'db-trigger'
|
||
|| node.type === 'routine'
|
||
|| node.type === 'saved-query'
|
||
|| node.type === 'external-sql-file';
|
||
const sectionLabel = node.type === 'object-group' && groupKey === 'tables' && metaText ? (
|
||
<span className="gn-v2-tree-section-label">全部</span>
|
||
) : null;
|
||
const titleClassName = [
|
||
'gn-v2-tree-title',
|
||
isMono ? 'is-mono' : '',
|
||
node.type === 'object-group' ? 'is-group' : '',
|
||
].filter(Boolean).join(' ');
|
||
return (
|
||
<>
|
||
<span
|
||
className={titleClassName}
|
||
title={hoverTitle}
|
||
data-node-type={node.type}
|
||
data-group-key={groupKey || undefined}
|
||
>
|
||
{statusBadge}
|
||
<span className="gn-v2-tree-label">{displayTitle}</span>
|
||
{metaText && <span className="gn-v2-tree-count">{metaText}</span>}
|
||
</span>
|
||
{sectionLabel}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const selectConnectionFromRail = useCallback((conn: SavedConnection) => {
|
||
const key = conn.id;
|
||
const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key);
|
||
setSelectedKeys([key]);
|
||
selectedNodesRef.current = connectionNode ? [connectionNode] : [];
|
||
setActiveContext({ connectionId: key, dbName: '' });
|
||
mergeExpandedTreeKeys([key]);
|
||
const targetNode = connectionNode || {
|
||
key,
|
||
dataRef: conn,
|
||
type: 'connection',
|
||
};
|
||
void loadDatabases(targetNode);
|
||
}, [setActiveContext]);
|
||
|
||
const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => {
|
||
if (!item) return;
|
||
closeV2CommandSearch();
|
||
if (item.kind === 'action') {
|
||
item.onRun();
|
||
return;
|
||
}
|
||
if (item.kind === 'recent') {
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: '最近查询',
|
||
type: 'query',
|
||
connectionId: item.connectionId || activeContext?.connectionId || activeTab?.connectionId || '',
|
||
dbName: item.dbName || activeContext?.dbName || activeTab?.dbName || '',
|
||
query: item.sql,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const node = item.node;
|
||
const dataRef = node.dataRef || {};
|
||
if (node.type === 'connection') {
|
||
selectConnectionFromRail(dataRef as SavedConnection);
|
||
return;
|
||
}
|
||
if (node.type === 'database') {
|
||
setActiveContext({ connectionId: resolveSidebarNodeConnectionId(node, connectionIds) || dataRef.id, dbName: dataRef.dbName });
|
||
mergeExpandedTreeKeys([dataRef.id, node.key]);
|
||
setSelectedKeys([node.key]);
|
||
selectedNodesRef.current = [node];
|
||
scrollSidebarTreeToKey(node.key);
|
||
return;
|
||
}
|
||
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
|
||
void locateObjectInSidebar({
|
||
connectionId: dataRef.id,
|
||
dbName: dataRef.dbName,
|
||
tableName: dataRef.tableName || dataRef.viewName,
|
||
schemaName: dataRef.schemaName,
|
||
objectGroup: node.type === 'table' ? 'tables' : (node.type === 'materialized-view' ? 'materializedViews' : 'views'),
|
||
});
|
||
onDoubleClick(null, node);
|
||
}
|
||
}, [activeContext, activeTab, addTab, closeV2CommandSearch, selectConnectionFromRail, setActiveContext]);
|
||
|
||
const handleV2CommandSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
setV2CommandActiveIndex((prev) => {
|
||
if (commandSearchFlatItems.length === 0) return 0;
|
||
return Math.min(prev + 1, commandSearchFlatItems.length - 1);
|
||
});
|
||
return;
|
||
}
|
||
if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0));
|
||
return;
|
||
}
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (v2CommandSearchAiMode && !v2CommandSearchQuery.aiPrompt) {
|
||
message.warning('请输入要问 AI 的问题');
|
||
return;
|
||
}
|
||
runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]);
|
||
return;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
closeV2CommandSearch();
|
||
}
|
||
};
|
||
|
||
const renderV2CommandSearchRow = (item: V2CommandSearchItem, active: boolean) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
className={`gn-v2-command-row${active ? ' is-active' : ''}`}
|
||
onMouseEnter={() => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === item.key))}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => runCommandSearchItem(item)}
|
||
>
|
||
<span className={`gn-v2-command-row-icon is-${item.kind}`}>{item.icon}</span>
|
||
<span className="gn-v2-command-row-main">
|
||
<strong>{item.title}</strong>
|
||
{item.meta ? <small>{item.meta}</small> : null}
|
||
</span>
|
||
{item.kind === 'action' && item.shortcut ? <kbd>{item.shortcut}</kbd> : null}
|
||
</button>
|
||
);
|
||
|
||
const renderV2CommandSearchSection = (title: string, items: V2CommandSearchItem[]) => {
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<section className="gn-v2-command-section">
|
||
<div className="gn-v2-command-section-title">{title}</div>
|
||
{items.map((item) => renderV2CommandSearchRow(
|
||
item,
|
||
commandSearchFlatItems[v2CommandActiveIndex]?.key === item.key,
|
||
))}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const renderV2CommandSearchOverlay = () => {
|
||
if (!isV2CommandSearchOpen) return null;
|
||
const emptyCopy = v2CommandSearchAiMode
|
||
? '输入「?」后加问题,按 Enter 发送到 AI 面板。'
|
||
: (v2CommandSearchObjectMode
|
||
? '未找到匹配的表、视图或物化视图。'
|
||
: '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。');
|
||
return (
|
||
<div className="gn-v2-command-backdrop" data-v2-command-search="true" onMouseDown={closeV2CommandSearch}>
|
||
<div className="gn-v2-command-palette" role="dialog" aria-modal="true" aria-label="搜索表、连接、动作" onMouseDown={(event) => event.stopPropagation()}>
|
||
<div className="gn-v2-command-searchbar">
|
||
<SearchOutlined />
|
||
<Input
|
||
{...noAutoCapInputProps}
|
||
ref={commandSearchInputRef}
|
||
variant="borderless"
|
||
value={v2CommandSearchValue}
|
||
onChange={(event) => setV2CommandSearchValue(event.target.value)}
|
||
onKeyDown={handleV2CommandSearchKeyDown}
|
||
placeholder="搜索表、连接、动作... 或问 AI"
|
||
/>
|
||
<kbd>esc</kbd>
|
||
</div>
|
||
<div className="gn-v2-command-list">
|
||
{renderV2CommandSearchSection('跳转 · GO TO', filteredCommandSearchTreeItems)}
|
||
{renderV2CommandSearchSection('AI · ASK', commandSearchAiItem)}
|
||
{renderV2CommandSearchSection('动作 · ACTIONS', filteredCommandSearchActionItems)}
|
||
{renderV2CommandSearchSection('近期查询 · RECENT', filteredCommandSearchRecentItems)}
|
||
{commandSearchFlatItems.length === 0 ? (
|
||
<div className="gn-v2-command-empty">
|
||
{emptyCopy}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="gn-v2-command-footer">
|
||
<span><kbd>↑</kbd><kbd>↓</kbd>导航</span>
|
||
<span><kbd>↵</kbd>选择</span>
|
||
<span><TableOutlined /> <kbd>@</kbd>只搜表对象</span>
|
||
<span><RobotOutlined /> <kbd>?</kbd>发送给 AI</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
expandConnectionFromRailRef.current = (connectionId: string) => {
|
||
const conn = connections.find((item) => item.id === connectionId);
|
||
if (conn) {
|
||
selectConnectionFromRail(conn);
|
||
}
|
||
};
|
||
|
||
const getNodeMenuItems = (node: any): MenuProps['items'] => {
|
||
const conn = node.dataRef as SavedConnection;
|
||
const isRedis = conn?.config?.type === 'redis';
|
||
|
||
// 表分组节点的右键菜单
|
||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
|
||
const groupData = node.dataRef; // { ...conn, dbName, groupKey }
|
||
const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`;
|
||
const currentSort = tableSortPreference[sortPreferenceKey] || 'name';
|
||
|
||
return [
|
||
{
|
||
key: 'new-table',
|
||
label: '新建表',
|
||
icon: <TableOutlined />,
|
||
onClick: () => openNewTableDesign(node)
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'sort-by-name',
|
||
label: '按名称排序',
|
||
icon: currentSort === 'name' ? <CheckSquareOutlined /> : null,
|
||
onClick: () => handleTableGroupSortAction(node, 'name')
|
||
},
|
||
{
|
||
key: 'sort-by-frequency',
|
||
label: '按使用频率排序',
|
||
icon: currentSort === 'frequency' ? <CheckSquareOutlined /> : null,
|
||
onClick: () => handleTableGroupSortAction(node, 'frequency')
|
||
}
|
||
];
|
||
}
|
||
|
||
// 视图分组节点的右键菜单
|
||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') {
|
||
return [
|
||
{
|
||
key: 'create-view',
|
||
label: '新建视图',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => openCreateView(node)
|
||
},
|
||
];
|
||
}
|
||
|
||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') {
|
||
return [
|
||
{
|
||
key: 'create-materialized-view',
|
||
label: '新建物化视图',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => openCreateStarRocksMaterializedView(node)
|
||
},
|
||
];
|
||
}
|
||
|
||
// 函数分组节点的右键菜单
|
||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
|
||
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
|
||
const routineMenu: MenuProps['items'] = [
|
||
{
|
||
key: 'create-function',
|
||
label: '新建函数',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => openCreateRoutine(node, 'FUNCTION')
|
||
},
|
||
];
|
||
if (dialect !== 'duckdb') {
|
||
routineMenu.push({
|
||
key: 'create-procedure',
|
||
label: '新建存储过程',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => openCreateRoutine(node, 'PROCEDURE')
|
||
});
|
||
}
|
||
return routineMenu;
|
||
}
|
||
|
||
// Connection Tag Menu — must be BEFORE the connection check
|
||
if (node.type === 'tag') {
|
||
return [
|
||
{
|
||
key: 'edit-tag',
|
||
label: '编辑标签',
|
||
icon: <EditOutlined />,
|
||
onClick: () => {
|
||
createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds });
|
||
setRenameViewTarget(node);
|
||
setIsCreateTagModalOpen(true);
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'delete-tag',
|
||
label: '删除标签',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`,
|
||
onOk: () => {
|
||
removeConnectionTag(node.dataRef.id);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
if (node.type === 'connection') {
|
||
// Redis connection menu
|
||
if (isRedis) {
|
||
return [
|
||
{
|
||
key: 'refresh',
|
||
label: '刷新',
|
||
icon: <ReloadOutlined />,
|
||
onClick: () => {
|
||
const connKey = String(node.key);
|
||
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
|
||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
|
||
// 清除 loadingNodesRef 中残留的子节点加载标记
|
||
Array.from(loadingNodesRef.current).forEach(lk => {
|
||
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
|
||
});
|
||
loadDatabases(node);
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'new-command',
|
||
label: '新建命令窗口',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `redis-cmd-${node.key}-${Date.now()}`,
|
||
title: '命令 - db0',
|
||
type: 'redis-command',
|
||
connectionId: node.key,
|
||
redisDB: 0
|
||
});
|
||
}
|
||
},
|
||
{
|
||
key: 'open-monitor',
|
||
label: 'Redis 实例监控',
|
||
icon: <DashboardOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||
title: '监控 - db0',
|
||
type: 'redis-monitor',
|
||
connectionId: node.key,
|
||
redisDB: 0
|
||
});
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'edit',
|
||
label: '编辑连接',
|
||
icon: <EditOutlined />,
|
||
onClick: () => {
|
||
if (onEditConnection) onEditConnection(node.dataRef);
|
||
}
|
||
},
|
||
{
|
||
key: 'copy-connection',
|
||
label: '复制连接',
|
||
icon: <CopyOutlined />,
|
||
onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection)
|
||
},
|
||
{
|
||
key: 'disconnect',
|
||
label: '断开连接',
|
||
icon: <DisconnectOutlined />,
|
||
onClick: () => {
|
||
setConnectionStates(prev => {
|
||
const next = { ...prev };
|
||
Object.keys(next).forEach(k => {
|
||
if (k === node.key || k.startsWith(`${node.key}-`)) {
|
||
delete next[k];
|
||
}
|
||
});
|
||
return next;
|
||
});
|
||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
replaceTreeNodeChildren(node.key, undefined);
|
||
closeTabsByConnection(String(node.key));
|
||
message.success("已断开连接");
|
||
}
|
||
},
|
||
{
|
||
key: 'delete',
|
||
label: '删除连接',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||
onOk: async () => {
|
||
const connId = String(node.key);
|
||
const backendApp = (window as any).go?.app?.App;
|
||
if (typeof backendApp?.DeleteConnection !== 'function') {
|
||
message.error('删除连接失败:后端接口不可用');
|
||
throw new Error('DeleteConnection unavailable');
|
||
}
|
||
try {
|
||
await backendApp.DeleteConnection(connId);
|
||
closeTabsByConnection(connId);
|
||
removeConnection(connId);
|
||
message.success('已删除连接');
|
||
} catch (error: any) {
|
||
message.error(error?.message || '删除连接失败');
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
// Tag submenu for connection
|
||
const tagSubMenuItems: MenuProps['items'] = connectionTags.map(tag => ({
|
||
key: `move-to-tag-${tag.id}`,
|
||
label: tag.name,
|
||
icon: <FolderOutlined />,
|
||
onClick: () => moveConnectionToTag(node.key, tag.id)
|
||
}));
|
||
if (connectionTags.length > 0) {
|
||
tagSubMenuItems.push({ type: 'divider' });
|
||
}
|
||
tagSubMenuItems.push({
|
||
key: 'move-to-ungrouped',
|
||
label: '移出标签',
|
||
onClick: () => moveConnectionToTag(node.key, null)
|
||
});
|
||
|
||
// Regular database connection menu
|
||
return [
|
||
{
|
||
key: 'new-db',
|
||
label: '新建数据库',
|
||
icon: <DatabaseOutlined />,
|
||
onClick: () => {
|
||
setTargetConnection(node);
|
||
setIsCreateDbModalOpen(true);
|
||
}
|
||
},
|
||
{
|
||
key: 'refresh',
|
||
label: '刷新',
|
||
icon: <ReloadOutlined />,
|
||
onClick: () => {
|
||
const connKey = String(node.key);
|
||
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
|
||
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
|
||
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
|
||
// 清除 loadingNodesRef 中残留的子节点加载标记
|
||
Array.from(loadingNodesRef.current).forEach(lk => {
|
||
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
|
||
});
|
||
loadDatabases(node);
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'new-query',
|
||
label: '新建查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: node.key,
|
||
dbName: undefined,
|
||
query: ''
|
||
});
|
||
}
|
||
},
|
||
{
|
||
key: 'open-sql-file',
|
||
label: '运行外部SQL文件',
|
||
icon: <FileAddOutlined />,
|
||
onClick: () => handleRunSQLFile(node)
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'edit',
|
||
label: '编辑连接',
|
||
icon: <EditOutlined />,
|
||
onClick: () => {
|
||
if (onEditConnection) onEditConnection(node.dataRef);
|
||
}
|
||
},
|
||
{
|
||
key: 'copy-connection',
|
||
label: '复制连接',
|
||
icon: <CopyOutlined />,
|
||
onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection)
|
||
},
|
||
{
|
||
key: 'move-to-tag',
|
||
label: '移至标签',
|
||
icon: <FolderOpenOutlined />,
|
||
children: tagSubMenuItems
|
||
},
|
||
{
|
||
key: 'disconnect',
|
||
label: '断开连接',
|
||
icon: <DisconnectOutlined />,
|
||
onClick: () => {
|
||
const connId = String(node.key || '');
|
||
// 强制清理该连接相关的 loading 标记,避免网络卡住后重连仍被短路。
|
||
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
|
||
if (loadingKey === `dbs-${connId}` || loadingKey.startsWith(`tables-${connId}-`)) {
|
||
loadingNodesRef.current.delete(loadingKey);
|
||
}
|
||
});
|
||
// Reset status recursively
|
||
setConnectionStates(prev => {
|
||
const next = { ...prev };
|
||
Object.keys(next).forEach(k => {
|
||
if (k === node.key || k.startsWith(`${node.key}-`)) {
|
||
delete next[k];
|
||
}
|
||
});
|
||
return next;
|
||
});
|
||
// Collapse node and children
|
||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
// Reset loaded state recursively
|
||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||
// Clear children (undefined to trigger reload)
|
||
replaceTreeNodeChildren(node.key, undefined);
|
||
closeTabsByConnection(String(node.key));
|
||
message.success("已断开连接");
|
||
}
|
||
},
|
||
{
|
||
key: 'delete',
|
||
label: '删除连接',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除连接 "${node.title}" 吗?`,
|
||
onOk: async () => {
|
||
const connId = String(node.key);
|
||
const backendApp = (window as any).go?.app?.App;
|
||
if (typeof backendApp?.DeleteConnection !== 'function') {
|
||
message.error('删除连接失败:后端接口不可用');
|
||
throw new Error('DeleteConnection unavailable');
|
||
}
|
||
try {
|
||
await backendApp.DeleteConnection(connId);
|
||
closeTabsByConnection(connId);
|
||
removeConnection(connId);
|
||
message.success('已删除连接');
|
||
} catch (error: any) {
|
||
message.error(error?.message || '删除连接失败');
|
||
throw error;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
];
|
||
} else if (node.type === 'redis-db') {
|
||
// Redis database menu
|
||
const { id, redisDB } = node.dataRef;
|
||
return [
|
||
{
|
||
key: 'open-keys',
|
||
label: '浏览 Key',
|
||
icon: <KeyOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `redis-keys-${id}-db${redisDB}`,
|
||
title: `db${redisDB}`,
|
||
type: 'redis-keys',
|
||
connectionId: id,
|
||
redisDB: redisDB
|
||
});
|
||
}
|
||
},
|
||
{
|
||
key: 'new-command',
|
||
label: '新建命令窗口',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`,
|
||
title: `命令 - db${redisDB}`,
|
||
type: 'redis-command',
|
||
connectionId: id,
|
||
redisDB: redisDB
|
||
});
|
||
}
|
||
},
|
||
{
|
||
key: 'open-monitor',
|
||
label: 'Redis 实例监控',
|
||
icon: <DashboardOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
|
||
title: `监控 - db${redisDB}`,
|
||
type: 'redis-monitor',
|
||
connectionId: id,
|
||
redisDB: redisDB
|
||
});
|
||
}
|
||
}
|
||
];
|
||
} else if (node.type === 'database') {
|
||
const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks';
|
||
const supportsSchemaActions = isPostgresSchemaDialect(getMetadataDialect(node.dataRef as SavedConnection));
|
||
return [
|
||
{
|
||
key: 'new-table',
|
||
label: '新建表',
|
||
icon: <TableOutlined />,
|
||
onClick: () => openNewTableDesign(node)
|
||
},
|
||
...(supportsSchemaActions ? [
|
||
{
|
||
key: 'new-schema',
|
||
label: '新建模式',
|
||
icon: <FolderAddOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema')
|
||
},
|
||
] : []),
|
||
...(isStarRocks ? [
|
||
{
|
||
key: 'new-materialized-view',
|
||
label: '新建物化视图',
|
||
icon: <ThunderboltOutlined />,
|
||
onClick: () => openCreateStarRocksMaterializedView(node)
|
||
},
|
||
{
|
||
key: 'new-external-catalog',
|
||
label: '新建外部 Catalog',
|
||
icon: <CloudOutlined />,
|
||
onClick: () => openCreateStarRocksExternalCatalog(node)
|
||
},
|
||
] : []),
|
||
{
|
||
key: 'rename-db',
|
||
label: '重命名数据库',
|
||
icon: <EditOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db')
|
||
},
|
||
{
|
||
key: 'danger-zone',
|
||
label: '危险操作',
|
||
icon: <WarningOutlined />,
|
||
children: [
|
||
{
|
||
key: 'drop-db',
|
||
label: '删除数据库',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db')
|
||
}
|
||
]
|
||
},
|
||
{
|
||
key: 'refresh',
|
||
label: '刷新',
|
||
icon: <ReloadOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh')
|
||
},
|
||
{
|
||
key: 'export-db-schema',
|
||
label: '导出全部表结构 (SQL)',
|
||
icon: <ExportOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema')
|
||
},
|
||
{
|
||
key: 'backup-db-sql',
|
||
label: '备份全部表 (结构+数据 SQL)',
|
||
icon: <SaveOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql')
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'disconnect-db',
|
||
label: '关闭数据库',
|
||
icon: <DisconnectOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db')
|
||
},
|
||
{
|
||
key: 'new-query',
|
||
label: '新建查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query')
|
||
},
|
||
{
|
||
key: 'run-sql',
|
||
label: '运行外部SQL文件',
|
||
icon: <FileAddOutlined />,
|
||
onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql')
|
||
}
|
||
];
|
||
} else if (node.type === 'view') {
|
||
return [
|
||
{
|
||
key: 'open-view',
|
||
label: '浏览视图数据',
|
||
icon: <EyeOutlined />,
|
||
onClick: () => onDoubleClick(null, node)
|
||
},
|
||
{
|
||
key: 'view-definition',
|
||
label: '查看视图定义',
|
||
icon: <CodeOutlined />,
|
||
onClick: () => openViewDefinition(node)
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'edit-view',
|
||
label: '编辑视图',
|
||
icon: <EditOutlined />,
|
||
onClick: () => openEditView(node)
|
||
},
|
||
{
|
||
key: 'new-query',
|
||
label: '新建查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: node.dataRef.id,
|
||
dbName: node.dataRef.dbName,
|
||
query: ''
|
||
});
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'rename-view',
|
||
label: '重命名视图',
|
||
icon: <EditOutlined />,
|
||
onClick: () => {
|
||
setRenameViewTarget(node);
|
||
renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) });
|
||
setIsRenameViewModalOpen(true);
|
||
}
|
||
},
|
||
{
|
||
key: 'danger-zone',
|
||
label: '危险操作',
|
||
icon: <WarningOutlined />,
|
||
children: [
|
||
{
|
||
key: 'drop-view',
|
||
label: '删除视图',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => handleDropView(node)
|
||
}
|
||
]
|
||
},
|
||
];
|
||
} else if (node.type === 'materialized-view') {
|
||
return [
|
||
{
|
||
key: 'open-materialized-view',
|
||
label: '浏览物化视图数据',
|
||
icon: <EyeOutlined />,
|
||
onClick: () => onDoubleClick(null, node)
|
||
},
|
||
{
|
||
key: 'materialized-view-definition',
|
||
label: '查看物化视图定义',
|
||
icon: <CodeOutlined />,
|
||
onClick: () => openViewDefinition(node)
|
||
},
|
||
{
|
||
key: 'new-query',
|
||
label: '新建查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: node.dataRef.id,
|
||
dbName: node.dataRef.dbName,
|
||
query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || ''))
|
||
});
|
||
}
|
||
},
|
||
];
|
||
} else if (node.type === 'routine') {
|
||
const routineType = node.dataRef?.routineType || 'FUNCTION';
|
||
const typeLabel = routineType === 'PROCEDURE' ? '存储过程' : '函数';
|
||
return [
|
||
{
|
||
key: 'view-routine-def',
|
||
label: '查看定义',
|
||
icon: <CodeOutlined />,
|
||
onClick: () => openRoutineDefinition(node)
|
||
},
|
||
{
|
||
key: 'edit-routine',
|
||
label: '编辑定义',
|
||
icon: <EditOutlined />,
|
||
onClick: () => openEditRoutine(node)
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'danger-zone',
|
||
label: '危险操作',
|
||
icon: <WarningOutlined />,
|
||
children: [
|
||
{
|
||
key: 'drop-routine',
|
||
label: `删除${typeLabel}`,
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => handleDropRoutine(node)
|
||
}
|
||
]
|
||
},
|
||
];
|
||
} else if (node.type === 'table') {
|
||
const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks';
|
||
return [
|
||
{
|
||
key: 'new-query',
|
||
label: '新建查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
|
||
addTab({
|
||
id: `query-${Date.now()}`,
|
||
title: `新建查询`,
|
||
type: 'query',
|
||
connectionId: node.dataRef.id,
|
||
dbName: node.dataRef.dbName,
|
||
query: queryTemplate
|
||
});
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'design-table',
|
||
label: '设计表',
|
||
icon: <EditOutlined />,
|
||
onClick: () => openDesign(node, 'columns', false)
|
||
},
|
||
...(isStarRocks ? [{
|
||
key: 'new-rollup',
|
||
label: '新增 Rollup',
|
||
icon: <ThunderboltOutlined />,
|
||
onClick: () => openCreateStarRocksRollup(node)
|
||
}] : []),
|
||
{
|
||
key: 'copy-table-name',
|
||
label: '复制表名',
|
||
icon: <CopyOutlined />,
|
||
onClick: () => handleCopyTableName(node)
|
||
},
|
||
{
|
||
key: 'copy-structure',
|
||
label: '复制表结构',
|
||
icon: <CopyOutlined />,
|
||
onClick: () => handleCopyStructure(node)
|
||
},
|
||
{
|
||
key: 'backup-table',
|
||
label: '备份表 (SQL)',
|
||
icon: <SaveOutlined />,
|
||
onClick: () => handleExport(node, 'sql')
|
||
},
|
||
{
|
||
key: 'rename-table',
|
||
label: '重命名表',
|
||
icon: <EditOutlined />,
|
||
onClick: () => {
|
||
setRenameTableTarget(node);
|
||
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
|
||
setIsRenameTableModalOpen(true);
|
||
}
|
||
},
|
||
{
|
||
key: 'danger-zone',
|
||
label: '危险操作',
|
||
icon: <WarningOutlined />,
|
||
children: [
|
||
...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{
|
||
key: 'truncate-table',
|
||
label: '截断表',
|
||
danger: true,
|
||
onClick: () => handleTableDataDangerAction(node, 'truncate')
|
||
}] : []),
|
||
{
|
||
key: 'clear-table',
|
||
label: '清空表',
|
||
danger: true,
|
||
onClick: () => handleTableDataDangerAction(node, 'clear')
|
||
},
|
||
{
|
||
key: 'drop-table',
|
||
label: '删除表',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => handleDeleteTable(node)
|
||
}
|
||
]
|
||
},
|
||
{
|
||
type: 'divider'
|
||
},
|
||
{
|
||
key: 'export',
|
||
label: '导出表数据',
|
||
icon: <ExportOutlined />,
|
||
children: [
|
||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') },
|
||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') },
|
||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') },
|
||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') },
|
||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') },
|
||
]
|
||
}
|
||
];
|
||
}
|
||
|
||
// 已存查询节点的右键菜单
|
||
if (node.type === 'saved-query') {
|
||
const q = node.dataRef;
|
||
return [
|
||
{
|
||
key: 'open-query',
|
||
label: '打开查询',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
addTab({
|
||
id: q.id,
|
||
title: q.name,
|
||
type: 'query',
|
||
connectionId: q.connectionId,
|
||
dbName: q.dbName,
|
||
query: q.sql,
|
||
savedQueryId: q.id,
|
||
});
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'delete-query',
|
||
label: '删除查询',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
|
||
okButtonProps: { danger: true },
|
||
onOk: () => {
|
||
deleteQuery(q.id);
|
||
// 从树中移除节点
|
||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||
list
|
||
.filter(n => n.key !== node.key)
|
||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||
const nextTreeData = removeNode(treeDataRef.current);
|
||
treeDataRef.current = nextTreeData;
|
||
setTreeData(nextTreeData);
|
||
message.success('查询已删除');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
if (node.type === 'external-sql-root') {
|
||
return [
|
||
{
|
||
key: 'add-external-sql-directory',
|
||
label: '添加 SQL 目录',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => {
|
||
void handleAddExternalSQLDirectory(node);
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
if (node.type === 'external-sql-directory') {
|
||
return [
|
||
{
|
||
key: 'refresh-external-sql-directory',
|
||
label: '刷新目录',
|
||
icon: <ReloadOutlined />,
|
||
onClick: () => {
|
||
void handleRefreshExternalSQLDirectory(node);
|
||
}
|
||
},
|
||
{ type: 'divider' },
|
||
{
|
||
key: 'remove-external-sql-directory',
|
||
label: '移除目录',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => {
|
||
void handleRemoveExternalSQLDirectory(node);
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
if (node.type === 'external-sql-file') {
|
||
return [
|
||
{
|
||
key: 'open-external-sql-file',
|
||
label: '打开 SQL 文件',
|
||
icon: <ConsoleSqlOutlined />,
|
||
onClick: () => {
|
||
void openExternalSQLFile(node);
|
||
}
|
||
}
|
||
];
|
||
}
|
||
|
||
return [];
|
||
};
|
||
|
||
const titleRender = (node: any) => {
|
||
let status: 'success' | 'error' | 'default' = 'default';
|
||
if (node.type === 'connection' || node.type === 'database') {
|
||
if (connectionStates[node.key] === 'success') status = 'success';
|
||
else if (connectionStates[node.key] === 'error') status = 'error';
|
||
}
|
||
|
||
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
|
||
isV2Ui
|
||
? <span className={`gn-v2-tree-status is-${status}`} aria-hidden="true" />
|
||
: <Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
|
||
) : null;
|
||
|
||
const displayTitle = String(node.title ?? '');
|
||
let hoverTitle = displayTitle;
|
||
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
|
||
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || '').trim();
|
||
const conn = node?.dataRef as SavedConnection | undefined;
|
||
if (rawTableName && shouldHideSchemaPrefix(conn)) {
|
||
const lastDotIndex = rawTableName.lastIndexOf('.');
|
||
if (lastDotIndex > 0 && lastDotIndex < rawTableName.length - 1) {
|
||
hoverTitle = rawTableName;
|
||
}
|
||
}
|
||
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
|
||
hoverTitle = String(node?.dataRef?.path || displayTitle);
|
||
}
|
||
|
||
if (node.type === 'jvm-mode') {
|
||
return (
|
||
<span
|
||
title={hoverTitle}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}
|
||
>
|
||
<JVMModeBadge
|
||
mode={String(node?.dataRef?.providerMode || displayTitle)}
|
||
label={displayTitle}
|
||
reason={String(node?.dataRef?.reason || '').trim() || undefined}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (node.type === 'external-sql-root') {
|
||
return (
|
||
<span
|
||
title={hoverTitle}
|
||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}
|
||
>
|
||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{statusBadge}
|
||
{displayTitle}
|
||
</span>
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<PlusOutlined />}
|
||
title="添加外部 SQL 目录"
|
||
aria-label="添加外部 SQL 目录"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
void handleAddExternalSQLDirectory(node);
|
||
}}
|
||
style={{ paddingInline: 4, height: 20 }}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (isV2Ui) {
|
||
return renderV2TreeTitle(node, hoverTitle, statusBadge);
|
||
}
|
||
|
||
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
|
||
};
|
||
|
||
const handleDrop = (info: any) => {
|
||
const dropKey = info.node.key;
|
||
const dragKey = info.dragNode.key;
|
||
const dropPos = info.node.pos.split('-');
|
||
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
|
||
|
||
const dragNode = info.dragNode;
|
||
const dropNode = info.node;
|
||
|
||
// Tag to Tag reordering
|
||
if (dragNode.type === 'tag') {
|
||
// You can only drop tags onto the root level (before/after other tags or connections at root)
|
||
if (dropNode.type === 'tag' || dropNode.type === 'connection') {
|
||
// Get current order
|
||
const currentTagOrder = connectionTags.map(t => t.id);
|
||
const dragTagId = dragNode.dataRef.id;
|
||
|
||
// Filter out the dragging tag
|
||
const newOrder = currentTagOrder.filter(id => id !== dragTagId);
|
||
|
||
let insertIndex = newOrder.length;
|
||
if (dropNode.type === 'tag') {
|
||
const dropTagId = dropNode.dataRef.id;
|
||
const dropIndex = newOrder.indexOf(dropTagId);
|
||
|
||
if (dropPosition === -1) {
|
||
insertIndex = dropIndex;
|
||
} else {
|
||
insertIndex = dropIndex + 1;
|
||
}
|
||
} else {
|
||
// Dropped onto a root connection, usually meaning moving to the end of tags
|
||
// Since tags are always displayed before ungrouped connections, just put it at the end
|
||
insertIndex = newOrder.length;
|
||
}
|
||
|
||
newOrder.splice(insertIndex, 0, dragTagId);
|
||
reorderTags(newOrder);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Connection moving to tag (any drop position on a tag node counts as "into")
|
||
if (dragNode.type === 'connection' && dropNode.type === 'tag') {
|
||
moveConnectionToTag(dragNode.key, dropNode.dataRef.id);
|
||
return;
|
||
}
|
||
|
||
// Connection moving to another connection inside a tag
|
||
if (dragNode.type === 'connection' && dropNode.type === 'connection') {
|
||
// Find if drop target is under a tag
|
||
const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key));
|
||
if (targetTag) {
|
||
moveConnectionToTag(dragNode.key, targetTag.id);
|
||
return;
|
||
}
|
||
|
||
// Drop target is NOT under a tag (ungrouped) -> move OUT of tag
|
||
const sourceTag = connectionTags.find(t => t.connectionIds.includes(dragNode.key));
|
||
if (sourceTag) {
|
||
moveConnectionToTag(dragNode.key, null);
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
const onRightClick = ({ event, node }: any) => {
|
||
if (isV2Ui && node?.type === 'connection') {
|
||
openV2ConnectionContextMenu(event, node);
|
||
return;
|
||
}
|
||
if (isV2Ui && node?.type === 'database') {
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items: [],
|
||
kind: 'v2-database',
|
||
node,
|
||
rootClassName: 'gn-v2-table-context-menu-popup',
|
||
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }
|
||
});
|
||
return;
|
||
}
|
||
if (isV2Ui && node?.type === 'object-group' && node?.dataRef?.groupKey === 'tables') {
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items: [],
|
||
kind: 'v2-table-group',
|
||
node,
|
||
rootClassName: 'gn-v2-table-context-menu-popup',
|
||
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }
|
||
});
|
||
return;
|
||
}
|
||
if (isV2Ui && node?.type === 'table') {
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items: [],
|
||
kind: 'v2-table',
|
||
node,
|
||
rootClassName: 'gn-v2-table-context-menu-popup',
|
||
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }
|
||
});
|
||
return;
|
||
}
|
||
const items = getNodeMenuItems(node);
|
||
if (items && items.length > 0) {
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items
|
||
});
|
||
}
|
||
};
|
||
|
||
const renderV2RailConnectionButton = (conn: SavedConnection) => {
|
||
const accent = resolveConnectionAccentColor(conn);
|
||
const status = buildRailConnectionStatus(conn.id);
|
||
const label = getRailConnectionLabel(conn);
|
||
const title = `${conn.name} · ${resolveConnectionHostSummary(conn.config) || conn.config.type}`;
|
||
|
||
return (
|
||
<Tooltip key={conn.id} title={title}>
|
||
<button
|
||
type="button"
|
||
className={`gn-v2-rail-item${conn.id === activeConnectionId ? ' is-active' : ''}`}
|
||
onClick={() => selectConnectionFromRail(conn)}
|
||
onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}
|
||
aria-label={`切换到连接 ${conn.name}`}
|
||
title={title}
|
||
data-v2-rail-host-context-menu-trigger="true"
|
||
>
|
||
<span className="gn-v2-rail-active-bar" />
|
||
<span className="gn-v2-rail-badge" style={{ background: accent }}>
|
||
{label}
|
||
</span>
|
||
<span className={`gn-v2-rail-status is-${status}`} />
|
||
<span className="gn-v2-rail-fallback">{label}</span>
|
||
</button>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
const renderV2RailConnectionGroup = (group: V2RailConnectionGroup) => {
|
||
const collapsed = collapsedV2RailGroupIdSet.has(group.id);
|
||
const groupTitle = group.name || '连接';
|
||
const groupLabel = getV2RailConnectionGroupBadgeText(group.name, group.isUngrouped ? '未' : '组');
|
||
|
||
return (
|
||
<div
|
||
key={group.id}
|
||
className={`gn-v2-rail-group${group.isUngrouped ? ' is-ungrouped' : ''}${collapsed ? ' is-collapsed' : ''}`}
|
||
data-v2-rail-connection-group="true"
|
||
>
|
||
{hasV2RailConnectionGroups && (
|
||
<Tooltip title={`${groupTitle} · ${group.connections.length} 个连接`}>
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-group-header"
|
||
onClick={() => toggleV2RailConnectionGroup(group.id)}
|
||
onContextMenu={(event) => {
|
||
if (group.isUngrouped) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setContextMenu({
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
items: [],
|
||
kind: 'v2-connection-group',
|
||
node: group,
|
||
rootClassName: 'gn-v2-table-context-menu-popup',
|
||
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
|
||
});
|
||
}}
|
||
aria-label={`${collapsed ? '展开' : '折叠'}连接分组 ${groupTitle}`}
|
||
aria-expanded={!collapsed}
|
||
title={`${groupTitle} · ${group.connections.length} 个连接`}
|
||
data-v2-rail-connection-group-header="true"
|
||
>
|
||
<span className="gn-v2-rail-group-chevron">
|
||
<DownOutlined />
|
||
</span>
|
||
<span className="gn-v2-rail-group-badge">{groupLabel}</span>
|
||
<span className="gn-v2-rail-group-count">{group.connections.length}</span>
|
||
</button>
|
||
</Tooltip>
|
||
)}
|
||
{!collapsed && (
|
||
<div className="gn-v2-rail-group-items">
|
||
{group.connections.map(renderV2RailConnectionButton)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderV2ConnectionRail = () => (
|
||
<div className="gn-v2-connection-rail" aria-label="连接切换">
|
||
<div className="gn-v2-rail-items">
|
||
{v2RailConnectionGroups.map(renderV2RailConnectionGroup)}
|
||
<Tooltip title="新建连接">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-item gn-v2-rail-add"
|
||
onClick={onCreateConnection}
|
||
aria-label="新建连接"
|
||
data-gonavi-create-connection-action="true"
|
||
>
|
||
<PlusOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
<div className="gn-v2-rail-footer">
|
||
<div className="gn-v2-rail-action-group" aria-label="左侧快捷操作">
|
||
<Tooltip title="新建组">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool gn-v2-rail-action"
|
||
onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }}
|
||
aria-label="新建组"
|
||
data-sidebar-create-group-action="true"
|
||
>
|
||
<FolderOpenOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="批量操作表">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool gn-v2-rail-action"
|
||
onClick={() => openBatchOperationModal()}
|
||
aria-label="批量操作表"
|
||
data-sidebar-batch-table-action="true"
|
||
>
|
||
<TableOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="批量操作库">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool gn-v2-rail-action"
|
||
onClick={() => openBatchDatabaseModal()}
|
||
aria-label="批量操作库"
|
||
data-sidebar-batch-database-action="true"
|
||
>
|
||
<DatabaseOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="运行外部SQL文件">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool gn-v2-rail-action"
|
||
onClick={handleOpenSQLFileFromToolbar}
|
||
aria-label="运行外部 SQL 文件"
|
||
data-sidebar-open-external-sql-file-action="true"
|
||
>
|
||
<FileAddOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
|
||
<span className="gn-v2-rail-action-wrap">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool gn-v2-rail-action"
|
||
onClick={handleLocateActiveTabInSidebar}
|
||
aria-label="定位当前打开表"
|
||
data-sidebar-locate-current-tab-action="true"
|
||
disabled={!canLocateActiveTab}
|
||
>
|
||
<AimOutlined />
|
||
</button>
|
||
</span>
|
||
</Tooltip>
|
||
</div>
|
||
<div className="gn-v2-rail-system-actions" aria-label="系统操作">
|
||
<Tooltip title="AI 助手">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool"
|
||
onClick={onToggleAI}
|
||
aria-label="AI 助手"
|
||
data-gonavi-ai-entry-action="true"
|
||
>
|
||
<RobotOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="工具">
|
||
<button
|
||
type="button"
|
||
className="gn-v2-rail-tool"
|
||
onClick={onOpenTools}
|
||
aria-label="工具"
|
||
data-gonavi-open-tools-action="true"
|
||
>
|
||
<ToolOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="设置">
|
||
<button type="button" className="gn-v2-rail-tool" onClick={onOpenSettings} aria-label="设置">
|
||
<SettingOutlined />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className={isV2Ui ? 'gn-v2-sidebar-redesign' : undefined} style={{ display: 'flex', height: '100%', minHeight: 0 }}>
|
||
{isV2Ui && renderV2ConnectionRail()}
|
||
<div className={isV2Ui ? 'gn-v2-object-explorer' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0, flex: 1 }}>
|
||
{isV2Ui && (
|
||
<div className="gn-v2-active-connection-header" data-object-count={activeConnectionObjectCount}>
|
||
<span className={`gn-v2-live-dot is-${activeConnection ? buildRailConnectionStatus(activeConnection.id) : 'idle'}`} />
|
||
<div className="gn-v2-active-connection-copy">
|
||
<strong>{activeConnection?.name || '未选择连接'}</strong>
|
||
<span>{activeConnectionHostSummary}</span>
|
||
</div>
|
||
<Tooltip title="连接操作">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<MoreOutlined />}
|
||
aria-label="连接操作"
|
||
disabled={!activeConnection}
|
||
onClick={(event) => {
|
||
if (activeConnection) {
|
||
openV2ConnectionContextMenu(event, activeConnection);
|
||
}
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
<div className={isV2Ui ? 'gn-v2-explorer-search' : undefined} style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||
{isV2Ui ? (
|
||
<button
|
||
type="button"
|
||
className="gn-v2-explorer-command-trigger"
|
||
onClick={() => {
|
||
openV2CommandSearch();
|
||
onFocusCommandSearch?.();
|
||
}}
|
||
aria-label="搜索表、连接、动作"
|
||
>
|
||
<SearchOutlined />
|
||
<span>搜索表、连接、动作... 或问 AI</span>
|
||
<span className="gn-v2-search-shortcut" aria-hidden="true">
|
||
<kbd>⌘</kbd>
|
||
<kbd>K</kbd>
|
||
</span>
|
||
</button>
|
||
) : (
|
||
<Input
|
||
{...noAutoCapInputProps}
|
||
ref={searchInputRef}
|
||
placeholder="搜索..."
|
||
onChange={onSearch}
|
||
size="small"
|
||
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)' }} />}
|
||
style={{
|
||
borderRadius: 6,
|
||
border: 'none',
|
||
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
|
||
boxShadow: 'none',
|
||
padding: '4px 8px',
|
||
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
|
||
}}
|
||
suffix={(
|
||
<Popover
|
||
content={searchScopePopoverContent}
|
||
trigger="click"
|
||
placement="bottomRight"
|
||
open={isSearchScopePopoverOpen}
|
||
onOpenChange={setIsSearchScopePopoverOpen}
|
||
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
|
||
>
|
||
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
cursor: 'pointer',
|
||
padding: '2px 6px',
|
||
borderRadius: 4,
|
||
background: isSearchScopePopoverOpen
|
||
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
|
||
: 'transparent',
|
||
transition: 'background 0.2s',
|
||
color: searchScopes.includes('smart')
|
||
? (darkMode ? '#ffd666' : '#1677ff')
|
||
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSearchScopePopoverOpen) {
|
||
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSearchScopePopoverOpen) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
e.currentTarget.style.color = searchScopes.includes('smart')
|
||
? (darkMode ? '#ffd666' : '#1677ff')
|
||
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
|
||
}
|
||
}}
|
||
>
|
||
<FilterOutlined style={{ fontSize: 13 }} />
|
||
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||
{searchScopes.includes('smart') ? '智' : searchScopes.length}
|
||
</span>
|
||
</div>
|
||
</Tooltip>
|
||
</Popover>
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{isV2Ui && (
|
||
<div className="gn-v2-explorer-filter-tabs" aria-label="对象筛选">
|
||
{V2_EXPLORER_FILTER_OPTIONS.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
className={v2ExplorerFilter === item.key ? 'is-active' : undefined}
|
||
aria-pressed={v2ExplorerFilter === item.key}
|
||
onClick={() => setV2ExplorerFilter(item.key)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Toolbar */}
|
||
{!isV2Ui && (
|
||
<div style={{ padding: '6px 16px', display: 'flex', gap: 8, justifyContent: 'space-between', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)' }}>
|
||
<Tooltip title="新建组">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<FolderOpenOutlined />}
|
||
aria-label="新建组"
|
||
data-sidebar-create-group-action="true"
|
||
onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }}
|
||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="批量操作表">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<TableOutlined />}
|
||
aria-label="批量操作表"
|
||
data-sidebar-batch-table-action="true"
|
||
onClick={() => openBatchOperationModal()}
|
||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="批量操作库">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<DatabaseOutlined />}
|
||
aria-label="批量操作库"
|
||
data-sidebar-batch-database-action="true"
|
||
onClick={() => openBatchDatabaseModal()}
|
||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="运行外部SQL文件">
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<FileAddOutlined />}
|
||
aria-label="运行外部 SQL 文件"
|
||
data-sidebar-open-external-sql-file-action="true"
|
||
onClick={handleOpenSQLFileFromToolbar}
|
||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
|
||
<span>
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<AimOutlined />}
|
||
aria-label="定位当前打开表"
|
||
data-sidebar-locate-current-tab-action="true"
|
||
disabled={!canLocateActiveTab}
|
||
onClick={handleLocateActiveTabInSidebar}
|
||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||
/>
|
||
</span>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={treeContainerRef} className={`sidebar-tree-scroll-shell${isV2Ui ? ' gn-v2-explorer-tree-shell' : ''}`} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||
<div className="sidebar-tree-scroll-content">
|
||
<Tree
|
||
ref={treeRef}
|
||
showIcon
|
||
draggable={{
|
||
icon: false,
|
||
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
|
||
}}
|
||
onDrop={handleDrop}
|
||
loadData={onLoadData}
|
||
treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}
|
||
onDoubleClick={onDoubleClick}
|
||
onSelect={onSelect}
|
||
titleRender={titleRender}
|
||
expandedKeys={expandedKeys}
|
||
onExpand={onExpand}
|
||
loadedKeys={loadedKeys}
|
||
onLoad={setLoadedKeys}
|
||
autoExpandParent={autoExpandParent}
|
||
selectedKeys={selectedKeys}
|
||
blockNode
|
||
height={treeHeight}
|
||
onRightClick={onRightClick}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isV2Ui && (
|
||
<div className="gn-v2-sidebar-log-footer">
|
||
<button type="button" className="gn-v2-sidebar-log-button" onClick={onToggleLogPanel}>
|
||
<BarsOutlined />
|
||
<span>SQL 执行日志</span>
|
||
<small>{sqlLogCount.toLocaleString()}</small>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{renderV2CommandSearchOverlay()}
|
||
|
||
{contextMenu && (
|
||
<Dropdown
|
||
menu={{ items: contextMenu.items }}
|
||
open={true}
|
||
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
||
trigger={['contextMenu']}
|
||
popupRender={(() => {
|
||
if (!contextMenu.node) return undefined;
|
||
if (contextMenu.kind === 'v2-table') return () => renderV2TableContextMenu(contextMenu.node);
|
||
if (contextMenu.kind === 'v2-database') return () => renderV2DatabaseContextMenu(contextMenu.node);
|
||
if (contextMenu.kind === 'v2-table-group') return () => renderV2TableGroupContextMenu(contextMenu.node);
|
||
if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);
|
||
if (contextMenu.kind === 'v2-connection-group') return () => renderV2ConnectionGroupContextMenu(contextMenu.node);
|
||
return undefined;
|
||
})()}
|
||
rootClassName={contextMenu.rootClassName}
|
||
overlayStyle={contextMenu.overlayStyle}
|
||
>
|
||
<div style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, width: 1, height: 1 }} />
|
||
</Dropdown>
|
||
)}
|
||
|
||
<Modal
|
||
title={renderSidebarModalTitle(
|
||
<FolderOpenOutlined />,
|
||
renameViewTarget?.type === 'tag' ? "编辑标签" : "新建组",
|
||
renameViewTarget?.type === 'tag' ? "调整分组名称和包含的连接。" : "为连接树创建一个更清晰的分组视图。"
|
||
)}
|
||
open={isCreateTagModalOpen}
|
||
centered
|
||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||
onOk={() => {
|
||
createTagForm.validateFields().then(values => {
|
||
if (renameViewTarget?.type === 'tag') {
|
||
// Rename
|
||
updateConnectionTag({
|
||
...renameViewTarget.dataRef,
|
||
name: values.name,
|
||
connectionIds: values.connectionIds || []
|
||
});
|
||
// update cross-connections
|
||
const allOtherTagsIds = connectionTags.filter(t => t.id !== renameViewTarget.dataRef.id).flatMap(t => t.connectionIds);
|
||
(values.connectionIds || []).forEach((cid: string) => {
|
||
if (allOtherTagsIds.includes(cid)) {
|
||
moveConnectionToTag(cid, renameViewTarget.dataRef.id);
|
||
}
|
||
});
|
||
} else {
|
||
// Create
|
||
const tagId = Date.now().toString();
|
||
addConnectionTag({
|
||
id: tagId,
|
||
name: values.name,
|
||
connectionIds: values.connectionIds || []
|
||
});
|
||
(values.connectionIds || []).forEach((cid: string) => {
|
||
moveConnectionToTag(cid, tagId);
|
||
});
|
||
}
|
||
setIsCreateTagModalOpen(false);
|
||
});
|
||
}}
|
||
onCancel={() => setIsCreateTagModalOpen(false)}
|
||
>
|
||
<Form form={createTagForm} layout="vertical">
|
||
<div style={modalSectionStyle}>
|
||
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
|
||
<Input placeholder="例如:线上环境 / 核心业务 / 临时调试" />
|
||
</Form.Item>
|
||
<Form.Item name="connectionIds" label="选择连接" style={{ marginBottom: 0 }}>
|
||
<Checkbox.Group style={{ width: '100%' }}>
|
||
<div style={modalScrollSectionStyle}>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
{connections.map(conn => (
|
||
<Checkbox key={conn.id} value={conn.id}>
|
||
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
</Checkbox.Group>
|
||
</Form.Item>
|
||
</div>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="新建数据库"
|
||
open={isCreateDbModalOpen}
|
||
onOk={handleCreateDatabase}
|
||
onCancel={() => setIsCreateDbModalOpen(false)}
|
||
>
|
||
<Form form={createDbForm} layout="vertical">
|
||
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||
<Input {...noAutoCapInputProps} />
|
||
</Form.Item>
|
||
{/* Charset option could be added here */}
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`新建模式${createSchemaTarget?.dataRef?.dbName ? ` (${createSchemaTarget.dataRef.dbName})` : ''}`}
|
||
open={isCreateSchemaModalOpen}
|
||
onOk={handleCreateSchema}
|
||
onCancel={() => {
|
||
setIsCreateSchemaModalOpen(false);
|
||
setCreateSchemaTarget(null);
|
||
createSchemaForm.resetFields();
|
||
}}
|
||
>
|
||
<Form form={createSchemaForm} layout="vertical">
|
||
<Form.Item name="name" label="模式名称" rules={[{ required: true, message: '请输入模式名称' }]}>
|
||
<Input {...noAutoCapInputProps} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`重命名数据库${renameDbTarget?.dataRef?.dbName ? ` (${renameDbTarget.dataRef.dbName})` : ''}`}
|
||
open={isRenameDbModalOpen}
|
||
onOk={handleRenameDatabase}
|
||
onCancel={() => {
|
||
setIsRenameDbModalOpen(false);
|
||
setRenameDbTarget(null);
|
||
renameDbForm.resetFields();
|
||
}}
|
||
>
|
||
<Form form={renameDbForm} layout="vertical">
|
||
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||
<Input {...noAutoCapInputProps} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`重命名表${renameTableTarget?.dataRef?.tableName ? ` (${renameTableTarget.dataRef.tableName})` : ''}`}
|
||
open={isRenameTableModalOpen}
|
||
onOk={handleRenameTable}
|
||
onCancel={() => {
|
||
setIsRenameTableModalOpen(false);
|
||
setRenameTableTarget(null);
|
||
renameTableForm.resetFields();
|
||
}}
|
||
>
|
||
<Form form={renameTableForm} layout="vertical">
|
||
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||
<Input {...noAutoCapInputProps} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`重命名视图${renameViewTarget?.dataRef?.viewName ? ` (${renameViewTarget.dataRef.viewName})` : ''}`}
|
||
open={isRenameViewModalOpen}
|
||
onOk={handleRenameView}
|
||
onCancel={() => {
|
||
setIsRenameViewModalOpen(false);
|
||
setRenameViewTarget(null);
|
||
renameViewForm.resetFields();
|
||
}}
|
||
>
|
||
<Form form={renameViewForm} layout="vertical">
|
||
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
|
||
<Input {...noAutoCapInputProps} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={renderSidebarModalTitle(<TableOutlined />, "批量操作表", "按对象批量导出结构、数据或完整备份。")}
|
||
open={isBatchModalOpen}
|
||
onCancel={() => setIsBatchModalOpen(false)}
|
||
width={720}
|
||
centered
|
||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||
footer={
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
|
||
<Button
|
||
key="clear"
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
onClick={() => handleBatchClear()}
|
||
disabled={checkedTableKeys.length === 0}
|
||
>
|
||
清空表
|
||
</Button>
|
||
<Button
|
||
key="export-schema"
|
||
icon={<ExportOutlined />}
|
||
onClick={() => handleBatchExport('schema')}
|
||
disabled={checkedTableKeys.length === 0}
|
||
>
|
||
导出结构
|
||
</Button>
|
||
<Button
|
||
key="export-data-only"
|
||
icon={<SaveOutlined />}
|
||
onClick={() => handleBatchExport('dataOnly')}
|
||
disabled={checkedTableKeys.length === 0}
|
||
>
|
||
仅数据(INSERT)
|
||
</Button>
|
||
<Button
|
||
key="backup"
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
onClick={() => handleBatchExport('backup')}
|
||
disabled={checkedTableKeys.length === 0}
|
||
>
|
||
备份(结构+数据)
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
}
|
||
>
|
||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择连接:</label>
|
||
<Select
|
||
value={selectedConnection}
|
||
onChange={handleConnectionChange}
|
||
style={{ width: '100%' }}
|
||
placeholder="请选择连接"
|
||
>
|
||
{connections.filter(c => c.config.type !== 'redis').map(conn => (
|
||
<Select.Option key={conn.id} value={conn.id}>
|
||
{conn.name}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择数据库:</label>
|
||
<Select
|
||
value={selectedDatabase}
|
||
onChange={handleDatabaseChange}
|
||
style={{ width: '100%' }}
|
||
placeholder="请先选择连接"
|
||
disabled={!selectedConnection}
|
||
>
|
||
{availableDatabases.map(db => (
|
||
<Select.Option key={db.key} value={db.dbName}>
|
||
{db.title}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div style={modalHintTextStyle}>先选择连接与数据库,再决定导出范围和目标对象。</div>
|
||
</div>
|
||
|
||
{batchTables.length > 0 && (
|
||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||
<Space wrap size={8} style={{ width: '100%' }}>
|
||
<Input
|
||
allowClear
|
||
value={batchFilterKeyword}
|
||
onChange={(e) => setBatchFilterKeyword(e.target.value)}
|
||
placeholder="筛选表/视图名称"
|
||
prefix={<SearchOutlined />}
|
||
style={{ width: 260 }}
|
||
/>
|
||
<Select
|
||
value={batchFilterType}
|
||
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
|
||
style={{ width: 140 }}
|
||
options={[
|
||
{ label: '全部对象', value: 'all' },
|
||
{ label: '仅表', value: 'table' },
|
||
{ label: '仅视图', value: 'view' },
|
||
]}
|
||
/>
|
||
<Select
|
||
value={batchSelectionScope}
|
||
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
|
||
style={{ width: 220 }}
|
||
options={[
|
||
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
|
||
{ label: '勾选作用于:全部对象', value: 'all' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
|
||
当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{batchTables.length > 0 && (
|
||
<>
|
||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
onClick={() => handleCheckAll(true)}
|
||
disabled={selectionScopeTargetKeys.length === 0}
|
||
>
|
||
全选
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={() => handleCheckAll(false)}
|
||
disabled={selectionScopeTargetKeys.length === 0}
|
||
>
|
||
取消全选
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={handleInvertSelection}
|
||
disabled={selectionScopeTargetKeys.length === 0}
|
||
>
|
||
反选
|
||
</Button>
|
||
<span style={{ color: '#999' }}>
|
||
已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
|
||
</span>
|
||
</Space>
|
||
</div>
|
||
<div style={modalScrollSectionStyle}>
|
||
<Checkbox.Group
|
||
value={checkedTableKeys}
|
||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{groupedBatchObjects.tables.length > 0 && (
|
||
<div>
|
||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||
表 ({groupedBatchObjects.tables.length})
|
||
</div>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
{groupedBatchObjects.tables.map(table => (
|
||
<Checkbox key={table.key} value={table.key}>
|
||
<TableOutlined style={{ marginRight: 8 }} />
|
||
{table.title}
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
)}
|
||
{groupedBatchObjects.views.length > 0 && (
|
||
<div>
|
||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||
视图 ({groupedBatchObjects.views.length})
|
||
</div>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
{groupedBatchObjects.views.map(view => (
|
||
<Checkbox key={view.key} value={view.key}>
|
||
<EyeOutlined style={{ marginRight: 8 }} />
|
||
{view.title}
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
)}
|
||
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
|
||
<div style={{ color: '#999', padding: '8px 0' }}>
|
||
无匹配对象
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Checkbox.Group>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={renderSidebarModalTitle(<DatabaseOutlined />, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")}
|
||
open={isBatchDbModalOpen}
|
||
onCancel={() => setIsBatchDbModalOpen(false)}
|
||
width={640}
|
||
centered
|
||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||
footer={[
|
||
<Button key="cancel" onClick={() => setIsBatchDbModalOpen(false)}>
|
||
取消
|
||
</Button>,
|
||
<Button
|
||
key="export-schema"
|
||
icon={<ExportOutlined />}
|
||
onClick={() => handleBatchDbExport(false)}
|
||
disabled={checkedDbKeys.length === 0}
|
||
>
|
||
导出库结构 ({checkedDbKeys.length})
|
||
</Button>,
|
||
<Button
|
||
key="backup"
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
onClick={() => handleBatchDbExport(true)}
|
||
disabled={checkedDbKeys.length === 0}
|
||
>
|
||
备份库 ({checkedDbKeys.length})
|
||
</Button>
|
||
]}
|
||
>
|
||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>选择连接:</label>
|
||
<Select
|
||
value={selectedDbConnection}
|
||
onChange={handleDbConnectionChange}
|
||
style={{ width: '100%' }}
|
||
placeholder="请选择连接"
|
||
>
|
||
{connections.filter(c => c.config.type !== 'redis').map(conn => (
|
||
<Select.Option key={conn.id} value={conn.id}>
|
||
{conn.name}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
<div style={{ ...modalHintTextStyle, marginTop: 10 }}>连接选定后会加载当前连接下可批量导出的数据库列表。</div>
|
||
</div>
|
||
|
||
{batchDatabases.length > 0 && (
|
||
<>
|
||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
onClick={() => handleCheckAllDb(true)}
|
||
>
|
||
全选
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={() => handleCheckAllDb(false)}
|
||
>
|
||
取消全选
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={handleInvertSelectionDb}
|
||
>
|
||
反选
|
||
</Button>
|
||
<span style={{ color: '#999' }}>
|
||
已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库
|
||
</span>
|
||
</Space>
|
||
</div>
|
||
<div style={modalScrollSectionStyle}>
|
||
<Checkbox.Group
|
||
value={checkedDbKeys}
|
||
onChange={(values) => setCheckedDbKeys(values as string[])}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
{batchDatabases.map(db => (
|
||
<Checkbox key={db.key} value={db.key}>
|
||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||
{db.title}
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
</Checkbox.Group>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* SQL 文件流式执行进度 Modal */}
|
||
<Modal
|
||
title="运行外部SQL文件"
|
||
open={sqlFileExecState.open}
|
||
centered
|
||
closable={sqlFileExecState.status !== 'running'}
|
||
maskClosable={false}
|
||
footer={sqlFileExecState.status === 'running' ? [
|
||
<Button key="cancel" danger onClick={() => {
|
||
CancelSQLFileExecution(sqlFileExecState.jobId);
|
||
setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' }));
|
||
}}>
|
||
取消执行
|
||
</Button>
|
||
] : [
|
||
<Button key="close" type="primary" onClick={() => setSqlFileExecState(prev => ({ ...prev, open: false }))}>
|
||
关闭
|
||
</Button>
|
||
]}
|
||
onCancel={() => {
|
||
if (sqlFileExecState.status !== 'running') {
|
||
setSqlFileExecState(prev => ({ ...prev, open: false }));
|
||
}
|
||
}}
|
||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
|
||
>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Progress
|
||
percent={Math.round(sqlFileExecState.percent)}
|
||
status={sqlFileExecState.status === 'error' ? 'exception' : sqlFileExecState.status === 'done' ? 'success' : 'active'}
|
||
strokeColor={sqlFileExecState.status === 'cancelled' ? '#faad14' : undefined}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 13, lineHeight: '22px', marginBottom: 8 }}>
|
||
<div>文件大小:<strong>{sqlFileExecState.fileSizeMB} MB</strong></div>
|
||
<div>状态:<strong>{
|
||
sqlFileExecState.status === 'running' ? '执行中...' :
|
||
sqlFileExecState.status === 'done' ? '✅ 完成' :
|
||
sqlFileExecState.status === 'cancelled' ? '⚠️ 已取消' : '❌ 出错'
|
||
}</strong></div>
|
||
<div>已执行:<strong style={{ color: '#52c41a' }}>{sqlFileExecState.executed}</strong> 条 | 失败:<strong style={{ color: sqlFileExecState.failed > 0 ? '#ff4d4f' : undefined }}>{sqlFileExecState.failed}</strong> 条</div>
|
||
</div>
|
||
{sqlFileExecState.currentSQL && sqlFileExecState.status === 'running' && (
|
||
<div style={{ fontSize: 12, color: 'rgba(128,128,128,0.8)', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '6px 10px', marginTop: 8, fontFamily: 'monospace', wordBreak: 'break-all', maxHeight: 60, overflow: 'hidden' }}>
|
||
{sqlFileExecState.currentSQL}
|
||
</div>
|
||
)}
|
||
{sqlFileExecState.resultMessage && sqlFileExecState.status !== 'running' && (
|
||
<div style={{ fontSize: 12, marginTop: 12, maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '8px 12px' }}>
|
||
{sqlFileExecState.resultMessage}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
<FindInDatabaseModal
|
||
open={findInDbContext.open}
|
||
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
|
||
connectionId={findInDbContext.connectionId}
|
||
dbName={findInDbContext.dbName}
|
||
/>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default Sidebar;
|