import { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2, GripHorizontal } from 'lucide-react'; import { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api'; import { Database } from '@/types'; import SqlEditor from '@/components/SqlEditor'; interface SqlTab { id: string; name: string; query: string; databaseId: string; result: SqlQueryResult | null; isExecuting: boolean; } interface SqlInterfaceState { tabs: SqlTab[]; activeTabId: string; splitPosition: number; // percentage } const STORAGE_KEY = 'sql_interface_state'; const generateTabId = () => `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const createNewTab = (databaseId: string = '', name?: string): SqlTab => ({ id: generateTabId(), name: name || 'Новый запрос', query: '', databaseId, result: null, isExecuting: false, }); const loadState = (): SqlInterfaceState | null => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { return JSON.parse(saved); } } catch (e) { console.error('Failed to load SQL interface state:', e); } return null; }; const saveState = (state: SqlInterfaceState) => { try { const stateToSave = { ...state, tabs: state.tabs.map(tab => ({ ...tab, isExecuting: false, result: tab.result ? { ...tab.result, data: tab.result.data?.slice(0, 100), } : null, })), }; localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); } catch (e) { console.error('Failed to save SQL interface state:', e); } }; export default function SqlInterface() { const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({ queryKey: ['databases'], queryFn: async () => { const { data } = await databasesApi.getAll(); return data.filter((db: Database) => db.type !== 'aql'); }, }); const [state, setState] = useState(() => { const saved = loadState(); if (saved && saved.tabs.length > 0) { return { ...saved, splitPosition: saved.splitPosition || 50, }; } const initialTab = createNewTab(); return { tabs: [initialTab], activeTabId: initialTab.id, splitPosition: 50, }; }); const containerRef = useRef(null); const isDragging = useRef(false); useEffect(() => { saveState(state); }, [state]); useEffect(() => { if (databases.length > 0) { setState(prev => ({ ...prev, tabs: prev.tabs.map(tab => tab.databaseId === '' ? { ...tab, databaseId: databases[0].id } : tab ), })); } }, [databases]); const activeTab = state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0]; const updateTab = useCallback((tabId: string, updates: Partial) => { setState(prev => ({ ...prev, tabs: prev.tabs.map(tab => tab.id === tabId ? { ...tab, ...updates } : tab ), })); }, []); const addTab = useCallback(() => { const defaultDbId = databases.length > 0 ? databases[0].id : ''; const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`); setState(prev => ({ ...prev, tabs: [...prev.tabs, newTab], activeTabId: newTab.id, })); }, [databases, state.tabs.length]); const closeTab = useCallback((tabId: string, e: React.MouseEvent) => { e.stopPropagation(); setState(prev => { if (prev.tabs.length === 1) { const defaultDbId = databases.length > 0 ? databases[0].id : ''; const newTab = createNewTab(defaultDbId); return { ...prev, tabs: [newTab], activeTabId: newTab.id, }; } const newTabs = prev.tabs.filter(t => t.id !== tabId); const newActiveId = prev.activeTabId === tabId ? newTabs[Math.max(0, prev.tabs.findIndex(t => t.id === tabId) - 1)].id : prev.activeTabId; return { ...prev, tabs: newTabs, activeTabId: newActiveId, }; }); }, [databases]); const setActiveTab = useCallback((tabId: string) => { setState(prev => ({ ...prev, activeTabId: tabId })); }, []); const executeQuery = useCallback(async () => { if (!activeTab || !activeTab.databaseId || !activeTab.query.trim()) { return; } updateTab(activeTab.id, { isExecuting: true, result: null }); try { const { data } = await sqlInterfaceApi.execute(activeTab.databaseId, activeTab.query); updateTab(activeTab.id, { isExecuting: false, result: data }); } catch (error: any) { updateTab(activeTab.id, { isExecuting: false, result: { success: false, error: error.response?.data?.error || error.message || 'Query execution failed', detail: error.response?.data?.detail, hint: error.response?.data?.hint, }, }); } }, [activeTab, updateTab]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); executeQuery(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [executeQuery]); // Drag to resize const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDragging.current = true; document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; }, []); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging.current || !containerRef.current) return; const container = containerRef.current; const rect = container.getBoundingClientRect(); const y = e.clientY - rect.top; const percentage = (y / rect.height) * 100; const clamped = Math.min(Math.max(percentage, 20), 80); setState(prev => ({ ...prev, splitPosition: clamped })); }; const handleMouseUp = () => { if (isDragging.current) { isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); if (isLoadingDatabases) { return (
); } return (
{/* Tabs bar */}
{state.tabs.map(tab => ( ))}
{/* Toolbar */}
updateTab(activeTab.id, { name: e.target.value })} className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 w-48" placeholder="Название вкладки" />
{/* Main content area with resizable split */}
{/* SQL Editor */}
updateTab(activeTab.id, { query: value })} databaseId={activeTab?.databaseId} />
{/* Resize handle */}
{/* Results panel */}
{/* Result header */} {activeTab?.result && (
{activeTab.result.success ? ( <>
Успешно
{activeTab.result.rowCount ?? 0} строк
{activeTab.result.executionTime !== undefined && (
{activeTab.result.executionTime} мс
)} {activeTab.result.command && ( {activeTab.result.command} )} ) : (
Ошибка
)}
)} {/* Result content */}
{!activeTab?.result && !activeTab?.isExecuting && (
Выполните запрос, чтобы увидеть результаты
)} {activeTab?.isExecuting && (
)} {activeTab?.result && !activeTab.result.success && (

{activeTab.result.error}

{activeTab.result.detail && (

{activeTab.result.detail}

)} {activeTab.result.hint && (

Подсказка: {activeTab.result.hint}

)}
)} {activeTab?.result?.success && activeTab.result.data && ( )}
); } interface ResultTableProps { data: any[]; fields: { name: string; dataTypeID: number }[]; } function ResultTable({ data, fields }: ResultTableProps) { if (data.length === 0) { return (
Запрос выполнен успешно, но не вернул данных
); } const columns = fields.length > 0 ? fields.map(f => f.name) : Object.keys(data[0] || {}); return (
{columns.map(col => ( ))} {data.map((row, rowIndex) => ( {columns.map(col => ( ))} ))}
# {col}
{rowIndex + 1} {formatCellValue(row[col])}
); } function formatCellValue(value: any): string { if (value === null) return 'NULL'; if (value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); }