From 38156a8f6dc9aa2cece8f3602fdd5638694994ff Mon Sep 17 00:00:00 2001 From: Ethanfly Date: Mon, 29 Dec 2025 18:44:31 +0800 Subject: [PATCH] Add update and delete row functionality to database operations --- dist/main/main.js | 52 +++++++ dist/preload/preload.js | 5 +- electron/main.ts | 61 ++++++++ electron/preload.ts | 6 + src/App.tsx | 34 ++++- src/components/MainContent.tsx | 253 +++++++++++++++++++++++++++++---- 6 files changed, 381 insertions(+), 30 deletions(-) diff --git a/dist/main/main.js b/dist/main/main.js index 4ad37e5..d6bc88e 100644 --- a/dist/main/main.js +++ b/dist/main/main.js @@ -530,3 +530,55 @@ electron.ipcMain.handle("db:exportTable", async (_, id, database, tableName, for return { error: err.message }; } }); +electron.ipcMain.handle("db:updateRow", async (_, id, database, tableName, primaryKey, updates) => { + const db = dbConnections.get(id); + if (!db) return { error: "未连接数据库" }; + try { + if (db.type === "mysql" || db.type === "mariadb") { + await db.conn.query(`USE \`${database}\``); + const setClauses = Object.entries(updates).map(([col, val]) => { + if (val === null) return `\`${col}\` = NULL`; + return `\`${col}\` = ?`; + }); + const values = Object.values(updates).filter((v) => v !== null); + values.push(primaryKey.value); + await db.conn.query( + `UPDATE \`${tableName}\` SET ${setClauses.join(", ")} WHERE \`${primaryKey.column}\` = ?`, + values + ); + return { success: true }; + } else if (db.type === "postgres") { + const setClauses = Object.entries(updates).map(([col, val], i) => { + if (val === null) return `"${col}" = NULL`; + return `"${col}" = $${i + 1}`; + }); + const values = Object.values(updates).filter((v) => v !== null); + values.push(primaryKey.value); + await db.conn.query( + `UPDATE "${tableName}" SET ${setClauses.join(", ")} WHERE "${primaryKey.column}" = $${values.length}`, + values + ); + return { success: true }; + } + return { error: "不支持的数据库类型" }; + } catch (err) { + return { error: err.message }; + } +}); +electron.ipcMain.handle("db:deleteRow", async (_, id, database, tableName, primaryKey) => { + const db = dbConnections.get(id); + if (!db) return { error: "未连接数据库" }; + try { + if (db.type === "mysql" || db.type === "mariadb") { + await db.conn.query(`USE \`${database}\``); + await db.conn.query(`DELETE FROM \`${tableName}\` WHERE \`${primaryKey.column}\` = ?`, [primaryKey.value]); + return { success: true }; + } else if (db.type === "postgres") { + await db.conn.query(`DELETE FROM "${tableName}" WHERE "${primaryKey.column}" = $1`, [primaryKey.value]); + return { success: true }; + } + return { error: "不支持的数据库类型" }; + } catch (err) { + return { error: err.message }; + } +}); diff --git a/dist/preload/preload.js b/dist/preload/preload.js index f5ae145..59bb6e7 100644 --- a/dist/preload/preload.js +++ b/dist/preload/preload.js @@ -22,5 +22,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { saveFile: (filePath, content) => electron.ipcRenderer.invoke("file:save", filePath, content), // 数据库备份与导出 backupDatabase: (id, database) => electron.ipcRenderer.invoke("db:backup", id, database), - exportTable: (id, database, tableName, format) => electron.ipcRenderer.invoke("db:exportTable", id, database, tableName, format) + exportTable: (id, database, tableName, format) => electron.ipcRenderer.invoke("db:exportTable", id, database, tableName, format), + // 数据编辑 + updateRow: (id, database, tableName, primaryKey, updates) => electron.ipcRenderer.invoke("db:updateRow", id, database, tableName, primaryKey, updates), + deleteRow: (id, database, tableName, primaryKey) => electron.ipcRenderer.invoke("db:deleteRow", id, database, tableName, primaryKey) }); diff --git a/electron/main.ts b/electron/main.ts index 13ff771..89712bc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -607,3 +607,64 @@ ipcMain.handle('db:exportTable', async (_, id: string, database: string, tableNa return { error: err.message } } }) + +// 更新表数据 +ipcMain.handle('db:updateRow', async (_, id: string, database: string, tableName: string, primaryKey: { column: string; value: any }, updates: Record) => { + const db = dbConnections.get(id) + if (!db) return { error: '未连接数据库' } + + try { + if (db.type === 'mysql' || db.type === 'mariadb') { + await db.conn.query(`USE \`${database}\``) + + const setClauses = Object.entries(updates).map(([col, val]) => { + if (val === null) return `\`${col}\` = NULL` + return `\`${col}\` = ?` + }) + const values = Object.values(updates).filter(v => v !== null) + values.push(primaryKey.value) + + await db.conn.query( + `UPDATE \`${tableName}\` SET ${setClauses.join(', ')} WHERE \`${primaryKey.column}\` = ?`, + values + ) + return { success: true } + } else if (db.type === 'postgres') { + const setClauses = Object.entries(updates).map(([col, val], i) => { + if (val === null) return `"${col}" = NULL` + return `"${col}" = $${i + 1}` + }) + const values = Object.values(updates).filter(v => v !== null) + values.push(primaryKey.value) + + await db.conn.query( + `UPDATE "${tableName}" SET ${setClauses.join(', ')} WHERE "${primaryKey.column}" = $${values.length}`, + values + ) + return { success: true } + } + return { error: '不支持的数据库类型' } + } catch (err: any) { + return { error: err.message } + } +}) + +// 删除表数据行 +ipcMain.handle('db:deleteRow', async (_, id: string, database: string, tableName: string, primaryKey: { column: string; value: any }) => { + const db = dbConnections.get(id) + if (!db) return { error: '未连接数据库' } + + try { + if (db.type === 'mysql' || db.type === 'mariadb') { + await db.conn.query(`USE \`${database}\``) + await db.conn.query(`DELETE FROM \`${tableName}\` WHERE \`${primaryKey.column}\` = ?`, [primaryKey.value]) + return { success: true } + } else if (db.type === 'postgres') { + await db.conn.query(`DELETE FROM "${tableName}" WHERE "${primaryKey.column}" = $1`, [primaryKey.value]) + return { success: true } + } + return { error: '不支持的数据库类型' } + } catch (err: any) { + return { error: err.message } + } +}) diff --git a/electron/preload.ts b/electron/preload.ts index 8716742..46b9d25 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -30,5 +30,11 @@ contextBridge.exposeInMainWorld('electronAPI', { backupDatabase: (id: string, database: string) => ipcRenderer.invoke('db:backup', id, database), exportTable: (id: string, database: string, tableName: string, format: 'excel' | 'sql' | 'csv') => ipcRenderer.invoke('db:exportTable', id, database, tableName, format), + + // 数据编辑 + updateRow: (id: string, database: string, tableName: string, primaryKey: { column: string; value: any }, updates: Record) => + ipcRenderer.invoke('db:updateRow', id, database, tableName, primaryKey, updates), + deleteRow: (id: string, database: string, tableName: string, primaryKey: { column: string; value: any }) => + ipcRenderer.invoke('db:deleteRow', id, database, tableName, primaryKey), }) diff --git a/src/App.tsx b/src/App.tsx index 540dbd6..587d2f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,11 @@ declare global { backupDatabase: (id: string, database: string) => Promise<{ success?: boolean; path?: string; error?: string; cancelled?: boolean }> exportTable: (id: string, database: string, tableName: string, format: 'excel' | 'sql' | 'csv') => Promise<{ success?: boolean; path?: string; error?: string; cancelled?: boolean }> + // 数据编辑 + updateRow: (id: string, database: string, tableName: string, primaryKey: { column: string; value: any }, updates: Record) => + Promise<{ success?: boolean; error?: string }> + deleteRow: (id: string, database: string, tableName: string, primaryKey: { column: string; value: any }) => + Promise<{ success?: boolean; error?: string }> } } } @@ -107,6 +112,33 @@ export default function App() { } } + // 切换选中的连接,如果已连接则加载数据库列表 + const handleSelectConnection = async (id: string) => { + setActiveConnection(id) + + // 如果该连接已经连接,加载其数据库列表 + if (connectedIds.has(id)) { + setSelectedDatabase(null) + setTables([]) + setAllColumns(new Map()) + setStatus({ text: '正在加载数据库列表...', type: 'info' }) + + try { + const dbs = await window.electronAPI?.getDatabases(id) + setDatabases(dbs || []) + setStatus({ text: `${dbs?.length || 0} 个数据库`, type: 'success' }) + } catch (err: any) { + setStatus({ text: err.message, type: 'error' }) + } + } else { + // 未连接的连接,清空数据库列表 + setDatabases([]) + setSelectedDatabase(null) + setTables([]) + setAllColumns(new Map()) + } + } + const handleSelectDatabase = async (db: string) => { if (!activeConnection) return @@ -278,7 +310,7 @@ export default function App() { selectedDatabase={selectedDatabase} loadingTables={loadingTables} onNewConnection={() => { setEditingConnection(null); setDefaultDbType(undefined); setShowModal(true) }} - onSelectConnection={setActiveConnection} + onSelectConnection={handleSelectConnection} onConnect={handleConnect} onDisconnect={handleDisconnect} onEditConnection={(c) => { setEditingConnection(c); setShowModal(true) }} diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 6d0d2b1..836bcd4 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1,4 +1,4 @@ -import { X, Play, Plus, Table2, ChevronLeft, ChevronRight, Key, Info, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, Pin, PinOff } from 'lucide-react' +import { X, Play, Plus, Table2, ChevronLeft, ChevronRight, Key, Info, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, Pin, PinOff, Trash2, RotateCcw } from 'lucide-react' import { QueryTab, DB_INFO, DatabaseType, TableInfo, ColumnInfo, TableTab } from '../types' import { useState, useRef, useEffect, useCallback } from 'react' import SqlEditor from './SqlEditor' @@ -17,13 +17,22 @@ interface DataTableColumn { interface DataTableProps { columns: DataTableColumn[] data: any[] - showColumnInfo?: boolean // 是否显示列的类型和备注信息 + showColumnInfo?: boolean + editable?: boolean + primaryKeyColumn?: string + onCellChange?: (rowIndex: number, colName: string, value: any) => void + onDeleteRow?: (rowIndex: number) => void + modifiedCells?: Set // "rowIndex-colName" 格式 } -function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { +function DataTable({ columns, data, showColumnInfo = false, editable = false, primaryKeyColumn, onCellChange, onDeleteRow, modifiedCells }: DataTableProps) { const [pinnedColumns, setPinnedColumns] = useState>(new Set()) const tableContainerRef = useRef(null) const [scrollLeft, setScrollLeft] = useState(0) + const [editingCell, setEditingCell] = useState<{ row: number; col: string } | null>(null) + const [editValue, setEditValue] = useState('') + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: number; col: string } | null>(null) + const inputRef = useRef(null) // 切换列固定状态 const togglePin = useCallback((colName: string) => { @@ -114,8 +123,9 @@ function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { key={col.name} data-pinned={isPinned} data-col={col.name} - className={`px-4 py-2 text-left font-medium border-b border-r border-metro-border whitespace-nowrap select-none - ${isPinned ? 'z-30' : ''}`} + onClick={() => togglePin(col.name)} + className={`px-4 py-2 text-left font-medium border-b border-r border-metro-border whitespace-nowrap select-none cursor-pointer + ${isPinned ? 'z-30' : 'hover:bg-white/5'}`} style={{ background: isPinned ? '#1a3a4a' : '#2d2d2d', position: isPinned ? 'sticky' : 'relative', @@ -123,17 +133,13 @@ function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { minWidth: '120px', boxShadow: isPinned && scrollLeft > 0 ? '2px 0 4px rgba(0,0,0,0.3)' : 'none', }} - title={col.comment ? `${col.name}\n类型: ${col.type}\n备注: ${col.comment}` : col.type ? `${col.name}\n类型: ${col.type}` : col.name} + title={isPinned ? `点击取消固定 ${col.name}` : `点击固定 ${col.name}`} >
- {/* 固定/取消固定按钮 */} - + {showColumnInfo && col.key === 'PRI' && } {col.name} @@ -141,7 +147,7 @@ function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { ({col.type}) )} {showColumnInfo && col.comment && ( - + )} @@ -157,27 +163,79 @@ function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { - {data.map((row, i) => ( - - {sortedColumns.map((col, j) => { + {data.map((row, rowIndex) => ( + { + if (editable) { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY, row: rowIndex, col: '' }) + } + }} + > + {sortedColumns.map((col) => { const isPinned = pinnedColumns.has(col.name) const pinnedIndex = isPinned ? [...pinnedColumns].indexOf(col.name) : -1 const value = row[col.name] + const isEditing = editingCell?.row === rowIndex && editingCell?.col === col.name + const isModified = modifiedCells?.has(`${rowIndex}-${col.name}`) return ( 0 ? '2px 0 4px rgba(0,0,0,0.2)' : 'none', }} + onClick={() => { + if (editable && !isEditing) { + setEditingCell({ row: rowIndex, col: col.name }) + setEditValue(value === null ? '' : String(value)) + setTimeout(() => inputRef.current?.focus(), 0) + } + }} + onContextMenu={(e) => { + if (editable) { + e.preventDefault() + e.stopPropagation() + setContextMenu({ x: e.clientX, y: e.clientY, row: rowIndex, col: col.name }) + } + }} > - {value === null ? ( + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={() => { + if (editValue !== (value === null ? '' : String(value))) { + onCellChange?.(rowIndex, col.name, editValue === '' ? null : editValue) + } + setEditingCell(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (editValue !== (value === null ? '' : String(value))) { + onCellChange?.(rowIndex, col.name, editValue === '' ? null : editValue) + } + setEditingCell(null) + } else if (e.key === 'Escape') { + setEditingCell(null) + } + }} + className="w-full bg-accent-blue/20 border border-accent-blue px-1 py-0.5 text-white outline-none" + style={{ minWidth: '80px' }} + /> + ) : value === null ? ( NULL ) : typeof value === 'object' ? ( {JSON.stringify(value)} @@ -197,6 +255,51 @@ function DataTable({ columns, data, showColumnInfo = false }: DataTableProps) { 暂无数据
)} + + {/* 右键菜单 */} + {contextMenu && editable && ( + <> +
setContextMenu(null)} /> +
+ {contextMenu.col && ( + <> + + +
+ + )} + +
+ + )}
) } @@ -217,6 +320,10 @@ interface Props { onUpdateTabTitle: (id: string, title: string) => void onLoadTablePage: (id: string, page: number) => void onNewConnectionWithType?: (type: DatabaseType) => void + onUpdateTableCell?: (tabId: string, rowIndex: number, colName: string, value: any) => void + onDeleteTableRow?: (tabId: string, rowIndex: number) => void + onSaveTableChanges?: (tabId: string) => Promise + onDiscardTableChanges?: (tabId: string) => void } export default function MainContent({ @@ -233,7 +340,34 @@ export default function MainContent({ onUpdateTabTitle, onLoadTablePage, onNewConnectionWithType, + onUpdateTableCell, + onDeleteTableRow, + onSaveTableChanges, + onDiscardTableChanges, }: Props) { + // 快捷键处理 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+W 关闭当前标签页 + if (e.ctrlKey && e.key === 'w') { + e.preventDefault() + if (activeTab !== 'welcome') { + onCloseTab(activeTab) + } + } + // Ctrl+S 保存(针对表数据编辑) + if (e.ctrlKey && e.key === 's') { + const tab = tabs.find(t => t.id === activeTab) + if (tab && 'tableName' in tab && (tab as any).pendingChanges?.size > 0) { + e.preventDefault() + onSaveTableChanges?.(activeTab) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [activeTab, tabs, onCloseTab, onSaveTableChanges]) const currentTab = tabs.find(t => t.id === activeTab) const getTabTitle = (tab: Tab) => { @@ -299,7 +433,14 @@ export default function MainContent({ ) : currentTab ? ( 'tableName' in currentTab ? ( - onLoadTablePage(currentTab.id, page)} /> + onLoadTablePage(currentTab.id, page)} + onCellChange={(rowIndex, colName, value) => onUpdateTableCell?.(currentTab.id, rowIndex, colName, value)} + onDeleteRow={(rowIndex) => onDeleteTableRow?.(currentTab.id, rowIndex)} + onSave={() => onSaveTableChanges?.(currentTab.id)} + onDiscard={() => onDiscardTableChanges?.(currentTab.id)} + /> ) : ( ; deletedRows?: Set } onLoadPage: (page: number) => void + onCellChange?: (rowIndex: number, colName: string, value: any) => void + onDeleteRow?: (rowIndex: number) => void + onSave?: () => void + onDiscard?: () => void }) { const totalPages = Math.ceil(tab.total / tab.pageSize) + const hasChanges = (tab.pendingChanges?.size || 0) > 0 || (tab.deletedRows?.size || 0) > 0 + + // 找到主键列 + const primaryKeyCol = tab.columns.find(c => c.key === 'PRI')?.name || tab.columns[0]?.name + + // 计算修改过的单元格 + const modifiedCells = new Set() + tab.pendingChanges?.forEach((changes, rowKey) => { + const rowIndex = parseInt(rowKey) + Object.keys(changes).forEach(colName => { + modifiedCells.add(`${rowIndex}-${colName}`) + }) + }) + + // 过滤掉已删除的行 + const visibleData = tab.data.filter((_, i) => !tab.deletedRows?.has(i)) + const originalIndexMap = tab.data.map((_, i) => i).filter(i => !tab.deletedRows?.has(i)) return (
@@ -383,9 +545,33 @@ function TableViewer({ tab, onLoadPage }: { ({tab.total} 行)
- - 点击列头图钉可固定列 - + {hasChanges ? ( +
+ + {(tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0)} 项修改待保存 + + + +
+ ) : ( + + 点击单元格可编辑,右键可删除行或设为 NULL + + )} {/* 分页控制 */}
@@ -409,13 +595,24 @@ function TableViewer({ tab, onLoadPage }: {
- {/* 数据表格 - 使用 DataTable 组件支持列固定 */} + {/* 数据表格 - 使用 DataTable 组件支持列固定和编辑 */}
{ + const originalIndex = originalIndexMap[visibleRowIndex] + onCellChange?.(originalIndex, colName, value) + }} + onDeleteRow={(visibleRowIndex) => { + const originalIndex = originalIndexMap[visibleRowIndex] + onDeleteRow?.(originalIndex) + }} />