更新-新增表数据功能
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) {
|
async function getTableInfo(conn, type, database, table) {
|
||||||
const columns = await getColumnsDetailed(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),
|
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) =>
|
||||||
|
|||||||
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(
|
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)
|
||||||
|
|||||||
@ -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) => {
|
||||||
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) => {
|
onDeleteRow={(visibleRowIndex) => {
|
||||||
const originalIndex = originalIndexMap[visibleRowIndex]
|
if (visibleRowIndex >= existingDataCount) {
|
||||||
onDeleteRow?.(originalIndex)
|
// 删除新增的行
|
||||||
|
const newRowIndex = visibleRowIndex - existingDataCount
|
||||||
|
onDeleteNewRow?.(newRowIndex)
|
||||||
|
} else {
|
||||||
|
const originalIndex = originalIndexMap[visibleRowIndex]
|
||||||
|
onDeleteRow?.(originalIndex)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDeleteRows={(visibleRowIndices) => {
|
onDeleteRows={(visibleRowIndices) => {
|
||||||
const originalIndices = visibleRowIndices.map(i => originalIndexMap[i])
|
const originalIndices: number[] = []
|
||||||
onDeleteRows?.(originalIndices)
|
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}
|
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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 }> = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user