diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cf89390..0471959 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,9 +14,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
+ "@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
+ "dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -24,6 +26,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
+ "reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
@@ -1214,6 +1217,108 @@
"react-dom": ">=16.9.0"
}
},
+ "node_modules/@reactflow/background": {
+ "version": "11.3.14",
+ "resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz",
+ "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/controls": {
+ "version": "11.2.14",
+ "resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz",
+ "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/core": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz",
+ "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3": "^7.4.0",
+ "@types/d3-drag": "^3.0.1",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/minimap": {
+ "version": "11.7.14",
+ "resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz",
+ "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-resizer": {
+ "version": "2.2.14",
+ "resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
+ "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.4",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-toolbar": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
+ "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -1928,6 +2033,12 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/dagre": {
+ "version": "0.7.54",
+ "resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz",
+ "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2509,6 +2620,12 @@
"chevrotain": "^11.0.0"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -3081,6 +3198,16 @@
"node": ">=12"
}
},
+ "node_modules/dagre": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz",
+ "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "graphlib": "^2.1.8",
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/dagre-d3-es": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
@@ -3387,6 +3514,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/graphlib": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
+ "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/hachure-fill": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
@@ -3675,6 +3811,12 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
@@ -5713,6 +5855,24 @@
"react": "^18.2.0"
}
},
+ "node_modules/reactflow": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz",
+ "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/background": "11.3.14",
+ "@reactflow/controls": "11.2.14",
+ "@reactflow/core": "11.11.4",
+ "@reactflow/minimap": "11.7.14",
+ "@reactflow/node-resizer": "2.2.14",
+ "@reactflow/node-toolbar": "1.3.14"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 197e39c..cb03395 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,9 +17,11 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
+ "@types/dagre": "^0.7.54",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
+ "dagre": "^0.8.5",
"fflate": "^0.8.3",
"mermaid": "^11.13.0",
"react": "^18.2.0",
@@ -27,6 +29,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
+ "reactflow": "^11.11.4",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
diff --git a/frontend/src/components/explain/ExplainGraph.tsx b/frontend/src/components/explain/ExplainGraph.tsx
new file mode 100644
index 0000000..2ff2f5c
--- /dev/null
+++ b/frontend/src/components/explain/ExplainGraph.tsx
@@ -0,0 +1,224 @@
+import { useCallback, useMemo } from 'react'
+import ReactFlow, {
+ Background,
+ BackgroundVariant,
+ Controls,
+ type Edge,
+ type Node,
+ type NodeMouseHandler,
+ Position,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+} from 'reactflow'
+import dagre from 'dagre'
+import 'reactflow/dist/style.css'
+import {
+ type ExplainEdge,
+ type ExplainNode,
+ opTypeColor,
+ formatNumber,
+} from '../../utils/explainTypes'
+
+// 执行计划图主组件。
+// 使用 react-flow 渲染扁平节点数组,dagre 自动计算树形布局。
+//
+// 设计要点:
+// - 自定义节点(ExplainGraphNodeData)按 opType 着色 + 警告 flag 边框高亮
+// - 点击节点触发 onSelectNode 回调(详情抽屉联动)
+// - 节点尺寸自适应内容,避免长 SQL/表名截断
+// - 通过 React.memo 避免不必要的重渲染(88W 数据下很重要)
+
+const NODE_WIDTH = 220
+const NODE_HEIGHT = 80
+
+export interface ExplainGraphNodeData {
+ node: ExplainNode
+ isSelected: boolean
+}
+
+interface ExplainGraphProps {
+ nodes: ExplainNode[]
+ edges: ExplainEdge[]
+ selectedNodeId?: string
+ onSelectNode?: (nodeId: string | null) => void
+}
+
+export default function ExplainGraph(props: ExplainGraphProps) {
+ return (
+
+
+
+ )
+}
+
+function ExplainGraphInner({ nodes, edges, selectedNodeId, onSelectNode }: ExplainGraphProps) {
+ const { rfNodes, rfEdges } = useMemo(
+ () => layoutWithDagre(nodes, edges, selectedNodeId),
+ [nodes, edges, selectedNodeId],
+ )
+
+ const [nodeState, , onNodesChange] = useNodesState(rfNodes)
+ const [edgeState, , onEdgesChange] = useEdgesState(rfEdges)
+
+ const handleNodeClick: NodeMouseHandler = useCallback(
+ (_event, node) => {
+ onSelectNode?.(node.id)
+ },
+ [onSelectNode],
+ )
+
+ const handlePaneClick = useCallback(() => {
+ onSelectNode?.(null)
+ }, [onSelectNode])
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+// layoutWithDagre 用 dagre 计算 react-flow 节点位置。
+// 默认从上到下(TB)布局,符合执行计划的"父子层级"心智模型。
+function layoutWithDagre(
+ nodes: ExplainNode[],
+ edges: ExplainEdge[],
+ selectedNodeId?: string,
+): { rfNodes: Node[]; rfEdges: Edge[] } {
+ const g = new dagre.graphlib.Graph()
+ g.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 60, marginx: 20, marginy: 20 })
+ g.setDefaultEdgeLabel(() => ({}))
+
+ for (const node of nodes) {
+ g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
+ }
+ for (const edge of edges) {
+ g.setEdge(edge.from, edge.to, { label: edge.label })
+ }
+ dagre.layout(g)
+
+ const rfNodes: Node[] = nodes.map((node) => {
+ const pos = g.node(node.id)
+ return {
+ id: node.id,
+ type: 'explain',
+ position: { x: pos?.x ?? 0, y: pos?.y ?? 0 },
+ data: { node, isSelected: node.id === selectedNodeId },
+ targetPosition: Position.Top,
+ sourcePosition: Position.Bottom,
+ draggable: false,
+ }
+ })
+
+ const rfEdges: Edge[] = edges.map((edge, idx) => ({
+ id: `e-${edge.from}-${edge.to}-${idx}`,
+ source: edge.from,
+ target: edge.to,
+ label: edge.label,
+ type: 'smoothstep',
+ style: { stroke: 'var(--gn-explain-edge, #adb5bd)', strokeWidth: 1.5 },
+ }))
+
+ return { rfNodes, rfEdges }
+}
+
+import { memo } from 'react'
+
+const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({
+ data,
+}: {
+ data: ExplainGraphNodeData
+}) {
+ const { node, isSelected } = data
+ const color = opTypeColor(node.opType)
+ const hasFullScan = node.flags?.includes('FULL_SCAN')
+ const hasFilesort = node.flags?.includes('FILESORT')
+ const hasTempTable = node.flags?.includes('TEMP_TABLE')
+
+ return (
+
+
{node.opDetail || node.opType}
+ {node.table && (
+
+ 表:
+ {node.table}
+
+ )}
+ {node.index && (
+
+ 索引:
+ {node.index}
+
+ )}
+
+ {node.estRows !== undefined && node.estRows > 0 && (
+
+ 估算 {formatNumber(node.estRows)}
+
+ )}
+ {node.actualRows !== undefined && node.actualRows > 0 && (
+
+ 实际 {formatNumber(node.actualRows)}
+
+ )}
+ {node.cost !== undefined && node.cost > 0 && (
+
+ 成本 {node.cost.toFixed(1)}
+
+ )}
+
+ {(hasFullScan || hasFilesort || hasTempTable) && (
+
+ {hasFullScan && }
+ {hasFilesort && }
+ {hasTempTable && }
+
+ )}
+
+ )
+})
+
+function FlagBadge({ color, text }: { color: string; text: string }) {
+ return (
+
+ {text}
+
+ )
+}
diff --git a/frontend/src/components/explain/ExplainSidebar.tsx b/frontend/src/components/explain/ExplainSidebar.tsx
new file mode 100644
index 0000000..712d660
--- /dev/null
+++ b/frontend/src/components/explain/ExplainSidebar.tsx
@@ -0,0 +1,234 @@
+import { useMemo } from 'react'
+import {
+ type ExplainNode,
+ type ExplainStats,
+ type IndexSuggestion,
+ severityColor,
+ severityRank,
+ formatNumber,
+ formatPercent,
+ formatMs,
+} from '../../utils/explainTypes'
+
+// 诊断侧栏:节点详情 + 统计条 + 索引建议列表的合集组件。
+// 拆分为一个文件减少模块碎片化(plan 原拆 3 个文件)。
+
+interface ExplainSidebarProps {
+ stats: ExplainStats
+ warnings?: string[]
+ suggestions: IndexSuggestion[]
+ selectedNode?: ExplainNode
+ onSelectSuggestion?: (suggestion: IndexSuggestion) => void
+}
+
+export default function ExplainSidebar(props: ExplainSidebarProps) {
+ const { stats, warnings, suggestions, selectedNode, onSelectSuggestion } = props
+ const sortedSuggestions = useMemo(
+ () =>
+ [...suggestions].sort((a, b) => {
+ const ra = severityRank[a.severity] ?? 99
+ const rb = severityRank[b.severity] ?? 99
+ if (ra !== rb) return ra - rb
+ return (b.estRows ?? 0) - (a.estRows ?? 0)
+ }),
+ [suggestions],
+ )
+
+ return (
+
+
+ {selectedNode && }
+
+
+ )
+}
+
+function ExplainStatsBar({
+ stats,
+ warnings,
+}: {
+ stats: ExplainStats
+ warnings?: string[]
+}) {
+ const statsList = [
+ { label: '总成本', value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' },
+ { label: '总耗时', value: formatMs(stats.totalDurationMs) },
+ { label: '扫描行数', value: formatNumber(stats.rowsRead) },
+ { label: '缓冲命中', value: formatPercent(stats.bufferHitRate) },
+ { label: '最大单节点行数', value: formatNumber(stats.maxEstRows) },
+ ]
+ return (
+
+
执行统计
+
+ {statsList.map((s) => (
+
+ {s.label}
+ {s.value}
+
+ ))}
+
+ {stats.hasFullScan &&
}
+ {stats.hasFilesort &&
}
+ {stats.hasTempTable &&
}
+ {warnings && warnings.length > 0 && (
+
+ {warnings.map((w, i) => (
+
⚠ {w}
+ ))}
+
+ )}
+
+ )
+}
+
+function WarningRow({ color, text }: { color: string; text: string }) {
+ return (
+
+
+ {text}
+
+ )
+}
+
+function ExplainNodeDetail({ node }: { node: ExplainNode }) {
+ const rows: Array<[string, string]> = []
+ rows.push(['操作类型', node.opType])
+ if (node.opDetail) rows.push(['操作详情', node.opDetail])
+ if (node.table) rows.push(['表', node.table])
+ if (node.index) rows.push(['索引', node.index])
+ if (node.estRows) rows.push(['估算行数', formatNumber(node.estRows)])
+ if (node.actualRows) rows.push(['实际行数', formatNumber(node.actualRows)])
+ if (node.loops) rows.push(['循环次数', formatNumber(node.loops)])
+ if (node.cost) rows.push(['成本', node.cost.toFixed(2)])
+ if (node.durationMs) rows.push(['耗时', formatMs(node.durationMs)])
+ if (node.bufferHit !== undefined && node.bufferHit > 0)
+ rows.push(['缓冲命中', formatPercent(node.bufferHit)])
+ if (node.flags && node.flags.length > 0) rows.push(['标志', node.flags.join(', ')])
+
+ return (
+
+
节点详情
+
+ {rows.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+ {node.extra && Object.keys(node.extra).length > 0 && (
+
+
+ Extra 字段({Object.keys(node.extra).length})
+
+
+ {JSON.stringify(node.extra, null, 2)}
+
+
+ )}
+
+ )
+}
+
+function IndexSuggestionList({
+ suggestions,
+ onSelect,
+}: {
+ suggestions: IndexSuggestion[]
+ onSelect?: (s: IndexSuggestion) => void
+}) {
+ return (
+
+
+ 索引建议({suggestions.length})
+
+ {suggestions.length === 0 ? (
+
+ 未发现明显性能问题
+
+ ) : (
+
+ {suggestions.map((s, idx) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function SuggestionCard({
+ suggestion,
+ onSelect,
+}: {
+ suggestion: IndexSuggestion
+ onSelect?: (s: IndexSuggestion) => void
+}) {
+ const color = severityColor(suggestion.severity)
+ return (
+ onSelect?.(suggestion)}
+ style={{
+ borderLeft: `3px solid ${color}`,
+ padding: '6px 8px',
+ background: 'var(--gn-suggestion-bg, #ffffff)',
+ cursor: onSelect ? 'pointer' : 'default',
+ fontSize: 12,
+ }}
+ >
+
+
+ {suggestion.severity}
+
+ {suggestion.estRows !== undefined && suggestion.estRows > 0 && (
+
+ {formatNumber(suggestion.estRows)} 行
+
+ )}
+
+
{suggestion.reason}
+ {suggestion.suggestedIndex && (
+
+ {suggestion.suggestedIndex}
+
+ )}
+ {suggestion.affectedTable && (
+
+ 表:{suggestion.affectedTable}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/explain/ExplainWorkbench.tsx b/frontend/src/components/explain/ExplainWorkbench.tsx
new file mode 100644
index 0000000..c07f033
--- /dev/null
+++ b/frontend/src/components/explain/ExplainWorkbench.tsx
@@ -0,0 +1,158 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Modal, Spin, Tabs, Typography } from 'antd'
+import { DiagnoseQuery } from '../../../wailsjs/go/app/App'
+import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
+import type { ConnectionConfig } from '../../types'
+import type { DiagnoseReport, ExplainNode, IndexSuggestion } from '../../utils/explainTypes'
+import ExplainGraph from './ExplainGraph'
+import ExplainSidebar from './ExplainSidebar'
+
+// SQL 诊断工作台主容器。
+// 通过 React.lazy 在 QueryEditor 触发"诊断"时延迟加载(避免 react-flow 进入主 bundle)。
+//
+// UI 结构:
+// ┌─────────────────────────────────────────────────┐
+// │ Modal:诊断工作台 │
+// ├──────────────────────────────┬──────────────────┤
+// │ react-flow 执行计划图 │ 侧栏 │
+// │ (点击节点联动) │ - 统计条 │
+// │ │ - 节点详情 │
+// │ │ - 索引建议 │
+// └──────────────────────────────┴──────────────────┘
+// 底部 tab:执行计划 | 原文(调试用)
+
+const { Title, Text, Paragraph } = Typography
+
+interface ExplainWorkbenchProps {
+ open: boolean
+ onClose: () => void
+ config: ConnectionConfig
+ dbName: string
+ sql: string
+}
+
+export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
+ const [loading, setLoading] = useState(false)
+ const [report, setReport] = useState(null)
+ const [error, setError] = useState(null)
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+
+ const runDiagnose = useCallback(async () => {
+ if (!sql.trim()) {
+ setError('查询语句为空')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ setReport(null)
+ setSelectedNodeId(null)
+ try {
+ const result = await DiagnoseQuery(buildRpcConnectionConfig(config), dbName, sql)
+ if (!result.success) {
+ setError(result.message || '诊断失败')
+ } else {
+ const data = result.data as DiagnoseReport
+ setReport(data)
+ }
+ } catch (e) {
+ setError(String(e))
+ } finally {
+ setLoading(false)
+ }
+ }, [config, dbName, sql])
+
+ useEffect(() => {
+ if (open) {
+ void runDiagnose()
+ }
+ }, [open, runDiagnose])
+
+ const selectedNode = useMemo(() => {
+ if (!report || !selectedNodeId) return undefined
+ return report.plan.nodes.find((n) => n.id === selectedNodeId)
+ }, [report, selectedNodeId])
+
+ const handleSelectSuggestion = useCallback((s: IndexSuggestion) => {
+ if (s.affectedNodeId) {
+ setSelectedNodeId(s.affectedNodeId)
+ }
+ }, [])
+
+ return (
+ SQL 诊断工作台}
+ destroyOnClose
+ >
+
+ {loading && (
+
+
+
+ )}
+ {error && (
+
+ 诊断失败:
+ {error}
+
+ )}
+ {!loading && !error && report && (
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'raw',
+ label: `原文(${report.plan.rawFormat})`,
+ children: (
+
+ {report.plan.rawPayload || '(无原文)'}
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+
+ )
+}
diff --git a/frontend/src/utils/explainTypes.ts b/frontend/src/utils/explainTypes.ts
new file mode 100644
index 0000000..96510aa
--- /dev/null
+++ b/frontend/src/utils/explainTypes.ts
@@ -0,0 +1,172 @@
+// SQL 诊断工作台前端类型定义。
+//
+// 本文件镜像后端 internal/connection/explain.go 的数据结构。
+// 当 Wails 重新生成 models.ts 后,可逐步迁移到 import { connection } from '../wailsjs/go/models',
+// 但在过渡期保持独立类型便于前端独立开发。
+
+// 节点操作类型(与后端 ExplainOp* 常量对齐)。
+export type ExplainOpType =
+ | 'SCAN' // 全表扫描
+ | 'INDEX_SCAN' // 索引扫描
+ | 'INDEX_ONLY' // 覆盖索引
+ | 'JOIN'
+ | 'AGGREGATE'
+ | 'SORT'
+ | 'LIMIT'
+ | 'FILTER'
+ | 'SUBQUERY'
+ | 'UNION'
+ | 'WINDOW'
+ | 'MATERIALIZE'
+ | 'INSERT'
+ | 'UPDATE'
+ | 'DELETE'
+ | 'OTHER'
+
+// 节点警告标志(用于 UI 高亮 + 规则匹配)。
+export type ExplainNodeFlag =
+ | 'FULL_SCAN'
+ | 'FILESORT'
+ | 'TEMP_TABLE'
+ | 'NO_INDEX'
+ | 'HIGH_COST'
+ | 'LOW_BUFFER_HIT'
+ | 'UNCERTAIN_ROWS'
+
+// EXPLAIN 原文格式。
+export type ExplainFormat = 'json' | 'table' | 'xml' | 'text'
+
+// 建议严重度。
+export type IndexSuggestionSeverity = 'critical' | 'warning' | 'info'
+
+export interface ExplainNode {
+ id: string
+ parentId?: string
+ opType: ExplainOpType | string
+ opDetail?: string
+ table?: string
+ index?: string
+ estRows?: number
+ actualRows?: number
+ loops?: number
+ cost?: number
+ durationMs?: number
+ bufferHit?: number
+ flags?: ExplainNodeFlag[] | string[]
+ extra?: Record
+}
+
+export interface ExplainEdge {
+ from: string
+ to: string
+ label?: string
+}
+
+export interface ExplainStats {
+ totalCost?: number
+ totalDurationMs?: number
+ rowsRead?: number
+ bufferHitRate?: number
+ hasFullScan: boolean
+ hasFilesort: boolean
+ hasTempTable: boolean
+ maxEstRows?: number
+}
+
+export interface ExplainResult {
+ dbType: string
+ sourceSql: string
+ nodes: ExplainNode[]
+ edges?: ExplainEdge[]
+ stats: ExplainStats
+ warnings?: string[]
+ rawFormat: ExplainFormat | string
+ rawPayload?: string
+}
+
+export interface IndexSuggestion {
+ severity: IndexSuggestionSeverity | string
+ rule: string
+ reason: string
+ suggestedIndex?: string
+ affectedNodeId?: string
+ affectedTable?: string
+ estRows?: number
+}
+
+export interface DiagnoseReport {
+ plan: ExplainResult
+ suggestions: IndexSuggestion[]
+}
+
+// severityRank 用于 UI 排序:critical 最前。
+export const severityRank: Record = {
+ critical: 0,
+ warning: 1,
+ info: 2,
+}
+
+// opTypeTheme 按 OpType 返回主题色 token(对应 v2-theme.css 的 CSS 变量)。
+// 颜色规则:SCAN 红橙(警告)、JOIN 蓝、AGGREGATE 紫、SORT 黄、其他灰。
+export function opTypeColor(opType: string): string {
+ switch (opType) {
+ case 'SCAN':
+ return 'var(--gn-explain-scan, #e8590c)'
+ case 'INDEX_SCAN':
+ return 'var(--gn-explain-index-scan, #1971c2)'
+ case 'INDEX_ONLY':
+ return 'var(--gn-explain-index-only, #2f9e44)'
+ case 'JOIN':
+ return 'var(--gn-explain-join, #1971c2)'
+ case 'AGGREGATE':
+ return 'var(--gn-explain-aggregate, #6741d9)'
+ case 'SORT':
+ return 'var(--gn-explain-sort, #f08c00)'
+ case 'LIMIT':
+ return 'var(--gn-explain-limit, #495057)'
+ case 'FILTER':
+ return 'var(--gn-explain-filter, #495057)'
+ case 'SUBQUERY':
+ return 'var(--gn-explain-subquery, #7048e8)'
+ case 'MATERIALIZE':
+ return 'var(--gn-explain-materialize, #e8590c)'
+ default:
+ return 'var(--gn-explain-other, #868e96)'
+ }
+}
+
+// severityColor 用于建议列表的左侧色条。
+export function severityColor(severity: string): string {
+ switch (severity) {
+ case 'critical':
+ return 'var(--gn-explain-critical, #fa5252)'
+ case 'warning':
+ return 'var(--gn-explain-warning, #f08c00)'
+ case 'info':
+ return 'var(--gn-explain-info, #1c7ed6)'
+ default:
+ return 'var(--gn-explain-other, #868e96)'
+ }
+}
+
+// formatNumber 容错格式化大数字(千分位)。
+export function formatNumber(n?: number): string {
+ if (n === undefined || n === null || isNaN(n)) return '-'
+ if (Math.abs(n) >= 10000) {
+ return new Intl.NumberFormat('en-US').format(n)
+ }
+ return String(n)
+}
+
+// formatPercent 把 0-1 的小数格式化为百分比字符串。
+export function formatPercent(ratio?: number): string {
+ if (ratio === undefined || ratio === null || isNaN(ratio)) return '-'
+ return `${(ratio * 100).toFixed(1)}%`
+}
+
+// formatMs 把毫秒格式化为人类可读(>1s 显示秒)。
+export function formatMs(ms?: number): string {
+ if (ms === undefined || ms === null || isNaN(ms)) return '-'
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
+ return `${ms.toFixed(1)}ms`
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index db1c836..b9bda7d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -28,5 +28,17 @@ export default defineConfig({
build: {
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
+ rollupOptions: {
+ output: {
+ // 拆分大体积三方依赖到独立 chunk,避免主 bundle 过大
+ // reactflow + dagre 约 130KB gzipped,单独成 chunk 可按需加载
+ // recharts 用于诊断面板统计条,与执行计划图无强依赖,单独 chunk
+ manualChunks: {
+ reactflow: ['reactflow'],
+ dagre: ['dagre'],
+ charts: ['recharts'],
+ },
+ },
+ },
}
})