689 lines
33 KiB
TypeScript
689 lines
33 KiB
TypeScript
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<string, RedisTreeNode>;
|
|
}
|
|
|
|
/** 把 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<string>;
|
|
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
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 (
|
|
<div key={child.fullPrefix}>
|
|
<button
|
|
onClick={() => {
|
|
onSelectKey(child.keyItem!.key);
|
|
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
|
|
}}
|
|
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
|
|
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
|
}`}
|
|
style={{ paddingLeft: 8 + indent }}
|
|
>
|
|
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
|
|
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
|
|
</button>
|
|
{selectedKey === child.keyItem.key && (
|
|
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
|
|
{loadingDetail ? (
|
|
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
|
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
|
</div>
|
|
) : keyDetail ? (
|
|
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
|
|
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
|
</div>
|
|
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
|
{typeof keyDetail.value === 'object'
|
|
? JSON.stringify(keyDetail.value, null, 2)
|
|
: String(keyDetail.value)}
|
|
</pre>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 文件夹节点
|
|
const isOpen = expanded.has(child.fullPrefix);
|
|
const childCount = child.children.size;
|
|
return (
|
|
<div key={child.fullPrefix}>
|
|
<button
|
|
onClick={() => toggle(child.fullPrefix)}
|
|
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
|
style={{ paddingLeft: 8 + indent }}
|
|
>
|
|
{isOpen
|
|
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
|
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
|
}
|
|
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
|
<span className="truncate flex-1 text-left">{child.name}</span>
|
|
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
|
|
</button>
|
|
{isOpen && renderNode(child, depth + 1)}
|
|
</div>
|
|
);
|
|
});
|
|
};
|
|
|
|
return <div>{renderNode(tree, 0)}</div>;
|
|
}
|
|
|
|
/** 每个数据库节点缓存的表列表 */
|
|
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<string[]>([]);
|
|
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 [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
|
const [tableIndexes, setTableIndexes] = useState<IndexInfo[]>([]);
|
|
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<RedisKeyItem[]>([]);
|
|
const [loadingRedis, setLoadingRedis] = useState(false);
|
|
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
const [keyDetail, setKeyDetail] = useState<any>(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 (
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
|
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
|
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
{(isMySQL || isRedis) && (
|
|
<div className="flex border-b border-[var(--dg-border)]">
|
|
{isMySQL && (
|
|
<button
|
|
onClick={() => setActiveTab('tables')}
|
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
|
activeTab === 'tables'
|
|
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
|
}`}
|
|
>
|
|
<Database className="w-3 h-3" /> 库表
|
|
</button>
|
|
)}
|
|
{isRedis && (
|
|
<button
|
|
onClick={() => setActiveTab('redis')}
|
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
|
activeTab === 'redis'
|
|
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
|
}`}
|
|
>
|
|
<KeyRound className="w-3 h-3" /> Keys
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{activeTab === 'tables' && isMySQL && (
|
|
<div className="h-full flex flex-col">
|
|
{/* Search */}
|
|
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
<input
|
|
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="搜索库/表..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* DB → Table → Column tree */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loadingDbs ? (
|
|
<div className="space-y-1 p-2">
|
|
{Array.from({ length: 5 }, (_, i) => (
|
|
<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="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredDatabases.length === 0 ? (
|
|
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
|
|
) : (
|
|
<div>
|
|
{filteredDatabases.map(dbName => {
|
|
const isExpanded = expandedDb === dbName;
|
|
const node = dbNodes[dbName];
|
|
const tables = getFilteredTables(dbName);
|
|
const isActive = dbName === database;
|
|
|
|
return (
|
|
<div key={dbName}>
|
|
{/* Level 1: Database */}
|
|
<button
|
|
onClick={() => {
|
|
handleExpandDb(dbName);
|
|
if (onDatabaseChange && dbName !== database) onDatabaseChange(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 ${
|
|
isActive ? 'text-blue-400' : ''
|
|
}`}
|
|
>
|
|
{isExpanded
|
|
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
}
|
|
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
|
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
|
|
{isActive && (
|
|
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 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={(e) => {
|
|
if (e.altKey) { onInsert(`${dbName}.${t.name}`); }
|
|
else { 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"
|
|
title={`单击展开/折叠 · Alt+点击插入表名`}
|
|
>
|
|
{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">
|
|
{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 & Indexes */}
|
|
{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>
|
|
) : (
|
|
<>
|
|
{/* 字段/索引 Tab */}
|
|
<div className="flex border-b border-[var(--dg-border-light)]">
|
|
<button
|
|
onClick={() => setDetailTab('columns')}
|
|
className={`flex-1 px-2 py-1 text-[10px] font-medium border-b-2 transition-all ${
|
|
detailTab === 'columns'
|
|
? 'text-blue-400 border-blue-400'
|
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
|
}`}
|
|
>
|
|
<Columns3 className="w-2.5 h-2.5 inline mr-0.5" />
|
|
字段 ({tableColumns.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setDetailTab('indexes')}
|
|
className={`flex-1 px-2 py-1 text-[10px] font-medium border-b-2 transition-all ${
|
|
detailTab === 'indexes'
|
|
? 'text-blue-400 border-blue-400'
|
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
|
}`}
|
|
>
|
|
<KeyRound className="w-2.5 h-2.5 inline mr-0.5" />
|
|
索引 ({tableIndexes.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* 字段列表 */}
|
|
{detailTab === 'columns' && tableColumns.map(col => (
|
|
<button
|
|
key={col.Field}
|
|
onClick={() => onInsert(`${t.name}.${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>
|
|
))}
|
|
|
|
{/* 索引列表 */}
|
|
{detailTab === 'indexes' && (
|
|
tableIndexes.length === 0 ? (
|
|
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">无索引</div>
|
|
) : (
|
|
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<string, IndexInfo[]>)
|
|
).map(([keyName, cols]) => (
|
|
<div key={keyName} className="px-2 py-1">
|
|
<div className="flex items-center gap-1 text-[10px]">
|
|
<KeyRound className="w-2 h-2 flex-shrink-0" style={{ color: cols[0].Non_unique ? 'var(--dg-text-muted)' : '#facc15' }} />
|
|
<span className="font-mono text-[var(--dg-text-secondary)] truncate">{keyName}</span>
|
|
{!cols[0].Non_unique && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">UNI</span>}
|
|
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{cols[0].Index_type}</span>
|
|
</div>
|
|
<div className="ml-4 flex flex-wrap gap-x-2">
|
|
{cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => (
|
|
<span key={c.Column_name} className="text-[9px] font-mono text-[var(--dg-text-muted)]">{c.Column_name}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'redis' && isRedis && (
|
|
<div className="h-full flex flex-col">
|
|
{/* DB + Search */}
|
|
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
|
{clusterMode ? (
|
|
<div className="px-2 py-1 text-[10px] text-amber-400 bg-amber-500/10 rounded text-center">
|
|
集群模式 · DB0
|
|
</div>
|
|
) : (
|
|
<select
|
|
value={redisDb}
|
|
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
|
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
|
>
|
|
{Array.from({ length: 16 }, (_, i) => (
|
|
<option key={i} value={i}>DB{i}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
<input
|
|
value={redisPattern}
|
|
onChange={(e) => 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="*"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={fetchRedisKeys}
|
|
disabled={loadingRedis}
|
|
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Key Tree + Detail */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loadingRedis ? (
|
|
<div className="space-y-1 p-2">
|
|
{Array.from({ length: 6 }, (_, i) => (
|
|
<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="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-10" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : redisKeys.length === 0 ? (
|
|
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
|
|
) : (
|
|
<RedisTree
|
|
tree={buildRedisTree(redisKeys)}
|
|
expanded={redisExpanded}
|
|
setExpanded={setRedisExpanded}
|
|
selectedKey={selectedKey}
|
|
onSelectKey={handleSelectKey}
|
|
keyDetail={keyDetail}
|
|
loadingDetail={loadingDetail}
|
|
onInsert={onInsert}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|