diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index c678324..6eeaa08 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox } 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, VerticalAlignBottomOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; @@ -358,31 +358,6 @@ const EditableCell: React.FC = React.memo(({ onMouseLeave={() => setIsHovered(false)} > {children} - {/* 填充柄 - 仅在悬停时显示 */} - {isHovered && cellContextMenuContext && ( -
{ - e.preventDefault(); - e.stopPropagation(); - if (cellRef.current && cellContextMenuContext) { - cellContextMenuContext.handleDragFillStart(record, dataIndex, cellRef.current); - } - }} - /> - )}
); } @@ -403,6 +378,8 @@ const EditableCell: React.FC = React.memo(({ {childNode} @@ -552,6 +529,20 @@ const DataGrid: React.FC = ({ const containerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); + // 批量编辑模式状态 + const [cellEditMode, setCellEditMode] = useState(false); + const [selectedCells, setSelectedCells] = useState>(new Set()); + const [cellSelectionStart, setCellSelectionStart] = useState<{ rowKey: string; dataIndex: string } | null>(null); + const [batchEditModalOpen, setBatchEditModalOpen] = useState(false); + const [batchEditValue, setBatchEditValue] = useState(''); + const [batchEditSetNull, setBatchEditSetNull] = useState(false); + + // 使用 ref 来优化拖拽性能,完全避免状态更新 + const cellSelectionRafRef = useRef(null); + const isDraggingRef = useRef(false); + const currentSelectionRef = useRef>(new Set()); + const selectionStartRef = useRef<{ rowKey: string; dataIndex: string } | null>(null); + // 拖拽填充状态 - 只保留必要的 React 状态 const [dragFillActive, setDragFillActive] = useState(false); const dragFillGhostRef = useRef(null); @@ -740,6 +731,178 @@ const DataGrid: React.FC = ({ const rowKeyStr = useCallback((k: React.Key) => String(k), []); + // 直接操作 DOM 更新选中效果,避免 React 重渲染 + const updateCellSelection = useCallback((newSelection: Set) => { + const tableBody = containerRef.current?.querySelector('.ant-table-body'); + if (!tableBody) return; + + // 移除所有旧的选中样式 + const allCells = tableBody.querySelectorAll('td[data-cell-selected="true"]'); + allCells.forEach(cell => { + (cell as HTMLElement).removeAttribute('data-cell-selected'); + (cell as HTMLElement).style.background = ''; + (cell as HTMLElement).style.outline = ''; + (cell as HTMLElement).style.outlineOffset = ''; + }); + + // 添加新的选中样式 - 使用 data-row-key 和 data-col-name 属性直接查找 + newSelection.forEach(cellKey => { + const [rowKey, colName] = cellKey.split('-'); + const cell = tableBody.querySelector(`td[data-row-key="${rowKey}"][data-col-name="${colName}"]`) as HTMLElement; + if (cell) { + cell.setAttribute('data-cell-selected', 'true'); + cell.style.background = 'rgba(24, 144, 255, 0.1)'; + cell.style.outline = '2px solid #1890ff'; + cell.style.outlineOffset = '-2px'; + } + }); + }, []); + + // 批量填充选中的单元格 + const handleBatchFillCells = useCallback(() => { + const cellsToFill = currentSelectionRef.current; + if (cellsToFill.size === 0) { + message.info('请先选择要填充的单元格'); + return; + } + + const fillValue = batchEditSetNull ? null : batchEditValue; + let updatedCount = 0; + + cellsToFill.forEach(cellKey => { + const [rowKey, dataIndex] = cellKey.split('-'); + const keyStr = rowKey; + 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] || {}; + const originalRow = displayDataRef.current.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); + updatedCount++; + return { + ...prev, + [keyStr]: { ...originalRow, ...existing, [dataIndex]: fillValue } + }; + }); + } + }); + + message.success(`已填充 ${updatedCount} 个单元格`); + setBatchEditModalOpen(false); + + // 清除选中状态 + setSelectedCells(new Set()); + setCellSelectionStart(null); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + updateCellSelection(new Set()); + }, [batchEditValue, batchEditSetNull, addedRows, rowKeyStr, updateCellSelection]); + + // 事件委托:在容器级别处理批量编辑模式的鼠标事件 + useEffect(() => { + if (!cellEditMode) return; + + const container = containerRef.current; + if (!container) return; + + const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => { + const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement; + if (!td) return null; + const rowKey = td.getAttribute('data-row-key'); + const colName = td.getAttribute('data-col-name'); + if (!rowKey || !colName) return null; + return { rowKey, colName }; + }; + + const onMouseDown = (e: MouseEvent) => { + const cellInfo = getCellInfo(e.target as HTMLElement); + if (!cellInfo) return; + + e.preventDefault(); + isDraggingRef.current = true; + selectionStartRef.current = { rowKey: cellInfo.rowKey, dataIndex: cellInfo.colName }; + currentSelectionRef.current = new Set([`${cellInfo.rowKey}-${cellInfo.colName}`]); + updateCellSelection(currentSelectionRef.current); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !selectionStartRef.current) return; + + const cellInfo = getCellInfo(e.target as HTMLElement); + if (!cellInfo) return; + + // 使用 RAF 节流 + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + } + + cellSelectionRafRef.current = requestAnimationFrame(() => { + cellSelectionRafRef.current = null; + const start = selectionStartRef.current; + if (!start) return; + + const currentData = displayDataRef.current; + const startRowIndex = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === start.rowKey); + const endRowIndex = currentData.findIndex(r => String(r?.[GONAVI_ROW_KEY]) === cellInfo.rowKey); + if (startRowIndex === -1 || endRowIndex === -1) return; + + const startColIndex = columnNames.indexOf(start.dataIndex); + const endColIndex = columnNames.indexOf(cellInfo.colName); + if (startColIndex === -1 || endColIndex === -1) return; + + const minRowIndex = Math.min(startRowIndex, endRowIndex); + const maxRowIndex = Math.max(startRowIndex, endRowIndex); + const minColIndex = Math.min(startColIndex, endColIndex); + const maxColIndex = Math.max(startColIndex, endColIndex); + + const newSelectedCells = new Set(); + for (let i = minRowIndex; i <= maxRowIndex; i++) { + const row = currentData[i]; + const rKey = String(row?.[GONAVI_ROW_KEY]); + for (let j = minColIndex; j <= maxColIndex; j++) { + newSelectedCells.add(`${rKey}-${columnNames[j]}`); + } + } + + currentSelectionRef.current = newSelectedCells; + updateCellSelection(newSelectedCells); + }); + }; + + const onMouseUp = () => { + if (!isDraggingRef.current) return; + isDraggingRef.current = false; + + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + + if (currentSelectionRef.current.size > 0) { + setSelectedCells(new Set(currentSelectionRef.current)); + setCellSelectionStart(selectionStartRef.current); + } + }; + + container.addEventListener('mousedown', onMouseDown); + container.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + return () => { + container.removeEventListener('mousedown', onMouseDown); + container.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [cellEditMode, columnNames, updateCellSelection]); + // 批量填充到选中行 const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { const sourceValue = sourceRecord[dataIndex]; @@ -1740,7 +1903,7 @@ const DataGrid: React.FC = ({ const enableVirtual = mergedDisplayData.length >= 200; return ( -
+
{/* Toolbar */}
{onReload && {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} +
+ + {cellEditMode && selectedCells.size > 0 && ( + <> + + + )}
{hasChanges && (