import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; interface TableOverviewProps { tab: TabData; } interface TableStatRow { name: string; comment: string; rows: number; dataSize: number; indexSize: number; engine: string; createTime: string; updateTime: string; } type SortField = 'name' | 'rows' | 'dataSize'; type SortOrder = 'asc' | 'desc'; 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 getMetadataDialect = (connType: string, driver?: string): string => { const type = (connType || '').trim().toLowerCase(); if (type === 'custom') { const d = (driver || '').trim().toLowerCase(); if (d === 'diros' || d === 'doris') return 'mysql'; return d; } if (type === 'mariadb' || 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': return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``; case 'postgres': case 'kingbase': case 'vastbase': case 'highgo': { 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 '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', '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 addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); const darkMode = theme === 'dark'; const [tables, setTables] = useState([]); const [loading, setLoading] = useState(true); const [searchText, setSearchText] = useState(''); const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); 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 dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver); const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName); const res = await DBQuery(config as any, tab.dbName || '', sql); if (res.success && Array.isArray(res.data)) { setTables(parseTableStats(dialect, res.data)); } else { message.error('获取表信息失败: ' + (res.message || '未知错误')); } } catch (e: any) { message.error('获取表信息失败: ' + (e?.message || String(e))); } finally { setLoading(false); } }, [connection, tab.dbName]); useEffect(() => { loadData(); }, [loadData]); const sortedFiltered = useMemo(() => { let list = [...tables]; if (searchText.trim()) { const kw = searchText.trim().toLowerCase(); list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw)); } list.sort((a, b) => { let cmp = 0; if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase()); else if (sortField === 'rows') cmp = a.rows - b.rows; else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize; return sortOrder === 'asc' ? cmp : -cmp; }); return list; }, [tables, searchText, sortField, sortOrder]); 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, }); }, [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 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(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 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(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 handleDeleteTable = useCallback((tableName: string) => { const config = buildConfig(); if (!config) return; Modal.confirm({ title: '确认删除表', content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, okButtonProps: { danger: true }, onOk: async () => { const res = await DropTable(config as any, tab.dbName || '', tableName); if (res.success) { message.success('表删除成功'); loadData(); } else { message.error('删除失败: ' + res.message); } }, }); }, [buildConfig, tab.dbName, loadData]); 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(config as any, tab.dbName || '', tableName, trimmed); if (res.success) { message.success('表重命名成功'); loadData(); } else { message.error('重命名失败: ' + res.message); } }, }); }, [buildConfig, tab.dbName, loadData]); // --- 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 = tables.reduce((s, t) => s + t.rows, 0); const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0); 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" />
{/* Cards Grid */}
{sortedFiltered.length === 0 ? ( ) : (
{sortedFiltered.map(t => ( , onClick: () => { setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' }); addTab({ id: `query-${Date.now()}`, title: '新建查询', type: 'query', connectionId: tab.connectionId, dbName: tab.dbName, query: `SELECT * FROM ${t.name};`, }); }}, { type: 'divider' }, { key: 'design-table', label: '设计表', icon: , onClick: () => openDesign(t.name) }, { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(t.name) }, { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(t.name, 'sql') }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(t.name) }, { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(t.name) }, { type: 'divider' }, { key: 'export', label: '导出表数据', icon: , children: [ { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') }, { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') }, { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') }, { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') }, { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') }, ]}, ], }} >
openTable(t.name)} style={{ background: cardBg, border: `1px solid ${cardBorder}`, borderRadius: 10, padding: '14px 16px', cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', }} onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} onMouseLeave={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}}
))}
)}
); }; export default TableOverview;