From ece61d097f8c0d45482f1efa012e673d7dc72082 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Jul 2026 12:30:30 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=8B=96=E9=80=89=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 允许聚合与只读结果拖选单元格并复制 TSV - 增加选区工具栏操作和 Ctrl/Cmd+C 快捷键 - 隔离后台结果页与编辑操作,避免快捷键抢占 Fixes #673 --- .../src/components/DataGrid.layout.test.tsx | 19 ++++- frontend/src/components/DataGrid.tsx | 3 + .../src/components/DataGridToolbarFrame.tsx | 13 ++++ .../QueryEditor.results-and-drop.test.tsx | 73 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 1 + .../components/QueryEditorResultsPanel.tsx | 3 + .../components/dataGridSelectionCopy.test.ts | 20 ++++- .../src/components/dataGridSelectionCopy.ts | 10 +++ .../src/components/useDataGridBatchActions.ts | 32 ++++++-- .../src/components/useDataGridV2Actions.ts | 31 +++++--- 10 files changed, 186 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 479c3d06..1a49f3c6 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -1126,7 +1126,7 @@ describe('DataGrid layout', () => { expect(dataGridSource).toContain('if (activeSelection.size === 0) {'); expect(dataGridSource).toContain('closeCellEditMode();'); expect(dataGridSource).toContain('resetCellSelection();'); - expect(dataGridSource).toContain("tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable"); + expect(dataGridSource).toContain('activeElement?.closest(nativeShortcutGuard) || eventTarget?.closest(nativeShortcutGuard)'); expect(paginationSource).toContain("padding: 0"); expect(paginationSource).toContain("justifyContent: 'flex-start'"); }); @@ -2464,6 +2464,23 @@ describe('DataGrid layout', () => { expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1); }); + it('keeps range selection and Ctrl/Cmd+C available for read-only aggregate results', () => { + const batchActionsSource = readFileSync(new URL('./useDataGridBatchActions.ts', import.meta.url), 'utf8'); + const v2ActionsSource = readFileSync(new URL('./useDataGridV2Actions.ts', import.meta.url), 'utf8'); + const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); + + expect(batchActionsSource).toContain('if (!isActive || !isTableSurfaceActive) return;'); + expect(batchActionsSource).not.toContain('if (!canModifyData || !isTableSurfaceActive) return;'); + expect(batchActionsSource).toContain('canSelectGridCellForClipboard({'); + expect(batchActionsSource).toContain('if (canModifyData && !cellEditModeRef.current)'); + expect(batchActionsSource).toContain('markCellSelectionDeleteEligible(canModifyData);'); + expect(v2ActionsSource).toContain('if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;'); + expect(v2ActionsSource).toContain("String(event.key || '').toLowerCase() === 'c'"); + expect(v2ActionsSource).toContain('if (document.getSelection?.()?.toString()) return;'); + expect(toolbarSource).toContain('!canModifyData && selectedCellsSize > 0'); + expect(toolbarSource).toContain('data-grid-copy-selection-action="true"'); + }); + it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => { const source = readDataGridSource(); const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 6419b9cb..0ea57329 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1773,6 +1773,7 @@ const DataGrid: React.FC = ({ effectiveEditLocator, isCellValueEqualForDiff, isDraggingRef, + isActive, isTableSurfaceActive, isWritableResultColumn, makeCellKey, @@ -3440,6 +3441,8 @@ const DataGrid: React.FC = ({ hasChanges, hasExplicitSort, hasFilteredExportSql, + isActive, + isTableSurfaceActive, isQueryResultExport, mergedDisplayData, modal, diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index b9330504..2c9b995f 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -434,6 +434,19 @@ const DataGridToolbarFrame: React.FC = ({ )} + {!canModifyData && selectedCellsSize > 0 && ( + <> + {renderToolbarDivider()} + + + )} + <> {renderToolbarDivider()} diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 40740549..5110c209 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -637,6 +637,7 @@ describe('QueryEditor external SQL save', () => { readOnly: true, }]} activeResultKey="result-1" + isActive loading={false} executionError="" sqlLogCount={1} @@ -3327,6 +3328,7 @@ describe('QueryEditor external SQL save', () => { sortInfo, }]} activeResultKey="result-1" + isActive loading={false} executionError="" sqlLogCount={0} @@ -3359,6 +3361,75 @@ describe('QueryEditor external SQL save', () => { renderer.unmount(); }); + it('activates shortcuts only for the visible result grid in the active query editor', async () => { + const resultSets = [ + { + key: 'result-1', + sql: 'select 1 as value', + rows: [{ value: 1 }], + columns: ['value'], + pkColumns: [], + readOnly: true, + }, + { + key: 'result-2', + sql: 'select 2 as value', + rows: [{ value: 2 }], + columns: ['value'], + pkColumns: [], + readOnly: true, + }, + ]; + const renderPanel = (activeResultKey: string, isActive: boolean) => ( + + ); + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create(renderPanel('result-1', true)); + }); + expect(dataGridState.latestProps?.data).toEqual([{ value: 1 }]); + expect(dataGridState.latestProps?.isActive).toBe(true); + + await act(async () => { + renderer.update(renderPanel('result-1', false)); + }); + expect(dataGridState.latestProps?.isActive).toBe(false); + + await act(async () => { + renderer.update(renderPanel('result-2', true)); + }); + expect(dataGridState.latestProps?.data).toEqual([{ value: 2 }]); + expect(dataGridState.latestProps?.isActive).toBe(true); + expect(readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8')) + .toContain('isActive={isActive && resolvedActiveResultKey === rs.key}'); + + renderer.unmount(); + }); + it('sorts complete query results locally and restores execution order when cleared', async () => { const query = "select 3 as id, 'Zulu' as name union all select 1, 'Alpha' union all select 2, 'Alpha';"; backendApp.DBQueryMulti.mockResolvedValueOnce({ @@ -3476,6 +3547,7 @@ describe('QueryEditor external SQL save', () => { { = ({ tab, isAc = ({ resultSets, activeResultKey, + isActive, loading, executionError, sqlLogCount, @@ -532,6 +534,7 @@ const QueryEditorResultsPanel: React.FC = ({ { + it('allows displayed read-only cells while keeping editable expressions out of batch selection', () => { + expect(canSelectGridCellForClipboard({ + canModifyData: false, + isDisplayedColumn: true, + isWritableColumn: false, + })).toBe(true); + expect(canSelectGridCellForClipboard({ + canModifyData: true, + isDisplayedColumn: true, + isWritableColumn: false, + })).toBe(false); + expect(canSelectGridCellForClipboard({ + canModifyData: false, + isDisplayedColumn: false, + isWritableColumn: false, + })).toBe(false); + }); + it('builds clipboard text in visible row and column order', () => { const text = buildSelectedCellClipboardText({ selectedCells: [ diff --git a/frontend/src/components/dataGridSelectionCopy.ts b/frontend/src/components/dataGridSelectionCopy.ts index b0cc2756..c9287f83 100644 --- a/frontend/src/components/dataGridSelectionCopy.ts +++ b/frontend/src/components/dataGridSelectionCopy.ts @@ -3,6 +3,16 @@ export interface SelectedGridCell { colName: string; } +export const canSelectGridCellForClipboard = ({ + canModifyData, + isDisplayedColumn, + isWritableColumn, +}: { + canModifyData: boolean; + isDisplayedColumn: boolean; + isWritableColumn: boolean; +}): boolean => isDisplayedColumn && (!canModifyData || isWritableColumn); + const normalizeClipboardCellValue = (value: unknown): string => { if (value === null || value === undefined) { return 'NULL'; diff --git a/frontend/src/components/useDataGridBatchActions.ts b/frontend/src/components/useDataGridBatchActions.ts index dad168fc..969226d1 100644 --- a/frontend/src/components/useDataGridBatchActions.ts +++ b/frontend/src/components/useDataGridBatchActions.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react'; import type React from 'react'; import { message } from 'antd'; import type { Item } from './DataGridCore'; +import { canSelectGridCellForClipboard } from './dataGridSelectionCopy'; type DataGridBatchActionsContext = Record & { CELL_SELECTION_DRAG_THRESHOLD_PX: number; @@ -75,6 +76,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => { displayColumnNames, displayDataRef, effectiveEditLocator, + isActive, isCellValueEqualForDiff, isDraggingRef, isTableSurfaceActive, @@ -102,6 +104,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => { } = ctx; const handleBatchFillCells = useCallback(() => { + if (!canModifyData) return; const cellsToFill = currentSelectionRef.current; if (cellsToFill.size === 0) { void message.info(translateDataGrid('data_grid.message.select_cells_to_fill')); @@ -131,6 +134,7 @@ const handleBatchFillCells = useCallback(() => { const parts = splitCellKey(cellKey); if (!parts) return; const { rowKey, colName } = parts; + if (!isWritableResultColumn(colName, effectiveEditLocator)) return; const existing = modifiedRows[rowKey]; const baseRow = baseRowMap.get(rowKey); @@ -200,12 +204,13 @@ const handleBatchFillCells = useCallback(() => { cellSelectionAutoScrollRafRef.current = null; } updateCellSelection(new Set()); - }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, markCellSelectionDeleteEligible, translateDataGrid]); + }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, markCellSelectionDeleteEligible, translateDataGrid, canModifyData, effectiveEditLocator, isWritableResultColumn, splitCellKey]); - // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 + // 事件委托:在容器级别处理单元格拖选。可编辑结果会自动进入编辑模式, + // 只读/聚合查询结果仅保留选区与复制能力,不触发任何数据修改入口。 useEffect(() => { const container = containerRef.current; - if (!canModifyData || !isTableSurfaceActive) return; + if (!isActive || !isTableSurfaceActive) return; if (!container) return; const EDGE_THRESHOLD_PX = 28; const MIN_SCROLL_STEP = 8; @@ -221,7 +226,11 @@ const handleBatchFillCells = useCallback(() => { const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement; if (!cell || !container.contains(cell)) return null; const colName = cell.getAttribute('data-col-name'); - if (!colName || !isWritableResultColumn(colName, effectiveEditLocator)) return null; + if (!colName || !canSelectGridCellForClipboard({ + canModifyData, + isDisplayedColumn: columnIndexMap.has(colName), + isWritableColumn: isWritableResultColumn(colName, effectiveEditLocator), + })) return null; return cell; }; @@ -263,7 +272,13 @@ const handleBatchFillCells = useCallback(() => { const row = currentData[i]; const rKey = String(row?.[GONAVI_ROW_KEY]); for (let j = minColIndex; j <= maxColIndex; j++) { - newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); + const colName = displayColumnNames[j]; + if (!canSelectGridCellForClipboard({ + canModifyData, + isDisplayedColumn: true, + isWritableColumn: isWritableResultColumn(colName, effectiveEditLocator), + })) continue; + newSelectedCells.add(makeCellKey(rKey, colName)); } } @@ -360,11 +375,12 @@ const handleBatchFillCells = useCallback(() => { }; const beginCellSelection = (cellInfo: { rowKey: string; colName: string }, x: number, y: number) => { - if (!cellEditModeRef.current) { + if (canModifyData && !cellEditModeRef.current) { cellEditModeRef.current = true; setCellEditMode(true); } suppressCellSelectionClickRef.current = true; + document.getSelection?.()?.removeAllRanges(); pendingCellSelectionStartRef.current = null; isDraggingRef.current = true; cellSelectionPointerRef.current = { x, y }; @@ -446,7 +462,7 @@ const handleBatchFillCells = useCallback(() => { if (currentSelectionRef.current.size > 0) { setSelectedCells(new Set(currentSelectionRef.current)); - markCellSelectionDeleteEligible(true); + markCellSelectionDeleteEligible(canModifyData); } }; @@ -493,7 +509,7 @@ const handleBatchFillCells = useCallback(() => { cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; - }, [canModifyData, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, markCellSelectionDeleteEligible, updateCellSelection]); + }, [canModifyData, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isWritableResultColumn, markCellSelectionDeleteEligible, updateCellSelection]); const handleCopySelectedColumnsFromRow = useCallback(() => { const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; diff --git a/frontend/src/components/useDataGridV2Actions.ts b/frontend/src/components/useDataGridV2Actions.ts index 50fbba09..eb942deb 100644 --- a/frontend/src/components/useDataGridV2Actions.ts +++ b/frontend/src/components/useDataGridV2Actions.ts @@ -73,6 +73,8 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => { hasChanges, hasExplicitSort, hasFilteredExportSql, + isActive, + isTableSurfaceActive, isQueryResultExport, mergedDisplayData, modal, @@ -298,22 +300,27 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade }, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard, translateDataGrid]); useEffect(() => { - if (!cellEditMode) return; + if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return; const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; const activeElement = document.activeElement as HTMLElement | null; - const tagName = String(activeElement?.tagName || '').toLowerCase(); - if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) { + const eventTarget = event.target instanceof HTMLElement ? event.target : null; + const nativeShortcutGuard = 'input, textarea, select, [contenteditable="true"], .ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover, [data-gonavi-close-shortcut-guard]'; + if (activeElement?.closest(nativeShortcutGuard) || eventTarget?.closest(nativeShortcutGuard)) { return; } if (event.key === 'Escape') { const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - event.preventDefault(); if (activeSelection.size === 0) { - closeCellEditMode(); + if (cellEditMode) { + event.preventDefault(); + closeCellEditMode(); + } return; } + event.preventDefault(); resetCellSelection(); return; } @@ -321,6 +328,8 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c'; if (!isCopy) return; + if (document.getSelection?.()?.toString()) return; + const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; if (activeSelection.size === 0) return; @@ -330,10 +339,10 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]); + }, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode, isActive, isTableSurfaceActive]); useEffect(() => { - if (!cellEditMode) return; + if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return; const onPointerDown = (event: MouseEvent) => { const root = rootRef.current; @@ -343,12 +352,16 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade && target.closest('.ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover')) { return; } - closeCellEditMode(); + if (cellEditMode) { + closeCellEditMode(); + } else { + resetCellSelection(); + } }; document.addEventListener('mousedown', onPointerDown); return () => document.removeEventListener('mousedown', onPointerDown); - }, [cellEditMode, closeCellEditMode]); + }, [cellEditMode, closeCellEditMode, isActive, isTableSurfaceActive, resetCellSelection, selectedCells.size]); const getTargets = useCallback((clickedRecord: any) => { const selKeys = selectedRowKeysRef.current;