🐛 fix(data-grid): 修复数据视图交互与右键菜单问题

- 修复当前页查找高亮、清空与 ESC 取消行为

- 优化单元格编辑器尺寸与选中状态取消逻辑

- 收敛工具栏重复操作并修复右键菜单遮挡

- 补充数据网格布局与右键菜单测试覆盖
This commit is contained in:
Syngnat
2026-05-31 22:30:54 +08:00
parent 73f3e2cf73
commit 4cfa4bc63f
5 changed files with 353 additions and 96 deletions

View File

@@ -140,6 +140,7 @@ vi.mock('@ant-design/icons', () => {
SearchOutlined: Icon,
LinkOutlined: Icon,
TableOutlined: Icon,
AimOutlined: Icon,
SortAscendingOutlined: Icon,
SortDescendingOutlined: Icon,
DatabaseOutlined: Icon,
@@ -576,6 +577,7 @@ describe('DataGrid DDL interactions', () => {
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
@@ -675,6 +677,7 @@ describe('DataGrid DDL interactions', () => {
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
@@ -872,6 +875,78 @@ describe('DataGrid DDL interactions', () => {
renderer!.unmount();
});
it('copies the current row for paste and pastes it as a new row from the v2 cell context menu', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' },
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
await waitForEffects();
const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
const contextTarget = {
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
? {
getAttribute: (name: string) => {
if (name === 'data-row-key') return 'row-1';
if (name === 'data-col-name') return 'name';
return null;
},
}
: null,
} as unknown as HTMLElement;
const openMenu = async () => {
const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' });
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
currentTarget: contextTarget,
target: contextTarget,
});
});
};
await openMenu();
await act(async () => {
findButton(renderer!, '复制本行为新增行').props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(messageApi.success).toHaveBeenCalledWith('已复制 1 行,可粘贴为新增行');
await openMenu();
await act(async () => {
findButton(renderer!, '粘贴为新增行 (1)').props.onClick({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
});
expect(messageApi.success).toHaveBeenCalledWith('已粘贴 1 行为新增行,请检查后提交事务');
expect(testRenderState.latestTableProps.dataSource).toHaveLength(2);
expect(testRenderState.latestTableProps.dataSource[1][GONAVI_ROW_KEY]).toContain('paste-');
renderer!.unmount();
});
it('switches the v2 footer field tab into the main fields view', async () => {
storeState.appearance.uiVersion = 'v2';

View File

@@ -160,6 +160,11 @@ describe('DataGrid layout', () => {
expect(pageFindSource).toContain("textAlign: 'left'");
expect(dataGridSource).toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(pageFindText), [pageFindText]);");
expect(dataGridSource).not.toContain("const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]);");
expect(dataGridSource).toContain("if (event.key === 'Escape')");
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(paginationSource).toContain("padding: 0");
expect(paginationSource).toContain("justifyContent: 'flex-start'");
});
@@ -357,7 +362,7 @@ describe('DataGrid layout', () => {
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {
it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
@@ -374,10 +379,8 @@ describe('DataGrid layout', () => {
/>,
);
expect(markup).toContain('data-grid-copy-row-action="true"');
expect(markup).toContain('data-grid-paste-row-action="true"');
expect(markup).toContain('复制行');
expect(markup).toContain('粘贴行');
expect(markup).not.toContain('data-grid-copy-row-action="true"');
expect(markup).not.toContain('data-grid-paste-row-action="true"');
});
it('renders a clickable copy action for aggregate query results', () => {
@@ -398,6 +401,33 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-query-copy-action="true"');
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
expect(markup).toContain('复制');
expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1);
});
it('keeps query-result export scopes explicit and repositions v2 context menus after measuring', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
expect(source).toContain("type QueryResultExportScope = 'selected' | 'page' | 'all';");
expect(source).toContain("title: '导出查询结果'");
expect(source).toContain('data-query-result-export-scope="true"');
expect(source).toContain('选中导出');
expect(source).toContain('当前页导出');
expect(source).toContain('全部导出');
expect(source).toContain('const queryResultCurrentPageRows = useMemo(() => {');
expect(source).toContain('const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {');
expect(source).toContain('const rect = element.getBoundingClientRect();');
expect(source).toContain('ref={cellContextMenuPortalRef}');
});
it('keeps inline cell editors stretched to the full cell width', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
expect(source).toContain('const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: \'100%\', minWidth: 0 };');
expect(source).toContain('className="data-grid-inline-editor-form-item"');
expect(source).toContain('className="data-grid-inline-editor-input"');
expect(source).toContain('style={{ width: \'100%\', ...inputCellPadding }}');
expect(source).toContain('.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content');
expect(source).toContain('.${gridId} .data-grid-inline-editor-input');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {

View File

@@ -955,7 +955,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
if (editable) {
childNode = editing ? (
<Form.Item style={{ margin: 0 }} name={getCellFieldName(record, dataIndex)}>
<Form.Item className="data-grid-inline-editor-form-item" style={INLINE_EDIT_FORM_ITEM_STYLE} name={getCellFieldName(record, dataIndex)}>
{isDateTimeField ? (
pickerType === 'time' ? (
<TimePicker
@@ -1014,7 +1014,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
) : (
<Input
ref={inputRef}
style={inputCellPadding}
className="data-grid-inline-editor-input"
style={{ width: '100%', ...inputCellPadding }}
onPressEnter={() => { void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
@@ -1229,6 +1230,7 @@ type GridFilterCondition = FilterCondition & {
type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er';
type DdlViewLayoutMode = 'bottom' | 'side';
type QueryResultExportScope = 'selected' | 'page' | 'all';
type VirtualEditingCellState = {
rowKey: string;
dataIndex: string;
@@ -1457,6 +1459,7 @@ const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = {
width: '100%',
};
const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 };
const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 };
const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
margin: 0,
padding: 0,
@@ -1795,10 +1798,10 @@ const DataGrid: React.FC<DataGridProps> = ({
// 布局常量(纯数字/字符串,无需 memoize
const panelRadius = 10;
const panelOuterGap = 6;
const panelPaddingY = 10;
const panelOuterGap = isQueryResultExport ? 2 : 6;
const panelPaddingY = isQueryResultExport ? 8 : 10;
const panelPaddingX = 12;
const toolbarBottomPadding = 6;
const toolbarBottomPadding = isQueryResultExport ? 4 : 6;
const filterTopPadding = 2;
const floatingScrollbarGap = 8;
const floatingScrollbarBottomOffset = 0;
@@ -1894,6 +1897,7 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex: '',
title: '',
});
const cellContextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
@@ -2000,25 +2004,26 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => document.removeEventListener('click', handleClickOutside);
}, [cellContextMenu.visible]);
const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {
const viewportH = window.innerHeight;
const viewportW = window.innerWidth;
const safeGap = 8;
let nextY = y;
let nextX = x;
if (nextY + estimatedHeight > viewportH - safeGap) {
nextY = Math.max(safeGap, viewportH - estimatedHeight - safeGap);
}
if (nextX + estimatedWidth > viewportW - safeGap) {
nextX = Math.max(safeGap, viewportW - estimatedWidth - safeGap);
}
return { x: nextX, y: nextY };
}, []);
const showCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => {
e.preventDefault();
e.stopPropagation();
const titleText = typeof (title as any) === 'string' ? (title as string) : (typeof (title as any) === 'number' ? String(title) : String(dataIndex));
// 预估菜单尺寸(菜单项数 × 行高 + 分隔线 + padding
const estimatedMenuHeight = 320;
const estimatedMenuWidth = 200;
const viewportH = window.innerHeight;
const viewportW = window.innerWidth;
let menuY = e.clientY;
let menuX = e.clientX;
// 底部空间不足时向上偏移
if (menuY + estimatedMenuHeight > viewportH) {
menuY = Math.max(4, viewportH - estimatedMenuHeight);
}
// 右侧空间不足时向左偏移
if (menuX + estimatedMenuWidth > viewportW) {
menuX = Math.max(4, viewportW - estimatedMenuWidth);
}
const { x: menuX, y: menuY } = resolveContextMenuPosition(e.clientX, e.clientY, 264, 420);
setCellContextMenu({
visible: true,
x: menuX,
@@ -2028,23 +2033,12 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex,
title: titleText,
});
}, []);
}, [resolveContextMenuPosition]);
const showColumnHeaderContextMenu = useCallback((e: React.MouseEvent, columnName: string) => {
e.preventDefault();
e.stopPropagation();
const estimatedMenuHeight = 292;
const estimatedMenuWidth = 264;
const viewportH = window.innerHeight;
const viewportW = window.innerWidth;
let menuY = e.clientY;
let menuX = e.clientX;
if (menuY + estimatedMenuHeight > viewportH) {
menuY = Math.max(4, viewportH - estimatedMenuHeight);
}
if (menuX + estimatedMenuWidth > viewportW) {
menuX = Math.max(4, viewportW - estimatedMenuWidth);
}
const { x: menuX, y: menuY } = resolveContextMenuPosition(e.clientX, e.clientY, 264, 360);
setCellContextMenu({
visible: true,
x: menuX,
@@ -2054,7 +2048,7 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex: columnName,
title: columnName,
});
}, []);
}, [resolveContextMenuPosition]);
// Helper to export specific data
const exportData = async (rows: any[], format: string) => {
@@ -2612,6 +2606,19 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .editable-cell-value-wrap > * {
min-width: 0;
}
.${gridId} .data-grid-inline-editor-form-item,
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-row,
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control,
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input,
.${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content {
width: 100%;
min-width: 0;
}
.${gridId} .data-grid-inline-editor-input,
.${gridId} .data-grid-inline-editor-form-item .ant-picker {
width: 100% !important;
min-width: 0;
}
.${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap {
content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};
contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};
@@ -3160,6 +3167,26 @@ const DataGrid: React.FC<DataGridProps> = ({
},
});
useEffect(() => {
if (!isTableSurfaceActive || !isV2Ui || !cellContextMenu.visible) return;
const portal = cellContextMenuPortalRef.current;
if (!portal) return;
const frame = requestAnimationFrame(() => {
const element = cellContextMenuPortalRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const next = resolveContextMenuPosition(cellContextMenu.x, cellContextMenu.y, rect.width, rect.height);
if (next.x !== cellContextMenu.x || next.y !== cellContextMenu.y) {
setCellContextMenu((prev) => {
if (!prev.visible) return prev;
if (prev.x === next.x && prev.y === next.y) return prev;
return { ...prev, x: next.x, y: next.y };
});
}
});
return () => cancelAnimationFrame(frame);
}, [cellContextMenu.visible, cellContextMenu.x, cellContextMenu.y, isTableSurfaceActive, isV2Ui, resolveContextMenuPosition]);
useEffect(() => {
cellEditModeRef.current = cellEditMode;
}, [cellEditMode]);
@@ -4893,7 +4920,7 @@ const DataGrid: React.FC<DataGridProps> = ({
className="data-grid-virtual-inline-editing"
onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)}
>
<Form.Item style={{ margin: 0, width: '100%' }} name={getCellFieldName(record, dataIndex)}>
<Form.Item className="data-grid-inline-editor-form-item" style={INLINE_EDIT_FORM_ITEM_STYLE} name={getCellFieldName(record, dataIndex)}>
{isDateTimeField ? (
pickerType === 'time' ? (
<TimePicker
@@ -4956,6 +4983,7 @@ const DataGrid: React.FC<DataGridProps> = ({
) : (
<Input
ref={virtualInlineInputRef}
className="data-grid-inline-editor-input"
style={{ width: '100%', ...inputCellPadding }}
onPressEnter={() => { void saveVirtualInlineEditor(); }}
onBlur={() => { void saveVirtualInlineEditor(); }}
@@ -5004,15 +5032,14 @@ const DataGrid: React.FC<DataGridProps> = ({
setAddedRows(prev => [...prev, newRow]);
};
const handleCopySelectedRowsForPaste = useCallback(() => {
if (selectedRowKeys.length === 0) {
const copyRowsForPaste = useCallback((keys: React.Key[]) => {
if (keys.length === 0) {
void message.info('请先选择要复制的行');
return;
}
const copiedRows = buildCopiedRowsForPaste({
rows: mergedDisplayData as Array<Record<string, any>>,
selectedRowKeys,
selectedRowKeys: keys,
columnNames: displayOutputColumnNames.filter((columnName) => isWritableResultColumn(columnName, effectiveEditLocator)),
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
@@ -5024,7 +5051,11 @@ const DataGrid: React.FC<DataGridProps> = ({
setCopiedRowsForPaste(copiedRows);
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr, effectiveEditLocator]);
}, [mergedDisplayData, displayOutputColumnNames, rowKeyStr, effectiveEditLocator]);
const handleCopySelectedRowsForPaste = useCallback(() => {
copyRowsForPaste(selectedRowKeys);
}, [copyRowsForPaste, selectedRowKeys]);
const handlePasteCopiedRowsAsNew = useCallback(() => {
if (copiedRowsForPaste.length === 0) {
@@ -5403,15 +5434,26 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!cellEditMode) return;
const onKeyDown = (event: KeyboardEvent) => {
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
if (!isCopy) return;
const activeElement = document.activeElement as HTMLElement | null;
const tagName = String(activeElement?.tagName || '').toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) {
return;
}
if (event.key === 'Escape') {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
event.preventDefault();
if (activeSelection.size === 0) {
closeCellEditMode();
return;
}
resetCellSelection();
return;
}
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
if (!isCopy) return;
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) return;
@@ -5421,7 +5463,7 @@ const DataGrid: React.FC<DataGridProps> = ({
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard]);
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]);
useEffect(() => {
if (!cellEditMode) return;
@@ -5690,13 +5732,77 @@ const DataGrid: React.FC<DataGridProps> = ({
return sql;
}, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
const records = getTargets(record);
if (isQueryResultExport) {
await exportData(records, format);
const queryResultCurrentPageRows = useMemo(() => {
if (!pagination) {
return mergedDisplayData;
}
const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize);
return mergedDisplayData.slice(offset, offset + pagination.pageSize);
}, [mergedDisplayData, pagination]);
const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => {
if (scope === 'selected') {
const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key)));
const rows = mergedDisplayData.filter((row) => {
const key = row?.[GONAVI_ROW_KEY];
return key !== undefined && key !== null && selectedKeySet.has(rowKeyStr(key));
});
if (rows.length === 0) {
void message.info('当前未选中任何行');
return;
}
await exportData(rows, format);
return;
}
if (scope === 'page') {
await exportData(queryResultCurrentPageRows, format);
return;
}
await exportData(mergedDisplayData, format);
}, [exportData, mergedDisplayData, queryResultCurrentPageRows, rowKeyStr, selectedRowKeys]);
const openQueryResultExportScopeModal = useCallback((format: string) => {
let instance: { destroy: () => void } | null = null;
const selectedCount = selectedRowKeys.length;
const runExport = async (scope: QueryResultExportScope) => {
instance?.destroy();
await exportQueryResultRows(format, scope);
};
instance = modal.info({
title: '导出查询结果',
content: (
<div data-query-result-export-scope="true">
<p style={{ marginBottom: 12 }}></p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button onClick={() => instance?.destroy()}></Button>
<Button
disabled={selectedCount <= 0}
onClick={() => { void runExport('selected'); }}
>
{selectedCount > 0 ? ` (${selectedCount}条)` : ''}
</Button>
<Button onClick={() => { void runExport('page'); }}>
({queryResultCurrentPageRows.length})
</Button>
<Button type="primary" onClick={() => { void runExport('all'); }}>
({mergedDisplayData.length})
</Button>
</div>
</div>
),
icon: <ExportOutlined />,
okButtonProps: { style: { display: 'none' } },
maskClosable: true,
});
}, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, selectedRowKeys.length]);
// Context Menu Export
const handleExportSelected = useCallback(async (format: string, record: any) => {
if (isQueryResultExport) {
await exportData(getContextMenuTargetRows(record), format);
return;
}
const records = getTargets(record);
if (!connectionId || !tableName) {
await exportData(records, format);
return;
@@ -5743,6 +5849,22 @@ const DataGrid: React.FC<DataGridProps> = ({
if (record) handleCopyRowData(record);
closeMenu();
return;
case 'copy-row-for-paste':
if (record) {
const rowKey = record?.[GONAVI_ROW_KEY];
if (rowKey === undefined || rowKey === null) {
void message.info('未识别到可复制的行');
} else {
setSelectedRowKeys([rowKey]);
copyRowsForPaste([rowKey]);
}
}
closeMenu();
return;
case 'paste-row-as-new':
handlePasteCopiedRowsAsNew();
closeMenu();
return;
case 'copy-column-data':
handleCopyColumnData(cellContextMenu.dataIndex);
closeMenu();
@@ -5831,8 +5953,12 @@ const DataGrid: React.FC<DataGridProps> = ({
// Export
const handleExport = async (format: string) => {
if (isQueryResultExport) {
openQueryResultExportScopeModal(format);
return;
}
if (!connectionId) return;
// 1. Export Selected
if (selectedRowKeys.length > 0) {
const selectedRows = displayData.filter(d => selectedRowKeys.includes(d?.[GONAVI_ROW_KEY]));
@@ -5840,12 +5966,6 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
// 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。
if (isQueryResultExport) {
await exportData(mergedDisplayData, format);
return;
}
// 2. Prompt for Current vs All
// Using a custom modal content with buttons to handle 3 states
let instance: any;
@@ -5944,7 +6064,13 @@ const DataGrid: React.FC<DataGridProps> = ({
if (onReload) onReload();
};
const exportMenu: MenuProps['items'] = hasFilteredExportSql ? [
const exportMenu: MenuProps['items'] = isQueryResultExport ? [
{ key: 'query-csv', label: 'CSV', onClick: () => handleExport('csv') },
{ key: 'query-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
{ key: 'query-json', label: 'JSON', onClick: () => handleExport('json') },
{ key: 'query-md', label: 'Markdown', onClick: () => handleExport('md') },
{ key: 'query-html', label: 'HTML', onClick: () => handleExport('html') },
] : hasFilteredExportSql ? [
{ type: 'group', label: '筛选结果', children: [
{ key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') },
{ key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') },
@@ -7173,7 +7299,6 @@ const DataGrid: React.FC<DataGridProps> = ({
onToggleFilter={onToggleFilter}
canModifyData={canModifyData}
selectedRowKeysLength={selectedRowKeys.length}
copiedRowsForPasteLength={copiedRowsForPaste.length}
allSelectedAreDeleted={allSelectedAreDeleted}
cellEditMode={cellEditMode}
selectedCellsSize={selectedCells.size}
@@ -7214,8 +7339,6 @@ const DataGrid: React.FC<DataGridProps> = ({
onRefresh={handleRefreshGrid}
onToggleFilterClick={handleToggleFilterWithDefault}
onAddRow={handleAddRow}
onCopySelectedRowsForPaste={handleCopySelectedRowsForPaste}
onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew}
onUndoDeleteSelected={handleUndoDeleteSelected}
onDeleteSelected={handleDeleteSelected}
onToggleCellEditMode={handleToggleCellEditMode}
@@ -7415,6 +7538,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal(
<div
ref={cellContextMenuPortalRef}
className="gn-v2-table-context-menu-portal"
style={{
position: 'fixed',
@@ -7431,6 +7555,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return (
<V2ColumnHeaderContextMenuView
fieldName={fieldName}
shortcutPlatform={activeShortcutPlatform}
columnType={meta?.type}
columnComment={meta?.comment}
sortOrder={(activeSort?.order === 'ascend' || activeSort?.order === 'descend') ? activeSort.order : null}
@@ -7442,10 +7567,12 @@ const DataGrid: React.FC<DataGridProps> = ({
})() : (
<V2CellContextMenuView
fieldName={resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title)}
shortcutPlatform={activeShortcutPlatform}
tableName={tableName}
rowLabel={cellContextMenu.record?.[GONAVI_ROW_KEY] === undefined ? undefined : `row ${String(cellContextMenu.record?.[GONAVI_ROW_KEY])}`}
selectedRowCount={selectedRowKeys.length}
canModifyData={canModifyData}
copiedRowCount={copiedRowsForPaste.length}
canPasteCopiedColumns={!!copiedCellPatch}
supportsCopyInsert={supportsCopyInsert}
onAction={handleV2CellContextMenuAction}
@@ -7461,11 +7588,25 @@ const DataGrid: React.FC<DataGridProps> = ({
bgContextMenu={bgContextMenu}
cellContextMenu={cellContextMenu}
canModifyData={canModifyData}
copiedRowsForPasteLength={copiedRowsForPaste.length}
selectedRowKeysLength={selectedRowKeys.length}
copiedCellPatchAvailable={!!copiedCellPatch}
supportsCopyInsert={supportsCopyInsert}
onClose={() => setCellContextMenu(prev => ({ ...prev, visible: false }))}
onCopyFieldName={handleCopyContextMenuFieldName}
onCopyRowData={() => {
if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record);
}}
onCopyRowForPaste={() => {
const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
if (rowKey === undefined || rowKey === null) {
void message.info('未识别到可复制的行');
return;
}
setSelectedRowKeys([rowKey]);
copyRowsForPaste([rowKey]);
}}
onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew}
onSetNull={handleCellSetNull}
onEditRow={handleOpenContextMenuRowEditor}
onFillToSelected={() => {

View File

@@ -16,11 +16,15 @@ interface DataGridLegacyCellContextMenuProps {
bgContextMenu: string;
cellContextMenu: CellContextMenuState;
canModifyData: boolean;
copiedRowsForPasteLength: number;
selectedRowKeysLength: number;
copiedCellPatchAvailable: boolean;
supportsCopyInsert: boolean;
onClose: () => void;
onCopyFieldName: () => void;
onCopyRowData: () => void;
onCopyRowForPaste: () => void;
onPasteCopiedRowsAsNew: () => void;
onSetNull: () => void;
onEditRow: () => void;
onFillToSelected: () => void;
@@ -55,11 +59,15 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
bgContextMenu,
cellContextMenu,
canModifyData,
copiedRowsForPasteLength,
selectedRowKeysLength,
copiedCellPatchAvailable,
supportsCopyInsert,
onClose,
onCopyFieldName,
onCopyRowData,
onCopyRowForPaste,
onPasteCopiedRowsAsNew,
onSetNull,
onEditRow,
onFillToSelected,
@@ -81,6 +89,7 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
const hoverBg = darkMode ? '#303030' : '#f5f5f5';
const canFillRows = selectedRowKeysLength > 0;
const canPasteRows = copiedRowsForPasteLength > 0;
const makeHoverHandlers = (enabled = true) => ({
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
@@ -129,6 +138,27 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
<EditOutlined style={{ marginRight: 8 }} />
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowForPaste)}>
<CopyOutlined style={{ marginRight: 8 }} />
</div>
<div
style={{
...baseItemStyle,
cursor: canPasteRows ? 'pointer' : 'not-allowed',
opacity: canPasteRows ? 1 : 0.5,
}}
{...makeHoverHandlers(canPasteRows)}
onClick={() => {
if (canPasteRows) {
onPasteCopiedRowsAsNew();
onClose();
}
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
{canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'}
</div>
<div
style={{
...baseItemStyle,
@@ -160,6 +190,10 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
<div style={separatorStyle(darkMode)} />
</>
)}
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyRowData)}>
<CopyOutlined style={{ marginRight: 8 }} />
</div>
{supportsCopyInsert && (
<>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={closeAfter(onCopyInsert)}> INSERT</div>

View File

@@ -59,7 +59,6 @@ export interface DataGridToolbarFrameProps {
onToggleFilter?: () => void;
canModifyData: boolean;
selectedRowKeysLength: number;
copiedRowsForPasteLength: number;
allSelectedAreDeleted: boolean;
cellEditMode: boolean;
selectedCellsSize: number;
@@ -95,8 +94,6 @@ export interface DataGridToolbarFrameProps {
onRefresh: () => void;
onToggleFilterClick: () => void;
onAddRow: () => void;
onCopySelectedRowsForPaste: () => void;
onPasteCopiedRowsAsNew: () => void;
onUndoDeleteSelected: () => void;
onDeleteSelected: () => void;
onToggleCellEditMode: () => void;
@@ -155,7 +152,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
onToggleFilter,
canModifyData,
selectedRowKeysLength,
copiedRowsForPasteLength,
allSelectedAreDeleted,
cellEditMode,
selectedCellsSize,
@@ -191,8 +187,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
onRefresh,
onToggleFilterClick,
onAddRow,
onCopySelectedRowsForPaste,
onPasteCopiedRowsAsNew,
onUndoDeleteSelected,
onDeleteSelected,
onToggleCellEditMode,
@@ -301,22 +295,6 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
<>
{renderToolbarDivider()}
<Button icon={<PlusOutlined />} onClick={onAddRow}></Button>
<Button
data-grid-copy-row-action="true"
icon={<CopyOutlined />}
disabled={selectedRowKeysLength === 0}
onClick={onCopySelectedRowsForPaste}
>
</Button>
<Button
data-grid-paste-row-action="true"
icon={<VerticalAlignBottomOutlined />}
disabled={copiedRowsForPasteLength === 0}
onClick={onPasteCopiedRowsAsNew}
>
{copiedRowsForPasteLength > 0 ? `粘贴行 (${copiedRowsForPasteLength})` : '粘贴行'}
</Button>
{allSelectedAreDeleted ? (
<Button icon={<UndoOutlined />} disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}></Button>
) : (
@@ -394,16 +372,15 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
{isQueryResultExport && (
<>
{renderToolbarDivider()}
<Button
data-grid-query-copy-action="true"
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
>
</Button>
<Dropdown menu={{ items: queryResultCopyMenu }} disabled={!canCopyQueryResult}>
<Button icon={<DownOutlined />} disabled={!canCopyQueryResult} />
<Button
data-grid-query-copy-action="true"
icon={<CopyOutlined />}
disabled={!canCopyQueryResult}
onClick={onCopyQueryResultCsv}
>
<DownOutlined />
</Button>
</Dropdown>
</>
)}