feat(redis): shared key browser with grouped tree + cursor pagination

New src/features/redis/keyBrowser/ shared by RedisDetailPage keys tab and
SchemaSidebar redis branch (both previously flat one-shot lists):
- keyTree: ':'-separated folder tree with recursive key counts, numeric
  sort, exact-hit pinned at root, local key removal (no full refetch),
  type color dots, readable TTL (45s/5m30s/2d5h)
- useRedisKeyBrowser: cursor state machine + request-sequence guard +
  Map merge on load-more; setter-synced args (Enter right after typing
  no longer races stale render), auto-search on db/instance change
- RedisKeyBrowser: enter-to-scan search bar (SCAN has cost - no debounce,
  opsxdb-style) with clear button, effective type filter, load-more
  button with cursor hint, '已全部加载' end state
- useRedisDbs: real db list from /redis/:id/dbs (cluster -> DB0 banner)
- RedisDetailPage: hash row edits now use field-level HSET/HDEL endpoint
  (preserves other fields + TTL); delete/add update tree locally; CLI
  output renders arrays/objects/errors properly
- SchemaSidebar: redis branch swaps to shared browser (compact mode,
  inline detail preview + insert-command preserved), auto-switches to
  Keys tab for redis instances
- vite dev proxy target overridable via API_PROXY_TARGET (local testing)

Browser-verified: 200/page with load-more (200->400->600), exact hit
badge, keyword auto-expand, key detail panel, queries sidebar
This commit is contained in:
Logan.Li committed 2026-10-26 18:39:38 +08:00
1 parent 6604fdf48e
commit ff539b47e0
8 files changed
+1762 -651

No files matched your search

