From 7933b4c315fcd11bb52974ab89cb7723d06b7c62 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 12:26:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat(window):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=AA=97=E5=8F=A3=E5=B0=BA=E5=AF=B8=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=AE=BD=E5=BA=A6=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 窗口状态:新增 windowState 记录全屏/最大化/普通状态,关闭后重开自动恢复 - 窗口尺寸:普通窗口模式下每2秒自动保存宽高和坐标位置 - 侧边栏宽度:sidebarWidth 从 useState 迁移至 zustand store 持久化 - 状态恢复:启动时根据保存的状态决定全屏/最大化/恢复具体尺寸位置 - 数据校验:新增 sanitizeWindowBounds/sanitizeWindowState/sanitizeSidebarWidth 校验函数 - 兼容处理:startupFullscreen 设置优先级高于自动记忆的窗口状态 - refs #259 --- frontend/src/App.tsx | 84 ++++++++++++++++++++++++++++++++++++++++--- frontend/src/store.ts | 58 ++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ebf4e4..41d0676 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons'; -import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; +import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -89,7 +89,8 @@ function App() { const [runtimePlatform, setRuntimePlatform] = useState(''); const [isLinuxRuntime, setIsLinuxRuntime] = useState(false); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); - const [sidebarWidth, setSidebarWidth] = useState(330); + const sidebarWidth = useStore(state => state.sidebarWidth); + const setSidebarWidth = useStore(state => state.setSidebarWidth); const globalProxyInvalidHintShownRef = React.useRef(false); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, @@ -285,14 +286,43 @@ function App() { }, applyRetryDelayMs); }; + const restoreWindowState = async () => { + if (cancelled) return; + const state = useStore.getState(); + // startupFullscreen 设置优先 + if (state.startupFullscreen) { + applyStartupWindowPreference(1); + return; + } + // 根据上次保存的窗口状态恢复 + const savedState = state.windowState; + if (savedState === 'fullscreen') { + applyStartupWindowPreference(1); + return; + } + if (savedState === 'maximized') { + try { await WindowMaximise(); } catch (_) {} + return; + } + // 普通窗口:恢复尺寸和位置 + const bounds = state.windowBounds; + if (!bounds || bounds.width < 400 || bounds.height < 300) return; + try { + WindowSetSize(bounds.width, bounds.height); + WindowSetPosition(bounds.x, bounds.y); + } catch (e) { + console.warn('Failed to restore window bounds', e); + } + }; + if (useStore.persist.hasHydrated()) { - applyStartupWindowPreference(1); + void restoreWindowState(); } const unsubscribeHydration = useStore.persist.onFinishHydration(() => { if (cancelled) { return; } - applyStartupWindowPreference(1); + void restoreWindowState(); }); return () => { @@ -304,6 +334,52 @@ function App() { }; }, []); + // 定时保存窗口状态、尺寸与位置 + useEffect(() => { + const SAVE_INTERVAL_MS = 2000; + let lastSaved = ''; + + const saveWindowState = async () => { + try { + const [isFs, isMax] = await Promise.all([ + WindowIsFullscreen().catch(() => false), + WindowIsMaximised().catch(() => false), + ]); + + // 保存窗口状态 + const store = useStore.getState(); + const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal'); + if (store.windowState !== newState) { + store.setWindowState(newState); + } + + // 只在普通窗口模式下保存尺寸和位置 + if (isFs || isMax) return; + + const [size, pos] = await Promise.all([ + WindowGetSize().catch(() => null), + WindowGetPosition().catch(() => null), + ]); + if (!size || !pos) return; + const w = Math.trunc(Number(size.w || 0)); + const h = Math.trunc(Number(size.h || 0)); + const x = Math.trunc(Number(pos.x || 0)); + const y = Math.trunc(Number(pos.y || 0)); + if (w < 400 || h < 300) return; + + const key = `${w},${h},${x},${y}`; + if (key === lastSaved) return; + lastSaved = key; + store.setWindowBounds({ width: w, height: h, x, y }); + } catch (e) { + // 静默忽略 + } + }; + + const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS); + return () => window.clearInterval(timer); + }, []); + useEffect(() => { if (!isWindowsPlatform()) { return; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 172099d..0338859 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -420,6 +420,9 @@ interface AppState { enableColumnOrderMemory: boolean; tableHiddenColumns: Record; enableHiddenColumnMemory: boolean; + windowBounds: { width: number; height: number; x: number; y: number } | null; + windowState: 'normal' | 'fullscreen' | 'maximized'; + sidebarWidth: number; addConnection: (conn: SavedConnection) => void; updateConnection: (conn: SavedConnection) => void; @@ -469,6 +472,9 @@ interface AppState { setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void; setEnableHiddenColumnMemory: (enabled: boolean) => void; clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void; + setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void; + setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void; + setSidebarWidth: (width: number) => void; } const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { @@ -599,6 +605,29 @@ const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => { }; }; +const sanitizeWindowState = (value: unknown): 'normal' | 'fullscreen' | 'maximized' => { + if (value === 'fullscreen' || value === 'maximized') return value; + return 'normal'; +}; + +const sanitizeSidebarWidth = (value: unknown): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 330; + return Math.max(200, Math.min(600, Math.trunc(parsed))); +}; + +const sanitizeWindowBounds = (value: unknown): { width: number; height: number; x: number; y: number } | null => { + if (!value || typeof value !== 'object') return null; + const raw = value as Record; + const width = Number(raw.width); + const height = Number(raw.height); + const x = Number(raw.x); + const y = Number(raw.y); + if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return null; + if (width < 400 || height < 300) return null; + return { width: Math.trunc(width), height: Math.trunc(height), x: Math.trunc(x), y: Math.trunc(y) }; +}; + const unwrapPersistedAppState = (persistedState: unknown): Record => { if (!persistedState || typeof persistedState !== 'object') { return {}; @@ -635,6 +664,9 @@ export const useStore = create()( enableColumnOrderMemory: true, tableHiddenColumns: {}, enableHiddenColumnMemory: true, + windowBounds: null, + windowState: 'normal' as const, + sidebarWidth: 330, addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })), updateConnection: (conn) => set((state) => ({ @@ -875,6 +907,19 @@ export const useStore = create()( }), setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }), + + setWindowBounds: (bounds) => set({ + windowBounds: { + width: Math.max(400, Math.trunc(bounds.width)), + height: Math.max(300, Math.trunc(bounds.height)), + x: Math.trunc(bounds.x), + y: Math.trunc(bounds.y), + } + }), + + setWindowState: (state) => set({ windowState: state }), + + setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }), }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) @@ -906,7 +951,10 @@ export const useStore = create()( nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false; const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns); nextState.tableHiddenColumns = safeHidden; - nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false; + nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false; + nextState.windowBounds = sanitizeWindowBounds(state.windowBounds); + nextState.windowState = sanitizeWindowState(state.windowState); + nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth); return nextState as AppState; }, merge: (persistedState, currentState) => { @@ -928,6 +976,9 @@ export const useStore = create()( enableColumnOrderMemory: state.enableColumnOrderMemory !== false, tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns), enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false, + windowBounds: sanitizeWindowBounds(state.windowBounds), + windowState: sanitizeWindowState(state.windowState), + sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth), sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), @@ -953,7 +1004,10 @@ export const useStore = create()( tableColumnOrders: state.tableColumnOrders, enableColumnOrderMemory: state.enableColumnOrderMemory, tableHiddenColumns: state.tableHiddenColumns, - enableHiddenColumnMemory: state.enableHiddenColumnMemory + enableHiddenColumnMemory: state.enableHiddenColumnMemory, + windowBounds: state.windowBounds, + windowState: state.windowState, + sidebarWidth: state.sidebarWidth, }), // Don't persist logs } ) From ab61e703b16bf45a2af7d872c2c8a059a032efd1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 14:32:12 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=A9=BA=E6=95=B0=E6=8D=AE=E8=A1=A8Shift+=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 目标匹配:isTableDataAreaTarget 新增 .ant-table-placeholder 选择器覆盖空表占位元素 - 虚拟回退:虚拟模式下 rc-virtual-list-holder 不存在时,回退到手动滚动表头并同步外部滚动条 - 精准匹配:仅添加 .ant-table-placeholder,避免 .ant-table-header 导致有数据表头体滚动不同步 --- frontend/src/components/DataGrid.tsx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 49c66cf..08e2e4e 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3988,7 +3988,7 @@ const DataGrid: React.FC = ({ const element = target instanceof HTMLElement ? target : null; if (!element) return false; if (element.closest('.data-grid-external-horizontal-scroll')) return false; - return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody'); + return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody, .ant-table-placeholder'); }; const handleContainerHorizontalWheel = (event: WheelEvent) => { @@ -4002,6 +4002,31 @@ const DataGrid: React.FC = ({ // 自动同步 header scrollLeft。 // 仅需在状态更新后同步外部横向滚动条。 horizontalSyncSourceRef.current = 'table'; + + // 空数据回退:virtual-holder 不存在时,手动滚动表头 + const virtualHolder = container.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + if (!virtualHolder) { + event.preventDefault(); + event.stopPropagation(); + const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null; + const contentEl = container.querySelector('.ant-table-content') as HTMLElement | null; + const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); + if (fallbackTargets.length > 0) { + fallbackTargets.forEach((target) => { + const max = Math.max(0, target.scrollWidth - target.clientWidth); + target.scrollLeft = Math.max(0, Math.min(max, target.scrollLeft + horizontalDelta)); + }); + lastTableScrollLeftRef.current = (fallbackTargets[0]).scrollLeft; + const externalScroll = externalHorizontalScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - lastTableScrollLeftRef.current) > 1) { + externalScroll.scrollLeft = lastTableScrollLeftRef.current; + lastExternalScrollLeftRef.current = lastTableScrollLeftRef.current; + } + } + horizontalSyncSourceRef.current = ''; + return; + } + requestAnimationFrame(() => { const nextScrollLeft = readVirtualHorizontalOffset(container); lastTableScrollLeftRef.current = nextScrollLeft; From 18cb66b89370fcbb13db7f17a1ef051b6a9d6c82 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 17:13:38 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor/data-grid):?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8DUPDATE=E5=BD=B1=E5=93=8D=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E4=B8=BA0=E5=8F=8A=E8=99=9A=E6=8B=9F=E8=A1=A8Shift+=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端修复:DBQueryMulti 包含写操作时跳过原生 QueryMulti,走逐条 Exec 路径获取 RowsAffected - 结果展示:UPDATE/INSERT/DELETE 结果改为简洁的执行成功提示,不再展示 DataGrid 全套操作按钮 - Tab标签:写操作结果集标签改为「结果 N ✓」替代原来的行数计数 - 横向滚动:修复虚拟表守卫检查选择器不匹配(.rc-virtual-list-holder → .ant-table-tbody-virtual-holder) - 事件处理:使用 event.isTrusted 区分合成事件,通过 applyVirtualHorizontalOffset 驱动 rc-virtual-list - 目标检查:isTableDataAreaTarget 改为黑名单模式,兼容 rc-virtual-list 包裹元素 --- frontend/src/components/DataGrid.tsx | 24 +++++++---- frontend/src/components/QueryEditor.tsx | 57 +++++++++++++++++-------- frontend/src/components/Sidebar.tsx | 3 +- internal/app/methods_db.go | 16 ++++++- 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 08e2e4e..dabe70a 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3815,6 +3815,7 @@ const DataGrid: React.FC = ({ // 通过合成 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, @@ -3987,27 +3988,29 @@ const DataGrid: React.FC = ({ const isTableDataAreaTarget = (target: EventTarget | null) => { const element = target instanceof HTMLElement ? target : null; if (!element) return false; + // 排除外部滚动条与工具栏,其余容器内元素一律视为数据区域 if (element.closest('.data-grid-external-horizontal-scroll')) return false; - return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody, .ant-table-placeholder'); + if (element.closest('.data-grid-toolbar')) return false; + return true; }; const handleContainerHorizontalWheel = (event: WheelEvent) => { + // applyVirtualHorizontalOffset 分发的合成 WheelEvent(isTrusted=false) + // 需要传播到 rc-virtual-list 的内部 handler,此处不拦截。 + if (!event.isTrusted) return; + const horizontalDelta = resolveHorizontalDelta(event); 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。 - // 仅需在状态更新后同步外部横向滚动条。 + event.preventDefault(); + event.stopPropagation(); horizontalSyncSourceRef.current = 'table'; // 空数据回退:virtual-holder 不存在时,手动滚动表头 - const virtualHolder = container.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const virtualHolder = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; if (!virtualHolder) { - event.preventDefault(); - event.stopPropagation(); const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null; const contentEl = container.querySelector('.ant-table-content') as HTMLElement | null; const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); @@ -4027,6 +4030,9 @@ const DataGrid: React.FC = ({ return; } + // 有数据:通过 applyVirtualHorizontalOffset 合成 WheelEvent 驱动 rc-virtual-list + const currentOffset = readVirtualHorizontalOffset(container); + applyVirtualHorizontalOffset(container, currentOffset + horizontalDelta); requestAnimationFrame(() => { const nextScrollLeft = readVirtualHorizontalOffset(container); lastTableScrollLeftRef.current = nextScrollLeft; @@ -4076,7 +4082,7 @@ const DataGrid: React.FC = ({ return () => { container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); }; - }, [enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]); + }, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]); useEffect(() => { if (viewMode !== 'table') return; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f54534b..fabc0aa 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2005,7 +2005,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { label: (
- {`结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`} + {(() => { + const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows'; + if (isAffected) return `结果 ${idx + 1} ✓`; + return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`; + })()} = ({ tab }) => {
), - children: ( -
- -
- ) + children: (() => { + // affectedRows 类型结果集(UPDATE/INSERT/DELETE):简洁提示 + const isAffectedResult = rs.columns.length === 1 && rs.columns[0] === 'affectedRows'; + if (isAffectedResult) { + const affected = Number(rs.rows[0]?.affectedRows ?? 0); + return ( +
+ + 执行成功 + 影响行数:{affected} +
+ ); + } + return ( +
+ +
+ ); + })() }))} /> ) : ( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0e3b694..ae0a9e9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1462,7 +1462,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> type: 'table-overview' as any, connectionId: id, dbName, - }); + schemaName, + } as any); return; } if (node.type === 'table') { diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index f95e55f..14a8bb1 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -525,8 +525,22 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu a.queryMu.Unlock() }() - // 尝试使用驱动原生多结果集支持 + // 尝试使用驱动原生多结果集支持。 + // 注意:原生 conn.Query() 执行写操作(UPDATE/INSERT/DELETE)时, + // sql.Rows 不暴露 RowsAffected,导致影响行数丢失。 + // 因此仅在全部语句皆为读操作时才使用原生路径。 + allReadOnly := true + for _, stmt := range splitSQLStatements(query) { + if strings.TrimSpace(stmt) != "" && !isReadOnlySQLQuery(runConfig.Type, stmt) { + allReadOnly = false + break + } + } + runMultiQuery := func(inst db.Database) ([]connection.ResultSetData, error) { + if !allReadOnly { + return nil, nil // 包含写操作,走逐条执行路径 + } if q, ok := inst.(db.MultiResultQuerierContext); ok { return q.QueryMultiContext(ctx, query) } From 561d3810dae2268726001f2c7496acbffe9f0631 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 18:16:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=AA=84=E8=A1=A8=E5=9C=BA=E6=99=AF=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=88=97=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 59 +++++++++++++++---- frontend/src/components/TableOverview.tsx | 2 +- .../src/components/dataGridLayout.test.ts | 32 +++++++++- frontend/src/components/dataGridLayout.ts | 25 ++++++++ 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index dabe70a..78ca46c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -31,7 +31,7 @@ import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; -import { calculateTableBodyBottomPadding } from './dataGridLayout'; +import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -1374,11 +1374,6 @@ const DataGrid: React.FC = ({ .${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; } /* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */ - .${gridId} .ant-table-selection-col, - .${gridId} .ant-table-bordered .ant-table-selection-col, - .${gridId} .ant-table-selection-col.ant-table-selection-col-with-dropdown { - width: ${selectionColumnWidth}px !important; - } .${gridId} .ant-table-header th:first-child, .${gridId} .ant-table-thead > tr > th:first-child { text-align: center !important; @@ -1392,6 +1387,17 @@ const DataGrid: React.FC = ({ padding-inline-start: 0 !important; padding-inline-end: 0 !important; } + /* 窄表场景下 rc-table 会按视口等比放大选择列宽度,不能再额外锁死 header 宽度; + 这里只统一 header/body 的内边距与对齐方式,避免第一列把后续数据列整体顶偏。 */ + .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column, + .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column, + .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column { + text-align: center !important; + padding-inline-start: 0 !important; + padding-inline-end: 0 !important; + padding-left: 0 !important; + padding-right: 0 !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; @@ -1457,6 +1463,11 @@ const DataGrid: React.FC = ({ .${gridId} .ant-table-sticky-scroll { display: none !important; } + /* 虚拟表列对齐:阻止 header 通过 min-width:100% 拉伸到视口, + 使 header 列宽与虚拟 body 单元格宽度精确一致 */ + .${gridId} .ant-table-header > table { + min-width: 0 !important; + } .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { display: none !important; } @@ -3764,10 +3775,13 @@ const DataGrid: React.FC = ({ const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth; const useContextMenuRow = false; const tableScrollX = useMemo(() => { - const baseWidth = Math.max(totalWidth, 1000); - if (!isMacLike || tableViewportWidth <= 0) return baseWidth; - // macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。 - return Math.max(baseWidth, tableViewportWidth + 2); + // rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。 + // 这里必须与其使用同一套 scroll.x 口径,否则少字段场景下 header/body 会错位。 + return calculateVirtualTableScrollX({ + totalWidth, + tableViewportWidth, + isMacLike, + }); }, [totalWidth, isMacLike, tableViewportWidth]); const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1; const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX); @@ -4090,6 +4104,31 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); + // 虚拟表列对齐:antd 虚拟表 body 使用
+
(非 ), + // 不会自动拉伸列宽到视口。而 header
会被 antd 的 CSS 或 JS + // 设置为 width:100% 自动拉伸。强制 header table 宽度等于 scroll.x, + // 使 header 列宽与 body 单元格宽度精确一致。 + useEffect(() => { + if (viewMode !== 'table') return; + const container = tableContainerRef.current; + if (!container) return; + const syncHeaderWidth = () => { + const headerTable = container.querySelector('.ant-table-header > table') as HTMLElement; + if (headerTable) { + headerTable.style.setProperty('width', `${tableScrollX}px`, 'important'); + headerTable.style.setProperty('min-width', '0px', 'important'); + headerTable.style.setProperty('max-width', `${tableScrollX}px`, 'important'); + } + }; + syncHeaderWidth(); + const rafId = requestAnimationFrame(syncHeaderWidth); + // 监听 antd 可能的重渲染覆盖 + const observer = new MutationObserver(syncHeaderWidth); + const headerEl = container.querySelector('.ant-table-header'); + if (headerEl) observer.observe(headerEl, { attributes: true, childList: true, subtree: true, attributeFilter: ['style'] }); + return () => { cancelAnimationFrame(rafId); observer.disconnect(); }; + }, [viewMode, tableScrollX, mergedDisplayData.length]); + useEffect(() => { if (viewMode !== 'table' || !onScrollSnapshotChange) return; const tableContainer = tableContainerRef.current; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 013ebd8..9da0075 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -194,7 +194,7 @@ const TableOverview: React.FC = ({ tab }) => { const openTable = useCallback((tableName: string) => { if (!connection) return; addTab({ - id: `${connection.id}-${tab.dbName}-${tableName}`, + id: `${connection.id}-${tab.dbName}-table-${tableName}`, title: tableName, type: 'table', connectionId: connection.id, diff --git a/frontend/src/components/dataGridLayout.test.ts b/frontend/src/components/dataGridLayout.test.ts index da5bd71..e52b23c 100644 --- a/frontend/src/components/dataGridLayout.test.ts +++ b/frontend/src/components/dataGridLayout.test.ts @@ -1,4 +1,4 @@ -import { calculateTableBodyBottomPadding } from './dataGridLayout'; +import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; const assertEqual = (actual: unknown, expected: unknown, message: string) => { if (actual !== expected) { @@ -36,4 +36,34 @@ assertEqual( '较粗滚动条场景下应同步放大底部安全区' ); +assertEqual( + calculateVirtualTableScrollX({ + totalWidth: 646, + tableViewportWidth: 1200, + isMacLike: false, + }), + 1200, + '列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度' +); + +assertEqual( + calculateVirtualTableScrollX({ + totalWidth: 646, + tableViewportWidth: 0, + isMacLike: false, + }), + 646, + '未拿到视口宽度时应退回列宽总和' +); + +assertEqual( + calculateVirtualTableScrollX({ + totalWidth: 1200, + tableViewportWidth: 800, + isMacLike: true, + }), + 1202, + 'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道' +); + console.log('dataGridLayout tests passed'); diff --git a/frontend/src/components/dataGridLayout.ts b/frontend/src/components/dataGridLayout.ts index d88cfbf..90469aa 100644 --- a/frontend/src/components/dataGridLayout.ts +++ b/frontend/src/components/dataGridLayout.ts @@ -4,6 +4,12 @@ export interface TableBodyBottomPaddingOptions { floatingScrollbarGap: number; } +export interface VirtualTableScrollXOptions { + totalWidth: number; + tableViewportWidth: number; + isMacLike: boolean; +} + const MIN_SCROLLBAR_CLEARANCE = 8; const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4; @@ -21,3 +27,22 @@ export const calculateTableBodyBottomPadding = ({ return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE; }; + +export const calculateVirtualTableScrollX = ({ + totalWidth, + tableViewportWidth, + isMacLike, +}: VirtualTableScrollXOptions): number => { + const safeTotalWidth = Math.max(0, Math.ceil(totalWidth)); + const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth)); + + if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) { + return safeViewportWidth; + } + + if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) { + return safeTotalWidth + 2; + } + + return safeTotalWidth; +}; From 3bf875841889adbc7fb02a8312ea7e9ad11a2ec4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 20:46:46 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(App/handleNewQuer?= =?UTF-8?q?y):=20=E7=BC=93=E5=AD=98=20handleNewQuery=20=E5=B9=B6=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20Tab=20ID=20=E7=A2=B0=E6=92=9E=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 handleNewQuery 改为 useCallback,减少 useEffect 中事件监听器的无效重绑定 - Tab ID 生成方式改为 Date.now() + 随机后缀,与项目既有模式一致 --- frontend/src/App.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41d0676..b9d22ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons'; @@ -986,7 +986,7 @@ function App() { setAboutLoading(false); }, []); - const handleNewQuery = () => { + const handleNewQuery = useCallback(() => { let connId = ''; let db = ''; @@ -1006,14 +1006,14 @@ function App() { } addTab({ - id: `query-${Date.now()}`, + id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, title: '新建查询', type: 'query', connectionId: connId, dbName: db, query: '' }); - }; + }, [activeTabId, tabs, connections, activeContext, addTab]); const handleImportConnections = async () => { const res = await (window as any).go.app.App.ImportConfigFile(); From 8950081a6c291055c820391c38ca2a194b373002 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 21:14:11 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20fix(QueryEditor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=20Tab=20=E5=AF=BC=E8=87=B4=20SQL=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E5=85=A8=E9=A1=B9=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerCompletionItemProvider 为 monaco.languages 全局 API,多 Tab 实例重复注册导致补全项成倍重复 - 添加模块级标志 sqlCompletionRegistered 确保全局只注册一次 - Provider 内部通过 ref 读取当前上下文,单次注册不影响多 Tab 的上下文感知 - refs #261 --- frontend/src/components/QueryEditor.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index fabc0aa..1a92a5f 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -170,6 +170,9 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [ { name: 'SLEEP', detail: '工具 - 延时' }, ]; +// 模块级标志:确保 SQL completion provider 全局只注册一次 +let sqlCompletionRegistered = false; + const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); @@ -428,6 +431,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // 应用透明主题(主题已在 main.tsx 全局注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); + // 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复 + if (!sqlCompletionRegistered) { + sqlCompletionRegistered = true; monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.'], provideCompletionItems: async (model: any, position: any) => { @@ -783,6 +789,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return { suggestions }; } }); + } // end sqlCompletionRegistered guard }; const handleFormat = () => {