refactor: Redis 树取长补短 — 单列内嵌详情 + 类型颜色图标 + 左竖线层级
取 SchemaSidebar (查询页) 之长: - 选中 key 内嵌展开详情(type/TTL/value),取代双列布局 - border-l 左竖线做层级指示,视觉更清晰 - 文件夹图标改为 text-amber-400/70 半透明琥珀色 - 搜索栏融入顶部 border-b 工具栏 保留变更页独有优势: - 点击叶子自动插入类型对应命令(GET/HGETALL/LRANGE/SMEMBERS/ZRANGE) - hover 显示插入提示 新增改进: - Key 图标按类型着色:string=绿 hash=蓝 list=紫 set=橙 zset=红 - 展开子级外包裹 border-l 容器,层级更直观 - 修复 renderRedisTreeNode 依赖列表(加 loadingRedisDetail/redisKeyDetail)
This commit is contained in:
1 parent
327ab33361
commit
2f76482eac
1 file changed
+77
-70
@@ -667,7 +667,6 @@ export default function TicketCreatePage() {
|
|||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
if (i === parts.length - 1) {
|
if (i === parts.length - 1) {
|
||||||
// leaf — replace with leaf node
|
|
||||||
node.fullKey = k.key;
|
node.fullKey = k.key;
|
||||||
node.type = k.type;
|
node.type = k.type;
|
||||||
delete node.children;
|
delete node.children;
|
||||||
@@ -677,12 +676,9 @@ export default function TicketCreatePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// sort: folders first, then leaves, alphabetically
|
|
||||||
const sortNodes = (arr: RedisTreeNode[]) => {
|
const sortNodes = (arr: RedisTreeNode[]) => {
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
const aIsLeaf = !!a.fullKey;
|
if (!!a.fullKey !== !!b.fullKey) return a.fullKey ? 1 : -1;
|
||||||
const bIsLeaf = !!b.fullKey;
|
|
||||||
if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1;
|
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
arr.forEach(n => { if (n.children) sortNodes(n.children); });
|
arr.forEach(n => { if (n.children) sortNodes(n.children); });
|
||||||
@@ -691,7 +687,6 @@ export default function TicketCreatePage() {
|
|||||||
return root;
|
return root;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* Redis expanded folders state */
|
|
||||||
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggleRedisFolder = useCallback((path: string) => {
|
const toggleRedisFolder = useCallback((path: string) => {
|
||||||
@@ -702,7 +697,6 @@ export default function TicketCreatePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Get Redis command for a key based on its type */
|
|
||||||
const redisCmdForKey = useCallback((key: string, type: string): string => {
|
const redisCmdForKey = useCallback((key: string, type: string): string => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'string': return `GET ${key}`;
|
case 'string': return `GET ${key}`;
|
||||||
@@ -714,29 +708,65 @@ export default function TicketCreatePage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Recursive tree renderer */
|
const keyTypeColor: Record<string, string> = {
|
||||||
|
string: 'text-emerald-400',
|
||||||
|
hash: 'text-blue-400',
|
||||||
|
list: 'text-purple-400',
|
||||||
|
set: 'text-orange-400',
|
||||||
|
zset: 'text-rose-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recursive tree renderer — single column with inline detail */
|
||||||
const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => {
|
const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => {
|
||||||
const path = parentPath ? `${parentPath}:${node.name}` : node.name;
|
const path = parentPath ? `${parentPath}:${node.name}` : node.name;
|
||||||
const isLeaf = !!node.fullKey;
|
const isLeaf = !!node.fullKey;
|
||||||
const isExpanded = redisExpanded.has(path);
|
const isExpanded = redisExpanded.has(path);
|
||||||
|
const indent = depth * 14;
|
||||||
|
|
||||||
if (isLeaf) {
|
if (isLeaf) {
|
||||||
|
const isSelected = selectedRedisKey === node.fullKey;
|
||||||
|
const keyIconColor = keyTypeColor[node.type || 'string'] || 'text-amber-400';
|
||||||
return (
|
return (
|
||||||
<button key={path}
|
<div key={path}>
|
||||||
onClick={() => {
|
<button
|
||||||
const cmd = redisCmdForKey(node.fullKey!, node.type || 'string');
|
onClick={() => {
|
||||||
setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + cmd);
|
const cmd = redisCmdForKey(node.fullKey!, node.type || 'string');
|
||||||
handleSelectRedisKey(node.fullKey!);
|
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)]'
|
className={`w-full flex items-center gap-1.5 py-1 text-[11px] font-mono transition-colors group ${
|
||||||
}`}
|
isSelected ? '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" />
|
style={{ paddingLeft: `${indent + 6}px` }}>
|
||||||
<span className="truncate flex-1 text-left">{node.name}</span>
|
<KeyRound className={`w-3 h-3 ${keyIconColor} shrink-0`} />
|
||||||
<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="truncate flex-1 text-left">{node.name}</span>
|
||||||
<span className="text-[9px] text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">插入</span>
|
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] shrink-0">{node.type}</span>
|
||||||
</button>
|
<span className="text-[9px] text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">插入</span>
|
||||||
|
</button>
|
||||||
|
{/* Inline key detail */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="px-2 pb-2 ml-4 border-l border-[var(--dg-border-light)]">
|
||||||
|
{loadingRedisDetail ? (
|
||||||
|
<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>
|
||||||
|
) : redisKeyDetail ? (
|
||||||
|
<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">{redisKeyDetail.type}</span>
|
||||||
|
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(redisKeyDetail.ttl)}</span>
|
||||||
|
{redisKeyDetail.size && <span className="text-[10px] text-[var(--dg-text-muted)]">{formatBytes(redisKeyDetail.size)}</span>}
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||||
|
{typeof redisKeyDetail.value === 'object'
|
||||||
|
? JSON.stringify(redisKeyDetail.value, null, 2)
|
||||||
|
: String(redisKeyDetail.value)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,30 +775,34 @@ export default function TicketCreatePage() {
|
|||||||
return (
|
return (
|
||||||
<div key={path}>
|
<div key={path}>
|
||||||
<button onClick={() => toggleRedisFolder(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"
|
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)] transition-colors"
|
||||||
style={{ paddingLeft: `${depth * 14 + 6}px` }}>
|
style={{ paddingLeft: `${indent + 6}px` }}>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />
|
? <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" />}
|
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />}
|
||||||
<FolderOpen className="w-3 h-3 text-blue-400 shrink-0" />
|
<FolderOpen className="w-3 h-3 text-amber-400/70 shrink-0" />
|
||||||
<span className="truncate">{node.name}</span>
|
<span className="truncate">{node.name}</span>
|
||||||
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto mr-1">{childCount}</span>
|
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto mr-1">{childCount}</span>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
|
{isExpanded && (
|
||||||
|
<div className="border-l border-[var(--dg-border-light)]" style={{ marginLeft: `${indent + 12}px` }}>
|
||||||
|
{node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey]);
|
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey, loadingRedisDetail, redisKeyDetail]);
|
||||||
|
|
||||||
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
|
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
|
||||||
|
|
||||||
const renderRedisTab = () => (
|
const renderRedisTab = () => (
|
||||||
<div className="p-3 space-y-3">
|
<div className="space-y-2">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)]">
|
||||||
<select
|
<select
|
||||||
value={redisDb}
|
value={redisDb}
|
||||||
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-[11px] focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
{databases.length > 0
|
{databases.length > 0
|
||||||
? databases.map(db => <option key={db} value={parseInt(db.replace('db', ''))}>{db}</option>)
|
? databases.map(db => <option key={db} value={parseInt(db.replace('db', ''))}>{db}</option>)
|
||||||
@@ -781,56 +815,29 @@ export default function TicketCreatePage() {
|
|||||||
value={redisPattern}
|
value={redisPattern}
|
||||||
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-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="Key pattern (e.g. matcher:*)"
|
placeholder="matcher:*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={fetchRedisKeys}
|
onClick={fetchRedisKeys}
|
||||||
disabled={loadingRedisKeys}
|
disabled={loadingRedisKeys}
|
||||||
className="px-2.5 py-1.5 bg-blue-500/15 text-blue-400 rounded text-xs hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1.5 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loadingRedisKeys ? <Loader2 className="w-3 h-3 animate-spin" /> : '扫描'}
|
{loadingRedisKeys ? <Loader2 className="w-3 h-3 animate-spin" /> : '扫描'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Tree */}
|
{/* Key Tree — single column with inline detail */}
|
||||||
{redisKeys.length > 0 ? (
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{redisKeys.length > 0 ? (
|
||||||
{/* Tree View */}
|
redisTree.map(node => renderRedisTreeNode(node, 0, ''))
|
||||||
<div className="space-y-0.5 max-h-[240px] overflow-y-auto">
|
) : (
|
||||||
{redisTree.map(node => renderRedisTreeNode(node, 0, ''))}
|
<div className="text-xs text-[var(--dg-text-muted)] text-center py-6">
|
||||||
|
{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Key Detail */}
|
</div>
|
||||||
<div className="bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] p-2.5">
|
|
||||||
{loadingRedisDetail ? (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" /> 加载...
|
|
||||||
</div>
|
|
||||||
) : redisKeyDetail ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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">{redisKeyDetail.type}</span>
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(redisKeyDetail.ttl)}</span>
|
|
||||||
{redisKeyDetail.size && <span className="text-[10px] text-[var(--dg-text-muted)]">{formatBytes(redisKeyDetail.size)}</span>}
|
|
||||||
</div>
|
|
||||||
<pre className="text-[11px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[180px] whitespace-pre-wrap">
|
|
||||||
{typeof redisKeyDetail.value === 'object'
|
|
||||||
? JSON.stringify(redisKeyDetail.value, null, 2)
|
|
||||||
: String(redisKeyDetail.value)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">点击 Key 查看详情</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">
|
|
||||||
{loadingRedisKeys ? '扫描中...' : '无 Keys'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user