更新-新增表数据功能
This commit is contained in:
parent
44e5b98f2e
commit
2f907369a0
@ -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)
|
||||
|
||||
@ -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) =>
|
||||
|
||||
94
src/App.tsx
94
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<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 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)
|
||||
|
||||
@ -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<void>
|
||||
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<string> // 正在加载的表标签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)}
|
||||
/>
|
||||
) : (
|
||||
<QueryEditor
|
||||
@ -286,9 +295,12 @@ const TableViewer = memo(function TableViewer({
|
||||
onDeleteRows,
|
||||
onSave,
|
||||
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
|
||||
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 (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 表信息栏 - 紧凑布局 */}
|
||||
@ -333,27 +364,13 @@ const TableViewer = memo(function TableViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:修改提示和按钮 */}
|
||||
{/* 中间:修改提示 */}
|
||||
{hasChanges && (
|
||||
<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">
|
||||
{(tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0)}项
|
||||
{changesCount}项待保存
|
||||
{newRowCount > 0 && <span className="ml-1 text-accent-green">+{newRowCount}新增</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>
|
||||
)}
|
||||
|
||||
@ -412,21 +429,146 @@ const TableViewer = memo(function TableViewer({
|
||||
primaryKeyColumn={primaryKeyCol}
|
||||
modifiedCells={modifiedCells}
|
||||
onCellChange={(visibleRowIndex, colName, value) => {
|
||||
// 判断是修改现有行还是新增行
|
||||
if (visibleRowIndex >= existingDataCount) {
|
||||
// 这是新增的行
|
||||
const newRowIndex = visibleRowIndex - existingDataCount
|
||||
onUpdateNewRow?.(newRowIndex, colName, value)
|
||||
} else {
|
||||
const originalIndex = originalIndexMap[visibleRowIndex]
|
||||
onCellChange?.(originalIndex, colName, value)
|
||||
}
|
||||
}}
|
||||
onDeleteRow={(visibleRowIndex) => {
|
||||
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])
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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<string>
|
||||
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<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(() => {
|
||||
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({
|
||||
<button
|
||||
className="navi-context-item"
|
||||
onClick={async () => {
|
||||
const cellKey = [...selectedCells][0]
|
||||
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))
|
||||
}
|
||||
}
|
||||
await copySelectedCells()
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
@ -720,10 +1017,7 @@ const VirtualDataTable = memo(function VirtualDataTable({
|
||||
<button
|
||||
className="navi-context-item"
|
||||
onClick={async () => {
|
||||
if (contextMenu.col && selectedCells.size === 1) {
|
||||
const text = await navigator.clipboard.readText()
|
||||
onCellChange?.(contextMenu.row, contextMenu.col, text)
|
||||
}
|
||||
await pasteToSelectedCells()
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
|
||||
@ -22,6 +22,7 @@ declare global {
|
||||
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 }>
|
||||
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 }>
|
||||
@ -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 }> => {
|
||||
const electronAPI = getElectronAPI()
|
||||
|
||||
@ -57,6 +57,7 @@ export interface TableTab {
|
||||
pendingChanges?: Map<string, Record<string, any>> // rowIndex -> { colName: newValue }
|
||||
deletedRows?: Set<number> // 待删除的行索引
|
||||
originalData?: any[] // 原始数据用于回滚
|
||||
newRows?: any[] // 新增的行数据(尚未保存到数据库)
|
||||
}
|
||||
|
||||
export const DB_INFO: Record<DatabaseType, { name: string; icon: string; color: string; port: number; supported: boolean }> = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user