diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 8887b51..cc16b52 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -237,13 +237,17 @@ vi.mock('antd', () => { ); - 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 ; - }, + }); + 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 () => { diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 04f5de0..de07fca 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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(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 = ('); expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)'); expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n { + 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();"); }); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 790fc42..90ccc5b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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(); const objectCellPreviewCache = new WeakMap(); @@ -392,6 +395,33 @@ export const attachDataGridVirtualEditRenderVersion = ( }); }; +export const attachDataGridDisplayRenderVersion = ( + 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)[DATA_GRID_DISPLAY_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[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)[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 = ({ data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, @@ -1432,6 +1480,7 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ 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(() => { if (editLocator) return editLocator; if (pkColumns.length === 0) return undefined; @@ -1664,7 +1722,7 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ const rootRef = useRef(null); const containerRef = useRef(null); const tableContainerRef = useRef(null); + const tableRef = useRef(null); const tableScrollTargetsRef = useRef([]); const externalHorizontalScrollRef = useRef(null); const virtualHorizontalElementsRef = useRef<{ @@ -2359,8 +2418,8 @@ const DataGrid: React.FC = ({ } .${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 = ({ 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 = ({ 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
通过 min-width:100% 拉伸到视口, 使 header 列宽与虚拟 body 单元格宽度精确一致 */ @@ -4202,12 +4277,17 @@ const DataGrid: React.FC = ({ }, [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 = ({ return { rowKey, dataIndex, record }; }, []); + const handleSharedCellContextMenu = useCallback((event: React.MouseEvent) => { + 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) => { if (!dataPanelOpenRef.current) return; const cellInfo = resolveRenderedCellInfoFromElement(event.target); @@ -4310,12 +4399,19 @@ const DataGrid: React.FC = ({ ? 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 = ({ 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 = ({ 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 = ({ 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 = ({ if (isVirtualInlineEditingCell && virtualEditable) { return (
handleVirtualCellContextMenu(e, record, dataIndex)} > @@ -4829,7 +4923,7 @@ const DataGrid: React.FC = ({ ) : ( { void saveVirtualInlineEditor(); }} onBlur={() => { void saveVirtualInlineEditor(); }} onFocus={(e) => { @@ -4853,15 +4947,21 @@ const DataGrid: React.FC = ({
); } + if (shouldUsePlainVirtualContent) { + return originalRenderContent; + } return
{originalRenderContent}
; } if (enableVirtual) { + if (shouldUsePlainVirtualContent) { + return originalRenderContent; + } return
{originalRenderContent}
; } 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 = ({ selectedRowKeys, onChange: setSelectedRowKeys, columnWidth: selectionColumnWidth, - renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => ( -
- {originNode} -
- ), - }), [selectedRowKeys, selectionColumnWidth]); + ...(isV2Ui ? {} : { + renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => ( +
+ {originNode} +
+ ), + }), + }), [isV2Ui, selectedRowKeys, selectionColumnWidth]); const rowPropsFactory = useCallback((record: any) => ({ record } as any), []); @@ -5922,8 +6024,14 @@ const DataGrid: React.FC = ({ }); }, [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 = {}; // 虚拟表模式下 render() 已返回 EditableCell;这里再挂 body.cell 会形成双层包装, @@ -5961,27 +6069,51 @@ const DataGrid: React.FC = ({ 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 = ({ cancelable: true, })); return true; - }, [resolveVirtualHorizontalElements, tableScrollX]); + }, [syncVirtualHorizontalVisualOffset]); const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => { tableHorizontalWheelRafRef.current = null; @@ -6002,18 +6134,16 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft; horizontalSyncSourceRef.current = ''; }); - }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset]); + }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) useEffect(() => { @@ -6661,9 +6809,13 @@ const DataGrid: React.FC = ({
= ({ rowKey={GONAVI_ROW_KEY} pagination={false} onChange={handleTableChange} + rowHoverable={!enableVirtual} bordered rowSelection={rowSelectionConfig} rowClassName={rowClassName} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f718843..2eda355 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index e094165..0f5b1ff 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -275,6 +275,52 @@ const DraggableTabNode: React.FC = ({ node }) => { }); }; +const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => { + if (tab.type === 'query') { + return ; + } + if (tab.type === 'table') { + return ; + } + if (tab.type === 'design') { + return ; + } + if (tab.type === 'redis-keys') { + return ; + } + if (tab.type === 'redis-command') { + return ; + } + if (tab.type === 'redis-monitor') { + return ; + } + if (tab.type === 'trigger') { + return ; + } + if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') { + return ; + } + if (tab.type === 'table-overview') { + return ; + } + if (tab.type === 'jvm-overview') { + return ; + } + if (tab.type === 'jvm-resource') { + return ; + } + if (tab.type === 'jvm-audit') { + return ; + } + if (tab.type === 'jvm-diagnostic') { + return ; + } + if (tab.type === 'jvm-monitoring') { + return ; + } + 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 = ; - } else if (tab.type === 'table') { - content = ; - } else if (tab.type === 'design') { - content = ; - } else if (tab.type === 'redis-keys') { - content = ; - } else if (tab.type === 'redis-command') { - content = ; - } else if (tab.type === 'redis-monitor') { - content = ; - } else if (tab.type === 'trigger') { - content = ; - } else if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') { - content = ; - } else if (tab.type === 'table-overview') { - content = ; - } else if (tab.type === 'jvm-overview') { - content = ; - } else if (tab.type === 'jvm-resource') { - content = ; - } else if (tab.type === 'jvm-audit') { - content = ; - } else if (tab.type === 'jvm-diagnostic') { - content = ; - } else if (tab.type === 'jvm-monitoring') { - content = ; - } const menuItems: MenuProps['items'] = [ { @@ -472,7 +488,7 @@ const TabManager: React.FC = React.memo(() => { ), key: tab.id, closable: !isV2Ui, - children: content, + children: , }; }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]); diff --git a/frontend/src/components/dataGridLayout.test.ts b/frontend/src/components/dataGridLayout.test.ts index 7ddb04f..3210acd 100644 --- a/frontend/src/components/dataGridLayout.test.ts +++ b/frontend/src/components/dataGridLayout.test.ts @@ -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, diff --git a/frontend/src/components/dataGridLayout.ts b/frontend/src/components/dataGridLayout.ts index 74fc1ab..a8fc830 100644 --- a/frontend/src/components/dataGridLayout.ts +++ b/frontend/src/components/dataGridLayout.ts @@ -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, diff --git a/frontend/src/dev/PerfDataGridHarness.tsx b/frontend/src/dev/PerfDataGridHarness.tsx index 4b617a0..31902a4 100644 --- a/frontend/src/dev/PerfDataGridHarness.tsx +++ b/frontend/src/dev/PerfDataGridHarness.tsx @@ -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 & { [GONAVI_ROW_KEY]: string; }; +type HarnessRuntimeConfig = { + uiVersion: HarnessUiVersion; + density: DataTableDensity; + theme: HarnessTheme; + uiScale: number; + fontSize: number; +}; + +type HarnessRestoreSnapshot = { + appearance: ReturnType['appearance']; + theme: ReturnType['theme']; + uiScale: number; + fontSize: number; + bodyUiVersion: string | null; + bodyTheme: string | null; + bodyFontSize: string; + rootVars: Record; +}; + +const hasHarnessAppearanceDrift = ( + appearance: ReturnType['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(initialConfig.uiVersion); + const [density, setDensity] = useState(initialConfig.density); + const restoreSnapshotRef = useRef(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 (
@@ -90,6 +287,14 @@ const PerfDataGridHarness: React.FC = () => { > DataGrid 性能复现页 + setUiVersion(value as HarnessUiVersion)} + options={[ + { label: '旧版 UI', value: 'legacy' }, + { label: '新版 UI', value: 'v2' }, + ]} + /> { style={{ width: 140 }} onChange={(value) => setDensity(value)} options={[ - { value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '标准' }, - { value: 'spacious', label: '宽松' }, + { value: 'standard', label: '紧凑' }, + { value: 'compact', label: '极紧凑' }, ]} />