mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 08:03:04 +08:00
✨feat(data-grid): 新增表格批量编辑功能
- 批量填充相同值:右键菜单新增"填充到选中行"选项,可将当前单元格值批量填充到所有选中行 - 拖拽填充柄:单元格悬停时右下角显示蓝色填充柄,支持向下拖拽自动填充 - 智能自增算法:数字类型+1,字符串末尾数字+1并保持前导零位数(如 item_001 → item_002) - 性能优化:使用 ref 缓存 DOM 查询结果,避免拖拽过程中触发 React 重渲染 - 选区指示器使用 fixed 定位渲染到 Portal,确保位置准确
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback }
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||
import { useStore } from '../store';
|
||||
@@ -49,6 +49,46 @@ const toFormText = (val: any): string => {
|
||||
|
||||
const INLINE_EDIT_MAX_CHARS = 2000;
|
||||
|
||||
/**
|
||||
* 智能自增算法:
|
||||
* - 纯数字:+1
|
||||
* - 字符串末尾数字:末尾数字 +1(保持前导零位数)
|
||||
* - 无数字:原值不变
|
||||
*/
|
||||
const smartIncrement = (value: any, step: number = 1): any => {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
// 纯数字类型
|
||||
if (typeof value === 'number') {
|
||||
return value + step;
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// 纯数字字符串
|
||||
if (/^-?\d+(\.\d+)?$/.test(str)) {
|
||||
const num = parseFloat(str);
|
||||
if (Number.isInteger(num)) {
|
||||
return String(num + step);
|
||||
}
|
||||
return String((num + step).toFixed((str.split('.')[1] || '').length));
|
||||
}
|
||||
|
||||
// 字符串末尾数字模式(如 item_1, user001)
|
||||
const match = str.match(/^(.*?)(\d+)$/);
|
||||
if (match) {
|
||||
const prefix = match[1];
|
||||
const numStr = match[2];
|
||||
const num = parseInt(numStr, 10) + step;
|
||||
// 保持前导零位数
|
||||
const newNumStr = String(num).padStart(numStr.length, '0');
|
||||
return prefix + newNumStr;
|
||||
}
|
||||
|
||||
// 无法自增,返回原值
|
||||
return value;
|
||||
};
|
||||
|
||||
const shouldOpenModalEditor = (val: any): boolean => {
|
||||
if (val === null || val === undefined) return false;
|
||||
if (typeof val === 'string') {
|
||||
@@ -129,6 +169,8 @@ const ResizableTitle = (props: any) => {
|
||||
const EditableContext = React.createContext<any>(null);
|
||||
const CellContextMenuContext = React.createContext<{
|
||||
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
|
||||
handleBatchFillToSelected: (record: Item, dataIndex: string) => void;
|
||||
handleDragFillStart: (record: Item, dataIndex: string, cellElement: HTMLElement) => void;
|
||||
} | null>(null);
|
||||
const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
@@ -167,6 +209,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
...restProps
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const cellRef = useRef<HTMLTableCellElement>(null);
|
||||
const form = useContext(EditableContext);
|
||||
@@ -247,10 +290,37 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
) : (
|
||||
<div
|
||||
className="editable-cell-value-wrap"
|
||||
style={{ paddingRight: 24, minHeight: 20 }}
|
||||
style={{ paddingRight: 24, minHeight: 20, position: 'relative' }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{/* 填充柄 - 仅在悬停时显示 */}
|
||||
{isHovered && cellContextMenuContext && (
|
||||
<div
|
||||
className="fill-handle"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: '#1890ff',
|
||||
cursor: 'crosshair',
|
||||
borderRadius: 1,
|
||||
zIndex: 10,
|
||||
}}
|
||||
title="拖拽向下填充"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (cellRef.current && cellContextMenuContext) {
|
||||
cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -420,6 +490,34 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
|
||||
// 拖拽填充状态 - 只保留必要的 React 状态
|
||||
const [dragFillActive, setDragFillActive] = useState(false);
|
||||
const dragFillGhostRef = useRef<HTMLDivElement>(null);
|
||||
const dragFillRafRef = useRef<number | null>(null);
|
||||
// 使用 ref 存储拖拽数据,避免状态更新导致重渲染
|
||||
const dragFillDataRef = useRef<{
|
||||
startRecord: Item | null;
|
||||
dataIndex: string;
|
||||
startRowIndex: number;
|
||||
currentRowIndex: number;
|
||||
startCellRect: DOMRect | null;
|
||||
colIndex: number;
|
||||
// 缓存 DOM 查询结果
|
||||
cachedRows: HTMLElement[];
|
||||
cachedRowKeys: string[];
|
||||
cachedStartEl: HTMLElement | null;
|
||||
}>({
|
||||
startRecord: null,
|
||||
dataIndex: '',
|
||||
startRowIndex: -1,
|
||||
currentRowIndex: -1,
|
||||
startCellRect: null,
|
||||
colIndex: -1,
|
||||
cachedRows: [],
|
||||
cachedRowKeys: [],
|
||||
cachedStartEl: null,
|
||||
});
|
||||
|
||||
const scrollTableBodyToBottom = useCallback(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
@@ -580,6 +678,270 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const rowKeyStr = useCallback((k: React.Key) => String(k), []);
|
||||
|
||||
// 批量填充到选中行
|
||||
const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
|
||||
const sourceValue = sourceRecord[dataIndex];
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
|
||||
if (selKeys.length === 0) {
|
||||
message.info('请先选择要填充的行');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceKey = sourceRecord?.[GONAVI_ROW_KEY];
|
||||
// 过滤掉源行本身
|
||||
const targetKeys = selKeys.filter(k => k !== sourceKey);
|
||||
|
||||
if (targetKeys.length === 0) {
|
||||
message.info('没有其他选中的行可以填充');
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量更新
|
||||
let updatedCount = 0;
|
||||
targetKeys.forEach(key => {
|
||||
const keyStr = rowKeyStr(key);
|
||||
const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
|
||||
if (isAdded) {
|
||||
setAddedRows(prev => prev.map(r => {
|
||||
if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
|
||||
updatedCount++;
|
||||
return { ...r, [dataIndex]: sourceValue };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
} else {
|
||||
setModifiedRows(prev => {
|
||||
const existing = prev[keyStr] || {};
|
||||
// 获取原始行数据
|
||||
const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
updatedCount++;
|
||||
return {
|
||||
...prev,
|
||||
[keyStr]: { ...originalRow, ...existing, [dataIndex]: sourceValue }
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
message.success(`已填充 ${updatedCount} 行`);
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [addedRows, rowKeyStr]);
|
||||
|
||||
// 拖拽填充开始
|
||||
const handleDragFillStart = useCallback((record: Item, dataIndex: string, cellElement: HTMLElement) => {
|
||||
const currentData = displayDataRef.current;
|
||||
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||
const rowIndex = currentData.findIndex(r => r?.[GONAVI_ROW_KEY] === rowKey);
|
||||
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
const cellRect = cellElement.getBoundingClientRect();
|
||||
|
||||
// 预先计算列索引
|
||||
let colIndex = -1;
|
||||
const headerRow = containerRef.current?.querySelector('.ant-table-thead tr');
|
||||
if (headerRow) {
|
||||
const headerCells = headerRow.querySelectorAll('th');
|
||||
headerCells.forEach((th, idx) => {
|
||||
const titleSpan = th.querySelector('.ant-table-column-title');
|
||||
const titleText = titleSpan?.textContent?.trim() || th.textContent?.trim();
|
||||
if (titleText === dataIndex) {
|
||||
colIndex = idx;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 预先缓存所有行的 DOM 元素和 key
|
||||
const tableBody = containerRef.current?.querySelector('.ant-table-body');
|
||||
const rows = tableBody ? Array.from(tableBody.querySelectorAll('tr[data-row-key]')) as HTMLElement[] : [];
|
||||
const rowKeys = rows.map(r => r.getAttribute('data-row-key') || '');
|
||||
const startKey = String(rowKey);
|
||||
const startEl = rows.find((_, i) => rowKeys[i] === startKey) || null;
|
||||
|
||||
// 存储到 ref
|
||||
dragFillDataRef.current = {
|
||||
startRecord: record,
|
||||
dataIndex,
|
||||
startRowIndex: rowIndex,
|
||||
currentRowIndex: rowIndex,
|
||||
startCellRect: cellRect,
|
||||
colIndex,
|
||||
cachedRows: rows,
|
||||
cachedRowKeys: rowKeys,
|
||||
cachedStartEl: startEl,
|
||||
};
|
||||
|
||||
setDragFillActive(true);
|
||||
document.body.style.cursor = 'crosshair';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
// 拖拽填充移动(极致优化:最小化 DOM 操作)
|
||||
const handleDragFillMove = useCallback((e: MouseEvent) => {
|
||||
const data = dragFillDataRef.current;
|
||||
if (!data.startRecord) return;
|
||||
|
||||
const ghost = dragFillGhostRef.current;
|
||||
if (!ghost) return;
|
||||
|
||||
const mouseY = e.clientY;
|
||||
const rows = data.cachedRows;
|
||||
const rowKeys = data.cachedRowKeys;
|
||||
const startEl = data.cachedStartEl;
|
||||
|
||||
if (!startEl || rows.length === 0) {
|
||||
ghost.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 二分查找优化:找到鼠标所在的行
|
||||
let endEl: HTMLElement = startEl;
|
||||
let endIdx = data.startRowIndex;
|
||||
|
||||
// 使用简单遍历(行数通常不多,二分查找收益有限)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rect = row.getBoundingClientRect();
|
||||
|
||||
// 只需要检查行的底部边界
|
||||
if (mouseY >= rect.top) {
|
||||
const currentData = displayDataRef.current;
|
||||
const rowKey = rowKeys[i];
|
||||
const dataIdx = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === rowKey);
|
||||
|
||||
if (dataIdx > data.startRowIndex) {
|
||||
endEl = row;
|
||||
endIdx = dataIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.currentRowIndex = endIdx;
|
||||
|
||||
// 直接读取位置并更新样式(单次 reflow)
|
||||
const startRect = startEl.getBoundingClientRect();
|
||||
const endRect = endEl.getBoundingClientRect();
|
||||
|
||||
const cells = startEl.querySelectorAll('td');
|
||||
const targetCell = (data.colIndex >= 0 && cells[data.colIndex]) ? cells[data.colIndex] : null;
|
||||
const cellLeft = targetCell ? targetCell.getBoundingClientRect().left : data.startCellRect!.left;
|
||||
const cellWidth = targetCell ? targetCell.getBoundingClientRect().width : data.startCellRect!.width;
|
||||
|
||||
// 批量设置样式(浏览器会合并为一次重绘)
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
display: block;
|
||||
left: ${cellLeft}px;
|
||||
top: ${startRect.top}px;
|
||||
width: ${cellWidth}px;
|
||||
height: ${endRect.bottom - startRect.top}px;
|
||||
border: 2px solid #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
`;
|
||||
}, []);
|
||||
|
||||
// 拖拽填充结束
|
||||
const handleDragFillEnd = useCallback(() => {
|
||||
// 清理 RAF
|
||||
if (dragFillRafRef.current !== null) {
|
||||
cancelAnimationFrame(dragFillRafRef.current);
|
||||
dragFillRafRef.current = null;
|
||||
}
|
||||
|
||||
const data = dragFillDataRef.current;
|
||||
|
||||
if (!data.startRecord) {
|
||||
setDragFillActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startRecord, dataIndex, startRowIndex, currentRowIndex } = data;
|
||||
const sourceValue = startRecord[dataIndex];
|
||||
const currentData = displayDataRef.current;
|
||||
|
||||
// 计算需要填充的行
|
||||
if (currentRowIndex > startRowIndex) {
|
||||
let updatedCount = 0;
|
||||
for (let i = startRowIndex + 1; i <= currentRowIndex && i < currentData.length; i++) {
|
||||
const targetRow = currentData[i];
|
||||
const targetKey = targetRow?.[GONAVI_ROW_KEY];
|
||||
if (targetKey === undefined) continue;
|
||||
|
||||
const keyStr = rowKeyStr(targetKey);
|
||||
const step = i - startRowIndex;
|
||||
const fillValue = smartIncrement(sourceValue, step);
|
||||
|
||||
const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
|
||||
if (isAdded) {
|
||||
setAddedRows(prev => prev.map(r => {
|
||||
if (rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) {
|
||||
updatedCount++;
|
||||
return { ...r, [dataIndex]: fillValue };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
} else {
|
||||
setModifiedRows(prev => {
|
||||
const existing = prev[keyStr] || {};
|
||||
updatedCount++;
|
||||
return {
|
||||
...prev,
|
||||
[keyStr]: { ...targetRow, ...existing, [dataIndex]: fillValue }
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
message.success(`已填充 ${updatedCount} 行`);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
if (dragFillGhostRef.current) {
|
||||
dragFillGhostRef.current.style.display = 'none';
|
||||
}
|
||||
|
||||
// 重置 ref
|
||||
dragFillDataRef.current = {
|
||||
startRecord: null,
|
||||
dataIndex: '',
|
||||
startRowIndex: -1,
|
||||
currentRowIndex: -1,
|
||||
startCellRect: null,
|
||||
colIndex: -1,
|
||||
cachedRows: [],
|
||||
cachedRowKeys: [],
|
||||
cachedStartEl: null,
|
||||
};
|
||||
|
||||
setDragFillActive(false);
|
||||
}, [addedRows, rowKeyStr]);
|
||||
|
||||
// 全局鼠标事件监听(拖拽填充)
|
||||
useEffect(() => {
|
||||
if (!dragFillActive) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => handleDragFillMove(e);
|
||||
const handleMouseUp = () => handleDragFillEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragFillActive, handleDragFillMove, handleDragFillEnd]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
return [...data, ...addedRows].filter(item => {
|
||||
const k = item?.[GONAVI_ROW_KEY];
|
||||
@@ -1528,7 +1890,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Modal>
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected, handleDragFillStart }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
@@ -1592,6 +1954,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
>
|
||||
设置为 NULL
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: selectedRowKeys.length > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'background 0.2s',
|
||||
opacity: selectedRowKeys.length > 0 ? 1 : 0.5,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedRowKeys.length > 0) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
if (selectedRowKeys.length > 0 && cellContextMenu.record) {
|
||||
handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
|
||||
填充到选中行 ({selectedRowKeys.length})
|
||||
</div>
|
||||
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<div
|
||||
style={{
|
||||
@@ -1741,10 +2123,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; }
|
||||
.${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; }
|
||||
.${gridId} .fill-handle:hover { background: #0050b3 !important; transform: scale(1.2); }
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
<div
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1759,6 +2142,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
{/* 拖拽填充选区指示器 - 使用 fixed 定位基于视口 */}
|
||||
{dragFillActive && createPortal(
|
||||
<div
|
||||
ref={dragFillGhostRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
border: '2px solid #1890ff',
|
||||
background: 'rgba(24, 144, 255, 0.1)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9998,
|
||||
display: 'none',
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user