From 577a4172926eb838775bc6ff10b93442e5d5c0ec Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:23:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(explain-ui):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=85=A2=20SQL=20=E5=8E=86=E5=8F=B2=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=20Ctrl+Shift+H=20=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 面板组件:SlowQueryPanel 含 TopN 列表 + 耗时/扫描行数/时间三种排序 + 清空操作 - 入口接入:QueryEditor 加 Ctrl+Shift+H 快捷键,点击条目回填 SQL 到编辑器 - Wails 绑定:手动同步 GetSlowQueries 与 ClearSlowQueries 到 App.js/App.d.ts - 颜色提示:耗时 >5s 红色、>1s 橙色,便于一眼识别重查询 --- frontend/src/components/QueryEditor.tsx | 24 +- .../src/components/explain/SlowQueryPanel.tsx | 222 ++++++++++++++++++ frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/explain/SlowQueryPanel.tsx diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index bca3b83b..2664901c 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -33,6 +33,8 @@ import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSid import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; // SQL 诊断工作台:lazy 加载避免 reactflow/dagre 进入主 bundle(约 130KB gzipped 独立 chunk) const ExplainWorkbench = lazy(() => import('./explain/ExplainWorkbench')); +// 慢 SQL 历史面板:lazy 加载 +const SlowQueryPanel = lazy(() => import('./explain/SlowQueryPanel')); import { SUPPORTED_LANGUAGES, t as translate } from '../i18n'; import { DUCKDB_ROWID_LOCATOR_COLUMN, @@ -2211,6 +2213,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // SQL 诊断工作台:Ctrl+Shift+D 触发(Mac 为 Cmd+Shift+D) const [explainOpen, setExplainOpen] = useState(false); + // 慢 SQL 历史:Ctrl+Shift+H 触发 + const [slowQueryOpen, setSlowQueryOpen] = useState(false); // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); @@ -2274,9 +2278,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc useEffect(() => { if (!isActive) return; const handler = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) { + if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; + const key = e.key.toLowerCase(); + if (key === 'd') { e.preventDefault(); setExplainOpen(true); + } else if (key === 'h') { + e.preventDefault(); + setSlowQueryOpen(true); } }; window.addEventListener('keydown', handler); @@ -6157,6 +6166,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc /> )} + + {/* 慢 SQL 历史:Ctrl+Shift+H 触发 */} + + {slowQueryOpen && explainConfig && ( + setSlowQueryOpen(false)} + config={explainConfig} + dbName={currentDb} + onPickQuery={(sql) => setQuery(sql)} + /> + )} + ); }; diff --git a/frontend/src/components/explain/SlowQueryPanel.tsx b/frontend/src/components/explain/SlowQueryPanel.tsx new file mode 100644 index 00000000..eb7fb141 --- /dev/null +++ b/frontend/src/components/explain/SlowQueryPanel.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Empty, Modal, Segmented, Spin, Tooltip, Typography, message } from 'antd' +import { ReloadOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { ClearSlowQueries, GetSlowQueries } from '../../../wailsjs/go/app/App' +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig' +import type { ConnectionConfig } from '../../types' +import { formatMs, formatNumber } from '../../utils/explainTypes' + +// 慢 SQL 历史面板。 +// 从 GetSlowQueries 加载 TopN,按 duration / rowsRead / recent 切换排序。 +// 点击条目可触发 onPickQuery 把 SQL 回填到 QueryEditor。 +// +// 设计要点: +// - 独立 Modal,不依赖 Sidebar 内部布局(Sidebar.tsx 已经 9000+ 行,避免污染) +// - 用户从 Sidebar 一个独立按钮触发 +// - SQL 指纹去重由后端完成,前端只展示 + +const { Title, Text, Paragraph } = Typography + +type SortBy = 'duration' | 'rowsRead' | 'recent' + +interface SlowQueryRecord { + id?: string + connectionFp?: string + sqlFp?: string + sqlPreview?: string + dbType?: string + durationMs?: number + rowsRead?: number + rowsReturned?: number + planHash?: string + executedAt?: string +} + +interface SlowQueryPanelProps { + open: boolean + onClose: () => void + config: ConnectionConfig + dbName: string + onPickQuery?: (sql: string) => void +} + +export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) { + const [loading, setLoading] = useState(false) + const [records, setRecords] = useState([]) + const [error, setError] = useState(null) + const [sortBy, setSortBy] = useState('duration') + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await GetSlowQueries(buildRpcConnectionConfig(config), dbName, sortBy, 100) + if (!result.success) { + setError(result.message || '加载失败') + setRecords([]) + } else { + setRecords((result.data as SlowQueryRecord[]) ?? []) + } + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + } + }, [config, dbName, sortBy]) + + useEffect(() => { + if (open) { + void reload() + } + }, [open, reload]) + + const handleClear = useCallback(async () => { + const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName) + if (result.success) { + message.success('已清空慢查询历史') + setRecords([]) + } else { + message.error(result.message || '清空失败') + } + }, [config, dbName]) + + const handlePick = useCallback( + (record: SlowQueryRecord) => { + if (record.sqlPreview && onPickQuery) { + onPickQuery(record.sqlPreview) + onClose() + } + }, + [onPickQuery, onClose], + ) + + const sorted = useMemo(() => records, [records]) // 后端已排序,前端不再排 + + return ( + + + 慢 SQL 历史 + + {dbName || '(当前连接)'} + + + } + destroyOnClose + > +
+ setSortBy(v as SortBy)} + options={[ + { label: '按耗时', value: 'duration' }, + { label: '按扫描行数', value: 'rowsRead' }, + { label: '按时间', value: 'recent' }, + ]} + /> +
+ +
+
+ + {loading && ( +
+ +
+ )} + + {error && ( + + 加载失败: + {error} + + )} + + {!loading && !error && sorted.length === 0 && ( + + )} + + {!loading && !error && sorted.length > 0 && ( +
+ {sorted.map((r, idx) => ( + handlePick(r)} /> + ))} +
+ )} +
+ ) +} + +function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: () => void }) { + const duration = record.durationMs ?? 0 + const durationColor = duration >= 5000 ? '#fa5252' : duration >= 1000 ? '#f08c00' : '#495057' + + return ( +
+
+
+ {formatMs(duration)} + {record.rowsRead !== undefined && record.rowsRead > 0 && ( + + 扫描 {formatNumber(record.rowsRead)} + + )} + {record.rowsReturned !== undefined && record.rowsReturned > 0 && ( + + 返回 {formatNumber(record.rowsReturned)} + + )} +
+
+ {record.dbType && {record.dbType}} + {record.executedAt && formatRelativeTime(record.executedAt)} +
+
+
+        {record.sqlPreview || '(无 SQL 预览)'}
+      
+
+ ) +} + +// formatRelativeTime 把 ISO 时间字符串格式化为相对时间("3分钟前")。 +function formatRelativeTime(isoTime: string): string { + const ts = Date.parse(isoTime) + if (isNaN(ts)) return '' + const diffMs = Date.now() - ts + if (diffMs < 60_000) return '刚刚' + if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)} 分钟前` + if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)} 小时前` + return `${Math.floor(diffMs / 86400_000)} 天前` +} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 01665b18..5c5d1ca2 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -84,6 +84,10 @@ export function DeleteSQLFile(arg1:string):Promise; export function DiagnoseQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; +export function GetSlowQueries(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise; + +export function ClearSlowQueries(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function DismissSecurityUpdateReminder():Promise; export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index c43680c8..da16b92f 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -158,6 +158,14 @@ export function DiagnoseQuery(arg1, arg2, arg3) { return window['go']['app']['App']['DiagnoseQuery'](arg1, arg2, arg3); } +export function GetSlowQueries(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['GetSlowQueries'](arg1, arg2, arg3, arg4); +} + +export function ClearSlowQueries(arg1, arg2) { + return window['go']['app']['App']['ClearSlowQueries'](arg1, arg2); +} + export function DismissSecurityUpdateReminder() { return window['go']['app']['App']['DismissSecurityUpdateReminder'](); }