feat: Redis Keys 目录树结构浏览 — 按 : 分隔符构建层级树
- buildRedisTree: 将扁平 key 列表按 ':' 分隔构建为树结构 - 文件夹节点可展开/折叠,显示子节点数量 - 叶子节点显示 key type 标签(string/hash/list/set/zset) - 点击叶子节点自动插入对应查询命令: - string → GET key - hash → HGETALL key - list → LRANGE key 0 -1 - set → SMEMBERS key - zset → ZRANGE key 0 -1 WITHSCORES - 右侧保留 Key 详情面板(类型/TTL/值预览) - DB 下拉改为从后端 databases 数据生成 - 树按文件夹优先+字母排序
This commit is contained in:
1 parent
ed13243bb0
commit
327ab33361
1 file changed
+124
-19
@@ -645,6 +645,122 @@ export default function TicketCreatePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ---- Redis Tab ---- */
|
/* ---- Redis Tab ---- */
|
||||||
|
/* ---- Redis Tree Node type ---- */
|
||||||
|
interface RedisTreeNode {
|
||||||
|
name: string;
|
||||||
|
fullKey?: string; // only leaf nodes
|
||||||
|
type?: string; // key type: string/hash/list/set/zset
|
||||||
|
children?: RedisTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a tree from flat key list using ':' as delimiter */
|
||||||
|
const buildRedisTree = useCallback((keys: RedisKeyItem[]): RedisTreeNode[] => {
|
||||||
|
const root: RedisTreeNode[] = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const parts = k.key.split(':');
|
||||||
|
let nodes = root;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
let node = nodes.find(n => n.name === part);
|
||||||
|
if (!node) {
|
||||||
|
node = { name: part, children: [] };
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
if (i === parts.length - 1) {
|
||||||
|
// leaf — replace with leaf node
|
||||||
|
node.fullKey = k.key;
|
||||||
|
node.type = k.type;
|
||||||
|
delete node.children;
|
||||||
|
} else {
|
||||||
|
if (!node.children) node.children = [];
|
||||||
|
nodes = node.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sort: folders first, then leaves, alphabetically
|
||||||
|
const sortNodes = (arr: RedisTreeNode[]) => {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const aIsLeaf = !!a.fullKey;
|
||||||
|
const bIsLeaf = !!b.fullKey;
|
||||||
|
if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
arr.forEach(n => { if (n.children) sortNodes(n.children); });
|
||||||
|
};
|
||||||
|
sortNodes(root);
|
||||||
|
return root;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Redis expanded folders state */
|
||||||
|
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleRedisFolder = useCallback((path: string) => {
|
||||||
|
setRedisExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) next.delete(path); else next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Get Redis command for a key based on its type */
|
||||||
|
const redisCmdForKey = useCallback((key: string, type: string): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 'string': return `GET ${key}`;
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Recursive tree renderer */
|
||||||
|
const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => {
|
||||||
|
const path = parentPath ? `${parentPath}:${node.name}` : node.name;
|
||||||
|
const isLeaf = !!node.fullKey;
|
||||||
|
const isExpanded = redisExpanded.has(path);
|
||||||
|
|
||||||
|
if (isLeaf) {
|
||||||
|
return (
|
||||||
|
<button key={path}
|
||||||
|
onClick={() => {
|
||||||
|
const cmd = redisCmdForKey(node.fullKey!, node.type || 'string');
|
||||||
|
setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + cmd);
|
||||||
|
handleSelectRedisKey(node.fullKey!);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-1.5 pl-2 pr-2 py-1 text-[11px] font-mono rounded-sm transition-colors group ${
|
||||||
|
selectedRedisKey === node.fullKey ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 6}px` }}>
|
||||||
|
<KeyRound className="w-3 h-3 text-amber-400 shrink-0" />
|
||||||
|
<span className="truncate flex-1 text-left">{node.name}</span>
|
||||||
|
<span className="text-[9px] px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] shrink-0">{node.type}</span>
|
||||||
|
<span className="text-[9px] text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">插入</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// folder node
|
||||||
|
const childCount = node.children?.length || 0;
|
||||||
|
return (
|
||||||
|
<div key={path}>
|
||||||
|
<button onClick={() => toggleRedisFolder(path)}
|
||||||
|
className="w-full flex items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-sm transition-colors"
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 6}px` }}>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />
|
||||||
|
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />}
|
||||||
|
<FolderOpen className="w-3 h-3 text-blue-400 shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto mr-1">{childCount}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey]);
|
||||||
|
|
||||||
|
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
|
||||||
|
|
||||||
const renderRedisTab = () => (
|
const renderRedisTab = () => (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
@@ -654,9 +770,10 @@ export default function TicketCreatePage() {
|
|||||||
onChange={(e) => { setRedisDb(Number(e.target.value)); fetchRedisKeys(); }}
|
onChange={(e) => { setRedisDb(Number(e.target.value)); fetchRedisKeys(); }}
|
||||||
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
{Array.from({ length: 16 }, (_, i) => (
|
{databases.length > 0
|
||||||
<option key={i} value={i}>DB{i}</option>
|
? databases.map(db => <option key={db} value={parseInt(db.replace('db', ''))}>{db}</option>)
|
||||||
))}
|
: Array.from({ length: 16 }, (_, i) => <option key={i} value={i}>DB{i}</option>)
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<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)]" />
|
||||||
@@ -665,7 +782,7 @@ export default function TicketCreatePage() {
|
|||||||
onChange={(e) => setRedisPattern(e.target.value)}
|
onChange={(e) => setRedisPattern(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
|
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
|
||||||
className="w-full pl-7 pr-3 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
className="w-full pl-7 pr-3 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
||||||
placeholder="Key pattern (e.g. user:*)"
|
placeholder="Key pattern (e.g. matcher:*)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -680,21 +797,9 @@ export default function TicketCreatePage() {
|
|||||||
{/* Key Tree */}
|
{/* Key Tree */}
|
||||||
{redisKeys.length > 0 ? (
|
{redisKeys.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{/* Key List */}
|
{/* Tree View */}
|
||||||
<div className="space-y-0.5 max-h-[240px] overflow-y-auto">
|
<div className="space-y-0.5 max-h-[240px] overflow-y-auto">
|
||||||
{redisKeys.map((k) => (
|
{redisTree.map(node => renderRedisTreeNode(node, 0, ''))}
|
||||||
<button
|
|
||||||
key={k.key}
|
|
||||||
onClick={() => handleSelectRedisKey(k.key)}
|
|
||||||
className={`flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded transition-colors ${
|
|
||||||
selectedRedisKey === k.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
<span className="font-mono truncate flex-1 text-left">{k.key}</span>
|
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Detail */}
|
{/* Key Detail */}
|
||||||
@@ -717,7 +822,7 @@ export default function TicketCreatePage() {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">选择 Key 查看详情</div>
|
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">点击 Key 查看详情</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user