🐛 fix(data-grid): 支持单值粘贴填充选区 (#781) (#784)

## 关联 Issue

Closes #781

## 问题根因

表格拖选会在 `currentSelectionRef` 中保存完整目标选区,但剪贴板粘贴逻辑仅使用 `selectionStartRef`
作为锚点,并按剪贴板二维矩阵生成目标坐标。单个复制值解析为 `1 x 1` 矩阵,因此只更新了锚点单元格,没有使用其余已选单元格。

## 修复方案

- 当剪贴板内容为 `1 x 1` 且当前选区超过一个单元格时,将单值映射到选区中的每个目标坐标。
- 粘贴时根据当前显示行和列重新解析选区坐标,避免排序后使用过期位置。
- 继续复用现有只读列过滤、草稿合并、新增行、删除行和原值比较逻辑。
- 多行或多列剪贴板仍保持原有的锚点二维矩阵粘贴行为,不引入重复铺贴或跨页填充语义。

## 验证结果

| 验证项 | 命令或步骤 | 结果 |
| --- | --- | --- |
| 修复前回归 | `npm --prefix frontend test --
src/components/useDataGridBatchActions.test.tsx` | 新增用例按预期失败:实际仅更新
`row-1.id`,期望两行四格 |
| 回归测试 | `npm --prefix frontend test --
src/components/useDataGridBatchActions.test.tsx` | 通过,5/5 |
| 剪贴板相关测试 | `npm --prefix frontend test --
src/components/dataGridClipboardPaste.test.ts
src/components/dataGridSelectionCopy.test.ts
src/components/useDataGridBatchActions.test.tsx` | 通过,3 个文件、13/13 |
| 类型与生产构建 | `npm --prefix frontend run build` | 通过,`tsc && vite build`
成功 |
| 差异检查 | `git diff --cached --check` | 通过 |

前端全量测试 `npm --prefix frontend test` 执行结果为 420 个文件通过、1 个文件失败,3481 项测试通过、1
项失败。唯一失败是 `src/testPolicy.test.ts` 检测到 `upstream/dev` 已存在的
`AIMessageCodeBlock.dependencyBoundary.test.ts` 读取源码文本;本 PR
未修改相关文件。该上游既有错误将通过独立 PR 修复,避免混入本 Issue。

GUI 验证未完成:本地 Vite 服务已正常启动,但浏览器自动化宿主连续返回 `browser guest not attached
(webview not ready)`,没有创建可操作页面或截图证据。未将该项记为通过。

## 风险与兼容性

- 不涉及数据或配置格式变化、数据库迁移或公共 API 变化。
- 仅改变 `1 x 1` 剪贴板内容在多格选区中的行为;现有二维矩阵粘贴由相关回归测试覆盖并保持不变。
- 操作仍只影响当前已加载并选中的可写单元格;只读列、已删除行和未变化值会沿用现有规则跳过。
- 更新规模随当前选区大小线性增长;没有新增网络请求、并发路径或跨页扫描。
- 验证边界:自动化测试覆盖了前端草稿状态与消息计数,未在真实 Wails/MySQL 环境提交数据库变更。

## 回滚方式

回滚提交 `ec6b4e9e0182a85b862627177c8d373137d1a90b`
即可恢复原有单锚点粘贴逻辑。该提交不改变数据或配置格式,回滚本身不需要数据迁移。
This commit is contained in:
Syngnat
2026-07-29 22:26:24 +08:00
committed by GitHub
3 changed files with 75 additions and 7 deletions

View File

@@ -23,6 +23,7 @@ export const buildDataGridClipboardPasteRows = ({
columnNames,
startRowIndex,
startColumnIndex,
targetCells,
rowKeyField,
addedRowKeys,
modifiedRows,
@@ -35,6 +36,7 @@ export const buildDataGridClipboardPasteRows = ({
columnNames: string[];
startRowIndex: number;
startColumnIndex: number;
targetCells?: Array<{ rowIndex: number; columnIndex: number }>;
rowKeyField: string;
addedRowKeys: Set<string>;
modifiedRows: Record<string, any>;
@@ -46,11 +48,28 @@ export const buildDataGridClipboardPasteRows = ({
return { rows: [], updatedCellCount: 0 };
}
const valuesByRowIndex = new Map<number, Array<{ columnIndex: number; value: DataGridClipboardValue }>>();
const appendValue = (rowIndex: number, columnIndex: number, value: DataGridClipboardValue) => {
const rowValues = valuesByRowIndex.get(rowIndex) || [];
rowValues.push({ columnIndex, value });
valuesByRowIndex.set(rowIndex, rowValues);
};
if (targetCells && matrix.length === 1 && matrix[0]?.length === 1) {
targetCells.forEach(({ rowIndex, columnIndex }) => appendValue(rowIndex, columnIndex, matrix[0][0]));
} else {
matrix.forEach((sourceValues, sourceRowIndex) => {
sourceValues.forEach((value, sourceColumnIndex) => {
appendValue(startRowIndex + sourceRowIndex, startColumnIndex + sourceColumnIndex, value);
});
});
}
const pasteRows: DataGridClipboardPasteRow[] = [];
let updatedCellCount = 0;
matrix.forEach((sourceValues, sourceRowIndex) => {
const baseRow = rows[startRowIndex + sourceRowIndex];
valuesByRowIndex.forEach((targetValues, targetRowIndex) => {
const baseRow = rows[targetRowIndex];
const rowKeyValue = baseRow?.[rowKeyField];
if (rowKeyValue === undefined || rowKeyValue === null) return;
@@ -61,11 +80,11 @@ export const buildDataGridClipboardPasteRows = ({
const currentRow = addedRowKeys.has(rowKey) ? baseRow : { ...baseRow, ...existing };
const values: Record<string, DataGridClipboardValue> = {};
sourceValues.forEach((nextValue, sourceColumnIndex) => {
const columnName = columnNames[startColumnIndex + sourceColumnIndex];
targetValues.forEach(({ columnIndex, value }) => {
const columnName = columnNames[columnIndex];
if (!columnName || !isWritableColumn(columnName)) return;
if (isValueEqual(currentRow?.[columnName], nextValue)) return;
values[columnName] = nextValue;
if (isValueEqual(currentRow?.[columnName], value)) return;
values[columnName] = value;
updatedCellCount += 1;
});

View File

@@ -212,6 +212,37 @@ describe('useDataGridBatchActions clipboard paste', () => {
expect(messageApi.success).toHaveBeenCalledWith('data_grid.message.pasted_columns_to_rows:{"rows":2,"cells":4}');
});
it('fills the selected cells when pasting a single value', () => {
const hook = renderHook();
const cell = selectCell(hook.container, 'row-1', 'id');
hook.currentSelectionRef.current = new Set([
makeCellKey('row-1', 'id'),
makeCellKey('row-1', 'name'),
makeCellKey('row-2', 'id'),
makeCellKey('row-2', 'name'),
]);
const preventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: cell,
clipboardData: { types: ['text/plain'], getData: vi.fn(() => 'filled') },
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledOnce();
const nextRows = hook.setModifiedRows.mock.calls[0][0]({});
expect(nextRows).toEqual({
'row-1': { id: 'filled', name: 'filled' },
'row-2': { id: 'filled', name: 'filled' },
});
const nextColumns = hook.setModifiedColumns.mock.calls[0][0]({});
expect(nextColumns['row-1']).toEqual(new Set(['id', 'name']));
expect(nextColumns['row-2']).toEqual(new Set(['id', 'name']));
expect(messageApi.success).toHaveBeenCalledWith('data_grid.message.pasted_columns_to_rows:{"rows":2,"cells":4}');
});
it('resolves the selected row and column again before pasting', () => {
const hook = renderHook();
const cell = selectCell(hook.container, 'row-2', 'name');

View File

@@ -531,6 +531,23 @@ const handleBatchFillCells = useCallback(() => {
const startColumnIndex = columnIndexMap.get(start.colName) ?? -1;
if (startRowIndex === -1 || startColumnIndex === -1) return;
let targetCells: Array<{ rowIndex: number; columnIndex: number }> | undefined;
if (matrix.length === 1 && matrix[0]?.length === 1 && currentSelectionRef.current.size > 1) {
const rowIndexes = new Map<string, number>();
currentRows.forEach((row, rowIndex) => {
const key = row?.[GONAVI_ROW_KEY];
if (key !== undefined && key !== null) rowIndexes.set(rowKeyStr(key), rowIndex);
});
const selectedTargets = Array.from(currentSelectionRef.current).flatMap((cellKey) => {
const cell = splitCellKey(cellKey);
if (!cell) return [];
const rowIndex = rowIndexes.get(cell.rowKey);
const columnIndex = columnIndexMap.get(cell.colName);
return rowIndex === undefined || columnIndex === undefined ? [] : [{ rowIndex, columnIndex }];
});
if (selectedTargets.length > 1) targetCells = selectedTargets;
}
const addedRowKeys = new Set<string>();
addedRows.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
@@ -542,6 +559,7 @@ const handleBatchFillCells = useCallback(() => {
columnNames: displayColumnNames,
startRowIndex,
startColumnIndex,
targetCells,
rowKeyField: GONAVI_ROW_KEY,
addedRowKeys,
modifiedRows,
@@ -615,7 +633,7 @@ const handleBatchFillCells = useCallback(() => {
cellSelectionPointerRef.current = null;
isDraggingRef.current = false;
};
}, [addedRows, canModifyData, deletedRowKeys, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isCellValueEqualForDiff, isWritableResultColumn, markCellSelectionDeleteEligible, modifiedRows, rowKeyStr, setAddedRows, setModifiedColumns, setModifiedRows, setSelectedCells, translateDataGrid, updateCellSelection]);
}, [addedRows, canModifyData, deletedRowKeys, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isCellValueEqualForDiff, isWritableResultColumn, markCellSelectionDeleteEligible, modifiedRows, rowKeyStr, setAddedRows, setModifiedColumns, setModifiedRows, setSelectedCells, splitCellKey, translateDataGrid, updateCellSelection]);
const handleCopySelectedColumnsFromRow = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;