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);
|
||||
}
|
||||
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}
|
||||
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>
|
||||
<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 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: `${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 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,56 +815,29 @@ 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 */}
|
||||
{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, ''))}
|
||||
{/* Key Tree — single column with inline detail */}
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{redisKeys.length > 0 ? (
|
||||
redisTree.map(node => renderRedisTreeNode(node, 0, ''))
|
||||
) : (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] text-center py-6">
|
||||
{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}
|
||||
</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>
|
||||
) : (
|
||||
<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