import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Plus, X, AlertTriangle, CheckCircle, Play, Loader2, ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star, StarOff, Download, Search, ArrowUpDown, Database, Table2, ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3, KeyRound, FolderOpen, Server, Zap, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; import PageHeader from '@/components/PageHeader'; import EmptyState from '@/components/EmptyState'; import { queryApi } from '@/api/query'; import { instanceApi } from '@/api/instance'; import SqlEditor from '@/components/SqlEditor'; import SchemaSidebar from '@/components/SchemaSidebar'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; import { redisApi } from '@/api/redis'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface QueryResult { columns: string[]; rows: Record[]; affected_rows: number; execution_time_ms: number; } interface QueryHistoryItem { id: number; sql: string; instance_id: number; database: string; db_type: string; status: string; execution_time_ms: number; result_preview: string; created_at: string; favorite?: boolean; } type SortDirection = 'asc' | 'desc' | null; type SortState = { column: string | null; direction: SortDirection }; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const dbTypeLabel: Record = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB', }; const dbTypeIcon: Record = { mysql: '/db-logos/mysql.svg', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg', }; const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no']; const placeholderMap: Record = { mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*", mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)", }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function maskData(value: string): string { if (!value) return '***'; if (typeof value === 'string' && value.includes('@')) { const [name, domain] = value.split('@'); return name.substring(0, 3) + '***@' + domain; } if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4); return '***'; } function exportToCSV(columns: string[], rows: Record[]): string { const header = columns.join(','); const lines = rows.map(row => columns.map(col => { const val = String(row[col] ?? ''); if (val.includes(',') || val.includes('"') || val.includes('\n')) { return `"${val.replace(/"/g, '""')}"`; } return val; }).join(',') ); return [header, ...lines].join('\n'); } function downloadCSV(filename: string, content: string) { const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ export default function QueriesPage() { const canCreate = useActionPermission('query.create'); const [showNewQuery, setShowNewQuery] = useState(false); const [history, setHistory] = useState(() => { try { const saved = localStorage.getItem('query_favorites'); const favIds = saved ? new Set(JSON.parse(saved)) : new Set(); const initialHistory: QueryHistoryItem[] = []; return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) })); } catch { return []; } }); const [favFilter, setFavFilter] = useState(false); const [searchHistory, setSearchHistory] = useState(''); const [clearConfirm, setClearConfirm] = useState(false); const toggleFavorite = useCallback((id: number) => { setHistory(prev => { const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); const favIds = next.filter(h => h.favorite).map(h => h.id); try { localStorage.setItem('query_favorites', JSON.stringify(favIds)); } catch { /* noop */ } return next; }); }, []); const handleExecuted = useCallback((item: QueryHistoryItem) => { setHistory(prev => [item, ...prev]); }, []); const handleClearHistory = useCallback(() => { if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; } setHistory([]); setClearConfirm(false); }, [clearConfirm]); const filteredHistory = useMemo(() => { let result = history; if (favFilter) result = result.filter(h => h.favorite); if (searchHistory) { const kw = searchHistory.toLowerCase(); result = result.filter(h => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw) ); } return result; }, [history, favFilter, searchHistory]); if (showNewQuery) { return ( setShowNewQuery(false)} onExecuted={handleExecuted} history={history} onToggleFavorite={toggleFavorite} /> ); } return (
setShowNewQuery(true), permission: 'query.create' } : undefined} /> {/* History Controls */} {history.length > 0 && (
setSearchHistory(e.target.value)} />
)} {history.length === 0 ? ( ) : filteredHistory.length === 0 ? (
没有找到匹配的历史记录
) : (
{filteredHistory.map((q, idx) => ( ))}
# 类型 语句 执行时长 状态 时间 操作
{idx + 1} {q.db_type && dbTypeIcon[q.db_type] && ( {q.db_type} )} {q.sql.substring(0, 120)} 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms {q.status === 'success' ? '成功' : '失败'} {new Date(q.created_at).toLocaleString()}
)}
); } /* ------------------------------------------------------------------ */ /* NewQueryPanel */ /* ------------------------------------------------------------------ */ function NewQueryPanel({ onCancel, onExecuted, history, onToggleFavorite, }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void; history: QueryHistoryItem[]; onToggleFavorite: (id: number) => void; }) { const [sql, setSql] = useState(''); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); const [instances, setInstances] = useState>([]); const [executing, setExecuting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); const [showTemplates, setShowTemplates] = useState(false); const [templateSearch, setTemplateSearch] = useState(''); const [templateCategory, setTemplateCategory] = useState('all'); const [sortState, setSortState] = useState({ column: null, direction: null }); const [showHistory, setShowHistory] = useState(false); const [rowHeight] = useState(36); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const resultContainerRef = useRef(null); const [showSidebar, setShowSidebar] = useState(true); useEffect(() => { instanceApi.list().then(res => { const data = Array.isArray(res) ? res : []; setInstances(data); }).catch(() => { setInstances([]); }); }, []); const selectedInstance = useMemo(() => { return instances.find(inst => String(inst.id) === instanceId); }, [instances, instanceId]); const dbType = (selectedInstance?.db_type || 'mysql') as DBType; const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const; 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 result = templates; if (templateCategory !== 'all') result = result.filter(t => t.category === templateCategory); if (templateSearch) { const kw = templateSearch.toLowerCase(); result = result.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); } return result; }, [templates, templateCategory, templateSearch]); const handleApplyTemplate = useCallback((tpl: QueryTemplate) => { const templateSQL = getTemplateSQL(tpl, dbType, { db: database || undefined }); setSql(templateSQL); setShowTemplates(false); }, [dbType, database]); const handleExecute = useCallback(async () => { if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; } // 检查未替换的模板占位符 if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符 {table}/{db}/{collection},请先替换或删除'); return; } setExecuting(true); setError(''); setResult(null); try { const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, }) as unknown as QueryResult; const execTime = res.execution_time_ms || 0; setResult(res); onExecuted({ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: execTime, result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false, }); } 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('; ') : (raw || '查询失败'); setError(msg); onExecuted({ 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, }); } finally { setExecuting(false); } }, [sql, instanceId, database, dbType, onExecuted]); /* ---- Result Sorting ---- */ const sortedResult = useMemo(() => { if (!result || !sortState.column || !sortState.direction) return result; const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1; const sortedRows = [...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; }); return { ...result, rows: sortedRows }; }, [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 SortIcon = ({ col }: { col: string }) => { if (sortState.column !== col) return ; if (sortState.direction === 'asc') return ; return ; }; /* ---- Virtual Scroll ---- */ const handleResultScroll = useCallback(() => { if (!resultContainerRef.current) return; const container = resultContainerRef.current; const scrollTop = container.scrollTop; const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5); const visibleCount = Math.ceil(container.clientHeight / rowHeight); const end = Math.min((sortedResult?.rows?.length || 0), start + visibleCount + 10); setVisibleRange({ start, end }); }, [sortedResult, rowHeight]); const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight; const handleExportResult = useCallback(() => { if (!result) return; const csv = exportToCSV(result.columns, result.rows); downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv); }, [result]); const handleExportHistory = useCallback(() => { const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间']; const rows = history.map((h, i) => ({ 序号: i + 1, SQL: h.sql, 数据库类型: h.db_type, '执行时长(ms)': h.execution_time_ms, 状态: h.status === 'success' ? '成功' : '失败', 时间: new Date(h.created_at).toLocaleString(), })); const csv = exportToCSV(columns, rows); downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv); }, [history]); const catLabel: Record = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询', }; return (

新建查询

{selectedInstance && ( {dbTypeLabel[dbType] || dbType} )}
{history.length > 0 && ( )}
{/* History Sidebar */} {showHistory && (
查询历史 ({history.length})
{history.length === 0 ? (
暂无查询历史
) : (
{history.map(h => (
{h.db_type && dbTypeIcon[h.db_type] && ( )} {h.sql.substring(0, 80)} {h.execution_time_ms}ms
))}
)}
)} {/* Instance + Database selector */}
setDatabase(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors" placeholder="可选,默认数据库" />
{/* Template selector */}
{instanceId && ( )}
{showTemplates && (
{/* Category tabs + search */}
{templateCategories.map(cat => ( ))}
setTemplateSearch(e.target.value)} className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40" placeholder="搜索模板..." />
{/* Templates Grid */} {filteredTemplates.length === 0 ? (
无匹配模板
) : (
{filteredTemplates.map(tpl => ( ))}
)}
)} {/* CodeMirror Editor + Schema Sidebar */}
{/* Schema Sidebar */} {showSidebar && instanceId && (
setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')} />
)} {/* Editor */}
{dbTypeIcon[dbType] && } {dbTypeLabel[dbType]} 模式
{sql.length} 字符 Ctrl+Enter 执行 Ctrl+Shift+F 格式化
{/* Security notice */}
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
{error && (
{error}
)} {/* Query Result */} {result && (
查询结果
{result.execution_time_ms}ms {result.rows?.length || 0} 行 {result.columns?.length || 0} 列 {result.rows && result.rows.length > 0 && ( )}
{result.columns && result.columns.length > 0 ? (
{result.columns.map(col => ( ))} {/* Virtual scroll: only render visible rows */} {(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => { const realIndex = visibleRange.start + i; return ( {result.columns.map(col => { const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k)); const cellValue = row[col] ?? ''; return ( ); })} ); })}
# handleSort(col)} className="text-left px-3 py-2.5 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap" >
{col} {sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && ( 敏 )}
{realIndex + 1} {isSensitive ? {maskData(String(cellValue))} : String(cellValue) }
) : (
无结果数据 {result.affected_rows > 0 && ( 影响行数: {result.affected_rows} )}
)}
)}
); }