🐛 fix(data-grid): 修复窄表场景表头与数据列错位

This commit is contained in:
Syngnat
2026-03-19 18:16:51 +08:00
parent 18cb66b893
commit 561d3810da
4 changed files with 106 additions and 12 deletions

View File

@@ -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<DataGridProps> = ({
.${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 无 classAnt 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<DataGridProps> = ({
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<DataGridProps> = ({
.${gridId} .ant-table-sticky-scroll {
display: none !important;
}
/* 虚拟表列对齐:阻止 header <table> 通过 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<DataGridProps> = ({
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<DataGridProps> = ({
return () => cancelAnimationFrame(rafId);
}, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]);
// 虚拟表列对齐antd 虚拟表 body 使用 <div>+<td>(非 <table>
// 不会自动拉伸列宽到视口。而 header <table> 会被 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;

View File

@@ -194,7 +194,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ 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,

View File

@@ -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');

View File

@@ -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;
};