feat(data-grid): 支持只读查询结果拖选复制

- 允许聚合与只读结果拖选单元格并复制 TSV
- 增加选区工具栏操作和 Ctrl/Cmd+C 快捷键
- 隔离后台结果页与编辑操作,避免快捷键抢占

Fixes #673
This commit is contained in:
Syngnat
2026-07-22 12:30:30 +08:00
parent b272c2e39c
commit ece61d097f
10 changed files with 186 additions and 19 deletions

View File

@@ -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');

View File

@@ -1773,6 +1773,7 @@ const DataGrid: React.FC<DataGridProps> = ({
effectiveEditLocator,
isCellValueEqualForDiff,
isDraggingRef,
isActive,
isTableSurfaceActive,
isWritableResultColumn,
makeCellKey,
@@ -3440,6 +3441,8 @@ const DataGrid: React.FC<DataGridProps> = ({
hasChanges,
hasExplicitSort,
hasFilteredExportSql,
isActive,
isTableSurfaceActive,
isQueryResultExport,
mergedDisplayData,
modal,

View File

@@ -434,6 +434,19 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
</>
)}
{!canModifyData && selectedCellsSize > 0 && (
<>
{renderToolbarDivider()}
<Button
data-grid-copy-selection-action="true"
icon={<CopyOutlined />}
onClick={onCopySelectedCellsToClipboard}
>
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
</Button>
</>
)}
<>
{renderToolbarDivider()}
<Tooltip title={translate('data_grid.toolbar.ai_insight_tooltip')}>

View File

@@ -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) => (
<QueryEditorResultsPanel
resultSets={resultSets}
activeResultKey={activeResultKey}
isActive={isActive}
loading={false}
executionError=""
sqlLogCount={0}
darkMode={false}
isV2Ui
currentDb="main"
currentConnectionId="conn-1"
toggleShortcutLabel=""
onActiveResultKeyChange={vi.fn()}
onHide={vi.fn()}
onCloseResult={vi.fn()}
onCloseOtherResultTabs={vi.fn()}
onCloseResultTabsToLeft={vi.fn()}
onCloseResultTabsToRight={vi.fn()}
onCloseAllResultTabs={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>
);
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', () => {
<QueryEditorResultsPanel
resultSets={[]}
activeResultKey=""
isActive
loading={false}
executionError=""
sqlLogCount={sqlLogCount}
@@ -3534,6 +3606,7 @@ describe('QueryEditor external SQL save', () => {
<QueryEditorResultsPanel
resultSets={resultSets}
activeResultKey="stale-result"
isActive
loading={false}
executionError=""
sqlLogCount={0}

View File

@@ -8936,6 +8936,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
<QueryEditorResultsPanel
resultSets={resultSets}
activeResultKey={activeResultKey}
isActive={isActive}
loading={loading}
executionError={executionError}
sqlLogCount={sqlLogCount}

View File

@@ -71,6 +71,7 @@ export const resolveEffectiveActiveResultKey = (
interface QueryEditorResultsPanelProps {
resultSets: QueryEditorResultSet[];
activeResultKey: string;
isActive: boolean;
loading: boolean;
executionError: string;
sqlLogCount: number;
@@ -131,6 +132,7 @@ export const shouldActivateResultTabDetachPointer = (event: {
const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
resultSets,
activeResultKey,
isActive,
loading,
executionError,
sqlLogCount,
@@ -532,6 +534,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
<DataGrid
data={rs.rows}
columnNames={visibleColumns}
isActive={isActive && resolvedActiveResultKey === rs.key}
loading={loading || rs.page?.loading === true}
tableName={resultTableName}
columnPinScope={resultTableName ? undefined : buildQueryResultColumnPinScope({

View File

@@ -1,8 +1,26 @@
import { describe, expect, it } from 'vitest';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { buildSelectedCellClipboardText, canSelectGridCellForClipboard } from './dataGridSelectionCopy';
describe('dataGridSelectionCopy helpers', () => {
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: [

View File

@@ -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';

View File

@@ -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<string, any> & {
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;

View File

@@ -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;