From 0c8c9a9f1221fbb2adff503f64ce96504b12ca2b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 27 May 2026 08:43:51 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(DataGrid):=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=95=B0=E6=8D=AE=E7=BD=91=E6=A0=BC=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 DataGrid 的筛选、DDL 视图、模态编辑和预览面板状态 - 抽离表头信息、分页栏、视图切换、辅助操作和旧版单元格右键菜单组件 - 优化虚拟单元格渲染判定与横向滚轮意图识别,减少滚动和编辑阶段的无效重绘 - 新增 DataGrid 性能复现页并补齐布局、DDL、列标题与滚动相关测试 --- frontend/src/components/DataGrid.ddl.test.tsx | 52 +- .../src/components/DataGrid.layout.test.tsx | 38 +- frontend/src/components/DataGrid.tsx | 3491 ++++++----------- .../DataGridColumnInfoPopoverContent.tsx | 104 + .../components/DataGridColumnTitle.test.tsx | 70 + .../src/components/DataGridColumnTitle.tsx | 168 + .../DataGridLegacyCellContextMenu.tsx | 183 + frontend/src/components/DataGridModals.tsx | 313 ++ frontend/src/components/DataGridPageFind.tsx | 79 + .../src/components/DataGridPaginationBar.tsx | 126 + .../src/components/DataGridPreviewPanel.tsx | 131 + .../src/components/DataGridRecordViews.tsx | 111 + .../components/DataGridResultViewSwitcher.tsx | 38 + .../components/DataGridSecondaryActions.tsx | 152 + .../src/components/DataGridToolbarFrame.tsx | 731 ++++ .../src/components/DataGridV2DdlWorkspace.tsx | 127 + .../components/DataGridV2MetadataViews.tsx | 92 + .../src/components/dataGridLayout.test.ts | 32 +- frontend/src/components/dataGridLayout.ts | 35 + frontend/src/components/useDataGridDdlView.ts | 194 + .../src/components/useDataGridFilters.tsx | 379 ++ .../src/components/useDataGridModalEditors.ts | 183 + .../src/components/useDataGridPreviewPanel.ts | 100 + frontend/src/dev/PerfDataGridHarness.tsx | 157 + frontend/src/main.tsx | 23 +- 25 files changed, 4780 insertions(+), 2329 deletions(-) create mode 100644 frontend/src/components/DataGridColumnInfoPopoverContent.tsx create mode 100644 frontend/src/components/DataGridColumnTitle.test.tsx create mode 100644 frontend/src/components/DataGridColumnTitle.tsx create mode 100644 frontend/src/components/DataGridLegacyCellContextMenu.tsx create mode 100644 frontend/src/components/DataGridModals.tsx create mode 100644 frontend/src/components/DataGridPageFind.tsx create mode 100644 frontend/src/components/DataGridPaginationBar.tsx create mode 100644 frontend/src/components/DataGridPreviewPanel.tsx create mode 100644 frontend/src/components/DataGridRecordViews.tsx create mode 100644 frontend/src/components/DataGridResultViewSwitcher.tsx create mode 100644 frontend/src/components/DataGridSecondaryActions.tsx create mode 100644 frontend/src/components/DataGridToolbarFrame.tsx create mode 100644 frontend/src/components/DataGridV2DdlWorkspace.tsx create mode 100644 frontend/src/components/DataGridV2MetadataViews.tsx create mode 100644 frontend/src/components/useDataGridDdlView.ts create mode 100644 frontend/src/components/useDataGridFilters.tsx create mode 100644 frontend/src/components/useDataGridModalEditors.ts create mode 100644 frontend/src/components/useDataGridPreviewPanel.ts create mode 100644 frontend/src/dev/PerfDataGridHarness.tsx diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 95c167a..8887b51 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -2,7 +2,12 @@ import React from 'react'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid'; +import DataGrid, { + attachDataGridVirtualEditRenderVersion, + buildDataGridCommitChangeSet, + GONAVI_ROW_KEY, + hasDataGridVirtualEditRenderVersionChanged, +} from './DataGrid'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; const storeState = vi.hoisted(() => ({ @@ -478,6 +483,20 @@ describe('DataGrid commit change set', () => { expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' }); }); + + it('marks the active virtual editing row so shouldCellUpdate can reopen inline editors', () => { + const rows = [ + { [GONAVI_ROW_KEY]: 'row-1', id: 1, name: 'alpha' }, + { [GONAVI_ROW_KEY]: 'row-2', id: 2, name: 'beta' }, + ]; + + const nextRows = attachDataGridVirtualEditRenderVersion(rows, { rowKey: 'row-1', dataIndex: 'name', title: 'name' }); + + expect(nextRows[0]).not.toBe(rows[0]); + expect(nextRows[1]).toBe(rows[1]); + expect(hasDataGridVirtualEditRenderVersionChanged(nextRows[0], rows[0])).toBe(true); + expect(hasDataGridVirtualEditRenderVersionChanged(nextRows[1], rows[1])).toBe(false); + }); }); describe('DataGrid DDL interactions', () => { @@ -633,37 +652,6 @@ describe('DataGrid DDL interactions', () => { }, ); - it('marks v2 table headers as single-line when column type and comment rows are hidden', async () => { - storeState.appearance.uiVersion = 'v2'; - storeState.queryOptions.showColumnComment = false; - storeState.queryOptions.showColumnType = false; - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create( - , - ); - }); - await waitForEffects(); - - const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id'); - expect(idColumn).toBeTruthy(); - expect(idColumn.onHeaderCell(idColumn).className).toContain('is-single-line-title'); - - const headerRenderer = create(<>{idColumn.title}); - expect(headerRenderer.root.findByProps({ 'data-grid-column-title-single-line': 'true' })).toBeTruthy(); - expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-type' })).toHaveLength(0); - expect(headerRenderer.root.findAllByProps({ className: 'gn-v2-column-title-comment' })).toHaveLength(0); - renderer!.unmount(); - }); - it('opens the v2 column header context menu from table headers', async () => { storeState.appearance.uiVersion = 'v2'; storeState.queryOptions.showColumnComment = true; diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 629736b..04f5de0 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -346,17 +346,19 @@ describe('DataGrid layout', () => { it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => { const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); + const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8'); const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - expect(source).toContain('const handleQuickWherePaste = useCallback'); - expect(source).toContain("event.clipboardData.getData('text/plain')"); - expect(source).toContain('const currentValue = input.value ?? quickWhereDraft;'); - expect(source).toContain('event.stopPropagation();'); - expect(source).toContain('data-grid-quick-where-input="true"'); - expect(source).toContain('{...noAutoCapInputProps}'); - expect(source).toContain('onCopy={stopQuickWhereClipboardPropagation}'); - expect(source).toContain('onCut={stopQuickWhereClipboardPropagation}'); - expect(source).toContain('onPaste={handleQuickWherePaste}'); + expect(filterHookSource).toContain('const handleQuickWherePaste = React.useCallback'); + expect(filterHookSource).toContain("event.clipboardData.getData('text/plain')"); + expect(filterHookSource).toContain('const currentValue = input.value ?? quickWhereDraft;'); + expect(filterHookSource).toContain('event.stopPropagation();'); + expect(toolbarSource).toContain('data-grid-quick-where-input="true"'); + expect(toolbarSource).toContain('{...noAutoCapInputProps}'); + expect(toolbarSource).toContain('onCopy={onQuickWhereCopy}'); + expect(toolbarSource).toContain('onCut={onQuickWhereCut}'); + expect(toolbarSource).toContain('onPaste={onQuickWherePaste}'); expect(source).toContain("['c', 'v', 'x'].includes"); expect(css).toContain('[data-grid-quick-where-input="true"]'); expect(css).toContain('font-size: var(--gn-font-size, 14px) !important;'); @@ -367,12 +369,30 @@ describe('DataGrid layout', () => { const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); expect(source).toContain('virtualHorizontalElementsRef'); + 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('if (externalSyncRafRef.current !== null)'); expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame'); + expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback'); + expect(source).toContain('tableTargetSyncRafRef.current = requestAnimationFrame'); + expect(source).toContain("boundHorizontalTargets = externalScroll ? [] : pickHorizontalScrollTargets(tableContainer);"); + expect(source).toContain('const useInlineEditableBodyCell = enableInlineEditableCell && !enableVirtual;'); + expect(source).toContain('if (useInlineEditableBodyCell) {'); + expect(source).toContain('}, areEditableCellPropsEqual);'); + expect(source).toContain('const [virtualEditingCell, setVirtualEditingCell] = useState(null);'); + expect(source).toContain('const openVirtualInlineEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {'); + expect(source).toContain('if (isVirtualInlineEditingCell && virtualEditable) {'); + expect(source).toContain('const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol(\'DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION\');'); + expect(source).toContain('const attachDataGridVirtualEditRenderVersion = ('); + expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)'); + expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n (); const objectCellPreviewCache = new WeakMap(); const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; @@ -353,6 +372,36 @@ const renderCellDisplayValue = (val: any, query: string, columnType?: string): R const formatCellValue = (val: any) => renderCellDisplayValue(val, ''); +export const attachDataGridVirtualEditRenderVersion = ( + rows: T[], + editingCell: VirtualEditingCellState | null, +): T[] => { + if (!editingCell) return rows; + + return rows.map((row) => { + const rowKey = row?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || String(rowKey) !== editingCell.rowKey) { + return row; + } + const nextRow = { ...(row as object) } as T; + Object.defineProperty(nextRow, DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, { + value: `${editingCell.rowKey}${CELL_KEY_SEP}${editingCell.dataIndex}`, + enumerable: false, + }); + return nextRow; + }); +}; + +export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { + const nextVersion = nextRecord && typeof nextRecord === 'object' + ? (nextRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + return nextVersion !== previousVersion; +}; + const toEditableText = (val: any): string => { if (val === null || val === undefined) return ''; if (typeof val === 'string') return val; @@ -696,6 +745,69 @@ interface EditableCellProps { // 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。 let globalDeletedRowKeys: Set = new Set(); +const resolveEditableCellRowKey = ( + record: Item | undefined, + rowKeyStr?: (k: React.Key) => string, +): string | null => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || typeof rowKeyStr !== 'function') { + return null; + } + return rowKeyStr(rowKey); +}; + +const isEditableCellDeleted = ( + record: Item | undefined, + deletedRowKeys?: Set, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!deletedRowKeys?.has(rowKey) : false; +}; + +const isEditableCellModified = ( + record: Item | undefined, + dataIndex: string, + modifiedColumns?: Record>, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!modifiedColumns?.[rowKey]?.has(dataIndex) : false; +}; + +const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: EditableCellProps): boolean => { + if (prevProps.editable !== nextProps.editable) return false; + if (prevProps.dataIndex !== nextProps.dataIndex) return false; + if (prevProps.title !== nextProps.title) return false; + if (prevProps.columnType !== nextProps.columnType) return false; + if (prevProps.darkMode !== nextProps.darkMode) return false; + if (prevProps.as !== nextProps.as) return false; + if (prevProps.handleSave !== nextProps.handleSave) return false; + if (prevProps.focusCell !== nextProps.focusCell) return false; + if ((prevProps.inputCellPadding?.padding ?? null) !== (nextProps.inputCellPadding?.padding ?? null)) return false; + if (prevProps.style !== nextProps.style) return false; + + const prevRecord = prevProps.record; + const nextRecord = nextProps.record; + if (resolveEditableCellRowKey(prevRecord, prevProps.rowKeyStr) !== resolveEditableCellRowKey(nextRecord, nextProps.rowKeyStr)) { + return false; + } + if (hasDataGridFindRenderVersionChanged(nextRecord, prevRecord)) { + return false; + } + if (!isCellValueEqualForRender(prevRecord?.[prevProps.dataIndex], nextRecord?.[nextProps.dataIndex])) { + return false; + } + if (isEditableCellDeleted(prevRecord, prevProps.deletedRowKeys, prevProps.rowKeyStr) !== isEditableCellDeleted(nextRecord, nextProps.deletedRowKeys, nextProps.rowKeyStr)) { + return false; + } + if (isEditableCellModified(prevRecord, prevProps.dataIndex, prevProps.modifiedColumns, prevProps.rowKeyStr) !== isEditableCellModified(nextRecord, nextProps.dataIndex, nextProps.modifiedColumns, nextProps.rowKeyStr)) { + return false; + } + + return true; +}; + const EditableCell: React.FC = React.memo(({ title, editable, @@ -803,7 +915,9 @@ const EditableCell: React.FC = React.memo(({ ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex) : false; - const modifiedStyle: React.CSSProperties = isModified ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } : {}; + const modifiedStyle: React.CSSProperties | undefined = isModified + ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } + : undefined; if (editable) { childNode = editing ? ( @@ -890,7 +1004,7 @@ const EditableCell: React.FC = React.memo(({ ) : (
{children} @@ -899,7 +1013,7 @@ const EditableCell: React.FC = React.memo(({ } else if (cellContextMenuContext) { // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 childNode = ( -
+
{children}
); @@ -939,7 +1053,7 @@ const EditableCell: React.FC = React.memo(({ {childNode} ); -}); +}, areEditableCellPropsEqual); const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { const context = useContext(DataContext); @@ -1081,6 +1195,12 @@ type GridFilterCondition = FilterCondition & { type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; type DdlViewLayoutMode = 'bottom' | 'side'; +type VirtualEditingCellState = { + rowKey: string; + dataIndex: string; + title: React.ReactNode; + columnType?: string; +}; type ColumnMeta = { type: string; @@ -1291,6 +1411,14 @@ export const buildDataGridCommitChangeSet = ({ // P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象 const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' }; +const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + width: '100%', +}; +const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }; const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, @@ -1325,15 +1453,20 @@ const DataGrid: React.FC = ({ 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 showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true; const dataTableDensity = appearance.dataTableDensity; const densityParams = useMemo(() => getDensityParams(dataTableDensity), [dataTableDensity]); const virtualCellWrapperStyle = useMemo(() => ({ margin: -8, padding: densityParams.cellPadding, - display: 'flex', - alignItems: 'center', + display: 'block', minWidth: 0, + width: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }), [densityParams]); const headerCellMinHeight = densityParams.headerMinHeight; const inputCellPadding: React.CSSProperties = { padding: densityParams.inputCellPadding }; @@ -1445,8 +1578,6 @@ const DataGrid: React.FC = ({ // Handle Dragging const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), ); const handleDragEnd = (event: DragEndEvent) => { @@ -1533,7 +1664,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: darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)', + floatingScrollbarThumbShadow: isMacLike ? '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)', @@ -1543,9 +1674,11 @@ const DataGrid: React.FC = ({ paginationShellBorderColor: darkMode ? `rgba(255,255,255,${_glassMode ? 0.10 : 0.08})` : `rgba(16,24,40,${_glassMode ? 0.08 : 0.08})`, - paginationShellShadow: darkMode - ? `0 16px 34px rgba(0,0,0,${_glassMode ? 0.10 : 0.22})` - : `0 14px 30px rgba(15,23,42,${_glassMode ? 0.03 : 0.08})`, + paginationShellShadow: isMacLike + ? 'none' + : (darkMode + ? `0 16px 34px rgba(0,0,0,${_glassMode ? 0.10 : 0.22})` + : `0 14px 30px rgba(15,23,42,${_glassMode ? 0.03 : 0.08})`), paginationChipBg: darkMode ? `rgba(255,255,255,${_glassMode ? Math.max(0.02, opacity * 0.035) : 0.04})` : `rgba(255,255,255,${_glassMode ? Math.max(0.18, opacity * 0.26) : 0.86})`, @@ -1563,7 +1696,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]); + }, [darkMode, opacity, resolvedAppearance.blur, isMacLike]); // 解构常用变量以保持后续代码引用不变 const { @@ -1604,50 +1737,66 @@ const DataGrid: React.FC = ({ const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); const gridId = useMemo(() => `grid-${generateUuid()}`, []); - const [viewMode, setViewMode] = useState('table'); const [textRecordIndex, setTextRecordIndex] = useState(0); - const [cellEditorOpen, setCellEditorOpen] = useState(false); - const [cellEditorValue, setCellEditorValue] = useState(''); - const [cellEditorIsJson, setCellEditorIsJson] = useState(false); - const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null); - const cellEditorApplyRef = useRef<((val: string) => void) | null>(null); - const [jsonEditorOpen, setJsonEditorOpen] = useState(false); - const [jsonEditorValue, setJsonEditorValue] = useState(''); - const [ddlModalOpen, setDdlModalOpen] = useState(false); - const [ddlLoading, setDdlLoading] = useState(false); - const [ddlText, setDdlText] = useState(''); - const [ddlViewLayout, setDdlViewLayout] = useState('bottom'); - const [ddlSidebarWidth, setDdlSidebarWidth] = useState(420); - const [ddlSidebarResizePreviewX, setDdlSidebarResizePreviewX] = useState(null); - const ddlSidebarResizeRef = useRef<{ - startX: number; - startWidth: number; - previewWidth: number; - moveHandler?: (event: MouseEvent) => void; - upHandler?: () => void; - } | null>(null); - const ddlRequestSeqRef = useRef(0); - const isTableSurfaceActive = viewMode === 'table' || (isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side'); - - // --- Data Preview Panel State --- - const [dataPanelOpen, setDataPanelOpen] = useState(false); - const dataPanelOpenRef = useRef(false); - const [focusedCellInfo, setFocusedCellInfo] = useState<{ record: Item; dataIndex: string; title: string } | null>(null); - const [dataPanelValue, setDataPanelValue] = useState(''); - const [dataPanelIsJson, setDataPanelIsJson] = useState(false); - const dataPanelDirtyRef = useRef(false); - const dataPanelOriginalRef = useRef(''); + const { + cellEditorOpen, + cellEditorValue, + setCellEditorValue, + cellEditorIsJson, + cellEditorMeta, + cellEditorApplyRef, + closeCellEditor, + openCellEditor, + jsonEditorOpen, + jsonEditorValue, + setJsonEditorValue, + openJsonEditor, + closeJsonEditor, + rowEditorOpen, + rowEditorRowKey, + rowEditorBaseRawRef, + rowEditorDisplayRef, + rowEditorNullColsRef, + rowEditorForm, + closeRowEditor, + openRowEditor, + batchEditModalOpen, + batchEditValue, + setBatchEditValue, + batchEditSetNull, + setBatchEditSetNull, + openBatchEditModal, + closeBatchEditModal, + } = useDataGridModalEditors({ + toEditableText, + looksLikeJsonText, + }); + const [virtualEditingCell, setVirtualEditingCell] = useState(null); + const virtualInlineInputRef = useRef(null); + const virtualInlinePickerOpenRef = useRef(false); + const virtualInlineScrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); + const { + dataPanelOpen, + dataPanelOpenRef, + focusedCellInfo, + dataPanelValue, + setDataPanelValue, + dataPanelIsJson, + dataPanelDirtyRef, + dataPanelOriginalRef, + toggleDataPanel, + updateFocusedCell, + handleDataPanelFormatJson, + } = useDataGridPreviewPanel({ + toEditableText, + looksLikeJsonText, + normalizeDateTimeString, + }); const focusedCellWritable = useMemo(() => ( canModifyData && !!focusedCellInfo && isWritableResultColumn(focusedCellInfo.dataIndex, effectiveEditLocator) ), [canModifyData, focusedCellInfo, effectiveEditLocator]); - const [rowEditorOpen, setRowEditorOpen] = useState(false); - const [rowEditorRowKey, setRowEditorRowKey] = useState(''); - const rowEditorBaseRawRef = useRef>({}); - const rowEditorDisplayRef = useRef>({}); - const rowEditorNullColsRef = useRef>(new Set()); - const [rowEditorForm] = Form.useForm(); // Cell Context Menu State const [cellContextMenu, setCellContextMenu] = useState<{ @@ -1682,8 +1831,10 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const externalSyncRafRef = useRef(null); + const tableTargetSyncRafRef = useRef(null); const tableHorizontalWheelRafRef = useRef(null); const pendingTableHorizontalDeltaRef = useRef(0); + const pendingTableTargetSyncSourceRef = useRef(null); const scrollSnapshotRafRef = useRef(null); const pendingScrollToBottomRef = useRef(false); const pastedRowSequenceRef = useRef(0); @@ -1695,9 +1846,6 @@ const DataGrid: React.FC = ({ const [selectedCells, setSelectedCells] = useState>(new Set()); const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record } | null>(null); const [copiedRowsForPaste, setCopiedRowsForPaste] = useState>>([]); - const [batchEditModalOpen, setBatchEditModalOpen] = useState(false); - const [batchEditValue, setBatchEditValue] = useState(''); - const [batchEditSetNull, setBatchEditSetNull] = useState(false); // 使用 ref 来优化拖拽性能,完全避免状态更新 const cellSelectionRafRef = useRef(null); @@ -1715,6 +1863,7 @@ const DataGrid: React.FC = ({ const currentSelectionRef = useRef>(new Set()); const selectionStartRef = useRef<{ rowKey: string; colName: string; rowIndex: number; colIndex: number } | null>(null); const rowIndexMapRef = useRef>(new Map()); + const mergedDisplayDataByRowKeyRef = useRef>(new Map()); const scrollTableBodyToBottom = useCallback(() => { const root = containerRef.current; @@ -1729,6 +1878,10 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(externalSyncRafRef.current); externalSyncRafRef.current = null; } + if (tableTargetSyncRafRef.current !== null) { + cancelAnimationFrame(tableTargetSyncRafRef.current); + tableTargetSyncRafRef.current = null; + } if (tableHorizontalWheelRafRef.current !== null) { cancelAnimationFrame(tableHorizontalWheelRafRef.current); tableHorizontalWheelRafRef.current = null; @@ -1738,6 +1891,7 @@ const DataGrid: React.FC = ({ scrollSnapshotRafRef.current = null; } pendingTableHorizontalDeltaRef.current = 0; + pendingTableTargetSyncSourceRef.current = null; }, []); // Close cell context menu when clicking outside @@ -1841,6 +1995,10 @@ const DataGrid: React.FC = ({ const [columnMetaMap, setColumnMetaMap] = useState>({}); const [foreignKeyMap, setForeignKeyMap] = useState>({}); const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]); + const mergedDisplayDataRef = useRef([]); + const closeCellEditModeRef = useRef<() => void>(() => {}); + const formRef = useRef(form); + formRef.current = form; const columnMetaCacheRef = useRef>>({}); const columnMetaSeqRef = useRef(0); const foreignKeyCacheRef = useRef>>({}); @@ -2034,6 +2192,16 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMapByLowerName]); + const displayColumnTypeMap = useMemo(() => { + const next: Record = {}; + displayColumnNames.forEach((columnName) => { + const normalizedName = String(columnName || '').trim(); + if (!normalizedName) return; + next[normalizedName] = columnMetaMap[normalizedName]?.type || columnTypeMapByLowerName[normalizedName.toLowerCase()] || ''; + }); + return next; + }, [displayColumnNames, columnMetaMap, columnTypeMapByLowerName]); + const foreignKeyMapByLowerName = useMemo(() => { const next: Record = {}; Object.entries(foreignKeyMap).forEach(([name, target]) => { @@ -2110,160 +2278,54 @@ const DataGrid: React.FC = ({ const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()]; - const shouldShowColumnType = showColumnType && !!meta?.type; - const shouldShowColumnComment = showColumnComment && !!meta?.comment; - const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment; - const hoverLines: string[] = []; - if (meta?.type) hoverLines.push(`类型:${meta.type}`); - if (meta?.comment) hoverLines.push(`备注:${meta.comment}`); - if (foreignKeyTarget?.refTableName) { - const refColumnText = foreignKeyTarget.refColumnName ? `.${foreignKeyTarget.refColumnName}` : ''; - hoverLines.push(`外键:${foreignKeyTarget.refTableName}${refColumnText}`); - } - const fieldLabel = foreignKeyTarget?.refTableName ? ( - - ) : ( - {normalizedName} - ); - - const titleNode = ( -
- {fieldLabel} - {shouldShowColumnType && ( - - {meta.type} - - )} - {shouldShowColumnComment && ( - - {meta.comment} - - )} -
- ); - - if (hoverLines.length === 0) return titleNode; return ( - {hoverLines.join('\n')}} - styles={{ root: { maxWidth: 640 } }} - {...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})} - > - {titleNode} - + openForeignKeyTarget(foreignKeyTarget) : undefined} + /> ); - }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, foreignKeyMap, foreignKeyMapByLowerName, showColumnComment, showColumnType, densityParams, isV2Ui, openForeignKeyTarget]); + }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, darkMode, densityParams.metaFontSize, foreignKeyMap, foreignKeyMapByLowerName, openForeignKeyTarget, showColumnComment, showColumnType]); - const closeCellEditor = useCallback(() => { - setCellEditorOpen(false); - setCellEditorMeta(null); - setCellEditorValue(''); - setCellEditorIsJson(false); - cellEditorApplyRef.current = null; - }, []); - - // --- Data Preview Panel Helpers --- - const updateFocusedCell = useCallback((record: Item, dataIndex: string) => { - if (!record || !dataIndex) return; - const raw = record?.[dataIndex]; - let text = toEditableText(raw); - // 日期时间字段格式化(处理带时区的 ISO 格式如 2026-03-22T00:00:00+08:00) - if (typeof raw === 'string') { - text = normalizeDateTimeString(raw); + const lockVirtualInlineTableScroll = useCallback((lock: boolean) => { + if (lock) { + if (virtualInlineScrollLockRef.current) { + return; + } + const tableWrapper = tableContainerRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; + if (!tableWrapper) { + return; + } + const handler = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); + virtualInlineScrollLockRef.current = { el: tableWrapper, handler }; + return; } - const isJson = looksLikeJsonText(text); - setFocusedCellInfo({ record, dataIndex, title: dataIndex }); - // 切换到新单元格时总是更新预览值并重置 dirty 标记 - dataPanelOriginalRef.current = text; - setDataPanelValue(text); - setDataPanelIsJson(isJson); - dataPanelDirtyRef.current = false; - }, []); - - const handleDataPanelFormatJson = useCallback(() => { - if (!dataPanelIsJson) return; - try { - const obj = JSON.parse(dataPanelValue); - setDataPanelValue(JSON.stringify(obj, null, 2)); - dataPanelDirtyRef.current = true; - } catch (e: any) { - void message.error('JSON 格式无效:' + (e?.message || String(e))); + if (!virtualInlineScrollLockRef.current) { + return; } - }, [dataPanelIsJson, dataPanelValue]); - - // 同步 ref 用于 onCell 闭包 - useEffect(() => { dataPanelOpenRef.current = dataPanelOpen; }, [dataPanelOpen]); - - const openCellEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode, onApplyValue?: (val: string) => void) => { - if (!record || !dataIndex) return; - const raw = record?.[dataIndex]; - const text = toEditableText(raw); - const isJson = looksLikeJsonText(text); - const titleText = typeof (title as any) === 'string' ? (title as string) : (typeof (title as any) === 'number' ? String(title) : String(dataIndex)); - - setCellEditorMeta({ record, dataIndex, title: titleText }); - setCellEditorValue(text); - setCellEditorIsJson(isJson); - setCellEditorOpen(true); - cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null; + const { el, handler } = virtualInlineScrollLockRef.current; + el.removeEventListener('wheel', handler, { capture: true } as EventListenerOptions); + virtualInlineScrollLockRef.current = null; }, []); + const closeVirtualInlineEditor = useCallback(() => { + lockVirtualInlineTableScroll(false); + virtualInlinePickerOpenRef.current = false; + setVirtualEditingCell(null); + }, [lockVirtualInlineTableScroll]); + // Dynamic Height const [tableHeight, setTableHeight] = useState(500); const [tableViewportWidth, setTableViewportWidth] = useState(0); @@ -2403,16 +2465,24 @@ const DataGrid: React.FC = ({ padding-bottom: ${tableBodyBottomPadding}px; box-sizing: border-box; scroll-padding-bottom: ${tableBodyBottomPadding}px; + contain: layout paint style; } .${gridId} .ant-table-tbody-virtual-holder, .${gridId} .rc-virtual-list-holder { 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'}; } .${gridId} .ant-table-tbody-virtual-holder-inner { padding-bottom: ${tableBodyBottomPadding}px; box-sizing: border-box; + contain: ${useAggressiveVirtualPaintHints ? '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'}; } .${gridId} .data-grid-table-wrap { width: 100%; @@ -2428,6 +2498,22 @@ const DataGrid: React.FC = ({ background: ${darkMode ? 'rgba(246, 196, 83, 0.42)' : 'rgba(255, 193, 7, 0.42)'}; color: inherit; } + .${gridId} .editable-cell-value-wrap { + display: block; + width: 100%; + min-width: 0; + min-height: 20px; + padding-right: 0; + position: relative; + contain: ${useAggressiveVirtualPaintHints ? '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'}; + } /* 虚拟表列对齐:阻止 header 通过 min-width:100% 拉伸到视口, 使 header 列宽与虚拟 body 单元格宽度精确一致 */ .${gridId} .ant-table-header > table { @@ -2526,8 +2612,8 @@ const DataGrid: React.FC = ({ border: 1px solid ${paginationShellBorderColor}; background: ${paginationShellBg}; box-shadow: ${paginationShellShadow}; - backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'}; - -webkit-backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'}; + backdrop-filter: ${dataGridBackdropFilter}; + -webkit-backdrop-filter: ${dataGridBackdropFilter}; } .${gridId} .data-grid-pagination-summary, .${gridId} .data-grid-pagination-page-chip { @@ -2861,142 +2947,57 @@ const DataGrid: React.FC = ({ inserts: string[]; }>({ deletes: [], updates: [], inserts: [] }); - const normalizeFilterLogic = useCallback((logic: unknown): 'AND' | 'OR' => { - return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND'; - }, []); - - // P6 性能优化:使用 ref 缓存首列名,避免 displayColumnNames 变化导致级联更新 - const firstColumnNameRef = useRef(displayColumnNames[0] || ''); - firstColumnNameRef.current = displayColumnNames[0] || ''; - - const normalizeGridFilterConditions = useCallback((conditions?: FilterCondition[]): GridFilterCondition[] => { - if (!Array.isArray(conditions)) return []; - return conditions.map((cond, index) => { - const fallbackId = index + 1; - const nextId = Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : fallbackId; - const op = String(cond?.op || EXACT_GRID_FILTER_OPERATOR); - const rawColumn = String(cond?.column || ''); - return { - id: nextId, - enabled: cond?.enabled !== false, - logic: normalizeFilterLogic(cond?.logic), - column: rawColumn || (op === 'CUSTOM' ? '' : String(firstColumnNameRef.current || '')), - op, - value: String(cond?.value ?? ''), - value2: String(cond?.value2 ?? ''), - }; - }); - }, [normalizeFilterLogic]); - - // Filter State - const [filterConditions, setFilterConditions] = useState([]); - const [nextFilterId, setNextFilterId] = useState(1); - const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition)); - const [quickWhereSuggestionsOpen, setQuickWhereSuggestionsOpen] = useState(false); - const filterPanelRef = useRef(null); - const autoDefaultFilterIdsRef = useRef>(new Set()); - - useEffect(() => { - const nextConditions = normalizeGridFilterConditions(appliedFilterConditions); - autoDefaultFilterIdsRef.current.clear(); - setFilterConditions(nextConditions); - const maxId = nextConditions.reduce((max, cond) => (cond.id > max ? cond.id : max), 0); - setNextFilterId(Math.max(1, maxId + 1)); - }, [appliedFilterConditions, normalizeGridFilterConditions]); - - useEffect(() => { - setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition)); - }, [quickWhereCondition]); - - useEffect(() => { - if (Object.keys(columnMetaMap).length === 0) return; - setFilterConditions(prev => { - let changed = false; - const nextConditions = prev.map((cond) => { - if (!autoDefaultFilterIdsRef.current.has(cond.id)) { - return cond; - } - const nextOp = resolveDefaultGridFilterOperator(getColumnFilterType(cond.column)); - if (nextOp === cond.op) return cond; - changed = true; - return { ...cond, op: nextOp }; - }); - return changed ? nextConditions : prev; - }); - }, [columnMetaMap, getColumnFilterType]); - - const quickWhereSuggestionOptions = useMemo(() => { - const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames; - return resolveWhereConditionSuggestions({ - input: quickWhereDraft, - columnNames: columnSuggestionSource, - dbType, - }).map((item) => ({ - value: item.value, - insertText: item.insertText, - suggestionKind: item.kind, - label: ( -
- {item.label} - {item.detail} -
- ), - })); - }, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]); - - const handleQuickWherePaste = useCallback((event: React.ClipboardEvent) => { - const pastedText = event.clipboardData.getData('text/plain') || event.clipboardData.getData('text'); - if (!pastedText) return; - - event.preventDefault(); - event.stopPropagation(); - - const input = event.currentTarget; - const currentValue = input.value ?? quickWhereDraft; - const start = input.selectionStart ?? currentValue.length; - const end = input.selectionEnd ?? start; - const nextValue = `${currentValue.slice(0, start)}${pastedText}${currentValue.slice(end)}`; - const nextCursor = start + pastedText.length; - - setQuickWhereDraft(nextValue); - requestAnimationFrame(() => { - input.focus(); - input.setSelectionRange(nextCursor, nextCursor); - }); - }, [quickWhereDraft]); - - const stopQuickWhereClipboardPropagation = useCallback((event: React.ClipboardEvent) => { - event.stopPropagation(); - }, []); - const gridFieldSelectOptions = useMemo( () => buildGridFieldSelectOptions(displayColumnNames), [displayColumnNames], ); - useEffect(() => { - if (!showFilter) { - return; - } - const root = filterPanelRef.current; - if (!root) { - return; - } - const apply = () => { - applyNoAutoCapAttributesWithin(root); - }; - apply(); - if (typeof MutationObserver === 'undefined') { - return; - } - const observer = new MutationObserver(() => { - apply(); - }); - observer.observe(root, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - }; - }, [showFilter]); + const { + filterConditions, + setFilterConditions, + quickWhereDraft, + setQuickWhereDraft, + quickWhereSuggestionsOpen, + setQuickWhereSuggestionsOpen, + filterPanelRef, + filterOpOptions, + filterLogicOptions, + quickWhereSuggestionOptions, + handleQuickWherePaste, + stopQuickWhereClipboardPropagation, + isNoValueOp, + isBetweenOp, + isListOp, + addFilter, + updateFilter, + removeFilter, + applyQuickWhereCondition, + clearQuickWhereCondition, + clearAllFiltersAndSorts, + applyFilters, + applyAllFiltersEnabled, + applyAllFiltersDisabled, + } = useDataGridFilters({ + appliedFilterConditions, + quickWhereCondition, + showFilter, + displayColumnNames, + allTableColumnNames, + columnMetaMap, + dbType, + darkMode, + onApplyFilter, + onApplyQuickWhereCondition, + onSort, + messageApi: { + warning: (content) => { + void message.warning(content); + }, + }, + getColumnFilterType, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, + }); const selectedRowKeysRef = useRef(selectedRowKeys); const displayDataRef = useRef([]); @@ -3013,33 +3014,44 @@ const DataGrid: React.FC = ({ }); }, [addedRows.length, scrollTableBodyToBottom]); - // Reset local state when data source likely changes (e.g. tableName change) - useEffect(() => { - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - setSelectedRowKeys([]); - setCopiedCellPatch(null); - setCopiedRowsForPaste([]); - setRowEditorOpen(false); - setRowEditorRowKey(''); - rowEditorBaseRawRef.current = {}; - rowEditorDisplayRef.current = {}; - rowEditorNullColsRef.current = new Set(); - ddlRequestSeqRef.current += 1; - setDdlModalOpen(false); - setDdlLoading(false); - setDdlText(''); - setDdlViewLayout('bottom'); - setDdlSidebarResizePreviewX(null); - rowEditorForm.resetFields(); - closeCellEditor(); - form.resetFields(); - }, [tableName, dbName, connectionId]); // Reset on context change - const rowKeyStr = useCallback((k: React.Key) => String(k), []); + const { + viewMode, + setViewMode, + ddlModalOpen, + setDdlModalOpen, + ddlLoading, + ddlText, + ddlViewLayout, + setDdlViewLayout, + ddlSidebarWidth, + ddlSidebarResizePreviewX, + ddlRequestSeqRef, + isTableSurfaceActive, + handleOpenTableDdl, + handleViewModeChange, + handleDdlSidebarResizeStart, + resetDdlViewState, + } = useDataGridDdlView({ + canViewDdl, + currentConnConfig, + dbName, + tableName, + isV2Ui, + cellEditMode, + selectedRowKeys, + mergedDisplayDataRef, + rowKeyStr, + closeCellEditModeRef, + setTextRecordIndex, + messageApi: { + error: (content) => { + void message.error(content); + }, + }, + }); + useEffect(() => { cellEditModeRef.current = cellEditMode; }, [cellEditMode]); @@ -3098,10 +3110,14 @@ const DataGrid: React.FC = ({ const closeCellEditMode = useCallback(() => { setCellEditMode(false); cellEditModeRef.current = false; - setBatchEditModalOpen(false); + closeBatchEditModal(); resetCellSelection(); }, [resetCellSelection]); + useEffect(() => { + closeCellEditModeRef.current = closeCellEditMode; + }, [closeCellEditMode]); + // 批量填充选中的单元格 const handleBatchFillCells = useCallback(() => { const cellsToFill = currentSelectionRef.current; @@ -3188,7 +3204,7 @@ const DataGrid: React.FC = ({ }); void message.success(`已填充 ${updatedCount} 个单元格`); - setBatchEditModalOpen(false); + closeBatchEditModal(); // 清除选中状态 setSelectedCells(new Set()); @@ -3201,7 +3217,7 @@ const DataGrid: React.FC = ({ cellSelectionAutoScrollRafRef.current = null; } updateCellSelection(new Set()); - }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); + }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal]); // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 useEffect(() => { @@ -4098,10 +4114,39 @@ const DataGrid: React.FC = ({ } }, [cellEditorIsJson, cellEditorValue]); + const openVirtualInlineEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { + if (!record || !dataIndex || !canModifyData) return; + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) return; + + const raw = record?.[dataIndex]; + if (shouldOpenModalEditor(raw)) { + openCellEditor(record, dataIndex, title); + return; + } + + const columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; + const pickerType = getTemporalPickerType(columnType); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(raw || ''))); + const fieldName = getCellFieldName(record, dataIndex); + if (isDateTimeField) { + setCellFieldValue(form, fieldName, parseToDayjs(raw, pickerType)); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } + setVirtualEditingCell({ + rowKey: rowKeyStr(rowKey), + dataIndex, + title, + columnType, + }); + }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, form, openCellEditor, rowKeyStr]); + const handleVirtualCellActivate = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { if (!canModifyData) return; - openCellEditor(record, dataIndex, title); - }, [canModifyData, openCellEditor]); + openVirtualInlineEditor(record, dataIndex, title); + }, [canModifyData, openVirtualInlineEditor]); const handleVirtualCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string) => { e.preventDefault(); @@ -4128,6 +4173,110 @@ const DataGrid: React.FC = ({ return result; }); }, [displayData, modifiedRows, deletedRowKeys]); + mergedDisplayDataRef.current = mergedDisplayData; + + // Reset local state when data source likely changes (e.g. tableName change) + useEffect(() => { + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + setSelectedRowKeys([]); + setCopiedCellPatch(null); + setCopiedRowsForPaste([]); + closeRowEditor(); + resetDdlViewState(); + closeVirtualInlineEditor(); + closeCellEditor(); + formRef.current.resetFields(); + }, [tableName, dbName, connectionId, closeRowEditor, resetDdlViewState, closeVirtualInlineEditor, closeCellEditor]); // Reset on context change + + useEffect(() => { + const next = new Map(); + mergedDisplayData.forEach((row) => { + const key = row?.[GONAVI_ROW_KEY]; + if (key === undefined || key === null) return; + next.set(rowKeyStr(key), row); + }); + mergedDisplayDataByRowKeyRef.current = next; + }, [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; + if (!element) { + return null; + } + const rowKey = String(element.getAttribute('data-row-key') || '').trim(); + const dataIndex = String(element.getAttribute('data-col-name') || '').trim(); + if (!rowKey || !dataIndex) { + return null; + } + const record = mergedDisplayDataByRowKeyRef.current.get(rowKey); + if (!record) { + return null; + } + return { rowKey, dataIndex, record }; + }, []); + + const handleVirtualTableClickCapture = useCallback((event: React.MouseEvent) => { + if (!dataPanelOpenRef.current) return; + const cellInfo = resolveRenderedCellInfoFromElement(event.target); + if (!cellInfo) return; + updateFocusedCell(cellInfo.record, cellInfo.dataIndex); + }, [resolveRenderedCellInfoFromElement, updateFocusedCell]); + + const handleVirtualTableDoubleClickCapture = useCallback((event: React.MouseEvent) => { + const cellInfo = resolveRenderedCellInfoFromElement(event.target); + if (!cellInfo) return; + const rowDeleted = cellInfo.record?.[GONAVI_ROW_KEY] !== undefined + ? deletedRowKeys.has(rowKeyStr(cellInfo.record[GONAVI_ROW_KEY])) + : false; + if (rowDeleted || !isWritableResultColumn(cellInfo.dataIndex, effectiveEditLocator)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + handleVirtualCellActivate(cellInfo.record, cellInfo.dataIndex, cellInfo.dataIndex); + }, [resolveRenderedCellInfoFromElement, deletedRowKeys, rowKeyStr, effectiveEditLocator, handleVirtualCellActivate]); + + const handleVirtualTableContextMenuCapture = useCallback((event: React.MouseEvent) => { + const cellInfo = resolveRenderedCellInfoFromElement(event.target); + if (!cellInfo) return; + event.preventDefault(); + event.stopPropagation(); + showCellContextMenu(event, cellInfo.record, cellInfo.dataIndex, cellInfo.dataIndex); + }, [resolveRenderedCellInfoFromElement, showCellContextMenu]); + + const saveVirtualInlineEditor = useCallback(async (pickerValue?: dayjs.Dayjs | null) => { + const editingCell = virtualEditingCell; + if (!editingCell) return; + + const record = mergedDisplayDataByRowKeyRef.current.get(editingCell.rowKey); + if (!record) { + closeVirtualInlineEditor(); + return; + } + + const pickerType = getTemporalPickerType(editingCell.columnType); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[editingCell.dataIndex] || ''))); + const fieldName = getCellFieldName(record, editingCell.dataIndex); + try { + await form.validateFields([fieldName]); + let nextValue = form.getFieldValue(fieldName); + if (isDateTimeField) { + nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); + } + closeVirtualInlineEditor(); + if (!isCellValueEqualForDiff(record?.[editingCell.dataIndex], nextValue)) { + handleCellSave({ ...record, [editingCell.dataIndex]: nextValue }); + } + } catch (errInfo) { + console.log('Virtual inline save failed:', errInfo); + if (isDateTimeField) { + closeVirtualInlineEditor(); + } + } + }, [closeVirtualInlineEditor, form, handleCellSave, virtualEditingCell]); const pageFindMatches = useMemo(() => collectDataGridFindMatches( mergedDisplayData, @@ -4162,8 +4311,11 @@ const DataGrid: React.FC = ({ : 0; const tableRenderData = useMemo( - () => attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText), - [mergedDisplayData, normalizedPageFindText] + () => attachDataGridVirtualEditRenderVersion( + attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText), + virtualEditingCell, + ), + [mergedDisplayData, normalizedPageFindText, virtualEditingCell] ); useEffect(() => { @@ -4210,15 +4362,6 @@ const DataGrid: React.FC = ({ return String(val); }, [columnMetaMap, columnMetaMapByLowerName]); - const closeRowEditor = useCallback(() => { - setRowEditorOpen(false); - setRowEditorRowKey(''); - rowEditorBaseRawRef.current = {}; - rowEditorDisplayRef.current = {}; - rowEditorNullColsRef.current = new Set(); - rowEditorForm.resetFields(); - }, [rowEditorForm]); - const openRowEditorByKey = useCallback((keyStr?: string) => { if (!canModifyData) return; if (!keyStr) { @@ -4258,14 +4401,14 @@ const DataGrid: React.FC = ({ if (baseVal === null || baseVal === undefined) nullCols.add(col); }); - rowEditorBaseRawRef.current = baseRawMap; - rowEditorDisplayRef.current = displayMap; - rowEditorNullColsRef.current = nullCols; - - rowEditorForm.setFieldsValue(formMap); - setRowEditorRowKey(keyStr); - setRowEditorOpen(true); - }, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]); + openRowEditor({ + rowKey: keyStr, + baseRawMap, + displayMap, + nullCols, + formValues: formMap, + }); + }, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowKeyStr, columnMetaMap, columnMetaMapByLowerName, openRowEditor]); const openCurrentViewRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4278,76 +4421,10 @@ const DataGrid: React.FC = ({ openRowEditorByKey(rowKeyStr(rowKey)); }, [canModifyData, mergedDisplayData, textRecordIndex, rowKeyStr, openRowEditorByKey]); - const openJsonEditor = useCallback(() => { + const handleOpenJsonEditor = useCallback(() => { if (!canModifyData) return; - setJsonEditorValue(jsonViewText); - setJsonEditorOpen(true); - }, [canModifyData, jsonViewText]); - - const handleOpenTableDdl = useCallback(async (options?: { asView?: boolean }) => { - if (!canViewDdl || !currentConnConfig || !tableName) { - void message.error('当前表缺少连接或表名,无法查看 DDL'); - return; - } - const asView = options?.asView === true && isV2Ui; - const requestSeq = ++ddlRequestSeqRef.current; - if (asView) { - setViewMode('ddl'); - setDdlModalOpen(false); - } else { - setDdlModalOpen(true); - } - setDdlLoading(true); - setDdlText(''); - try { - const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig) as any, dbName || '', tableName); - if (requestSeq !== ddlRequestSeqRef.current) return; - if (res.success) { - setDdlText(String(res.data ?? '')); - return; - } - void message.error(res.message || '获取 DDL 失败'); - } catch (error: any) { - if (requestSeq !== ddlRequestSeqRef.current) return; - void message.error(error?.message || '获取 DDL 失败'); - } finally { - if (requestSeq === ddlRequestSeqRef.current) { - setDdlLoading(false); - } - } - }, [canViewDdl, currentConnConfig, dbName, isV2Ui, tableName]); - - useEffect(() => { - if (isV2Ui || (viewMode !== 'fields' && viewMode !== 'ddl' && viewMode !== 'er')) return; - setViewMode('table'); - }, [isV2Ui, viewMode]); - - const handleViewModeChange = useCallback((nextMode: GridViewMode) => { - if ((nextMode === 'fields' || nextMode === 'ddl' || nextMode === 'er') && !isV2Ui) { - setViewMode('table'); - return; - } - if (nextMode === 'ddl') { - void handleOpenTableDdl({ asView: true }); - setViewMode('ddl'); - return; - } - if (nextMode === 'json' && cellEditMode) { - closeCellEditMode(); - } - - if (nextMode === 'text') { - const selectedKey = selectedRowKeys[0]; - if (selectedKey !== undefined) { - const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey)); - if (idx >= 0) { - setTextRecordIndex(idx); - } - } - } - - setViewMode(nextMode); - }, [cellEditMode, handleOpenTableDdl, isV2Ui, mergedDisplayData, selectedRowKeys, rowKeyStr, closeCellEditMode]); + openJsonEditor(jsonViewText); + }, [canModifyData, jsonViewText, openJsonEditor]); const handleOpenContextMenuRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4464,9 +4541,9 @@ const DataGrid: React.FC = ({ return next; }); - setJsonEditorOpen(false); + closeJsonEditor(); void message.success("JSON 修改已应用到当前结果集,可继续“提交事务”"); - }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator]); + }, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator, closeJsonEditor]); const openRowEditorFieldEditor = useCallback((dataIndex: string) => { if (!dataIndex) return; @@ -4535,6 +4612,21 @@ const DataGrid: React.FC = ({ const enableVirtual = isTableSurfaceActive; const enableInlineEditableCell = canModifyData; + const useInlineEditableBodyCell = enableInlineEditableCell && !enableVirtual; + + useEffect(() => { + if (!virtualEditingCell) return; + const rafId = requestAnimationFrame(() => { + virtualInlineInputRef.current?.focus?.(); + try { + const inputElement = virtualInlineInputRef.current?.input as HTMLInputElement | undefined; + inputElement?.select?.(); + } catch { + // ignore + } + }); + return () => cancelAnimationFrame(rafId); + }, [virtualEditingCell]); const columns: (ColumnType & { editable?: boolean })[] = useMemo(() => { return displayColumnNames.map(key => ({ @@ -4549,15 +4641,22 @@ const DataGrid: React.FC = ({ sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false, sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined, editable: canModifyData && isWritableResultColumn(key, effectiveEditLocator), - render: (text: any) => ( -
- {renderCellDisplayValue(text, normalizedPageFindText, (columnMetaMap[key] || columnMetaMapByLowerName[key.toLowerCase()])?.type)} -
- ), + render: (text: any) => { + const renderedContent = renderCellDisplayValue(text, normalizedPageFindText, displayColumnTypeMap[key]); + if (enableVirtual) { + return renderedContent; + } + return ( +
+ {renderedContent} +
+ ); + }, shouldCellUpdate: (record: Item, prevRecord: Item) => { const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY]; if (rowKeyChanged) return true; if (hasDataGridFindRenderVersionChanged(record, prevRecord)) return true; + if (hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)) return true; return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]); }, onHeaderCell: (column: any) => ({ @@ -4594,7 +4693,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, isV2Ui, showColumnHeaderContextMenu, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]); + }, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, isV2Ui, showColumnHeaderContextMenu, canModifyData, onSort, renderColumnTitle, dataTableDensity, normalizedPageFindText, displayColumnTypeMap, enableVirtual, showColumnComment, showColumnType]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { const dataIndex = String(col.dataIndex); @@ -4607,14 +4706,14 @@ const DataGrid: React.FC = ({ 'data-row-key': rowKey === undefined || rowKey === null ? undefined : String(rowKey), 'data-col-name': dataIndex, }; - // 数据预览面板:单击单元格时更新聚焦信息 - cellProps.onClick = () => { - if (dataPanelOpenRef.current) { + if (!enableVirtual && dataPanelOpenRef.current) { + // 非虚拟表保留最直接的点击同步;虚拟表改走容器级事件委托,避免每格闭包。 + cellProps.onClick = () => { updateFocusedCell(record, dataIndex); - } - }; + }; + } - if (col.editable && enableInlineEditableCell) { + if (col.editable && useInlineEditableBodyCell) { // 可编辑模式(非虚拟):传递给 EditableCell 的 props cellProps.record = record; cellProps.editable = col.editable; @@ -4622,20 +4721,16 @@ const DataGrid: React.FC = ({ cellProps.title = dataIndex; cellProps.handleSave = handleCellSave; cellProps.focusCell = openCellEditor; - cellProps.columnType = (columnMetaMap[dataIndex] || columnMetaMapByLowerName[dataIndex.toLowerCase()])?.type; + cellProps.columnType = displayColumnTypeMap[dataIndex]; cellProps.inputCellPadding = inputCellPadding; - } else if (col.editable && !enableInlineEditableCell) { - // 可编辑但非 inline(虚拟模式下):已删除行不绑定双击处理 - const rowDeleted = record?.[GONAVI_ROW_KEY] !== undefined - ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) - : false; - if (!rowDeleted) { - cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex); - } + cellProps.modifiedColumns = modifiedColumns; + cellProps.rowKeyStr = rowKeyStr; + cellProps.deletedRowKeys = deletedRowKeys; + cellProps.darkMode = darkMode; + } else if (enableVirtual) { + // 虚拟表格主要走 table 容器级事件委托;这里保留一个共享右键入口,兼容测试桩和非标准事件分发场景。 cellProps.onContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - showCellContextMenu(e, record, dataIndex, dataIndex); + handleVirtualCellContextMenu(e, record, dataIndex); }; } else { // 不可编辑(只读查询结果):只绑定右键菜单 @@ -4645,52 +4740,128 @@ const DataGrid: React.FC = ({ showCellContextMenu(e, record, dataIndex, dataIndex); }; } - cellProps.modifiedColumns = modifiedColumns; - cellProps.rowKeyStr = rowKeyStr; - cellProps.deletedRowKeys = deletedRowKeys; - cellProps.darkMode = darkMode; return cellProps; }, render: (text: any, record: Item, index: number) => { const originalRenderContent = col.render ? (col.render as any)(text, record, index) : text; - const rowDeletedForRender = record?.[GONAVI_ROW_KEY] !== undefined - ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) - : false; + const rowKey = record?.[GONAVI_ROW_KEY]; + const rowKeyText = rowKey === undefined || rowKey === null ? '' : rowKeyStr(rowKey); + const rowDeletedForRender = !!rowKeyText && deletedRowKeys.has(rowKeyText); + const columnType = displayColumnTypeMap[dataIndex]; + const isVirtualInlineEditingCell = !!virtualEditingCell + && virtualEditingCell.rowKey === rowKeyText + && virtualEditingCell.dataIndex === dataIndex; + const isModifiedCell = !!rowKeyText && !!modifiedColumns[rowKeyText]?.has(dataIndex); + const modifiedStyle: React.CSSProperties | undefined = isModifiedCell + ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } + : undefined; if (enableVirtual && enableInlineEditableCell) { - return ( - - {originalRenderContent} - - ); - } - if (enableVirtual) { + const pickerType = getTemporalPickerType(columnType); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); + const virtualCellStyle = modifiedStyle ? { ...virtualCellWrapperStyle, ...modifiedStyle } : virtualCellWrapperStyle; + const virtualEditable = !!col.editable && !rowDeletedForRender; + if (isVirtualInlineEditingCell && virtualEditable) { return (
handleVirtualCellContextMenu(e, record, dataIndex)} > - {originalRenderContent} -
- ); + + {isDateTimeField ? ( + pickerType === 'time' ? ( + setTimeout(() => { void saveVirtualInlineEditor(value); }, 0)} + onOpenChange={lockVirtualInlineTableScroll} + onBlur={() => setTimeout(() => { void saveVirtualInlineEditor(); }, 0)} + needConfirm={false} + /> + ) : pickerType === 'datetime' ? ( + ( + { + setCellFieldValue(form, getCellFieldName(record, dataIndex), dayjs()); + }} + >此刻 + )} + onOk={(value) => setTimeout(() => { void saveVirtualInlineEditor((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} + onOpenChange={(open) => { + virtualInlinePickerOpenRef.current = open; + lockVirtualInlineTableScroll(open); + if (!open) { + setTimeout(() => { + if (!virtualInlinePickerOpenRef.current) { + closeVirtualInlineEditor(); + } + }, 0); + } + }} + onBlur={() => { + setTimeout(() => { + if (!virtualInlinePickerOpenRef.current) { + closeVirtualInlineEditor(); + } + }, 150); + }} + needConfirm + /> + ) : ( + setTimeout(() => { void saveVirtualInlineEditor(value); }, 0)} + onOpenChange={lockVirtualInlineTableScroll} + onBlur={() => setTimeout(() => { void saveVirtualInlineEditor(); }, 0)} + needConfirm={false} + /> + ) + ) : ( + { void saveVirtualInlineEditor(); }} + onBlur={() => { void saveVirtualInlineEditor(); }} + onFocus={(e) => { + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> + )} + + + ); + } + return
{originalRenderContent}
; + } + if (enableVirtual) { + return
{originalRenderContent}
; } return originalRenderContent; } }; - }), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleVirtualCellContextMenu, columnMetaMap, columnMetaMapByLowerName, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode]); + }), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleVirtualCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]); const handleAddRow = () => { const newKey = `new-${Date.now()}`; @@ -5640,117 +5811,6 @@ const DataGrid: React.FC = ({ if (onReload) onReload(); }; - // Filters - const filterOpOptions = useMemo(() => ([ - { value: '=', label: '=' }, - { value: '!=', label: '!=' }, - { value: '<', label: '<' }, - { value: '<=', label: '<=' }, - { value: '>', label: '>' }, - { value: '>=', label: '>=' }, - { value: 'CONTAINS', label: '包含' }, - { value: 'NOT_CONTAINS', label: '不包含' }, - { value: 'STARTS_WITH', label: '开始以' }, - { value: 'NOT_STARTS_WITH', label: '不是开始于' }, - { value: 'ENDS_WITH', label: '结束以' }, - { value: 'NOT_ENDS_WITH', label: '不是结束于' }, - { value: 'IS_NULL', label: '是 null' }, - { value: 'IS_NOT_NULL', label: '不是 null' }, - { value: 'IS_EMPTY', label: '是空的' }, - { value: 'IS_NOT_EMPTY', label: '不是空的' }, - { value: 'BETWEEN', label: '介于' }, - { value: 'NOT_BETWEEN', label: '不介于' }, - { value: 'IN', label: '在列表' }, - { value: 'NOT_IN', label: '不在列表' }, - { value: 'CUSTOM', label: '[自定义]' }, - ]), []); - const filterLogicOptions = useMemo(() => ([ - { value: 'AND', label: '且 (AND)' }, - { value: 'OR', label: '或 (OR)' }, - ]), []); - - const isNoValueOp = useCallback((op: string) => ( - op === 'IS_NULL' || op === 'IS_NOT_NULL' || op === 'IS_EMPTY' || op === 'IS_NOT_EMPTY' - ), []); - const isBetweenOp = useCallback((op: string) => op === 'BETWEEN' || op === 'NOT_BETWEEN', []); - const isListOp = useCallback((op: string) => op === 'IN' || op === 'NOT_IN', []); - - const addFilter = () => { - const column = displayColumnNames[0] || ''; - const id = nextFilterId; - autoDefaultFilterIdsRef.current.add(id); - setFilterConditions([ - ...filterConditions, - { - id, - enabled: true, - logic: 'AND', - column, - op: resolveDefaultGridFilterOperator(getColumnFilterType(column)), - value: '', - value2: '', - } - ]); - setNextFilterId(nextFilterId + 1); - }; - const updateFilter = (id: number, field: keyof GridFilterCondition, val: string | boolean) => { - setFilterConditions(prev => prev.map(c => { - if (c.id !== id) return c; - const next: GridFilterCondition = { ...c, [field]: val } as GridFilterCondition; - if (field === 'column') { - next.op = resolveNextGridFilterOperatorForColumnChange({ - currentOperator: c.op, - previousColumnType: getColumnFilterType(c.column), - nextColumnType: getColumnFilterType(String(val)), - }); - if (isNoValueOp(next.op)) { - next.value = ''; - next.value2 = ''; - } else if (!isBetweenOp(next.op)) { - next.value2 = ''; - } - } - if (field === 'op') { - autoDefaultFilterIdsRef.current.delete(id); - const nextOp = String(val); - if (isNoValueOp(nextOp)) { - next.value = ''; - next.value2 = ''; - } else if (isBetweenOp(nextOp)) { - if (typeof next.value2 !== 'string') next.value2 = ''; - } else { - next.value2 = ''; - } - } - return next; - })); - }; - const removeFilter = (id: number) => { - autoDefaultFilterIdsRef.current.delete(id); - setFilterConditions(prev => prev.filter(c => c.id !== id)); - }; - const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => { - const normalized = normalizeQuickWhereCondition(condition); - const validation = validateQuickWhereCondition(normalized); - if (!validation.ok) { - void message.warning(validation.message); - return false; - } - setQuickWhereDraft(normalized); - if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized); - return true; - }, [quickWhereDraft, onApplyQuickWhereCondition]); - - const clearQuickWhereCondition = useCallback(() => { - setQuickWhereDraft(''); - if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(''); - }, [onApplyQuickWhereCondition]); - - const applyFilters = () => { - if (!applyQuickWhereCondition()) return; - if (onApplyFilter) onApplyFilter(filterConditions); - }; - const exportMenu: MenuProps['items'] = hasFilteredExportSql ? [ { type: 'group', label: '筛选结果', children: [ { key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') }, @@ -5783,94 +5843,38 @@ const DataGrid: React.FC = ({ const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && displayOutputColumnNames.length > 0; const columnInfoSettingContent = ( -
-
显示设置
- setQueryOptions({ showColumnComment: e.target.checked })} - > - 表头显示备注 - - setQueryOptions({ showColumnType: e.target.checked })} - > - 表头显示类型 - -
- - - setColumnSearchText(e.target.value)} - allowClear - /> -
- {allOrderedColumnNames.filter(col => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase())).map(col => ( - toggleColumnVisibility(col, e.target.checked)} - style={{ marginLeft: 0 }} - > - {col} - - ))} -
- -
- setEnableColumnOrderMemory(e.target.checked)} - > - 记忆自定义列序 - - setEnableHiddenColumnMemory(e.target.checked)} - > - 记忆隐藏列配置 - -
- - -
-
+ setQueryOptions({ showColumnComment: checked })} + onShowColumnTypeChange={(checked) => setQueryOptions({ showColumnType: checked })} + onToggleAllColumnsVisibility={toggleAllColumnsVisibility} + onColumnSearchTextChange={setColumnSearchText} + onToggleColumnVisibility={toggleColumnVisibility} + onEnableColumnOrderMemoryChange={setEnableColumnOrderMemory} + onEnableHiddenColumnMemoryChange={setEnableHiddenColumnMemory} + onResetOrder={() => { + if (connectionId && dbName && tableName) { + clearTableColumnOrder(connectionId, dbName, tableName); + void message.success('已恢复默认列排序'); + } + }} + onResetHidden={() => { + if (connectionId && dbName && tableName) { + clearTableHiddenColumns(connectionId, dbName, tableName); + setLocalHiddenColumns([]); + void message.success('已恢复全列显示'); + } + }} + /> ); const dataContextValue = useMemo(() => ({ @@ -5922,7 +5926,9 @@ const DataGrid: React.FC = ({ const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]); const tableComponents = useMemo(() => { const body: Record = {}; - if (enableInlineEditableCell) { + // 虚拟表模式下 render() 已返回 EditableCell;这里再挂 body.cell 会形成双层包装, + // 增加滚动期间的组件与上下文开销。 + if (useInlineEditableBodyCell) { body.cell = EditableCell; } if (useContextMenuRow) { @@ -5931,7 +5937,7 @@ const DataGrid: React.FC = ({ return Object.keys(body).length > 0 ? { body, header: { cell: SortableHeaderCell } } : { header: { cell: SortableHeaderCell } }; - }, [enableInlineEditableCell, useContextMenuRow]); + }, [useInlineEditableBodyCell, useContextMenuRow]); const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]); const resolveVirtualHorizontalElements = useCallback((tableContainer: HTMLElement) => { @@ -6029,6 +6035,19 @@ const DataGrid: React.FC = ({ return active ? [active] : []; }, []); + const pickTableToExternalSyncTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => { + if (enableVirtual) { + const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; + const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null; + const candidates = [headerEl, contentEl].filter((node): node is HTMLElement => node instanceof HTMLElement); + const active = candidates.find((target) => target.scrollWidth > target.clientWidth + 1) || candidates[0]; + if (active) { + return [active]; + } + } + return pickHorizontalScrollTargets(tableContainer); + }, [enableVirtual, pickHorizontalScrollTargets]); + const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => { const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null; @@ -6134,6 +6153,24 @@ const DataGrid: React.FC = ({ } }, [enableVirtual, readVirtualHorizontalOffset]); + const scheduleSyncExternalScrollFromTargets = useCallback((source?: HTMLElement | null) => { + pendingTableTargetSyncSourceRef.current = source ?? null; + if (tableTargetSyncRafRef.current !== null) { + return; + } + tableTargetSyncRafRef.current = requestAnimationFrame(() => { + tableTargetSyncRafRef.current = null; + const pendingSource = pendingTableTargetSyncSourceRef.current; + pendingTableTargetSyncSourceRef.current = null; + if (horizontalSyncSourceRef.current === 'external') { + return; + } + horizontalSyncSourceRef.current = 'table'; + syncExternalScrollFromTargets(undefined, pendingSource); + horizontalSyncSourceRef.current = ''; + }); + }, [syncExternalScrollFromTargets]); + const applyExternalScrollToTableTargets = useCallback(() => { const externalScroll = externalHorizontalScrollRef.current; if (!(externalScroll instanceof HTMLDivElement)) { @@ -6243,16 +6280,6 @@ const DataGrid: React.FC = ({ const container = tableContainerRef.current; if (!(container instanceof HTMLElement)) return; - const resolveHorizontalDelta = (event: WheelEvent) => { - if (Math.abs(event.deltaX) > 0.5) { - return event.deltaX; - } - if (event.shiftKey && Math.abs(event.deltaY) > 0.5) { - return event.deltaY; - } - return 0; - }; - const isTableDataAreaTarget = (target: EventTarget | null) => { const element = target instanceof HTMLElement ? target : null; if (!element) return false; @@ -6267,7 +6294,11 @@ const DataGrid: React.FC = ({ // 需要传播到 rc-virtual-list 的内部 handler,此处不拦截。 if (!event.isTrusted) return; - const horizontalDelta = resolveHorizontalDelta(event); + const horizontalDelta = resolveDataGridHorizontalWheelDelta({ + deltaX: event.deltaX, + deltaY: event.deltaY, + shiftKey: event.shiftKey, + }); if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return; if (!isTableDataAreaTarget(event.target)) return; @@ -6370,11 +6401,7 @@ const DataGrid: React.FC = ({ }; syncHeaderWidth(); const rafId = requestAnimationFrame(syncHeaderWidth); - // 监听 antd 可能的重渲染覆盖 - const observer = new MutationObserver(syncHeaderWidth); - const headerEl = container.querySelector('.ant-table-header'); - if (headerEl) observer.observe(headerEl, { attributes: true, childList: true, subtree: true, attributeFilter: ['style'] }); - return () => { cancelAnimationFrame(rafId); observer.disconnect(); }; + return () => { cancelAnimationFrame(rafId); }; }, [isTableSurfaceActive, tableScrollX, mergedDisplayData.length]); useEffect(() => { @@ -6396,7 +6423,7 @@ const DataGrid: React.FC = ({ const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer); const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer); const top = verticalTarget ? verticalTarget.scrollTop : 0; - const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0; + const left = externalScroll?.scrollLeft ?? horizontalTargets[0]?.scrollLeft ?? 0; if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) { return; } @@ -6416,11 +6443,11 @@ const DataGrid: React.FC = ({ externalScroll?.removeEventListener('scroll', emitSnapshot); boundVerticalTarget = pickVerticalScrollTarget(tableContainer); - boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer); + boundHorizontalTargets = externalScroll ? [] : pickHorizontalScrollTargets(tableContainer); boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true }); - boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true })); externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true }); + boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true })); emitSnapshot(); }; @@ -6430,6 +6457,7 @@ const DataGrid: React.FC = ({ if (scrollSnapshotRafRef.current !== null) { cancelAnimationFrame(scrollSnapshotRafRef.current); scrollSnapshotRafRef.current = null; + emitSnapshotNow(); } if (boundVerticalTarget) { boundVerticalTarget.removeEventListener('scroll', emitSnapshot); @@ -6488,15 +6516,13 @@ const DataGrid: React.FC = ({ const handleTargetScroll = (event: Event) => { const source = event.target as HTMLElement | null; if (horizontalSyncSourceRef.current === 'external') return; - horizontalSyncSourceRef.current = 'table'; - syncExternalScrollFromTargets(undefined, source); - horizontalSyncSourceRef.current = ''; + scheduleSyncExternalScrollFromTargets(source); }; const bindCurrentTableTargets = () => { // Unbind previous targets boundTargets.forEach(t => t.removeEventListener('scroll', handleTargetScroll)); - const nextTargets = pickHorizontalScrollTargets(tableContainer); + const nextTargets = pickTableToExternalSyncTargets(tableContainer); tableScrollTargetsRef.current = nextTargets; boundTargets = nextTargets; // Bind scroll listener on new targets @@ -6523,8 +6549,13 @@ const DataGrid: React.FC = ({ if (rafId !== null) { cancelAnimationFrame(rafId); } + if (tableTargetSyncRafRef.current !== null) { + cancelAnimationFrame(tableTargetSyncRafRef.current); + tableTargetSyncRafRef.current = null; + } + pendingTableTargetSyncSourceRef.current = null; }; - }, [isTableSurfaceActive, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]); + }, [isTableSurfaceActive, tableScrollX, mergedDisplayData.length, pickTableToExternalSyncTargets, scheduleSyncExternalScrollFromTargets, syncExternalScrollFromTargets]); const paginationSummaryText = useMemo(() => { if (!pagination) return ''; @@ -6600,12 +6631,6 @@ const DataGrid: React.FC = ({ onPageChange(nextPage, pagination.pageSize); }, [onPageChange, pagination, paginationTotalPages]); - const renderToolbarDivider = () => ( -
- ); const aiShortcutLabel = resolveShortcutDisplay(shortcutOptions ?? DEFAULT_SHORTCUT_OPTIONS, 'toggleAIPanel', activeShortcutPlatform); const legacyAiButtonStyle: React.CSSProperties | undefined = isV2Ui ? undefined : { background: darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))', @@ -6618,6 +6643,9 @@ const DataGrid: React.FC = ({
= ({
); - const renderV2FieldsView = () => ( -
-
-
- FIELDS - {tableName || '查询结果'} -
-
- {displayOutputColumnNames.length} 个字段 -
-
-
-
- # - 名称 - 类型 - NN - PK - 默认值 - 注释 -
- {displayOutputColumnNames.map((columnName, index) => { - const meta = columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()]; - const isPk = pkColumns.includes(columnName) || effectiveEditLocator?.columns?.includes(columnName); - return ( -
- {index + 1} - {columnName} - {meta?.type || '-'} - - - {isPk ? PK : '-'} - - - {meta?.comment || '-'} -
- ); - })} -
-
- ); - const renderV2DdlView = (layout: DdlViewLayoutMode) => ( -
-
-
- DDL - {tableName ? `DDL - ${tableName}` : 'DDL'} -
-
- setDdlViewLayout(String(value) as DdlViewLayoutMode)} - /> - - - {layout === 'side' && ( - - )} -
-
-
- -
-
- ); - const handleDdlSidebarResizeStart = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const startX = event.clientX; - const startWidth = ddlSidebarWidth; - const moveHandler = (moveEvent: MouseEvent) => { - const nextWidth = Math.max(320, Math.min(760, startWidth + (startX - moveEvent.clientX))); - if (ddlSidebarResizeRef.current) { - ddlSidebarResizeRef.current.previewWidth = nextWidth; - } - setDdlSidebarResizePreviewX(moveEvent.clientX); - }; - const upHandler = () => { - const nextWidth = ddlSidebarResizeRef.current?.previewWidth ?? startWidth; - setDdlSidebarWidth(nextWidth); - setDdlSidebarResizePreviewX(null); - document.removeEventListener('mousemove', moveHandler); - document.removeEventListener('mouseup', upHandler); - ddlSidebarResizeRef.current = null; - }; - - ddlSidebarResizeRef.current = { startX, startWidth, previewWidth: startWidth, moveHandler, upHandler }; - document.addEventListener('mousemove', moveHandler); - document.addEventListener('mouseup', upHandler); - }, [ddlSidebarWidth]); - const renderV2DdlSideWorkspace = () => ( -
-
- {renderDataTableView()} -
-
- -
-
- ); - const renderV2ErView = () => ( -
-
- TABLE - {tableName || '查询结果'} - {displayOutputColumnNames.length} fields -
-
- - -
-
- {displayOutputColumnNames.slice(0, 6).map((columnName) => ( -
- FIELD - {columnName} - {(columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type || '-'} -
- ))} -
-
- ); - const toggleDataPanel = () => { - const next = !dataPanelOpen; - setDataPanelOpen(next); - if (!next) { - setFocusedCellInfo(null); - setDataPanelValue(''); - setDataPanelIsJson(false); - dataPanelDirtyRef.current = false; - } - }; const pageFindContent = ( - -
- } - placeholder="当前页查找..." - value={pageFindText} - onChange={(event) => setPageFindText(event.target.value)} - style={isV2Ui ? undefined : { width: 220 }} - /> - - - {normalizedPageFindText && ( - - {pageFindMatches.length > 0 ? `${activePageFindPosition} / ${pageFindMatches.length} · ` : ''}匹配 {pageFindSummary.occurrenceCount} 处 / {pageFindSummary.matchedCellCount} 个单元格 - - )} -
-
+ } + pageFindText={pageFindText} + normalizedPageFindText={normalizedPageFindText} + hasMatches={pageFindMatches.length > 0} + activePageFindPosition={activePageFindPosition} + matchCount={pageFindMatches.length} + occurrenceCount={pageFindSummary.occurrenceCount} + matchedCellCount={pageFindSummary.matchedCellCount} + onPageFindTextChange={setPageFindText} + onNavigatePrevious={() => handleNavigatePageFind('previous')} + onNavigateNext={() => handleNavigatePageFind('next')} + /> ); const resultViewSwitcher = ( -
- 结果视图 - handleViewModeChange(String(val) as GridViewMode)} - /> -
+ ); - const paginationContent = pagination ? ( -
- {isV2Ui ? ( -
-
- {paginationV2SummaryText} -
-
- )} -
- ) : null; - const renderSecondaryActions = () => { - if (isV2Ui) { - const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [ - { key: 'table', label: '数据预览', icon: }, - { key: 'fields', label: '字段信息', icon: }, - { key: 'ddl', label: '查看 DDL', icon: , disabled: !canViewDdl }, - { key: 'er', label: 'ER 图', icon: }, - ]; - return ( -
-
- {viewTabItems.map((item) => ( - - ))} -
-
- {resultViewSwitcher} - - - -
- live - {mergedDisplayData.length} 行 - 未提交 {pendingChangeCount} -
- {pageFindContent} - - ); + const paginationContent = ( + + ); + + const rowEditorFields = useMemo(() => ( + displayColumnNames.map((col) => { + const sample = rowEditorDisplayRef.current?.[col] ?? ''; + const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; + const isJson = looksLikeJsonText(sample); + const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const pickerType = getTemporalPickerType(colMeta?.type); + const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); + const isWritable = isWritableResultColumn(col, effectiveEditLocator); + return { + columnName: col, + sample, + placeholder, + isJson, + useTextArea, + pickerType, + isTemporalValue, + isWritable, + }; + }) + ), [displayColumnNames, columnMetaMap, columnMetaMapByLowerName, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); + + const handleRefreshGrid = useCallback(() => { + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setSelectedRowKeys([]); + if (onReload) onReload(); + }, [onReload]); + + const handleToggleFilterWithDefault = useCallback(() => { + if (!onToggleFilter) return; + onToggleFilter(); + if (filterConditions.length === 0 && !showFilter) addFilter(); + }, [onToggleFilter, filterConditions.length, showFilter]); + + const handleToggleCellEditMode = useCallback(() => { + const next = !cellEditMode; + if (!next) { + closeCellEditMode(); + } else { + cellEditModeRef.current = true; + setCellEditMode(true); + resetCellSelection(); } - return ( - <> -
-
- - - - - {canViewDdl && ( - - )} - {pageFindContent} -
- {resultViewSwitcher} -
- {paginationContent} - - ); - }; + void message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式').then(); + }, [cellEditMode, closeCellEditMode, resetCellSelection]); + + const handleRequestAiInsight = useCallback(() => { + const sampleData = mergedDisplayData.slice(0, 10); + const prompt = `请帮我分析以下查询结果数据(取前 ${sampleData.length} 条示例):\n\`\`\`json\n${JSON.stringify(sampleData, null, 2)}\n\`\`\`\n\n请分析数据特征、发现规律,或者给出一些业务上的洞察。`; + const store = useStore.getState(); + const wasClosed = !store.aiPanelVisible; + if (wasClosed) store.setAIPanelVisible(true); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); + }, wasClosed ? 350 : 0); + }, [mergedDisplayData]); + + const handleToggleTotalCount = useCallback(() => { + if (!onRequestTotalCount) return; + if (pagination?.totalCountLoading) { + if (onCancelTotalCount) onCancelTotalCount(); + return; + } + onRequestTotalCount(); + }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); return (
- {/* Toolbar + Filter Panel */} -
-
- {isV2Ui && ( - <> -
- - {tableName || '查询结果'} - {dbName && · {dbName}} -
- {renderToolbarDivider()} - - )} - {onReload && } - - {onToggleFilter && ( - <> - {renderToolbarDivider()} - - - )} - - {canModifyData && ( - <> - {renderToolbarDivider()} - - - - {allSelectedAreDeleted ? ( - - ) : ( - - )} - {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} - {renderToolbarDivider()} - - {cellEditMode && selectedCells.size > 0 && ( - <> - - - - - )} - {cellEditMode && copiedCellPatch && ( - <> - - - 已复制 {Object.keys(copiedCellPatch.values).length} 列 - - - )} - {renderToolbarDivider()} - - {hasChanges && ( - , - onClick: handlePreviewChanges, - } - ] }}> - - - )} - {hasChanges && ()} - - )} - - {(canImport || canExport) && ( - <> - {renderToolbarDivider()} - {canImport && } - {canExport && } - - )} - - {isQueryResultExport && ( - <> - {renderToolbarDivider()} - - - - - - - {prefersManualTotalCount && onRequestTotalCount && ( - <> - {renderToolbarDivider()} - - - - - )} - -
-
- - {showFilter && ( -
-
- - WHERE - - { - const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); - if (isClipboardShortcut) { - event.stopPropagation(); - return; - } - if (!shouldApplyQuickWhereOnEnter({ - key: event.key, - shiftKey: event.shiftKey, - isComposing: Boolean((event.nativeEvent as any)?.isComposing), - suggestionsOpen: quickWhereSuggestionsOpen, - suggestionCount: quickWhereSuggestionOptions.length, - activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), - })) { - return; - } - event.preventDefault(); - applyQuickWhereCondition(); - }} - onSelect={(value, option) => { - setQuickWhereDraft(resolveWhereConditionSelectedValue({ - selectedValue: value, - currentInput: quickWhereDraft, - insertText: (option as any)?.insertText, - })); - }} - style={{ flex: '1 1 320px', minWidth: 220 }} - popupMatchSelectWidth={420} - > - - - - -
- {/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */} -
- {filterConditions.map((cond, condIndex) => ( -
- updateFilter(cond.id, 'enabled', e.target.checked)} - style={{ marginTop: 6, flex: '0 0 auto', whiteSpace: 'nowrap' }} - > - 启用 - - updateFilter(cond.id, 'column', v)} - options={gridFieldSelectOptions} - showSearch - optionFilterProp="label" - optionRender={renderGridFieldSelectOption} - popupMatchSelectWidth={FILTER_FIELD_POPUP_WIDTH} - filterOption={(input, option) => - String(option?.label ?? '') - .toLowerCase() - .includes(String(input || '').trim().toLowerCase()) - } - placeholder="搜索字段名" - disabled={cond.op === 'CUSTOM'} - /> - updateFilter(cond.id, 'value', e.target.value)} - placeholder="开始值" - /> - updateFilter(cond.id, 'value2', e.target.value)} - placeholder="结束值" - /> - - ) : isNoValueOp(cond.op) ? ( - - ) : ( - updateFilter(cond.id, 'value', e.target.value)} - /> - )} - -
- ))} - {onSort && ( -
0 ? 4 : 0, borderTop: filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}> - {sortInfo.map((s, idx) => ( -
- { - const next = [...sortInfo]; - next[idx] = { ...next[idx], enabled: e.target.checked }; - onSort(JSON.stringify(next), ''); - }} - style={{ flex: '0 0 auto' }} - /> - {idx === 0 ? '排序' : '然后'} - { - const next = [...sortInfo]; - next[idx] = { ...next[idx], order: v }; - onSort(JSON.stringify(next), ''); - }} - options={[ - { value: 'ascend', label: '升序 ↑' }, - { value: 'descend', label: '降序 ↓' }, - ]} - disabled={!s.columnKey} - /> -
- ))} -
- )} -
-
0) || filterConditions.length > 0 ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}> - - {onSort && ( - - )} -
- - -
- - -
-
- )} -
+ } + filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} + filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} + exportMenu={exportMenu} + queryResultCopyMenu={queryResultCopyMenu} + dbType={dbType} + onResetPendingChanges={() => { + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + }} + onRefresh={handleRefreshGrid} + onToggleFilterClick={handleToggleFilterWithDefault} + onAddRow={handleAddRow} + onCopySelectedRowsForPaste={handleCopySelectedRowsForPaste} + onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} + onUndoDeleteSelected={handleUndoDeleteSelected} + onDeleteSelected={handleDeleteSelected} + onToggleCellEditMode={handleToggleCellEditMode} + onCopySelectedCellsToClipboard={handleCopySelectedCellsToClipboard} + onCopySelectedColumnsFromRow={handleCopySelectedColumnsFromRow} + onOpenBatchEditModal={openBatchEditModal} + onPasteCopiedColumnsToSelectedRows={() => handlePasteCopiedColumnsToSelectedRows()} + onCommit={handleCommit} + onPreviewChanges={handlePreviewChanges} + onImport={handleImport} + onCopyQueryResultCsv={handleCopyQueryResultCsv} + onRequestAiInsight={handleRequestAiInsight} + onToggleTotalCount={handleToggleTotalCount} + onQuickWhereDraftChange={setQuickWhereDraft} + onQuickWhereSuggestionsOpenChange={setQuickWhereSuggestionsOpen} + onQuickWhereKeyDown={(event) => { + const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); + if (isClipboardShortcut) { + event.stopPropagation(); + return; + } + if (!shouldApplyQuickWhereOnEnter({ + key: event.key, + shiftKey: event.shiftKey, + isComposing: Boolean((event.nativeEvent as any)?.isComposing), + suggestionsOpen: quickWhereSuggestionsOpen, + suggestionCount: quickWhereSuggestionOptions.length, + activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), + })) { + return; + } + event.preventDefault(); + applyQuickWhereCondition(); + }} + onQuickWhereSelect={(value, option) => { + setQuickWhereDraft(resolveWhereConditionSelectedValue({ + selectedValue: value, + currentInput: quickWhereDraft, + insertText: (option as any)?.insertText, + })); + }} + onQuickWhereCopy={stopQuickWhereClipboardPropagation} + onQuickWhereCut={stopQuickWhereClipboardPropagation} + onQuickWherePaste={handleQuickWherePaste} + onApplyQuickWhere={() => applyQuickWhereCondition()} + onClearQuickWhere={clearQuickWhereCondition} + updateFilter={updateFilter} + removeFilter={removeFilter} + addFilter={addFilter} + isListOp={isListOp} + isBetweenOp={isBetweenOp} + isNoValueOp={isNoValueOp} + enableSortControls={!!onSort} + onApplySortInfo={applySortInfo} + onApplyFilters={applyFilters} + onEnableAllFilters={applyAllFiltersEnabled} + onDisableAllFilters={applyAllFiltersDisabled} + onClearFiltersAndSorts={clearAllFiltersAndSorts} + />
{contextHolder} - 取消, - , - ]} - > -
- {tableName ? `${tableName}` : ''} - {rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''} -
-
-
- {displayColumnNames.map((col: string) => { - const sample = rowEditorDisplayRef.current?.[col] ?? ''; - const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; - const isJson = looksLikeJsonText(sample); - const useArea = isJson || sample.includes('\n') || sample.length >= 160; - const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const rowPickerType = getTemporalPickerType(colMeta?.type); - const isRowDateTimeField = !!rowPickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); - const isWritable = isWritableResultColumn(col, effectiveEditLocator); - - return ( - -
- - {isRowDateTimeField ? ( - rowPickerType === 'time' ? ( - - ) : rowPickerType === 'datetime' ? ( - - ) : ( - - ) - ) : useArea ? ( - - ) : ( - - )} - - -
-
- ); - })} -
- -
- - 格式化 JSON - , - , - , - ]} - > -
- {cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''} -
- {cellEditorOpen && ( - setCellEditorValue(val || '')} - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: "on", - fontSize: 14, - tabSize: 2, - automaticLayout: true, - }} - /> - )} -
- - {/* 批量编辑弹窗 */} - setBatchEditModalOpen(false)} - onOk={handleBatchFillCells} - width={500} - > -
- setBatchEditSetNull(e.target.checked)} - > - 设置为 NULL - -
- {!batchEditSetNull && ( - setBatchEditValue(e.target.value)} - placeholder="输入要填充的值" - autoSize={{ minRows: 3, maxRows: 10 }} - autoFocus - /> - )} -
- setJsonEditorOpen(false)} - destroyOnHidden - width={980} - maskClosable={false} - footer={[ - , - , - , - ]} - > -
- 说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。 -
- {jsonEditorOpen && ( - setJsonEditorValue(val || '')} - options={{ - readOnly: false, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: "off", - fontSize: 12, - tabSize: 2, - automaticLayout: true, - }} - /> - )} -
- setDdlModalOpen(false)} - destroyOnHidden - width={960} - footer={[ - , - , - ]} - > - {ddlModalOpen && ( - - )} - + setDdlModalOpen(false)} + onCopyDdl={handleCopyDdl} + /> {viewMode === 'table' ? ( renderDataTableView() ) : isV2Ui && viewMode === 'fields' ? ( - renderV2FieldsView() + ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( - renderV2DdlSideWorkspace() + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + ddlSidebarWidth={ddlSidebarWidth} + ddlSidebarResizePreviewX={ddlSidebarResizePreviewX} + onResizeStart={handleDdlSidebarResizeStart} + /> ) : isV2Ui && viewMode === 'ddl' ? ( - renderV2DdlView('bottom') + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + /> ) : isV2Ui && viewMode === 'er' ? ( - renderV2ErView() + ) : viewMode === 'json' ? ( -
-
- - {mergedDisplayData.length === 0 ? '当前结果集无数据' : `当前结果集 ${mergedDisplayData.length} 条记录`} - - {canModifyData && ( - - )} -
-
- -
-
+ ) : ( -
-
- - - - {textViewRows.length === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${textViewRows.length}`} - - {canModifyData && ( - - )} -
-
- {currentTextRow ? displayOutputColumnNames.map((col) => ( -
-
- {col} : -
-
- {formatTextViewValue((currentTextRow as any)[col], col)} -
-
- )) : ( -
- 当前结果集无数据 -
- )} -
-
+ setTextRecordIndex(i => Math.max(0, i - 1))} + onNext={() => setTextRecordIndex(i => Math.min(textViewRows.length - 1, i + 1))} + onEditCurrent={openCurrentViewRowEditor} + formatTextViewValue={formatTextViewValue} + /> )} - {/* Data Preview Panel */} - {dataPanelOpen && isTableSurfaceActive && ( -
-
- - {focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'} - - {focusedCellInfo && (() => { - const meta = columnMetaMap[focusedCellInfo.dataIndex] || columnMetaMapByLowerName[focusedCellInfo.dataIndex.toLowerCase()]; - return meta?.type ? ({meta.type}) : null; - })()} -
- {dataPanelIsJson && ( - - )} - {focusedCellWritable && ( - - )} -
-
- {focusedCellInfo ? ( - { - const newVal = val || ''; - setDataPanelValue(newVal); - // 只有值真正与原始值不同时才标记 dirty - dataPanelDirtyRef.current = newVal !== dataPanelOriginalRef.current; - }} - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: 'on', - fontSize: 13, - tabSize: 2, - automaticLayout: true, - readOnly: !focusedCellWritable, - lineNumbers: 'off', - glyphMargin: false, - folding: false, - lineDecorationsWidth: 4, - padding: { top: 6, bottom: 6 }, - }} - /> - ) : ( -
- 点击表格中的单元格以预览完整数据 -
- )} -
-
- )} + { + handleDataPanelFormatJson((errorMessage) => { + void message.error('JSON 格式无效:' + errorMessage); + }); + }} + onSave={handleDataPanelSave} + onValueChange={setDataPanelValue} + onDirtyChange={(dirty) => { + dataPanelDirtyRef.current = dirty; + }} + isDirtyComparedToOriginal={(value) => value !== dataPanelOriginalRef.current} + /> {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal(
= ({ document.body )} - {/* Cell Context Menu - 使用 Portal 渲染到 body,避免 backdropFilter 影响 fixed 定位 */} - {isTableSurfaceActive && !isV2Ui && cellContextMenu.visible && createPortal( -
e.stopPropagation()} - > -
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={handleCopyContextMenuFieldName} - > - - 复制字段名称 -
-
- {canModifyData && ( - <> -
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={handleCellSetNull} - > - 设置为 NULL -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={handleOpenContextMenuRowEditor} - > - - 编辑本行 -
-
0 ? 'pointer' : 'not-allowed', - transition: 'background 0.2s', - opacity: selectedRowKeys.length > 0 ? 1 : 0.5, - }} - onMouseEnter={(e) => { - if (selectedRowKeys.length > 0) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'; - }} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (selectedRowKeys.length > 0 && cellContextMenu.record) { - handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); - } - }} - > - - 填充到选中行 ({selectedRowKeys.length}) -
-
{ - if (copiedCellPatch) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'; - }} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (!copiedCellPatch) return; - const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; - handlePasteCopiedColumnsToSelectedRows(fallbackKey); - }} - > - - 粘贴已复制列(同名列) -
-
- - )} - {supportsCopyInsert && ( - <> -
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 INSERT -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 UPDATE -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 DELETE -
- - )} -
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 JSON -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 CSV -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) { - const records = getTargets(cellContextMenu.record); - const lines = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - return `| ${Object.values(vals).join(' | ')} |`; - }); - copyToClipboard(lines.join('\n')); - } - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 复制为 Markdown -
-
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record).catch(console.error); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 导出为 CSV -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record).catch(console.error); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 导出为 Excel -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record).catch(console.error); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 导出为 JSON -
-
e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'} - onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} - onClick={() => { - if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record).catch(console.error); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }} - > - 导出为 HTML -
-
, - document.body - )} + setCellContextMenu(prev => ({ ...prev, visible: false }))} + onCopyFieldName={handleCopyContextMenuFieldName} + onSetNull={handleCellSetNull} + onEditRow={handleOpenContextMenuRowEditor} + onFillToSelected={() => { + if (selectedRowKeys.length > 0 && cellContextMenu.record) { + handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); + } + }} + onPasteCopiedColumns={() => { + const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + handlePasteCopiedColumnsToSelectedRows(fallbackKey); + }} + onCopyInsert={() => { + if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); + }} + onCopyUpdate={() => { + if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); + }} + onCopyDelete={() => { + if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); + }} + onCopyJson={() => { + if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); + }} + onCopyCsv={() => { + if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); + }} + onCopyMarkdown={() => { + if (cellContextMenu.record) { + const records = getTargets(cellContextMenu.record); + const lines = records.map((r: any) => { + const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; + return `| ${Object.values(vals).join(' | ')} |`; + }); + copyToClipboard(lines.join('\n')); + } + }} + onExportCsv={() => { + if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record).catch(console.error); + }} + onExportXlsx={() => { + if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record).catch(console.error); + }} + onExportJson={() => { + if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record).catch(console.error); + }} + onExportHtml={() => { + if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record).catch(console.error); + }} + />
- {renderSecondaryActions()} + { + void handleOpenTableDdl(); + }} + /> diff --git a/frontend/src/components/DataGridColumnInfoPopoverContent.tsx b/frontend/src/components/DataGridColumnInfoPopoverContent.tsx new file mode 100644 index 0000000..c9ba1fd --- /dev/null +++ b/frontend/src/components/DataGridColumnInfoPopoverContent.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Button, Checkbox, Input } from 'antd'; + +export interface DataGridColumnInfoPopoverContentProps { + darkMode: boolean; + showColumnComment: boolean; + showColumnType: boolean; + columnSearchText: string; + allOrderedColumnNames: string[]; + localHiddenColumns: string[]; + enableColumnOrderMemory: boolean; + enableHiddenColumnMemory: boolean; + canResetOrder: boolean; + canResetHidden: boolean; + onShowColumnCommentChange: (checked: boolean) => void; + onShowColumnTypeChange: (checked: boolean) => void; + onToggleAllColumnsVisibility: (visible: boolean) => void; + onColumnSearchTextChange: (value: string) => void; + onToggleColumnVisibility: (columnName: string, visible: boolean) => void; + onEnableColumnOrderMemoryChange: (checked: boolean) => void; + onEnableHiddenColumnMemoryChange: (checked: boolean) => void; + onResetOrder: () => void; + onResetHidden: () => void; +} + +const DataGridColumnInfoPopoverContent: React.FC = ({ + darkMode, + showColumnComment, + showColumnType, + columnSearchText, + allOrderedColumnNames, + localHiddenColumns, + enableColumnOrderMemory, + enableHiddenColumnMemory, + canResetOrder, + canResetHidden, + onShowColumnCommentChange, + onShowColumnTypeChange, + onToggleAllColumnsVisibility, + onColumnSearchTextChange, + onToggleColumnVisibility, + onEnableColumnOrderMemoryChange, + onEnableHiddenColumnMemoryChange, + onResetOrder, + onResetHidden, +}) => ( +
+
显示设置
+ onShowColumnCommentChange(e.target.checked)}> + 表头显示备注 + + onShowColumnTypeChange(e.target.checked)}> + 表头显示类型 + +
+ + + onColumnSearchTextChange(e.target.value)} + allowClear + /> +
+ {allOrderedColumnNames + .filter((col) => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase())) + .map((col) => ( + onToggleColumnVisibility(col, e.target.checked)} + style={{ marginLeft: 0 }} + > + {col} + + ))} +
+ +
+ onEnableColumnOrderMemoryChange(e.target.checked)}> + 记忆自定义列序 + + onEnableHiddenColumnMemoryChange(e.target.checked)}> + 记忆隐藏列配置 + +
+ + +
+
+); + +export default DataGridColumnInfoPopoverContent; diff --git a/frontend/src/components/DataGridColumnTitle.test.tsx b/frontend/src/components/DataGridColumnTitle.test.tsx new file mode 100644 index 0000000..32103f1 --- /dev/null +++ b/frontend/src/components/DataGridColumnTitle.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import DataGridColumnTitle from './DataGridColumnTitle'; + +vi.mock('antd', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('DataGridColumnTitle', () => { + it('marks v2 table headers as single-line when column type and comment rows are hidden', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-grid-column-title-single-line="true"'); + expect(markup).not.toContain('gn-v2-column-title-type'); + expect(markup).not.toContain('gn-v2-column-title-comment'); + }); + + it('renders column type and comment rows when enabled', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('class="gn-v2-column-title"'); + expect(markup).toContain('class="gn-v2-column-title-type"'); + expect(markup).toContain('bigint'); + expect(markup).toContain('class="gn-v2-column-title-comment"'); + expect(markup).toContain('主键 ID'); + expect(markup).toContain('flex-direction:column'); + expect(markup).toContain('align-items:flex-start'); + }); + + it('renders foreign-key jump affordance when reference target exists', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-grid-fk-jump="true"'); + expect(markup).toContain('data-ref-table-name="customers"'); + }); +}); diff --git a/frontend/src/components/DataGridColumnTitle.tsx b/frontend/src/components/DataGridColumnTitle.tsx new file mode 100644 index 0000000..57c6d82 --- /dev/null +++ b/frontend/src/components/DataGridColumnTitle.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import { LinkOutlined } from '@ant-design/icons'; + +export interface DataGridColumnTitleProps { + columnName: string; + columnMeta?: { + type?: string; + comment?: string; + } | null; + foreignKeyTarget?: { + refTableName?: string; + refColumnName?: string; + } | null; + showColumnType: boolean; + showColumnComment: boolean; + metaFontSize: number; + columnMetaHintColor: string; + columnMetaTooltipColor: string; + darkMode: boolean; + onOpenForeignKey?: () => void; +} + +const DataGridColumnTitle: React.FC = ({ + columnName, + columnMeta, + foreignKeyTarget, + showColumnType, + showColumnComment, + metaFontSize, + columnMetaHintColor, + columnMetaTooltipColor, + darkMode, + onOpenForeignKey, +}) => { + const normalizedName = String(columnName || ''); + const columnType = String(columnMeta?.type || '').trim(); + const columnComment = String(columnMeta?.comment || '').trim(); + const refTableName = String(foreignKeyTarget?.refTableName || '').trim(); + const refColumnName = String(foreignKeyTarget?.refColumnName || '').trim(); + const shouldShowColumnType = showColumnType && columnType.length > 0; + const shouldShowColumnComment = showColumnComment && columnComment.length > 0; + const isSingleLineColumnTitle = !shouldShowColumnType && !shouldShowColumnComment; + + const hoverLines: string[] = []; + if (columnType) hoverLines.push(`类型:${columnType}`); + if (columnComment) hoverLines.push(`备注:${columnComment}`); + if (refTableName) { + const refColumnText = refColumnName ? `.${refColumnName}` : ''; + hoverLines.push(`外键:${refTableName}${refColumnText}`); + } + + const fieldLabel = refTableName ? ( + + ) : ( + {normalizedName} + ); + + const titleNode = ( +
+ {fieldLabel} + {shouldShowColumnType && ( + + {columnType} + + )} + {shouldShowColumnComment && ( + + {columnComment} + + )} +
+ ); + + if (hoverLines.length === 0) { + return titleNode; + } + + return ( + + {hoverLines.join('\n')} + + )} + styles={{ root: { maxWidth: 640 } }} + {...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})} + > + {titleNode} + + ); +}; + +export default DataGridColumnTitle; diff --git a/frontend/src/components/DataGridLegacyCellContextMenu.tsx b/frontend/src/components/DataGridLegacyCellContextMenu.tsx new file mode 100644 index 0000000..44b5c06 --- /dev/null +++ b/frontend/src/components/DataGridLegacyCellContextMenu.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { CopyOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; + +interface CellContextMenuState { + visible: boolean; + x: number; + y: number; + record: Record | null; + dataIndex: string; +} + +interface DataGridLegacyCellContextMenuProps { + visible: boolean; + darkMode: boolean; + bgContextMenu: string; + cellContextMenu: CellContextMenuState; + canModifyData: boolean; + selectedRowKeysLength: number; + copiedCellPatchAvailable: boolean; + supportsCopyInsert: boolean; + onClose: () => void; + onCopyFieldName: () => void; + onSetNull: () => void; + onEditRow: () => void; + onFillToSelected: () => void; + onPasteCopiedColumns: () => void; + onCopyInsert: () => void; + onCopyUpdate: () => void; + onCopyDelete: () => void; + onCopyJson: () => void; + onCopyCsv: () => void; + onCopyMarkdown: () => void; + onExportCsv: () => void; + onExportXlsx: () => void; + onExportJson: () => void; + onExportHtml: () => void; +} + +const baseItemStyle: React.CSSProperties = { + padding: '8px 12px', + cursor: 'pointer', + transition: 'background 0.2s', +}; + +const separatorStyle = (darkMode: boolean): React.CSSProperties => ({ + height: 1, + background: darkMode ? '#303030' : '#f0f0f0', + margin: '4px 0', +}); + +const DataGridLegacyCellContextMenu: React.FC = ({ + visible, + darkMode, + bgContextMenu, + cellContextMenu, + canModifyData, + selectedRowKeysLength, + copiedCellPatchAvailable, + supportsCopyInsert, + onClose, + onCopyFieldName, + onSetNull, + onEditRow, + onFillToSelected, + onPasteCopiedColumns, + onCopyInsert, + onCopyUpdate, + onCopyDelete, + onCopyJson, + onCopyCsv, + onCopyMarkdown, + onExportCsv, + onExportXlsx, + onExportJson, + onExportHtml, +}) => { + if (!visible) { + return null; + } + + const hoverBg = darkMode ? '#303030' : '#f5f5f5'; + const canFillRows = selectedRowKeysLength > 0; + + const makeHoverHandlers = (enabled = true) => ({ + onMouseEnter: (e: React.MouseEvent) => { + if (enabled) e.currentTarget.style.background = hoverBg; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.background = 'transparent'; + }, + }); + + const closeAfter = (callback: () => void) => () => { + callback(); + onClose(); + }; + + return createPortal( +
e.stopPropagation()} + > +
+ + 复制字段名称 +
+
+ {canModifyData && ( + <> +
+ 设置为 NULL +
+
+ + 编辑本行 +
+
{ + if (canFillRows) onFillToSelected(); + }} + > + + 填充到选中行 ({selectedRowKeysLength}) +
+
{ + if (copiedCellPatchAvailable) onPasteCopiedColumns(); + }} + > + + 粘贴已复制列(同名列) +
+
+ + )} + {supportsCopyInsert && ( + <> +
复制为 INSERT
+
复制为 UPDATE
+
复制为 DELETE
+ + )} +
复制为 JSON
+
复制为 CSV
+
复制为 Markdown
+
+
导出为 CSV
+
导出为 Excel
+
导出为 JSON
+
导出为 HTML
+
, + document.body, + ); +}; + +export default DataGridLegacyCellContextMenu; diff --git a/frontend/src/components/DataGridModals.tsx b/frontend/src/components/DataGridModals.tsx new file mode 100644 index 0000000..9ad4098 --- /dev/null +++ b/frontend/src/components/DataGridModals.tsx @@ -0,0 +1,313 @@ +import React from 'react'; +import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd'; +import dayjs from 'dayjs'; +import { CopyOutlined } from '@ant-design/icons'; +import Editor from './MonacoEditor'; +import { + TEMPORAL_FORMATS, + getTemporalPickerType, + type TemporalPickerType, +} from './dataGridTemporal'; + +type ColumnMeta = { + type: string; + comment: string; +}; + +export interface DataGridRowEditorField { + columnName: string; + sample: string; + placeholder?: string; + isJson: boolean; + useTextArea: boolean; + pickerType?: TemporalPickerType; + isTemporalValue: boolean; + isWritable: boolean; +} + +export interface DataGridModalsProps { + tableName?: string; + darkMode: boolean; + displayColumnNames: string[]; + rowEditorOpen: boolean; + rowEditorRowKey: string; + rowEditorForm: any; + rowEditorFields: DataGridRowEditorField[]; + onCloseRowEditor: () => void; + onApplyRowEditor: () => void; + onOpenRowEditorFieldEditor: (columnName: string) => void; + cellEditorOpen: boolean; + cellEditorMeta: { record: Record; dataIndex: string; title: string } | null; + cellEditorIsJson: boolean; + cellEditorValue: string; + onCloseCellEditor: () => void; + onFormatJsonInEditor: () => void; + onSaveCellEditor: () => void; + onCellEditorValueChange: (value: string) => void; + batchEditModalOpen: boolean; + selectedCellsSize: number; + batchEditSetNull: boolean; + batchEditValue: string; + onCloseBatchEditModal: () => void; + onApplyBatchFill: () => void; + onBatchEditSetNullChange: (checked: boolean) => void; + onBatchEditValueChange: (value: string) => void; + jsonEditorOpen: boolean; + jsonEditorValue: string; + onCloseJsonEditor: () => void; + onFormatJsonEditor: () => void; + onApplyJsonEditor: () => void; + onJsonEditorValueChange: (value: string) => void; + ddlModalOpen: boolean; + ddlLoading: boolean; + ddlText: string; + onCloseDdlModal: () => void; + onCopyDdl: () => void; +} + +const DataGridModals: React.FC = ({ + tableName, + darkMode, + rowEditorOpen, + rowEditorRowKey, + rowEditorForm, + rowEditorFields, + onCloseRowEditor, + onApplyRowEditor, + onOpenRowEditorFieldEditor, + cellEditorOpen, + cellEditorMeta, + cellEditorIsJson, + cellEditorValue, + onCloseCellEditor, + onFormatJsonInEditor, + onSaveCellEditor, + onCellEditorValueChange, + batchEditModalOpen, + selectedCellsSize, + batchEditSetNull, + batchEditValue, + onCloseBatchEditModal, + onApplyBatchFill, + onBatchEditSetNullChange, + onBatchEditValueChange, + jsonEditorOpen, + jsonEditorValue, + onCloseJsonEditor, + onFormatJsonEditor, + onApplyJsonEditor, + onJsonEditorValueChange, + ddlModalOpen, + ddlLoading, + ddlText, + onCloseDdlModal, + onCopyDdl, +}) => ( + <> + 取消, + , + ]} + > +
+ {tableName ? `${tableName}` : ''} + {rowEditorRowKey ? `rowKey: ${rowEditorRowKey}` : ''} +
+
+
+ {rowEditorFields.map((field) => ( + +
+ + {field.isTemporalValue && field.pickerType ? ( + field.pickerType === 'time' ? ( + + ) : field.pickerType === 'datetime' ? ( + + ) : ( + + ) + ) : field.useTextArea ? ( + + ) : ( + + )} + + +
+
+ ))} +
+ +
+ + 格式化 JSON, + , + , + ]} + > +
+ {cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''} +
+ {cellEditorOpen && ( + onCellEditorValueChange(value || '')} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + fontSize: 14, + tabSize: 2, + automaticLayout: true, + }} + /> + )} +
+ + +
+ onBatchEditSetNullChange(event.target.checked)}> + 设置为 NULL + +
+ {!batchEditSetNull && ( + onBatchEditValueChange(event.target.value)} + placeholder="输入要填充的值" + autoSize={{ minRows: 3, maxRows: 10 }} + autoFocus + /> + )} +
+ + 格式化 JSON, + , + , + ]} + > +
+ 说明:此处按当前结果集顺序编辑,不支持在 JSON 模式增删记录(可在表格模式操作)。 +
+ {jsonEditorOpen && ( + onJsonEditorValueChange(value || '')} + options={{ + readOnly: false, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'off', + fontSize: 12, + tabSize: 2, + automaticLayout: true, + }} + /> + )} +
+ + } onClick={onCopyDdl} disabled={!ddlText.trim()}> + 复制 DDL + , + , + ]} + > + {ddlModalOpen && ( + + )} + + +); + +export default DataGridModals; diff --git a/frontend/src/components/DataGridPageFind.tsx b/frontend/src/components/DataGridPageFind.tsx new file mode 100644 index 0000000..a49ac3a --- /dev/null +++ b/frontend/src/components/DataGridPageFind.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Button, Input, Tooltip } from 'antd'; +import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons'; + +export interface DataGridPageFindProps { + isV2Ui: boolean; + darkMode: boolean; + inputProps?: Record; + pageFindText: string; + normalizedPageFindText: string; + hasMatches: boolean; + activePageFindPosition: number; + matchCount: number; + occurrenceCount: number; + matchedCellCount: number; + onPageFindTextChange: (value: string) => void; + onNavigatePrevious: () => void; + onNavigateNext: () => void; +} + +const DataGridPageFind: React.FC = ({ + isV2Ui, + darkMode, + inputProps, + pageFindText, + normalizedPageFindText, + hasMatches, + activePageFindPosition, + matchCount, + occurrenceCount, + matchedCellCount, + onPageFindTextChange, + onNavigatePrevious, + onNavigateNext, +}) => ( + +
+ } + placeholder="当前页查找..." + value={pageFindText} + onChange={(event) => onPageFindTextChange(event.target.value)} + style={isV2Ui ? undefined : { width: 220 }} + /> + + + {normalizedPageFindText && ( + + {hasMatches ? `${activePageFindPosition} / ${matchCount} · ` : ''}匹配 {occurrenceCount} 处 / {matchedCellCount} 个单元格 + + )} +
+
+); + +export default DataGridPageFind; diff --git a/frontend/src/components/DataGridPaginationBar.tsx b/frontend/src/components/DataGridPaginationBar.tsx new file mode 100644 index 0000000..995d9eb --- /dev/null +++ b/frontend/src/components/DataGridPaginationBar.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Button, Pagination, Select } from 'antd'; +import { LeftOutlined, RightOutlined } from '@ant-design/icons'; + +interface DataGridPaginationState { + current: number; + pageSize: number; + total: number; + totalKnown?: boolean; + totalApprox?: boolean; + approximateTotal?: number; + totalCountLoading?: boolean; + totalCountCancelled?: boolean; +} + +export interface DataGridPaginationBarProps { + isV2Ui: boolean; + pagination?: DataGridPaginationState; + paginationV2SummaryText: string; + paginationSummaryText: string; + paginationPageText: string; + paginationControlTotal: number; + paginationTotalPages: number; + paginationPageSizeOptions: string[]; + onPageChange?: (page: number, size: number) => void; + onPageSizeChange: (value: string) => void; + onV2PageStep: (direction: 'previous' | 'next') => void; +} + +const DataGridPaginationBar: React.FC = ({ + isV2Ui, + pagination, + paginationV2SummaryText, + paginationSummaryText, + paginationPageText, + paginationControlTotal, + paginationTotalPages, + paginationPageSizeOptions, + onPageChange, + onPageSizeChange, + onV2PageStep, +}) => { + if (!pagination) { + return null; + } + + return ( +
+ {isV2Ui ? ( +
+
+ {paginationV2SummaryText} +
+
+ )} +
+ ); +}; + +export default DataGridPaginationBar; diff --git a/frontend/src/components/DataGridPreviewPanel.tsx b/frontend/src/components/DataGridPreviewPanel.tsx new file mode 100644 index 0000000..2b72b79 --- /dev/null +++ b/frontend/src/components/DataGridPreviewPanel.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Button } from 'antd'; +import Editor from './MonacoEditor'; + +type ColumnMeta = { + type?: string; +}; + +interface DataGridPreviewPanelProps { + visible: boolean; + isTableSurfaceActive: boolean; + darkMode: boolean; + focusedCellInfo: { dataIndex: string } | null; + dataPanelIsJson: boolean; + focusedCellWritable: boolean; + dataPanelValue: string; + columnMetaMap: Record; + columnMetaMapByLowerName: Record; + onFormatJson: () => void; + onSave: () => void; + onValueChange: (value: string) => void; + onDirtyChange: (dirty: boolean) => void; + isDirtyComparedToOriginal: (value: string) => boolean; +} + +const DataGridPreviewPanel: React.FC = ({ + visible, + isTableSurfaceActive, + darkMode, + focusedCellInfo, + dataPanelIsJson, + focusedCellWritable, + dataPanelValue, + columnMetaMap, + columnMetaMapByLowerName, + onFormatJson, + onSave, + onValueChange, + onDirtyChange, + isDirtyComparedToOriginal, +}) => { + if (!visible || !isTableSurfaceActive) { + return null; + } + + const meta = focusedCellInfo + ? (columnMetaMap[focusedCellInfo.dataIndex] || columnMetaMapByLowerName[focusedCellInfo.dataIndex.toLowerCase()]) + : undefined; + + return ( +
+
+ + {focusedCellInfo ? focusedCellInfo.dataIndex : '点击单元格查看数据'} + + {meta?.type ? ({meta.type}) : null} +
+ {dataPanelIsJson && ( + + )} + {focusedCellWritable && ( + + )} +
+
+ {focusedCellInfo ? ( + { + const newVal = val || ''; + onValueChange(newVal); + onDirtyChange(isDirtyComparedToOriginal(newVal)); + }} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + fontSize: 13, + tabSize: 2, + automaticLayout: true, + readOnly: !focusedCellWritable, + lineNumbers: 'off', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 4, + padding: { top: 6, bottom: 6 }, + }} + /> + ) : ( +
+ 点击表格中的单元格以预览完整数据 +
+ )} +
+
+ ); +}; + +export default DataGridPreviewPanel; diff --git a/frontend/src/components/DataGridRecordViews.tsx b/frontend/src/components/DataGridRecordViews.tsx new file mode 100644 index 0000000..0bfb4f7 --- /dev/null +++ b/frontend/src/components/DataGridRecordViews.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Button } from 'antd'; +import Editor from './MonacoEditor'; + +interface DataGridJsonViewProps { + darkMode: boolean; + rowCount: number; + canModifyData: boolean; + jsonViewText: string; + onOpenJsonEditor: () => void; +} + +export const DataGridJsonView: React.FC = ({ + darkMode, + rowCount, + canModifyData, + jsonViewText, + onOpenJsonEditor, +}) => ( +
+
+ + {rowCount === 0 ? '当前结果集无数据' : `当前结果集 ${rowCount} 条记录`} + + {canModifyData && ( + + )} +
+
+ +
+
+); + +interface DataGridTextViewProps { + darkMode: boolean; + rowCount: number; + textRecordIndex: number; + canModifyData: boolean; + currentTextRow: Record | null; + displayOutputColumnNames: string[]; + onPrev: () => void; + onNext: () => void; + onEditCurrent: () => void; + formatTextViewValue: (value: any, columnName?: string) => string; +} + +export const DataGridTextView: React.FC = ({ + darkMode, + rowCount, + textRecordIndex, + canModifyData, + currentTextRow, + displayOutputColumnNames, + onPrev, + onNext, + onEditCurrent, + formatTextViewValue, +}) => ( +
+
+ + + + {rowCount === 0 ? '当前结果集无数据' : `记录 ${textRecordIndex + 1} / ${rowCount}`} + + {canModifyData && ( + + )} +
+
+ {currentTextRow ? displayOutputColumnNames.map((col) => ( +
+
+ {col} : +
+
+ {formatTextViewValue(currentTextRow[col], col)} +
+
+ )) : ( +
+ 当前结果集无数据 +
+ )} +
+
+); diff --git a/frontend/src/components/DataGridResultViewSwitcher.tsx b/frontend/src/components/DataGridResultViewSwitcher.tsx new file mode 100644 index 0000000..35d382f --- /dev/null +++ b/frontend/src/components/DataGridResultViewSwitcher.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Segmented } from 'antd'; + +type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; + +export interface DataGridResultViewSwitcherProps { + isV2Ui: boolean; + darkMode: boolean; + viewMode: GridViewMode; + onViewModeChange: (nextMode: GridViewMode) => void; +} + +const DataGridResultViewSwitcher: React.FC = ({ + isV2Ui, + darkMode, + viewMode, + onViewModeChange, +}) => ( +
+ 结果视图 + onViewModeChange(String(value) as GridViewMode)} + /> +
+); + +export default DataGridResultViewSwitcher; diff --git a/frontend/src/components/DataGridSecondaryActions.tsx b/frontend/src/components/DataGridSecondaryActions.tsx new file mode 100644 index 0000000..f921b5a --- /dev/null +++ b/frontend/src/components/DataGridSecondaryActions.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Button, Popover } from 'antd'; +import { + ConsoleSqlOutlined, + EditOutlined, + FileTextOutlined, + LinkOutlined, + TableOutlined, +} from '@ant-design/icons'; + +type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; + +export interface DataGridSecondaryActionsProps { + isV2Ui: boolean; + canViewDdl: boolean; + viewMode: GridViewMode; + ddlLoading: boolean; + showColumnComment: boolean; + showColumnType: boolean; + mergedDisplayCount: number; + pendingChangeCount: number; + resultViewSwitcher: React.ReactNode; + columnInfoSettingContent: React.ReactNode; + pageFindContent: React.ReactNode; + paginationContent: React.ReactNode; + onViewModeChange: (nextMode: GridViewMode) => void; + dataPanelOpen: boolean; + isTableSurfaceActive: boolean; + onToggleDataPanel: () => void; + onOpenTableDdl: () => void; +} + +const DataGridSecondaryActions: React.FC = ({ + isV2Ui, + canViewDdl, + viewMode, + ddlLoading, + showColumnComment, + showColumnType, + mergedDisplayCount, + pendingChangeCount, + resultViewSwitcher, + columnInfoSettingContent, + pageFindContent, + paginationContent, + onViewModeChange, + dataPanelOpen, + isTableSurfaceActive, + onToggleDataPanel, + onOpenTableDdl, +}) => { + if (isV2Ui) { + const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [ + { key: 'table', label: '数据预览', icon: }, + { key: 'fields', label: '字段信息', icon: }, + { key: 'ddl', label: '查看 DDL', icon: , disabled: !canViewDdl }, + { key: 'er', label: 'ER 图', icon: }, + ]; + + return ( +
+
+ {viewTabItems.map((item) => ( + + ))} +
+
+ {resultViewSwitcher} + + + +
+ live + {mergedDisplayCount} 行 + 未提交 {pendingChangeCount} +
+ {pageFindContent} + + ); + } + + return ( + <> +
+
+ + + + + {canViewDdl && ( + + )} + {pageFindContent} +
+ {resultViewSwitcher} +
+ {paginationContent} + + ); +}; + +export default DataGridSecondaryActions; diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx new file mode 100644 index 0000000..d9be960 --- /dev/null +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -0,0 +1,731 @@ +import React from 'react'; +import { AutoComplete, Button, Checkbox, Dropdown, Input, Select, Tooltip } from 'antd'; +import type { MenuProps } from 'antd'; +import { + ClearOutlined, + CloseOutlined, + ConsoleSqlOutlined, + CopyOutlined, + DeleteOutlined, + DownOutlined, + EditOutlined, + ExportOutlined, + FilterOutlined, + ImportOutlined, + PlusOutlined, + ReloadOutlined, + RobotOutlined, + SaveOutlined, + TableOutlined, + UndoOutlined, + VerticalAlignBottomOutlined, +} from '@ant-design/icons'; + +type GridFilterCondition = { + id: number; + enabled?: boolean; + logic?: string; + column: string; + op: string; + value: string; + value2?: string; +}; + +type GridSortInfo = { + columnKey: string; + order: string; + enabled?: boolean; +}; + +export interface DataGridToolbarFrameProps { + isV2Ui: boolean; + tableName?: string; + dbName?: string; + loading: boolean; + darkMode: boolean; + bgFilter: string; + panelFrameColor: string; + panelRadius: number; + panelOuterGap: number; + panelPaddingY: number; + panelPaddingX: number; + toolbarBottomPadding: number; + filterTopPadding: number; + selectionAccentHex: string; + toolbarDividerColor: string; + showFilter?: boolean; + filterPanelRef?: React.RefObject; + onReload?: () => void; + onToggleFilter?: () => void; + canModifyData: boolean; + selectedRowKeysLength: number; + copiedRowsForPasteLength: number; + allSelectedAreDeleted: boolean; + cellEditMode: boolean; + selectedCellsSize: number; + copiedCellPatchColumnCount: number; + hasChanges: boolean; + pendingChangeCount: number; + canImport: boolean; + canExport: boolean; + isQueryResultExport: boolean; + canCopyQueryResult: boolean; + prefersManualTotalCount: boolean; + aiShortcutLabel: string; + legacyAiButtonStyle?: React.CSSProperties; + paginationTotalCountLoading?: boolean; + filterConditions: GridFilterCondition[]; + sortInfo: GridSortInfo[]; + displayColumnNames: string[]; + quickWhereDraft: string; + quickWhereCondition?: string; + quickWhereSuggestionsOpen: boolean; + quickWhereSuggestionOptions: Array<{ value: string; label?: React.ReactNode; insertText?: string }>; + gridFieldSelectOptions: Array<{ value: string; label: string; title: string }>; + filterLogicOptions: Array<{ value: string; label: string }>; + filterOpOptions: Array<{ value: string; label: string }>; + renderGridFieldSelectOption: (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => React.ReactNode; + noAutoCapInputProps: Record; + filterFieldSelectStyle: React.CSSProperties; + filterFieldPopupWidth: number; + exportMenu: MenuProps['items']; + queryResultCopyMenu: MenuProps['items']; + dbType: string; + onResetPendingChanges: () => void; + onRefresh: () => void; + onToggleFilterClick: () => void; + onAddRow: () => void; + onCopySelectedRowsForPaste: () => void; + onPasteCopiedRowsAsNew: () => void; + onUndoDeleteSelected: () => void; + onDeleteSelected: () => void; + onToggleCellEditMode: () => void; + onCopySelectedCellsToClipboard: () => void; + onCopySelectedColumnsFromRow: () => void; + onOpenBatchEditModal: () => void; + onPasteCopiedColumnsToSelectedRows: () => void; + onCommit: () => void; + onPreviewChanges: () => void; + onImport: () => void; + onCopyQueryResultCsv: () => void; + onRequestAiInsight: () => void; + onToggleTotalCount: () => void; + onQuickWhereDraftChange: (value: string) => void; + onQuickWhereSuggestionsOpenChange: (open: boolean) => void; + onQuickWhereKeyDown: (event: React.KeyboardEvent) => void; + onQuickWhereSelect: (value: string, option: unknown) => void; + onQuickWhereCopy: (event: React.ClipboardEvent) => void; + onQuickWhereCut: (event: React.ClipboardEvent) => void; + onQuickWherePaste: (event: React.ClipboardEvent) => void; + onApplyQuickWhere: () => void; + onClearQuickWhere: () => void; + updateFilter: (id: number, field: keyof GridFilterCondition, value: string | boolean) => void; + removeFilter: (id: number) => void; + addFilter: () => void; + isListOp: (op: string) => boolean; + isBetweenOp: (op: string) => boolean; + isNoValueOp: (op: string) => boolean; + enableSortControls: boolean; + onApplySortInfo: (next: GridSortInfo[]) => void; + onApplyFilters: () => void; + onEnableAllFilters: () => void; + onDisableAllFilters: () => void; + onClearFiltersAndSorts: () => void; +} + +const DataGridToolbarFrame: React.FC = ({ + isV2Ui, + tableName, + dbName, + loading, + darkMode, + bgFilter, + panelFrameColor, + panelRadius, + panelOuterGap, + panelPaddingY, + panelPaddingX, + toolbarBottomPadding, + filterTopPadding, + selectionAccentHex, + toolbarDividerColor, + showFilter, + filterPanelRef, + onReload, + onToggleFilter, + canModifyData, + selectedRowKeysLength, + copiedRowsForPasteLength, + allSelectedAreDeleted, + cellEditMode, + selectedCellsSize, + copiedCellPatchColumnCount, + hasChanges, + pendingChangeCount, + canImport, + canExport, + isQueryResultExport, + canCopyQueryResult, + prefersManualTotalCount, + aiShortcutLabel, + legacyAiButtonStyle, + paginationTotalCountLoading, + filterConditions, + sortInfo, + displayColumnNames, + quickWhereDraft, + quickWhereCondition, + quickWhereSuggestionsOpen, + quickWhereSuggestionOptions, + gridFieldSelectOptions, + filterLogicOptions, + filterOpOptions, + renderGridFieldSelectOption, + noAutoCapInputProps, + filterFieldSelectStyle, + filterFieldPopupWidth, + exportMenu, + queryResultCopyMenu, + dbType, + onResetPendingChanges, + onRefresh, + onToggleFilterClick, + onAddRow, + onCopySelectedRowsForPaste, + onPasteCopiedRowsAsNew, + onUndoDeleteSelected, + onDeleteSelected, + onToggleCellEditMode, + onCopySelectedCellsToClipboard, + onCopySelectedColumnsFromRow, + onOpenBatchEditModal, + onPasteCopiedColumnsToSelectedRows, + onCommit, + onPreviewChanges, + onImport, + onCopyQueryResultCsv, + onRequestAiInsight, + onToggleTotalCount, + onQuickWhereDraftChange, + onQuickWhereSuggestionsOpenChange, + onQuickWhereKeyDown, + onQuickWhereSelect, + onQuickWhereCopy, + onQuickWhereCut, + onQuickWherePaste, + onApplyQuickWhere, + onClearQuickWhere, + updateFilter, + removeFilter, + addFilter, + isListOp, + isBetweenOp, + isNoValueOp, + enableSortControls, + onApplySortInfo, + onApplyFilters, + onEnableAllFilters, + onDisableAllFilters, + onClearFiltersAndSorts, +}) => { + const renderToolbarDivider = () => ( +