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 | 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 | 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 | 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 = ( entries: T[], options: { connectionId: string; dbName: string; sortBy: SidebarTableSortPreference; tableAccessCount?: Record; 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(); 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, 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 } | 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 = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { acc[option.value] = option.label; return acc; }, {} as Record); const SEARCH_SCOPE_ICON_MAP: Record = { smart: , object: , database: , host: , tag: , }; 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([]); 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) => (
{icon}
{title}
{description}
); const [searchValue, setSearchValue] = useState(''); const deferredSearchValue = useDeferredValue(searchValue); const [searchScopes, setSearchScopes] = useState(['smart']); const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all'); const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false); const searchInputRef = useRef(null); const commandSearchInputRef = useRef(null); const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false); const [v2CommandSearchValue, setV2CommandSearchValue] = useState(''); const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0); const [expandedKeys, setExpandedKeys] = useState([]); const [autoExpandParent, setAutoExpandParent] = useState(true); const [loadedKeys, setLoadedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); const clickTimerRef = useRef | null>(null); const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record } | null>(null); const driverUpdateWarningKeysRef = useRef>(new Set()); const connectionReloadSignaturesRef = useRef>({}); const [contextMenu, setContextMenu] = useState(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const v2RailConnectionGroups = useMemo( () => buildV2RailConnectionGroups(connections, connectionTags), [connections, connectionTags], ); const [collapsedV2RailGroupIds, setCollapsedV2RailGroupIds] = useState([]); 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(null); const treeRef = useRef(null); const treeDataRef = useRef([]); 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>({}); // Create Database Modal const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(null); const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false); const [createSchemaForm] = Form.useForm(); const [createSchemaTarget, setCreateSchemaTarget] = useState(null); const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false); const [renameDbForm] = Form.useForm(); const [renameDbTarget, setRenameDbTarget] = useState(null); const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false); const [renameTableForm] = Form.useForm(); const [renameTableTarget, setRenameTableTarget] = useState(null); const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false); const [renameViewForm] = Form.useForm(); const [renameViewTarget, setRenameViewTarget] = useState(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([]); const [checkedTableKeys, setCheckedTableKeys] = useState([]); const [batchDbContext, setBatchDbContext] = useState(null); const [selectedConnection, setSelectedConnection] = useState(''); const [selectedDatabase, setSelectedDatabase] = useState(''); const [availableDatabases, setAvailableDatabases] = useState([]); const [batchFilterKeyword, setBatchFilterKeyword] = useState(''); const [batchFilterType, setBatchFilterType] = useState('all'); const [batchSelectionScope, setBatchSelectionScope] = useState('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([]); const [checkedDbKeys, setCheckedDbKeys] = useState([]); const [batchConnContext, setBatchConnContext] = useState(null); const [selectedDbConnection, setSelectedDbConnection] = useState(''); // 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 = {}; const staleConnectionIds = new Set(); 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(); // 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(); const tagNodes: TreeNode[] = connectionTags.map((tag) => { tag.connectionIds.forEach(id => taggedConnIds.add(id)); return { title: tag.name, key: `tag-${tag.id}`, icon: , 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 ; case 'external-sql-directory': return ; case 'external-sql-folder': return ; default: return ; } })(); 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[]; 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(); 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, candidateKeys: string[]): string => { const keyMap = new Map(); 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, candidateKeys: string[]): any => { const keyMap = new Map(); 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 => { 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 => { 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() !== ''); } 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() !== ''); }; 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[], 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(); 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(); 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(); 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(); 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(); 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> => { const cached = driverStatusCacheRef.current; if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { return cached.items; } const result: Record = {}; 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: , 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: , 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: , 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: , 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 ? : , 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: , type: 'queries-folder', isLeaf: dbQueries.length === 0, children: dbQueries.map(q => ({ title: q.name, key: q.id, icon: , 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>((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(); 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 ? : , 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: , 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: , 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: , 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: , 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 = {} ): 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(); 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', '表', , bucket.tables, { schemaName: bucket.schemaName }), buildObjectGroup(schemaNodeKey, 'views', '视图', , bucket.views, { schemaName: bucket.schemaName }), ...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', '物化视图', , bucket.materializedViews, { schemaName: bucket.schemaName })] : []), buildObjectGroup(schemaNodeKey, 'routines', '函数', , bucket.routines, { schemaName: bucket.schemaName }), buildObjectGroup(schemaNodeKey, 'triggers', '触发器', , bucket.triggers, { schemaName: bucket.schemaName }), ]; return { title: schemaTitle, key: schemaNodeKey, icon: , 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', '表', , sortedTableEntries.map(buildTableNode)), buildObjectGroup(key as string, 'views', '视图', , viewEntries.map(buildViewNode)), ...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', '物化视图', , materializedViewEntries.map(buildMaterializedViewNode))] : []), buildObjectGroup(key as string, 'routines', '函数', , routineEntries.map(buildRoutineNode)), buildObjectGroup(key as string, 'triggers', '触发器', , 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>(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: , type: 'folder-columns', isLeaf: true, dataRef: conn }, { title: '索引', key: `${key}-indexes`, icon: , type: 'folder-indexes', isLeaf: true, dataRef: conn }, { title: '外键', key: `${key}-fks`, icon: , type: 'folder-fks', isLeaf: true, dataRef: conn }, { title: '触发器', key: `${key}-triggers`, icon: , 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((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 : {}; 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: , 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((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; 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; 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; 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) => { 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 (
搜索范围
“智能”自动匹配最可能的命中项;手动模式支持按维度组合筛选。
手动范围
支持多选组合
{scopedOptions.map((option) => { const checked = searchScopes.includes(option.value); return ( ); })}
智能与其他项互斥。若你明确知道要搜的是对象、库、Host 或标签,建议切到手动范围以减少噪音结果。
); }, [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: , 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' ? : , node, }); } if (node.children) visit(node.children); }); }; visit(treeData); return result; }, [connections, treeData]); const commandSearchRecentItems = useMemo(() => { 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: , sql: log.sql, dbName: log.dbName, })); }, [sqlLogs]); const commandSearchActionItems = useMemo(() => [ { key: 'action-new-query', kind: 'action', title: '新建查询', meta: '打开一个新的 SQL 编辑页', shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform), icon: , onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')), }, { key: 'action-new-connection', kind: 'action', title: '新建数据源', meta: '创建数据库、运行时或其他数据源连接', shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform), icon: , onRun: () => onCreateConnection?.(), }, { key: 'action-open-ai', kind: 'action', title: '打开 AI 数据洞察', meta: '让 AI 分析当前数据库上下文', shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform), icon: , onRun: () => onToggleAI?.(), }, { key: 'action-open-sql-log', kind: 'action', title: '查看 SQL 执行日志', meta: '打开最近执行记录面板', shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform), icon: , 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(() => { if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return []; return [{ key: 'action-ask-ai', kind: 'action', title: '让 AI 回答', meta: v2CommandSearchQuery.aiPrompt, shortcut: '↵', icon: , 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(); const objectGroupCounts = new Map(); 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(); 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, 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 ( { 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 ( { setContextMenu(null); handleV2TableGroupContextMenuAction(node, action); }} /> ); }; const renderV2DatabaseContextMenu = (node: any) => { const dialect = getMetadataDialect(node.dataRef as SavedConnection); return ( { 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 ( ({ id: tag.id, name: tag.name, selected: tag.id === currentTagId, }))} onAction={(action) => { setContextMenu(null); handleV2ConnectionContextMenuAction(node, action); }} /> ); }; const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => ( { 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; 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 ? ( 全部 ) : null; const titleClassName = [ 'gn-v2-tree-title', isMono ? 'is-mono' : '', node.type === 'object-group' ? 'is-group' : '', ].filter(Boolean).join(' '); return ( <> {statusBadge} {displayTitle} {metaText && {metaText}} {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) => { 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) => ( ); const renderV2CommandSearchSection = (title: string, items: V2CommandSearchItem[]) => { if (items.length === 0) return null; return (
{title}
{items.map((item) => renderV2CommandSearchRow( item, commandSearchFlatItems[v2CommandActiveIndex]?.key === item.key, ))}
); }; const renderV2CommandSearchOverlay = () => { if (!isV2CommandSearchOpen) return null; const emptyCopy = v2CommandSearchAiMode ? '输入「?」后加问题,按 Enter 发送到 AI 面板。' : (v2CommandSearchObjectMode ? '未找到匹配的表、视图或物化视图。' : '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。'); return (
event.stopPropagation()}>
setV2CommandSearchValue(event.target.value)} onKeyDown={handleV2CommandSearchKeyDown} placeholder="搜索表、连接、动作... 或问 AI" /> esc
{renderV2CommandSearchSection('跳转 · GO TO', filteredCommandSearchTreeItems)} {renderV2CommandSearchSection('AI · ASK', commandSearchAiItem)} {renderV2CommandSearchSection('动作 · ACTIONS', filteredCommandSearchActionItems)} {renderV2CommandSearchSection('近期查询 · RECENT', filteredCommandSearchRecentItems)} {commandSearchFlatItems.length === 0 ? (
{emptyCopy}
) : null}
导航 选择 @只搜表对象 ?发送给 AI
); }; 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: , onClick: () => openNewTableDesign(node) }, { type: 'divider' }, { key: 'sort-by-name', label: '按名称排序', icon: currentSort === 'name' ? : null, onClick: () => handleTableGroupSortAction(node, 'name') }, { key: 'sort-by-frequency', label: '按使用频率排序', icon: currentSort === 'frequency' ? : null, onClick: () => handleTableGroupSortAction(node, 'frequency') } ]; } // 视图分组节点的右键菜单 if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') { return [ { key: 'create-view', label: '新建视图', icon: , onClick: () => openCreateView(node) }, ]; } if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') { return [ { key: 'create-materialized-view', label: '新建物化视图', icon: , 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: , onClick: () => openCreateRoutine(node, 'FUNCTION') }, ]; if (dialect !== 'duckdb') { routineMenu.push({ key: 'create-procedure', label: '新建存储过程', icon: , 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: , onClick: () => { createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); setRenameViewTarget(node); setIsCreateTagModalOpen(true); } }, { type: 'divider' }, { key: 'delete-tag', label: '删除标签', icon: , 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: , 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: , 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: , 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: , onClick: () => { if (onEditConnection) onEditConnection(node.dataRef); } }, { key: 'copy-connection', label: '复制连接', icon: , onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) }, { key: 'disconnect', label: '断开连接', icon: , 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: , 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: , 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: , onClick: () => { setTargetConnection(node); setIsCreateDbModalOpen(true); } }, { key: 'refresh', label: '刷新', icon: , 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: , onClick: () => { addTab({ id: `query-${Date.now()}`, title: `新建查询`, type: 'query', connectionId: node.key, dbName: undefined, query: '' }); } }, { key: 'open-sql-file', label: '运行外部SQL文件', icon: , onClick: () => handleRunSQLFile(node) }, { type: 'divider' }, { key: 'edit', label: '编辑连接', icon: , onClick: () => { if (onEditConnection) onEditConnection(node.dataRef); } }, { key: 'copy-connection', label: '复制连接', icon: , onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) }, { key: 'move-to-tag', label: '移至标签', icon: , children: tagSubMenuItems }, { key: 'disconnect', label: '断开连接', icon: , 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: , 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: , onClick: () => { addTab({ id: `redis-keys-${id}-db${redisDB}`, title: `db${redisDB}`, type: 'redis-keys', connectionId: id, redisDB: redisDB }); } }, { key: 'new-command', label: '新建命令窗口', icon: , 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: , 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: , onClick: () => openNewTableDesign(node) }, ...(supportsSchemaActions ? [ { key: 'new-schema', label: '新建模式', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') }, ] : []), ...(isStarRocks ? [ { key: 'new-materialized-view', label: '新建物化视图', icon: , onClick: () => openCreateStarRocksMaterializedView(node) }, { key: 'new-external-catalog', label: '新建外部 Catalog', icon: , onClick: () => openCreateStarRocksExternalCatalog(node) }, ] : []), { key: 'rename-db', label: '重命名数据库', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') }, { key: 'danger-zone', label: '危险操作', icon: , children: [ { key: 'drop-db', label: '删除数据库', icon: , danger: true, onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') } ] }, { key: 'refresh', label: '刷新', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') }, { key: 'export-db-schema', label: '导出全部表结构 (SQL)', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') }, { key: 'backup-db-sql', label: '备份全部表 (结构+数据 SQL)', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') }, { type: 'divider' }, { key: 'disconnect-db', label: '关闭数据库', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') }, { key: 'new-query', label: '新建查询', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') }, { key: 'run-sql', label: '运行外部SQL文件', icon: , onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') } ]; } else if (node.type === 'view') { return [ { key: 'open-view', label: '浏览视图数据', icon: , onClick: () => onDoubleClick(null, node) }, { key: 'view-definition', label: '查看视图定义', icon: , onClick: () => openViewDefinition(node) }, { type: 'divider' }, { key: 'edit-view', label: '编辑视图', icon: , onClick: () => openEditView(node) }, { key: 'new-query', label: '新建查询', icon: , 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: , onClick: () => { setRenameViewTarget(node); renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); setIsRenameViewModalOpen(true); } }, { key: 'danger-zone', label: '危险操作', icon: , children: [ { key: 'drop-view', label: '删除视图', icon: , danger: true, onClick: () => handleDropView(node) } ] }, ]; } else if (node.type === 'materialized-view') { return [ { key: 'open-materialized-view', label: '浏览物化视图数据', icon: , onClick: () => onDoubleClick(null, node) }, { key: 'materialized-view-definition', label: '查看物化视图定义', icon: , onClick: () => openViewDefinition(node) }, { key: 'new-query', label: '新建查询', icon: , 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: , onClick: () => openRoutineDefinition(node) }, { key: 'edit-routine', label: '编辑定义', icon: , onClick: () => openEditRoutine(node) }, { type: 'divider' }, { key: 'danger-zone', label: '危险操作', icon: , children: [ { key: 'drop-routine', label: `删除${typeLabel}`, icon: , danger: true, onClick: () => handleDropRoutine(node) } ] }, ]; } else if (node.type === 'table') { const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; return [ { key: 'new-query', label: '新建查询', icon: , 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: , onClick: () => openDesign(node, 'columns', false) }, ...(isStarRocks ? [{ key: 'new-rollup', label: '新增 Rollup', icon: , onClick: () => openCreateStarRocksRollup(node) }] : []), { key: 'copy-table-name', label: '复制表名', icon: , onClick: () => handleCopyTableName(node) }, { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(node) }, { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(node, 'sql') }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => { setRenameTableTarget(node); renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); setIsRenameTableModalOpen(true); } }, { key: 'danger-zone', label: '危险操作', icon: , 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: , danger: true, onClick: () => handleDeleteTable(node) } ] }, { type: 'divider' }, { key: 'export', label: '导出表数据', icon: , 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: , 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: , 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: , onClick: () => { void handleAddExternalSQLDirectory(node); } } ]; } if (node.type === 'external-sql-directory') { return [ { key: 'refresh-external-sql-directory', label: '刷新目录', icon: , onClick: () => { void handleRefreshExternalSQLDirectory(node); } }, { type: 'divider' }, { key: 'remove-external-sql-directory', label: '移除目录', icon: , danger: true, onClick: () => { void handleRemoveExternalSQLDirectory(node); } } ]; } if (node.type === 'external-sql-file') { return [ { key: 'open-external-sql-file', label: '打开 SQL 文件', icon: , 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 ?