feat: SchemaSidebar 三级树结构 — 库→表→字段 (类似 Navicat)

- MySQL: 先加载数据库列表,点击库展开表,点击表展开字段
- 当前选中的 database 自动展开并标记「当前」
- 搜索框支持搜库名和表名,跨库搜索
- 每个库的表列表独立缓存,切换库不重复请求
- Tab 名改为「库表」更直观
- Redis 部分保持不变
This commit is contained in:
logan committed 2026-05-24 15:52:08 +08:00
1 parent 6a8c4d95d5
commit e6ab5bf496
1 file changed
+200 -82
+200 -82
View File
@@ -23,6 +23,14 @@ interface RedisKeyItem {
ttl: number; ttl: number;
} }
/** 每个数据库节点缓存的表列表 */
interface DbNode {
name: string;
tables: TableMeta[];
loading: boolean;
loaded: boolean;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -43,13 +51,15 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
const isRedis = dbType === 'redis'; const isRedis = dbType === 'redis';
// -- MySQL State -- // -- MySQL: DB → Table → Column 三级 --
const [tables, setTables] = useState<TableMeta[]>([]); const [databases, setDatabases] = useState<string[]>([]);
const [loadingTables, setLoadingTables] = useState(false); const [loadingDbs, setLoadingDbs] = useState(false);
const [expandedDb, setExpandedDb] = useState<string | null>(null);
const [dbNodes, setDbNodes] = useState<Record<string, DbNode>>({});
const [expandedTable, setExpandedTable] = useState<string | null>(null); const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]); const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
const [tableSearch, setTableSearch] = useState(''); const [search, setSearch] = useState('');
// -- Redis State -- // -- Redis State --
const [redisDb, setRedisDb] = useState(0); const [redisDb, setRedisDb] = useState(0);
@@ -64,36 +74,76 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch tables */ /* Fetch databases (level 1) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
useEffect(() => { useEffect(() => {
if (!instanceId || !database || !isMySQL) { setTables([]); return; } if (!instanceId || !isMySQL) { setDatabases([]); return; }
setLoadingTables(true); setLoadingDbs(true);
instanceDetailApi.listTables(Number(instanceId), database) instanceDetailApi.listDatabases(Number(instanceId))
.then((data) => setTables(Array.isArray(data) ? data : [])) .then((data) => {
.catch(() => setTables([])) const list = Array.isArray(data) ? data : [];
.finally(() => setLoadingTables(false)); setDatabases(list);
}, [instanceId, database, isMySQL]); // 如果当前已选了 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) => { const handleExpandDb = useCallback(async (dbName: string) => {
if (expandedTable === tableName) { setExpandedTable(null); return; } if (expandedDb === dbName) {
setExpandedTable(tableName); 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([]); setTableColumns([]);
setLoadingColumns(true); setLoadingColumns(true);
try { try {
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
setTableColumns(detail?.columns || []); setTableColumns(detail?.columns || []);
} catch { /* ignore */ } } catch { /* ignore */ }
finally { setLoadingColumns(false); } finally { setLoadingColumns(false); }
}, [expandedTable, instanceId, database]); }, [expandedTable, instanceId]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch Redis keys */ /* Fetch Redis keys */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const fetchRedisKeys = useCallback(async () => { const fetchRedisKeys = useCallback(async () => {
@@ -118,7 +168,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
}, [isRedis, instanceId, fetchRedisKeys]); }, [isRedis, instanceId, fetchRedisKeys]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch Redis key detail */ /* Fetch Redis key detail */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const handleSelectKey = useCallback(async (key: string) => { const handleSelectKey = useCallback(async (key: string) => {
@@ -132,12 +182,29 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
}, [instanceId, redisDb]); }, [instanceId, redisDb]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Filtered tables */ /* Filtered databases (search matches db name or table name) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const filteredTables = tables.filter(t => const filteredDatabases = databases.filter(dbName => {
!tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase()) 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 */ /* 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)]' : 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
}`} }`}
> >
<Table2 className="w-3 h-3" /> 表 <Database className="w-3 h-3" /> 库表
</button> </button>
)} )}
{isRedis && ( {isRedis && (
@@ -204,79 +271,130 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input <input
value={tableSearch} value={search}
onChange={(e) => setTableSearch(e.target.value)} 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" 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="搜索库/表..."
/> />
</div> </div>
</div> </div>
{/* Table List */} {/* DB → Table → Column tree */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loadingTables ? ( {loadingDbs ? (
<div className="space-y-1 p-2"> <div className="space-y-1 p-2">
{Array.from({ length: 6 }, (_, i) => ( {Array.from({ length: 5 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5"> <div key={i} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" /> <div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" /> <div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-8" />
</div> </div>
))} ))}
</div> </div>
) : filteredTables.length === 0 ? ( ) : filteredDatabases.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无表</div> <div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
) : ( ) : (
<div className="divide-y divide-[var(--dg-border-light)]"> <div>
{filteredTables.map(t => ( {filteredDatabases.map(dbName => {
<div key={t.name}> const isExpanded = expandedDb === dbName;
<button const node = dbNodes[dbName];
onClick={() => handleExpandTable(t.name)} const tables = getFilteredTables(dbName);
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors group" const isActive = dbName === database;
>
{expandedTable === t.name
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
}
<Table2 className="w-3 h-3 text-blue-400" />
<span
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
title="点击插入"
>
{t.name}
</span>
{t.rows !== undefined && (
<span className="text-[9px] text-[var(--dg-text-muted)]">{t.rows.toLocaleString()}</span>
)}
</button>
{/* Columns */} return (
{expandedTable === t.name && ( <div key={dbName}>
<div className="ml-5 border-l border-[var(--dg-border-light)]"> {/* Level 1: Database */}
{loadingColumns ? ( <button
<div className="flex items-center gap-1 p-2 text-[10px] text-[var(--dg-text-muted)]"> onClick={() => handleExpandDb(dbName)}
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ... className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors ${
</div> isActive ? 'text-blue-400' : ''
) : ( }`}
tableColumns.map(col => ( >
<button {isExpanded
key={col.Field} ? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
onClick={() => onInsert(col.Field)} : <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors" }
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`} <Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
> <span className="font-mono truncate flex-1 text-left">{dbName}</span>
<Columns3 className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" /> {isActive && (
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span> <span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto">{col.Type}</span>
{col.Key === 'PRI' && <span className="text-[8px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
</button>
))
)} )}
</div> </button>
)}
</div> {/* Level 2: Tables */}
))} {isExpanded && (
<div className="ml-3 border-l border-[var(--dg-border-light)]">
{node?.loading ? (
<div className="space-y-1 px-2 py-1">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-1 py-1">
<div className="w-2.5 h-2.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
</div>
))}
</div>
) : tables.length === 0 ? (
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
) : (
tables.map(t => {
const tableKey = `${dbName}.${t.name}`;
const isTableExpanded = expandedTable === tableKey;
return (
<div key={t.name}>
<button
onClick={() => handleExpandTable(dbName, t.name)}
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
>
{isTableExpanded
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
}
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
<span
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
title="点击插入表名"
>
{t.name}
</span>
{t.rows !== undefined && (
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span>
)}
</button>
{/* Level 3: Columns */}
{isTableExpanded && (
<div className="ml-4 border-l border-[var(--dg-border-light)]">
{loadingColumns ? (
<div className="flex items-center gap-1 p-1.5 text-[10px] text-[var(--dg-text-muted)]">
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
</div>
) : (
tableColumns.map(col => (
<button
key={col.Field}
onClick={() => onInsert(col.Field)}
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
>
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span>
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span>
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>}
</button>
))
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
})}
</div> </div>
)} )}
</div> </div>