feat: 查询页增强 + 权限体系优化
- Redis DB0~DB15 下拉选择,默认DB0 - 查询历史粘贴按钮(一键填入SQL编辑器) - 去掉导出历史按钮 - SchemaSidebar 按目标数据库筛选 - SQL自动补全 schema 双层注册(db→tables, table→columns) - 后端 db-schema 接口(INFORMATION_SCHEMA.COLUMNS 一次加载) - autocompletion 扩展激活 - Redis key 树形结构(冒号分隔层级) - Redis 查询模板 GET/HGETALL/HGET - guest 角色彻底移除,改为 auditor - 登录页优化:去掉演示账号、placeholder 中文提示
This commit is contained in:
1 parent
aa2e26c827
commit
35a014e571
13 files changed
+345
-111
No files matched your search
@@ -23,6 +23,134 @@ interface RedisKeyItem {
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/** Redis key 树节点 */
|
||||
interface RedisTreeNode {
|
||||
name: string;
|
||||
fullPrefix: string;
|
||||
isLeaf: boolean;
|
||||
keyItem?: RedisKeyItem; // 叶子节点才有
|
||||
children: Map<string, RedisTreeNode>;
|
||||
}
|
||||
|
||||
/** 把 flat keys 按 ':' 构建成树 */
|
||||
function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode {
|
||||
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() };
|
||||
for (const item of keys) {
|
||||
const parts = item.key.split(':');
|
||||
let node = root;
|
||||
let prefix = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
prefix += (i === 0 ? '' : ':') + parts[i];
|
||||
if (!node.children.has(parts[i])) {
|
||||
node.children.set(parts[i], {
|
||||
name: parts[i],
|
||||
fullPrefix: prefix,
|
||||
isLeaf: i === parts.length - 1,
|
||||
keyItem: i === parts.length - 1 ? item : undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
node = node.children.get(parts[i])!;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Redis 树形渲染组件 */
|
||||
function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail }: {
|
||||
tree: RedisTreeNode;
|
||||
expanded: Set<string>;
|
||||
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
selectedKey: string | null;
|
||||
onSelectKey: (key: string) => void;
|
||||
keyDetail: any;
|
||||
loadingDetail: boolean;
|
||||
}) {
|
||||
const toggle = (prefix: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(prefix) ? next.delete(prefix) : next.add(prefix);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderNode = (node: RedisTreeNode, depth: number) => {
|
||||
const sorted = [...node.children.values()].sort((a, b) => {
|
||||
// 文件夹优先,叶子在后
|
||||
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sorted.map(child => {
|
||||
const indent = depth * 12;
|
||||
|
||||
if (child.isLeaf && child.keyItem) {
|
||||
// 叶子节点:实际 Redis key
|
||||
return (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => onSelectKey(child.keyItem!.key)}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
|
||||
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
|
||||
</button>
|
||||
{selectedKey === child.keyItem.key && (
|
||||
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
|
||||
{loadingDetail ? (
|
||||
<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>
|
||||
) : keyDetail ? (
|
||||
<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">{keyDetail.type}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹节点
|
||||
const isOpen = expanded.has(child.fullPrefix);
|
||||
const childCount = child.children.size;
|
||||
return (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => toggle(child.fullPrefix)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
}
|
||||
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
||||
<span className="truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
|
||||
</button>
|
||||
{isOpen && renderNode(child, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <div>{renderNode(tree, 0)}</div>;
|
||||
}
|
||||
|
||||
/** 每个数据库节点缓存的表列表 */
|
||||
interface DbNode {
|
||||
name: string;
|
||||
@@ -66,6 +194,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
|
||||
const [redisPattern, setRedisPattern] = useState('*');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
|
||||
const [loadingRedis, setLoadingRedis] = useState(false);
|
||||
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyDetail, setKeyDetail] = useState<any>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
@@ -186,6 +315,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const filteredDatabases = databases.filter(dbName => {
|
||||
// 如果已选目标数据库,只显示该库
|
||||
if (database && dbName !== database) return false;
|
||||
if (!search) return true;
|
||||
const kw = search.toLowerCase();
|
||||
// 库名匹配
|
||||
@@ -433,7 +564,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Key List + Detail */}
|
||||
{/* Key Tree + Detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRedis ? (
|
||||
<div className="space-y-1 p-2">
|
||||
@@ -448,45 +579,15 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
{redisKeys.map(k => (
|
||||
<div key={k.key}>
|
||||
<button
|
||||
onClick={() => handleSelectKey(k.key)}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs transition-colors ${
|
||||
selectedKey === 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 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
|
||||
</button>
|
||||
|
||||
{/* Key Detail */}
|
||||
{selectedKey === k.key && (
|
||||
<div className="px-2 pb-2">
|
||||
{loadingDetail ? (
|
||||
<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>
|
||||
) : keyDetail ? (
|
||||
<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">{keyDetail.type}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<RedisTree
|
||||
tree={buildRedisTree(redisKeys)}
|
||||
expanded={redisExpanded}
|
||||
setExpanded={setRedisExpanded}
|
||||
selectedKey={selectedKey}
|
||||
onSelectKey={handleSelectKey}
|
||||
keyDetail={keyDetail}
|
||||
loadingDetail={loadingDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user