From 2f907369a0ab8c92dcd53a08f6421a058656d97c Mon Sep 17 00:00:00 2001 From: Ethanfly Date: Wed, 31 Dec 2025 14:05:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0-=E6=96=B0=E5=A2=9E=E8=A1=A8?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.js | 81 +++++++ electron/preload.js | 2 + src/App.tsx | 94 +++++++- src/components/MainContent.tsx | 200 ++++++++++++++--- src/components/VirtualDataTable.tsx | 326 ++++++++++++++++++++++++++-- src/lib/electron-api.ts | 12 + src/types.ts | 1 + 7 files changed, 668 insertions(+), 48 deletions(-) diff --git a/electron/main.js b/electron/main.js index d6b30c7..be49502 100644 --- a/electron/main.js +++ b/electron/main.js @@ -319,6 +319,18 @@ ipcMain.handle('db:deleteRow', async (event, id, database, table, primaryKey) => } }) +ipcMain.handle('db:insertRow', async (event, id, database, table, columns, values) => { + const connInfo = await ensureConnection(id) + if (!connInfo) return { success: false, message: '连接不存在或已断开,请重新连接' } + + try { + const result = await insertRow(connInfo.connection, connInfo.type, database, table, columns, values) + return { success: true, message: '插入成功', insertId: result?.insertId } + } catch (e) { + return { success: false, message: e.message } + } +}) + // ============ 数据库管理操作 ============ // 创建数据库 @@ -1535,6 +1547,75 @@ async function deleteRow(conn, type, database, table, primaryKey) { } } +async function insertRow(conn, type, database, table, columns, values) { + switch (type) { + case 'mysql': + case 'mariadb': { + const colList = columns.map(c => `\`${c}\``).join(', ') + const placeholders = columns.map(() => '?').join(', ') + const [result] = await conn.query( + `INSERT INTO \`${database}\`.\`${table}\` (${colList}) VALUES (${placeholders})`, + values + ) + return { insertId: result.insertId } + } + case 'postgresql': + case 'postgres': { + const colList = columns.map(c => `"${c}"`).join(', ') + const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ') + const result = await conn.query( + `INSERT INTO "${table}" (${colList}) VALUES (${placeholders}) RETURNING *`, + values + ) + return { insertId: result.rows[0]?.id } + } + case 'sqlite': { + const colList = columns.map(c => `"${c}"`).join(', ') + const placeholders = columns.map(() => '?').join(', ') + conn.run(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`, values) + // 获取最后插入的行ID + const stmt = conn.prepare('SELECT last_insert_rowid() as id') + let insertId = null + if (stmt.step()) { + insertId = stmt.getAsObject().id + } + stmt.free() + return { insertId } + } + case 'mongodb': { + const db = conn.db(database) + const doc = {} + columns.forEach((col, i) => { + doc[col] = values[i] + }) + const result = await db.collection(table).insertOne(doc) + return { insertId: result.insertedId.toString() } + } + case 'redis': { + await conn.select(parseInt(database) || 0) + // Redis: 假设第一个列是键名,第二个列是值 + if (columns.length >= 2) { + await conn.set(values[0], values[1]) + } + return { insertId: values[0] } + } + case 'sqlserver': { + const colList = columns.map(c => `[${c}]`).join(', ') + const request = conn.request() + columns.forEach((col, i) => { + request.input(`col${i}`, values[i]) + }) + const paramList = columns.map((_, i) => `@col${i}`).join(', ') + const result = await request.query( + `INSERT INTO [${table}] (${colList}) VALUES (${paramList}); SELECT SCOPE_IDENTITY() as id` + ) + return { insertId: result.recordset[0]?.id } + } + default: + throw new Error(`不支持的数据库类型: ${type}`) + } +} + // ============ 获取表详细信息(用于表设计器) ============ async function getTableInfo(conn, type, database, table) { const columns = await getColumnsDetailed(conn, type, database, table) diff --git a/electron/preload.js b/electron/preload.js index 7d3c1d9..0dff51b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -25,6 +25,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('db:updateRow', id, database, table, primaryKey, updates), deleteRow: (id, database, table, primaryKey) => ipcRenderer.invoke('db:deleteRow', id, database, table, primaryKey), + insertRow: (id, database, table, columns, values) => + ipcRenderer.invoke('db:insertRow', id, database, table, columns, values), // 数据库管理 createDatabase: (id, dbName, charset, collation) => diff --git a/src/App.tsx b/src/App.tsx index f1e2bc4..e47f3a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -455,6 +455,40 @@ export default function App() { } } + // 插入新增行 + if (tab.newRows && tab.newRows.length > 0) { + for (const newRow of tab.newRows) { + // 过滤掉所有值为null的列(只保留有值的列) + const columns: string[] = [] + const values: any[] = [] + + for (const [colName, value] of Object.entries(newRow)) { + if (value !== null && value !== undefined && value !== '') { + columns.push(colName) + values.push(value) + } + } + + // 如果所有列都是空的,跳过这行 + if (columns.length === 0) { + continue + } + + const result = await api.insertRow( + tab.connectionId, + tab.database, + tab.tableName, + columns, + values + ) + + if (result?.error) { + setStatus({ text: `插入失败: ${result.error}`, type: 'error' }) + return + } + } + } + // 重新加载数据 const result = await api.getTableData( tab.connectionId, tab.database, tab.tableName, tab.page, tab.pageSize @@ -471,7 +505,8 @@ export default function App() { total: totalResult?.total || tab.total, originalData: result?.data || [], pendingChanges: new Map(), - deletedRows: new Set() + deletedRows: new Set(), + newRows: [] } : t )) @@ -492,12 +527,61 @@ export default function App() { ...tab, data: tab.originalData || tab.data, pendingChanges: new Map(), - deletedRows: new Set() + deletedRows: new Set(), + newRows: [] } })) setStatus({ text: '已放弃修改', type: 'warning' }) } + // 新增表格行 + const handleAddTableRow = (tabId: string) => { + setTabs(prev => prev.map(t => { + if (t.id !== tabId || !('tableName' in t)) return t + const tab = t as TableTab + + // 创建一个空行,所有列的值设为null + const newRow: Record = {} + tab.columns.forEach(col => { + newRow[col.name] = null + }) + + const newRows = [...(tab.newRows || []), newRow] + + return { ...tab, newRows } + })) + setStatus({ text: '已添加新行', type: 'info' }) + } + + // 更新新增行的数据 + const handleUpdateNewRow = (tabId: string, rowIndex: number, colName: string, value: any) => { + setTabs(prev => prev.map(t => { + if (t.id !== tabId || !('tableName' in t)) return t + const tab = t as TableTab + + const newRows = [...(tab.newRows || [])] + if (newRows[rowIndex]) { + newRows[rowIndex] = { ...newRows[rowIndex], [colName]: value } + } + + return { ...tab, newRows } + })) + } + + // 删除新增行 + const handleDeleteNewRow = (tabId: string, rowIndex: number) => { + setTabs(prev => prev.map(t => { + if (t.id !== tabId || !('tableName' in t)) return t + const tab = t as TableTab + + const newRows = [...(tab.newRows || [])] + newRows.splice(rowIndex, 1) + + return { ...tab, newRows } + })) + setStatus({ text: '已删除新增行', type: 'info' }) + } + // 刷新表数据 const handleRefreshTable = async (tabId: string) => { const tab = tabs.find(t => t.id === tabId) as TableTab | undefined @@ -523,7 +607,8 @@ export default function App() { total: totalResult?.total || tab.total, originalData: result?.data || [], pendingChanges: new Map(), - deletedRows: new Set() + deletedRows: new Set(), + newRows: [] // 刷新时清空新增行 } : t )) @@ -980,6 +1065,9 @@ export default function App() { onSaveTableChanges={handleSaveTableChanges} onDiscardTableChanges={handleDiscardTableChanges} onRefreshTable={handleRefreshTable} + onAddTableRow={handleAddTableRow} + onUpdateNewRow={handleUpdateNewRow} + onDeleteNewRow={handleDeleteNewRow} loadingTables={loadingTables} onNewConnectionWithType={(type) => { setEditingConnection(null) diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 4dd0d4e..8df1306 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1,4 +1,4 @@ -import { X, Play, Plus, Table2, ChevronLeft, ChevronRight, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, RotateCcw, Loader2 } from 'lucide-react' +import { X, Play, Plus, Minus, Table2, ChevronLeft, ChevronRight, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, RotateCcw, Loader2, Check, RefreshCw } from 'lucide-react' import { QueryTab, DB_INFO, DatabaseType, TableInfo, ColumnInfo, TableTab } from '../types' import { useState, useRef, useEffect, useCallback, memo, Suspense, lazy } from 'react' import { format } from 'sql-formatter' @@ -41,6 +41,9 @@ interface Props { onSaveTableChanges?: (tabId: string) => Promise onDiscardTableChanges?: (tabId: string) => void onRefreshTable?: (tabId: string) => void + onAddTableRow?: (tabId: string) => void // 新增行 + onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void // 更新新增行 + onDeleteNewRow?: (tabId: string, rowIndex: number) => void // 删除新增行 loadingTables?: Set // 正在加载的表标签ID } @@ -66,6 +69,9 @@ const MainContent = memo(function MainContent({ onSaveTableChanges, onDiscardTableChanges, onRefreshTable, + onAddTableRow, + onUpdateNewRow, + onDeleteNewRow, loadingTables, }: Props) { // 快捷键处理 @@ -171,6 +177,9 @@ const MainContent = memo(function MainContent({ onSave={() => onSaveTableChanges?.(currentTab.id)} onDiscard={() => onDiscardTableChanges?.(currentTab.id)} onRefresh={() => onRefreshTable?.(currentTab.id)} + onAddRow={() => onAddTableRow?.(currentTab.id)} + onUpdateNewRow={(rowIndex, colName, value) => onUpdateNewRow?.(currentTab.id, rowIndex, colName, value)} + onDeleteNewRow={(rowIndex) => onDeleteNewRow?.(currentTab.id, rowIndex)} /> ) : ( ; deletedRows?: Set } + tab: TableTab & { pendingChanges?: Map; deletedRows?: Set; newRows?: any[] } isLoading?: boolean onLoadPage: (page: number) => void onChangePageSize?: (pageSize: number) => void @@ -298,9 +310,12 @@ const TableViewer = memo(function TableViewer({ onSave?: () => void onDiscard?: () => void onRefresh?: () => void + onAddRow?: () => void + onUpdateNewRow?: (rowIndex: number, colName: string, value: any) => void + onDeleteNewRow?: (rowIndex: number) => void }) { const totalPages = Math.ceil(tab.total / tab.pageSize) - const hasChanges = (tab.pendingChanges?.size || 0) > 0 || (tab.deletedRows?.size || 0) > 0 + const hasChanges = (tab.pendingChanges?.size || 0) > 0 || (tab.deletedRows?.size || 0) > 0 || (tab.newRows?.length || 0) > 0 const primaryKeyCol = tab.columns.find(c => c.key === 'PRI')?.name || tab.columns[0]?.name // 计算修改过的单元格 @@ -312,10 +327,26 @@ const TableViewer = memo(function TableViewer({ }) }) - // 过滤掉已删除的行 - const visibleData = tab.data.filter((_, i) => !tab.deletedRows?.has(i)) + // 标记新增行的单元格 + const newRowCount = tab.newRows?.length || 0 + if (newRowCount > 0) { + const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length + for (let i = 0; i < newRowCount; i++) { + const rowIndex = existingDataCount + i + tab.columns.forEach(col => { + modifiedCells.add(`${rowIndex}-${col.name}`) + }) + } + } + + // 过滤掉已删除的行,并添加新增的行 + const visibleData = [...tab.data.filter((_, i) => !tab.deletedRows?.has(i)), ...(tab.newRows || [])] const originalIndexMap = tab.data.map((_, i) => i).filter(i => !tab.deletedRows?.has(i)) + // 计算修改统计 + const changesCount = (tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0) + (tab.newRows?.length || 0) + const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length + return (
{/* 表信息栏 - 紧凑布局 */} @@ -333,27 +364,13 @@ const TableViewer = memo(function TableViewer({ )}
- {/* 中间:修改提示和按钮 */} + {/* 中间:修改提示 */} {hasChanges && (
- {(tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0)}项 + {changesCount}项待保存 + {newRowCount > 0 && +{newRowCount}新增} - -
)} @@ -412,21 +429,146 @@ const TableViewer = memo(function TableViewer({ primaryKeyColumn={primaryKeyCol} modifiedCells={modifiedCells} onCellChange={(visibleRowIndex, colName, value) => { - const originalIndex = originalIndexMap[visibleRowIndex] - onCellChange?.(originalIndex, colName, value) + // 判断是修改现有行还是新增行 + if (visibleRowIndex >= existingDataCount) { + // 这是新增的行 + const newRowIndex = visibleRowIndex - existingDataCount + onUpdateNewRow?.(newRowIndex, colName, value) + } else { + const originalIndex = originalIndexMap[visibleRowIndex] + onCellChange?.(originalIndex, colName, value) + } }} onDeleteRow={(visibleRowIndex) => { - const originalIndex = originalIndexMap[visibleRowIndex] - onDeleteRow?.(originalIndex) + if (visibleRowIndex >= existingDataCount) { + // 删除新增的行 + const newRowIndex = visibleRowIndex - existingDataCount + onDeleteNewRow?.(newRowIndex) + } else { + const originalIndex = originalIndexMap[visibleRowIndex] + onDeleteRow?.(originalIndex) + } }} onDeleteRows={(visibleRowIndices) => { - const originalIndices = visibleRowIndices.map(i => originalIndexMap[i]) - onDeleteRows?.(originalIndices) + const originalIndices: number[] = [] + const newRowIndices: number[] = [] + + visibleRowIndices.forEach(i => { + if (i >= existingDataCount) { + newRowIndices.push(i - existingDataCount) + } else { + originalIndices.push(originalIndexMap[i]) + } + }) + + if (originalIndices.length > 0) { + onDeleteRows?.(originalIndices) + } + // 从后往前删除新增行,避免索引问题 + newRowIndices.sort((a, b) => b - a).forEach(i => { + onDeleteNewRow?.(i) + }) }} onRefresh={onRefresh} + onSave={onSave} + onAddRow={onAddRow} + onBatchUpdate={(updates) => { + updates.forEach(({ rowIndex, colName, value }) => { + // 判断是修改现有行还是新增行 + if (rowIndex >= existingDataCount) { + const newRowIndex = rowIndex - existingDataCount + onUpdateNewRow?.(newRowIndex, colName, value) + } else { + const originalIndex = originalIndexMap[rowIndex] + if (originalIndex !== undefined) { + onCellChange?.(originalIndex, colName, value) + } + } + }) + }} /> + + {/* 底部操作栏 - 参考 Navicat 风格 */} +
+ {/* 左侧:数据操作按钮 */} +
+ + + +
+ + + + +
+ + {/* 中间:状态信息 */} +
+ {hasChanges ? ( + + {tab.pendingChanges?.size || 0}修改 · {tab.deletedRows?.size || 0}删除 · {newRowCount}新增 + + ) : ( + 共 {visibleData.length} 行 + )} +
+ + {/* 右侧:SQL 提示 */} +
+ SELECT * FROM `{tab.tableName}` LIMIT {tab.pageSize} +
+
) }) diff --git a/src/components/VirtualDataTable.tsx b/src/components/VirtualDataTable.tsx index e2ec27d..5e839a3 100644 --- a/src/components/VirtualDataTable.tsx +++ b/src/components/VirtualDataTable.tsx @@ -19,6 +19,9 @@ interface VirtualDataTableProps { onDeleteRow?: (rowIndex: number) => void onDeleteRows?: (rowIndices: number[]) => void onRefresh?: () => void + onSave?: () => void // 保存回调 + onAddRow?: () => void // 新增行回调 + onBatchUpdate?: (updates: { rowIndex: number; colName: string; value: any }[]) => void // 批量更新回调 modifiedCells?: Set rowHeight?: number overscan?: number @@ -84,6 +87,9 @@ const VirtualDataTable = memo(function VirtualDataTable({ onDeleteRow, onDeleteRows, onRefresh, + onSave, + onAddRow, + onBatchUpdate, modifiedCells, rowHeight = 28, overscan = 20 @@ -349,22 +355,267 @@ const VirtualDataTable = memo(function VirtualDataTable({ jumpToMatch(prevIndex) }, [currentMatchIndex, matchesArray.length, jumpToMatch]) + // 移动到下一个单元格 + const moveToNextCell = useCallback((currentRow: number, currentCol: string, direction: 'next' | 'prev' = 'next') => { + const currentColIndex = getColIndex(currentCol) + let nextRow = currentRow + let nextColIndex = direction === 'next' ? currentColIndex + 1 : currentColIndex - 1 + + if (direction === 'next') { + if (nextColIndex >= sortedColumns.length) { + nextColIndex = 0 + nextRow = currentRow + 1 + if (nextRow >= data.length) { + nextRow = data.length - 1 + nextColIndex = sortedColumns.length - 1 + } + } + } else { + if (nextColIndex < 0) { + nextColIndex = sortedColumns.length - 1 + nextRow = currentRow - 1 + if (nextRow < 0) { + nextRow = 0 + nextColIndex = 0 + } + } + } + + const nextColName = sortedColumns[nextColIndex]?.name + if (nextColName) { + setActiveCell({ row: nextRow, col: nextColName }) + setSelectedCells(new Set([`${nextRow}-${nextColName}`])) + + // 自动滚动到可见区域 + const container = containerRef.current + if (container) { + const targetTop = nextRow * rowHeight + const visibleTop = container.scrollTop + const visibleBottom = visibleTop + containerHeight + + if (targetTop < visibleTop) { + container.scrollTop = targetTop + } else if (targetTop + rowHeight > visibleBottom) { + container.scrollTop = targetTop - containerHeight + rowHeight + } + } + } + + return { row: nextRow, col: sortedColumns[nextColIndex]?.name } + }, [getColIndex, sortedColumns, data.length, rowHeight, containerHeight]) + + // 复制选中的单元格数据(Navicat风格:制表符分隔列,换行符分隔行) + const copySelectedCells = useCallback(async () => { + if (selectedCells.size === 0) return + + // 解析选中的单元格,获取行列范围 + const cellsArray = [...selectedCells] + const rowIndices = new Set() + const colIndices = new Set() + + cellsArray.forEach(cellKey => { + const idx = cellKey.indexOf('-') + const rowIndex = parseInt(cellKey.substring(0, idx)) + const colName = cellKey.substring(idx + 1) + rowIndices.add(rowIndex) + colIndices.add(getColIndex(colName)) + }) + + const sortedRows = [...rowIndices].sort((a, b) => a - b) + const sortedColIndices = [...colIndices].sort((a, b) => a - b) + + // 构建复制的文本(制表符分隔列,换行符分隔行) + const lines: string[] = [] + for (const rowIndex of sortedRows) { + const row = data[rowIndex] + if (!row) continue + + const values: string[] = [] + for (const colIdx of sortedColIndices) { + const col = sortedColumns[colIdx] + if (!col) continue + const cellKey = `${rowIndex}-${col.name}` + // 只复制选中的单元格 + if (selectedCells.has(cellKey)) { + const value = row[col.name] + values.push(value === null || value === undefined ? '' : String(value)) + } else { + values.push('') + } + } + lines.push(values.join('\t')) + } + + const text = lines.join('\n') + await navigator.clipboard.writeText(text) + return { rows: sortedRows.length, cols: sortedColIndices.length } + }, [selectedCells, data, sortedColumns, getColIndex]) + + // 粘贴数据到选中的单元格(Navicat风格:自动扩展到多个单元格,超出则新增行) + const pasteToSelectedCells = useCallback(async () => { + if (!activeCell || !editable) return + + const text = await navigator.clipboard.readText() + if (!text) return + + // 解析粘贴的数据(制表符分隔列,换行符分隔行) + const lines = text.split('\n').map(line => line.split('\t')) + if (lines.length === 0) return + + const startRow = activeCell.row + const startColIdx = getColIndex(activeCell.col) + + const updates: { rowIndex: number; colName: string; value: any }[] = [] + let needNewRows = 0 + + for (let i = 0; i < lines.length; i++) { + const rowIndex = startRow + i + const lineData = lines[i] + + // 检查是否需要新增行 + if (rowIndex >= data.length) { + needNewRows++ + } + + for (let j = 0; j < lineData.length; j++) { + const colIdx = startColIdx + j + if (colIdx >= sortedColumns.length) continue + + const col = sortedColumns[colIdx] + const value = lineData[j] + + updates.push({ + rowIndex, + colName: col.name, + value: value === '' ? null : value + }) + } + } + + // 先新增需要的行 + for (let i = 0; i < needNewRows; i++) { + onAddRow?.() + } + + // 批量更新或逐个更新 + if (onBatchUpdate && updates.length > 0) { + // 稍微延迟以确保新增行已经创建 + setTimeout(() => { + onBatchUpdate(updates) + }, needNewRows > 0 ? 50 : 0) + } else { + // 逐个更新 + setTimeout(() => { + updates.forEach(({ rowIndex, colName, value }) => { + onCellChange?.(rowIndex, colName, value) + }) + }, needNewRows > 0 ? 50 : 0) + } + + // 更新选中区域 + const newSelectedCells = new Set() + for (let i = 0; i < lines.length; i++) { + for (let j = 0; j < lines[i].length; j++) { + const colIdx = startColIdx + j + if (colIdx >= sortedColumns.length) continue + newSelectedCells.add(`${startRow + i}-${sortedColumns[colIdx].name}`) + } + } + setSelectedCells(newSelectedCells) + + return { rows: lines.length, cols: lines[0]?.length || 0, newRows: needNewRows } + }, [activeCell, editable, data, sortedColumns, getColIndex, onAddRow, onBatchUpdate, onCellChange]) + // 快捷键 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+F 搜索 if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) { e.preventDefault() setShowSearch(true) setTimeout(() => searchInputRef.current?.focus(), 50) } + // Escape 关闭搜索 if (e.key === 'Escape' && showSearch) { setShowSearch(false) setSearchQuery('') } + // Ctrl+S 保存 + if ((e.ctrlKey || e.metaKey) && e.key === 's' && isFocused && editable) { + e.preventDefault() + onSave?.() + } + // Ctrl+C 复制 + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && isFocused && !editingCell && selectedCells.size > 0) { + e.preventDefault() + copySelectedCells() + } + // Ctrl+V 粘贴 + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && isFocused && !editingCell && editable && activeCell) { + e.preventDefault() + pasteToSelectedCells() + } + // F5 刷新 + if (e.key === 'F5' && isFocused) { + e.preventDefault() + onRefresh?.() + } + // Tab 键导航(当不在编辑状态时) + if (e.key === 'Tab' && isFocused && !editingCell && activeCell) { + e.preventDefault() + moveToNextCell(activeCell.row, activeCell.col, e.shiftKey ? 'prev' : 'next') + } + // 方向键导航 + if (isFocused && !editingCell && activeCell) { + if (e.key === 'ArrowRight') { + e.preventDefault() + moveToNextCell(activeCell.row, activeCell.col, 'next') + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + moveToNextCell(activeCell.row, activeCell.col, 'prev') + } else if (e.key === 'ArrowDown') { + e.preventDefault() + const newRow = Math.min(activeCell.row + 1, data.length - 1) + setActiveCell({ row: newRow, col: activeCell.col }) + setSelectedCells(new Set([`${newRow}-${activeCell.col}`])) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const newRow = Math.max(activeCell.row - 1, 0) + setActiveCell({ row: newRow, col: activeCell.col }) + setSelectedCells(new Set([`${newRow}-${activeCell.col}`])) + } + } + // Enter 进入编辑模式 + if (e.key === 'Enter' && isFocused && !editingCell && activeCell && editable) { + e.preventDefault() + const value = data[activeCell.row]?.[activeCell.col] + setEditingCell({ row: activeCell.row, col: activeCell.col }) + setEditValue(value === null ? '' : String(value)) + setTimeout(() => inputRef.current?.focus(), 0) + } + // Delete 或 Backspace 清空单元格 + if ((e.key === 'Delete' || e.key === 'Backspace') && isFocused && !editingCell && activeCell && editable) { + e.preventDefault() + onCellChange?.(activeCell.row, activeCell.col, null) + } + // 直接输入进入编辑模式(可打印字符) + if (isFocused && !editingCell && activeCell && editable && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault() + setEditingCell({ row: activeCell.row, col: activeCell.col }) + setEditValue(e.key) // 直接用输入的字符作为初始值 + setTimeout(() => { + const input = inputRef.current + if (input) { + input.focus() + // 将光标移到末尾 + input.setSelectionRange(input.value.length, input.value.length) + } + }, 0) + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isFocused, showSearch]) + }, [isFocused, showSearch, editable, onSave, onRefresh, editingCell, activeCell, moveToNextCell, data, onCellChange, selectedCells, copySelectedCells, pasteToSelectedCells]) // 全选 const handleSelectAll = useCallback(() => { @@ -603,7 +854,7 @@ const VirtualDataTable = memo(function VirtualDataTable({ maxWidth: colWidth, height: rowHeight, boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.3)' : 'none', - outline: isActiveCell ? '1px solid #007acc' : 'none', + outline: isActiveCell && !isEditing ? '1px solid #007acc' : 'none', outlineOffset: '-1px', zIndex: isPinned ? 10 : 1, }} @@ -641,12 +892,67 @@ const VirtualDataTable = memo(function VirtualDataTable({ }} onKeyDown={(e) => { if (e.key === 'Enter') { + // 保存当前单元格 if (editValue !== (value === null ? '' : String(value))) { onCellChange?.(actualRowIndex, col.name, editValue === '' ? null : editValue) } setEditingCell(null) + // 移动到下一行同列 + const newRow = Math.min(actualRowIndex + 1, data.length - 1) + setActiveCell({ row: newRow, col: col.name }) + setSelectedCells(new Set([`${newRow}-${col.name}`])) + } else if (e.key === 'Tab') { + e.preventDefault() + // 保存当前单元格 + if (editValue !== (value === null ? '' : String(value))) { + onCellChange?.(actualRowIndex, col.name, editValue === '' ? null : editValue) + } + // 计算下一个单元格位置 + const currentColIndex = getColIndex(col.name) + let nextRow = actualRowIndex + let nextColIndex = e.shiftKey ? currentColIndex - 1 : currentColIndex + 1 + + if (!e.shiftKey) { + if (nextColIndex >= sortedColumns.length) { + nextColIndex = 0 + nextRow = actualRowIndex + 1 + if (nextRow >= data.length) { + nextRow = data.length - 1 + nextColIndex = sortedColumns.length - 1 + } + } + } else { + if (nextColIndex < 0) { + nextColIndex = sortedColumns.length - 1 + nextRow = actualRowIndex - 1 + if (nextRow < 0) { + nextRow = 0 + nextColIndex = 0 + } + } + } + + const nextColName = sortedColumns[nextColIndex]?.name + if (nextColName) { + // 直接切换到下一个单元格的编辑状态 + const nextValue = data[nextRow]?.[nextColName] + setEditingCell({ row: nextRow, col: nextColName }) + setEditValue(nextValue === null ? '' : String(nextValue)) + setActiveCell({ row: nextRow, col: nextColName }) + setSelectedCells(new Set([`${nextRow}-${nextColName}`])) + setTimeout(() => inputRef.current?.focus(), 0) + } } else if (e.key === 'Escape') { setEditingCell(null) + } else if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + // 保存当前单元格 + if (editValue !== (value === null ? '' : String(value))) { + onCellChange?.(actualRowIndex, col.name, editValue === '' ? null : editValue) + } + setEditingCell(null) + // 触发保存 + onSave?.() } }} onClick={(e) => e.stopPropagation()} @@ -697,16 +1003,7 @@ const VirtualDataTable = memo(function VirtualDataTable({