mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-31 10:19:36 +08:00
⚡️ perf(ui): 优化数据页滚动与编辑响应
- 优化 DataGrid 虚拟滚动横向同步与外部滚动条宽度计算 - 降低 v2 数据表内容容器的重绘与持久化写入开销 - 拆分 Tab 内容渲染并收敛 QueryEditor 对活跃标签的订阅 - 修复虚拟编辑态与单元格右键菜单的共享渲染路径 - 调整 v2 数据表编辑态样式并补齐性能复现 harness 对照能力 - 补充 DataGrid 布局与滚动相关回归测试
This commit is contained in:
@@ -237,13 +237,17 @@ vi.mock('antd', () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
Table: (props: any) => {
|
||||
const MockTable = React.forwardRef((_props: any, _ref) => {
|
||||
const props = _props;
|
||||
const { columns } = props;
|
||||
testRenderState.latestColumns = Array.isArray(columns) ? columns : [];
|
||||
testRenderState.latestTableProps = props;
|
||||
return <table />;
|
||||
},
|
||||
});
|
||||
MockTable.displayName = 'MockTable';
|
||||
|
||||
return {
|
||||
Table: MockTable,
|
||||
message: messageApi,
|
||||
Input,
|
||||
Button,
|
||||
@@ -719,12 +723,25 @@ describe('DataGrid DDL interactions', () => {
|
||||
|
||||
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
|
||||
const cellProps = idColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1 });
|
||||
const contextTarget = {
|
||||
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
|
||||
? {
|
||||
getAttribute: (name: string) => {
|
||||
if (name === 'data-row-key') return 'row-1';
|
||||
if (name === 'data-col-name') return 'id';
|
||||
return null;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
} as unknown as HTMLElement;
|
||||
await act(async () => {
|
||||
cellProps.onContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 160,
|
||||
clientY: 120,
|
||||
currentTarget: contextTarget,
|
||||
target: contextTarget,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -803,12 +820,25 @@ describe('DataGrid DDL interactions', () => {
|
||||
|
||||
const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
|
||||
const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' });
|
||||
const contextTarget = {
|
||||
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
|
||||
? {
|
||||
getAttribute: (name: string) => {
|
||||
if (name === 'data-row-key') return 'row-1';
|
||||
if (name === 'data-col-name') return 'name';
|
||||
return null;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
} as unknown as HTMLElement;
|
||||
await act(async () => {
|
||||
cellProps.onContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 160,
|
||||
clientY: 120,
|
||||
currentTarget: contextTarget,
|
||||
target: contextTarget,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -827,6 +857,8 @@ describe('DataGrid DDL interactions', () => {
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 160,
|
||||
clientY: 120,
|
||||
currentTarget: contextTarget,
|
||||
target: contextTarget,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
|
||||
@@ -369,10 +369,13 @@ describe('DataGrid layout', () => {
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('virtualHorizontalElementsRef');
|
||||
expect(source).toContain('type VirtualTableScrollReference = TableReference & {');
|
||||
expect(source).toContain('const tableRef = useRef<VirtualTableScrollReference | null>(null);');
|
||||
expect(source).toContain('resolveDataGridHorizontalWheelDelta({');
|
||||
expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback');
|
||||
expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;');
|
||||
expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });');
|
||||
expect(source).toContain('if (externalSyncRafRef.current !== null)');
|
||||
expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback');
|
||||
@@ -388,11 +391,33 @@ describe('DataGrid layout', () => {
|
||||
expect(source).toContain('const attachDataGridVirtualEditRenderVersion = <T extends Item>(');
|
||||
expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)');
|
||||
expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n <EditableCell');
|
||||
expect(source).toContain("content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};");
|
||||
expect(source).toContain("contain-intrinsic-size: ${useAggressiveVirtualPaintHints ? '24px 160px' : 'auto'};");
|
||||
expect(source).toContain("contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};");
|
||||
expect(source).toContain("content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'};");
|
||||
expect(source).toContain("content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};");
|
||||
expect(source).toContain("contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};");
|
||||
expect(source).toContain("const useVirtualHolderPaintHints = !isMacLike && !isV2Ui;");
|
||||
expect(source).toContain("const useVirtualCellContentContain = false;");
|
||||
expect(source).toContain("const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;");
|
||||
expect(source).toContain("contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};");
|
||||
expect(source).toContain('const handleSharedCellContextMenu = useCallback');
|
||||
expect(source).toContain('const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;');
|
||||
expect(source).toContain('if (shouldUsePlainVirtualContent) {');
|
||||
expect(source).toContain('return originalRenderContent;');
|
||||
expect(source).toContain('if (scrollSnapshotRafRef.current !== null) return;');
|
||||
expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame');
|
||||
expect(source).toContain("const dataGridBackdropFilter = isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
|
||||
expect(source).toContain("const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
|
||||
expect(source).toContain('rowHoverable={!enableVirtual}');
|
||||
});
|
||||
|
||||
it('keeps the DataGrid performance harness aligned with legacy and v2 comparison controls', () => {
|
||||
const harnessSource = readFileSync(new URL('../dev/PerfDataGridHarness.tsx', import.meta.url), 'utf8');
|
||||
expect(harnessSource).toContain("options={[");
|
||||
expect(harnessSource).toContain("{ label: '旧版 UI', value: 'legacy' }");
|
||||
expect(harnessSource).toContain("{ label: '新版 UI', value: 'v2' }");
|
||||
expect(harnessSource).toContain("{ value: 'comfortable', label: '标准' }");
|
||||
expect(harnessSource).toContain("{ value: 'standard', label: '紧凑' }");
|
||||
expect(harnessSource).toContain("{ value: 'compact', label: '极紧凑' }");
|
||||
expect(harnessSource).toContain("document.body.setAttribute('data-ui-version', uiVersion);");
|
||||
expect(harnessSource).toContain("if (value === null || value === undefined || value === '') {");
|
||||
expect(harnessSource).toContain("const currentState = useStore.getState();");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
|
||||
import type { Reference as TableReference } from 'rc-table';
|
||||
import { CloseOutlined, ConsoleSqlOutlined, CopyOutlined, EditOutlined, ExportOutlined, FileTextOutlined, LeftOutlined, RightOutlined, SearchOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
|
||||
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
|
||||
import {
|
||||
calculateExternalHorizontalScrollInnerWidth,
|
||||
calculateTableBodyBottomPadding,
|
||||
calculateVirtualTableScrollX,
|
||||
resolveDataGridHorizontalWheelDelta,
|
||||
@@ -178,6 +180,7 @@ const CELL_KEY_SEP = '\u0001';
|
||||
const CELL_SELECTION_DRAG_THRESHOLD_PX = 4;
|
||||
const DATE_TIME_CACHE_LIMIT = 2000;
|
||||
const TABLE_CELL_PREVIEW_MAX_CHARS = 240;
|
||||
const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION');
|
||||
const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION');
|
||||
const normalizedDateTimeCache = new Map<string, string>();
|
||||
const objectCellPreviewCache = new WeakMap<object, string>();
|
||||
@@ -392,6 +395,33 @@ export const attachDataGridVirtualEditRenderVersion = <T extends Item>(
|
||||
});
|
||||
};
|
||||
|
||||
export const attachDataGridDisplayRenderVersion = <T extends Item>(
|
||||
rows: T[],
|
||||
renderVersion: string,
|
||||
): T[] => {
|
||||
if (!renderVersion) return rows;
|
||||
|
||||
return rows.map((row) => {
|
||||
if (!row || typeof row !== 'object') return row;
|
||||
const nextRow = { ...(row as object) } as T;
|
||||
Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, {
|
||||
value: renderVersion,
|
||||
enumerable: false,
|
||||
});
|
||||
return nextRow;
|
||||
});
|
||||
};
|
||||
|
||||
export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
|
||||
const nextVersion = nextRecord && typeof nextRecord === 'object'
|
||||
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_DISPLAY_RENDER_VERSION]
|
||||
: undefined;
|
||||
const previousVersion = previousRecord && typeof previousRecord === 'object'
|
||||
? (previousRecord as Record<symbol, unknown>)[DATA_GRID_DISPLAY_RENDER_VERSION]
|
||||
: undefined;
|
||||
return nextVersion !== previousVersion;
|
||||
};
|
||||
|
||||
export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
|
||||
const nextVersion = nextRecord && typeof nextRecord === 'object'
|
||||
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION]
|
||||
@@ -1214,6 +1244,10 @@ type ForeignKeyTarget = {
|
||||
constraintName: string;
|
||||
};
|
||||
|
||||
type VirtualTableScrollReference = TableReference & {
|
||||
scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void;
|
||||
};
|
||||
|
||||
const EXACT_GRID_FILTER_OPERATOR = '=';
|
||||
const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS';
|
||||
const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = {
|
||||
@@ -1419,6 +1453,20 @@ const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
};
|
||||
const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 };
|
||||
const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
minHeight: 'calc(28px * var(--gn-ui-scale, 1))',
|
||||
height: 'calc(28px * var(--gn-ui-scale, 1))',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
|
||||
@@ -1432,6 +1480,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const uiScale = useStore(state => state.uiScale);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const tableColumnOrders = useStore(state => state.tableColumnOrders);
|
||||
@@ -1449,12 +1498,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const isV2Ui = appearance?.uiVersion === 'v2';
|
||||
const effectiveUiScale = Math.min(1.25, Math.max(0.8, Number(uiScale) || 1));
|
||||
const activeShortcutPlatform = useMemo(() => getShortcutPlatform(isMacLike), [isMacLike]);
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const useAggressiveVirtualPaintHints = !isMacLike;
|
||||
const dataGridBackdropFilter = isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');
|
||||
const useVirtualHolderPaintHints = !isMacLike && !isV2Ui;
|
||||
const useVirtualRowCellContain = !isMacLike && !isV2Ui;
|
||||
const useVirtualCellContentContain = false;
|
||||
const useVirtualEditablePaintContain = !isMacLike && !isV2Ui;
|
||||
const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;
|
||||
const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');
|
||||
const showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true;
|
||||
const dataTableDensity = appearance.dataTableDensity;
|
||||
const densityParams = useMemo(() => getDensityParams(dataTableDensity), [dataTableDensity]);
|
||||
@@ -1467,13 +1521,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}), [densityParams]);
|
||||
contain: useVirtualCellContentContain ? 'layout style' : undefined,
|
||||
}), [densityParams, useVirtualCellContentContain]);
|
||||
const headerCellMinHeight = densityParams.headerMinHeight;
|
||||
const inputCellPadding: React.CSSProperties = { padding: densityParams.inputCellPadding };
|
||||
const dataTableVerticalBorderColor = resolveDataTableVerticalBorderColor({
|
||||
darkMode,
|
||||
visible: showDataTableVerticalBorders,
|
||||
});
|
||||
const dataTableVerticalBorderRule = showDataTableVerticalBorders
|
||||
? `1px solid ${dataTableVerticalBorderColor}`
|
||||
: 'none';
|
||||
const effectiveEditLocator = useMemo<EditRowLocator | undefined>(() => {
|
||||
if (editLocator) return editLocator;
|
||||
if (pkColumns.length === 0) return undefined;
|
||||
@@ -1664,7 +1722,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
panelFrameColor: darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)',
|
||||
floatingScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)',
|
||||
floatingScrollbarThumbBorderColor: darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)',
|
||||
floatingScrollbarThumbShadow: isMacLike ? 'none' : (darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'),
|
||||
floatingScrollbarThumbShadow: (isMacLike || isV2Ui) ? 'none' : (darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'),
|
||||
verticalScrollbarTrackBg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
|
||||
horizontalScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)',
|
||||
toolbarDividerColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)',
|
||||
@@ -1696,7 +1754,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
paginationActiveItemBorderColor: darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)',
|
||||
paginationActiveItemTextColor: darkMode ? '#fff7d6' : '#0958d9',
|
||||
};
|
||||
}, [darkMode, opacity, resolvedAppearance.blur, isMacLike]);
|
||||
}, [darkMode, opacity, resolvedAppearance.blur, isMacLike, isV2Ui]);
|
||||
|
||||
// 解构常用变量以保持后续代码引用不变
|
||||
const {
|
||||
@@ -1819,6 +1877,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableRef = useRef<VirtualTableScrollReference | null>(null);
|
||||
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
|
||||
const externalHorizontalScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtualHorizontalElementsRef = useRef<{
|
||||
@@ -2359,8 +2418,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
.${gridId} .ant-table-tbody > tr > td,
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell,
|
||||
.${gridId} .ant-table-tbody-virtual-holder .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 ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !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 ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; }
|
||||
.${gridId} .ant-table-tbody-virtual-holder .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: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !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: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; }
|
||||
.${gridId} .ant-table-tbody > tr > td:last-child,
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child,
|
||||
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child,
|
||||
@@ -2472,17 +2531,33 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
padding-bottom: ${tableBodyBottomPadding}px;
|
||||
box-sizing: border-box;
|
||||
scroll-padding-bottom: ${tableBodyBottomPadding}px;
|
||||
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
|
||||
content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};
|
||||
contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'};
|
||||
content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'};
|
||||
}
|
||||
.${gridId} .ant-table-tbody-virtual-holder-inner {
|
||||
padding-bottom: ${tableBodyBottomPadding}px;
|
||||
box-sizing: border-box;
|
||||
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
|
||||
contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'};
|
||||
}
|
||||
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row,
|
||||
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
|
||||
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'none'};
|
||||
contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};
|
||||
}
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody > tr > td,
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell,
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-cell-row-hover,
|
||||
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.data-grid-virtual-inline-editing {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
.${gridId} .data-grid-table-wrap {
|
||||
width: 100%;
|
||||
@@ -2505,14 +2580,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
min-height: 20px;
|
||||
padding-right: 0;
|
||||
position: relative;
|
||||
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
|
||||
contain: ${useVirtualEditablePaintContain ? 'layout paint style' : 'layout style'};
|
||||
}
|
||||
.${gridId} .editable-cell-value-wrap > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap {
|
||||
content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};
|
||||
contain-intrinsic-size: ${useAggressiveVirtualPaintHints ? '24px 160px' : 'auto'};
|
||||
content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};
|
||||
contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};
|
||||
}
|
||||
/* 虚拟表列对齐:阻止 header <table> 通过 min-width:100% 拉伸到视口,
|
||||
使 header 列宽与虚拟 body 单元格宽度精确一致 */
|
||||
@@ -4202,12 +4277,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}, [mergedDisplayData, rowKeyStr]);
|
||||
|
||||
const resolveRenderedCellInfoFromElement = useCallback((target: EventTarget | null) => {
|
||||
const element = target instanceof HTMLElement ? target.closest('[data-row-key][data-col-name]') as HTMLElement | null : null;
|
||||
const closestSource = target && typeof target === 'object' && 'closest' in target
|
||||
? target as { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null }
|
||||
: null;
|
||||
const element = typeof closestSource?.closest === 'function'
|
||||
? closestSource.closest('[data-row-key][data-col-name]')
|
||||
: null;
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const rowKey = String(element.getAttribute('data-row-key') || '').trim();
|
||||
const dataIndex = String(element.getAttribute('data-col-name') || '').trim();
|
||||
const rowKey = String(element.getAttribute?.('data-row-key') || '').trim();
|
||||
const dataIndex = String(element.getAttribute?.('data-col-name') || '').trim();
|
||||
if (!rowKey || !dataIndex) {
|
||||
return null;
|
||||
}
|
||||
@@ -4218,6 +4298,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return { rowKey, dataIndex, record };
|
||||
}, []);
|
||||
|
||||
const handleSharedCellContextMenu = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
const eventTarget = (event.currentTarget as EventTarget | null) ?? event.target;
|
||||
const cellInfo = resolveRenderedCellInfoFromElement(eventTarget);
|
||||
if (!cellInfo) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showCellContextMenu(event, cellInfo.record, cellInfo.dataIndex, cellInfo.dataIndex);
|
||||
}, [resolveRenderedCellInfoFromElement, showCellContextMenu]);
|
||||
|
||||
const handleVirtualTableClickCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dataPanelOpenRef.current) return;
|
||||
const cellInfo = resolveRenderedCellInfoFromElement(event.target);
|
||||
@@ -4310,12 +4399,19 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
? activePageFindMatchIndex + 1
|
||||
: 0;
|
||||
|
||||
const displayRenderVersion = useMemo(() => (
|
||||
`${isV2Ui ? 'v2' : 'legacy'}|${theme}|${dataTableDensity}|${effectiveUiScale}`
|
||||
), [dataTableDensity, effectiveUiScale, isV2Ui, theme]);
|
||||
|
||||
const tableRenderData = useMemo(
|
||||
() => attachDataGridVirtualEditRenderVersion(
|
||||
attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
|
||||
attachDataGridDisplayRenderVersion(
|
||||
attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
|
||||
displayRenderVersion,
|
||||
),
|
||||
virtualEditingCell,
|
||||
),
|
||||
[mergedDisplayData, normalizedPageFindText, virtualEditingCell]
|
||||
[displayRenderVersion, mergedDisplayData, normalizedPageFindText, virtualEditingCell]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -4655,6 +4751,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
shouldCellUpdate: (record: Item, prevRecord: Item) => {
|
||||
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
|
||||
if (rowKeyChanged) return true;
|
||||
if (hasDataGridDisplayRenderVersionChanged(record, prevRecord)) return true;
|
||||
if (hasDataGridFindRenderVersionChanged(record, prevRecord)) return true;
|
||||
if (hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)) return true;
|
||||
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
|
||||
@@ -4728,17 +4825,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cellProps.deletedRowKeys = deletedRowKeys;
|
||||
cellProps.darkMode = darkMode;
|
||||
} else if (enableVirtual) {
|
||||
// 虚拟表格主要走 table 容器级事件委托;这里保留一个共享右键入口,兼容测试桩和非标准事件分发场景。
|
||||
cellProps.onContextMenu = (e: React.MouseEvent) => {
|
||||
handleVirtualCellContextMenu(e, record, dataIndex);
|
||||
};
|
||||
// 虚拟表格主要走容器级事件委托;这里保留共享 handler,
|
||||
// 兼容测试桩与非标准事件分发,同时避免为每个单元格创建闭包。
|
||||
cellProps.onContextMenu = handleSharedCellContextMenu;
|
||||
} else {
|
||||
// 不可编辑(只读查询结果):只绑定右键菜单
|
||||
cellProps.onContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showCellContextMenu(e, record, dataIndex, dataIndex);
|
||||
};
|
||||
// 不可编辑(只读查询结果):共享右键菜单 handler,减少单元格闭包。
|
||||
cellProps.onContextMenu = handleSharedCellContextMenu;
|
||||
}
|
||||
return cellProps;
|
||||
},
|
||||
@@ -4755,6 +4847,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const modifiedStyle: React.CSSProperties | undefined = isModifiedCell
|
||||
? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' }
|
||||
: undefined;
|
||||
const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;
|
||||
if (enableVirtual && enableInlineEditableCell) {
|
||||
const pickerType = getTemporalPickerType(columnType);
|
||||
const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || '')));
|
||||
@@ -4763,7 +4856,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (isVirtualInlineEditingCell && virtualEditable) {
|
||||
return (
|
||||
<div
|
||||
style={virtualCellStyle}
|
||||
style={modifiedStyle ? { ...VIRTUAL_EDITING_CELL_STYLE, ...modifiedStyle } : VIRTUAL_EDITING_CELL_STYLE}
|
||||
className="data-grid-virtual-inline-editing"
|
||||
onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)}
|
||||
>
|
||||
<Form.Item style={{ margin: 0, width: '100%' }} name={getCellFieldName(record, dataIndex)}>
|
||||
@@ -4829,7 +4923,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
) : (
|
||||
<Input
|
||||
ref={virtualInlineInputRef}
|
||||
style={inputCellPadding}
|
||||
style={{ width: '100%', ...inputCellPadding }}
|
||||
onPressEnter={() => { void saveVirtualInlineEditor(); }}
|
||||
onBlur={() => { void saveVirtualInlineEditor(); }}
|
||||
onFocus={(e) => {
|
||||
@@ -4853,15 +4947,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (shouldUsePlainVirtualContent) {
|
||||
return originalRenderContent;
|
||||
}
|
||||
return <div style={virtualCellStyle}>{originalRenderContent}</div>;
|
||||
}
|
||||
if (enableVirtual) {
|
||||
if (shouldUsePlainVirtualContent) {
|
||||
return originalRenderContent;
|
||||
}
|
||||
return <div style={virtualCellWrapperStyle}>{originalRenderContent}</div>;
|
||||
}
|
||||
return originalRenderContent;
|
||||
}
|
||||
};
|
||||
}), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleVirtualCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]);
|
||||
}), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleSharedCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
@@ -5901,12 +6001,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{originNode}
|
||||
</div>
|
||||
),
|
||||
}), [selectedRowKeys, selectionColumnWidth]);
|
||||
...(isV2Ui ? {} : {
|
||||
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{originNode}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
}), [isV2Ui, selectedRowKeys, selectionColumnWidth]);
|
||||
|
||||
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
|
||||
|
||||
@@ -5922,8 +6024,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}, [totalWidth, isMacLike, tableViewportWidth]);
|
||||
const horizontalScrollVisible = isTableSurfaceActive && tableScrollX > tableViewportWidth + 1;
|
||||
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
|
||||
const horizontalScrollWidth = useMemo(() => calculateExternalHorizontalScrollInnerWidth({
|
||||
tableScrollWidth: tableScrollX,
|
||||
trackInset: floatingScrollbarInset,
|
||||
}), [tableScrollX, floatingScrollbarInset]);
|
||||
const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]);
|
||||
const virtualListItemHeight = useMemo(() => (
|
||||
isV2Ui ? Math.max(24, Math.round(28 * effectiveUiScale)) : undefined
|
||||
), [effectiveUiScale, isV2Ui]);
|
||||
const tableComponents = useMemo(() => {
|
||||
const body: Record<string, any> = {};
|
||||
// 虚拟表模式下 render() 已返回 EditableCell;这里再挂 body.cell 会形成双层包装,
|
||||
@@ -5961,27 +6069,51 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => {
|
||||
const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
|
||||
const marginLeft = innerEl ? Math.abs(parseFloat(innerEl.style.marginLeft) || 0) : 0;
|
||||
const headerLeft = headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
|
||||
return Math.max(marginLeft, headerLeft);
|
||||
if (innerEl instanceof HTMLElement) {
|
||||
return Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
|
||||
}
|
||||
return headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
|
||||
}, [resolveVirtualHorizontalElements]);
|
||||
|
||||
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
|
||||
const { holderEl, innerEl } = resolveVirtualHorizontalElements(tableContainer);
|
||||
const syncVirtualHorizontalVisualOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
|
||||
const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
|
||||
if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth);
|
||||
const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset));
|
||||
const currentOffset = Math.abs(parseFloat(innerEl.style.marginLeft) || 0);
|
||||
const currentOffset = Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
|
||||
const nextMarginLeft = `${-clampedOffset}px`;
|
||||
|
||||
if (innerEl.style.marginLeft !== nextMarginLeft) {
|
||||
innerEl.style.marginLeft = nextMarginLeft;
|
||||
}
|
||||
if (headerEl instanceof HTMLElement && Math.abs(headerEl.scrollLeft - clampedOffset) > 1) {
|
||||
headerEl.scrollLeft = clampedOffset;
|
||||
}
|
||||
|
||||
return { holderEl, clampedOffset, currentOffset };
|
||||
}, [resolveVirtualHorizontalElements, tableScrollX]);
|
||||
|
||||
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
|
||||
const synced = syncVirtualHorizontalVisualOffset(tableContainer, nextOffset);
|
||||
if (!synced) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { holderEl, clampedOffset, currentOffset } = synced;
|
||||
const deltaX = clampedOffset - currentOffset;
|
||||
if (Math.abs(deltaX) < 0.5) return true;
|
||||
|
||||
// 通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state,
|
||||
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
|
||||
// 不直接操作 DOM marginLeft,避免 React re-render 覆盖。
|
||||
const tableInstance = tableRef.current;
|
||||
if (tableInstance && typeof tableInstance.scrollTo === 'function') {
|
||||
tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退:通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state,
|
||||
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
|
||||
holderEl.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaX: deltaX,
|
||||
deltaY: 0,
|
||||
@@ -5989,7 +6121,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cancelable: true,
|
||||
}));
|
||||
return true;
|
||||
}, [resolveVirtualHorizontalElements, tableScrollX]);
|
||||
}, [syncVirtualHorizontalVisualOffset]);
|
||||
|
||||
const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => {
|
||||
tableHorizontalWheelRafRef.current = null;
|
||||
@@ -6002,18 +6134,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const currentOffset = readVirtualHorizontalOffset(tableContainer);
|
||||
applyVirtualHorizontalOffset(tableContainer, currentOffset + delta);
|
||||
requestAnimationFrame(() => {
|
||||
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
|
||||
lastTableScrollLeftRef.current = nextScrollLeft;
|
||||
const externalScroll = externalHorizontalScrollRef.current;
|
||||
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
|
||||
externalScroll.scrollLeft = nextScrollLeft;
|
||||
lastExternalScrollLeftRef.current = nextScrollLeft;
|
||||
}
|
||||
if (pendingTableHorizontalDeltaRef.current === 0 && tableHorizontalWheelRafRef.current === null) {
|
||||
horizontalSyncSourceRef.current = '';
|
||||
}
|
||||
});
|
||||
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
|
||||
lastTableScrollLeftRef.current = nextScrollLeft;
|
||||
const externalScroll = externalHorizontalScrollRef.current;
|
||||
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
|
||||
externalScroll.scrollLeft = nextScrollLeft;
|
||||
lastExternalScrollLeftRef.current = nextScrollLeft;
|
||||
}
|
||||
if (pendingTableHorizontalDeltaRef.current === 0 && tableHorizontalWheelRafRef.current === null) {
|
||||
horizontalSyncSourceRef.current = '';
|
||||
}
|
||||
}, [applyVirtualHorizontalOffset, readVirtualHorizontalOffset]);
|
||||
|
||||
const scheduleVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement, delta: number) => {
|
||||
@@ -6180,10 +6310,23 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(lastExternalScrollLeftRef.current - externalScroll.scrollLeft) < 1) {
|
||||
const tableContainer = tableContainerRef.current;
|
||||
let nextExternalScrollLeft = externalScroll.scrollLeft;
|
||||
if (enableVirtual && tableContainer instanceof HTMLElement) {
|
||||
const synced = syncVirtualHorizontalVisualOffset(tableContainer, externalScroll.scrollLeft);
|
||||
if (synced) {
|
||||
nextExternalScrollLeft = synced.clampedOffset;
|
||||
lastTableScrollLeftRef.current = synced.clampedOffset;
|
||||
if (Math.abs(externalScroll.scrollLeft - synced.clampedOffset) > 1) {
|
||||
externalScroll.scrollLeft = synced.clampedOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(lastExternalScrollLeftRef.current - nextExternalScrollLeft) < 1) {
|
||||
return;
|
||||
}
|
||||
lastExternalScrollLeftRef.current = externalScroll.scrollLeft;
|
||||
lastExternalScrollLeftRef.current = nextExternalScrollLeft;
|
||||
if (externalSyncRafRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -6205,7 +6348,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (applied) {
|
||||
// WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref
|
||||
requestAnimationFrame(() => {
|
||||
lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer);
|
||||
const resolvedScrollLeft = readVirtualHorizontalOffset(tableContainer);
|
||||
lastTableScrollLeftRef.current = resolvedScrollLeft;
|
||||
if (Math.abs(latestExternalScroll.scrollLeft - resolvedScrollLeft) > 1) {
|
||||
latestExternalScroll.scrollLeft = resolvedScrollLeft;
|
||||
}
|
||||
lastExternalScrollLeftRef.current = resolvedScrollLeft;
|
||||
horizontalSyncSourceRef.current = '';
|
||||
});
|
||||
return;
|
||||
@@ -6242,7 +6390,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft;
|
||||
horizontalSyncSourceRef.current = '';
|
||||
});
|
||||
}, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset]);
|
||||
}, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]);
|
||||
|
||||
// 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
|
||||
useEffect(() => {
|
||||
@@ -6661,9 +6809,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={displayColumnNames} strategy={horizontalListSortingStrategy}>
|
||||
<Table
|
||||
ref={tableRef}
|
||||
components={tableComponents}
|
||||
dataSource={tableRenderData}
|
||||
columns={mergedColumns}
|
||||
{...(enableVirtual && typeof virtualListItemHeight === 'number'
|
||||
? { listItemHeight: virtualListItemHeight }
|
||||
: {})}
|
||||
showSorterTooltip={{ target: 'sorter-icon' }}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
@@ -6674,6 +6826,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
rowHoverable={!enableVirtual}
|
||||
bordered
|
||||
rowSelection={rowSelectionConfig}
|
||||
rowClassName={rowClassName}
|
||||
|
||||
@@ -862,7 +862,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
|
||||
[activeShortcutPlatform, shortcutOptions],
|
||||
);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const currentSavedQuery = useMemo(() => {
|
||||
@@ -916,7 +915,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
// 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量
|
||||
// 确保 completion provider 始终使用当前活跃 Tab 的上下文
|
||||
useEffect(() => {
|
||||
if (activeTabId !== tab.id) return;
|
||||
if (!isActive) return;
|
||||
sharedCurrentDb = currentDb;
|
||||
sharedCurrentConnectionId = currentConnectionId;
|
||||
sharedConnections = connections;
|
||||
@@ -924,7 +923,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
sharedAllColumnsData = allColumnsRef.current;
|
||||
sharedVisibleDbs = visibleDbsRef.current;
|
||||
sharedColumnsCacheData = columnsCacheRef.current;
|
||||
}, [activeTabId, tab.id, currentDb, currentConnectionId, connections]);
|
||||
}, [isActive, currentDb, currentConnectionId, connections]);
|
||||
|
||||
useEffect(() => {
|
||||
connectionsRef.current = connections;
|
||||
@@ -1011,7 +1010,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
// 存储可见数据库列表用于跨库智能提示
|
||||
visibleDbsRef.current = dbs;
|
||||
if (activeTabId === tab.id) {
|
||||
if (isActive) {
|
||||
sharedVisibleDbs = dbs;
|
||||
}
|
||||
|
||||
@@ -1022,7 +1021,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
} else {
|
||||
visibleDbsRef.current = [];
|
||||
if (activeTabId === tab.id) {
|
||||
if (isActive) {
|
||||
sharedVisibleDbs = [];
|
||||
}
|
||||
setDbList([]);
|
||||
@@ -1110,13 +1109,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
tablesRef.current = allTables;
|
||||
allColumnsRef.current = allColumns;
|
||||
// 如果当前 Tab 是活跃 Tab,同步更新共享变量
|
||||
if (activeTabId === tab.id) {
|
||||
if (isActive) {
|
||||
sharedTablesData = allTables;
|
||||
sharedAllColumnsData = allColumns;
|
||||
}
|
||||
};
|
||||
void fetchMetadata();
|
||||
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
|
||||
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive]); // dbList 变化时触发重新加载
|
||||
|
||||
// Query ID management helpers
|
||||
const setQueryId = (id: string) => {
|
||||
@@ -2504,7 +2503,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectAllInEditor = (event: KeyboardEvent) => {
|
||||
if (activeTabId !== tab.id) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
|
||||
@@ -2540,7 +2539,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleSelectAllInEditor, true);
|
||||
};
|
||||
}, [activeTabId, tab.id]);
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const binding = runQueryShortcutBinding;
|
||||
@@ -2549,7 +2548,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
|
||||
const handleRunShortcut = (event: KeyboardEvent) => {
|
||||
if (activeTabId !== tab.id) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
if (!isShortcutMatch(event, binding.combo)) {
|
||||
@@ -2568,7 +2567,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleRunShortcut, true);
|
||||
};
|
||||
}, [activeTabId, tab.id, runQueryShortcutBinding, handleRun]);
|
||||
}, [isActive, runQueryShortcutBinding, handleRun]);
|
||||
|
||||
// Re-register Monaco internal keybinding when runQuery shortcut changes
|
||||
useEffect(() => {
|
||||
@@ -2637,7 +2636,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
useEffect(() => {
|
||||
const handleRunActiveQuery = () => {
|
||||
if (activeTabId !== tab.id) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
void handleRun();
|
||||
@@ -2647,7 +2646,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener);
|
||||
};
|
||||
}, [activeTabId, tab.id, handleRun]);
|
||||
}, [isActive, handleRun]);
|
||||
|
||||
// 监听由 TabManager 分发的专用注入事件
|
||||
useEffect(() => {
|
||||
@@ -3130,4 +3129,4 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
||||
export default React.memo(QueryEditor);
|
||||
|
||||
@@ -275,6 +275,52 @@ const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
|
||||
if (tab.type === 'query') {
|
||||
return <QueryEditor tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'table') {
|
||||
return <DataViewer tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'design') {
|
||||
return <TableDesigner tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'redis-keys') {
|
||||
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-command') {
|
||||
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-monitor') {
|
||||
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'trigger') {
|
||||
return <TriggerViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') {
|
||||
return <DefinitionViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'table-overview') {
|
||||
return <TableOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-overview') {
|
||||
return <JVMOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-resource') {
|
||||
return <JVMResourceBrowser tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-audit') {
|
||||
return <JVMAuditViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-diagnostic') {
|
||||
return <JVMDiagnosticConsole tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-monitoring') {
|
||||
return <JVMMonitoringDashboard tab={tab} />;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const TabManager: React.FC = React.memo(() => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -398,36 +444,6 @@ const TabManager: React.FC = React.memo(() => {
|
||||
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
|
||||
const hostSummary = resolveConnectionHostSummary(connection?.config);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'design') {
|
||||
content = <TableDesigner tab={tab} />;
|
||||
} else if (tab.type === 'redis-keys') {
|
||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-monitor') {
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') {
|
||||
content = <DefinitionViewer tab={tab} />;
|
||||
} else if (tab.type === 'table-overview') {
|
||||
content = <TableOverview tab={tab} />;
|
||||
} else if (tab.type === 'jvm-overview') {
|
||||
content = <JVMOverview tab={tab} />;
|
||||
} else if (tab.type === 'jvm-resource') {
|
||||
content = <JVMResourceBrowser tab={tab} />;
|
||||
} else if (tab.type === 'jvm-audit') {
|
||||
content = <JVMAuditViewer tab={tab} />;
|
||||
} else if (tab.type === 'jvm-diagnostic') {
|
||||
content = <JVMDiagnosticConsole tab={tab} />;
|
||||
} else if (tab.type === 'jvm-monitoring') {
|
||||
content = <JVMMonitoringDashboard tab={tab} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
@@ -472,7 +488,7 @@ const TabManager: React.FC = React.memo(() => {
|
||||
),
|
||||
key: tab.id,
|
||||
closable: !isV2Ui,
|
||||
children: content,
|
||||
children: <TabContent tab={tab} isActive={tabIsActive} />,
|
||||
};
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateExternalHorizontalScrollInnerWidth,
|
||||
calculateTableBodyBottomPadding,
|
||||
calculateVirtualTableScrollX,
|
||||
resolveDataGridHorizontalWheelDelta,
|
||||
@@ -34,6 +35,18 @@ describe('dataGridLayout helpers', () => {
|
||||
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
|
||||
});
|
||||
|
||||
it('keeps external horizontal scrollbar range aligned with table content range', () => {
|
||||
expect(calculateExternalHorizontalScrollInnerWidth({
|
||||
tableScrollWidth: 4563,
|
||||
trackInset: 10,
|
||||
})).toBe(4543);
|
||||
|
||||
expect(calculateExternalHorizontalScrollInnerWidth({
|
||||
tableScrollWidth: 18,
|
||||
trackInset: 10,
|
||||
})).toBe(1);
|
||||
});
|
||||
|
||||
it('only treats wheel gestures as horizontal when the horizontal intent is strong enough', () => {
|
||||
expect(resolveDataGridHorizontalWheelDelta({
|
||||
deltaX: 18,
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface DataGridHorizontalWheelIntentOptions {
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalHorizontalScrollInnerWidthOptions {
|
||||
tableScrollWidth: number;
|
||||
trackInset: number;
|
||||
}
|
||||
|
||||
const MIN_SCROLLBAR_CLEARANCE = 8;
|
||||
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
|
||||
const HORIZONTAL_WHEEL_MIN_DELTA = 0.5;
|
||||
@@ -55,6 +60,16 @@ export const calculateVirtualTableScrollX = ({
|
||||
return safeTotalWidth;
|
||||
};
|
||||
|
||||
export const calculateExternalHorizontalScrollInnerWidth = ({
|
||||
tableScrollWidth,
|
||||
trackInset,
|
||||
}: ExternalHorizontalScrollInnerWidthOptions): number => {
|
||||
const safeTableScrollWidth = Math.max(0, Math.ceil(tableScrollWidth));
|
||||
const safeTrackInset = Math.max(0, Math.ceil(trackInset));
|
||||
|
||||
return Math.max(1, safeTableScrollWidth - safeTrackInset * 2);
|
||||
};
|
||||
|
||||
export const resolveDataGridHorizontalWheelDelta = ({
|
||||
deltaX,
|
||||
deltaY,
|
||||
|
||||
@@ -1,15 +1,111 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Button, Card, InputNumber, Segmented, Select, Space, Typography } from 'antd';
|
||||
|
||||
import DataGrid, { GONAVI_ROW_KEY } from '../components/DataGrid';
|
||||
import { useStore } from '../store';
|
||||
import type { EditRowLocator } from '../utils/rowLocator';
|
||||
import type { DataTableDensity } from '../utils/dataGridDisplay';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type HarnessUiVersion = 'legacy' | 'v2';
|
||||
type HarnessTheme = 'light' | 'dark';
|
||||
|
||||
type HarnessRow = Record<string, any> & {
|
||||
[GONAVI_ROW_KEY]: string;
|
||||
};
|
||||
|
||||
type HarnessRuntimeConfig = {
|
||||
uiVersion: HarnessUiVersion;
|
||||
density: DataTableDensity;
|
||||
theme: HarnessTheme;
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
type HarnessRestoreSnapshot = {
|
||||
appearance: ReturnType<typeof useStore.getState>['appearance'];
|
||||
theme: ReturnType<typeof useStore.getState>['theme'];
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
bodyUiVersion: string | null;
|
||||
bodyTheme: string | null;
|
||||
bodyFontSize: string;
|
||||
rootVars: Record<string, string>;
|
||||
};
|
||||
|
||||
const hasHarnessAppearanceDrift = (
|
||||
appearance: ReturnType<typeof useStore.getState>['appearance'],
|
||||
uiVersion: HarnessUiVersion,
|
||||
density: DataTableDensity,
|
||||
): boolean => (
|
||||
appearance.uiVersion !== uiVersion
|
||||
|| appearance.dataTableDensity !== density
|
||||
|| appearance.dataTableFontSize !== null
|
||||
|| appearance.dataTableFontSizeFollowGlobal !== true
|
||||
);
|
||||
|
||||
const DEFAULT_HARNESS_CONFIG: HarnessRuntimeConfig = {
|
||||
uiVersion: 'legacy',
|
||||
density: 'comfortable',
|
||||
theme: 'light',
|
||||
uiScale: 1,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const clampHarnessUiScale = (value: unknown): number => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return DEFAULT_HARNESS_CONFIG.uiScale;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.uiScale;
|
||||
return Math.min(1.25, Math.max(0.8, numeric));
|
||||
};
|
||||
|
||||
const clampHarnessFontSize = (value: unknown): number => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return DEFAULT_HARNESS_CONFIG.fontSize;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.fontSize;
|
||||
return Math.min(20, Math.max(12, Math.round(numeric)));
|
||||
};
|
||||
|
||||
const readHarnessRuntimeConfig = (): HarnessRuntimeConfig => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { ...DEFAULT_HARNESS_CONFIG };
|
||||
}
|
||||
try {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const uiVersion = searchParams.get('uiVersion') === 'v2' ? 'v2' : DEFAULT_HARNESS_CONFIG.uiVersion;
|
||||
const densityRaw = searchParams.get('density');
|
||||
const density: DataTableDensity = densityRaw === 'compact' || densityRaw === 'standard'
|
||||
? densityRaw
|
||||
: DEFAULT_HARNESS_CONFIG.density;
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : DEFAULT_HARNESS_CONFIG.theme;
|
||||
return {
|
||||
uiVersion,
|
||||
density,
|
||||
theme,
|
||||
uiScale: clampHarnessUiScale(searchParams.get('uiScale')),
|
||||
fontSize: clampHarnessFontSize(searchParams.get('fontSize')),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_HARNESS_CONFIG };
|
||||
}
|
||||
};
|
||||
|
||||
const DOCUMENT_ROOT_VAR_KEYS = [
|
||||
'--gonavi-font-size',
|
||||
'--gn-ui-scale',
|
||||
'--gn-font-size',
|
||||
'--gn-font-size-sm',
|
||||
'--gn-font-size-xs',
|
||||
'--gn-font-size-mono',
|
||||
'--gn-data-table-font-size',
|
||||
'--gn-sidebar-tree-font-size',
|
||||
] as const;
|
||||
|
||||
const buildHarnessColumns = (count: number): string[] => {
|
||||
const safeCount = Math.max(8, Math.min(64, Math.trunc(count || 0)));
|
||||
return Array.from({ length: safeCount }, (_, index) => {
|
||||
@@ -62,12 +158,113 @@ const HARNESS_EDIT_LOCATOR: EditRowLocator = {
|
||||
};
|
||||
|
||||
const PerfDataGridHarness: React.FC = () => {
|
||||
const initialConfig = useMemo(() => readHarnessRuntimeConfig(), []);
|
||||
const setAppearance = useStore((state) => state.setAppearance);
|
||||
const setTheme = useStore((state) => state.setTheme);
|
||||
const setUiScale = useStore((state) => state.setUiScale);
|
||||
const setFontSize = useStore((state) => state.setFontSize);
|
||||
const [rowCount, setRowCount] = useState(10000);
|
||||
const [columnCount, setColumnCount] = useState(24);
|
||||
const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
|
||||
const [uiVersion, setUiVersion] = useState<HarnessUiVersion>(initialConfig.uiVersion);
|
||||
const [density, setDensity] = useState<DataTableDensity>(initialConfig.density);
|
||||
const restoreSnapshotRef = useRef<HarnessRestoreSnapshot | null>(null);
|
||||
|
||||
const columnNames = useMemo(() => buildHarnessColumns(columnCount), [columnCount]);
|
||||
const data = useMemo(() => buildHarnessData(rowCount, columnNames), [rowCount, columnNames]);
|
||||
const effectiveUiScale = clampHarnessUiScale(initialConfig.uiScale);
|
||||
const effectiveFontSize = clampHarnessFontSize(initialConfig.fontSize);
|
||||
const effectiveDataTableFontSize = effectiveFontSize;
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreSnapshotRef.current) return;
|
||||
const currentState = useStore.getState();
|
||||
restoreSnapshotRef.current = {
|
||||
appearance: { ...currentState.appearance },
|
||||
theme: currentState.theme,
|
||||
uiScale: currentState.uiScale,
|
||||
fontSize: currentState.fontSize,
|
||||
bodyUiVersion: document.body.getAttribute('data-ui-version'),
|
||||
bodyTheme: document.body.getAttribute('data-theme'),
|
||||
bodyFontSize: document.body.style.fontSize,
|
||||
rootVars: Object.fromEntries(
|
||||
DOCUMENT_ROOT_VAR_KEYS.map((key) => [key, document.documentElement.style.getPropertyValue(key)])
|
||||
),
|
||||
};
|
||||
|
||||
return () => {
|
||||
const snapshot = restoreSnapshotRef.current;
|
||||
if (!snapshot) return;
|
||||
useStore.getState().setAppearance(snapshot.appearance);
|
||||
useStore.getState().setTheme(snapshot.theme);
|
||||
useStore.getState().setUiScale(snapshot.uiScale);
|
||||
useStore.getState().setFontSize(snapshot.fontSize);
|
||||
if (snapshot.bodyUiVersion) {
|
||||
document.body.setAttribute('data-ui-version', snapshot.bodyUiVersion);
|
||||
} else {
|
||||
document.body.removeAttribute('data-ui-version');
|
||||
}
|
||||
if (snapshot.bodyTheme) {
|
||||
document.body.setAttribute('data-theme', snapshot.bodyTheme);
|
||||
} else {
|
||||
document.body.removeAttribute('data-theme');
|
||||
}
|
||||
document.body.style.fontSize = snapshot.bodyFontSize;
|
||||
DOCUMENT_ROOT_VAR_KEYS.forEach((key) => {
|
||||
const value = snapshot.rootVars[key];
|
||||
if (value) {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
return;
|
||||
}
|
||||
document.documentElement.style.removeProperty(key);
|
||||
});
|
||||
restoreSnapshotRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentState = useStore.getState();
|
||||
if (hasHarnessAppearanceDrift(currentState.appearance, uiVersion, density)) {
|
||||
setAppearance({
|
||||
uiVersion,
|
||||
dataTableDensity: density,
|
||||
dataTableFontSize: null,
|
||||
dataTableFontSizeFollowGlobal: true,
|
||||
});
|
||||
}
|
||||
if (currentState.theme !== initialConfig.theme) {
|
||||
setTheme(initialConfig.theme);
|
||||
}
|
||||
if (Math.abs(currentState.uiScale - initialConfig.uiScale) > 0.0001) {
|
||||
setUiScale(initialConfig.uiScale);
|
||||
}
|
||||
if (currentState.fontSize !== initialConfig.fontSize) {
|
||||
setFontSize(initialConfig.fontSize);
|
||||
}
|
||||
}, [
|
||||
density,
|
||||
initialConfig.fontSize,
|
||||
initialConfig.theme,
|
||||
initialConfig.uiScale,
|
||||
setAppearance,
|
||||
setFontSize,
|
||||
setTheme,
|
||||
setUiScale,
|
||||
uiVersion,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', initialConfig.theme);
|
||||
document.body.setAttribute('data-ui-version', uiVersion);
|
||||
document.body.style.fontSize = `${effectiveFontSize}px`;
|
||||
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
|
||||
document.documentElement.style.setProperty('--gn-ui-scale', `${effectiveUiScale}`);
|
||||
document.documentElement.style.setProperty('--gn-font-size', `${effectiveFontSize}px`);
|
||||
document.documentElement.style.setProperty('--gn-font-size-sm', `${Math.max(10, Math.round(effectiveFontSize * 0.86))}px`);
|
||||
document.documentElement.style.setProperty('--gn-font-size-xs', `${Math.max(9, Math.round(effectiveFontSize * 0.76))}px`);
|
||||
document.documentElement.style.setProperty('--gn-font-size-mono', `${Math.max(10, Math.round(effectiveDataTableFontSize * 0.92))}px`);
|
||||
document.documentElement.style.setProperty('--gn-data-table-font-size', `${effectiveDataTableFontSize}px`);
|
||||
document.documentElement.style.setProperty('--gn-sidebar-tree-font-size', `${effectiveFontSize}px`);
|
||||
}, [effectiveDataTableFontSize, effectiveFontSize, effectiveUiScale, initialConfig.theme, uiVersion]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', overflow: 'hidden', background: '#0b1220', padding: 16, boxSizing: 'border-box' }}>
|
||||
@@ -90,6 +287,14 @@ const PerfDataGridHarness: React.FC = () => {
|
||||
>
|
||||
<Space wrap align="center" size={12}>
|
||||
<Text strong>DataGrid 性能复现页</Text>
|
||||
<Segmented
|
||||
value={uiVersion}
|
||||
onChange={(value) => setUiVersion(value as HarnessUiVersion)}
|
||||
options={[
|
||||
{ label: '旧版 UI', value: 'legacy' },
|
||||
{ label: '新版 UI', value: 'v2' },
|
||||
]}
|
||||
/>
|
||||
<InputNumber
|
||||
min={200}
|
||||
max={50000}
|
||||
@@ -111,9 +316,9 @@ const PerfDataGridHarness: React.FC = () => {
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setDensity(value)}
|
||||
options={[
|
||||
{ value: 'compact', label: '紧凑' },
|
||||
{ value: 'comfortable', label: '标准' },
|
||||
{ value: 'spacious', label: '宽松' },
|
||||
{ value: 'standard', label: '紧凑' },
|
||||
{ value: 'compact', label: '极紧凑' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -128,7 +333,7 @@ const PerfDataGridHarness: React.FC = () => {
|
||||
type="info"
|
||||
showIcon
|
||||
message="这个页面只用于开发态滚动性能采样"
|
||||
description={`当前 ${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
|
||||
description={`当前 ${uiVersion === 'v2' ? '新版' : '旧版'} UI,${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
|
||||
/>
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<DataGrid
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
createJSONStorage,
|
||||
persist,
|
||||
type PersistStorage,
|
||||
type StateStorage,
|
||||
} from "zustand/middleware";
|
||||
import {
|
||||
ConnectionConfig,
|
||||
ProxyConfig,
|
||||
@@ -76,6 +81,7 @@ const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||||
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
|
||||
const PERSIST_VERSION = 9;
|
||||
const PERSIST_STORAGE_KEY = "lite-db-storage";
|
||||
const PERSIST_WRITE_DEBOUNCE_MS = 160;
|
||||
const MAX_PERSISTED_QUERY_TABS = 20;
|
||||
const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024;
|
||||
const MAX_SQL_LOGS = 1000;
|
||||
@@ -93,6 +99,100 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
password: "",
|
||||
hasPassword: false,
|
||||
};
|
||||
|
||||
const isFrontendTestRuntime = (): boolean => {
|
||||
const env = (import.meta as unknown as { env?: Record<string, unknown> }).env || {};
|
||||
return env.MODE === "test" || env.VITEST === true || env.VITEST === "true";
|
||||
};
|
||||
|
||||
const createDebouncedPersistStorage = <S>(
|
||||
getStorage: () => StateStorage,
|
||||
debounceMs = PERSIST_WRITE_DEBOUNCE_MS,
|
||||
): PersistStorage<S> | undefined => {
|
||||
const baseStorage = createJSONStorage<S>(getStorage);
|
||||
if (!baseStorage || isFrontendTestRuntime()) {
|
||||
return baseStorage;
|
||||
}
|
||||
|
||||
type PersistedValue = Parameters<PersistStorage<S>["setItem"]>[1];
|
||||
let pendingWrite: { name: string; value: PersistedValue } | null = null;
|
||||
let pendingTimer: number | null = null;
|
||||
let listenersBound = false;
|
||||
let pendingResolves: Array<() => void> = [];
|
||||
let pendingRejects: Array<(error: unknown) => void> = [];
|
||||
|
||||
const settlePending = (error?: unknown) => {
|
||||
const resolves = pendingResolves;
|
||||
const rejects = pendingRejects;
|
||||
pendingResolves = [];
|
||||
pendingRejects = [];
|
||||
if (error !== undefined) {
|
||||
rejects.forEach((reject) => reject(error));
|
||||
return;
|
||||
}
|
||||
resolves.forEach((resolve) => resolve());
|
||||
};
|
||||
|
||||
const flushPendingWrite = async (): Promise<void> => {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
const nextWrite = pendingWrite;
|
||||
pendingWrite = null;
|
||||
if (!nextWrite) {
|
||||
settlePending();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await baseStorage.setItem(nextWrite.name, nextWrite.value);
|
||||
settlePending();
|
||||
} catch (error) {
|
||||
settlePending(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const bindFlushListeners = () => {
|
||||
if (listenersBound || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
listenersBound = true;
|
||||
const handleFlush = () => {
|
||||
void flushPendingWrite();
|
||||
};
|
||||
window.addEventListener("pagehide", handleFlush, { capture: true });
|
||||
window.addEventListener("beforeunload", handleFlush, { capture: true });
|
||||
};
|
||||
|
||||
return {
|
||||
getItem: baseStorage.getItem,
|
||||
setItem: (name, value) => {
|
||||
bindFlushListeners();
|
||||
pendingWrite = { name, value };
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingResolves.push(resolve);
|
||||
pendingRejects.push(reject);
|
||||
pendingTimer = window.setTimeout(() => {
|
||||
void flushPendingWrite();
|
||||
}, debounceMs);
|
||||
});
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
pendingWrite = null;
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
settlePending();
|
||||
await baseStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolveOceanBaseProtocol = (
|
||||
raw: Record<string, unknown>,
|
||||
normalizedConnectionParams: string,
|
||||
@@ -2384,6 +2484,7 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
{
|
||||
name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
|
||||
storage: createDebouncedPersistStorage(() => localStorage),
|
||||
version: PERSIST_VERSION,
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = unwrapPersistedAppState(
|
||||
|
||||
@@ -3049,6 +3049,43 @@ body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody .ant-table-row > .a
|
||||
padding: 0 calc(10px * var(--gn-ui-scale, 1)) !important;
|
||||
font-family: var(--gn-font-mono) !important;
|
||||
font-size: var(--gn-data-table-font-size, var(--gn-font-size-mono, 12px)) !important;
|
||||
line-height: calc(28px * var(--gn-ui-scale, 1)) !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody > tr > td .editable-cell-value-wrap,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell .editable-cell-value-wrap {
|
||||
min-height: calc(28px * var(--gn-ui-scale, 1));
|
||||
line-height: calc(28px * var(--gn-ui-scale, 1));
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-row,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control-input,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control-input-content {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 24px !important;
|
||||
min-height: 24px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-input,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-input-affix-wrapper,
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-picker {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
height: 24px !important;
|
||||
min-height: 24px !important;
|
||||
line-height: 24px !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid-statusbar {
|
||||
|
||||
Reference in New Issue
Block a user