feat(datagrid): 为表格编辑增加单元格级撤销能力

- 在 V2 与旧版单元格菜单中增加撤销当前修改入口
- 复用现有改单保存链路回退单元格值与脏标记
- 修复刷新后本地改单状态未完全清理的问题
- 补充相关布局与菜单回归测试

Close #563
This commit is contained in:
Syngnat
2026-06-14 15:41:26 +08:00
parent 6bbe5ad30d
commit 6cb5998cd6
4 changed files with 99 additions and 1 deletions

View File

@@ -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(
<V2CellContextMenuView
fieldName="status"
tableName="orders"
rowLabel="row 1"
canModifyData
canUndoCellChange
/>,
);
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');
});

View File

@@ -4339,6 +4339,45 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
handleCopyColumnData(cellContextMenu.dataIndex);
closeMenu();
return;
case 'undo-cell-change':
handleUndoContextMenuCellChange();
return;
case 'set-null':
handleCellSetNull();
return;
@@ -6128,6 +6170,7 @@ const DataGrid: React.FC<DataGridProps> = ({
getTargets,
handleBatchFillToSelected,
handleCellSetNull,
handleUndoContextMenuCellChange,
handleCopyContextMenuFieldName,
handleCopyCsv,
handleCopyDelete,
@@ -7465,6 +7508,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setModifiedColumns({});
setSelectedRowKeys([]);
if (onReload) onReload();
}, [clearAutoCommitTimer, onReload]);
@@ -7832,6 +7876,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
copyRowsForPaste([rowKey]);
}}
onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew}
onUndoCellChange={handleUndoContextMenuCellChange}
onSetNull={handleCellSetNull}
onEditRow={handleOpenContextMenuRowEditor}
onFillToSelected={() => {

View File

@@ -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<DataGridLegacyCellContextMenuProps
copiedRowsForPasteLength,
selectedRowKeysLength,
copiedCellPatchAvailable,
canUndoCellChange,
supportsCopyInsert,
onClose,
onCopyFieldName,
onCopyRowData,
onCopyRowForPaste,
onPasteCopiedRowsAsNew,
onUndoCellChange,
onSetNull,
onEditRow,
onFillToSelected,
@@ -131,6 +135,22 @@ const DataGridLegacyCellContextMenu: React.FC<DataGridLegacyCellContextMenuProps
<div style={separatorStyle(darkMode)} />
{canModifyData && (
<>
<div
style={{
...baseItemStyle,
cursor: canUndoCellChange ? 'pointer' : 'not-allowed',
opacity: canUndoCellChange ? 1 : 0.5,
}}
{...makeHoverHandlers(canUndoCellChange)}
onClick={() => {
if (canUndoCellChange) {
onUndoCellChange();
}
}}
>
<UndoOutlined style={{ marginRight: 8 }} />
</div>
<div style={baseItemStyle} {...makeHoverHandlers()} onClick={onSetNull}>
NULL
</div>

View File

@@ -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<{
<>
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{
action: 'undo-cell-change',
icon: <UndoOutlined />,
title: '撤销此单元格修改',
disabled: !canUndoCellChange,
},
{ action: 'set-null', icon: <ClearOutlined />, title: '设置为 NULL' },
{ action: 'edit-row', icon: <EditOutlined />, title: '编辑本行', kbd: '↵' },
{ action: 'copy-row-for-paste', icon: <CopyOutlined />, title: '复制本行为新增行' },