From 4cfa4bc63f754c2fa82873e233355757f4b1ea85 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 31 May 2026 22:30:54 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=95=B0=E6=8D=AE=E8=A7=86=E5=9B=BE=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=8E=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复当前页查找高亮、清空与 ESC 取消行为 - 优化单元格编辑器尺寸与选中状态取消逻辑 - 收敛工具栏重复操作并修复右键菜单遮挡 - 补充数据网格布局与右键菜单测试覆盖 --- frontend/src/components/DataGrid.ddl.test.tsx | 75 +++++ .../src/components/DataGrid.layout.test.tsx | 40 ++- frontend/src/components/DataGrid.tsx | 261 ++++++++++++++---- .../DataGridLegacyCellContextMenu.tsx | 34 +++ .../src/components/DataGridToolbarFrame.tsx | 39 +-- 5 files changed, 353 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index cc16b52..bef8473 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -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( + , + ); + }); + 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'; diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 6c00045..3123658 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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( { />, ); - 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', () => { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index bdd6799..27a8d34 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -955,7 +955,7 @@ const EditableCell: React.FC = React.memo(({ if (editable) { childNode = editing ? ( - + {isDateTimeField ? ( pickerType === 'time' ? ( = React.memo(({ ) : ( { 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 = ({ // 布局常量(纯数字/字符串,无需 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 = ({ dataIndex: '', title: '', }); + const cellContextMenuPortalRef = useRef(null); const rootRef = useRef(null); const containerRef = useRef(null); const tableContainerRef = useRef(null); @@ -2000,25 +2004,26 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ .${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 = ({ }, }); + 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 = ({ className="data-grid-virtual-inline-editing" onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)} > - + {isDateTimeField ? ( pickerType === 'time' ? ( = ({ ) : ( { void saveVirtualInlineEditor(); }} onBlur={() => { void saveVirtualInlineEditor(); }} @@ -5004,15 +5032,14 @@ const DataGrid: React.FC = ({ 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>, - selectedRowKeys, + selectedRowKeys: keys, columnNames: displayOutputColumnNames.filter((columnName) => isWritableResultColumn(columnName, effectiveEditLocator)), rowKeyField: GONAVI_ROW_KEY, rowKeyToString: rowKeyStr, @@ -5024,7 +5051,11 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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: ( +
+

请选择导出范围:

+
+ + + + +
+
+ ), + icon: , + 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 = ({ 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 = ({ // 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 = ({ 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 = ({ 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 = ({ 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 = ({ onRefresh={handleRefreshGrid} onToggleFilterClick={handleToggleFilterWithDefault} onAddRow={handleAddRow} - onCopySelectedRowsForPaste={handleCopySelectedRowsForPaste} - onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} onUndoDeleteSelected={handleUndoDeleteSelected} onDeleteSelected={handleDeleteSelected} onToggleCellEditMode={handleToggleCellEditMode} @@ -7415,6 +7538,7 @@ const DataGrid: React.FC = ({ {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal(
= ({ return ( = ({ })() : ( = ({ 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={() => { diff --git a/frontend/src/components/DataGridLegacyCellContextMenu.tsx b/frontend/src/components/DataGridLegacyCellContextMenu.tsx index 44b5c06..7167aaa 100644 --- a/frontend/src/components/DataGridLegacyCellContextMenu.tsx +++ b/frontend/src/components/DataGridLegacyCellContextMenu.tsx @@ -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 0; + const canPasteRows = copiedRowsForPasteLength > 0; const makeHoverHandlers = (enabled = true) => ({ onMouseEnter: (e: React.MouseEvent) => { @@ -129,6 +138,27 @@ const DataGridLegacyCellContextMenu: React.FC 编辑本行
+
+ + 复制本行为新增行 +
+
{ + if (canPasteRows) { + onPasteCopiedRowsAsNew(); + onClose(); + } + }} + > + + {canPasteRows ? `粘贴为新增行 (${copiedRowsForPasteLength})` : '粘贴为新增行'} +
)} +
+ + 复制行数据 +
{supportsCopyInsert && ( <>
复制为 INSERT
diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index d9be960..c3384a4 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -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 = ({ onToggleFilter, canModifyData, selectedRowKeysLength, - copiedRowsForPasteLength, allSelectedAreDeleted, cellEditMode, selectedCellsSize, @@ -191,8 +187,6 @@ const DataGridToolbarFrame: React.FC = ({ onRefresh, onToggleFilterClick, onAddRow, - onCopySelectedRowsForPaste, - onPasteCopiedRowsAsNew, onUndoDeleteSelected, onDeleteSelected, onToggleCellEditMode, @@ -301,22 +295,6 @@ const DataGridToolbarFrame: React.FC = ({ <> {renderToolbarDivider()} - - {allSelectedAreDeleted ? ( ) : ( @@ -394,16 +372,15 @@ const DataGridToolbarFrame: React.FC = ({ {isQueryResultExport && ( <> {renderToolbarDivider()} - - )}