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:
logan committed 2026-05-24 23:16:22 +08:00
1 parent ed13243bb0
commit 327ab33361
1 file changed
+124 -19
+124 -19
View File
@@ -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>