feat(datagrid): 完善左固定列体验并统一选中主题色

- 虚拟表横滚:表头 sticky + 表体 translateX 补偿,勾选/行号/钉住列同步固定
- 支持列头钉住左侧、钉住列显示图标;序号列可拖宽且不参与排序
- 行选中与单元格选区统一 V2 绿色,固定列底色对齐 --gn-bg-panel
- 查询结果页启用列头筛选,并在客户端按条件过滤当前结果集
This commit is contained in:
Syngnat
2026-07-09 15:37:20 +08:00
parent 947277bdec
commit af9f490172
20 changed files with 2064 additions and 744 deletions

View File

@@ -54,6 +54,7 @@ const storeState = vi.hoisted(() => ({
addTab: vi.fn(),
setActiveContext: vi.fn(),
tableColumnOrders: {},
tablePinnedLeftColumns: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
@@ -213,6 +214,7 @@ vi.mock('@ant-design/icons', () => {
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
ColumnWidthOutlined: Icon,
PushpinOutlined: Icon,
EyeInvisibleOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
@@ -1324,6 +1326,8 @@ describe('DataGrid DDL interactions', () => {
"t('data_grid.context_menu.current_marker')",
"t('data_grid.context_menu.column_display_section')",
"t('data_grid.context_menu.auto_fit_column')",
"t('data_grid.context_menu.pin_column_left')",
"t('data_grid.context_menu.unpin_column_left')",
"t('data_grid.context_menu.hide_column')",
"t('data_grid.context_menu.show_column_type')",
"t('data_grid.context_menu.hide_column_type')",

View File

@@ -73,6 +73,7 @@ vi.mock('../store', () => ({
addTab: vi.fn(),
setActiveContext: vi.fn(),
tableColumnOrders: {},
tablePinnedLeftColumns: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
@@ -2130,6 +2131,8 @@ describe('DataGrid layout', () => {
expect(markup).toContain('vertical-align:middle');
expect(markup).toContain('data-grid-row-number="true"');
expect(markup).toContain('width:36');
// ant Table fixed 列会渲染 fix 相关 class
expect(markup.includes('ant-table-cell-fix') || markup.includes('fixed')).toBe(true);
expect(markup).toContain('51');
} finally {
setCurrentLanguage(previousLanguage);
@@ -2542,7 +2545,7 @@ describe('DataGrid layout', () => {
expect(filterHookSource).toContain('export type GridColumnFilterDraft');
expect(filterHookSource).toContain('const applyColumnFilter = React.useCallback');
expect(filterHookSource).toContain('onApplyFilter(nextConditions)');
expect(source).toContain("const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;");
expect(source).toContain("const columnHeaderFilterEnabled = !!onApplyFilter || exportScope === 'queryResult';");
expect(source).toContain("filterOpOptions.filter((option) => option.value !== 'CUSTOM')");
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-trigger=\"true\"]')");
expect(source).toContain("eventTarget?.closest?.('[data-grid-column-filter-popover=\"true\"]')");

View File

@@ -42,6 +42,7 @@ import {
resolveDataTableVerticalBorderColor,
} from '../utils/dataGridDisplay';
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
import { filterRowsByGridConditions } from '../utils/dataGridClientFilter';
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
import {
absorbExtraWidthIntoFlexibleColumns,
@@ -308,6 +309,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const setTableColumnOrder = useStore(state => state.setTableColumnOrder);
const setEnableColumnOrderMemory = useStore(state => state.setEnableColumnOrderMemory);
const clearTableColumnOrder = useStore(state => state.clearTableColumnOrder);
const tablePinnedLeftColumns = useStore(state => state.tablePinnedLeftColumns);
const setTablePinnedLeftColumns = useStore(state => state.setTablePinnedLeftColumns);
const tableHiddenColumns = useStore(state => state.tableHiddenColumns);
const enableHiddenColumnMemory = useStore(state => state.enableHiddenColumnMemory);
@@ -478,11 +481,35 @@ const DataGrid: React.FC<DataGridProps> = ({
setAllOrderedColumnNames(nextOrder);
}, [visibleColumnNames, tableColumnOrders, enableColumnOrderMemory, connectionId, dbName, tableName]);
// Compute final display columns
const tableMemoryKey = useMemo(() => {
if (!connectionId || !dbName || !tableName) return '';
return `${connectionId}-${dbName}-${tableName}`;
}, [connectionId, dbName, tableName]);
const pinnedLeftColumnNames = useMemo(() => {
if (!tableMemoryKey) return [] as string[];
const stored = tablePinnedLeftColumns?.[tableMemoryKey];
return Array.isArray(stored) ? stored.map((col) => String(col || '').trim()).filter(Boolean) : [];
}, [tableMemoryKey, tablePinnedLeftColumns]);
const pinnedLeftColumnSet = useMemo(
() => new Set(pinnedLeftColumnNames),
[pinnedLeftColumnNames],
);
// Compute final display columns固定列须连续靠左否则 ant Table fixed 会错位)
useEffect(() => {
const hiddenSet = new Set(localHiddenColumns);
setDisplayColumnNames(allOrderedColumnNames.filter(col => !hiddenSet.has(col)));
}, [allOrderedColumnNames, localHiddenColumns]);
const visible = allOrderedColumnNames.filter((col) => !hiddenSet.has(col));
if (pinnedLeftColumnNames.length === 0) {
setDisplayColumnNames(visible);
return;
}
const pinnedSet = new Set(pinnedLeftColumnNames);
const pinnedVisible = pinnedLeftColumnNames.filter((col) => visible.includes(col));
const restVisible = visible.filter((col) => !pinnedSet.has(col));
setDisplayColumnNames([...pinnedVisible, ...restVisible]);
}, [allOrderedColumnNames, localHiddenColumns, pinnedLeftColumnNames]);
const displayOutputColumnNames = useMemo(
() => resolveDataGridOutputColumnNames(
@@ -1087,6 +1114,8 @@ const DataGrid: React.FC<DataGridProps> = ({
const gridCssText = useMemo(
() => buildDataGridCssText({
darkMode,
// 固定列实心底必须跟表格内容底一致,禁止写死 #141414 / #1f1f1f
bgContent,
dataGridBackdropFilter,
dataTableVerticalBorderRule,
densityParams,
@@ -1275,7 +1304,8 @@ const DataGrid: React.FC<DataGridProps> = ({
resolveNextGridFilterOperatorForColumnChange,
});
const columnHeaderFilterEnabled = exportScope === 'table' && !!onApplyFilter;
// 表数据页服务端筛选onApplyFilter查询结果页列头筛选 + 客户端过滤当前结果
const columnHeaderFilterEnabled = !!onApplyFilter || exportScope === 'queryResult';
const columnHeaderFilterOpOptions = useMemo(
() => filterOpOptions.filter((option) => option.value !== 'CUSTOM'),
[filterOpOptions],
@@ -1323,6 +1353,7 @@ const DataGrid: React.FC<DataGridProps> = ({
columnMetaTooltipColor={columnMetaTooltipColor}
darkMode={darkMode}
highlighted={highlightedColumnName === normalizedName}
pinnedLeft={pinnedLeftColumnSet.has(normalizedName)}
translate={translateDataGrid}
onOpenForeignKey={foreignKeyTarget ? () => openForeignKeyTarget(foreignKeyTarget) : undefined}
columnFilter={columnFilterState ? {
@@ -1366,11 +1397,11 @@ const DataGrid: React.FC<DataGridProps> = ({
isListOp,
isNoValueOp,
openForeignKeyTarget,
pinnedLeftColumnSet,
showColumnComment,
showColumnType,
translateDataGrid,
]);
const selectedRowKeysRef = useRef(selectedRowKeys);
const displayDataRef = useRef<any[]>([]);
@@ -1618,8 +1649,13 @@ const DataGrid: React.FC<DataGridProps> = ({
), [data, isMongoDBConnection]);
const displayData = useMemo(() => {
return [...baseData, ...addedRows];
}, [baseData, addedRows]);
const rows = [...baseData, ...addedRows];
// 查询结果页没有服务端 WHERE 回调时,用列头筛选条件在客户端过滤当前结果集
if (exportScope === 'queryResult' && filterConditions.length > 0) {
return filterRowsByGridConditions(rows, filterConditions);
}
return rows;
}, [addedRows, baseData, exportScope, filterConditions]);
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
@@ -2464,6 +2500,7 @@ const DataGrid: React.FC<DataGridProps> = ({
manualWidth: columnWidths[key],
density: dataTableDensity,
}),
...(pinnedLeftColumnSet.has(key) ? { fixed: 'left' as const } : {}),
sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false,
sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined,
editable: canModifyData && isWritableResultColumn(key, effectiveEditLocator),
@@ -2524,7 +2561,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [canModifyData, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, renderColumnTitle, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
}, [canModifyData, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);
@@ -2701,6 +2738,15 @@ const DataGrid: React.FC<DataGridProps> = ({
};
}), [closeVirtualInlineEditor, columns, currentConnConfig, darkMode, dbType, deletedRowKeys, displayColumnTypeMap, enableInlineEditableCell, enableVirtual, form, handleCellSave, handleSharedCellContextMenu, handleVirtualCellActivate, inputCellPadding, lockVirtualInlineTableScroll, modifiedColumns, openCellEditor, rowKeyStr, saveVirtualInlineEditor, updateFocusedCell, useInlineEditableBodyCell, virtualCellWrapperStyle, virtualEditingCell]);
const rowNumberColumnWidth = useMemo(() => {
const manual = columnWidths[GONAVI_ROW_NUMBER_COLUMN_KEY];
if (typeof manual === 'number' && Number.isFinite(manual) && manual > 0) {
// 序号列允许拖宽,但保持较窄下限,避免挤占过多数据列
return Math.max(28, Math.min(120, Math.round(manual)));
}
return ROW_NUMBER_COLUMN_WIDTH;
}, [columnWidths]);
const rowNumberColumn = useMemo<ColumnType<any>>(() => ({
title: (
<div
@@ -2720,30 +2766,37 @@ const DataGrid: React.FC<DataGridProps> = ({
textAlign: 'center',
}}
>
{/* 序号列默认固定,不展示 📌,仅用户钉住的数据列显示图标 */}
<span aria-label={translateDataGrid('data_grid.aria.row_number')}>#</span>
</div>
),
key: GONAVI_ROW_NUMBER_COLUMN_KEY,
dataIndex: GONAVI_ROW_NUMBER_COLUMN_KEY,
width: ROW_NUMBER_COLUMN_WIDTH,
width: rowNumberColumnWidth,
className: 'data-grid-row-number-cell',
align: 'center',
ellipsis: true,
// 横向滚动时固定在左侧;支持拖拽调宽,不参与排序
fixed: 'left' as const,
sorter: false,
ellipsis: false,
onHeaderCell: () => ({
// 无 id → 不参与列拖拽排序;有 width + onResizeStart → 可拖列宽
className: 'data-grid-row-number-cell ant-table-cell-fix-left',
width: rowNumberColumnWidth,
onResizeStart: handleResizeStart(GONAVI_ROW_NUMBER_COLUMN_KEY),
onResizeAutoFit: handleResizeAutoFit(GONAVI_ROW_NUMBER_COLUMN_KEY),
style: {
textAlign: 'center' as const,
paddingInline: 2,
verticalAlign: 'middle' as const,
width: ROW_NUMBER_COLUMN_WIDTH,
minWidth: ROW_NUMBER_COLUMN_WIDTH,
maxWidth: ROW_NUMBER_COLUMN_WIDTH,
width: rowNumberColumnWidth,
minWidth: 28,
},
}),
onCell: () => ({
style: {
width: ROW_NUMBER_COLUMN_WIDTH,
minWidth: ROW_NUMBER_COLUMN_WIDTH,
maxWidth: ROW_NUMBER_COLUMN_WIDTH,
width: rowNumberColumnWidth,
minWidth: 28,
paddingInline: 2,
textAlign: 'center' as const,
},
@@ -2758,23 +2811,28 @@ const DataGrid: React.FC<DataGridProps> = ({
</span>
);
},
}), [pagination?.current, pagination?.pageSize]);
}), [handleResizeAutoFit, handleResizeStart, pagination?.current, pagination?.pageSize, rowNumberColumnWidth]);
const tableColumns = useMemo(() => {
const baseColumns = resolvedShowRowNumberColumn
? [rowNumberColumn, ...mergedColumns]
: mergedColumns;
// 少列时把视口多余宽度只加到数据列,避免行号列被 rc-table 均摊撑宽
const fixedKeys = [
...(resolvedShowRowNumberColumn ? [GONAVI_ROW_NUMBER_COLUMN_KEY] : []),
...pinnedLeftColumnNames,
];
return absorbExtraWidthIntoFlexibleColumns({
columns: baseColumns,
selectionColumnWidth,
tableViewportWidth,
fixedColumnKeys: resolvedShowRowNumberColumn ? [GONAVI_ROW_NUMBER_COLUMN_KEY] : [],
fixedColumnKeys: fixedKeys,
defaultColumnWidth: densityParams.defaultColumnWidth,
});
}, [
densityParams.defaultColumnWidth,
mergedColumns,
pinnedLeftColumnNames,
resolvedShowRowNumberColumn,
rowNumberColumn,
selectionColumnWidth,
@@ -3221,6 +3279,8 @@ const DataGrid: React.FC<DataGridProps> = ({
supportsSqlQueryExport,
tableName,
toggleColumnVisibility,
pinnedLeftColumnNames,
setTablePinnedLeftColumns,
translateDataGrid,
uniqueKeyGroups,
withSortBufferTuningSQL,
@@ -3314,6 +3374,8 @@ const DataGrid: React.FC<DataGridProps> = ({
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
// 与行号列一起左侧固定,横向滚动时仍可勾选/识别行
fixed: true as const,
...(isV2Ui ? {} : {
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
@@ -3382,12 +3444,20 @@ const DataGrid: React.FC<DataGridProps> = ({
const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => {
const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
// 虚拟表数据区横向靠 marginLeft 驱动rc-virtual-list 约定),必须以此为准
if (innerEl instanceof HTMLElement) {
return Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
}
return headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
}, [resolveVirtualHorizontalElements]);
/**
* 虚拟表横滚视觉同步:
* - 表体filler marginLeft = -offset固定列用 --gn-datagrid-h-scroll 做 translateX 补偿
* filler 有 transform:translateYsticky 在表体不可靠)
* - 表头:真实 scrollLeft + sticky 固定全选/行号(不要对 header table 做 transform
* 否则会把全选 checkbox / # 裁没或钉飞)
*/
const syncVirtualHorizontalVisualOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) {
@@ -3398,12 +3468,35 @@ const DataGrid: React.FC<DataGridProps> = ({
const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset));
const currentOffset = Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
const nextMarginLeft = `${-clampedOffset}px`;
const scrollVar = `${clampedOffset}px`;
if (innerEl.style.marginLeft !== nextMarginLeft) {
innerEl.style.marginLeft = nextMarginLeft;
}
if (headerEl instanceof HTMLElement && Math.abs(headerEl.scrollLeft - clampedOffset) > 1) {
headerEl.scrollLeft = clampedOffset;
// 仅表体固定列补偿用;表头走 sticky不要挂 transform
tableContainer.style.setProperty('--gn-datagrid-h-scroll', scrollVar);
holderEl.style.setProperty('--gn-datagrid-h-scroll', scrollVar);
innerEl.style.setProperty('--gn-datagrid-h-scroll', scrollVar);
if (headerEl instanceof HTMLElement) {
headerEl.style.removeProperty('--gn-datagrid-h-scroll');
const headerTable = headerEl.querySelector('table') as HTMLElement | null;
if (headerTable) {
if (headerTable.style.marginLeft) {
headerTable.style.marginLeft = '';
}
// 去掉历史 transform 方案残留
if (headerTable.style.transform) {
headerTable.style.transform = '';
}
headerTable.style.setProperty('width', `${tableScrollX}px`, 'important');
headerTable.style.setProperty('min-width', `${tableScrollX}px`, 'important');
headerTable.style.setProperty('max-width', 'none', 'important');
}
if (Math.abs(headerEl.scrollLeft - clampedOffset) > 1) {
headerEl.scrollLeft = clampedOffset;
}
}
return { holderEl, clampedOffset, currentOffset };
@@ -3419,20 +3512,33 @@ const DataGrid: React.FC<DataGridProps> = ({
const deltaX = clampedOffset - currentOffset;
if (Math.abs(deltaX) < 0.5 && !options?.forceInternalScroll) return true;
const reassertVisual = () => {
syncVirtualHorizontalVisualOffset(tableContainer, clampedOffset);
};
const tableInstance = tableRef.current;
if (tableInstance && typeof tableInstance.scrollTo === 'function') {
// 更新 rc-virtual-list 内部 offsetLeft
tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });
// antd onInternalScroll 会 forceScroll(header.scrollLeft),需在其后清掉并重申 CSS 变量
requestAnimationFrame(() => {
reassertVisual();
window.setTimeout(reassertVisual, 0);
});
return true;
}
// 回退:通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
// 回退:合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state
holderEl.dispatchEvent(new WheelEvent('wheel', {
deltaX: deltaX,
deltaY: 0,
bubbles: true,
cancelable: true,
}));
requestAnimationFrame(() => {
reassertVisual();
window.setTimeout(reassertVisual, 0);
});
return true;
}, [syncVirtualHorizontalVisualOffset]);
@@ -3464,6 +3570,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [applyVirtualHorizontalOffset, enableVirtual, isTableSurfaceActive, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]);
const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => {
tableHorizontalWheelRafRef.current = null;
const delta = pendingTableHorizontalDeltaRef.current;
@@ -4024,8 +4131,8 @@ const DataGrid: React.FC<DataGridProps> = ({
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');
headerTable.style.setProperty('min-width', `${tableScrollX}px`, 'important');
headerTable.style.setProperty('max-width', 'none', 'important');
}
};
syncHeaderWidth();
@@ -4507,6 +4614,7 @@ const DataGrid: React.FC<DataGridProps> = ({
panelPaddingY,
panelRadius,
pendingChangeCount,
pinnedLeftColumnSet,
pkColumns,
prefersManualTotalCount,
previewModalOpen,

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Input, Popover, Select, Tooltip } from 'antd';
import { FilterOutlined, LinkOutlined } from '@ant-design/icons';
import { FilterOutlined, LinkOutlined, PushpinOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string;
@@ -49,6 +49,8 @@ export interface DataGridColumnTitleProps {
columnMetaTooltipColor: string;
darkMode: boolean;
highlighted?: boolean;
/** 左侧钉住列:表头显示固定图标 */
pinnedLeft?: boolean;
translate?: DataGridColumnTitleTranslate;
onOpenForeignKey?: () => void;
columnFilter?: DataGridColumnFilterConfig | null;
@@ -69,6 +71,7 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
columnMetaTooltipColor,
darkMode,
highlighted = false,
pinnedLeft = false,
translate = defaultTranslate,
onOpenForeignKey,
columnFilter,
@@ -81,6 +84,18 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
const shouldShowColumnType = showColumnType && columnType.length > 0;
const shouldShowColumnComment = showColumnComment && columnComment.length > 0;
const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment;
const pinIcon = pinnedLeft ? (
<PushpinOutlined
data-grid-column-pinned-icon="true"
aria-label={translate('data_grid.context_menu.pin_column_left')}
title={translate('data_grid.context_menu.pin_column_left')}
style={{
flex: 'none',
fontSize: Math.max(11, metaFontSize),
color: darkMode ? 'rgba(250, 204, 21, 0.92)' : 'rgba(202, 138, 4, 0.95)',
}}
/>
) : null;
const [filterPopoverOpen, setFilterPopoverOpen] = React.useState(false);
const initialFilterOperator = columnFilter?.initialOperator || columnFilter?.defaultOperator || '=';
const [draftFilterOperator, setDraftFilterOperator] = React.useState(initialFilterOperator);
@@ -136,13 +151,19 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
cursor: 'pointer',
}}
>
{pinIcon}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{normalizedName}
</span>
<LinkOutlined style={{ fontSize: metaFontSize + 1, color: columnMetaHintColor, flex: 'none' }} />
</button>
) : (
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 0, maxWidth: '100%' }}>
{pinIcon}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>
{normalizedName}
</span>
</span>
);
const titleNode = (
@@ -375,7 +396,8 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
className="gn-v2-column-title-shell"
style={{
display: 'inline-flex',
alignItems: 'center',
// 顶对齐有无注释时标题块高度不同center 会让筛选图标上下错位
alignItems: 'flex-start',
gap: 4,
maxWidth: '100%',
minWidth: 0,
@@ -409,6 +431,8 @@ const DataGridColumnTitle: React.FC<DataGridColumnTitleProps> = ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
// 与列名首行光学对齐(约 1.2 行高与 22px 按钮的中线差)
marginTop: 0,
padding: 0,
border: columnFilter.active ? `1px solid ${activeColor}` : '1px solid transparent',
borderRadius: 6,

View File

@@ -693,8 +693,15 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
return <th ref={ref} {...restProps} style={nextStyle} />;
}
// 缩放手柄 absolute 定位需要 relative 上下文;
// 固定列表头的 sticky 由 CSS !important 覆盖,不会被这里的 relative 破坏。
const thStyle: React.CSSProperties = {
...nextStyle,
position: 'relative',
};
return (
<th ref={ref} {...restProps} style={{ ...nextStyle, position: 'relative' }}>
<th ref={ref} {...restProps} style={thStyle}>
{restProps.children}
<span
className="react-resizable-handle"
@@ -724,7 +731,8 @@ const ResizableTitle = React.forwardRef<HTMLTableCellElement, any>((props, ref)
top: 0,
width: 10,
cursor: 'col-resize',
zIndex: 10,
// 必须低于固定列表头 z-index(30),否则横向滚动时会穿透到勾选/行号上方
zIndex: 2,
touchAction: 'none'
}}
/>
@@ -779,20 +787,20 @@ const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props)
isDragging,
} = useSortable({ id: id || '' });
// 未拖拽时不要写 transform/willChange否则会破坏表头 sticky 固定列(全选 / 行号)
const dndTransform = isDragging ? CSS.Transform.toString(transform) : undefined;
const style: React.CSSProperties = {
...propStyle,
transform: CSS.Transform.toString(transform),
...(dndTransform ? { transform: dndTransform, willChange: 'transform' as const } : {}),
transition,
...(isDragging ? {
position: 'relative',
zIndex: 9999,
opacity: 0.6,
...(isDragging ? {
position: 'relative',
zIndex: 9999,
opacity: 0.6,
backgroundColor: 'rgba(24, 144, 255, 0.15)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
} : {}),
touchAction: 'none',
willChange: 'transform',
// 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制
cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer',
};
@@ -802,18 +810,27 @@ const SortableHeaderCell: React.FC<SortableHeaderCellProps> = React.memo((props)
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
// 选择列 / 无 id保留 propStyle含 sticky left不要被 dnd 样式污染
if (!id || id === 'GONAVI_SELECTION_COLUMN') {
return <ResizableTitle {...restProps} style={{ ...propStyle, ...style }}>{children}</ResizableTitle>;
return (
<ResizableTitle
{...restProps}
className={propClassName}
style={propStyle}
>
{children}
</ResizableTitle>
);
}
return (
<ResizableTitle
ref={setNodeRef}
style={style}
<ResizableTitle
ref={setNodeRef}
style={style}
className={`${propClassName || ''} ${isDragging ? 'is-dragging' : ''}`}
data-cursor-grabbing={isDragging || isPressed}
{...restProps}
{...attributes}
{...restProps}
{...attributes}
{...listeners}
onPointerDown={(e: any) => {
setIsPressed(true);

View File

@@ -243,6 +243,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (props) => {
panelPaddingY,
panelRadius,
pendingChangeCount,
pinnedLeftColumnSet,
pkColumns,
prefersManualTotalCount,
previewModalOpen,
@@ -873,6 +874,8 @@ const renderDataTableView = () => (
sortOrder={(activeSort?.order === 'ascend' || activeSort?.order === 'descend') ? activeSort.order : null}
showColumnType={showColumnType}
showColumnComment={showColumnComment}
pinnedLeft={Boolean(pinnedLeftColumnSet?.has?.(fieldName))}
canPinLeft={Boolean(connectionId && dbName && tableName)}
onAction={handleV2ColumnHeaderContextMenuAction}
/>
);

View File

@@ -646,6 +646,8 @@ export type V2ColumnHeaderContextMenuActionKey =
| 'sort-desc'
| 'clear-sort'
| 'auto-fit-column'
| 'pin-column-left'
| 'unpin-column-left'
| 'hide-column'
| 'show-column-type'
| 'hide-column-type'
@@ -660,6 +662,8 @@ export const V2ColumnHeaderContextMenuView: React.FC<{
sortOrder?: 'ascend' | 'descend' | null;
showColumnType?: boolean;
showColumnComment?: boolean;
pinnedLeft?: boolean;
canPinLeft?: boolean;
onAction?: (action: V2ColumnHeaderContextMenuActionKey) => void;
}> = ({
fieldName,
@@ -669,6 +673,8 @@ export const V2ColumnHeaderContextMenuView: React.FC<{
sortOrder,
showColumnType = true,
showColumnComment = true,
pinnedLeft = false,
canPinLeft = true,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
@@ -708,6 +714,15 @@ export const V2ColumnHeaderContextMenuView: React.FC<{
<div className="gn-v2-context-menu-section-title">{t('data_grid.context_menu.column_display_section')}</div>
{renderItems([
{ action: 'auto-fit-column', icon: <ColumnWidthOutlined />, title: t('data_grid.context_menu.auto_fit_column') },
{
action: pinnedLeft ? 'unpin-column-left' : 'pin-column-left',
icon: <PushpinOutlined />,
title: pinnedLeft
? t('data_grid.context_menu.unpin_column_left')
: t('data_grid.context_menu.pin_column_left'),
selected: pinnedLeft,
disabled: !canPinLeft && !pinnedLeft,
},
{ action: 'hide-column', icon: <EyeInvisibleOutlined />, title: t('data_grid.context_menu.hide_column') },
{
action: showColumnType ? 'hide-column-type' : 'show-column-type',

View File

@@ -68,7 +68,10 @@ export const calculateVirtualTableScrollX = ({
return safeTotalWidth;
};
export type AbsorbExtraColumnWidthOptions<T extends { key?: string | number; width?: number | string }> = {
/** 兼容 antd ColumnType.keyReact.Key = string | number | bigint */
export type AbsorbExtraColumnKey = string | number | bigint;
export type AbsorbExtraColumnWidthOptions<T extends { key?: AbsorbExtraColumnKey; width?: number | string }> = {
columns: T[];
selectionColumnWidth: number;
tableViewportWidth: number;
@@ -81,7 +84,7 @@ export type AbsorbExtraColumnWidthOptions<T extends { key?: string | number; wid
* 少字段时 rc-table 会把 scroll.x 扩到视口并均摊拉宽所有列。
* 将「视口 声明总宽」的差额加到最后一个非固定数据列,避免行号/勾选列被一起撑宽。
*/
export const absorbExtraWidthIntoFlexibleColumns = <T extends { key?: string | number; width?: number | string }>({
export const absorbExtraWidthIntoFlexibleColumns = <T extends { key?: AbsorbExtraColumnKey; width?: number | string }>({
columns,
selectionColumnWidth,
tableViewportWidth,

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { resolveDataTableColumnWidth } from '../utils/dataGridDisplay';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { DEFAULT_GRID_MONO_FONT_FAMILY } from './DataGridCore';
import { DEFAULT_GRID_MONO_FONT_FAMILY, GONAVI_ROW_NUMBER_COLUMN_KEY } from './DataGridCore';
const ROW_NUMBER_DEFAULT_WIDTH = 36;
const ROW_NUMBER_MIN_WIDTH = 28;
const ROW_NUMBER_MAX_WIDTH = 120;
type UseDataGridColumnResizeContext = Record<string, any>;
@@ -48,10 +52,14 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
isResizingRef.current = true;
const startX = e.clientX;
const currentWidth = resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
density: dataTableDensity,
});
// 序号列默认宽度与数据列不同,不能走 density 默认列宽
const isRowNumberColumn = key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const currentWidth = isRowNumberColumn
? (typeof columnWidths[key] === 'number' && columnWidths[key] > 0 ? columnWidths[key] : ROW_NUMBER_DEFAULT_WIDTH)
: resolveDataTableColumnWidth({
manualWidth: columnWidths[key],
density: dataTableDensity,
});
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
draggingRef.current = { startX, startWidth: currentWidth, key, containerLeft };
latestClientXRef.current = startX;
@@ -161,10 +169,15 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 序号列双击还原默认窄宽,不按数据内容撑开
if (key === GONAVI_ROW_NUMBER_COLUMN_KEY) {
setColumnWidths((prev: Record<string, number>) => ({ ...prev, [key]: ROW_NUMBER_DEFAULT_WIDTH }));
return;
}
const handleEl = e.currentTarget as HTMLElement | null;
const headerEl = handleEl?.closest('th') as HTMLElement | null;
autoFitColumnWidth(key, headerEl);
}, [autoFitColumnWidth]);
}, [autoFitColumnWidth, setColumnWidths]);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!draggingRef.current) return;
@@ -178,7 +191,10 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
const { startX, startWidth, key } = draggingRef.current;
const deltaX = e.clientX - startX;
const newWidth = Math.max(50, startWidth + deltaX);
const isRowNumberColumn = key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const minWidth = isRowNumberColumn ? ROW_NUMBER_MIN_WIDTH : 50;
const maxWidth = isRowNumberColumn ? ROW_NUMBER_MAX_WIDTH : Number.POSITIVE_INFINITY;
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
setColumnWidths((prev: Record<string, number>) => ({ ...prev, [key]: newWidth }));

View File

@@ -102,6 +102,8 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => {
supportsSqlQueryExport,
tableName,
toggleColumnVisibility,
pinnedLeftColumnNames,
setTablePinnedLeftColumns,
translateDataGrid,
uniqueKeyGroups,
withSortBufferTuningSQL,
@@ -134,6 +136,24 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
case 'auto-fit-column':
autoFitColumnWidth(columnName);
break;
case 'pin-column-left': {
if (!connectionId || !dbName || !tableName) {
void message.info(translateDataGrid('data_grid.message.pin_column_unavailable'));
break;
}
const nextPinned = [
...pinnedLeftColumnNames.filter((col: string) => col !== columnName),
columnName,
];
setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
break;
}
case 'unpin-column-left': {
if (!connectionId || !dbName || !tableName) break;
const nextPinned = pinnedLeftColumnNames.filter((col: string) => col !== columnName);
setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
break;
}
case 'hide-column':
if (displayColumnNames.length <= 1) {
void message.info(translateDataGrid('data_grid.message.keep_one_visible_column'));
@@ -162,10 +182,15 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
autoFitColumnWidth,
cellContextMenu.dataIndex,
cellContextMenu.title,
connectionId,
copyToClipboard,
dbName,
displayColumnNames.length,
handleCopyColumnData,
pinnedLeftColumnNames,
setQueryOptions,
setTablePinnedLeftColumns,
tableName,
translateDataGrid,
toggleColumnVisibility,
]);