mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 12:51:47 +08:00
✨ feat(datagrid): 完善左固定列体验并统一选中主题色
- 虚拟表横滚:表头 sticky + 表体 translateX 补偿,勾选/行号/钉住列同步固定 - 支持列头钉住左侧、钉住列显示图标;序号列可拖宽且不参与排序 - 行选中与单元格选区统一 V2 绿色,固定列底色对齐 --gn-bg-panel - 查询结果页启用列头筛选,并在客户端按条件过滤当前结果集
This commit is contained in:
@@ -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')",
|
||||
|
||||
@@ -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\"]')");
|
||||
|
||||
@@ -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:translateY,sticky 在表体不可靠)
|
||||
* - 表头:真实 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -68,7 +68,10 @@ export const calculateVirtualTableScrollX = ({
|
||||
return safeTotalWidth;
|
||||
};
|
||||
|
||||
export type AbsorbExtraColumnWidthOptions<T extends { key?: string | number; width?: number | string }> = {
|
||||
/** 兼容 antd ColumnType.key(React.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
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user