feat(data-grid): 支持拖选单元格直接复制到剪贴板

Fixes #322
This commit is contained in:
Syngnat
2026-04-11 22:10:48 +08:00
parent 632e57ea60
commit 7a2563b83b
4 changed files with 176 additions and 1 deletions

View File

@@ -23,11 +23,12 @@
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制效果就如操作excel表格的选择复制一样 | Fixed | Pending |
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | Pending |
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
| #331 | 重复连接 DB一分钟重试了 60 多次 | Fixed | `ca76440` |
## Notes
@@ -92,6 +93,12 @@
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend``npm run build` 确认 TS 与打包通过。
### #322
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch用于“粘贴到选中行”没有把矩形选区真正导出到系统剪贴板。
- 处理:新增选区复制 helper将矩形选区按当前可见行列顺序导出为制表符文本同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend``npm run build` 确认功能接线通过。
## Next
- 继续处理下一个最早且可直接落地的开放 issue。

View File

@@ -49,6 +49,7 @@ import {
resolveUniqueKeyGroupsFromIndexes,
} from './dataGridCopyInsert';
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -3734,6 +3735,59 @@ const DataGrid: React.FC<DataGridProps> = ({
navigator.clipboard.writeText(text).catch(console.error);
void message.success("Copied to clipboard");
}, []);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
void message.info('请先拖选要复制的单元格');
return;
}
const parsed = Array.from(activeSelection)
.map((cellKey) => splitCellKey(cellKey))
.filter((item): item is { rowKey: string; colName: string } => !!item);
if (parsed.length === 0) {
void message.info('未识别到可复制的单元格');
return;
}
const text = buildSelectedCellClipboardText({
selectedCells: parsed,
rows: mergedDisplayData as Array<Record<string, any>>,
columnOrder: displayColumnNames,
rowKeyField: GONAVI_ROW_KEY,
});
if (!text) {
void message.info('当前选区没有可复制内容');
return;
}
copyToClipboard(text);
}, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard]);
useEffect(() => {
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;
}
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) return;
event.preventDefault();
handleCopySelectedCellsToClipboard();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard]);
const getTargets = useCallback((clickedRecord: any) => {
const selKeys = selectedRowKeysRef.current;
@@ -4892,6 +4946,12 @@ const DataGrid: React.FC<DataGridProps> = ({
</Button>
{cellEditMode && selectedCells.size > 0 && (
<>
<Button
icon={<CopyOutlined />}
onClick={handleCopySelectedCellsToClipboard}
>
({selectedCells.size})
</Button>
<Button
icon={<CopyOutlined />}
onClick={handleCopySelectedColumnsFromRow}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
describe('dataGridSelectionCopy helpers', () => {
it('builds clipboard text in visible row and column order', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-2', colName: 'name' },
{ rowKey: 'row-1', colName: 'id' },
{ rowKey: 'row-1', colName: 'name' },
{ rowKey: 'row-2', colName: 'id' },
],
rows: [
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
],
columnOrder: ['id', 'name', 'email'],
rowKeyField: '__rowKey',
});
expect(text).toBe('1\tAlice\n2\tBob');
});
it('normalizes null, objects and multiline text for clipboard safety', () => {
const text = buildSelectedCellClipboardText({
selectedCells: [
{ rowKey: 'row-1', colName: 'notes' },
{ rowKey: 'row-1', colName: 'meta' },
{ rowKey: 'row-2', colName: 'notes' },
{ rowKey: 'row-2', colName: 'meta' },
],
rows: [
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
],
columnOrder: ['notes', 'meta'],
rowKeyField: '__rowKey',
});
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
});
});

View File

@@ -0,0 +1,65 @@
export interface SelectedGridCell {
rowKey: string;
colName: string;
}
const normalizeClipboardCellValue = (value: unknown): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'string') {
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
try {
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
} catch {
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
}
};
export const buildSelectedCellClipboardText = ({
selectedCells,
rows,
columnOrder,
rowKeyField,
}: {
selectedCells: SelectedGridCell[];
rows: Array<Record<string, any>>;
columnOrder: string[];
rowKeyField: string;
}): string => {
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
return '';
}
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
if (!orderedRows.length || !orderedColumns.length) {
return '';
}
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
return orderedRows
.map((row) => {
const rowKey = String(row?.[rowKeyField] ?? '');
return orderedColumns
.map((columnName) => {
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
return '';
}
return normalizeClipboardCellValue(row?.[columnName]);
})
.join('\t');
})
.join('\n');
};