From 6cb5998cd6b7ec8a99a7f8a9f61ef173500be94f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 14 Jun 2026 15:41:26 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(datagrid):=20=E4=B8=BA?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E7=BC=96=E8=BE=91=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=A0=BC=E7=BA=A7=E6=92=A4=E9=94=80=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 V2 与旧版单元格菜单中增加撤销当前修改入口 - 复用现有改单保存链路回退单元格值与脏标记 - 修复刷新后本地改单状态未完全清理的问题 - 补充相关布局与菜单回归测试 Close #563 --- .../src/components/DataGrid.layout.test.tsx | 21 +++++++++ frontend/src/components/DataGrid.tsx | 47 +++++++++++++++++++ .../DataGridLegacyCellContextMenu.tsx | 22 ++++++++- .../src/components/V2TableContextMenu.tsx | 10 ++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 23ea1ee..4c88515 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -11,6 +11,7 @@ import DataGrid, { resolveNextGridFilterOperatorForColumnChange, } from './DataGrid'; import DataGridPaginationBar from './DataGridPaginationBar'; +import { V2CellContextMenuView } from './V2TableContextMenu'; import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts'; vi.mock('../store', () => ({ @@ -280,6 +281,26 @@ describe('DataGrid layout', () => { expect(markup).toContain('AI 洞察'); }); + it('clears modified cell markers when refreshing the grid', () => { + const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[clearAutoCommitTimer, onReload\]\);/); + }); + + it('renders a cell-level undo action in the v2 context menu for modified cells', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('撤销此单元格修改'); + }); + it('preserves fractional seconds when rendering datetime values', () => { expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456'); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 7bc71d6..d653b6e 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4339,6 +4339,45 @@ const DataGrid: React.FC = ({ setCellContextMenu(prev => ({ ...prev, visible: false })); }, [cellContextMenu, handleCellSave, effectiveEditLocator]); + const canUndoContextMenuCellChange = useMemo(() => { + const record = cellContextMenu.record; + const dataIndex = String(cellContextMenu.dataIndex || '').trim(); + const rowKey = record?.[GONAVI_ROW_KEY]; + if (!record || !dataIndex || rowKey === undefined || rowKey === null) return false; + const keyStr = rowKeyStr(rowKey); + if (addedRowKeySet.has(keyStr)) return false; + return !!modifiedColumns[keyStr]?.has(dataIndex); + }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, modifiedColumns, rowKeyStr]); + + const handleUndoContextMenuCellChange = useCallback(() => { + const record = cellContextMenu.record; + const dataIndex = String(cellContextMenu.dataIndex || '').trim(); + const rowKey = record?.[GONAVI_ROW_KEY]; + if (!record || !dataIndex || rowKey === undefined || rowKey === null) return; + + const keyStr = rowKeyStr(rowKey); + if (addedRowKeySet.has(keyStr)) { + void message.info('新增行请使用删除选中或整表回滚撤销'); + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + if (!modifiedColumns[keyStr]?.has(dataIndex)) { + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + + const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr); + if (!originalRow) { + void message.error('未找到该单元格的原始数据,无法撤销'); + setCellContextMenu(prev => ({ ...prev, visible: false })); + return; + } + + handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] }); + setCellContextMenu(prev => ({ ...prev, visible: false })); + void message.success('已撤销单元格修改'); + }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr]); + const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; if (!isWritableResultColumn(cellEditorMeta.dataIndex, effectiveEditLocator)) { @@ -6060,6 +6099,9 @@ const DataGrid: React.FC = ({ handleCopyColumnData(cellContextMenu.dataIndex); closeMenu(); return; + case 'undo-cell-change': + handleUndoContextMenuCellChange(); + return; case 'set-null': handleCellSetNull(); return; @@ -6128,6 +6170,7 @@ const DataGrid: React.FC = ({ getTargets, handleBatchFillToSelected, handleCellSetNull, + handleUndoContextMenuCellChange, handleCopyContextMenuFieldName, handleCopyCsv, handleCopyDelete, @@ -7465,6 +7508,7 @@ const DataGrid: React.FC = ({ setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); + setModifiedColumns({}); setSelectedRowKeys([]); if (onReload) onReload(); }, [clearAutoCommitTimer, onReload]); @@ -7832,6 +7876,7 @@ const DataGrid: React.FC = ({ rowLabel={cellContextMenu.record?.[GONAVI_ROW_KEY] === undefined ? undefined : `row ${String(cellContextMenu.record?.[GONAVI_ROW_KEY])}`} selectedRowCount={selectedRowKeys.length} canModifyData={canModifyData} + canUndoCellChange={canUndoContextMenuCellChange} copiedRowCount={copiedRowsForPaste.length} canPasteCopiedColumns={!!copiedCellPatch} supportsCopyInsert={supportsCopyInsert} @@ -7851,6 +7896,7 @@ const DataGrid: React.FC = ({ copiedRowsForPasteLength={copiedRowsForPaste.length} selectedRowKeysLength={selectedRowKeys.length} copiedCellPatchAvailable={!!copiedCellPatch} + canUndoCellChange={canUndoContextMenuCellChange} supportsCopyInsert={supportsCopyInsert} onClose={() => setCellContextMenu(prev => ({ ...prev, visible: false }))} onCopyFieldName={handleCopyContextMenuFieldName} @@ -7867,6 +7913,7 @@ const DataGrid: React.FC = ({ copyRowsForPaste([rowKey]); }} onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} + onUndoCellChange={handleUndoContextMenuCellChange} onSetNull={handleCellSetNull} onEditRow={handleOpenContextMenuRowEditor} onFillToSelected={() => { diff --git a/frontend/src/components/DataGridLegacyCellContextMenu.tsx b/frontend/src/components/DataGridLegacyCellContextMenu.tsx index 7167aaa..ed79685 100644 --- a/frontend/src/components/DataGridLegacyCellContextMenu.tsx +++ b/frontend/src/components/DataGridLegacyCellContextMenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { CopyOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; +import { CopyOutlined, EditOutlined, UndoOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; interface CellContextMenuState { visible: boolean; @@ -19,12 +19,14 @@ interface DataGridLegacyCellContextMenuProps { copiedRowsForPasteLength: number; selectedRowKeysLength: number; copiedCellPatchAvailable: boolean; + canUndoCellChange: boolean; supportsCopyInsert: boolean; onClose: () => void; onCopyFieldName: () => void; onCopyRowData: () => void; onCopyRowForPaste: () => void; onPasteCopiedRowsAsNew: () => void; + onUndoCellChange: () => void; onSetNull: () => void; onEditRow: () => void; onFillToSelected: () => void; @@ -62,12 +64,14 @@ const DataGridLegacyCellContextMenu: React.FC {canModifyData && ( <> +
{ + if (canUndoCellChange) { + onUndoCellChange(); + } + }} + > + + 撤销此单元格修改 +
设置为 NULL
diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index 38747f9..d8ccb79 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -27,6 +27,7 @@ import { FolderAddOutlined, HddOutlined, PushpinOutlined, + UndoOutlined, SortAscendingOutlined, SortDescendingOutlined, VerticalAlignBottomOutlined, @@ -543,6 +544,7 @@ export type V2CellContextMenuActionKey = | 'copy-row-for-paste' | 'paste-row-as-new' | 'copy-column-data' + | 'undo-cell-change' | 'set-null' | 'edit-row' | 'fill-selected' @@ -652,6 +654,7 @@ export const V2CellContextMenuView: React.FC<{ rowLabel?: string; selectedRowCount?: number; canModifyData?: boolean; + canUndoCellChange?: boolean; copiedRowCount?: number; canPasteCopiedColumns?: boolean; supportsCopyInsert?: boolean; @@ -663,6 +666,7 @@ export const V2CellContextMenuView: React.FC<{ rowLabel, selectedRowCount = 0, canModifyData = false, + canUndoCellChange = false, copiedRowCount = 0, canPasteCopiedColumns = false, supportsCopyInsert = true, @@ -694,6 +698,12 @@ export const V2CellContextMenuView: React.FC<{ <>
编辑
{renderItems([ + { + action: 'undo-cell-change', + icon: , + title: '撤销此单元格修改', + disabled: !canUndoCellChange, + }, { action: 'set-null', icon: , title: '设置为 NULL' }, { action: 'edit-row', icon: , title: '编辑本行', kbd: '↵' }, { action: 'copy-row-for-paste', icon: , title: '复制本行为新增行' },