From 561d3810dae2268726001f2c7496acbffe9f0631 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 18:16:51 +0800 Subject: [PATCH] =?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; +};