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>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
defaultHighlightStyle,
|
||||
bracketMatching,
|
||||
} from '@codemirror/language';
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,6 +26,8 @@ export interface SqlEditorProps {
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
onExecute?: () => void;
|
||||
/** SQL schema: { [dbName]: { [tableName]: string[] (columns) } } or { [tableName]: string[] } */
|
||||
schema?: Record<string, Record<string, string[]> | string[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,7 +76,7 @@ const MONGODB_KEYWORDS = [
|
||||
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
|
||||
];
|
||||
|
||||
function getSQLExtension(language: SqlEditorProps['language']): Extension {
|
||||
function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEditorProps['schema']): Extension {
|
||||
const lang = language ?? 'sql';
|
||||
|
||||
if (lang === 'redis') {
|
||||
@@ -91,6 +93,43 @@ function getSQLExtension(language: SqlEditorProps['language']): Extension {
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
// MySQL/PG — pass schema for autocomplete (tables + columns)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
// Flatten: if first value is an array, it's { table: columns[] }
|
||||
// if first value is an object, it's { db: { table: columns[] } }
|
||||
const sampleValue = Object.values(schema)[0];
|
||||
let tables: Record<string, readonly string[]> = {};
|
||||
|
||||
if (Array.isArray(sampleValue)) {
|
||||
// Direct: { table: [columns] }
|
||||
tables = Object.fromEntries(
|
||||
Object.entries(schema).map(([k, v]) => [k, v as string[]])
|
||||
);
|
||||
} else {
|
||||
// Nested: { db: { table: [columns] } }
|
||||
// Register: db → table names (so "db." shows tables)
|
||||
// Register: table → columns (so "table." shows columns)
|
||||
for (const [db, tableMap] of Object.entries(schema)) {
|
||||
const entries = tableMap as Record<string, string[]>;
|
||||
const tableNames = Object.keys(entries);
|
||||
// "shop_db" → shows table names as completions after "."
|
||||
tables[db] = tableNames;
|
||||
for (const [table, cols] of Object.entries(entries)) {
|
||||
// "goods" → shows column names after "."
|
||||
if (cols.length > 0) {
|
||||
tables[table] = cols;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
|
||||
schema: tables,
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
return sql();
|
||||
}
|
||||
|
||||
@@ -228,6 +267,7 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
||||
height = '200px',
|
||||
readOnly = false,
|
||||
onExecute,
|
||||
schema,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
@@ -294,11 +334,15 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
icons: false,
|
||||
}),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
appTheme,
|
||||
oneDark,
|
||||
langCompartmentRef.current.of(getSQLExtension(language)),
|
||||
langCompartmentRef.current.of(getSQLExtension(language, schema)),
|
||||
readOnlyCompartmentRef.current.of(
|
||||
EditorState.readOnly.of(readOnly),
|
||||
),
|
||||
@@ -345,14 +389,14 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Update language when it changes
|
||||
// Update language/schema when they change
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)),
|
||||
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
|
||||
});
|
||||
}, [language]);
|
||||
}, [language, schema]);
|
||||
|
||||
// Update readOnly when it changes
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user