From e6ab5bf496af1c41468f253d922320f446468b88 Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 15:52:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SchemaSidebar=20=E4=B8=89=E7=BA=A7?= =?UTF-8?q?=E6=A0=91=E7=BB=93=E6=9E=84=20=E2=80=94=20=E5=BA=93=E2=86=92?= =?UTF-8?q?=E8=A1=A8=E2=86=92=E5=AD=97=E6=AE=B5=20(=E7=B1=BB=E4=BC=BC=20Na?= =?UTF-8?q?vicat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL: 先加载数据库列表,点击库展开表,点击表展开字段 - 当前选中的 database 自动展开并标记「当前」 - 搜索框支持搜库名和表名,跨库搜索 - 每个库的表列表独立缓存,切换库不重复请求 - Tab 名改为「库表」更直观 - Redis 部分保持不变 --- frontend/src/components/SchemaSidebar.tsx | 282 +++++++++++++++------- 1 file changed, 200 insertions(+), 82 deletions(-) diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx index b41c66b..c1679a6 100644 --- a/frontend/src/components/SchemaSidebar.tsx +++ b/frontend/src/components/SchemaSidebar.tsx @@ -23,6 +23,14 @@ interface RedisKeyItem { ttl: number; } +/** 每个数据库节点缓存的表列表 */ +interface DbNode { + name: string; + tables: TableMeta[]; + loading: boolean; + loaded: boolean; +} + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -43,13 +51,15 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isRedis = dbType === 'redis'; - // -- MySQL State -- - const [tables, setTables] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); + // -- 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 [loadingColumns, setLoadingColumns] = useState(false); - const [tableSearch, setTableSearch] = useState(''); + const [search, setSearch] = useState(''); // -- Redis State -- const [redisDb, setRedisDb] = useState(0); @@ -64,36 +74,76 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); /* ---------------------------------------------------------------- */ - /* Fetch tables */ + /* Fetch databases (level 1) */ /* ---------------------------------------------------------------- */ useEffect(() => { - if (!instanceId || !database || !isMySQL) { setTables([]); return; } - setLoadingTables(true); - instanceDetailApi.listTables(Number(instanceId), database) - .then((data) => setTables(Array.isArray(data) ? data : [])) - .catch(() => setTables([])) - .finally(() => setLoadingTables(false)); - }, [instanceId, database, isMySQL]); + 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) { + setExpandedDb(database); + } + }, [database, databases]); /* ---------------------------------------------------------------- */ - /* Fetch columns on expand */ + /* Expand database → load tables (level 2) */ /* ---------------------------------------------------------------- */ - const handleExpandTable = useCallback(async (tableName: string) => { - if (expandedTable === tableName) { setExpandedTable(null); return; } - setExpandedTable(tableName); + 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([]); setLoadingColumns(true); try { - const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); + const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); setTableColumns(detail?.columns || []); } catch { /* ignore */ } finally { setLoadingColumns(false); } - }, [expandedTable, instanceId, database]); + }, [expandedTable, instanceId]); /* ---------------------------------------------------------------- */ - /* Fetch Redis keys */ + /* Fetch Redis keys */ /* ---------------------------------------------------------------- */ const fetchRedisKeys = useCallback(async () => { @@ -118,7 +168,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } }, [isRedis, instanceId, fetchRedisKeys]); /* ---------------------------------------------------------------- */ - /* Fetch Redis key detail */ + /* Fetch Redis key detail */ /* ---------------------------------------------------------------- */ const handleSelectKey = useCallback(async (key: string) => { @@ -132,12 +182,29 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } }, [instanceId, redisDb]); /* ---------------------------------------------------------------- */ - /* Filtered tables */ + /* Filtered databases (search matches db name or table name) */ /* ---------------------------------------------------------------- */ - const filteredTables = tables.filter(t => - !tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase()) - ); + const filteredDatabases = databases.filter(dbName => { + 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 */ @@ -177,7 +244,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } : 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]' }`} > - 表 + 库表 )} {isRedis && ( @@ -204,79 +271,130 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
setTableSearch(e.target.value)} + value={search} + onChange={(e) => 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="搜索表..." + placeholder="搜索库/表..." />
- {/* Table List */} + {/* DB → Table → Column tree */}
- {loadingTables ? ( + {loadingDbs ? (
- {Array.from({ length: 6 }, (_, i) => ( + {Array.from({ length: 5 }, (_, i) => (
-
))}
- ) : filteredTables.length === 0 ? ( -
无表
+ ) : filteredDatabases.length === 0 ? ( +
无数据库
) : ( -
- {filteredTables.map(t => ( -
- +
+ {filteredDatabases.map(dbName => { + const isExpanded = expandedDb === dbName; + const node = dbNodes[dbName]; + const tables = getFilteredTables(dbName); + const isActive = dbName === database; - {/* Columns */} - {expandedTable === t.name && ( -
- {loadingColumns ? ( -
- ... -
- ) : ( - tableColumns.map(col => ( - - )) + 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 */} + {isTableExpanded && ( +
+ {loadingColumns ? ( +
+ ... +
+ ) : ( + tableColumns.map(col => ( + + )) + )} +
+ )} +
+ ); + }) + )} +
+ )} +
+ ); + })}
)}