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:
1 parent
6604fdf48e
commit
ff539b47e0
8 files changed
+1910
-799
No files matched your search
+302
-135
@@ -17,8 +17,13 @@ import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePan
|
||||
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
|
||||
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
|
||||
import type {
|
||||
QueryHistoryItem, QueryInstanceOption, QueryResult,
|
||||
RedisKeyDetail, RedisKeyInfo, SortDirection, SortState,
|
||||
QueryHistoryItem,
|
||||
QueryInstanceOption,
|
||||
QueryResult,
|
||||
RedisKeyDetail,
|
||||
RedisKeyInfo,
|
||||
SortDirection,
|
||||
SortState,
|
||||
} from '@/features/query/types';
|
||||
import api from '@/api/request';
|
||||
import { readStorage, writeStorage } from '@/lib/storage';
|
||||
@@ -35,7 +40,12 @@ export default function QueriesPage() {
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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 [redisType, setRedisType] = useState('');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
||||
@@ -66,8 +76,10 @@ export default function QueriesPage() {
|
||||
const saved = readStorage('queryFavorites');
|
||||
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
||||
const initialHistory: QueryHistoryItem[] = [];
|
||||
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
|
||||
} catch { return []; }
|
||||
return initialHistory.map((h) => ({ ...h, favorite: favIds.has(h.id) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [favFilter, setFavFilter] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState('');
|
||||
@@ -75,39 +87,74 @@ export default function QueriesPage() {
|
||||
|
||||
/* ---- Data loading ---- */
|
||||
useEffect(() => {
|
||||
instanceApi.accessible('readonly').then(res => {
|
||||
setInstances(Array.isArray(res) ? res : []);
|
||||
}).catch(() => { setInstances([]); });
|
||||
instanceApi
|
||||
.accessible('readonly')
|
||||
.then((res) => {
|
||||
setInstances(Array.isArray(res) ? res : []);
|
||||
})
|
||||
.catch(() => {
|
||||
setInstances([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; }
|
||||
const inst = instances.find(i => i.id === Number(instanceId));
|
||||
if (inst?.db_type === 'redis') { setDatabases([]); setDatabase('0'); setEditorSchema({}); return; }
|
||||
if (!instanceId) {
|
||||
setDatabases([]);
|
||||
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 || '';
|
||||
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
|
||||
setLoadingDbs(true);
|
||||
instanceDetailApi.listDatabases(Number(instanceId))
|
||||
.then(async data => {
|
||||
instanceDetailApi
|
||||
.listDatabases(Number(instanceId))
|
||||
.then(async (data) => {
|
||||
const dbs = Array.isArray(data) ? data : [];
|
||||
setDatabases(dbs);
|
||||
const schema: Record<string, Record<string, string[]>> = {};
|
||||
await Promise.all(dbs.map(async db => {
|
||||
try {
|
||||
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
|
||||
if (Array.isArray(tables)) { schema[db] = {}; for (const t of tables) { schema[db][t.name] = []; } }
|
||||
} catch { /* ignore */ }
|
||||
}));
|
||||
await Promise.all(
|
||||
dbs.map(async (db) => {
|
||||
try {
|
||||
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
|
||||
if (Array.isArray(tables)) {
|
||||
schema[db] = {};
|
||||
for (const t of tables) {
|
||||
schema[db][t.name] = [];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
);
|
||||
setEditorSchema(schema);
|
||||
})
|
||||
.catch(() => { setDatabases([]); setEditorSchema({}); })
|
||||
.catch(() => {
|
||||
setDatabases([]);
|
||||
setEditorSchema({});
|
||||
})
|
||||
.finally(() => setLoadingDbs(false));
|
||||
} else { setDatabases([]); setEditorSchema({}); }
|
||||
} else {
|
||||
setDatabases([]);
|
||||
setEditorSchema({});
|
||||
}
|
||||
}, [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 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(() => {
|
||||
setRedisKeys([]);
|
||||
@@ -125,11 +172,10 @@ export default function QueriesPage() {
|
||||
}
|
||||
setRedisLoading(true);
|
||||
try {
|
||||
const res = await redisApi.getKeys(Number(instanceId), {
|
||||
const res = await redisApi.scanKeys(Number(instanceId), {
|
||||
db: Number(database || 0),
|
||||
pattern,
|
||||
type: redisType || undefined,
|
||||
count: 80,
|
||||
});
|
||||
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
|
||||
} catch {
|
||||
@@ -139,100 +185,189 @@ export default function QueriesPage() {
|
||||
}
|
||||
}, [instanceId, database, redisPattern, redisType]);
|
||||
|
||||
const loadRedisKeyDetail = useCallback(async (keyName: string) => {
|
||||
if (!instanceId) return;
|
||||
setRedisDetailLoading(true);
|
||||
try {
|
||||
const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0));
|
||||
setRedisSelectedKey(detail as RedisKeyDetail);
|
||||
setRedisValueSearch('');
|
||||
} catch {
|
||||
setRedisSelectedKey(null);
|
||||
} finally {
|
||||
setRedisDetailLoading(false);
|
||||
}
|
||||
}, [instanceId, database]);
|
||||
const loadRedisKeyDetail = useCallback(
|
||||
async (keyName: string) => {
|
||||
if (!instanceId) return;
|
||||
setRedisDetailLoading(true);
|
||||
try {
|
||||
const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0));
|
||||
setRedisSelectedKey(detail as RedisKeyDetail);
|
||||
setRedisValueSearch('');
|
||||
} catch {
|
||||
setRedisSelectedKey(null);
|
||||
} finally {
|
||||
setRedisDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[instanceId, database],
|
||||
);
|
||||
|
||||
const copyRedisText = useCallback((text: string) => {
|
||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
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(() => {
|
||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
||||
if (/^\d+$/.test(database)) return;
|
||||
instanceDetailApi.getDbSchema(Number(instanceId), database)
|
||||
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); })
|
||||
.catch(() => { /* ignore */ });
|
||||
instanceDetailApi
|
||||
.getDbSchema(Number(instanceId), database)
|
||||
.then((data) => {
|
||||
if (data && typeof data === 'object')
|
||||
setEditorSchema((prev) => ({ ...prev, [database]: data as Record<string, string[]> }));
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, [instanceId, database, dbType]);
|
||||
|
||||
/* ---- Templates ---- */
|
||||
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(() => {
|
||||
let r = templates;
|
||||
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 (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));
|
||||
}
|
||||
return r;
|
||||
}, [templates, templateCategory, templateSearch]);
|
||||
|
||||
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => {
|
||||
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
|
||||
setShowTemplates(false);
|
||||
}, [dbType, database]);
|
||||
const handleApplyTemplate = useCallback(
|
||||
(tpl: QueryTemplate) => {
|
||||
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
|
||||
setShowTemplates(false);
|
||||
},
|
||||
[dbType, database],
|
||||
);
|
||||
|
||||
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
|
||||
const catLabel: Record<string, string> = {
|
||||
all: '全部',
|
||||
表结构: '表结构',
|
||||
性能分析: '性能分析',
|
||||
数据统计: '数据统计',
|
||||
基础查询: '基础查询',
|
||||
};
|
||||
|
||||
/* ---- Execute ---- */
|
||||
const handleExecute = useCallback(async (pageArg: number | unknown = 1) => {
|
||||
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
||||
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; }
|
||||
setExecuting(true); setError(''); setCurrentPage(page);
|
||||
if (page === 1) setResult(null);
|
||||
try {
|
||||
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 normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
||||
if (normalized.error) {
|
||||
setError(normalized.error);
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(normalized);
|
||||
const handleExecute = useCallback(
|
||||
async (pageArg: number | unknown = 1) => {
|
||||
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
|
||||
if (!sql || !instanceId) {
|
||||
setError('请选择实例并输入查询语句');
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
if (/\{(?:table|db|collection)\}/.test(sql)) {
|
||||
setError('SQL 中含有未替换的占位符,请先替换或删除');
|
||||
return;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail;
|
||||
const msg = Array.isArray(raw)
|
||||
? raw.map(v => v.msg).join('; ')
|
||||
: (typeof raw === 'string' ? raw : raw ? JSON.stringify(raw) : '查询失败');
|
||||
setError(msg);
|
||||
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]);
|
||||
setExecuting(true);
|
||||
setError('');
|
||||
setCurrentPage(page);
|
||||
if (page === 1) setResult(null);
|
||||
try {
|
||||
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 normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
|
||||
if (normalized.error) {
|
||||
setError(normalized.error);
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(normalized);
|
||||
}
|
||||
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,
|
||||
]);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data
|
||||
?.detail;
|
||||
const msg = Array.isArray(raw)
|
||||
? raw.map((v) => v.msg).join('; ')
|
||||
: typeof raw === 'string'
|
||||
? raw
|
||||
: raw
|
||||
? JSON.stringify(raw)
|
||||
: '查询失败';
|
||||
setError(msg);
|
||||
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,
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
} finally { setExecuting(false); }
|
||||
}, [sql, instanceId, database, dbType, pageSize]);
|
||||
},
|
||||
[sql, instanceId, database, dbType, pageSize],
|
||||
);
|
||||
|
||||
/* ---- Result Sorting ---- */
|
||||
const sortedResult = useMemo(() => {
|
||||
if (!result || !sortState.column || !sortState.direction) return result;
|
||||
if (!Array.isArray(result.rows)) return result;
|
||||
const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1;
|
||||
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;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
})};
|
||||
const col = sortState.column;
|
||||
const dir = sortState.direction === 'asc' ? 1 : -1;
|
||||
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;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
}),
|
||||
};
|
||||
}, [result, sortState]);
|
||||
|
||||
const handleSort = useCallback((col: string) => {
|
||||
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 }; }
|
||||
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 };
|
||||
}
|
||||
return { column: col, direction: 'asc' as SortDirection };
|
||||
});
|
||||
}, []);
|
||||
@@ -240,11 +375,15 @@ export default function QueriesPage() {
|
||||
const handleExportResult = useCallback(async () => {
|
||||
if (!sql || !instanceId) return;
|
||||
try {
|
||||
const blob = await api.post<unknown, Blob>('/query/export', {
|
||||
instance_id: Number(instanceId),
|
||||
database: database || undefined,
|
||||
sql,
|
||||
}, { responseType: 'blob' });
|
||||
const blob = await api.post<unknown, Blob>(
|
||||
'/query/export',
|
||||
{
|
||||
instance_id: Number(instanceId),
|
||||
database: database || undefined,
|
||||
sql,
|
||||
},
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -257,65 +396,82 @@ export default function QueriesPage() {
|
||||
}, [sql, instanceId, database]);
|
||||
|
||||
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
|
||||
const handlePageSizeChange = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
handleExecute(1);
|
||||
}, [handleExecute]);
|
||||
const handlePageSizeChange = useCallback(
|
||||
(size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
handleExecute(1);
|
||||
},
|
||||
[handleExecute],
|
||||
);
|
||||
|
||||
/* ---- History ---- */
|
||||
const toggleFavorite = useCallback((id: number) => {
|
||||
setHistory(prev => {
|
||||
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 */ }
|
||||
setHistory((prev) => {
|
||||
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 */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClearHistory = useCallback(() => {
|
||||
if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; }
|
||||
setHistory([]); setClearConfirm(false);
|
||||
if (!clearConfirm) {
|
||||
setClearConfirm(true);
|
||||
setTimeout(() => setClearConfirm(false), 3000);
|
||||
return;
|
||||
}
|
||||
setHistory([]);
|
||||
setClearConfirm(false);
|
||||
}, [clearConfirm]);
|
||||
|
||||
// -- Sidebar 拖拽 --
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = sideWidth;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const next = Math.min(600, Math.max(200, startWidth + delta));
|
||||
setSideWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
setSideWidth(w => {
|
||||
writeStorage('querySideWidth', String(w));
|
||||
return w;
|
||||
});
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}, [sideWidth]);
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = sideWidth;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const next = Math.min(600, Math.max(200, startWidth + delta));
|
||||
setSideWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
setSideWidth((w) => {
|
||||
writeStorage('querySideWidth', String(w));
|
||||
return w;
|
||||
});
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[sideWidth],
|
||||
);
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
let r = history;
|
||||
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 (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));
|
||||
}
|
||||
return r;
|
||||
}, [history, favFilter, searchHistory]);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []);
|
||||
const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []);
|
||||
const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []);
|
||||
const handleToggleResult = useCallback(() => setShowResult(v => !v), []);
|
||||
const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
|
||||
const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
|
||||
const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
|
||||
const handleToggleResult = useCallback(() => setShowResult((v) => !v), []);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
||||
{/* -- 顶栏: 实例+库选择 -- */}
|
||||
<QueryTopBar
|
||||
instances={instances}
|
||||
@@ -357,18 +513,25 @@ export default function QueriesPage() {
|
||||
{/* Schema 侧边栏(左侧,独立) */}
|
||||
{showSidebar && instanceId && (
|
||||
<>
|
||||
<div 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}
|
||||
<div
|
||||
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}
|
||||
onInsert={(text) => {
|
||||
if (dbType === 'redis') {
|
||||
setSql(text);
|
||||
} else {
|
||||
setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||
}
|
||||
}}
|
||||
onDatabaseChange={setDatabase}
|
||||
singleDb={!!database} />
|
||||
singleDb={!!database}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={sidebarDragRef}
|
||||
@@ -416,7 +579,8 @@ export default function QueriesPage() {
|
||||
{error && (
|
||||
<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">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -448,9 +612,12 @@ export default function QueriesPage() {
|
||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end 编辑器+结果区 */}
|
||||
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||
</div>{/* end 主区域 */}
|
||||
</div>
|
||||
{/* end 编辑器+结果区 */}
|
||||
</div>
|
||||
{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||
</div>
|
||||
{/* end 主区域 */}
|
||||
|
||||
{showHistory && (
|
||||
<QueryHistoryDrawer
|
||||
|
||||
Reference in new issue
Block a user