import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { AlertTriangle } from 'lucide-react'; import SchemaSidebar from '@/components/SchemaSidebar'; import { queryApi } from '@/api/query'; import { instanceApi } from '@/api/instance'; import { redisApi } from '@/api/redis'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; import { instanceDetailApi } from '@/api/instanceDetail'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog'; import type { RedisLineEditorState } from '@/features/redis/types'; import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer'; import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel'; import { QueryTopBar } from '@/features/query/components/QueryTopBar'; import { SqlEditorSection } from '@/features/query/components/SqlEditorSection'; import type { SqlEditorHandle } from '@/components/SqlEditor'; import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel'; import { QueryResultPanel } from '@/features/query/components/QueryResultPanel'; import { dbTypeIcon, normalizeDbType } from '@/features/query/constants'; import type { QueryInstanceOption, QueryResult, RedisKeyDetail, RedisKeyInfo, SortDirection, SortState, } from '@/features/query/types'; import api from '@/api/request'; import { readStorage, writeStorage } from '@/lib/storage'; export default function QueriesPage() { // -- 编辑器 state -- const [sql, setSql] = useState(''); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); const [databases, setDatabases] = useState([]); const [loadingDbs, setLoadingDbs] = useState(false); const [instances, setInstances] = useState([]); const [editorSchema, setEditorSchema] = useState>>({}); const [executing, setExecuting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); const [redisLineViewer, setRedisLineViewer] = useState({ open: false, field: '', value: '', format: 'Json', }); const [redisPattern, setRedisPattern] = useState(''); const [redisType, setRedisType] = useState(''); const [redisKeys, setRedisKeys] = useState([]); const [redisSelectedKey, setRedisSelectedKey] = useState(null); const [redisLoading, setRedisLoading] = useState(false); const [redisDetailLoading, setRedisDetailLoading] = useState(false); const [redisValueSearch, setRedisValueSearch] = useState(''); // -- Pagination -- const [pageSize, setPageSize] = useState(100); const [currentPage, setCurrentPage] = useState(1); // -- UI state -- const [showTemplates, setShowTemplates] = useState(false); const [templateSearch, setTemplateSearch] = useState(''); const [templateCategory, setTemplateCategory] = useState('all'); const [showSidebar, setShowSidebar] = useState(true); const [showResult, setShowResult] = useState(true); const [showHistory, setShowHistory] = useState(false); const [sortState, setSortState] = useState({ column: null, direction: null }); // -- Sidebar 拖拽宽度 -- const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280); const sidebarDragRef = useRef(null); // -- 历史 state -- const [historyRefreshKey, setHistoryRefreshKey] = useState(0); const sqlEditorRef = useRef(null); /** 最近一次执行的语句(选中片段或全文),分页重放以此为准 */ const lastExecutedSqlRef = useRef(''); /* ---- Data loading ---- */ useEffect(() => { 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; } const type = inst?.db_type || ''; if (type === 'mysql' || type === 'postgres' || type === 'postgresql') { setLoadingDbs(true); instanceDetailApi .listDatabases(Number(instanceId)) .then(async (data) => { const dbs = Array.isArray(data) ? data : []; setDatabases(dbs); const schema: Record> = {}; 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({}); }) .finally(() => setLoadingDbs(false)); } else { setDatabases([]); setEditorSchema({}); } }, [instanceId, instances]); 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); useEffect(() => { setRedisKeys([]); setRedisSelectedKey(null); setRedisValueSearch(''); }, [instanceId, database]); const handleRedisSearch = useCallback(async () => { if (!instanceId) return; const pattern = redisPattern.trim(); if (!pattern) { setRedisKeys([]); setRedisSelectedKey(null); return; } setRedisLoading(true); try { const res = await redisApi.scanKeys(Number(instanceId), { db: Number(database || 0), pattern, type: redisType || undefined, }); setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]); } catch { setRedisKeys([]); } finally { setRedisLoading(false); } }, [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 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', }); }, []); 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 })); }) .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 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)); } return r; }, [templates, templateCategory, templateSearch]); const handleApplyTemplate = useCallback( (tpl: QueryTemplate) => { setSql(getTemplateSQL(tpl, dbType, { db: database || undefined })); setShowTemplates(false); }, [dbType, database], ); const catLabel: Record = { all: '全部', 表结构: '表结构', 性能分析: '性能分析', 数据统计: '数据统计', 基础查询: '基础查询', }; /* ---- Execute ---- */ const handleExecute = useCallback( async (pageArg: number | unknown = 1, sqlOverride?: string) => { const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1; // 执行范围:显式覆盖 > 选区/光标语句(仅首页) > 上次执行语句(分页) > 编辑器全文 const effectiveSql = sqlOverride ?? (page === 1 ? (sqlEditorRef.current?.getSqlToRun() ?? sql) : lastExecutedSqlRef.current || sql); if (!effectiveSql || !instanceId) { setError('请选择实例并输入查询语句'); return; } if (/\{(?:table|db|collection)\}/.test(effectiveSql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; } lastExecutedSqlRef.current = effectiveSql; 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: effectiveSql, 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) { // 历史由后端执行时自动落库,这里只触发抽屉刷新 setHistoryRefreshKey((k) => k + 1); } } 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); } finally { setExecuting(false); } }, [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; }), }; }, [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 }; } return { column: col, direction: 'asc' as SortDirection }; }); }, []); const handleExportResult = useCallback(async () => { if (!sql || !instanceId) return; try { const blob = await api.post( '/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; a.download = `query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`; a.click(); URL.revokeObjectURL(url); } catch (e) { setError((e as Error).message || '导出失败'); } }, [sql, instanceId, database]); // 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包) const handlePageSizeChange = useCallback( (size: number) => { setPageSize(size); setCurrentPage(1); handleExecute(1); }, [handleExecute], ); // -- 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 handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []); const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []); const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []); const handleToggleResult = useCallback(() => setShowResult((v) => !v), []); return (
{/* ====== 主区域 ====== */}
{/* -- 顶栏: 实例+库选择 -- */} {showTemplates && ( )} {/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
{/* Schema 侧边栏(左侧,独立) */} {showSidebar && instanceId && ( <>
{ if (dbType === 'redis') { setSql(text); } else if (/^\s*SELECT\b/i.test(text)) { // 左侧栏点表生成的 SELECT 语句:直接替换编辑器内容, // 避免和上一条语句拼接成非法 SQL setSql(text); } else { // 插入表名/字段名:追加到现有内容后 setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' '); } }} onExecuteSql={(sqlText) => { setSql(sqlText); handleExecute(1, sqlText); }} onDatabaseChange={setDatabase} singleDb={!!database} />
)} {/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
{dbType === 'redis' && instanceId && ( )} {/* SQL 编辑器 */} {/* -- 错误提示 -- */} {error && (
{error}
)} {/* -- 查询结果 (编辑器下方,独立滚动,含虚拟滚动表格/分页/raw 展示) -- */} {result && ( )}
{/* end 编辑器+结果区 */}
{/* end 主体区 (sidebar + 编辑器+结果) */}
{/* end 主区域 */} {showHistory && ( setShowHistory(false)} onUseSql={(sqlText, run) => { setSql(sqlText); if (run) handleExecute(1, sqlText); }} refreshKey={historyRefreshKey} /> )} setRedisLineViewer({ open: false, field: '', value: '', format: 'Json' })} />
); }