diff --git a/.gitignore b/.gitignore index 902ca17..6a07141 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,8 @@ GoNavi-Wails.exe .ace-tool/ .superpowers/ .claude/ -tmpclaude-* +.gemini/ +**/tmpclaude-* CLAUDE.md **/CLAUDE.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 183106a..ccff62a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -89,6 +89,7 @@ function App() { const [runtimePlatform, setRuntimePlatform] = useState(''); const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); + const [sidebarWidth, setSidebarWidth] = useState(330); const globalProxyInvalidHintShownRef = React.useRef(false); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, @@ -442,7 +443,6 @@ function App() { const floatingLogButtonShadow = darkMode ? '0 8px 22px rgba(0,0,0,0.38)' : '0 8px 20px rgba(0,0,0,0.16)'; - const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0; const utilityButtonBgAlpha = darkMode ? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72)) @@ -462,10 +462,13 @@ function App() { : (darkMode ? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})` : `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`); + const isSidebarNarrow = sidebarWidth < 360; + const isSidebarCompact = sidebarWidth < 320; + const isSidebarUltraCompact = sidebarWidth < 260; const utilityButtonStyle = useMemo(() => ({ height: Math.max(30, Math.round(32 * effectiveUiScale)), width: '100%', - paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)), + paddingInline: isSidebarCompact ? Math.max(8, Math.round(9 * effectiveUiScale)) : Math.max(10, Math.round(12 * effectiveUiScale)), borderRadius: 10, border: `1px solid ${utilityButtonBorderColor}`, background: utilityButtonBgColor, @@ -476,8 +479,13 @@ function App() { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - gap: 6, - }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); + gap: isSidebarCompact ? 4 : 6, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: isSidebarCompact ? 13 : 14, + }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); const sidebarQuickActionBaseStyle = useMemo(() => ({ @@ -493,6 +501,8 @@ function App() { backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter, minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', whiteSpace: 'nowrap', }), [blurFilter, darkMode, effectiveUiScale]); const sidebarQueryActionStyle = useMemo(() => ({ @@ -561,7 +571,7 @@ function App() { marginTop: 2, }), [overlayTheme]); - const sidebarHorizontalPadding = 10; + const sidebarHorizontalPadding = isSidebarCompact ? 8 : 10; const addTab = useStore(state => state.addTab); const activeContext = useStore(state => state.activeContext); @@ -943,7 +953,7 @@ function App() { } catch (e) { void message.error("解析 JSON 失败"); } - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("导入失败: " + res.message); } }; @@ -956,7 +966,7 @@ function App() { const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json"); if (res.success) { void message.success("导出成功"); - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("导出失败: " + res.message); } }; @@ -1058,7 +1068,6 @@ function App() { }; // Sidebar Resizing - const [sidebarWidth, setSidebarWidth] = useState(330); const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null); const rafRef = React.useRef(null); const ghostRef = React.useRef(null); @@ -1445,15 +1454,15 @@ function App() { >
-
- - - - +
+ + + +
-
+
diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ab3738c..f7e13b4 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1013,7 +1013,7 @@ const ConnectionModal: React.FC<{ if (selectedPath) { form.setFieldValue('sshKeyPath', selectedPath); } - } else if (res?.message !== 'Cancelled') { + } else if (res?.message !== '已取消') { message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`); } } catch (e: any) { @@ -1037,7 +1037,7 @@ const ConnectionModal: React.FC<{ if (selectedPath) { form.setFieldValue('host', normalizeFileDbPath(selectedPath)); } - } else if (res?.message !== 'Cancelled') { + } else if (res?.message !== '已取消') { message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`); } } catch (e: any) { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index a450ba5..00eaad6 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -362,6 +362,11 @@ const ResizableTitle = React.forwardRef((props, ref) // Pass the header element reference implicitly via event target onResizeStart(e); }} + onPointerDown={(e) => { + // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, + // 避免调整列宽时意外触发列拖拽排序 + e.stopPropagation(); + }} onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', @@ -392,6 +397,7 @@ interface SortableHeaderCellProps extends React.HTMLAttributes = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, @@ -841,6 +852,8 @@ const DataGrid: React.FC = ({ ); const handleDragEnd = (event: DragEndEvent) => { + // 防御性检查:若正在调整列宽,忽略拖拽排序事件 + if (isResizingRef.current) return; const { active, over } = event; if (active.id !== over?.id && over) { setAllOrderedColumnNames((prevAllOrder) => { @@ -889,82 +902,101 @@ const DataGrid: React.FC = ({ const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]); const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0; - // Background Helper - const getBg = (darkHex: string) => { - if (!darkMode) return `rgba(255, 255, 255, ${opacity})`; - const hex = darkHex.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${opacity})`; - }; - const bgContent = getBg('#1d1d1d'); - const bgFilter = getBg('#262626'); - const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff'; - - // Row Colors with Opacity - const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`; - const rowAddedBg = darkMode ? getRowBg(22, 43, 22) : getRowBg(246, 255, 237); - const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255); - const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190); - const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255); - const selectionAccentHex = darkMode ? '#f6c453' : '#1890ff'; - const selectionAccentRgb = darkMode ? '246, 196, 83' : '24, 144, 255'; - const darkHighlightTextColor = 'rgba(255, 236, 179, 0.98)'; - const lightMetaHintColor = '#595959'; - const lightMetaTooltipColor = '#262626'; + // --- 主题样式变量(仅在 darkMode / opacity / blur 变化时重算) --- + const themeStyles = useMemo(() => { + const _getBg = (darkHex: string) => { + if (!darkMode) return `rgba(255, 255, 255, ${opacity})`; + const hex = darkHex.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + }; + const _rowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`; + const _glassMode = opacity < 0.999 || resolvedAppearance.blur > 0; + + return { + bgContent: _getBg('#1d1d1d'), + bgFilter: _getBg('#262626'), + bgContextMenu: darkMode ? '#1f1f1f' : '#ffffff', + rowAddedBg: darkMode ? _rowBg(22, 43, 22) : _rowBg(246, 255, 237), + rowModBg: darkMode ? _rowBg(22, 34, 56) : _rowBg(230, 247, 255), + rowAddedHover: darkMode ? _rowBg(31, 61, 31) : _rowBg(217, 247, 190), + rowModHover: darkMode ? _rowBg(29, 53, 94) : _rowBg(186, 231, 255), + selectionAccentHex: darkMode ? '#f6c453' : '#1890ff', + selectionAccentRgb: darkMode ? '246, 196, 83' : '24, 144, 255', + columnMetaHintColor: darkMode ? 'rgba(255, 236, 179, 0.98)' : '#595959', + columnMetaTooltipColor: darkMode ? 'rgba(255, 236, 179, 0.98)' : '#262626', + panelFrameColor: darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)', + floatingScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)', + floatingScrollbarThumbBorderColor: darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)', + floatingScrollbarThumbShadow: darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)', + verticalScrollbarTrackBg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', + horizontalScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)', + toolbarDividerColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)', + paginationShellBg: darkMode + ? `linear-gradient(135deg, rgba(17,22,34,${_glassMode ? Math.max(0.22, opacity * 0.38) : 0.82}) 0%, rgba(10,14,24,${_glassMode ? Math.max(0.28, opacity * 0.46) : 0.9}) 100%)` + : `linear-gradient(135deg, rgba(255,255,255,${_glassMode ? Math.max(0.24, opacity * 0.36) : 0.96}) 0%, rgba(246,248,252,${_glassMode ? Math.max(0.32, opacity * 0.44) : 0.99}) 100%)`, + paginationShellBorderColor: darkMode + ? `rgba(255,255,255,${_glassMode ? 0.10 : 0.08})` + : `rgba(16,24,40,${_glassMode ? 0.08 : 0.08})`, + paginationShellShadow: darkMode + ? `0 16px 34px rgba(0,0,0,${_glassMode ? 0.10 : 0.22})` + : `0 14px 30px rgba(15,23,42,${_glassMode ? 0.03 : 0.08})`, + paginationChipBg: darkMode + ? `rgba(255,255,255,${_glassMode ? Math.max(0.02, opacity * 0.035) : 0.04})` + : `rgba(255,255,255,${_glassMode ? Math.max(0.18, opacity * 0.26) : 0.86})`, + paginationChipBorderColor: darkMode + ? `rgba(255,255,255,${_glassMode ? 0.10 : 0.08})` + : `rgba(16,24,40,${_glassMode ? 0.10 : 0.08})`, + paginationHoverBg: darkMode + ? `rgba(255,255,255,${_glassMode ? Math.max(0.04, opacity * 0.06) : 0.07})` + : `rgba(255,255,255,${_glassMode ? Math.max(0.24, opacity * 0.34) : 0.96})`, + paginationPrimaryTextColor: darkMode ? '#f5f7ff' : '#162033', + paginationSecondaryTextColor: darkMode ? 'rgba(255,255,255,0.54)' : 'rgba(16,24,40,0.56)', + paginationAccentBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.10)', + paginationAccentBorderColor: darkMode ? 'rgba(255,214,102,0.38)' : 'rgba(24,144,255,0.22)', + paginationActiveItemBg: darkMode ? 'rgba(255,214,102,0.18)' : 'rgba(24,144,255,0.12)', + paginationActiveItemBorderColor: darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)', + paginationActiveItemTextColor: darkMode ? '#fff7d6' : '#0958d9', + }; + }, [darkMode, opacity, resolvedAppearance.blur]); + + // 解构常用变量以保持后续代码引用不变 + const { + bgContent, bgFilter, bgContextMenu, + rowAddedBg, rowModBg, rowAddedHover, rowModHover, + selectionAccentHex, selectionAccentRgb, + columnMetaHintColor, columnMetaTooltipColor, + panelFrameColor, + floatingScrollbarThumbBg, floatingScrollbarThumbBorderColor, floatingScrollbarThumbShadow, + verticalScrollbarTrackBg, horizontalScrollbarThumbBg, + toolbarDividerColor, + paginationShellBg, paginationShellBorderColor, paginationShellShadow, + paginationChipBg, paginationChipBorderColor, paginationHoverBg, + paginationPrimaryTextColor, paginationSecondaryTextColor, + paginationAccentBg, paginationAccentBorderColor, + paginationActiveItemBg, paginationActiveItemBorderColor, paginationActiveItemTextColor, + } = themeStyles; + + // 布局常量(纯数字/字符串,无需 memoize) const panelRadius = 10; const panelOuterGap = 6; const panelPaddingY = 10; const panelPaddingX = 12; const toolbarBottomPadding = 6; const filterTopPadding = 2; - const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)'; const floatingScrollbarGap = 8; const floatingScrollbarBottomOffset = 0; const floatingScrollbarInset = 10; const floatingScrollbarHeight = 10; - const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)'; - const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)'; - const floatingScrollbarThumbShadow = darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'; - const verticalScrollbarTrackBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; const horizontalScrollbarTrackBg = 'transparent'; const horizontalScrollbarTrackBorderColor = 'transparent'; const horizontalScrollbarTrackShadow = 'none'; - const horizontalScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)'; const horizontalScrollbarThumbBorderColor = 'transparent'; const horizontalScrollbarThumbShadow = 'none'; const externalScrollbarMinWidth = 1; - const toolbarDividerColor = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)'; - const columnMetaHintColor = darkMode ? darkHighlightTextColor : lightMetaHintColor; - const columnMetaTooltipColor = darkMode ? darkHighlightTextColor : lightMetaTooltipColor; const paginationPageSizeOptions = ['100', '200', '500', '1000']; - const paginationGlassMode = opacity < 0.999 || resolvedAppearance.blur > 0; - const paginationShellBg = darkMode - ? `linear-gradient(135deg, rgba(17,22,34,${paginationGlassMode ? Math.max(0.22, opacity * 0.38) : 0.82}) 0%, rgba(10,14,24,${paginationGlassMode ? Math.max(0.28, opacity * 0.46) : 0.9}) 100%)` - : `linear-gradient(135deg, rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.36) : 0.96}) 0%, rgba(246,248,252,${paginationGlassMode ? Math.max(0.32, opacity * 0.44) : 0.99}) 100%)`; - const paginationShellBorderColor = darkMode - ? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})` - : `rgba(16,24,40,${paginationGlassMode ? 0.08 : 0.08})`; - const paginationShellShadow = darkMode - ? `0 16px 34px rgba(0,0,0,${paginationGlassMode ? 0.10 : 0.22})` - : `0 14px 30px rgba(15,23,42,${paginationGlassMode ? 0.03 : 0.08})`; - const paginationChipBg = darkMode - ? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.02, opacity * 0.035) : 0.04})` - : `rgba(255,255,255,${paginationGlassMode ? Math.max(0.18, opacity * 0.26) : 0.86})`; - const paginationChipBorderColor = darkMode - ? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})` - : `rgba(16,24,40,${paginationGlassMode ? 0.10 : 0.08})`; - const paginationHoverBg = darkMode - ? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.04, opacity * 0.06) : 0.07})` - : `rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.34) : 0.96})`; - const paginationPrimaryTextColor = darkMode ? '#f5f7ff' : '#162033'; - const paginationSecondaryTextColor = darkMode ? 'rgba(255,255,255,0.54)' : 'rgba(16,24,40,0.56)'; - const paginationAccentBg = darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.10)'; - const paginationAccentBorderColor = darkMode ? 'rgba(255,214,102,0.38)' : 'rgba(24,144,255,0.22)'; - const paginationActiveItemBg = darkMode ? 'rgba(255,214,102,0.18)' : 'rgba(24,144,255,0.12)'; - const paginationActiveItemBorderColor = darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)'; - const paginationActiveItemTextColor = darkMode ? '#fff7d6' : '#0958d9'; const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); @@ -1086,7 +1118,7 @@ const DataGrid: React.FC = ({ const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format); if (res.success) { void message.success("导出成功"); - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("导出失败: " + res.message); } } catch (e: any) { @@ -1296,12 +1328,452 @@ const DataGrid: React.FC = ({ const [tableHeight, setTableHeight] = useState(500); const [tableViewportWidth, setTableViewportWidth] = useState(0); const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0); + + // P0 性能优化:CSS 模板字符串 memoize,仅在主题/布局变量变化时重算 + const gridCssText = useMemo(() => ` + .${gridId} .data-grid-toolbar-scroll > * { + flex-shrink: 0; + } + .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar { + height: 7px; + } + .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb { + background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'}; + border-radius: 999px; + } + .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track { + background: transparent; + } + .${gridId} .ant-table, + .${gridId} .ant-table-wrapper, + .${gridId} .ant-table-container { + background: transparent !important; + border-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-wrapper, + .${gridId} .ant-table-container { + border: none !important; + overflow: hidden !important; + } + .${gridId} .ant-table-tbody > tr > td, + .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } + .${gridId} .ant-table-thead > tr:first-child > th:first-child, + .${gridId} .ant-table-header table > thead > tr:first-child > th:first-child { + border-top-left-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-thead > tr:first-child > th:last-child, + .${gridId} .ant-table-header table > thead > tr:first-child > th:last-child { + border-top-right-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-body { + border-bottom-left-radius: ${panelRadius}px !important; + border-bottom-right-radius: ${panelRadius}px !important; + } + .${gridId} .ant-table-thead > tr > th::before { display: none !important; } + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; } + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } + .${gridId} .ant-table-tbody > tr:hover > td, + .${gridId} .ant-table-tbody .ant-table-row:hover > .ant-table-cell { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td, + .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 0.12)`} !important; } + .${gridId} .row-added td, + .${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } + .${gridId} .row-modified td, + .${gridId} .row-modified > .ant-table-cell { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } + .${gridId} .ant-table-tbody > tr.row-added:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; } + .${gridId} .ant-table-tbody > tr.row-modified:hover > td, + .${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; } + .${gridId} .ant-table-tbody > tr > td[data-col-name], + .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } + .${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"], + .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"], + .${gridId} [data-cell-selected="true"] { + box-shadow: inset 0 0 0 2px ${selectionAccentHex} !important; + background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}) !important; + } + .${gridId} .ant-table-content, + .${gridId} .ant-table-body { + scrollbar-gutter: stable; + } + .${gridId} .ant-table-body { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + scroll-padding-bottom: ${tableBodyBottomPadding}px; + } + .${gridId} .ant-table-tbody-virtual-holder, + .${gridId} .rc-virtual-list-holder { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + scroll-padding-bottom: ${tableBodyBottomPadding}px; + } + .${gridId} .ant-table-tbody-virtual-holder-inner { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + } + .${gridId} .data-grid-table-wrap { + width: 100%; + max-width: 100%; + overflow: hidden; + } + .${gridId} .ant-table-sticky-scroll { + display: none !important; + } + .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { + display: none !important; + } + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content { + overflow-x: hidden !important; + } + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-body { + overflow-x: hidden !important; + overflow-y: auto !important; + } + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-tbody-virtual-holder, + .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .rc-virtual-list-holder { + overflow-x: hidden !important; + } + .${gridId} .ant-table-body { + scrollbar-width: thin; + scrollbar-color: ${floatingScrollbarThumbBg} transparent; + } + .${gridId} .ant-table-body::-webkit-scrollbar { + width: ${floatingScrollbarHeight}px; + height: 0; + } + .${gridId} .ant-table-body::-webkit-scrollbar-track { + background: ${verticalScrollbarTrackBg}; + margin: 8px 0; + border-radius: 999px; + } + .${gridId} .ant-table-body::-webkit-scrollbar-thumb { + background: ${floatingScrollbarThumbBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${floatingScrollbarThumbShadow}; + } + .${gridId} .rc-virtual-list-holder { + scrollbar-width: thin; + scrollbar-color: ${floatingScrollbarThumbBg} transparent; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar { + width: ${floatingScrollbarHeight}px; + height: 0; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track { + background: ${verticalScrollbarTrackBg}; + margin: 8px 0; + border-radius: 999px; + } + .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { + background: ${floatingScrollbarThumbBg}; + border: 1px solid ${floatingScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${floatingScrollbarThumbShadow}; + } + .${gridId} .data-grid-external-horizontal-scroll { + position: absolute; + left: ${floatingScrollbarInset}px; + right: ${floatingScrollbarInset}px; + bottom: ${floatingScrollbarBottomOffset}px; + height: ${floatingScrollbarHeight + 4}px; + overflow-x: auto; + overflow-y: hidden; + background: transparent; + z-index: 24; + } + .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar { + height: ${floatingScrollbarHeight}px; + } + .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-track { + background: ${horizontalScrollbarTrackBg}; + border: 1px solid ${horizontalScrollbarTrackBorderColor}; + border-radius: 999px; + box-shadow: ${horizontalScrollbarTrackShadow}; + } + .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb { + background: ${horizontalScrollbarThumbBg}; + border: 1px solid ${horizontalScrollbarThumbBorderColor}; + border-radius: 999px; + box-shadow: ${horizontalScrollbarThumbShadow}; + } + .${gridId} .data-grid-external-horizontal-scroll-inner { + height: 1px; + } + .${gridId} .data-grid-pagination-shell { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + max-width: 100%; + padding: 8px 10px; + border-radius: 16px; + border: 1px solid ${paginationShellBorderColor}; + background: ${paginationShellBg}; + box-shadow: ${paginationShellShadow}; + backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'}; + -webkit-backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'}; + } + .${gridId} .data-grid-pagination-summary, + .${gridId} .data-grid-pagination-page-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid ${paginationChipBorderColor}; + background: ${paginationChipBg}; + color: ${paginationPrimaryTextColor}; + font-size: 12px; + line-height: 1; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .${gridId} .data-grid-pagination-kicker { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + background: ${paginationAccentBg}; + border: 1px solid ${paginationAccentBorderColor}; + color: ${paginationActiveItemTextColor}; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + } + .${gridId} .data-grid-pagination-summary-value { + color: ${paginationPrimaryTextColor}; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .${gridId} .data-grid-pagination-page-chip { + color: ${paginationSecondaryTextColor}; + font-weight: 600; + } + .${gridId} .ant-pagination { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: ${paginationPrimaryTextColor}; + } + .${gridId} .ant-pagination .ant-pagination-item, + .${gridId} .ant-pagination .ant-pagination-prev, + .${gridId} .ant-pagination .ant-pagination-next, + .${gridId} .ant-pagination .ant-pagination-jump-prev, + .${gridId} .ant-pagination .ant-pagination-jump-next { + min-width: 34px; + height: 34px; + margin-inline-end: 0; + border-radius: 12px; + border: 1px solid ${paginationChipBorderColor}; + background: ${paginationChipBg}; + box-shadow: none; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; + } + .${gridId} .ant-pagination .ant-pagination-item a, + .${gridId} .ant-pagination .ant-pagination-prev .ant-pagination-item-link, + .${gridId} .ant-pagination .ant-pagination-next .ant-pagination-item-link, + .${gridId} .ant-pagination .ant-pagination-prev > *, + .${gridId} .ant-pagination .ant-pagination-next > * { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: ${paginationPrimaryTextColor}; + font-weight: 600; + border: none; + background: transparent; + border-radius: inherit; + line-height: 1; + } + .${gridId} .ant-pagination .ant-pagination-item:hover, + .${gridId} .ant-pagination .ant-pagination-prev:hover, + .${gridId} .ant-pagination .ant-pagination-next:hover { + background: ${paginationHoverBg}; + border-color: ${paginationActiveItemBorderColor}; + transform: translateY(-1px); + } + .${gridId} .ant-pagination .ant-pagination-item-active { + border-color: ${paginationActiveItemBorderColor}; + background: ${paginationActiveItemBg}; + box-shadow: inset 0 0 0 1px ${paginationAccentBorderColor}; + } + .${gridId} .ant-pagination .ant-pagination-item-active a { + color: ${paginationActiveItemTextColor}; + } + .${gridId} .ant-pagination .ant-pagination-disabled, + .${gridId} .ant-pagination .ant-pagination-disabled:hover { + background: transparent; + border-color: ${paginationChipBorderColor}; + transform: none; + opacity: 0.42; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev, + .${gridId} .ant-pagination .ant-pagination-jump-next { + padding: 0; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + line-height: 1; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-container, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: relative; + line-height: 1; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis, + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon { + position: absolute !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + inset: 0 !important; + width: fit-content !important; + height: fit-content !important; + min-width: 0 !important; + min-height: 0 !important; + margin: auto !important; + padding: 0 !important; + transform: none !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + line-height: 1 !important; + color: ${paginationSecondaryTextColor}; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis { + letter-spacing: 0.18em; + text-indent: 0.18em; + text-align: center; + } + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon .anticon, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon .anticon, + .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon svg, + .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon svg { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 1em; + height: 1em; + line-height: 1; + } + .${gridId} .data-grid-pagination-nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 12px; + line-height: 1; + } + .${gridId} .data-grid-pagination-nav-icon .anticon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + .${gridId} .data-grid-pagination-size-select { + min-width: 112px; + height: 34px; + display: inline-flex; + align-items: stretch; + } + .${gridId} .data-grid-pagination-size-select.ant-select-single, + .${gridId} .data-grid-pagination-size-select.ant-select-single.ant-select-sm { + height: 34px; + } + .${gridId} .data-grid-pagination-size-select .ant-select-selector { + height: 34px !important; + border-radius: 12px !important; + border: 1px solid ${paginationChipBorderColor} !important; + background: ${paginationChipBg} !important; + box-shadow: none !important; + padding: 0 12px !important; + display: flex !important; + align-items: center !important; + } + .${gridId} .data-grid-pagination-size-select .ant-select-selection-wrap { + display: flex !important; + align-items: center !important; + height: 100%; + } + .${gridId} .data-grid-pagination-size-select .ant-select-selection-search, + .${gridId} .data-grid-pagination-size-select .ant-select-selection-search-input { + height: 100% !important; + } + .${gridId} .data-grid-pagination-size-select .ant-select-selection-item, + .${gridId} .data-grid-pagination-size-select .ant-select-selection-placeholder { + display: flex; + align-items: center; + height: 100%; + line-height: 34px !important; + color: ${paginationPrimaryTextColor}; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .${gridId} .data-grid-pagination-size-select .ant-select-selection-search { + inset-inline-start: 12px !important; + inset-inline-end: 32px !important; + } + .${gridId} .data-grid-pagination-size-select .ant-select-arrow { + color: ${paginationSecondaryTextColor}; + inset-inline-end: 12px; + top: 50%; + transform: translateY(-50%); + margin-top: 0; + display: inline-flex; + align-items: center; + justify-content: center; + height: 16px; + line-height: 1; + } + .${gridId} .data-grid-pagination-size-select .ant-select-arrow .anticon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + } + `, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity]); + const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => { const target = targetElement || containerRef.current; if (!target) return; - const height = target.getBoundingClientRect().height; - const width = target.getBoundingClientRect().width; + // P5 性能优化:合并 getBoundingClientRect 调用,减少 DOM 查询次数 + const rect = target.getBoundingClientRect(); + const height = rect.height; + const width = rect.width; if (!Number.isFinite(height) || height < 50) return; if (Number.isFinite(width) && width > 0) { setTableViewportWidth(Math.floor(width)); @@ -1374,6 +1846,10 @@ const DataGrid: React.FC = ({ return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND'; }, []); + // P6 性能优化:使用 ref 缓存首列名,避免 displayColumnNames 变化导致级联更新 + const firstColumnNameRef = useRef(displayColumnNames[0] || ''); + firstColumnNameRef.current = displayColumnNames[0] || ''; + const normalizeGridFilterConditions = useCallback((conditions?: FilterCondition[]): GridFilterCondition[] => { if (!Array.isArray(conditions)) return []; return conditions.map((cond, index) => { @@ -1385,13 +1861,13 @@ const DataGrid: React.FC = ({ id: nextId, enabled: cond?.enabled !== false, logic: normalizeFilterLogic(cond?.logic), - column: rawColumn || (op === 'CUSTOM' ? '' : String(displayColumnNames[0] || '')), + column: rawColumn || (op === 'CUSTOM' ? '' : String(firstColumnNameRef.current || '')), op, value: String(cond?.value ?? ''), value2: String(cond?.value2 ?? ''), }; }); - }, [displayColumnNames, normalizeFilterLogic]); + }, [normalizeFilterLogic]); // Filter State const [filterConditions, setFilterConditions] = useState([]); @@ -2521,7 +2997,7 @@ const DataGrid: React.FC = ({ sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined, editable: canModifyData, // Only editable if table name known and not readonly render: (text: any) => ( -
+
{formatCellValue(text)}
), @@ -2600,7 +3076,7 @@ const DataGrid: React.FC = ({ handleSave={handleCellSave} focusCell={openCellEditor} as="div" - style={{ margin: -8, padding: '8px 8px 8px 8px' }} + style={VIRTUAL_CELL_WRAPPER_STYLE} > {originalRenderContent} @@ -2609,7 +3085,7 @@ const DataGrid: React.FC = ({ if (enableVirtual) { return (
{ e.preventDefault(); e.stopPropagation(); @@ -2829,7 +3305,7 @@ const DataGrid: React.FC = ({ const res = await ExportQuery(config as any, dbName || '', sql, defaultName || 'export', format); if (res.success) { void message.success("导出成功"); - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("导出失败: " + res.message); } } catch (e: any) { @@ -2947,7 +3423,7 @@ const DataGrid: React.FC = ({ const res = await ExportTable(config as any, dbName || '', tableName, format); if (res.success) { void message.success("导出成功"); - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("导出失败: " + res.message); } } catch (e: any) { @@ -3023,7 +3499,7 @@ const DataGrid: React.FC = ({ if (res.success && res.data && res.data.filePath) { setImportFilePath(res.data.filePath); setImportPreviewVisible(true); - } else if (res.message !== "Cancelled") { + } else if (res.message !== "已取消") { void message.error("选择文件失败: " + res.message); } }; @@ -3291,17 +3767,26 @@ const DataGrid: React.FC = ({ }, [resolveVirtualHorizontalElements]); const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => { - const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer); + const { holderEl, innerEl } = resolveVirtualHorizontalElements(tableContainer); if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) { return false; } const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth); const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset)); - innerEl.style.marginLeft = `${-clampedOffset}px`; - if (headerEl) { - headerEl.scrollLeft = clampedOffset; - } + const currentOffset = Math.abs(parseFloat(innerEl.style.marginLeft) || 0); + const deltaX = clampedOffset - currentOffset; + if (Math.abs(deltaX) < 0.5) return true; + + // 通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state, + // 让 rc-table onInternalScroll 自动同步 header scrollLeft。 + // 不直接操作 DOM marginLeft,避免 React re-render 覆盖。 + holderEl.dispatchEvent(new WheelEvent('wheel', { + deltaX: deltaX, + deltaY: 0, + bubbles: true, + cancelable: true, + })); return true; }, [resolveVirtualHorizontalElements, tableScrollX]); @@ -3371,11 +3856,6 @@ const DataGrid: React.FC = ({ return; } - const liveTargets = tableScrollTargetsRef.current; - if (liveTargets.length === 0) { - return; - } - if (Math.abs(lastExternalScrollLeftRef.current - externalScroll.scrollLeft) < 1) { return; } @@ -3383,10 +3863,20 @@ const DataGrid: React.FC = ({ horizontalSyncSourceRef.current = 'external'; const tableContainer = tableContainerRef.current; + // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, + // rc-table 自动同步 header scrollLeft。 if (enableVirtual && tableContainer instanceof HTMLElement) { - if (applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft)) { - lastTableScrollLeftRef.current = externalScroll.scrollLeft; - } + applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft); + // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref + requestAnimationFrame(() => { + lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer); + horizontalSyncSourceRef.current = ''; + }); + return; + } + // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步 + const liveTargets = tableScrollTargetsRef.current; + if (liveTargets.length === 0) { horizontalSyncSourceRef.current = ''; return; } @@ -3400,7 +3890,7 @@ const DataGrid: React.FC = ({ }); lastTableScrollLeftRef.current = externalScroll.scrollLeft; horizontalSyncSourceRef.current = ''; - }, [applyVirtualHorizontalOffset, enableVirtual]); + }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset]); // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) useEffect(() => { @@ -3456,48 +3946,47 @@ const DataGrid: React.FC = ({ if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return; if (!isTableDataAreaTarget(event.target)) return; + if (enableVirtual) { + // 虚拟模式:不拦截事件,让 rc-virtual-list 原生处理 wheel。 + // rc-virtual-list 会通过内部 setOffsetLeft → re-render → onVirtualScroll + // 自动同步 header scrollLeft。 + // 仅需在状态更新后同步外部横向滚动条。 + horizontalSyncSourceRef.current = 'table'; + requestAnimationFrame(() => { + const nextScrollLeft = readVirtualHorizontalOffset(container); + lastTableScrollLeftRef.current = nextScrollLeft; + const externalScroll = externalHorizontalScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + horizontalSyncSourceRef.current = ''; + }); + return; + } + + // 非虚拟模式:拦截事件并手动同步 const targets = pickHorizontalScrollTargets(container); event.preventDefault(); event.stopPropagation(); horizontalSyncSourceRef.current = 'table'; - let nextScrollLeft = 0; - if (enableVirtual) { - const currentOffset = readVirtualHorizontalOffset(container); - const { holderEl } = resolveVirtualHorizontalElements(container); - if (!(holderEl instanceof HTMLElement)) { - horizontalSyncSourceRef.current = ''; - return; - } - const maxScrollLeft = Math.max(0, tableScrollX - holderEl.clientWidth); - if (maxScrollLeft <= 0) { - horizontalSyncSourceRef.current = ''; - return; - } - nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, currentOffset + horizontalDelta)); - if (Math.abs(nextScrollLeft - currentOffset) < 1) { - horizontalSyncSourceRef.current = ''; - return; - } - applyVirtualHorizontalOffset(container, nextScrollLeft); - } else { - const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; - if (!(activeTarget instanceof HTMLElement)) { - horizontalSyncSourceRef.current = ''; - return; - } - const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); - if (maxScrollLeft <= 0) { - horizontalSyncSourceRef.current = ''; - return; - } - nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); - if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) { - horizontalSyncSourceRef.current = ''; - return; - } - activeTarget.scrollLeft = nextScrollLeft; + const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; + if (!(activeTarget instanceof HTMLElement)) { + horizontalSyncSourceRef.current = ''; + return; } + const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); + if (maxScrollLeft <= 0) { + horizontalSyncSourceRef.current = ''; + return; + } + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); + if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) { + horizontalSyncSourceRef.current = ''; + return; + } + activeTarget.scrollLeft = nextScrollLeft; lastTableScrollLeftRef.current = nextScrollLeft; const externalScroll = externalHorizontalScrollRef.current; @@ -3512,7 +4001,7 @@ const DataGrid: React.FC = ({ return () => { container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); }; - }, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, resolveVirtualHorizontalElements, tableScrollX, viewMode]); + }, [enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]); useEffect(() => { if (viewMode !== 'table') return; @@ -4517,441 +5006,7 @@ const DataGrid: React.FC = ({
)} - + {/* Ghost Resize Line for Columns */}
void; onOpenG const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { const fileRes = await SelectDriverPackageFile(downloadDir); if (!fileRes?.success) { - if (String(fileRes?.message || '') !== 'Cancelled') { + if (String(fileRes?.message || '') !== '已取消') { message.error(fileRes?.message || '选择本地驱动包文件失败'); } return; @@ -863,7 +863,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const installDriversFromDirectory = useCallback(async () => { const directoryRes = await SelectDriverPackageDirectory(downloadDir); if (!directoryRes?.success) { - if (String(directoryRes?.message || '') !== 'Cancelled') { + if (String(directoryRes?.message || '') !== '已取消') { message.error(directoryRes?.message || '选择本地驱动包目录失败'); } return; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 69294d1..01507e7 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -6,12 +6,20 @@ import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; -import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; +import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { convertMongoShellToJsonCommand } from '../utils/mongodb'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; +const SQL_KEYWORDS = [ + 'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', + 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', + 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD', 'MODIFY', 'CHANGE', + 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', + 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN', +]; + const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); @@ -33,7 +41,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); - const [currentQueryId, setCurrentQueryId] = useState(''); + const [, setCurrentQueryId] = useState(''); const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -50,6 +58,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const monacoRef = useRef(null); const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); + const queryEditorRootRef = useRef(null); + const editorPaneRef = useRef(null); const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db) const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db) const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense @@ -60,6 +70,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { [connections] ); const addSqlLog = useStore(state => state.addSqlLog); + const addTab = useStore(state => state.addTab); + const savedQueries = useStore(state => state.savedQueries); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); const connectionsRef = useRef(connections); @@ -74,6 +86,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const shortcutOptions = useStore(state => state.shortcutOptions); const activeTabId = useStore(state => state.activeTabId); + const currentSavedQuery = useMemo(() => { + const savedId = String(tab.savedQueryId || '').trim(); + if (savedId) { + return savedQueries.find((item) => item.id === savedId) || null; + } + const tabId = String(tab.id || '').trim(); + if (!tabId) { + return null; + } + return savedQueries.find((item) => item.id === tabId) || null; + }, [savedQueries, tab.id, tab.savedQueryId]); + useEffect(() => { currentConnectionIdRef.current = currentConnectionId; }, [currentConnectionId]); @@ -159,7 +183,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { setDbList([]); } }; - fetchDbs(); + void fetchDbs(); }, [currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) @@ -211,7 +235,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { tablesRef.current = allTables; allColumnsRef.current = allColumns; }; - fetchMetadata(); + void fetchMetadata(); }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 // Query ID management helpers @@ -346,7 +370,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1); // 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列 - const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/); + const threePartMatch = linePrefix.match(/([`"]?\w+[`"]?)\.([`"]?\w+[`"]?)\.(\w*)$/); if (threePartMatch) { const dbPart = stripQuotes(threePartMatch[1]); const tablePart = stripQuotes(threePartMatch[2]); @@ -374,7 +398,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } // 1) 两段式 qualifier.xxx 格式 - const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/); + const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_]\w*[`"]?)\.(\w*)$/); if (qualifierMatch) { const qualifier = stripQuotes(qualifierMatch[1]); const prefix = (qualifierMatch[2] || '').toLowerCase(); @@ -439,7 +463,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const aliasMap: Record = {}; // Capture table and optional alias, support db.table format - const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi; + const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi; let m; while ((m = aliasRegex.exec(fullText)) !== null) { const tableIdent = normalizeQualifiedName(m[1] || ''); @@ -468,7 +492,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { // Prefer preloaded MySQL all-columns cache - let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = []; + let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; if (allColumnsRef.current.length > 0) { cols = allColumnsRef.current .filter(c => @@ -498,7 +522,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } // 2) global/table/column completion - const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)/gi; + const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)/gi; const foundTables = new Set(); let match; while ((match = tableRegex.exec(fullText)) !== null) { @@ -509,6 +533,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } const currentDatabase = currentDbRef.current || ''; + const wordPrefix = (word.word || '').toLowerCase(); + const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix); + const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim()); + const shouldBoostKeywords = !expectsTableName + && wordPrefix.length > 0 + && SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix)); + const sortGroups = shouldBoostKeywords + ? { keyword: '00', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' } + : expectsTableName + ? { keyword: '20', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' } + : { keyword: '30', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' }; // 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等) // 权重最高,输入 WHERE 条件时优先显示 @@ -516,7 +551,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); - return foundTables.has(fullIdent) || foundTables.has(shortIdent); + return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 @@ -527,12 +562,18 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { insertText: c.name, detail: `${c.type} (${c.dbName}.${c.tableName})`, range, - sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先 + sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name, }; }); // 表提示:当前库显示表名,其他库显示 db.table 格式 - const tableSuggestions = tablesRef.current.map(t => { + const tableSuggestions = tablesRef.current + .filter(t => { + const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); + const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; + return startsWithPrefix(label || ''); + }) + .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; @@ -542,27 +583,31 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { insertText, detail: `Table (${t.dbName})`, range, - sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先 + sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, }; }); // 数据库提示 - const dbSuggestions = visibleDbsRef.current.map(db => ({ - label: db, - kind: monaco.languages.CompletionItemKind.Module, - insertText: db, - detail: 'Database', - range, - sortText: '20' + db // 数据库最后 - })); + const dbSuggestions = visibleDbsRef.current + .filter((db) => startsWithPrefix(db)) + .map(db => ({ + label: db, + kind: monaco.languages.CompletionItemKind.Module, + insertText: db, + detail: 'Database', + range, + sortText: sortGroups.db + db, + })); // 关键字提示 - const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({ + const keywordSuggestions = SQL_KEYWORDS + .filter((k) => startsWithPrefix(k)) + .map(k => ({ label: k, kind: monaco.languages.CompletionItemKind.Keyword, insertText: k, range, - sortText: '30' + k // 关键字权重最低 + sortText: sortGroups.keyword + k, })); const suggestions = [ @@ -581,7 +626,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); syncQueryToEditor(formatted); } catch (e) { - message.error("格式化失败: SQL 语法可能有误"); + void message.error("格式化失败: SQL 语法可能有误"); } }; @@ -731,6 +776,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return statements; }; + // DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。 + // 当恢复前端自动行数限制功能时需要启用。 + // eslint-disable-next-line @typescript-eslint/no-unused-vars const getLeadingKeyword = (sql: string): string => { const text = (sql || '').replace(/\r\n/g, '\n'); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; @@ -1023,6 +1071,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return -1; }; + // DEBT: 改用 DBQueryMulti 后前端不再逐条处理语句,此函数暂时未使用。 + // 当恢复前端自动行数限制功能时需要启用。 + // eslint-disable-next-line @typescript-eslint/no-unused-vars const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === ''; @@ -1112,36 +1163,31 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const dbType = String((config as any).type || 'mysql'); const normalizedDbType = dbType.trim().toLowerCase(); const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); - const splitInput = normalizedDbType === 'mongodb' - ? normalizedRawSQL + + // MongoDB 仍走逐条执行的旧路径 + const isMongoDB = normalizedDbType === 'mongodb'; + + if (isMongoDB) { + // MongoDB: 保持逐条执行 + const splitInput = normalizedRawSQL .replace(/^\s*\/\/.*$/gm, '') - .replace(/^\s*#.*$/gm, '') - : normalizedRawSQL; - const statements = splitSQLStatements(splitInput); - if (statements.length === 0) { - message.info('没有可执行的 SQL。'); - setResultSets([]); - setActiveResultKey(''); - return; - } + .replace(/^\s*#.*$/gm, ''); + const statements = splitSQLStatements(splitInput); + if (statements.length === 0) { + message.info('没有可执行的 SQL。'); + setResultSets([]); + setActiveResultKey(''); + return; + } - const nextResultSets: ResultSet[] = []; - const maxRows = Number(queryOptions?.maxRows) || 0; - const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; - const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; - const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0; - let anyTruncated = false; - const pendingPk: Array<{ resultKey: string; tableName: string }> = []; + const nextResultSets: ResultSet[] = []; + const maxRows = Number(queryOptions?.maxRows) || 0; + const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; + let anyTruncated = false; - for (let idx = 0; idx < statements.length; idx++) { - const rawStatement = statements[idx]; - const leadingKeyword = getLeadingKeyword(rawStatement); - const shouldAutoLimit = leadingKeyword === 'select' || leadingKeyword === 'with'; - - const limitApplied = shouldAutoLimit && wantsLimitProbe; - const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit }; - let executedSql = limited.sql; - if (String(dbType || '').trim().toLowerCase() === 'mongodb') { + for (let idx = 0; idx < statements.length; idx++) { + const rawStatement = statements[idx]; + let executedSql = rawStatement; const shellConvert = convertMongoShellToJsonCommand(executedSql); if (shellConvert.recognized) { if (shellConvert.error) { @@ -1155,10 +1201,97 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { executedSql = shellConvert.command; } } - } - const startTime = Date.now(); + const startTime = Date.now(); + let queryId: string; + try { + queryId = await GenerateQueryID(); + } catch (error) { + console.warn('GenerateQueryID failed, using local UUID fallback:', error); + queryId = 'query-' + uuidv4(); + } + setQueryId(queryId); - // Generate query ID for cancellation using backend UUID with fallback + const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); + const duration = Date.now() - startTime; + addSqlLog({ + id: `log-${Date.now()}-query-${idx + 1}`, + timestamp: Date.now(), + sql: executedSql, + status: res.success ? 'success' : 'error', + duration, + message: res.success ? '' : res.message, + affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), + dbName: currentDb + }); + if (!res.success) { + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; + message.error(prefix + res.message); + setResultSets([]); + setActiveResultKey(''); + return; + } + if (Array.isArray(res.data)) { + let rows = (res.data as any[]) || []; + let truncated = false; + if (wantsLimitProbe && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { + truncated = true; + anyTruncated = true; + rows = rows.slice(0, maxRows); + } + const cols = (res.fields && res.fields.length > 0) + ? (res.fields as string[]) + : (rows.length > 0 ? Object.keys(rows[0]) : []); + rows.forEach((row: any, i: number) => { + if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; + }); + nextResultSets.push({ + key: `result-${idx + 1}`, + sql: rawStatement, + exportSql: rawStatement, + rows, + columns: cols, + pkColumns: [], + readOnly: true, + truncated + }); + } else { + const affected = Number((res.data as any)?.affectedRows); + if (Number.isFinite(affected)) { + const row = { affectedRows: affected }; + (row as any)[GONAVI_ROW_KEY] = 0; + nextResultSets.push({ + key: `result-${idx + 1}`, + sql: rawStatement, + exportSql: rawStatement, + rows: [row], + columns: ['affectedRows'], + pkColumns: [], + readOnly: true + }); + } + } + } + setResultSets(nextResultSets); + setActiveResultKey(nextResultSets[0]?.key || ''); + if (statements.length > 1) { + message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); + } else if (nextResultSets.length === 0) { + message.success('执行成功。'); + } + if (anyTruncated && maxRows > 0) { + message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); + } + } else { + // 非 MongoDB:使用 DBQueryMulti 一次性执行多条 SQL,后端返回多结果集 + const fullSQL = normalizedRawSQL; + if (!fullSQL.trim()) { + message.info('没有可执行的 SQL。'); + setResultSets([]); + setActiveResultKey(''); + return; + } + + const startTime = Date.now(); let queryId: string; try { queryId = await GenerateQueryID(); @@ -1168,22 +1301,20 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } setQueryId(queryId); - const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); + const res = await DBQueryMulti(config as any, currentDb, fullSQL, queryId); const duration = Date.now() - startTime; addSqlLog({ - id: `log-${Date.now()}-query-${idx + 1}`, + id: `log-${Date.now()}-query-multi`, timestamp: Date.now(), - sql: executedSql, + sql: fullSQL, status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, - affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), dbName: currentDb }); if (!res.success) { - // 检查是否为查询取消错误 const errorMsg = res.message.toLowerCase(); const isCancelledError = errorMsg.includes('context canceled') || errorMsg.includes('查询已取消') || @@ -1191,72 +1322,49 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { errorMsg.includes('cancelled') || errorMsg.includes('statement canceled') || errorMsg.includes('sql: statement canceled'); - - // 确保不是超时错误 const isTimeoutError = errorMsg.includes('context deadline exceeded') || errorMsg.includes('timeout') || errorMsg.includes('超时') || errorMsg.includes('deadline exceeded'); if (isCancelledError && !isTimeoutError) { - // 查询已被用户取消,不显示错误消息,清理状态 setResultSets([]); setActiveResultKey(''); - // 清除查询ID,与handleCancel保持一致 if (currentQueryIdRef.current) { clearQueryId(); } return; } - const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; - message.error(prefix + res.message); + message.error(res.message); setResultSets([]); setActiveResultKey(''); return; } - if (Array.isArray(res.data)) { - let rows = (res.data as any[]) || []; - let truncated = false; - if (limited.applied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { - truncated = true; - anyTruncated = true; - rows = rows.slice(0, maxRows); - } - const cols = (res.fields && res.fields.length > 0) - ? (res.fields as string[]) - : (rows.length > 0 ? Object.keys(rows[0]) : []); + // res.data 是 ResultSetData[] 数组 + const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; + const nextResultSets: ResultSet[] = []; + const maxRows = Number(queryOptions?.maxRows) || 0; + const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; + let anyTruncated = false; + const pendingPk: Array<{ resultKey: string; tableName: string }> = []; - rows.forEach((row: any, i: number) => { - if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; - }); + // 前端也拆分语句用于匹配原始 SQL(展示和表名检测) + const statements = splitSQLStatements(fullSQL); - let simpleTableName: string | undefined = undefined; - const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); - if (tableMatch) { - simpleTableName = tableMatch[1]; - if (!forceReadOnlyResult) { - pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); - } - } + for (let idx = 0; idx < resultSetDataArray.length; idx++) { + const rsData = resultSetDataArray[idx]; + const rawStatement = (idx < statements.length) ? statements[idx] : ''; - nextResultSets.push({ - key: `result-${idx + 1}`, - sql: rawStatement, - exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement, - rows, - columns: cols, - tableName: simpleTableName, - pkColumns: [], - readOnly: true, - pkLoading: !!simpleTableName, - truncated - }); - } else { - const affected = Number((res.data as any)?.affectedRows); - if (Number.isFinite(affected)) { - const row = { affectedRows: affected }; + // 检查是否为 affectedRows 类结果集 + const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 + && rsData.columns && rsData.columns.length === 1 + && rsData.columns[0] === 'affectedRows'; + + if (isAffectedResult) { + const affected = Number(rsData.rows[0]?.affectedRows); + const row = { affectedRows: Number.isFinite(affected) ? affected : 0 }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${idx + 1}`, @@ -1267,37 +1375,80 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { pkColumns: [], readOnly: true }); + } else { + let rows = Array.isArray(rsData.rows) ? rsData.rows : []; + let truncated = false; + if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { + truncated = true; + anyTruncated = true; + rows = rows.slice(0, maxRows); + } + const cols = (rsData.columns && rsData.columns.length > 0) + ? rsData.columns + : (rows.length > 0 ? Object.keys(rows[0]) : []); + + rows.forEach((row: any, i: number) => { + if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; + }); + + let simpleTableName: string | undefined = undefined; + if (rawStatement) { + const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); + if (tableMatch) { + simpleTableName = tableMatch[1]; + if (!forceReadOnlyResult) { + pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); + } + } + } + + nextResultSets.push({ + key: `result-${idx + 1}`, + sql: rawStatement, + exportSql: rawStatement, + rows, + columns: cols, + tableName: simpleTableName, + pkColumns: [], + readOnly: true, + pkLoading: !!simpleTableName, + truncated + }); } } - } - setResultSets(nextResultSets); - setActiveResultKey(nextResultSets[0]?.key || ''); + setResultSets(nextResultSets); + setActiveResultKey(nextResultSets[0]?.key || ''); - pendingPk.forEach(({ resultKey, tableName }) => { - DBGetColumns(config as any, currentDb, tableName) - .then((resCols: any) => { - if (runSeqRef.current !== runSeq) return; - if (!resCols?.success) { + pendingPk.forEach(({ resultKey, tableName }) => { + DBGetColumns(config as any, currentDb, tableName) + .then((resCols: any) => { + if (runSeqRef.current !== runSeq) return; + if (!resCols?.success) { + setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); + return; + } + const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); + setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs)); + }) + .catch(() => { + if (runSeqRef.current !== runSeq) return; setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); - return; - } - const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); - setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs)); - }) - .catch(() => { - if (runSeqRef.current !== runSeq) return; - setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); - }); - }); + }); + }); - if (statements.length > 1) { - message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); - } else if (nextResultSets.length === 0) { - message.success('执行成功。'); - } - if (anyTruncated && maxRows > 0) { - message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); + // 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示) + if (res.message) { + message.info(res.message); + } + if (resultSetDataArray.length > 1) { + message.success(`已执行完成,生成 ${nextResultSets.length} 个结果集。`); + } else if (nextResultSets.length === 0) { + message.success('执行成功。'); + } + if (anyTruncated && maxRows > 0) { + message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); + } } } catch (e: any) { message.error("Error executing query: " + e.message); @@ -1341,6 +1492,46 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } }; + useEffect(() => { + const handleSelectAllInEditor = (event: KeyboardEvent) => { + if (activeTabId !== tab.id) { + return; + } + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { + return; + } + + const editor = editorRef.current; + if (!editor) { + return; + } + + const targetNode = event.target instanceof Node ? event.target : null; + const editorHasFocus = !!editor.hasTextFocus?.(); + const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); + const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); + if (!editorHasFocus && !inEditorPane) { + return; + } + if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) { + return; + } + if (!editorHasFocus && !inQueryEditor) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + editor.focus?.(); + editor.trigger('keyboard', 'editor.action.selectAll', null); + }; + + window.addEventListener('keydown', handleSelectAllInEditor, true); + return () => { + window.removeEventListener('keydown', handleSelectAllInEditor, true); + }; + }, [activeTabId, tab.id]); + useEffect(() => { const binding = shortcutOptions.runQuery; if (!binding?.enabled || !binding.combo) { @@ -1383,16 +1574,60 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; }, [activeTabId, tab.id, handleRun]); + const resolveDefaultQueryName = () => { + const rawTitle = String(tab.title || '').trim(); + if (!rawTitle || rawTitle.startsWith('新建查询')) { + return '未命名查询'; + } + return rawTitle; + }; + + const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => { + const sql = getCurrentQuery(); + const saved = { + id: payload.id, + name: payload.name, + sql, + connectionId: currentConnectionId, + dbName: currentDb || tab.dbName || '', + createdAt: payload.createdAt ?? Date.now(), + }; + saveQuery(saved); + addTab({ + ...tab, + title: payload.name, + query: sql, + connectionId: currentConnectionId, + dbName: currentDb || tab.dbName || '', + savedQueryId: payload.id, + }); + return saved; + }; + + const handleQuickSave = () => { + const existed = currentSavedQuery || null; + const fallbackSavedId = String(tab.savedQueryId || '').trim(); + const saveId = existed?.id || fallbackSavedId || ''; + if (!saveId) { + saveForm.setFieldsValue({ name: resolveDefaultQueryName() }); + setIsSaveModalOpen(true); + return; + } + const saveName = existed?.name || resolveDefaultQueryName(); + persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt }); + message.success('查询已保存!'); + }; + const handleSave = async () => { try { const values = await saveForm.validateFields(); - saveQuery({ - id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`, - name: values.name, - sql: getCurrentQuery(), - connectionId: currentConnectionId, - dbName: currentDb || tab.dbName || '', - createdAt: Date.now() + const existed = currentSavedQuery || null; + const fallbackSavedId = String(tab.savedQueryId || '').trim(); + const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`; + persistQuery({ + id: nextSavedId, + name: String(values.name || '').trim() || '未命名查询', + createdAt: existed?.createdAt, }); message.success('查询已保存!'); setIsSaveModalOpen(false); @@ -1408,8 +1643,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { setActiveResultKey(prevActive => { if (prevActive && prevActive !== key) return prevActive; - const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; - return nextKey; + return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; }); return next; @@ -1417,7 +1651,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; return ( -
+
+