import { useState, useEffect, useCallback } from 'react'; import { ChevronDown, ChevronRight, Database, Table2, Columns3, KeyRound, Search, Loader2, FolderOpen, Server, } from 'lucide-react'; import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail'; import { redisApi } from '@/api/redis'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface Props { instanceId: string; database: string; dbType: string; /** Redis 集群模式为 true */ clusterMode?: boolean; onInsert: (text: string) => void; onDatabaseChange?: (db: string) => void; /** 为 true 时只显示当前选中的库,不允许切换(数据变更场景防误操作) */ singleDb?: boolean; } interface RedisKeyItem { key: string; type: string; ttl: number; } /** Redis key 树节点 */ interface RedisTreeNode { name: string; fullPrefix: string; isLeaf: boolean; keyItem?: RedisKeyItem; // 叶子节点才有 children: Map; } /** 把 flat keys 按 ':' 构建成树 */ function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode { const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() }; for (const item of keys) { const parts = item.key.split(':'); let node = root; let prefix = ''; for (let i = 0; i < parts.length; i++) { prefix += (i === 0 ? '' : ':') + parts[i]; if (!node.children.has(parts[i])) { node.children.set(parts[i], { name: parts[i], fullPrefix: prefix, isLeaf: i === parts.length - 1, keyItem: i === parts.length - 1 ? item : undefined, children: new Map(), }); } node = node.children.get(parts[i])!; } } return root; } /** Redis 树形渲染组件 */ /** 根据 key type 生成查询命令 */ function redisCommand(key: string, type: string): string { switch (type) { case 'hash': return `HGETALL ${key}`; case 'list': return `LRANGE ${key} 0 -1`; case 'set': return `SMEMBERS ${key}`; case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`; default: return `GET ${key}`; } } function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: { tree: RedisTreeNode; expanded: Set; setExpanded: React.Dispatch>>; selectedKey: string | null; onSelectKey: (key: string) => void; keyDetail: any; loadingDetail: boolean; onInsert?: (cmd: string) => void; }) { const toggle = (prefix: string) => { setExpanded(prev => { const next = new Set(prev); next.has(prefix) ? next.delete(prefix) : next.add(prefix); return next; }); }; const renderNode = (node: RedisTreeNode, depth: number) => { const sorted = [...node.children.values()].sort((a, b) => { // 文件夹优先,叶子在后 if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1; return a.name.localeCompare(b.name); }); return sorted.map(child => { const indent = depth * 12; if (child.isLeaf && child.keyItem) { // 叶子节点:实际 Redis key return (
{selectedKey === child.keyItem.key && (
{loadingDetail ? (
...
) : keyDetail ? (
{keyDetail.type} TTL: {formatTTL(keyDetail.ttl)}
                      {typeof keyDetail.value === 'object'
                        ? JSON.stringify(keyDetail.value, null, 2)
                        : String(keyDetail.value)}
                    
) : null}
)}
); } // 文件夹节点 const isOpen = expanded.has(child.fullPrefix); const childCount = child.children.size; return (
{isOpen && renderNode(child, depth + 1)}
); }); }; return
{renderNode(tree, 0)}
; } /** 每个数据库节点缓存的表列表 */ interface DbNode { name: string; tables: TableMeta[]; loading: boolean; loaded: boolean; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function formatTTL(ttl: number): string { if (ttl < 0) return '永不过期'; if (ttl < 60) return `${ttl}s`; if (ttl < 3600) return `${Math.floor(ttl / 60)}m`; if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`; return `${Math.floor(ttl / 86400)}d`; } /* ================================================================== */ /* COMPONENT */ /* ================================================================== */ export default function SchemaSidebar({ instanceId, database, dbType, clusterMode, onInsert, onDatabaseChange, singleDb }: Props) { const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isRedis = dbType === 'redis'; // -- MySQL: DB → Table → Column 三级 -- const [databases, setDatabases] = useState([]); const [loadingDbs, setLoadingDbs] = useState(false); const [expandedDb, setExpandedDb] = useState(null); const [dbNodes, setDbNodes] = useState>({}); const [expandedTable, setExpandedTable] = useState(null); const [tableColumns, setTableColumns] = useState([]); const [tableIndexes, setTableIndexes] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns'); const [search, setSearch] = useState(''); // -- Redis State -- const [redisDb, setRedisDb] = useState(0); const [redisPattern, setRedisPattern] = useState('*'); const [redisKeys, setRedisKeys] = useState([]); const [loadingRedis, setLoadingRedis] = useState(false); const [redisExpanded, setRedisExpanded] = useState>(new Set()); const [selectedKey, setSelectedKey] = useState(null); const [keyDetail, setKeyDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); // -- Tab -- const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); /* ---------------------------------------------------------------- */ /* Fetch databases (level 1) */ /* ---------------------------------------------------------------- */ useEffect(() => { if (!instanceId || !isMySQL) { setDatabases([]); return; } setLoadingDbs(true); instanceDetailApi.listDatabases(Number(instanceId)) .then((data) => { const list = Array.isArray(data) ? data : []; setDatabases(list); // 如果当前已选了 database,自动展开 if (database && list.includes(database)) { setExpandedDb(database); } }) .catch(() => setDatabases([])) .finally(() => setLoadingDbs(false)); }, [instanceId, isMySQL]); // 当 database prop 变化时自动展开并加载表 useEffect(() => { if (database && databases.includes(database) && expandedDb !== database) { handleExpandDb(database); } }, [database, databases]); /* ---------------------------------------------------------------- */ /* Expand database → load tables (level 2) */ /* ---------------------------------------------------------------- */ const handleExpandDb = useCallback(async (dbName: string) => { if (expandedDb === dbName) { setExpandedDb(null); return; } setExpandedDb(dbName); setExpandedTable(null); // 已加载过则跳过 if (dbNodes[dbName]?.loaded) return; setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } })); try { const data = await instanceDetailApi.listTables(Number(instanceId), dbName); const tables = Array.isArray(data) ? data : []; setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } })); } catch { setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } })); } }, [expandedDb, instanceId, dbNodes]); /* ---------------------------------------------------------------- */ /* Expand table → load columns (level 3) */ /* ---------------------------------------------------------------- */ const handleExpandTable = useCallback(async (dbName: string, tableName: string) => { const key = `${dbName}.${tableName}`; if (expandedTable === key) { setExpandedTable(null); return; } setExpandedTable(key); setTableColumns([]); setTableIndexes([]); setDetailTab('columns'); setLoadingColumns(true); try { const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); setTableColumns(detail?.columns || []); setTableIndexes(detail?.indexes || []); } catch { /* ignore */ } finally { setLoadingColumns(false); } }, [expandedTable, instanceId]); /* ---------------------------------------------------------------- */ /* Fetch Redis keys */ /* ---------------------------------------------------------------- */ const fetchRedisKeys = useCallback(async () => { if (!instanceId) return; setLoadingRedis(true); try { const res = await redisApi.scanKeys(Number(instanceId), { db: redisDb, pattern: redisPattern || '*', count: 100, }); const keys = (res?.keys || []).map((k: any) => ({ key: typeof k === 'string' ? k : k.key, type: typeof k === 'string' ? 'string' : (k.type || 'string'), ttl: k.ttl ?? -1, })); setRedisKeys(keys); } catch { setRedisKeys([]); } finally { setLoadingRedis(false); } }, [instanceId, redisDb, redisPattern]); useEffect(() => { if (isRedis && instanceId) fetchRedisKeys(); }, [isRedis, instanceId, fetchRedisKeys]); /* ---------------------------------------------------------------- */ /* Fetch Redis key detail */ /* ---------------------------------------------------------------- */ const handleSelectKey = useCallback(async (key: string) => { setSelectedKey(key); setLoadingDetail(true); try { const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); setKeyDetail(detail); } catch { setKeyDetail(null); } finally { setLoadingDetail(false); } }, [instanceId, redisDb]); /* ---------------------------------------------------------------- */ /* Filtered databases (search matches db name or table name) */ /* ---------------------------------------------------------------- */ const filteredDatabases = databases.filter(dbName => { if (singleDb && database && dbName !== database) return false; if (!search) return true; const kw = search.toLowerCase(); if (dbName.toLowerCase().includes(kw)) return true; const node = dbNodes[dbName]; if (node?.loaded) { return node.tables.some(t => t.name.toLowerCase().includes(kw)); } return false; }); const getFilteredTables = (dbName: string) => { const node = dbNodes[dbName]; if (!node?.loaded) return []; if (!search) return node.tables; const kw = search.toLowerCase(); return node.tables.filter(t => t.name.toLowerCase().includes(kw)); }; /* ================================================================== */ /* RENDER */ /* ================================================================== */ if (!instanceId) { return (
数据库导航

请先选择实例

); } return (
{/* Header */}
{dbType.toUpperCase()}
{/* Tabs */} {(isMySQL || isRedis) && (
{isMySQL && ( )} {isRedis && ( )}
)} {/* Content */}
{activeTab === 'tables' && isMySQL && (
{/* Search */}
setSearch(e.target.value)} className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" placeholder="搜索库/表..." />
{/* DB → Table → Column tree */}
{loadingDbs ? (
{Array.from({ length: 5 }, (_, i) => (
))}
) : filteredDatabases.length === 0 ? (
无数据库
) : (
{filteredDatabases.map(dbName => { const isExpanded = expandedDb === dbName; const node = dbNodes[dbName]; const tables = getFilteredTables(dbName); const isActive = dbName === database; return (
{/* Level 1: Database */} {/* Level 2: Tables */} {isExpanded && (
{node?.loading ? (
{Array.from({ length: 4 }, (_, i) => (
))}
) : tables.length === 0 ? (
无表
) : ( tables.map(t => { const tableKey = `${dbName}.${t.name}`; const isTableExpanded = expandedTable === tableKey; return (
{/* Level 3: Columns & Indexes */} {isTableExpanded && (
{loadingColumns ? (
...
) : ( <> {/* 字段/索引 Tab */}
{/* 字段列表 */} {detailTab === 'columns' && tableColumns.map(col => ( ))} {/* 索引列表 */} {detailTab === 'indexes' && ( tableIndexes.length === 0 ? (
无索引
) : ( Object.entries( tableIndexes.reduce((acc, idx) => { if (!acc[idx.Key_name]) acc[idx.Key_name] = []; acc[idx.Key_name].push(idx); return acc; }, {} as Record) ).map(([keyName, cols]) => (
{keyName} {!cols[0].Non_unique && UNI} {cols[0].Index_type}
{cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => ( {c.Column_name} ))}
)) ) )} )}
)}
); }) )}
)}
); })}
)}
)} {activeTab === 'redis' && isRedis && (
{/* DB + Search */}
{clusterMode ? (
集群模式 · DB0
) : ( )}
setRedisPattern(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()} className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" placeholder="*" />
{/* Key Tree + Detail */}
{loadingRedis ? (
{Array.from({ length: 6 }, (_, i) => (
))}
) : redisKeys.length === 0 ? (
无 Keys
) : ( )}
)}
); }