import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd'; import type { MenuProps } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; import { buildSidebarTablePinKey, useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; import { TABLE_OVERVIEW_RENDER_BATCH_SIZE, buildTableOverviewSearchIndex, filterAndSortTableOverviewRows, prioritizePinnedTableOverviewRows, resolveTableOverviewVisibleRows, type TableOverviewSortField, type TableOverviewSortOrder, } from '../utils/tableOverviewFilter'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { isMacLikePlatform } from '../utils/appearance'; import { getShortcutPlatform } from '../utils/shortcuts'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; interface TableOverviewProps { tab: TabData; } interface TableStatRow { name: string; comment: string; rows: number; dataSize: number; indexSize: number; engine: string; createTime: string; updateTime: string; } type SortField = TableOverviewSortField; type SortOrder = TableOverviewSortOrder; type ViewMode = 'card' | 'list'; type OverviewContextMenuState = { tableName: string; x: number; y: number; sourceX: number; sourceY: number; maxHeight: number; }; type OverviewTableSection = { key: string; title: string; kind: 'pinned' | 'all'; rows: TableStatRow[]; }; const OVERVIEW_CONTEXT_MENU_SAFE_GAP = 8; const OVERVIEW_CONTEXT_MENU_WIDTH = 264; const OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT = 420; const resolveOverviewContextMenuPosition = ( x: number, y: number, options?: { width?: number; height?: number; viewportWidth?: number; viewportHeight?: number; safeGap?: number; }, ): { x: number; y: number; maxHeight: number } => { const safeGap = options?.safeGap ?? OVERVIEW_CONTEXT_MENU_SAFE_GAP; const viewportWidth = options?.viewportWidth ?? (typeof window === 'undefined' ? 1024 : window.innerWidth); const viewportHeight = options?.viewportHeight ?? (typeof window === 'undefined' ? 768 : window.innerHeight); const width = Math.max(0, options?.width ?? OVERVIEW_CONTEXT_MENU_WIDTH); const height = Math.max(0, options?.height ?? OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT); const maxX = Math.max(safeGap, viewportWidth - width - safeGap); const maxY = Math.max(safeGap, viewportHeight - height - safeGap); const nextX = Math.max(safeGap, Math.min(x, maxX)); const nextY = Math.max(safeGap, Math.min(y, maxY)); return { x: nextX, y: nextY, maxHeight: Math.max(120, viewportHeight - nextY - safeGap), }; }; const formatSize = (bytes: number): string => { if (!bytes || bytes <= 0) return '—'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }; const formatRows = (count: number): string => { if (count === undefined || count === null || count < 0) return '—'; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; return String(count); }; const isOverviewTablePinned = ( pinnedKeys: string[], connectionId: string | undefined, dbName: string | undefined, schemaName: string | undefined, tableName: string, ): boolean => { const key = buildSidebarTablePinKey(connectionId || '', dbName || '', tableName, schemaName || ''); return !!key && pinnedKeys.includes(key); }; const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => { const type = (connType || '').trim().toLowerCase(); if (type === 'custom') { const d = (driver || '').trim().toLowerCase(); if (d === 'diros' || d === 'doris') return 'mysql'; if (d === 'oceanbase') return normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql'; if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss'; return d; } if (type === 'oceanbase' && normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle') return 'oracle'; if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql'; if (type === 'dameng') return 'dm'; return type; }; const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => { const escapeLiteral = (s: string) => s.replace(/'/g, "''"); switch (dialect) { case 'mysql': case 'starrocks': return ` SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment, TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine, CREATE_TIME AS create_time, UPDATE_TIME AS update_time FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`; case 'postgres': case 'kingbase': case 'vastbase': case 'highgo': case 'opengauss': { const schema = schemaName || 'public'; return ` SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname = '${escapeLiteral(schema)}' ORDER BY c.relname`; } case 'sqlserver': { const safeDB = `[${dbName.replace(/]/g, ']]')}]`; return ` SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment, SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length FROM ${safeDB}.sys.tables t JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id WHERE t.type = 'U' GROUP BY s.name, t.name, ep.value ORDER BY s.name, t.name`; } case 'clickhouse': return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`; case 'tdengine': return `SHOW TABLES FROM \`${dbName.replace(/`/g, '``')}\``; case 'dm': case 'oracle': { const owner = (schemaName || dbName).toUpperCase(); return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`; } default: return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`; } }; const parseTableStats = (dialect: string, rows: Record[]): TableStatRow[] => { return rows.map((row) => { const get = (keys: string[]): any => { for (const k of keys) { for (const rk of Object.keys(row)) { if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk]; } } return undefined; }; const strVal = (keys: string[]) => String(get(keys) ?? '').trim(); const numVal = (keys: string[]) => { const v = get(keys); if (v === null || v === undefined || v === '') return 0; const n = Number(v); return isNaN(n) ? 0 : Math.max(0, Math.round(n)); }; return { name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME']), comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']), rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']), dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']), indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']), engine: strVal(['Engine', 'engine']), createTime: strVal(['Create_time', 'create_time']), updateTime: strVal(['Update_time', 'update_time']), }; }).filter(t => t.name); }; const TableOverview: React.FC = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const addAIContext = useStore(state => state.addAIContext); const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables); const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned); const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); const [tables, setTables] = useState([]); const [loading, setLoading] = useState(true); const [searchText, setSearchText] = useState(''); const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState(isV2Ui ? 'card' : 'list'); const [v2ContextMenu, setV2ContextMenu] = useState(null); const v2ContextMenuPortalRef = useRef(null); const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE); const deferredSearchText = useDeferredValue(searchText); const isSearchPending = searchText !== deferredSearchText; const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); const metadataDialect = useMemo( () => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol), [connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type] ); const schemaName = String((tab as any).schemaName || '').trim(); const autoFetchVisible = useAutoFetchVisibility(); const loadData = useCallback(async () => { if (!connection) return; setLoading(true); try { const config = { ...connection.config, port: Number(connection.config.port), password: connection.config.password || '', database: connection.config.database || '', useSSH: connection.config.useSSH || false, ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }; const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', schemaName); const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql); if (res.success && Array.isArray(res.data)) { setTables(parseTableStats(metadataDialect, res.data)); } else { message.error('获取表信息失败: ' + (res.message || '未知错误')); } } catch (e: any) { message.error('获取表信息失败: ' + (e?.message || String(e))); } finally { setLoading(false); } }, [connection, metadataDialect, schemaName, tab.dbName]); useEffect(() => { if (!autoFetchVisible) { return; } void loadData(); }, [autoFetchVisible, loadData]); const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]); const sortedFiltered = useMemo(() => ( filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder) ), [deferredSearchText, sortField, sortOrder, tableSearchIndex]); const pinnedOverview = useMemo(() => ( prioritizePinnedTableOverviewRows( sortedFiltered, (table) => isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name), ) ), [connection?.id, pinnedSidebarTables, schemaName, sortedFiltered, tab.dbName]); useEffect(() => { setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE); }, [deferredSearchText, sortField, sortOrder, viewMode, tables, pinnedSidebarTables]); const visibleOverview = useMemo(() => ( resolveTableOverviewVisibleRows(pinnedOverview.orderedRows, visibleTableLimit) ), [pinnedOverview.orderedRows, visibleTableLimit]); const visibleTables = visibleOverview.visibleRows; const visibleTableSections = useMemo(() => { if (pinnedOverview.pinnedRows.length === 0) { return [{ key: 'all', title: '全部', kind: 'all', rows: visibleTables }]; } const visiblePinnedNames = new Set( visibleTables .filter((table) => isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name)) .map((table) => table.name), ); const pinnedRows = pinnedOverview.pinnedRows.filter((table) => visiblePinnedNames.has(table.name)); const regularRows = visibleTables.filter((table) => !visiblePinnedNames.has(table.name)); return [ ...(pinnedRows.length > 0 ? [{ key: 'pinned', title: '置顶', kind: 'pinned' as const, rows: pinnedRows }] : []), ...(regularRows.length > 0 ? [{ key: 'all', title: '全部', kind: 'all' as const, rows: regularRows }] : []), ]; }, [connection?.id, pinnedOverview.pinnedRows, pinnedSidebarTables, schemaName, tab.dbName, visibleTables]); const v2ContextMenuTable = useMemo( () => (v2ContextMenu ? tables.find(table => table.name === v2ContextMenu.tableName) || null : null), [tables, v2ContextMenu], ); const openV2OverviewContextMenu = useCallback((event: React.MouseEvent, table: TableStatRow) => { if (!isV2Ui) return; event.preventDefault(); event.stopPropagation(); const position = resolveOverviewContextMenuPosition(event.clientX, event.clientY); setV2ContextMenu({ tableName: table.name, x: position.x, y: position.y, sourceX: event.clientX, sourceY: event.clientY, maxHeight: position.maxHeight, }); }, [isV2Ui]); useEffect(() => { if (!v2ContextMenu) return; const onPointerDown = (event: MouseEvent) => { const target = event.target instanceof Node ? event.target : null; if (target && v2ContextMenuPortalRef.current?.contains(target)) return; setV2ContextMenu(null); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; setV2ContextMenu(null); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [v2ContextMenu]); useEffect(() => { if (!v2ContextMenu) return; const frame = requestAnimationFrame(() => { const portal = v2ContextMenuPortalRef.current; if (!portal) return; const rect = portal.getBoundingClientRect(); const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); const position = resolveOverviewContextMenuPosition(v2ContextMenu.sourceX, v2ContextMenu.sourceY, { width: rect.width || OVERVIEW_CONTEXT_MENU_WIDTH, height: measuredHeight || OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT, }); setV2ContextMenu(prev => { if (!prev) return prev; if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; }); }); return () => cancelAnimationFrame(frame); }, [v2ContextMenu]); const openTable = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `${connection.id}-${tab.dbName}-${tableName}`, title: tableName, type: 'table', connectionId: connection.id, dbName: tab.dbName, tableName, objectType: 'table', }); }, [connection, tab.dbName, addTab, setActiveContext]); const openDesign = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `design-${connection.id}-${tab.dbName}-${tableName}`, title: `设计表 (${tableName})`, type: 'design', connectionId: connection.id, dbName: tab.dbName, tableName, initialTab: 'columns', readOnly: false, }); }, [connection, tab.dbName, addTab, setActiveContext]); const openTableDdl = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `design-${connection.id}-${tab.dbName}-${tableName}`, title: `表结构 (${tableName})`, type: 'design', connectionId: connection.id, dbName: tab.dbName, tableName, initialTab: 'ddl', readOnly: true, }); }, [connection, tab.dbName, addTab, setActiveContext]); const openQueryForTable = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `query-${Date.now()}`, title: '新建查询', type: 'query', connectionId: connection.id, dbName: tab.dbName, query: buildTableSelectQuery(metadataDialect, tableName), }); }, [addTab, connection, metadataDialect, setActiveContext, tab.dbName]); const openTableInER = useCallback((tableName: string) => { if (!connection) return; openTable(tableName); setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', { detail: { connectionId: connection.id, dbName: tab.dbName, tableName, viewMode: 'er', }, })); }, 0); }, [connection, openTable, tab.dbName]); const buildConfig = useCallback(() => { if (!connection) return null; return { ...connection.config, port: Number(connection.config.port), password: connection.config.password || '', database: connection.config.database || '', useSSH: connection.config.useSSH || false, ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, }; }, [connection]); const handleCopyStructure = useCallback(async (tableName: string) => { const config = buildConfig(); if (!config) return; const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName); if (res.success) { navigator.clipboard.writeText(res.data as string); message.success('表结构已复制到剪贴板'); } else { message.error(res.message); } }, [buildConfig, tab.dbName]); const handleCopyTableName = useCallback(async (tableName: string) => { const name = String(tableName || '').trim(); if (!name) { message.warning('表名为空,无法复制'); return; } try { await navigator.clipboard.writeText(name); message.success('表名已复制到剪贴板'); } catch (e: any) { message.error('复制表名失败: ' + (e?.message || String(e))); } }, []); const handleExport = useCallback(async (tableName: string, format: string) => { const config = buildConfig(); if (!config) return; const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0); const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format); hide(); if (res.success) { message.success('导出成功'); } else if (res.message !== '已取消') { message.error('导出失败: ' + res.message); } }, [buildConfig, tab.dbName]); const handleCopyTableAsInsert = useCallback(async (tableName: string) => { await handleExport(tableName, 'sql'); }, [handleExport]); const handleDeleteTable = useCallback((tableName: string) => { const config = buildConfig(); if (!config) return; Modal.confirm({ title: '确认删除表', content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, okButtonProps: { danger: true }, onOk: async () => { const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName); if (res.success) { message.success('表删除成功'); loadData(); } else { message.error('删除失败: ' + res.message); } }, }); }, [buildConfig, tab.dbName, loadData]); const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => { const config = buildConfig(); if (!config) return; const { label, progressLabel } = getTableDataDangerActionMeta(action); Modal.confirm({ title: `确认${label}`, content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`, okText: '继续', cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => { const app = (window as any).go.app.App; const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables'; const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0); try { const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]); hide(); if (res.success) { message.success(`${progressLabel}成功`); loadData(); } else { message.error(`${progressLabel}失败: ${res.message}`); return Promise.reject(); } } catch (e: any) { hide(); message.error(`${progressLabel}失败: ${e?.message || String(e)}`); return Promise.reject(); } }, }); }, [buildConfig, tab.dbName, loadData]); const toggleOverviewTablePinned = useCallback((tableName: string, pinned?: boolean) => { if (!connection?.id || !tab.dbName || !tableName) return; const currentlyPinned = isOverviewTablePinned( pinnedSidebarTables, connection.id, tab.dbName, schemaName, tableName, ); const shouldPin = pinned ?? !currentlyPinned; setSidebarTablePinned(connection.id, tab.dbName, tableName, schemaName, shouldPin); window.dispatchEvent(new CustomEvent('gonavi:sidebar-table-pin-changed', { detail: { connectionId: connection.id, dbName: tab.dbName, }, })); message.success(shouldPin ? '已置顶表' : '已取消置顶'); }, [connection?.id, pinnedSidebarTables, schemaName, setSidebarTablePinned, tab.dbName]); const handleRenameTable = useCallback((tableName: string) => { const config = buildConfig(); if (!config) return; let newName = tableName; Modal.confirm({ title: '重命名表', content: ( { newName = e.target.value; }} placeholder="输入新表名" autoFocus style={{ marginTop: 8 }} /> ), onOk: async () => { const trimmed = newName.trim(); if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); } if (trimmed === tableName) { message.warning('新旧表名相同'); return; } const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed); if (res.success) { message.success('表重命名成功'); loadData(); } else { message.error('重命名失败: ' + res.message); } }, }); }, [buildConfig, tab.dbName, loadData]); const openCreateStarRocksRollup = useCallback((tableName: string) => { if (!connection) return; 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: connection.id, dbName: tab.dbName, query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`, }); }, [addTab, connection, tab.dbName]); const injectTablePromptToAI = useCallback(async (tableName: string, promptKind: 'explain' | 'query') => { const dbName = tab.dbName || ''; if (!connection?.id || !dbName || !tableName) { message.warning('当前表缺少连接上下文,无法发送给 AI'); return; } let ddl = ''; const config = buildConfig(); if (config) { try { const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName); if (res.success) { ddl = String(res.data || '').trim(); addAIContext(connection.id, { dbName, tableName, ddl }); } } catch { // AI 入口仍可基于表名工作,DDL 获取失败不阻断打开面板。 } } const prompt = promptKind === 'explain' ? [ `请解释数据表 ${dbName}.${tableName} 的结构和业务含义。`, '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', ].filter(Boolean).join('\n') : [ `请基于数据表 ${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); }, [addAIContext, buildConfig, connection?.id, setAIPanelVisible, tab.dbName]); // --- Theme --- const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'; const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'; const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'; const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)'; const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)'; const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)'; const accentColor = '#1677ff'; const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)'; const toggleSort = (field: SortField) => { if (sortField === field) { setSortOrder(o => o === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortOrder(field === 'name' ? 'asc' : 'desc'); } }; const sortMenuItems = [ { key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') }, { key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') }, { key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') }, ]; const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]); const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]); const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => { return Math.max(max, table.dataSize + table.indexSize); }, 0), [sortedFiltered]); const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver); const handleV2TableContextMenuAction = useCallback((table: TableStatRow, action: V2TableContextMenuActionKey) => { const tableName = table.name; switch (action) { case 'open-data': case 'open-new-tab': openTable(tableName); return; case 'pin-table': toggleOverviewTablePinned(tableName, true); return; case 'unpin-table': toggleOverviewTablePinned(tableName, false); return; case 'design-table': openDesign(tableName); return; case 'new-query': openQueryForTable(tableName); return; case 'view-ddl': openTableDdl(tableName); return; case 'view-er': openTableInER(tableName); return; case 'copy-table-name': void handleCopyTableName(tableName); return; case 'copy-structure': void handleCopyStructure(tableName); return; case 'copy-insert': void handleCopyTableAsInsert(tableName); return; case 'rename-table': handleRenameTable(tableName); return; case 'new-rollup': openCreateStarRocksRollup(tableName); return; case 'backup-table': void handleExport(tableName, 'sql'); return; case 'refresh-stats': void loadData(); return; case 'export-xlsx': void handleExport(tableName, 'xlsx'); return; case 'export-csv': void handleExport(tableName, 'csv'); return; case 'export-json': void handleExport(tableName, 'json'); return; case 'ai-explain': void injectTablePromptToAI(tableName, 'explain'); return; case 'ai-generate-query': void injectTablePromptToAI(tableName, 'query'); return; case 'truncate-table': void handleTableDataDangerAction(tableName, 'truncate'); return; case 'drop-table': handleDeleteTable(tableName); return; default: return; } }, [ handleCopyStructure, handleCopyTableAsInsert, handleCopyTableName, handleDeleteTable, handleExport, handleRenameTable, handleTableDataDangerAction, injectTablePromptToAI, loadData, openCreateStarRocksRollup, openDesign, openQueryForTable, openTable, openTableDdl, openTableInER, toggleOverviewTablePinned, ]); const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => ( { setV2ContextMenu(null); handleV2TableContextMenuAction(table, action); }} /> ), [activeShortcutPlatform, allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]); const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [ { key: 'new-query', label: '新建查询', icon: , onClick: () => openQueryForTable(table.name) }, { type: 'divider' }, { key: 'design-table', label: '设计表', icon: , onClick: () => openDesign(table.name) }, { key: 'copy-table-name', label: '复制表名', icon: , onClick: () => handleCopyTableName(table.name) }, { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(table.name) }, { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(table.name, 'sql') }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(table.name) }, { key: 'danger-zone', label: '危险操作', icon: , children: [ ...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []), { key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') }, { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(table.name) }, ]}, { type: 'divider' }, { key: 'export', label: '导出表数据', icon: , children: [ { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') }, { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') }, { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') }, { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') }, { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') }, ]}, ], [ allowTruncate, handleCopyStructure, handleCopyTableName, handleDeleteTable, handleExport, handleRenameTable, handleTableDataDangerAction, openDesign, openQueryForTable, ]); const renderOverviewSectionTitle = (section: OverviewTableSection) => (
{section.title} {section.rows.length}
); const renderCardTableContent = (t: TableStatRow) => (
openTable(t.name)} onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined} style={{ background: cardBg, border: `1px solid ${cardBorder}`, borderRadius: 10, padding: '14px 16px', cursor: 'pointer', transition: isV2Ui ? undefined : 'all 0.15s ease', userSelect: 'none', }} onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }} >
{t.name}
{t.comment && (
{t.comment}
)}
📊 {formatRows(t.rows)} 💾 {formatSize(t.dataSize)} {t.engine && {t.engine}}
{isV2Ui && (
0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
)}
); const renderCardTable = (t: TableStatRow) => { if (isV2Ui) { return {renderCardTableContent(t)}; } return ( {renderCardTableContent(t)} ); }; const renderListTable = (t: TableStatRow) => { const combinedSize = t.dataSize + t.indexSize; const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0; const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%'; const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)'; const rowSecondary = t.comment || (t.engine ? `${t.engine} 表` : '双击打开数据,右键查看更多操作'); const content = (
openTable(t.name)} onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined} style={{ position: 'relative', overflow: 'hidden', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg, cursor: 'pointer', transition: isV2Ui ? undefined : 'all 0.15s ease', userSelect: 'none', }} onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }} >
{t.name} {t.engine && ( {t.engine} )}
{rowSecondary}
行数
{formatRows(t.rows)}
数据大小
{formatSize(t.dataSize)}
索引大小
{formatSize(t.indexSize)}
相对大小
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
); if (isV2Ui) { return {content}; } return ( {content} ); }; if (loading) { return (
); } return (
{/* Toolbar */}
{tab.dbName} {tables.length} 张表 · {formatRows(totalRows)} 行 · {formatSize(totalSize)}
} value={searchText} onChange={e => setSearchText(e.target.value)} allowClear style={{ width: 240 }} size="small" />
setViewMode('card')} style={{ padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', color: viewMode === 'card' ? accentColor : textMuted, }} >
setViewMode('list')} style={{ padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s', background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent', boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', color: viewMode === 'list' ? accentColor : textMuted, }} >
{/* Content Area */}
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
{isSearchPending ? '正在更新筛选结果...' : `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length} 张`} {visibleOverview.hiddenCount > 0 && ( 还有 {visibleOverview.hiddenCount} 张未渲染,可继续加载或缩小搜索范围 )}
)} {sortedFiltered.length === 0 ? ( ) : (
{visibleTableSections.map((section) => (
{pinnedOverview.pinnedRows.length > 0 && renderOverviewSectionTitle(section)} {viewMode === 'card' ? (
{section.rows.map(renderCardTable)}
) : (
{section.rows.map(renderListTable)}
)}
))}
)} {sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
)}
{isV2Ui && v2ContextMenu && v2ContextMenuTable && typeof document !== 'undefined' && createPortal(
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} > {renderV2OverviewTableContextMenu(v2ContextMenuTable)}
, document.body, )}
); }; export default TableOverview;