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:
logan committed 2026-05-24 23:25:48 +08:00
1 parent 327ab33361
commit 2f76482eac
1 file changed
+65 -58
+65 -58
View File
@@ -667,7 +667,6 @@ export default function TicketCreatePage() {
nodes.push(node);
}
if (i === parts.length - 1) {
// leaf — replace with leaf node
node.fullKey = k.key;
node.type = k.type;
delete node.children;
@@ -677,12 +676,9 @@ export default function TicketCreatePage() {
}
}
}
// 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;
if (!!a.fullKey !== !!b.fullKey) return a.fullKey ? 1 : -1;
return a.name.localeCompare(b.name);
});
arr.forEach(n => { if (n.children) sortNodes(n.children); });
@@ -691,7 +687,6 @@ export default function TicketCreatePage() {
return root;
}, []);
/* Redis expanded folders state */
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
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 => {
switch (type) {
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 path = parentPath ? `${parentPath}:${node.name}` : node.name;
const isLeaf = !!node.fullKey;
const isExpanded = redisExpanded.has(path);
const indent = depth * 14;
if (isLeaf) {
const isSelected = selectedRedisKey === node.fullKey;
const keyIconColor = keyTypeColor[node.type || 'string'] || 'text-amber-400';
return (
<button key={path}
<div key={path}>
<button
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)]'
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` }}>
<KeyRound className={`w-3 h-3 ${keyIconColor} 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] px-1 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>
{/* 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 (
<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` }}>
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: `${indent + 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" />
<FolderOpen className="w-3 h-3 text-amber-400/70 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))}
{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>
);
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey]);
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey, loadingRedisDetail, redisKeyDetail]);
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
const renderRedisTab = () => (
<div className="p-3 space-y-3">
<div className="space-y-2">
{/* 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
value={redisDb}
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.map(db => <option key={db} value={parseInt(db.replace('db', ''))}>{db}</option>)
@@ -781,57 +815,30 @@ export default function TicketCreatePage() {
value={redisPattern}
onChange={(e) => setRedisPattern(e.target.value)}
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"
placeholder="Key pattern (e.g. matcher:*)"
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="matcher:*"
/>
</div>
<button
onClick={fetchRedisKeys}
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" /> : '扫描'}
</button>
</div>
{/* Key Tree */}
{/* Key Tree — single column with inline detail */}
<div className="max-h-[300px] overflow-y-auto">
{redisKeys.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{/* Tree View */}
<div className="space-y-0.5 max-h-[240px] overflow-y-auto">
{redisTree.map(node => renderRedisTreeNode(node, 0, ''))}
</div>
{/* Key Detail */}
<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>
redisTree.map(node => renderRedisTreeNode(node, 0, ''))
) : (
<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-6">
{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}
</div>
)}
</div>
</div>
) : (
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">
{loadingRedisKeys ? '扫描中...' : '无 Keys'}
</div>
)}
</div>
);
/* ---- Collections Tab (MongoDB) ---- */