+39 -13
View File
@@ -1,17 +1,36 @@
import request from '../request'; import request from '../request';
/** 键摘要(TTL 毫秒,-1 永久) */
export interface RedisKeySummary {
key: string;
type: string;
ttl: number;
}
/** 游标分页扫描响应 */
export interface RedisScanPage {
keys: RedisKeySummary[];
/** 不透明游标;空串表示已扫完 */
next_cursor: string;
exact: RedisKeySummary | null;
cluster: boolean;
}
export const redisApi = { export const redisApi = {
// Redis 信息概览 // Redis 信息概览
getInfo: (instanceId: number) => getInfo: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/info'),
request.get<any, any>('/redis/' + instanceId + '/info'),
// 库列表(集群恒 [0])
getDbs: (instanceId: number) =>
request.get<any, { dbs: number[]; cluster: boolean }>('/redis/' + instanceId + '/dbs'),
// DBSIZE // DBSIZE
getDbsize: (instanceId: number, db?: number) => getDbsize: (instanceId: number, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }), request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
// 键列表 // 键列表(游标分页;无通配符自动按 *关键词* 包含匹配并做精确命中探测)
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) => scanKeys: (instanceId: number, params: { db?: number; pattern?: string; type?: string; cursor?: string }) =>
request.get<any, any>('/redis/' + instanceId + '/keys', { params }), request.get<any, RedisScanPage>('/redis/' + instanceId + '/keys', { params }),
// 键详情 // 键详情
getKeyDetail: (instanceId: number, key: string, db?: number) => getKeyDetail: (instanceId: number, key: string, db?: number) =>
@@ -22,24 +41,31 @@ export const redisApi = {
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }), request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
// 客户端列表 // 客户端列表
getClients: (instanceId: number) => getClients: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/clients'),
request.get<any, any>('/redis/' + instanceId + '/clients'),
// 执行命令 // 执行只读命令(整串,引号感知;白名单外的命令会被拒绝)
executeCommand: (instanceId: number, data: { db: number; command: string }) => executeCommand: (instanceId: number, data: { db: number; command: string }) =>
request.post<any, any>('/redis/' + instanceId + '/command', data), request.post<any, any>('/redis/' + instanceId + '/command', data),
// 获取键列表(兼容旧接口) // Hash 行级编辑(op: set/del,不整键重写)
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) => updateKeyField: (
request.get<any, any>('/redis/' + instanceId + '/keys', { params }), instanceId: number,
key: string,
data: { db?: number; op: 'set' | 'del'; field: string; value?: string },
) =>
request.post<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key) + '/field', data, {
params: { db: data.db ?? 0 },
}),
// 创建键 // 创建键
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) => createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
request.post<any, any>('/redis/' + instanceId + '/keys', data), request.post<any, any>('/redis/' + instanceId + '/keys', data),
// 更新键值(整体替换 value,并可设置 TTL) // 更新键值(整体替换 value;ttl 省略时保留原 TTL)
updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) => updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) =>
request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }), request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, {
params: { db: data.db ?? 0 },
}),
// 删除键 // 删除键
deleteKey: (instanceId: number, key: string, db?: number) => deleteKey: (instanceId: number, key: string, db?: number) =>
+191 -298
View File
@@ -1,10 +1,20 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
ChevronDown, ChevronRight, Database, Table2, Columns3, ChevronDown,
KeyRound, Search, Loader2, FolderOpen, Server, ChevronRight,
Database,
Table2,
Columns3,
KeyRound,
Search,
Loader2,
FolderOpen,
Server,
} from 'lucide-react'; } from 'lucide-react';
import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail'; import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis'; import { redisApi } from '@/api/redis';
import { RedisKeyBrowser } from '@/features/redis/keyBrowser/RedisKeyBrowser';
import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
@@ -22,156 +32,6 @@ interface Props {
singleDb?: boolean; singleDb?: boolean;
} }
interface RedisKeyItem {
key: string;
type: string;
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 树形渲染组件 */
/** 根据 key type 生成查询命令 */
function redisCommand(key: string, type: string): string {
switch (type) {
case 'hash': return `HGETALL ${key}`;
case 'list': return `LRANGE ${key} 0 -1`;
case 'set': return `SMEMBERS ${key}`;
case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`;
default: return `GET ${key}`;
}
}
function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: {
tree: RedisTreeNode;
expanded: Set<string>;
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
selectedKey: string | null;
onSelectKey: (key: string) => void;
keyDetail: any;
loadingDetail: boolean;
onInsert?: (cmd: string) => void;
}) {
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);
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
}}
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 { interface DbNode {
name: string; name: string;
tables: TableMeta[]; tables: TableMeta[];
@@ -195,7 +55,15 @@ function formatTTL(ttl: number): string {
/* COMPONENT */ /* COMPONENT */
/* ================================================================== */ /* ================================================================== */
export default function SchemaSidebar({ instanceId, database, dbType, clusterMode, onInsert, onDatabaseChange, singleDb }: Props) { export default function SchemaSidebar({
instanceId,
database,
dbType,
clusterMode,
onInsert,
onDatabaseChange,
singleDb,
}: Props) {
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
const isRedis = dbType === 'redis'; const isRedis = dbType === 'redis';
@@ -211,12 +79,9 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns'); const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
// -- Redis State -- // -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
const [redisDb, setRedisDb] = useState(0); const [redisDb, setRedisDb] = useState(0);
const [redisPattern, setRedisPattern] = useState('*'); const redisDbs = useRedisDbs(Number(instanceId));
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 [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyDetail, setKeyDetail] = useState<any>(null); const [keyDetail, setKeyDetail] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
@@ -224,14 +89,24 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
// -- Tab -- // -- Tab --
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
// 驱动类型切换时落到对应 tab:redis → Keys,SQL 库 → 库表
useEffect(() => {
if (isRedis) setActiveTab('redis');
else setActiveTab('tables');
}, [isRedis]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch databases (level 1) */ /* Fetch databases (level 1) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
useEffect(() => { useEffect(() => {
if (!instanceId || !isMySQL) { setDatabases([]); return; } if (!instanceId || !isMySQL) {
setDatabases([]);
return;
}
setLoadingDbs(true); setLoadingDbs(true);
instanceDetailApi.listDatabases(Number(instanceId)) instanceDetailApi
.listDatabases(Number(instanceId))
.then((data) => { .then((data) => {
const list = Array.isArray(data) ? data : []; const list = Array.isArray(data) ? data : [];
setDatabases(list); setDatabases(list);
@@ -255,7 +130,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
/* Expand database → load tables (level 2) */ /* Expand database → load tables (level 2) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const handleExpandDb = useCallback(async (dbName: string) => { const handleExpandDb = useCallback(
async (dbName: string) => {
if (expandedDb === dbName) { if (expandedDb === dbName) {
setExpandedDb(null); setExpandedDb(null);
return; return;
@@ -266,23 +142,29 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
// 已加载过则跳过 // 已加载过则跳过
if (dbNodes[dbName]?.loaded) return; if (dbNodes[dbName]?.loaded) return;
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } })); setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } }));
try { try {
const data = await instanceDetailApi.listTables(Number(instanceId), dbName); const data = await instanceDetailApi.listTables(Number(instanceId), dbName);
const tables = Array.isArray(data) ? data : []; const tables = Array.isArray(data) ? data : [];
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } })); setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } }));
} catch { } catch {
setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } })); setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } }));
} }
}, [expandedDb, instanceId, dbNodes]); },
[expandedDb, instanceId, dbNodes],
);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Expand table → load columns (level 3) */ /* Expand table → load columns (level 3) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const handleExpandTable = useCallback(async (dbName: string, tableName: string) => { const handleExpandTable = useCallback(
async (dbName: string, tableName: string) => {
const key = `${dbName}.${tableName}`; const key = `${dbName}.${tableName}`;
if (expandedTable === key) { setExpandedTable(null); return; } if (expandedTable === key) {
setExpandedTable(null);
return;
}
setExpandedTable(key); setExpandedTable(key);
setTableColumns([]); setTableColumns([]);
setTableIndexes([]); setTableIndexes([]);
@@ -292,79 +174,51 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName);
setTableColumns(detail?.columns || []); setTableColumns(detail?.columns || []);
setTableIndexes(detail?.indexes || []); setTableIndexes(detail?.indexes || []);
} catch { /* ignore */ } } catch {
finally { setLoadingColumns(false); } /* ignore */
}, [expandedTable, instanceId]); } finally {
setLoadingColumns(false);
}
},
[expandedTable, instanceId],
);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch Redis keys */ /* Fetch Redis keys */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const fetchRedisKeys = useCallback(async () => {
if (!instanceId) return;
setLoadingRedis(true);
try {
const res = await redisApi.scanKeys(Number(instanceId), {
db: redisDb, pattern: redisPattern || '*', count: 100,
});
const keys = (res?.keys || []).map((k: any) => ({
key: typeof k === 'string' ? k : k.key,
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
ttl: k.ttl ?? -1,
}));
setRedisKeys(keys);
} catch { setRedisKeys([]); }
finally { setLoadingRedis(false); }
}, [instanceId, redisDb, redisPattern]);
useEffect(() => {
if (isRedis && instanceId) fetchRedisKeys();
}, [isRedis, instanceId, fetchRedisKeys]);
useEffect(() => {
const keyword = redisPattern.trim();
if (!isRedis || redisKeys.length === 0 || !keyword || keyword === '*') return;
setRedisExpanded(prev => {
const next = new Set(prev);
redisKeys.forEach(item => {
const parts = item.key.split(':');
let prefix = '';
for (let i = 0; i < parts.length - 1; i++) {
prefix += (i === 0 ? '' : ':') + parts[i];
next.add(prefix);
}
});
return next;
});
}, [isRedis, redisKeys, redisPattern]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Fetch Redis key detail */ /* Fetch Redis key detail */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const handleSelectKey = useCallback(async (key: string) => { const handleSelectKey = useCallback(
async (key: string) => {
setSelectedKey(key); setSelectedKey(key);
setLoadingDetail(true); setLoadingDetail(true);
try { try {
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
setKeyDetail(detail); setKeyDetail(detail);
} catch { setKeyDetail(null); } } catch {
finally { setLoadingDetail(false); } setKeyDetail(null);
}, [instanceId, redisDb]); } finally {
setLoadingDetail(false);
}
},
[instanceId, redisDb],
);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Filtered databases (search matches db name or table name) */ /* Filtered databases (search matches db name or table name) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const filteredDatabases = databases.filter(dbName => { const filteredDatabases = databases.filter((dbName) => {
if (singleDb && database && dbName !== database) return false; if (singleDb && database && dbName !== database) return false;
if (!search) return true; if (!search) return true;
const kw = search.toLowerCase(); const kw = search.toLowerCase();
if (dbName.toLowerCase().includes(kw)) return true; if (dbName.toLowerCase().includes(kw)) return true;
const node = dbNodes[dbName]; const node = dbNodes[dbName];
if (node?.loaded) { if (node?.loaded) {
return node.tables.some(t => t.name.toLowerCase().includes(kw)); return node.tables.some((t) => t.name.toLowerCase().includes(kw));
} }
return false; return false;
}); });
@@ -374,7 +228,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
if (!node?.loaded) return []; if (!node?.loaded) return [];
if (!search) return node.tables; if (!search) return node.tables;
const kw = search.toLowerCase(); const kw = search.toLowerCase();
return node.tables.filter(t => t.name.toLowerCase().includes(kw)); return node.tables.filter((t) => t.name.toLowerCase().includes(kw));
}; };
/* ================================================================== */ /* ================================================================== */
@@ -465,7 +319,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div> <div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
) : ( ) : (
<div> <div>
{filteredDatabases.map(dbName => { {filteredDatabases.map((dbName) => {
const isExpanded = expandedDb === dbName; const isExpanded = expandedDb === dbName;
const node = dbNodes[dbName]; const node = dbNodes[dbName];
const tables = getFilteredTables(dbName); const tables = getFilteredTables(dbName);
@@ -483,14 +337,17 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
isActive ? 'text-blue-400' : '' isActive ? 'text-blue-400' : ''
}`} }`}
> >
{isExpanded {isExpanded ? (
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" /> <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" /> ) : (
} <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
)}
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" /> <Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left">{dbName}</span> <span className="font-mono truncate flex-1 text-left">{dbName}</span>
{isActive && ( {isActive && (
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span> <span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">
当前
</span>
)} )}
</button> </button>
@@ -509,7 +366,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
) : tables.length === 0 ? ( ) : tables.length === 0 ? (
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div> <div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
) : ( ) : (
tables.map(t => { tables.map((t) => {
const tableKey = `${dbName}.${t.name}`; const tableKey = `${dbName}.${t.name}`;
const isTableExpanded = expandedTable === tableKey; const isTableExpanded = expandedTable === tableKey;
@@ -517,22 +374,28 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
<div key={t.name}> <div key={t.name}>
<button <button
onClick={(e) => { onClick={(e) => {
if (e.altKey) { onInsert(`${dbName}.${t.name}`); } if (e.altKey) {
else { handleExpandTable(dbName, t.name); } onInsert(`${dbName}.${t.name}`);
} else {
handleExpandTable(dbName, t.name);
}
}} }}
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group" className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
title={`单击展开/折叠 · Alt+点击插入表名`} title={`单击展开/折叠 · Alt+点击插入表名`}
> >
{isTableExpanded {isTableExpanded ? (
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" /> <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" /> ) : (
} <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
)}
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" /> <Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"> <span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
{t.name} {t.name}
</span> </span>
{t.rows !== undefined && ( {t.rows !== undefined && (
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span> <span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">
{t.rows.toLocaleString()}
</span>
)} )}
</button> </button>
@@ -572,7 +435,8 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
</div> </div>
{/* 字段列表 */} {/* 字段列表 */}
{detailTab === 'columns' && tableColumns.map(col => ( {detailTab === 'columns' &&
tableColumns.map((col) => (
<button <button
key={col.Field} key={col.Field}
onClick={() => onInsert(`${t.name}.${col.Field}`)} onClick={() => onInsert(`${t.name}.${col.Field}`)}
@@ -580,40 +444,74 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`} title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
> >
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" /> <Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span> <span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span> {col.Field}
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>} </span>
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
{col.Type}
</span>
{col.Key === 'PRI' && (
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
PK
</span>
)}
</button> </button>
))} ))}
{/* 索引列表 */} {/* 索引列表 */}
{detailTab === 'indexes' && ( {detailTab === 'indexes' &&
tableIndexes.length === 0 ? ( (tableIndexes.length === 0 ? (
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">无索引</div> <div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">
无索引
</div>
) : ( ) : (
Object.entries( Object.entries(
tableIndexes.reduce((acc, idx) => { tableIndexes.reduce(
(acc, idx) => {
if (!acc[idx.Key_name]) acc[idx.Key_name] = []; if (!acc[idx.Key_name]) acc[idx.Key_name] = [];
acc[idx.Key_name].push(idx); acc[idx.Key_name].push(idx);
return acc; return acc;
}, {} as Record<string, IndexInfo[]>) },
{} as Record<string, IndexInfo[]>,
),
).map(([keyName, cols]) => ( ).map(([keyName, cols]) => (
<div key={keyName} className="px-2 py-1"> <div key={keyName} className="px-2 py-1">
<div className="flex items-center gap-1 text-[10px]"> <div className="flex items-center gap-1 text-[10px]">
<KeyRound className="w-2 h-2 flex-shrink-0" style={{ color: cols[0].Non_unique ? 'var(--dg-text-muted)' : '#facc15' }} /> <KeyRound
<span className="font-mono text-[var(--dg-text-secondary)] truncate">{keyName}</span> className="w-2 h-2 flex-shrink-0"
{!cols[0].Non_unique && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">UNI</span>} style={{
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{cols[0].Index_type}</span> color: cols[0].Non_unique
? 'var(--dg-text-muted)'
: '#facc15',
}}
/>
<span className="font-mono text-[var(--dg-text-secondary)] truncate">
{keyName}
</span>
{!cols[0].Non_unique && (
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
UNI
</span>
)}
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
{cols[0].Index_type}
</span>
</div> </div>
<div className="ml-4 flex flex-wrap gap-x-2"> <div className="ml-4 flex flex-wrap gap-x-2">
{cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => ( {cols
<span key={c.Column_name} className="text-[9px] font-mono text-[var(--dg-text-muted)]">{c.Column_name}</span> .sort((a, b) => a.Seq_in_index - b.Seq_in_index)
.map((c) => (
<span
key={c.Column_name}
className="text-[9px] font-mono text-[var(--dg-text-muted)]"
>
{c.Column_name}
</span>
))} ))}
</div> </div>
</div> </div>
)) ))
) ))}
)}
</> </>
)} )}
</div> </div>
@@ -635,68 +533,63 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
{activeTab === 'redis' && isRedis && ( {activeTab === 'redis' && isRedis && (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* DB + Search */} {clusterMode || redisDbs.cluster ? (
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5"> <div className="px-2 py-1.5 text-[10px] text-amber-400 bg-amber-500/10 border-b border-[var(--dg-border-light)] text-center">
{clusterMode ? (
<div className="px-2 py-1 text-[10px] text-amber-400 bg-amber-500/10 rounded text-center">
集群模式 · DB0 集群模式 · DB0
</div> </div>
) : ( ) : (
<select <select
value={redisDb} value={redisDb}
onChange={(e) => { setRedisDb(Number(e.target.value)); }} onChange={(e) => {
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" setRedisDb(Number(e.target.value));
setSelectedKey(null);
}}
className="mx-2 mt-2 px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
> >
{Array.from({ length: 16 }, (_, i) => ( {redisDbs.dbs.map((i) => (
<option key={i} value={i}>DB{i}</option> <option key={i} value={i}>
DB{i}
</option>
))} ))}
</select> </select>
)} )}
<div className="relative"> <div className="flex-1 min-h-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" /> <RedisKeyBrowser
<input compact
value={redisPattern} instanceId={Number(instanceId)}
onChange={(e) => setRedisPattern(e.target.value)} db={redisDb}
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
placeholder="*"
/>
</div>
<button
onClick={fetchRedisKeys}
disabled={loadingRedis}
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
>
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
</button>
</div>
{/* Key Tree + Detail */}
<div className="flex-1 overflow-y-auto">
{loadingRedis ? (
<div className="space-y-1 p-2">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-10" />
</div>
))}
</div>
) : redisKeys.length === 0 ? (
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
) : (
<RedisTree
tree={buildRedisTree(redisKeys)}
expanded={redisExpanded}
setExpanded={setRedisExpanded}
selectedKey={selectedKey} selectedKey={selectedKey}
onSelectKey={handleSelectKey} onKeySelect={(key) => {
keyDetail={keyDetail} handleSelectKey(key);
loadingDetail={loadingDetail} }}
onInsert={onInsert} onInsertCommand={onInsert}
renderDetail={(key) => {
if (selectedKey !== key) return null;
if (loadingDetail) {
return (
<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>
);
}
if (!keyDetail) return null;
return (
<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>
);
}}
/> />
)}
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,313 @@
/**
* Redis 键浏览复合组件(搜索 + 类型筛选 + 分组树 + 游标加载更多)。
* 用途:RedisDetailPage 键管理 tab(标准模式)与 SchemaSidebar Redis 分支(compact 模式)共用。
* 必需 props:instanceId / db / onKeySelect;已知限制:树按 `:` 分组,分隔符不可配。
*/
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react';
import { Search, X, ChevronDown, ChevronRight, FolderOpen, KeyRound, Loader2, RefreshCw } from 'lucide-react';
import { redisApi } from '@/api/datasource/redis';
import {
buildRedisKeyTree,
removeRedisKeyFromTree,
sortTreeChildren,
redisTypeColor,
formatRedisTtl,
redisReadCommand,
type RedisTreeNode,
} from './keyTree';
import { useRedisKeyBrowser } from './useRedisKeyBrowser';
export interface RedisKeyBrowserHandle {
/** 重新执行当前搜索(新建键后调用) */
refresh: () => void;
/** 本地移除一个键(删除成功后调用) */
removeKey: (keyName: string) => void;
}
interface Props {
instanceId: number;
db: number;
selectedKey?: string | null;
onKeySelect: (key: string, type: string) => void;
/** 叶子点击时额外回调(如向编辑器插入读命令) */
onInsertCommand?: (cmd: string) => void;
/** 选中键的内联详情预览(SchemaSidebar 模式) */
renderDetail?: (key: string) => ReactNode;
/** 搜索区上方的额外工具(如「新建」按钮) */
toolbar?: ReactNode;
compact?: boolean;
}
const TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'string', label: 'String' },
{ value: 'hash', label: 'Hash' },
{ value: 'list', label: 'List' },
{ value: 'set', label: 'Set' },
{ value: 'zset', label: 'ZSet' },
{ value: 'stream', label: 'Stream' },
];
export const RedisKeyBrowser = forwardRef<RedisKeyBrowserHandle, Props>(function RedisKeyBrowser(
{ instanceId, db, selectedKey, onKeySelect, onInsertCommand, renderDetail, toolbar, compact = false },
ref,
) {
const browser = useRedisKeyBrowser(instanceId, db);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const lastSearchKeyRef = useRef('');
useImperativeHandle(
ref,
() => ({
refresh: browser.search,
removeKey: browser.removeKey,
}),
[browser.search, browser.removeKey],
);
const tree = useMemo(() => buildRedisKeyTree(browser.items, browser.exact), [browser.items, browser.exact]);
// 新搜索完成且带关键字时自动展开文件夹(加载更多不触发)
useEffect(() => {
const kw = browser.pattern.trim();
const searchKey = `${kw}|${browser.typeFilter}|${db}`;
const isFreshSearch = lastSearchKeyRef.current !== searchKey;
lastSearchKeyRef.current = searchKey;
if (!isFreshSearch || browser.items.length === 0) return;
if (!kw || kw === '*') return;
setExpanded(() => {
const next = new Set<string>();
browser.items.forEach((item) => {
const parts = item.key.split(':');
let prefix = '';
for (let i = 0; i < parts.length - 1; i++) {
prefix += (i === 0 ? '' : ':') + parts[i];
next.add(prefix);
}
});
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browser.items]);
const toggle = (prefix: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(prefix)) next.delete(prefix);
else next.add(prefix);
return next;
});
};
const inputCls = compact
? 'w-full bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500'
: 'h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] font-mono text-sm';
const selectCls = compact
? 'px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500'
: 'h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm';
const searchBtnCls = compact
? 'px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50'
: 'h-9 px-4 rounded-md bg-[var(--dg-blue)] text-sm font-medium text-white hover:opacity-90 disabled:opacity-50';
const renderNode = (node: RedisTreeNode, depth: number): ReactNode => {
const sorted = sortTreeChildren([...node.children.values()]);
const indent = depth * 12;
return sorted.map((child) => {
if (child.isLeaf && child.keyItem) {
const item = child.keyItem;
const active = selectedKey === item.key;
return (
<div key={`leaf:${child.fullPrefix}`}>
<button
title={`${item.key} · ${item.type} · TTL ${formatRedisTtl(item.ttl)}`}
onClick={() => {
onKeySelect(item.key, item.type);
onInsertCommand?.(redisReadCommand(item.key, item.type));
}}
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} transition-colors ${
active
? 'bg-[var(--dg-blue)]/10 text-blue-400'
: 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
}`}
style={{ paddingLeft: 8 + indent }}
>
{node === tree && browser.exact?.key === item.key && (
<span className="rounded bg-emerald-500/15 px-1 text-[9px] text-emerald-400 flex-shrink-0">精确</span>
)}
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="font-mono truncate flex-1 text-left" title={item.key}>
{child.name}
</span>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${redisTypeColor(item.type)}`}
title={item.type}
/>
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{formatRedisTtl(item.ttl)}</span>
</button>
{active && renderDetail && (
<div className={compact ? 'px-2 pb-2' : 'px-3 pb-2'} style={{ paddingLeft: 12 + indent }}>
{renderDetail(item.key)}
</div>
)}
</div>
);
}
const isOpen = expanded.has(child.fullPrefix);
return (
<div key={`dir:${child.fullPrefix}`}>
<button
onClick={() => toggle(child.fullPrefix)}
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors`}
style={{ paddingLeft: 8 + indent }}
title={child.fullPrefix}
>
{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 font-mono">{child.name}</span>
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{child.leafCount}</span>
</button>
{isOpen && renderNode(child, depth + 1)}
</div>
);
});
};
const hasQuery = browser.pattern.trim() !== '' || browser.typeFilter !== '';
return (
<div className="flex min-h-0 h-full flex-col">
{/* 搜索区 */}
<div
className={
compact
? 'border-b border-[var(--dg-border-light)] px-2 py-2 space-y-1.5'
: 'border-b border-[var(--dg-border)] p-3 space-y-2'
}
>
{toolbar}
<div className={compact ? 'relative' : 'relative mb-0'}>
<Search
className={`absolute ${compact ? 'left-2 w-3 h-3' : 'left-3 h-4 w-4'} top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]`}
/>
<input
value={browser.pattern}
onChange={(e) => browser.setPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') browser.search();
}}
className={`${inputCls} ${compact ? 'pl-7 pr-7 py-1' : 'pl-9 pr-9'}`}
placeholder={compact ? '搜索 key,回车扫描;* 全部' : '输入关键词或 pattern,回车扫描(* 扫全部)'}
/>
{browser.pattern && (
<button
onClick={() => {
browser.setPattern('');
}}
className={`absolute top-1/2 -translate-y-1/2 ${compact ? 'right-2' : 'right-3'} text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]`}
title="清空"
>
<X className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
</button>
)}
</div>
<div className={compact ? 'flex gap-1.5' : 'grid grid-cols-[1fr_auto] gap-2'}>
<select
value={browser.typeFilter}
onChange={(e) => {
browser.setTypeFilter(e.target.value);
}}
className={selectCls}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<button
onClick={browser.search}
disabled={browser.loading}
className={`${searchBtnCls} inline-flex items-center justify-center gap-1.5`}
title="从当前游标继续扫描,不会一次读取全部 Key"
>
{browser.loading ? (
<Loader2 className={compact ? 'w-3 h-3' : 'w-4 h-4'} animate-spin />
) : (
<Search className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
)}
{!compact && '搜索'}
</button>
</div>
</div>
{/* 计数行 */}
<div
className={`flex items-center justify-between border-b border-[var(--dg-border-light)] ${compact ? 'px-2 py-1.5 text-[10px]' : 'px-3 py-2 text-xs'} text-[var(--dg-text-muted)]`}
>
<span>
共 {browser.total} 键{browser.exact ? ' · 精确命中置顶' : ''}
</span>
{browser.total > 0 && (
<button
onClick={browser.search}
disabled={browser.loading}
className="inline-flex items-center gap-1 hover:text-[var(--dg-text-primary)]"
title="重新扫描"
>
<RefreshCw className={`w-3 h-3 ${browser.loading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
{/* 树 */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{browser.loading ? (
<div className={`space-y-2 ${compact ? 'p-2' : 'p-3'}`}>
{Array.from({ length: compact ? 6 : 8 }, (_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
))}
</div>
) : !hasQuery && browser.items.length === 0 ? (
<div
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
>
输入关键词搜索;留空回车可浏览全部
</div>
) : browser.items.length === 0 && !browser.exact ? (
<div
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
>
无匹配 Keys
</div>
) : (
<>
{renderNode(tree, 0)}
{browser.hasMore && (
<button
onClick={browser.loadMore}
disabled={browser.loadingMore}
className={`w-full ${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-blue-400 hover:bg-blue-500/5 disabled:opacity-50`}
title="继续从当前游标扫描,不会一次读取全部 Key"
>
{browser.loadingMore ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '加载更多'}
</button>
)}
{!browser.hasMore && browser.total > 0 && (
<div
className={`${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-center text-[var(--dg-text-muted)]`}
>
已全部加载
</div>
)}
</>
)}
</div>
</div>
);
});
@@ -0,0 +1,169 @@
/**
* Redis 键分组树(对标 opsxdb src/redis.ts):
* - 按 `:` 分隔符把键编成 folder 树,文件夹带递归 key 计数
* - 无分隔符的键留根层;精确命中(exact)置顶根层
* - 支持本地移除(删键后不整树重拉)
*/
import type { RedisKeySummary } from '@/api/datasource/redis';
export interface RedisTreeNode {
name: string;
fullPrefix: string;
isLeaf: boolean;
keyItem?: RedisKeySummary;
children: Map<string, RedisTreeNode>;
/** 该子树下(递归)的叶子键数 */
leafCount: number;
}
export const REDIS_KEY_SEPARATOR = ':';
/** 类型色点(string 绿/hash 蓝/list 琥珀/set 紫/zset 青/stream 粉) */
export function redisTypeColor(type: string): string {
switch (type) {
case 'string':
return 'bg-emerald-400';
case 'hash':
return 'bg-blue-400';
case 'list':
return 'bg-amber-400';
case 'set':
return 'bg-purple-400';
case 'zset':
return 'bg-cyan-400';
case 'stream':
return 'bg-pink-400';
default:
return 'bg-red-400';
}
}
/** TTL 毫秒 → 可读(-1 永久 / 45s / 5m30s / 3h20m / 2d5h) */
export function formatRedisTtl(ttlMs: number | undefined): string {
if (ttlMs == null || ttlMs < 0) return '永久';
if (ttlMs === 0) return '即将过期';
const seconds = Math.ceil(ttlMs / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60 ? ' ' + (seconds % 60) + 's' : ''}`;
if (seconds < 86400)
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60) ? ' ' + Math.floor((seconds % 3600) / 60) + 'm' : ''}`;
return `${Math.floor(seconds / 86400)}d${Math.floor((seconds % 86400) / 3600) ? ' ' + Math.floor((seconds % 86400) / 3600) + 'h' : ''}`;
}
function countLeaves(node: RedisTreeNode): number {
if (node.isLeaf) return 1;
let n = 0;
for (const child of node.children.values()) n += countLeaves(child);
return n;
}
/** 建树:exact 命中(若有)置顶根层 */
export function buildRedisKeyTree(keys: RedisKeySummary[], exact?: RedisKeySummary | null): RedisTreeNode {
const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map(), leafCount: 0 };
const seen = new Set<string>();
if (exact) {
root.children.set(exact.key, {
name: exact.key,
fullPrefix: exact.key,
isLeaf: true,
keyItem: exact,
children: new Map(),
leafCount: 1,
});
seen.add(exact.key);
}
for (const item of keys) {
if (seen.has(item.key)) continue;
seen.add(item.key);
const parts = item.key.split(REDIS_KEY_SEPARATOR);
if (parts.length === 1) {
// 无分隔符:留根层
root.children.set(item.key, {
name: item.key,
fullPrefix: item.key,
isLeaf: true,
keyItem: item,
children: new Map(),
leafCount: 1,
});
continue;
}
let node = root;
let prefix = '';
for (let i = 0; i < parts.length - 1; i++) {
prefix += (i === 0 ? '' : REDIS_KEY_SEPARATOR) + parts[i];
let child = node.children.get(parts[i]);
if (!child || child.isLeaf) {
child = { name: parts[i], fullPrefix: prefix, isLeaf: false, children: new Map(), leafCount: 0 };
node.children.set(parts[i], child);
}
node = child;
}
const leafName = parts[parts.length - 1];
node.children.set(leafName, {
name: leafName,
fullPrefix: item.key,
isLeaf: true,
keyItem: item,
children: new Map(),
leafCount: 1,
});
}
root.leafCount = countLeaves(root);
return root;
}
/** 本地移除一个键(含空文件夹剪枝);返回树是否变化 */
export function removeRedisKeyFromTree(root: RedisTreeNode, keyName: string): boolean {
const parts = keyName.split(REDIS_KEY_SEPARATOR);
const path: RedisTreeNode[] = [root];
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const child = node.children.get(parts[i]);
if (!child) return false;
path.push(child);
node = child;
}
const leafName = parts[parts.length - 1];
if (!node.children.delete(leafName)) return false;
// 从深处向根剪空文件夹并重算计数
for (let i = path.length - 1; i >= 1; i--) {
const parent = path[i - 1];
const child = path[i];
child.leafCount = countLeaves(child);
if (!child.isLeaf && child.children.size === 0) {
parent.children.delete(child.name);
}
}
root.leafCount = countLeaves(root);
return true;
}
/** 文件夹优先、同层 numeric 感知排序 */
export function sortTreeChildren(children: RedisTreeNode[]): RedisTreeNode[] {
return [...children].sort((a, b) => {
if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1;
const na = Number(a.name);
const nb = Number(b.name);
const aNum = !Number.isNaN(na) && a.name.trim() !== '';
const bNum = !Number.isNaN(nb) && b.name.trim() !== '';
if (aNum && bNum) return na - nb;
return a.name.localeCompare(b.name);
});
}
/** 根据 key type 生成查询命令 */
export function redisReadCommand(key: string, type: string): string {
switch (type) {
case 'hash':
return `HGETALL ${key}`;
case 'list':
return `LRANGE ${key} 0 -1`;
case 'set':
return `SMEMBERS ${key}`;
case 'zset':
return `ZRANGE ${key} 0 -1 WITHSCORES`;
default:
return `GET ${key}`;
}
}
@@ -0,0 +1,178 @@
/**
* Redis 键浏览数据 hook(游标分页状态机):
* - 请求序号守卫:过期响应直接丢弃(快速切库/改筛选不竞态)
* - 增量合并:加载更多按键名 Map 追加,不整表重拉
* - db / pattern / type 任一变化都重置游标
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { redisApi, type RedisKeySummary } from '@/api/datasource/redis';
export interface UseRedisKeyBrowserResult {
items: RedisKeySummary[];
exact: RedisKeySummary | null;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
pattern: string;
typeFilter: string;
setPattern: (v: string) => void;
setTypeFilter: (v: string) => void;
/** 用当前 pattern/typeFilter 发起新搜索 */
search: () => void;
loadMore: () => void;
/** 本地移除一个键(删除成功后调用,不重拉) */
removeKey: (keyName: string) => void;
total: number;
}
export function useRedisKeyBrowser(instanceId: number, db: number): UseRedisKeyBrowserResult {
const [items, setItems] = useState<RedisKeySummary[]>([]);
const [exact, setExact] = useState<RedisKeySummary | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [total, setTotal] = useState(0);
const [pattern, setPatternState] = useState('');
const [typeFilter, setTypeFilterState] = useState('');
const cursorRef = useRef<string>('');
const seqRef = useRef(0);
const mergedRef = useRef<Map<string, RedisKeySummary>>(new Map());
// 输入后立即回车时组件尚未重渲染,参数必须由 setter 即时同步(不能等渲染期赋值)
const argsRef = useRef({ pattern: '', typeFilter: '' });
const setPattern = useCallback((v: string) => {
argsRef.current.pattern = v;
setPatternState(v);
}, []);
const setTypeFilter = useCallback((v: string) => {
argsRef.current.typeFilter = v;
setTypeFilterState(v);
}, []);
const search = useCallback(() => {
const seq = ++seqRef.current;
cursorRef.current = '';
mergedRef.current = new Map();
setLoading(true);
setHasMore(false);
redisApi
.scanKeys(instanceId, {
db,
pattern: argsRef.current.pattern.trim(),
type: argsRef.current.typeFilter || undefined,
})
.then((res) => {
if (seq !== seqRef.current) return;
const keys = res?.keys || [];
keys.forEach((k) => mergedRef.current.set(k.key, k));
setItems([...mergedRef.current.values()]);
setExact(res?.exact || null);
cursorRef.current = res?.next_cursor || '';
setHasMore(Boolean(res?.next_cursor));
setTotal(mergedRef.current.size);
})
.catch(() => {
if (seq !== seqRef.current) return;
setItems([]);
setExact(null);
setTotal(0);
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoading(false);
});
}, [instanceId, db]);
const loadMore = useCallback(() => {
if (!cursorRef.current || loadingMore || loading) return;
const seq = seqRef.current;
setLoadingMore(true);
redisApi
.scanKeys(instanceId, {
db,
pattern: argsRef.current.pattern.trim(),
type: argsRef.current.typeFilter || undefined,
cursor: cursorRef.current,
})
.then((res) => {
if (seq !== seqRef.current) return;
(res?.keys || []).forEach((k) => mergedRef.current.set(k.key, k));
setItems([...mergedRef.current.values()]);
cursorRef.current = res?.next_cursor || '';
setHasMore(Boolean(res?.next_cursor));
setTotal(mergedRef.current.size);
})
.catch(() => {
/* 拦截器已提示 */
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoadingMore(false);
});
}, [instanceId, db, loading, loadingMore]);
const removeKey = useCallback((keyName: string) => {
if (mergedRef.current.delete(keyName)) {
setItems([...mergedRef.current.values()]);
setTotal(mergedRef.current.size);
setExact((prev) => (prev?.key === keyName ? null : prev));
}
}, []);
// 切库 / 换实例:清空并按当前条件重搜
useEffect(() => {
seqRef.current++;
cursorRef.current = '';
mergedRef.current = new Map();
setItems([]);
setExact(null);
setHasMore(false);
setTotal(0);
if (instanceId) search();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, db]);
return {
items,
exact,
loading,
loadingMore,
hasMore,
pattern,
typeFilter,
setPattern,
setTypeFilter,
search,
loadMore,
removeKey,
total,
};
}
/** 库列表(集群恒 [0]);实例变化自动重拉 */
export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boolean } {
const [dbs, setDbs] = useState<number[]>([0]);
const [cluster, setCluster] = useState(false);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
redisApi
.getDbs(instanceId)
.then((res) => {
if (cancelled) return;
setDbs(res?.dbs?.length ? res.dbs : [0]);
setCluster(Boolean(res?.cluster));
})
.catch(() => {
/* 集群探测失败按单机 0-15 处理 */
});
return () => {
cancelled = true;
};
}, [instanceId]);
return { dbs, cluster };
}
+246 -79
View File
@@ -17,8 +17,13 @@ import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePan
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel'; import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants'; import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
import type { import type {
QueryHistoryItem, QueryInstanceOption, QueryResult, QueryHistoryItem,
RedisKeyDetail, RedisKeyInfo, SortDirection, SortState, QueryInstanceOption,
QueryResult,
RedisKeyDetail,
RedisKeyInfo,
SortDirection,
SortState,
} from '@/features/query/types'; } from '@/features/query/types';
import api from '@/api/request'; import api from '@/api/request';
import { readStorage, writeStorage } from '@/lib/storage'; import { readStorage, writeStorage } from '@/lib/storage';
@@ -35,7 +40,12 @@ export default function QueriesPage() {
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null); const [result, setResult] = useState<QueryResult | null>(null);
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' }); const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({
open: false,
field: '',
value: '',
format: 'Json',
});
const [redisPattern, setRedisPattern] = useState(''); const [redisPattern, setRedisPattern] = useState('');
const [redisType, setRedisType] = useState(''); const [redisType, setRedisType] = useState('');
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]); const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
@@ -66,8 +76,10 @@ export default function QueriesPage() {
const saved = readStorage('queryFavorites'); const saved = readStorage('queryFavorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>(); const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
const initialHistory: QueryHistoryItem[] = []; const initialHistory: QueryHistoryItem[] = [];
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) })); return initialHistory.map((h) => ({ ...h, favorite: favIds.has(h.id) }));
} catch { return []; } } catch {
return [];
}
}); });
const [favFilter, setFavFilter] = useState(false); const [favFilter, setFavFilter] = useState(false);
const [searchHistory, setSearchHistory] = useState(''); const [searchHistory, setSearchHistory] = useState('');
@@ -75,39 +87,74 @@ export default function QueriesPage() {
/* ---- Data loading ---- */ /* ---- Data loading ---- */
useEffect(() => { useEffect(() => {
instanceApi.accessible('readonly').then(res => { instanceApi
.accessible('readonly')
.then((res) => {
setInstances(Array.isArray(res) ? res : []); setInstances(Array.isArray(res) ? res : []);
}).catch(() => { setInstances([]); }); })
.catch(() => {
setInstances([]);
});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; } if (!instanceId) {
const inst = instances.find(i => i.id === Number(instanceId)); setDatabases([]);
if (inst?.db_type === 'redis') { setDatabases([]); setDatabase('0'); setEditorSchema({}); return; } setDatabase('');
setEditorSchema({});
return;
}
const inst = instances.find((i) => i.id === Number(instanceId));
if (inst?.db_type === 'redis') {
setDatabases([]);
setDatabase('0');
setEditorSchema({});
return;
}
const type = inst?.db_type || ''; const type = inst?.db_type || '';
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') { if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
setLoadingDbs(true); setLoadingDbs(true);
instanceDetailApi.listDatabases(Number(instanceId)) instanceDetailApi
.then(async data => { .listDatabases(Number(instanceId))
.then(async (data) => {
const dbs = Array.isArray(data) ? data : []; const dbs = Array.isArray(data) ? data : [];
setDatabases(dbs); setDatabases(dbs);
const schema: Record<string, Record<string, string[]>> = {}; const schema: Record<string, Record<string, string[]>> = {};
await Promise.all(dbs.map(async db => { await Promise.all(
dbs.map(async (db) => {
try { try {
const tables = await instanceDetailApi.listTables(Number(instanceId), db); const tables = await instanceDetailApi.listTables(Number(instanceId), db);
if (Array.isArray(tables)) { schema[db] = {}; for (const t of tables) { schema[db][t.name] = []; } } if (Array.isArray(tables)) {
} catch { /* ignore */ } schema[db] = {};
})); for (const t of tables) {
schema[db][t.name] = [];
}
}
} catch {
/* ignore */
}
}),
);
setEditorSchema(schema); setEditorSchema(schema);
}) })
.catch(() => { setDatabases([]); setEditorSchema({}); }) .catch(() => {
setDatabases([]);
setEditorSchema({});
})
.finally(() => setLoadingDbs(false)); .finally(() => setLoadingDbs(false));
} else { setDatabases([]); setEditorSchema({}); } } else {
setDatabases([]);
setEditorSchema({});
}
}, [instanceId, instances]); }, [instanceId, instances]);
const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]); const selectedInstance = useMemo(
() => instances.find((inst) => String(inst.id) === instanceId),
[instances, instanceId],
);
const dbType = normalizeDbType(selectedInstance?.db_type); const dbType = normalizeDbType(selectedInstance?.db_type);
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const; const editorLang =
dbType === 'redis' ? ('redis' as const) : dbType === 'mongodb' ? ('mongodb' as const) : ('sql' as const);
useEffect(() => { useEffect(() => {
setRedisKeys([]); setRedisKeys([]);
@@ -125,11 +172,10 @@ export default function QueriesPage() {
} }
setRedisLoading(true); setRedisLoading(true);
try { try {
const res = await redisApi.getKeys(Number(instanceId), { const res = await redisApi.scanKeys(Number(instanceId), {
db: Number(database || 0), db: Number(database || 0),
pattern, pattern,
type: redisType || undefined, type: redisType || undefined,
count: 80,
}); });
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]); setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
} catch { } catch {
@@ -139,7 +185,8 @@ export default function QueriesPage() {
} }
}, [instanceId, database, redisPattern, redisType]); }, [instanceId, database, redisPattern, redisType]);
const loadRedisKeyDetail = useCallback(async (keyName: string) => { const loadRedisKeyDetail = useCallback(
async (keyName: string) => {
if (!instanceId) return; if (!instanceId) return;
setRedisDetailLoading(true); setRedisDetailLoading(true);
try { try {
@@ -151,51 +198,94 @@ export default function QueriesPage() {
} finally { } finally {
setRedisDetailLoading(false); setRedisDetailLoading(false);
} }
}, [instanceId, database]); },
[instanceId, database],
);
const copyRedisText = useCallback((text: string) => { const copyRedisText = useCallback((text: string) => {
navigator.clipboard?.writeText(text).catch(() => undefined); navigator.clipboard?.writeText(text).catch(() => undefined);
}, []); }, []);
const handleInspectRedisValue = useCallback((field: string, value: unknown) => { const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' }); setRedisLineViewer({
open: true,
field,
value: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
format: 'Json',
});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return; if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
if (/^\d+$/.test(database)) return; if (/^\d+$/.test(database)) return;
instanceDetailApi.getDbSchema(Number(instanceId), database) instanceDetailApi
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); }) .getDbSchema(Number(instanceId), database)
.catch(() => { /* ignore */ }); .then((data) => {
if (data && typeof data === 'object')
setEditorSchema((prev) => ({ ...prev, [database]: data as Record<string, string[]> }));
})
.catch(() => {
/* ignore */
});
}, [instanceId, database, dbType]); }, [instanceId, database, dbType]);
/* ---- Templates ---- */ /* ---- Templates ---- */
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]); const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
const templateCategories = useMemo(() => { const cats = new Set(templates.map(t => t.category)); return ['all', ...Array.from(cats)]; }, [templates]); const templateCategories = useMemo(() => {
const cats = new Set(templates.map((t) => t.category));
return ['all', ...Array.from(cats)];
}, [templates]);
const filteredTemplates = useMemo(() => { const filteredTemplates = useMemo(() => {
let r = templates; let r = templates;
if (templateCategory !== 'all') r = r.filter(t => t.category === templateCategory); if (templateCategory !== 'all') r = r.filter((t) => t.category === templateCategory);
if (templateSearch) { const kw = templateSearch.toLowerCase(); r = r.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); } if (templateSearch) {
const kw = templateSearch.toLowerCase();
r = r.filter((t) => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
}
return r; return r;
}, [templates, templateCategory, templateSearch]); }, [templates, templateCategory, templateSearch]);
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => { const handleApplyTemplate = useCallback(
(tpl: QueryTemplate) => {
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined })); setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
setShowTemplates(false); setShowTemplates(false);
}, [dbType, database]); },
[dbType, database],
);
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' }; const catLabel: Record<string, string> = {
all: '全部',
表结构: '表结构',
性能分析: '性能分析',
数据统计: '数据统计',
基础查询: '基础查询',
};
/* ---- Execute ---- */ /* ---- Execute ---- */
const handleExecute = useCallback(async (pageArg: number | unknown = 1) => { const handleExecute = useCallback(
async (pageArg: number | unknown = 1) => {
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1; const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; } if (!sql || !instanceId) {
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; } setError('请选择实例并输入查询语句');
setExecuting(true); setError(''); setCurrentPage(page); return;
}
if (/\{(?:table|db|collection)\}/.test(sql)) {
setError('SQL 中含有未替换的占位符,请先替换或删除');
return;
}
setExecuting(true);
setError('');
setCurrentPage(page);
if (page === 1) setResult(null); if (page === 1) setResult(null);
try { try {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, limit: pageSize, offset }) as unknown as QueryResult; const res = (await queryApi.execute({
instance_id: Number(instanceId),
database: database || undefined,
sql,
limit: pageSize,
offset,
})) as unknown as QueryResult;
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 }; const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
if (normalized.error) { if (normalized.error) {
setError(normalized.error); setError(normalized.error);
@@ -204,35 +294,80 @@ export default function QueriesPage() {
setResult(normalized); setResult(normalized);
} }
if (page === 1) { if (page === 1) {
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]); setHistory((prev) => [
{
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'success',
execution_time_ms: normalized.execution_time_ms,
result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100),
created_at: new Date().toISOString(),
favorite: false,
},
...prev,
]);
} }
} catch (e: unknown) { } catch (e: unknown) {
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail; const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data
?.detail;
const msg = Array.isArray(raw) const msg = Array.isArray(raw)
? raw.map(v => v.msg).join('; ') ? raw.map((v) => v.msg).join('; ')
: (typeof raw === 'string' ? raw : raw ? JSON.stringify(raw) : '查询失败'); : typeof raw === 'string'
? raw
: raw
? JSON.stringify(raw)
: '查询失败';
setError(msg); setError(msg);
if (page === 1) { if (page === 1) {
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]); setHistory((prev) => [
{
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'failed',
execution_time_ms: 0,
result_preview: msg,
created_at: new Date().toISOString(),
favorite: false,
},
...prev,
]);
} }
} finally { setExecuting(false); } } finally {
}, [sql, instanceId, database, dbType, pageSize]); setExecuting(false);
}
},
[sql, instanceId, database, dbType, pageSize],
);
/* ---- Result Sorting ---- */ /* ---- Result Sorting ---- */
const sortedResult = useMemo(() => { const sortedResult = useMemo(() => {
if (!result || !sortState.column || !sortState.direction) return result; if (!result || !sortState.column || !sortState.direction) return result;
if (!Array.isArray(result.rows)) return result; if (!Array.isArray(result.rows)) return result;
const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1; const col = sortState.column;
return { ...result, rows: [...result.rows].sort((a, b) => { const dir = sortState.direction === 'asc' ? 1 : -1;
const av = a[col] ?? ''; const bv = b[col] ?? ''; return {
...result,
rows: [...result.rows].sort((a, b) => {
const av = a[col] ?? '';
const bv = b[col] ?? '';
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir; return String(av).localeCompare(String(bv)) * dir;
})}; }),
};
}, [result, sortState]); }, [result, sortState]);
const handleSort = useCallback((col: string) => { const handleSort = useCallback((col: string) => {
setSortState(prev => { setSortState((prev) => {
if (prev.column === col) { if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection }; if (prev.direction === 'desc') return { column: null, direction: null }; } if (prev.column === col) {
if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection };
if (prev.direction === 'desc') return { column: null, direction: null };
}
return { column: col, direction: 'asc' as SortDirection }; return { column: col, direction: 'asc' as SortDirection };
}); });
}, []); }, []);
@@ -240,11 +375,15 @@ export default function QueriesPage() {
const handleExportResult = useCallback(async () => { const handleExportResult = useCallback(async () => {
if (!sql || !instanceId) return; if (!sql || !instanceId) return;
try { try {
const blob = await api.post<unknown, Blob>('/query/export', { const blob = await api.post<unknown, Blob>(
'/query/export',
{
instance_id: Number(instanceId), instance_id: Number(instanceId),
database: database || undefined, database: database || undefined,
sql, sql,
}, { responseType: 'blob' }); },
{ responseType: 'blob' },
);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -257,28 +396,41 @@ export default function QueriesPage() {
}, [sql, instanceId, database]); }, [sql, instanceId, database]);
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包) // 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
const handlePageSizeChange = useCallback((size: number) => { const handlePageSizeChange = useCallback(
(size: number) => {
setPageSize(size); setPageSize(size);
setCurrentPage(1); setCurrentPage(1);
handleExecute(1); handleExecute(1);
}, [handleExecute]); },
[handleExecute],
);
/* ---- History ---- */ /* ---- History ---- */
const toggleFavorite = useCallback((id: number) => { const toggleFavorite = useCallback((id: number) => {
setHistory(prev => { setHistory((prev) => {
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); const next = prev.map((h) => (h.id === id ? { ...h, favorite: !h.favorite } : h));
try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } try {
writeStorage('queryFavorites', JSON.stringify(next.filter((h) => h.favorite).map((h) => h.id)));
} catch {
/* noop */
}
return next; return next;
}); });
}, []); }, []);
const handleClearHistory = useCallback(() => { const handleClearHistory = useCallback(() => {
if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; } if (!clearConfirm) {
setHistory([]); setClearConfirm(false); setClearConfirm(true);
setTimeout(() => setClearConfirm(false), 3000);
return;
}
setHistory([]);
setClearConfirm(false);
}, [clearConfirm]); }, [clearConfirm]);
// -- Sidebar 拖拽 -- // -- Sidebar 拖拽 --
const handleDragStart = useCallback((e: React.MouseEvent) => { const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const startX = e.clientX; const startX = e.clientX;
const startWidth = sideWidth; const startWidth = sideWidth;
@@ -290,32 +442,36 @@ export default function QueriesPage() {
const onUp = () => { const onUp = () => {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
setSideWidth(w => { setSideWidth((w) => {
writeStorage('querySideWidth', String(w)); writeStorage('querySideWidth', String(w));
return w; return w;
}); });
}; };
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}, [sideWidth]); },
[sideWidth],
);
const filteredHistory = useMemo(() => { const filteredHistory = useMemo(() => {
let r = history; let r = history;
if (favFilter) r = r.filter(h => h.favorite); if (favFilter) r = r.filter((h) => h.favorite);
if (searchHistory) { const kw = searchHistory.toLowerCase(); r = r.filter(h => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw)); } if (searchHistory) {
const kw = searchHistory.toLowerCase();
r = r.filter((h) => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw));
}
return r; return r;
}, [history, favFilter, searchHistory]); }, [history, favFilter, searchHistory]);
const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []); const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []); const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []); const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
const handleToggleResult = useCallback(() => setShowResult(v => !v), []); const handleToggleResult = useCallback(() => setShowResult((v) => !v), []);
return ( return (
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0"> <div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
{/* ====== 主区域 ====== */} {/* ====== 主区域 ====== */}
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
{/* -- 顶栏: 实例+库选择 -- */} {/* -- 顶栏: 实例+库选择 -- */}
<QueryTopBar <QueryTopBar
instances={instances} instances={instances}
@@ -357,18 +513,25 @@ export default function QueriesPage() {
{/* Schema 侧边栏(左侧,独立) */} {/* Schema 侧边栏(左侧,独立) */}
{showSidebar && instanceId && ( {showSidebar && instanceId && (
<> <>
<div className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}> <div
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType} className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto"
style={{ width: sideWidth + 'px' }}
>
<SchemaSidebar
instanceId={instanceId}
database={database}
dbType={dbType}
clusterMode={selectedInstance?.cluster_mode} clusterMode={selectedInstance?.cluster_mode}
onInsert={(text) => { onInsert={(text) => {
if (dbType === 'redis') { if (dbType === 'redis') {
setSql(text); setSql(text);
} else { } else {
setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' '); setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
} }
}} }}
onDatabaseChange={setDatabase} onDatabaseChange={setDatabase}
singleDb={!!database} /> singleDb={!!database}
/>
</div> </div>
<div <div
ref={sidebarDragRef} ref={sidebarDragRef}
@@ -416,7 +579,8 @@ export default function QueriesPage() {
{error && ( {error && (
<div className="shrink-0 px-4 pb-2"> <div className="shrink-0 px-4 pb-2">
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400"> <div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
<AlertTriangle className="w-4 h-4 shrink-0" />{error} <AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div> </div>
</div> </div>
)} )}
@@ -448,9 +612,12 @@ export default function QueriesPage() {
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。 查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
</div> </div>
</div> </div>
</div>{/* end 编辑器+结果区 */} </div>
</div>{/* end 主体区 (sidebar + 编辑器+结果) */} {/* end 编辑器+结果区 */}
</div>{/* end 主区域 */} </div>
{/* end 主体区 (sidebar + 编辑器+结果) */}
</div>
{/* end 主区域 */}
{showHistory && ( {showHistory && (
<QueryHistoryDrawer <QueryHistoryDrawer
File diff suppressed because it is too large. Load diff
+17 -12
View File
@@ -1,16 +1,20 @@
import { execSync } from "child_process" import { execSync } from 'child_process';
import fs from "fs" import fs from 'fs';
import path from "path" import path from 'path';
import react from "@vitejs/plugin-react" import react from '@vitejs/plugin-react';
import { defineConfig } from "vite" import { defineConfig } from 'vite';
// 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命) // 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命)
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "package.json"), "utf-8")) const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
const gitHash = (() => { const gitHash = (() => {
try { return execSync("git rev-parse --short HEAD").toString().trim() } catch { return "unknown" } try {
})() return execSync('git rev-parse --short HEAD').toString().trim();
const buildTime = new Date().toISOString() } catch {
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})` return 'unknown';
}
})();
const buildTime = new Date().toISOString();
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`;
export default defineConfig({ export default defineConfig({
base: '/', base: '/',
@@ -35,14 +39,15 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:9000', // 本地联调可用 API_PROXY_TARGET=http://localhost:9001 npm run dev 指向测试实例
target: process.env.API_PROXY_TARGET || 'http://localhost:9000',
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), '@': path.resolve(__dirname, './src'),
}, },
}, },
}); });