mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
✨ feat(explain-ui): 新增执行计划图渲染组件与索引建议侧栏
- 依赖引入:新增 reactflow + dagre 用于执行计划 DAG 自动布局 - 构建配置:vite manualChunks 拆分 reactflow/dagre/recharts 独立 chunk,便于按需加载 - 类型镜像:utils/explainTypes.ts 镜像后端 ExplainResult/Node/Stats/IndexSuggestion,含颜色与格式化 helper - 图渲染:ExplainGraph 自定义节点按 opType 着色 + 警告 flag 边框高亮 + dagre TB 布局 - 侧栏组件:ExplainSidebar 含统计条、节点详情、索引建议按 severity 排序 - 主容器:ExplainWorkbench 含 Modal + 执行计划/原文双 tab,调用 DiagnoseQuery 端到端联通
This commit is contained in:
160
frontend/package-lock.json
generated
160
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
224
frontend/src/components/explain/ExplainGraph.tsx
Normal file
224
frontend/src/components/explain/ExplainGraph.tsx
Normal file
@@ -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 (
|
||||
<ReactFlowProvider>
|
||||
<ExplainGraphInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="gn-explain-graph" style={{ width: '100%', height: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={nodeState}
|
||||
edges={edgeState}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={{ explain: ExplainGraphNodeRenderer }}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// layoutWithDagre 用 dagre 计算 react-flow 节点位置。
|
||||
// 默认从上到下(TB)布局,符合执行计划的"父子层级"心智模型。
|
||||
function layoutWithDagre(
|
||||
nodes: ExplainNode[],
|
||||
edges: ExplainEdge[],
|
||||
selectedNodeId?: string,
|
||||
): { rfNodes: Node<ExplainGraphNodeData>[]; 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<ExplainGraphNodeData>[] = 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 (
|
||||
<div
|
||||
className="gn-explain-node"
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
minHeight: NODE_HEIGHT,
|
||||
border: `2px solid ${isSelected ? 'var(--gn-explain-selected, #1971c2)' : color}`,
|
||||
background: 'var(--gn-explain-node-bg, #ffffff)',
|
||||
boxShadow: isSelected ? '0 0 0 3px rgba(25, 113, 194, 0.2)' : 'none',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color, marginBottom: 4 }}>{node.opDetail || node.opType}</div>
|
||||
{node.table && (
|
||||
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
|
||||
<span style={{ opacity: 0.6 }}>表:</span>
|
||||
<code style={{ fontSize: 11 }}>{node.table}</code>
|
||||
</div>
|
||||
)}
|
||||
{node.index && (
|
||||
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
|
||||
<span style={{ opacity: 0.6 }}>索引:</span>
|
||||
<code style={{ fontSize: 11 }}>{node.index}</code>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{node.estRows !== undefined && node.estRows > 0 && (
|
||||
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
|
||||
估算 <strong>{formatNumber(node.estRows)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{node.actualRows !== undefined && node.actualRows > 0 && (
|
||||
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
|
||||
实际 <strong>{formatNumber(node.actualRows)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{node.cost !== undefined && node.cost > 0 && (
|
||||
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
|
||||
成本 <strong>{node.cost.toFixed(1)}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(hasFullScan || hasFilesort || hasTempTable) && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{hasFullScan && <FlagBadge color="#fa5252" text="全表扫描" />}
|
||||
{hasFilesort && <FlagBadge color="#f08c00" text="额外排序" />}
|
||||
{hasTempTable && <FlagBadge color="#7048e8" text="临时表" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function FlagBadge({ color, text }: { color: string; text: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
234
frontend/src/components/explain/ExplainSidebar.tsx
Normal file
234
frontend/src/components/explain/ExplainSidebar.tsx
Normal file
@@ -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 (
|
||||
<div className="gn-explain-sidebar" style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12 }}>
|
||||
<ExplainStatsBar stats={stats} warnings={warnings} />
|
||||
{selectedNode && <ExplainNodeDetail node={selectedNode} />}
|
||||
<IndexSuggestionList suggestions={sortedSuggestions} onSelect={onSelectSuggestion} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--gn-card-bg, #f8f9fa)',
|
||||
border: '1px solid var(--gn-border, #dee2e6)',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>执行统计</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px', fontSize: 12 }}>
|
||||
{statsList.map((s) => (
|
||||
<div key={s.label} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>{s.label}</span>
|
||||
<strong>{s.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{stats.hasFullScan && <WarningRow color="#fa5252" text="存在全表扫描" />}
|
||||
{stats.hasFilesort && <WarningRow color="#f08c00" text="存在额外排序" />}
|
||||
{stats.hasTempTable && <WarningRow color="#7048e8" text="使用临时表" />}
|
||||
{warnings && warnings.length > 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
|
||||
{warnings.map((w, i) => (
|
||||
<div key={i}>⚠ {w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WarningRow({ color, text }: { color: string; text: string }) {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color }}>
|
||||
<span style={{ display: 'inline-block', width: 8, height: 8, background: color, marginRight: 6, borderRadius: 2 }} />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--gn-card-bg, #f8f9fa)',
|
||||
border: '1px solid var(--gn-border, #dee2e6)',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>节点详情</div>
|
||||
<div style={{ fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} style={{ display: 'flex', gap: 8 }}>
|
||||
<span style={{ color: 'var(--gn-text-muted, #6c757d)', minWidth: 80 }}>{label}</span>
|
||||
<span style={{ wordBreak: 'break-all' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{node.extra && Object.keys(node.extra).length > 0 && (
|
||||
<details style={{ marginTop: 8, fontSize: 11 }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--gn-text-muted, #6c757d)' }}>
|
||||
Extra 字段({Object.keys(node.extra).length})
|
||||
</summary>
|
||||
<pre style={{ marginTop: 4, fontSize: 11, maxHeight: 120, overflow: 'auto' }}>
|
||||
{JSON.stringify(node.extra, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexSuggestionList({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: {
|
||||
suggestions: IndexSuggestion[]
|
||||
onSelect?: (s: IndexSuggestion) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--gn-card-bg, #f8f9fa)',
|
||||
border: '1px solid var(--gn-border, #dee2e6)',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
flex: 1,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>
|
||||
索引建议({suggestions.length})
|
||||
</div>
|
||||
{suggestions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--gn-text-muted, #6c757d)', padding: '20px 0', textAlign: 'center' }}>
|
||||
未发现明显性能问题
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{suggestions.map((s, idx) => (
|
||||
<SuggestionCard key={`${s.rule}-${idx}`} suggestion={s} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestionCard({
|
||||
suggestion,
|
||||
onSelect,
|
||||
}: {
|
||||
suggestion: IndexSuggestion
|
||||
onSelect?: (s: IndexSuggestion) => void
|
||||
}) {
|
||||
const color = severityColor(suggestion.severity)
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect?.(suggestion)}
|
||||
style={{
|
||||
borderLeft: `3px solid ${color}`,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--gn-suggestion-bg, #ffffff)',
|
||||
cursor: onSelect ? 'pointer' : 'default',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color, fontWeight: 600, textTransform: 'uppercase', fontSize: 10 }}>
|
||||
{suggestion.severity}
|
||||
</span>
|
||||
{suggestion.estRows !== undefined && suggestion.estRows > 0 && (
|
||||
<span style={{ color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
|
||||
{formatNumber(suggestion.estRows)} 行
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>{suggestion.reason}</div>
|
||||
{suggestion.suggestedIndex && (
|
||||
<code
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 4,
|
||||
background: 'var(--gn-code-bg, #f1f3f5)',
|
||||
fontSize: 11,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{suggestion.suggestedIndex}
|
||||
</code>
|
||||
)}
|
||||
{suggestion.affectedTable && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
|
||||
表:<code>{suggestion.affectedTable}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
frontend/src/components/explain/ExplainWorkbench.tsx
Normal file
158
frontend/src/components/explain/ExplainWorkbench.tsx
Normal file
@@ -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<DiagnoseReport | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<ExplainNode | undefined>(() => {
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
title={<Title level={5} style={{ margin: 0 }}>SQL 诊断工作台</Title>}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ minHeight: 480 }}>
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Paragraph type="danger" style={{ padding: 16 }}>
|
||||
<Text strong>诊断失败:</Text>
|
||||
{error}
|
||||
</Paragraph>
|
||||
)}
|
||||
{!loading && !error && report && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'plan',
|
||||
label: `执行计划(${report.plan.nodes.length} 节点)`,
|
||||
children: (
|
||||
<div style={{ display: 'flex', gap: 12, height: '70vh', minHeight: 400 }}>
|
||||
<div style={{ flex: 1, minWidth: 400, position: 'relative' }}>
|
||||
<ExplainGraph
|
||||
nodes={report.plan.nodes}
|
||||
edges={report.plan.edges ?? []}
|
||||
selectedNodeId={selectedNodeId ?? undefined}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 320, flexShrink: 0, overflowY: 'auto', maxHeight: '70vh' }}>
|
||||
<ExplainSidebar
|
||||
stats={report.plan.stats}
|
||||
warnings={report.plan.warnings}
|
||||
suggestions={report.suggestions ?? []}
|
||||
selectedNode={selectedNode}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: `原文(${report.plan.rawFormat})`,
|
||||
children: (
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
background: 'var(--gn-code-bg, #f1f3f5)',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{report.plan.rawPayload || '(无原文)'}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
172
frontend/src/utils/explainTypes.ts
Normal file
172
frontend/src/utils/explainTypes.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, number> = {
|
||||
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`
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user