更新-新增表数据功能

This commit is contained in:
Ethanfly 2025-12-31 14:05:51 +08:00
parent 44e5b98f2e
commit 2f907369a0
7 changed files with 668 additions and 48 deletions

View File

@ -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) { async function getTableInfo(conn, type, database, table) {
const columns = await getColumnsDetailed(conn, type, database, table) const columns = await getColumnsDetailed(conn, type, database, table)

View File

@ -25,6 +25,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('db:updateRow', id, database, table, primaryKey, updates), ipcRenderer.invoke('db:updateRow', id, database, table, primaryKey, updates),
deleteRow: (id, database, table, primaryKey) => deleteRow: (id, database, table, primaryKey) =>
ipcRenderer.invoke('db: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) => createDatabase: (id, dbName, charset, collation) =>

View File

@ -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( const result = await api.getTableData(
tab.connectionId, tab.database, tab.tableName, tab.page, tab.pageSize tab.connectionId, tab.database, tab.tableName, tab.page, tab.pageSize
@ -471,7 +505,8 @@ export default function App() {
total: totalResult?.total || tab.total, total: totalResult?.total || tab.total,
originalData: result?.data || [], originalData: result?.data || [],
pendingChanges: new Map(), pendingChanges: new Map(),
deletedRows: new Set() deletedRows: new Set(),
newRows: []
} }
: t : t
)) ))
@ -492,12 +527,61 @@ export default function App() {
...tab, ...tab,
data: tab.originalData || tab.data, data: tab.originalData || tab.data,
pendingChanges: new Map(), pendingChanges: new Map(),
deletedRows: new Set() deletedRows: new Set(),
newRows: []
} }
})) }))
setStatus({ text: '已放弃修改', type: 'warning' }) 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<string, any> = {}
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 handleRefreshTable = async (tabId: string) => {
const tab = tabs.find(t => t.id === tabId) as TableTab | undefined const tab = tabs.find(t => t.id === tabId) as TableTab | undefined
@ -523,7 +607,8 @@ export default function App() {
total: totalResult?.total || tab.total, total: totalResult?.total || tab.total,
originalData: result?.data || [], originalData: result?.data || [],
pendingChanges: new Map(), pendingChanges: new Map(),
deletedRows: new Set() deletedRows: new Set(),
newRows: [] // 刷新时清空新增行
} }
: t : t
)) ))
@ -980,6 +1065,9 @@ export default function App() {
onSaveTableChanges={handleSaveTableChanges} onSaveTableChanges={handleSaveTableChanges}
onDiscardTableChanges={handleDiscardTableChanges} onDiscardTableChanges={handleDiscardTableChanges}
onRefreshTable={handleRefreshTable} onRefreshTable={handleRefreshTable}
onAddTableRow={handleAddTableRow}
onUpdateNewRow={handleUpdateNewRow}
onDeleteNewRow={handleDeleteNewRow}
loadingTables={loadingTables} loadingTables={loadingTables}
onNewConnectionWithType={(type) => { onNewConnectionWithType={(type) => {
setEditingConnection(null) setEditingConnection(null)

View File

@ -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 { QueryTab, DB_INFO, DatabaseType, TableInfo, ColumnInfo, TableTab } from '../types'
import { useState, useRef, useEffect, useCallback, memo, Suspense, lazy } from 'react' import { useState, useRef, useEffect, useCallback, memo, Suspense, lazy } from 'react'
import { format } from 'sql-formatter' import { format } from 'sql-formatter'
@ -41,6 +41,9 @@ interface Props {
onSaveTableChanges?: (tabId: string) => Promise<void> onSaveTableChanges?: (tabId: string) => Promise<void>
onDiscardTableChanges?: (tabId: string) => void onDiscardTableChanges?: (tabId: string) => void
onRefreshTable?: (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<string> // 正在加载的表标签ID loadingTables?: Set<string> // 正在加载的表标签ID
} }
@ -66,6 +69,9 @@ const MainContent = memo(function MainContent({
onSaveTableChanges, onSaveTableChanges,
onDiscardTableChanges, onDiscardTableChanges,
onRefreshTable, onRefreshTable,
onAddTableRow,
onUpdateNewRow,
onDeleteNewRow,
loadingTables, loadingTables,
}: Props) { }: Props) {
// 快捷键处理 // 快捷键处理
@ -171,6 +177,9 @@ const MainContent = memo(function MainContent({
onSave={() => onSaveTableChanges?.(currentTab.id)} onSave={() => onSaveTableChanges?.(currentTab.id)}
onDiscard={() => onDiscardTableChanges?.(currentTab.id)} onDiscard={() => onDiscardTableChanges?.(currentTab.id)}
onRefresh={() => onRefreshTable?.(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)}
/> />
) : ( ) : (
<QueryEditor <QueryEditor
@ -286,9 +295,12 @@ const TableViewer = memo(function TableViewer({
onDeleteRows, onDeleteRows,
onSave, onSave,
onDiscard, onDiscard,
onRefresh onRefresh,
onAddRow,
onUpdateNewRow,
onDeleteNewRow,
}: { }: {
tab: TableTab & { pendingChanges?: Map<string, any>; deletedRows?: Set<number> } tab: TableTab & { pendingChanges?: Map<string, any>; deletedRows?: Set<number>; newRows?: any[] }
isLoading?: boolean isLoading?: boolean
onLoadPage: (page: number) => void onLoadPage: (page: number) => void
onChangePageSize?: (pageSize: number) => void onChangePageSize?: (pageSize: number) => void
@ -298,9 +310,12 @@ const TableViewer = memo(function TableViewer({
onSave?: () => void onSave?: () => void
onDiscard?: () => void onDiscard?: () => void
onRefresh?: () => 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 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 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 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 ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 表信息栏 - 紧凑布局 */} {/* 表信息栏 - 紧凑布局 */}
@ -333,27 +364,13 @@ const TableViewer = memo(function TableViewer({
)} )}
</div> </div>
{/* 中间:修改提示和按钮 */} {/* 中间:修改提示 */}
{hasChanges && ( {hasChanges && (
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-accent-orange font-medium px-1.5 py-0.5 bg-accent-orange/10 rounded"> <span className="text-xs text-accent-orange font-medium px-1.5 py-0.5 bg-accent-orange/10 rounded">
{(tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0)} {changesCount}
{newRowCount > 0 && <span className="ml-1 text-accent-green">+{newRowCount}</span>}
</span> </span>
<button
onClick={onSave}
className="h-6 px-2 bg-accent-green hover:bg-accent-green-hover flex items-center gap-1 text-xs font-medium transition-all"
title="保存修改 (Ctrl+S)"
>
<Save size={11} />
</button>
<button
onClick={onDiscard}
className="h-6 px-2 bg-metro-surface hover:bg-metro-hover flex items-center gap-1 text-xs transition-all border border-metro-border"
>
<RotateCcw size={11} />
</button>
</div> </div>
)} )}
@ -412,21 +429,146 @@ const TableViewer = memo(function TableViewer({
primaryKeyColumn={primaryKeyCol} primaryKeyColumn={primaryKeyCol}
modifiedCells={modifiedCells} modifiedCells={modifiedCells}
onCellChange={(visibleRowIndex, colName, value) => { onCellChange={(visibleRowIndex, colName, value) => {
// 判断是修改现有行还是新增行
if (visibleRowIndex >= existingDataCount) {
// 这是新增的行
const newRowIndex = visibleRowIndex - existingDataCount
onUpdateNewRow?.(newRowIndex, colName, value)
} else {
const originalIndex = originalIndexMap[visibleRowIndex] const originalIndex = originalIndexMap[visibleRowIndex]
onCellChange?.(originalIndex, colName, value) onCellChange?.(originalIndex, colName, value)
}
}} }}
onDeleteRow={(visibleRowIndex) => { onDeleteRow={(visibleRowIndex) => {
if (visibleRowIndex >= existingDataCount) {
// 删除新增的行
const newRowIndex = visibleRowIndex - existingDataCount
onDeleteNewRow?.(newRowIndex)
} else {
const originalIndex = originalIndexMap[visibleRowIndex] const originalIndex = originalIndexMap[visibleRowIndex]
onDeleteRow?.(originalIndex) onDeleteRow?.(originalIndex)
}
}} }}
onDeleteRows={(visibleRowIndices) => { onDeleteRows={(visibleRowIndices) => {
const originalIndices = visibleRowIndices.map(i => originalIndexMap[i]) 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) onDeleteRows?.(originalIndices)
}
// 从后往前删除新增行,避免索引问题
newRowIndices.sort((a, b) => b - a).forEach(i => {
onDeleteNewRow?.(i)
})
}} }}
onRefresh={onRefresh} 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)
}
}
})
}}
/> />
</div> </div>
</div> </div>
{/* 底部操作栏 - 参考 Navicat 风格 */}
<div className="bg-metro-bg border-t border-metro-border/50 flex items-center px-2 gap-1" style={{ flexShrink: 0, height: 32 }}>
{/* 左侧:数据操作按钮 */}
<div className="flex items-center gap-0.5">
<button
onClick={onAddRow}
disabled={isLoading}
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
title="添加行"
>
<Plus size={16} />
</button>
<button
onClick={() => {
// 如果有选中行删除选中的行此功能已在VirtualDataTable中的右键菜单中实现
// 这里作为快捷按钮,可以删除最后一个新增行
if (newRowCount > 0) {
onDeleteNewRow?.(newRowCount - 1)
}
}}
disabled={isLoading || newRowCount === 0}
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
title="删除行"
>
<Minus size={16} />
</button>
<div className="w-px h-4 bg-metro-border mx-1" />
<button
onClick={onSave}
disabled={isLoading || !hasChanges}
className={`w-7 h-7 flex items-center justify-center transition-colors rounded ${
hasChanges
? 'hover:bg-accent-green/20 text-accent-green'
: 'text-text-tertiary opacity-40 cursor-not-allowed'
}`}
title="保存修改 (Ctrl+S)"
>
<Check size={16} />
</button>
<button
onClick={onDiscard}
disabled={isLoading || !hasChanges}
className={`w-7 h-7 flex items-center justify-center transition-colors rounded ${
hasChanges
? 'hover:bg-accent-red/20 text-accent-red'
: 'text-text-tertiary opacity-40 cursor-not-allowed'
}`}
title="放弃修改"
>
<X size={16} />
</button>
<button
onClick={onRefresh}
disabled={isLoading}
className="w-7 h-7 flex items-center justify-center hover:bg-metro-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded text-text-secondary hover:text-white"
title="刷新数据"
>
<RefreshCw size={14} />
</button>
</div>
{/* 中间:状态信息 */}
<div className="flex-1 flex items-center justify-center text-xs text-text-tertiary">
{hasChanges ? (
<span className="text-accent-orange">
{tab.pendingChanges?.size || 0} · {tab.deletedRows?.size || 0} · {newRowCount}
</span>
) : (
<span> {visibleData.length} </span>
)}
</div>
{/* 右侧SQL 提示 */}
<div className="text-xs text-text-disabled">
SELECT * FROM `{tab.tableName}` LIMIT {tab.pageSize}
</div>
</div>
</div> </div>
) )
}) })

View File

@ -19,6 +19,9 @@ interface VirtualDataTableProps {
onDeleteRow?: (rowIndex: number) => void onDeleteRow?: (rowIndex: number) => void
onDeleteRows?: (rowIndices: number[]) => void onDeleteRows?: (rowIndices: number[]) => void
onRefresh?: () => void onRefresh?: () => void
onSave?: () => void // 保存回调
onAddRow?: () => void // 新增行回调
onBatchUpdate?: (updates: { rowIndex: number; colName: string; value: any }[]) => void // 批量更新回调
modifiedCells?: Set<string> modifiedCells?: Set<string>
rowHeight?: number rowHeight?: number
overscan?: number overscan?: number
@ -84,6 +87,9 @@ const VirtualDataTable = memo(function VirtualDataTable({
onDeleteRow, onDeleteRow,
onDeleteRows, onDeleteRows,
onRefresh, onRefresh,
onSave,
onAddRow,
onBatchUpdate,
modifiedCells, modifiedCells,
rowHeight = 28, rowHeight = 28,
overscan = 20 overscan = 20
@ -349,22 +355,267 @@ const VirtualDataTable = memo(function VirtualDataTable({
jumpToMatch(prevIndex) jumpToMatch(prevIndex)
}, [currentMatchIndex, matchesArray.length, jumpToMatch]) }, [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<number>()
const colIndices = new Set<number>()
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<string>()
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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+F 搜索
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) { if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) {
e.preventDefault() e.preventDefault()
setShowSearch(true) setShowSearch(true)
setTimeout(() => searchInputRef.current?.focus(), 50) setTimeout(() => searchInputRef.current?.focus(), 50)
} }
// Escape 关闭搜索
if (e.key === 'Escape' && showSearch) { if (e.key === 'Escape' && showSearch) {
setShowSearch(false) setShowSearch(false)
setSearchQuery('') 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) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('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(() => { const handleSelectAll = useCallback(() => {
@ -603,7 +854,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
maxWidth: colWidth, maxWidth: colWidth,
height: rowHeight, height: rowHeight,
boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.3)' : 'none', 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', outlineOffset: '-1px',
zIndex: isPinned ? 10 : 1, zIndex: isPinned ? 10 : 1,
}} }}
@ -641,12 +892,67 @@ const VirtualDataTable = memo(function VirtualDataTable({
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
// 保存当前单元格
if (editValue !== (value === null ? '' : String(value))) { if (editValue !== (value === null ? '' : String(value))) {
onCellChange?.(actualRowIndex, col.name, editValue === '' ? null : editValue) onCellChange?.(actualRowIndex, col.name, editValue === '' ? null : editValue)
} }
setEditingCell(null) 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') { } else if (e.key === 'Escape') {
setEditingCell(null) 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()} onClick={(e) => e.stopPropagation()}
@ -697,16 +1003,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
<button <button
className="navi-context-item" className="navi-context-item"
onClick={async () => { onClick={async () => {
const cellKey = [...selectedCells][0] await copySelectedCells()
if (cellKey) {
const idx = cellKey.indexOf('-')
const rowIndex = parseInt(cellKey.substring(0, idx))
const colName = cellKey.substring(idx + 1)
const value = data[rowIndex]?.[colName]
if (value !== null && value !== undefined) {
await navigator.clipboard.writeText(String(value))
}
}
setContextMenu(null) setContextMenu(null)
}} }}
> >
@ -720,10 +1017,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
<button <button
className="navi-context-item" className="navi-context-item"
onClick={async () => { onClick={async () => {
if (contextMenu.col && selectedCells.size === 1) { await pasteToSelectedCells()
const text = await navigator.clipboard.readText()
onCellChange?.(contextMenu.row, contextMenu.col, text)
}
setContextMenu(null) setContextMenu(null)
}} }}
> >

View File

@ -22,6 +22,7 @@ declare global {
getTableData: (id: string, database: string, table: string, page: number, pageSize: number) => Promise<TableDataResult> getTableData: (id: string, database: string, table: string, page: number, pageSize: number) => Promise<TableDataResult>
updateRow: (id: string, database: string, table: string, primaryKey: { column: string; value: any }, updates: Record<string, any>) => Promise<{ success: boolean; message: string }> updateRow: (id: string, database: string, table: string, primaryKey: { column: string; value: any }, updates: Record<string, any>) => Promise<{ success: boolean; message: string }>
deleteRow: (id: string, database: string, table: string, primaryKey: { column: string; value: any }) => Promise<{ success: boolean; message: string }> deleteRow: (id: string, database: string, table: string, primaryKey: { column: string; value: any }) => Promise<{ success: boolean; message: string }>
insertRow: (id: string, database: string, table: string, columns: string[], values: any[]) => Promise<{ success: boolean; message: string; insertId?: number }>
// 数据库管理 // 数据库管理
createDatabase: (id: string, dbName: string, charset?: string, collation?: string) => Promise<{ success: boolean; message: string }> createDatabase: (id: string, dbName: string, charset?: string, collation?: string) => Promise<{ success: boolean; message: string }>
@ -332,6 +333,17 @@ const api = {
} }
}, },
insertRow: async (id: string, database: string, tableName: string, columns: string[], values: any[]): Promise<{ success?: boolean; error?: string; insertId?: number }> => {
const electronAPI = getElectronAPI()
if (!electronAPI) return { success: false, error: 'Electron API 不可用' }
try {
const result = await electronAPI.insertRow(id, database, tableName, columns, values)
return { success: result.success, error: result.success ? undefined : result.message, insertId: result.insertId }
} catch (e: any) {
return { success: false, error: e.toString() }
}
},
// 数据库管理 // 数据库管理
createDatabase: async (id: string, dbName: string, charset = 'utf8mb4', collation = 'utf8mb4_general_ci'): Promise<{ success: boolean; message: string }> => { createDatabase: async (id: string, dbName: string, charset = 'utf8mb4', collation = 'utf8mb4_general_ci'): Promise<{ success: boolean; message: string }> => {
const electronAPI = getElectronAPI() const electronAPI = getElectronAPI()

View File

@ -57,6 +57,7 @@ export interface TableTab {
pendingChanges?: Map<string, Record<string, any>> // rowIndex -> { colName: newValue } pendingChanges?: Map<string, Record<string, any>> // rowIndex -> { colName: newValue }
deletedRows?: Set<number> // 待删除的行索引 deletedRows?: Set<number> // 待删除的行索引
originalData?: any[] // 原始数据用于回滚 originalData?: any[] // 原始数据用于回滚
newRows?: any[] // 新增的行数据(尚未保存到数据库)
} }
export const DB_INFO: Record<DatabaseType, { name: string; icon: string; color: string; port: number; supported: boolean }> = { export const DB_INFO: Record<DatabaseType, { name: string; icon: string; color: string; port: number; supported: boolean }> = {