Files
MyGoNavi/frontend/src/components/TableOverview.tsx
Syngnat 3ed45fab41 🐛 fix(sidebar): 修复表默认打开行为与左树滚动
- 工具设置新增双击表名行为开关,默认打开表数据,可切换到对象设计

- 左侧树、表概览卡片视图和列表视图统一按开关打开数据页内对象设计

- 调整 V2 左侧树横向滚动条布局,使滚动条保持在底部可见
2026-06-26 17:28:49 +08:00

1349 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Modal from './common/ResizableDraggableModal';
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Input, Spin, Empty, Dropdown, message, Tooltip, 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 { DBGetTables, DBQuery, DBShowCreateTable, ExportTableWithOptions, 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 { 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 { t } from '../i18n';
import { buildTableExportTab } from '../utils/tableExportTab';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
import { useExportProgressDialog } from './ExportProgressModal';
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;
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 === 'goldendb' || d === 'greatdb' || d === 'gdb') return 'mysql';
if (d === 'oceanbase') return normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql';
if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss';
if (d === 'gaussdb' || d === 'gauss_db' || d === 'gauss-db') return 'gaussdb';
return d;
}
if (type === 'oceanbase' && normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle') return 'oracle';
if (type === 'goldendb' || 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, "''");
const iotdbDevicePattern = (name: string) => {
const normalized = String(name || '').trim().replace(/[`"]/g, '');
if (!normalized) return '';
return normalized.endsWith('.**') ? normalized : `${normalized}.**`;
};
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':
case 'gaussdb': {
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 'iotdb': {
const pattern = iotdbDevicePattern(dbName);
return pattern ? `SHOW DEVICES ${pattern}` : 'SHOW DEVICES';
}
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<string, any>[]): 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', 'Table', 'table', 'Device', 'device']),
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<TableOverviewProps> = ({ 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 tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const [tables, setTables] = useState<TableStatRow[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>(isV2Ui ? 'card' : 'list');
const [v2ContextMenu, setV2ContextMenu] = useState<OverviewContextMenuState | null>(null);
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
const v2ContextMenuPortalRef = useRef<HTMLDivElement | null>(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 supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner;
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: '' },
};
if (metadataDialect === 'tdengine') {
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, tab.dbName || '');
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error(t('table_overview.message.load_tables_failed', {
detail: res.message || t('table_overview.message.unknown_error'),
}));
}
return;
}
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(t('table_overview.message.load_tables_failed', {
detail: res.message || t('table_overview.message.unknown_error'),
}));
}
} catch (e: any) {
message.error(t('table_overview.message.load_tables_failed', {
detail: e?.message || String(e),
}));
} finally {
setLoading(false);
}
}, [connection, metadataDialect, schemaName, t, 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<OverviewTableSection[]>(() => {
if (pinnedOverview.pinnedRows.length === 0) {
return [{ key: 'all', 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', kind: 'pinned' as const, rows: pinnedRows }] : []),
...(regularRows.length > 0 ? [{ key: 'all', 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 || '' });
const structureOnly = !supportsDesignWrite;
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: t(
structureOnly ? 'table_overview.tab.table_structure_title' : 'table_overview.tab.design_table_title',
{ table: tableName },
),
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: structureOnly,
});
}, [connection, tab.dbName, addTab, setActiveContext, supportsDesignWrite, t]);
const openTableObjectDesigner = 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,
initialViewMode: 'fields',
initialViewModeRequestId: String(Date.now()),
objectType: 'table',
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openTableByDefaultAction = useCallback((tableName: string) => {
if (tableDoubleClickAction === 'open-design') {
openTableObjectDesigner(tableName);
return;
}
openTable(tableName);
}, [openTable, openTableObjectDesigner, tableDoubleClickAction]);
const openTableDdl = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: t('table_overview.tab.table_structure_title', { table: tableName }),
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'ddl',
readOnly: true,
});
}, [connection, tab.dbName, addTab, setActiveContext, t]);
const openQueryForTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: t('table_overview.menu.new_query'),
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, tableName),
});
}, [addTab, connection, metadataDialect, setActiveContext, t, 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(t('table_overview.message.copy_structure_success'));
} else {
message.error(t('table_overview.message.copy_structure_failed', {
detail: res.message || t('table_overview.message.unknown_error'),
}));
}
}, [buildConfig, t, tab.dbName]);
const handleCopyTableName = useCallback(async (tableName: string) => {
const name = String(tableName || '').trim();
if (!name) {
message.warning(t('table_overview.message.copy_table_name_empty'));
return;
}
try {
await navigator.clipboard.writeText(name);
message.success(t('table_overview.message.copy_table_name_success'));
} catch (e: any) {
message.error(t('table_overview.message.copy_table_name_failed', {
detail: e?.message || String(e),
}));
}
}, [t]);
const handleExport = useCallback(async (tableName: string, options: { format: string; xlsxMaxRowsPerSheet?: number }, totalRows?: number) => {
const config = buildConfig();
if (!config) return;
const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) > 0;
await runExportWithProgress({
title: t('table_overview.message.exporting_table_format', {
table: tableName,
format: options.format.toUpperCase(),
}),
targetName: tableName,
format: options.format,
totalRows: totalRowsKnown ? Number(totalRows) : undefined,
run: (jobId) => ExportTableWithOptions(
buildRpcConnectionConfig(config) as any,
tab.dbName || '',
tableName,
{
...options,
jobId,
totalRowsHint: totalRowsKnown ? Number(totalRows) : 0,
totalRowsKnown,
} as any,
),
});
}, [buildConfig, runExportWithProgress, t, tab.dbName]);
const openExportDialog = useCallback(async (tableName: string, totalRows?: number) => {
addTab(buildTableExportTab({
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName,
title: t('file.backend.dialog.export_table', { table: tableName }),
objectType: 'table',
rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) > 0
? { all: Math.trunc(Number(totalRows)) }
: undefined,
}));
}, [addTab, tab.connectionId, tab.dbName]);
const handleCopyTableAsInsert = useCallback(async (tableName: string) => {
await handleExport(tableName, { format: 'sql' });
}, [handleExport]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
Modal.confirm({
title: t('table_overview.modal.delete_table.title'),
content: t('table_overview.modal.delete_table.content', { table: tableName }),
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
if (res.success) {
message.success(t('table_overview.message.delete_table_success'));
loadData();
} else {
message.error(t('table_overview.message.delete_table_failed', { detail: res.message }));
}
},
});
}, [buildConfig, loadData, t, tab.dbName]);
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
const config = buildConfig();
if (!config) return;
const actionLabel = t(`table_overview.table_data_action.${action}.label`);
Modal.confirm({
title: t('table_overview.modal.table_data_action.title', { action: actionLabel }),
content: t('table_overview.modal.table_data_action.content', {
action: actionLabel,
table: tableName,
}),
okText: t('common.continue'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
onOk: async () => {
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(t('table_overview.message.table_data_action_loading', {
action: actionLabel,
table: tableName,
}), 0);
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
hide();
if (res.success) {
message.success(t('table_overview.message.table_data_action_success', { action: actionLabel }));
loadData();
} else {
message.error(t('table_overview.message.table_data_action_failed', { action: actionLabel, detail: res.message }));
return Promise.reject();
}
} catch (e: any) {
hide();
message.error(t('table_overview.message.table_data_action_failed', {
action: actionLabel,
detail: e?.message || String(e),
}));
return Promise.reject();
}
},
});
}, [buildConfig, loadData, t, tab.dbName]);
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 ? t('table_overview.message.pinned') : t('table_overview.message.unpinned'));
}, [connection?.id, pinnedSidebarTables, schemaName, setSidebarTablePinned, t, tab.dbName]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
let newName = tableName;
Modal.confirm({
title: t('table_overview.modal.rename_table.title'),
content: (
<Input
{...noAutoCapInputProps}
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder={t('table_overview.modal.rename_table.placeholder')}
autoFocus
style={{ marginTop: 8 }}
/>
),
onOk: async () => {
const trimmed = newName.trim();
if (!trimmed) { message.error(t('table_overview.validation.table_name_required')); return Promise.reject(); }
if (trimmed === tableName) { message.warning(t('table_overview.validation.table_name_unchanged')); return; }
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success(t('table_overview.message.rename_table_success'));
loadData();
} else {
message.error(t('table_overview.message.rename_table_failed', { detail: res.message }));
}
},
});
}, [buildConfig, loadData, t, tab.dbName]);
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: t('sidebar.v2_table_menu.new_rollup', { keyword: 'Rollup' }),
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
});
}, [addTab, connection, t, tab.dbName]);
const injectTablePromptToAI = useCallback(async (tableName: string, promptKind: 'explain' | 'query') => {
const dbName = tab.dbName || '';
if (!connection?.id || !dbName || !tableName) {
message.warning(t('sidebar.message.ai_table_context_missing'));
return;
}
const tableRef = `${dbName}.${tableName}`;
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'
? [
t('sidebar.ai_prompt.explain.intro', { table: tableRef }),
t('sidebar.ai_prompt.explain.detail'),
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n')
: [
t('sidebar.ai_prompt.query.intro', { table: tableRef }),
t('sidebar.ai_prompt.query.detail'),
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 getSortMenuLabel = (field: SortField, labelKey: string) => {
const suffix = sortField === field ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : '';
return `${t(labelKey)}${suffix}`;
};
const sortMenuItems = [
{ key: 'name', label: getSortMenuLabel('name', 'table_overview.sort.name'), onClick: () => toggleSort('name') },
{ key: 'rows', label: getSortMenuLabel('rows', 'table_overview.sort.rows'), onClick: () => toggleSort('rows') },
{ key: 'dataSize', label: getSortMenuLabel('dataSize', 'table_overview.sort.size'), 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 renderToolbarSummary = () => {
const countToken = '__COUNT__';
const rowsToken = '__ROWS__';
const sizeToken = '__SIZE__';
const template = t('table_overview.toolbar.summary', {
count: countToken,
rows: rowsToken,
size: sizeToken,
});
const parts = template.split(/(__COUNT__|__ROWS__|__SIZE__)/g);
return parts.map((part, index) => {
if (part === countToken) {
return <strong key={`count-${index}`}>{tables.length}</strong>;
}
if (part === rowsToken) {
return <strong key={`rows-${index}`}>{formatRows(totalRows)}</strong>;
}
if (part === sizeToken) {
return <strong key={`size-${index}`}>{formatSize(totalSize)}</strong>;
}
return <React.Fragment key={`text-${index}`}>{part}</React.Fragment>;
});
};
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, { format: 'sql' });
return;
case 'refresh-stats':
void loadData();
return;
case 'export-data':
void openExportDialog(tableName, tables.find((item) => item.name === tableName)?.rows);
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,
openExportDialog,
injectTablePromptToAI,
loadData,
openCreateStarRocksRollup,
openDesign,
openQueryForTable,
openTable,
openTableDdl,
openTableInER,
tables,
toggleOverviewTablePinned,
]);
const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => (
<V2TableContextMenuView
tableName={table.name}
shortcutPlatform={activeShortcutPlatform}
stats={{
rowCount: table.rows,
dataLength: table.dataSize,
indexLength: table.indexSize,
engine: table.engine,
}}
isPinned={isOverviewTablePinned(pinnedSidebarTables, connection?.id, tab.dbName, schemaName, table.name)}
supportsTruncate={allowTruncate}
supportsStarRocksRollup={metadataDialect === 'starrocks'}
onAction={(action) => {
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: t('table_overview.menu.new_query'), icon: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
{ type: 'divider' },
{
key: 'design-table',
label: supportsDesignWrite ? t('table_overview.menu.design_table') : t('table_overview.menu.table_structure'),
icon: <EditOutlined />,
onClick: () => openDesign(table.name),
},
{ key: 'copy-table-name', label: t('table_overview.menu.copy_table_name'), icon: <CopyOutlined />, onClick: () => handleCopyTableName(table.name) },
{ key: 'copy-structure', label: t('table_overview.menu.copy_structure'), icon: <CopyOutlined />, onClick: () => handleCopyStructure(table.name) },
{ key: 'backup-table', label: t('table_overview.menu.backup_table_sql'), icon: <SaveOutlined />, onClick: () => handleExport(table.name, { format: 'sql' }) },
{ key: 'rename-table', label: t('table_overview.menu.rename_table'), icon: <EditOutlined />, onClick: () => handleRenameTable(table.name) },
{ key: 'danger-zone', label: t('table_overview.menu.danger_operations'), icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: t('table_overview.menu.truncate_table'), danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []),
{ key: 'clear-table', label: t('table_overview.menu.clear_table'), danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') },
{ key: 'drop-table', label: t('table_overview.menu.delete_table'), icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(table.name) },
]},
{ type: 'divider' },
{ key: 'export', label: t('table_overview.menu.export_table_data'), icon: <ExportOutlined />, onClick: () => openExportDialog(table.name, table.rows) },
], [
allowTruncate,
handleCopyStructure,
handleCopyTableName,
handleDeleteTable,
handleExport,
handleRenameTable,
handleTableDataDangerAction,
openExportDialog,
openDesign,
openQueryForTable,
supportsDesignWrite,
t,
]);
const renderOverviewSectionTitle = (section: OverviewTableSection) => {
const sectionTitle = section.kind === 'pinned'
? t('table_overview.section.pinned')
: t('table_overview.section.all');
return (
<div
className={isV2Ui ? 'gn-v2-table-overview-section-title' : undefined}
data-overview-table-section={section.kind}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: section.kind === 'pinned' ? '0 0 8px' : '14px 0 8px',
color: textMuted,
fontSize: 12,
fontWeight: 600,
}}
>
<span>{sectionTitle}</span>
<span>{section.rows.length}</span>
</div>
);
};
const renderCardTableContent = (table: TableStatRow) => (
<div
className={isV2Ui ? 'gn-v2-table-card' : undefined}
onDoubleClick={() => openTableByDefaultAction(table.name)}
onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : 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; }}
>
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={table.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{table.name}
</span>
</Tooltip>
</div>
{table.comment && (
<Tooltip title={table.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{table.comment}
</div>
</Tooltip>
)}
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title={t('table_overview.sort.rows')} style={{ minWidth: 52 }}>📊 {formatRows(table.rows)}</span>
<span title={t('table_overview.metric.data_size')} style={{ minWidth: 72 }}>💾 {formatSize(table.dataSize)}</span>
{table.engine && <span title={t('table_overview.metric.engine')} style={{ marginLeft: 'auto', opacity: 0.7 }}>{table.engine}</span>}
</div>
{isV2Ui && (
<div className="gn-v2-table-size-bar">
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((table.dataSize + table.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
</div>
)}
</div>
);
const renderCardTable = (table: TableStatRow) => {
if (isV2Ui) {
return <React.Fragment key={table.name}>{renderCardTableContent(table)}</React.Fragment>;
}
return (
<Dropdown
key={table.name}
trigger={['contextMenu']}
menu={{ items: buildLegacyTableContextMenuItems(table) }}
>
{renderCardTableContent(table)}
</Dropdown>
);
};
const renderListTable = (table: TableStatRow) => {
const combinedSize = table.dataSize + table.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 = table.comment || (table.engine
? t('table_overview.row.engine_table', { engine: table.engine })
: t('table_overview.row.open_hint'));
const content = (
<div
className={isV2Ui ? 'gn-v2-table-row' : undefined}
onDoubleClick={() => openTableByDefaultAction(table.name)}
onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : 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; }}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: fillWidth,
background: fillColor,
pointerEvents: 'none',
transition: 'width 0.2s ease',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={table.name} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{table.name}
</span>
</Tooltip>
{table.engine && (
<span
style={{
flexShrink: 0,
padding: '1px 6px',
borderRadius: 999,
fontSize: 11,
color: textMuted,
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
}}
>
{table.engine}
</span>
)}
</div>
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rowSecondary}
</div>
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}>{t('table_overview.sort.rows')}</div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(table.rows)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}>{t('table_overview.metric.data_size')}</div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(table.dataSize)}</div>
</div>
<div style={{ minWidth: 110, textAlign: 'right' }}>
<div style={{ color: textMuted }}>{t('table_overview.metric.index_size')}</div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(table.indexSize)}</div>
</div>
<div style={{ minWidth: 96, textAlign: 'right' }}>
<div style={{ color: textMuted }}>{t('table_overview.metric.relative_size')}</div>
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
</div>
</div>
</div>
</div>
</div>
);
if (isV2Ui) {
return <React.Fragment key={table.name}>{content}</React.Fragment>;
}
return (
<Dropdown
key={table.name}
trigger={['contextMenu']}
menu={{ items: buildLegacyTableContextMenuItems(table) }}
>
{content}
</Dropdown>
);
};
if (loading) {
return (
<div className={isV2Ui ? 'gn-v2-table-overview gn-v2-table-overview-loading' : undefined} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<Spin size="large" tip={t('table_overview.status.loading_tables')} />
</div>
);
}
return (
<div className={isV2Ui ? 'gn-v2-table-overview' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{exportProgressModal}
{/* Toolbar */}
<div className={isV2Ui ? 'gn-v2-table-overview-header' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<span className={isV2Ui ? 'gn-v2-table-overview-icon' : undefined}>
<DatabaseOutlined style={{ fontSize: 16, color: isV2Ui ? undefined : accentColor }} />
</span>
<span className={isV2Ui ? 'gn-v2-table-overview-title' : undefined} style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span className={isV2Ui ? 'gn-v2-table-overview-summary' : undefined} style={{ fontSize: 12, color: textMuted }}>
{renderToolbarSummary()}
</span>
<div style={{ flex: 1 }} />
<Input
{...noAutoCapInputProps}
placeholder={t('table_overview.placeholder.search')}
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
size="small"
/>
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title={t('table_overview.tooltip.sort')}><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
<Tooltip title={t('table_overview.tooltip.card_view')}>
<div
onClick={() => 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,
}}
>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
<Tooltip title={t('table_overview.tooltip.list_view')}>
<div
onClick={() => 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,
}}
>
<UnorderedListOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
</div>
<Tooltip title={t('table_overview.tooltip.refresh')}><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Content Area */}
<div className={isV2Ui ? 'gn-v2-table-overview-content' : undefined} style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 10,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
color: textMuted,
fontSize: 12,
}}
>
<span>
{isSearchPending
? t('table_overview.status.updating_filter')
: t('table_overview.status.matching_rendered', {
matched: sortedFiltered.length,
rendered: visibleTables.length,
})}
</span>
{visibleOverview.hiddenCount > 0 && (
<span>{t('table_overview.status.hidden_count_hint', { count: visibleOverview.hiddenCount })}</span>
)}
</div>
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? t('table_overview.empty.no_matches') : t('table_overview.empty.no_tables')} style={{ marginTop: 80 }} />
) : (
<div className={isV2Ui ? 'gn-v2-table-overview-sections' : undefined}>
{visibleTableSections.map((section) => (
<section key={section.key} className={isV2Ui ? 'gn-v2-table-overview-section' : undefined}>
{pinnedOverview.pinnedRows.length > 0 && renderOverviewSectionTitle(section)}
{viewMode === 'card' ? (
<div className={isV2Ui ? 'gn-v2-table-card-grid' : undefined} style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{section.rows.map(renderCardTable)}
</div>
) : (
<div className={isV2Ui ? 'gn-v2-table-row-list' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{section.rows.map(renderListTable)}
</div>
)}
</section>
))}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
<Button
size="small"
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
>
{t('table_overview.action.show_more', { count: visibleOverview.hiddenCount })}
</Button>
</div>
)}
</div>
{isV2Ui && v2ContextMenu && v2ContextMenuTable && typeof document !== 'undefined' && createPortal(
<div
ref={v2ContextMenuPortalRef}
className="gn-v2-table-overview-context-menu-portal gn-v2-table-context-menu-popup"
style={{
position: 'fixed',
left: v2ContextMenu.x,
top: v2ContextMenu.y,
zIndex: 10000,
width: OVERVIEW_CONTEXT_MENU_WIDTH,
maxWidth: 'calc(100vw - 24px)',
['--gn-v2-context-menu-max-height' as any]: `${v2ContextMenu.maxHeight}px`,
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{renderV2OverviewTableContextMenu(v2ContextMenuTable)}
</div>,
document.body,
)}
</div>
);
};
export default TableOverview;