diff --git a/electron/main.js b/electron/main.js index be49502..a638a06 100644 --- a/electron/main.js +++ b/electron/main.js @@ -9,6 +9,7 @@ import initSqlJs from 'sql.js' import { MongoClient } from 'mongodb' import Redis from 'ioredis' import mssql from 'mssql' +import Blowfish from 'blowfish-node' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -907,6 +908,217 @@ ipcMain.handle('file:read', async (event, filePath) => { } }) +// ============ Navicat 密码解密 ============ +// 支持 Navicat 11 和 Navicat 12+ 的密码解密 +ipcMain.handle('crypto:decryptNavicatPassword', async (event, encryptedPassword, version = 12) => { + try { + if (!encryptedPassword) return '' + + // 尝试所有解密方法 + let result = '' + + // 首先尝试 Navicat 12+ (AES-128-CBC) + result = decryptNavicat12(encryptedPassword) + if (result && isPrintableString(result)) { + console.log('Navicat 12 AES 解密成功') + return result + } + + // 尝试 Navicat 11 (Blowfish/XOR) + result = decryptNavicat11(encryptedPassword) + if (result && isPrintableString(result)) { + console.log('Navicat 11 解密成功') + return result + } + + // 如果都失败,返回空字符串 + console.warn('所有解密方法都失败,密码可能使用了不支持的加密方式') + return '' + } catch (e) { + console.error('Navicat 密码解密失败:', e.message) + return '' + } +}) + +// 检查字符串是否为可打印字符 +function isPrintableString(str) { + if (!str || str.length === 0) return false + // 检查是否包含合理的可打印字符 + return /^[\x20-\x7E\u4e00-\u9fa5]+$/.test(str) +} + +// Navicat 12+ AES-128-CBC 解密 +function decryptNavicat12(encryptedPassword) { + // Navicat 12 使用 AES-128-CBC + // 密钥: libcckeylibcckey (16 bytes) + // IV: 多种可能的格式 + + try { + const encryptedBuffer = Buffer.from(encryptedPassword, 'hex') + + if (encryptedBuffer.length === 0) { + return '' + } + + // 尝试多种可能的密钥和 IV 组合 + const attempts = [ + // 组合 1: IV 作为 UTF-8 字符串 + { key: 'libcckeylibcckey', iv: Buffer.from('d0288c8e24342312', 'utf8') }, + // 组合 2: IV 重复两次的十六进制 + { key: 'libcckeylibcckey', iv: Buffer.from('d0288c8e24342312d0288c8e24342312', 'hex') }, + // 组合 3: 字节数组 IV + { key: 'libcckeylibcckey', iv: Buffer.from([0xD0, 0x28, 0x8C, 0x8E, 0x24, 0x34, 0x23, 0x12, 0xD0, 0x28, 0x8C, 0x8E, 0x24, 0x34, 0x23, 0x12]) }, + // 组合 4: 全零 IV + { key: 'libcckeylibcckey', iv: Buffer.alloc(16, 0) }, + // 组合 5: libcciv 作为 IV + { key: 'libcckeylibcckey', iv: Buffer.from('libcciv libcciv ', 'utf8') }, + // 组合 6: 反向字节序 + { key: 'libcckeylibcckey', iv: Buffer.from([0x12, 0x23, 0x34, 0x24, 0x8E, 0x8C, 0x28, 0xD0, 0x12, 0x23, 0x34, 0x24, 0x8E, 0x8C, 0x28, 0xD0]) }, + ] + + for (const attempt of attempts) { + try { + const keyBuffer = Buffer.from(attempt.key, 'utf8') + const decipher = crypto.createDecipheriv('aes-128-cbc', keyBuffer, attempt.iv) + decipher.setAutoPadding(true) + + const decrypted = Buffer.concat([ + decipher.update(encryptedBuffer), + decipher.final() + ]) + + const result = decrypted.toString('utf8').replace(/\0+$/, '') + if (result && isPrintableString(result)) { + return result + } + } catch (e) { + // 继续尝试下一个组合 + } + + // 尝试关闭自动填充 + try { + const keyBuffer = Buffer.from(attempt.key, 'utf8') + const decipher = crypto.createDecipheriv('aes-128-cbc', keyBuffer, attempt.iv) + decipher.setAutoPadding(false) + + let decrypted = Buffer.concat([ + decipher.update(encryptedBuffer), + decipher.final() + ]) + + // 手动移除填充 + const paddingLen = decrypted[decrypted.length - 1] + if (paddingLen > 0 && paddingLen <= 16) { + // 验证填充是否正确 + let validPadding = true + for (let i = 0; i < paddingLen; i++) { + if (decrypted[decrypted.length - 1 - i] !== paddingLen) { + validPadding = false + break + } + } + if (validPadding) { + decrypted = decrypted.slice(0, -paddingLen) + } + } + + const result = decrypted.toString('utf8').replace(/\0+$/, '') + if (result && isPrintableString(result)) { + return result + } + } catch (e) { + // 继续尝试下一个组合 + } + } + + return '' + } catch (e) { + return '' + } +} + +// Navicat 11 解密 - 使用 Blowfish ECB +function decryptNavicat11(encryptedPassword) { + try { + const encryptedBuffer = Buffer.from(encryptedPassword, 'hex') + + if (encryptedBuffer.length === 0) { + return '' + } + + // 方法 1: 使用 Blowfish ECB 模式 + // Navicat 11 密钥是 SHA1("3DC5CA39") 的前 8 字节 + const keyStr = '3DC5CA39' + const sha1Key = crypto.createHash('sha1').update(keyStr).digest() + const blowfishKey = sha1Key.slice(0, 8) + + try { + const bf = new Blowfish(blowfishKey, Blowfish.MODE.ECB, Blowfish.PADDING.NULL) + const decrypted = bf.decode(encryptedBuffer, Blowfish.TYPE.UINT8_ARRAY) + const result = Buffer.from(decrypted).toString('utf8').replace(/\0+$/, '') + if (result && isPrintableString(result)) { + console.log('Blowfish ECB 解密成功') + return result + } + } catch (e) { + // 继续尝试其他方法 + } + + // 方法 2: 直接使用密钥字符串作为 Blowfish 密钥 + try { + const bf = new Blowfish(keyStr, Blowfish.MODE.ECB, Blowfish.PADDING.NULL) + const decrypted = bf.decode(encryptedBuffer, Blowfish.TYPE.UINT8_ARRAY) + const result = Buffer.from(decrypted).toString('utf8').replace(/\0+$/, '') + if (result && isPrintableString(result)) { + console.log('Blowfish ECB (direct key) 解密成功') + return result + } + } catch (e) { + // 继续尝试其他方法 + } + + // 方法 3: XOR 解密(作为后备) + const sha1Hash = crypto.createHash('sha1').update(keyStr).digest() + let result = Buffer.alloc(encryptedBuffer.length) + for (let i = 0; i < encryptedBuffer.length; i++) { + result[i] = encryptedBuffer[i] ^ sha1Hash[i % sha1Hash.length] + } + + let decrypted = result.toString('utf8').replace(/\0+$/, '') + if (decrypted && isPrintableString(decrypted)) { + return decrypted + } + + // 方法 4: Navicat 特定的 XOR 序列 + result = navicatXorDecrypt(encryptedBuffer) + decrypted = result.toString('utf8').replace(/\0+$/, '') + if (decrypted && isPrintableString(decrypted)) { + return decrypted + } + + return '' + } catch (e) { + console.error('Navicat 11 解密错误:', e.message) + return '' + } +} + +// Navicat 特定的 XOR 解密算法 +function navicatXorDecrypt(encryptedBuffer) { + // Navicat 使用特定的 XOR 序列 + const xorKey = Buffer.from([ + 0x42, 0xCE, 0xB2, 0x71, 0xA5, 0xE4, 0x58, 0xB7, + 0x4E, 0x13, 0xEA, 0x1C, 0x91, 0x67, 0xA3, 0x6D + ]) + + const result = Buffer.alloc(encryptedBuffer.length) + for (let i = 0; i < encryptedBuffer.length; i++) { + result[i] = encryptedBuffer[i] ^ xorKey[i % xorKey.length] + } + + return result +} + // ============ 数据库连接辅助函数 ============ async function createConnection(config) { const { type, host, port, username, password, database } = config diff --git a/electron/preload.js b/electron/preload.js index 0dff51b..24a4ff5 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -72,5 +72,9 @@ contextBridge.exposeInMainWorld('electronAPI', { selectFile: (extensions) => ipcRenderer.invoke('file:select', extensions), saveDialog: (options) => ipcRenderer.invoke('file:saveDialog', options), writeFile: (filePath, content) => ipcRenderer.invoke('file:write', filePath, content), - readFile: (filePath) => ipcRenderer.invoke('file:read', filePath) + readFile: (filePath) => ipcRenderer.invoke('file:read', filePath), + + // 密码解密 + decryptNavicatPassword: (encryptedPassword, version) => + ipcRenderer.invoke('crypto:decryptNavicatPassword', encryptedPassword, version) }) diff --git a/package-lock.json b/package-lock.json index 69192a9..30674f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@monaco-editor/react": "^4.7.0", + "blowfish-node": "^1.1.4", "ioredis": "^5.8.2", "lucide-react": "^0.294.0", "monaco-editor": "^0.55.1", @@ -3452,6 +3453,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/blowfish-node": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/blowfish-node/-/blowfish-node-1.1.4.tgz", + "integrity": "sha512-Iahpxc/cutT0M0tgwV5goklB+EzDuiYLgwJg050AmUG2jSIOpViWMLdnRgBxzZuNfswAgHSUiIdvmNdgL2v6DA==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", diff --git a/package.json b/package.json index 9068091..3b6b1e3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "blowfish-node": "^1.1.4", "ioredis": "^5.8.2", "lucide-react": "^0.294.0", "monaco-editor": "^0.55.1", diff --git a/src/App.tsx b/src/App.tsx index 6d57b4d..2d6b1d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import MainContent from './components/MainContent' import ConnectionModal from './components/ConnectionModal' import CreateDatabaseModal from './components/CreateDatabaseModal' import CreateTableModal from './components/CreateTableModal' +import TableDesigner from './components/TableDesigner' import InputDialog from './components/InputDialog' import { Connection, QueryTab, DatabaseType, TableInfo, ColumnInfo, TableTab } from './types' import api from './lib/electron-api' @@ -21,6 +22,8 @@ function App() { const [createDbConnectionId, setCreateDbConnectionId] = useState(null) const [showCreateTableModal, setShowCreateTableModal] = useState(false) const [createTableContext, setCreateTableContext] = useState<{ connectionId: string; database: string } | null>(null) + const [showTableDesigner, setShowTableDesigner] = useState(false) + const [tableDesignerContext, setTableDesignerContext] = useState<{ connectionId: string; database: string; tableName?: string; mode: 'create' | 'edit' } | null>(null) const [inputDialog, setInputDialog] = useState<{ isOpen: boolean title: string @@ -50,6 +53,7 @@ function App() { const { databasesMap, setDatabasesMap, loadingDbSet, setLoadingDbSet, + loadingConnectionsSet, fetchDatabases } = useDatabaseOperations(showNotification) @@ -77,7 +81,11 @@ function App() { const handleConnect = useCallback(async (conn: Connection) => { if (connectedIds.has(conn.id)) return try { - await api.connect(conn) + const result = await api.connect(conn) + if (!result.success) { + showNotification('error', '连接失败:' + result.message) + return + } setConnectedIds(prev => new Set(prev).add(conn.id)) setActiveConnection(conn.id) await fetchDatabases(conn.id) @@ -101,12 +109,27 @@ function App() { next.delete(id) return next }) + // 关闭该连接的所有标签页 + setTabs(prev => { + const remainingTabs = prev.filter(tab => { + // TableTab 有 connectionId + if ('connectionId' in tab && tab.connectionId === id) { + return false + } + return true + }) + // 如果当前活跃标签页被关闭,切换到第一个标签页或主页 + if (activeTab && !remainingTabs.find(t => t.id === activeTab)) { + setActiveTab(remainingTabs.length > 0 ? remainingTabs[0].id : 'welcome') + } + return remainingTabs + }) if (activeConnection === id) setActiveConnection(null) showNotification('info', '连接已断开') } catch (err) { showNotification('error', '断开失败:' + (err as Error).message) } - }, [activeConnection, setConnectedIds, setDatabasesMap, showNotification]) + }, [activeConnection, activeTab, setConnectedIds, setDatabasesMap, setTabs, setActiveTab, showNotification]) // 选择数据库 const handleSelectDatabase = useCallback(async (db: string, connectionId: string) => { @@ -126,40 +149,62 @@ function App() { } }, [fetchTables, fetchColumns, tablesMap, setLoadingDbSet]) + // 切换连接(在查询界面使用,会清空数据库选择) + const handleConnectionChange = useCallback((connectionId: string) => { + // 如果切换到不同的连接,清空数据库选择 + if (connectionId !== activeConnection) { + setSelectedDatabase(null) + } + setActiveConnection(connectionId) + }, [activeConnection]) + // 打开表 const handleOpenTable = useCallback(async (connectionId: string, database: string, tableName: string) => { - const existingTab = tabs.find(t => 'tableName' in t && t.tableName === tableName && t.database === database) + const existingTab = tabs.find(t => 'tableName' in t && t.tableName === tableName && t.database === database && t.connectionId === connectionId) if (existingTab) { setActiveTab(existingTab.id) return } const newTabId = `table-${Date.now()}` + const pageSize = 100 + + // 先创建标签页并显示(带 loading 状态) + const newTab: TableTab = { + id: newTabId, + tableName, + database, + connectionId, + columns: [], + data: [], + total: 0, + page: 1, + pageSize, + pendingChanges: new Map(), + deletedRows: new Set(), + newRows: [] + } + setTabs(prev => [...prev, newTab]) + setActiveTab(newTabId) setLoadingTables(prev => new Set(prev).add(newTabId)) + // 然后异步加载数据 try { const cols = await api.getTableColumns(connectionId, database, tableName) - const pageSize = 100 - const { rows, total } = await api.getTableData(connectionId, database, tableName, 1, pageSize) + const { data, total } = await api.getTableData(connectionId, database, tableName, 1, pageSize) - const newTab: TableTab = { - id: newTabId, - tableName, - database, - connectionId, + // 更新标签页数据 + setTabs(prev => prev.map(t => t.id === newTabId ? { + ...t, columns: cols, - data: rows, - total, - page: 1, - pageSize, - pendingChanges: new Map(), - deletedRows: new Set(), - newRows: [] - } - setTabs(prev => [...prev, newTab]) - setActiveTab(newTabId) + data: data || [], + total + } : t)) } catch (err) { showNotification('error', '打开表失败:' + (err as Error).message) + // 加载失败时移除标签页 + setTabs(prev => prev.filter(t => t.id !== newTabId)) + setActiveTab('welcome') } finally { setLoadingTables(prev => { const next = new Set(prev) @@ -176,8 +221,8 @@ function App() { setLoadingTables(prev => new Set(prev).add(tabId)) try { - const { rows, total } = await api.getTableData(tab.connectionId, tab.database, tab.tableName, page, tab.pageSize) - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, data: rows, total, page, pendingChanges: new Map(), deletedRows: new Set(), newRows: [] } : t)) + const { data, total } = await api.getTableData(tab.connectionId, tab.database, tab.tableName, page, tab.pageSize) + setTabs(prev => prev.map(t => t.id === tabId ? { ...t, data: data || [], total, page, pendingChanges: new Map(), deletedRows: new Set(), newRows: [] } : t)) } catch (err) { showNotification('error', '加载数据失败') } finally { @@ -193,13 +238,22 @@ function App() { const handleUpdateTableCell = useCallback((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 & { pendingChanges: Map } + const tab = t as TableTab & { pendingChanges: Map; data: any[] } + + // 更新 pendingChanges const changes = new Map(tab.pendingChanges) const rowKey = String(rowIndex) const rowChanges = changes.get(rowKey) || {} rowChanges[colName] = value changes.set(rowKey, rowChanges) - return { ...t, pendingChanges: changes } + + // 同时更新 data 数组以便 UI 立即显示更新 + const newData = [...tab.data] + if (newData[rowIndex]) { + newData[rowIndex] = { ...newData[rowIndex], [colName]: value } + } + + return { ...t, data: newData, pendingChanges: changes } })) }, [setTabs]) @@ -330,10 +384,16 @@ function App() { // 新建查询 const handleNewQuery = useCallback(() => { - const newTab: QueryTab = { id: `query-${Date.now()}`, title: `查询 ${tabs.filter(t => !('tableName' in t)).length + 1}`, sql: '', results: null } + const newTab: QueryTab = { + id: `query-${Date.now()}`, + title: `查询 ${tabs.filter(t => !('tableName' in t)).length + 1}`, + sql: '', + connectionId: activeConnection || undefined, + results: null + } setTabs(prev => [...prev, newTab]) setActiveTab(newTab.id) - }, [tabs, setTabs, setActiveTab]) + }, [tabs, setTabs, setActiveTab, activeConnection]) // 执行查询 const handleRunQuery = useCallback(async (tabId: string, sql: string) => { @@ -431,10 +491,10 @@ function App() { } }, [fetchDatabases, showNotification]) - // 创建表 + // 创建表 - 使用 TableDesigner const handleCreateTable = useCallback((connectionId: string, database: string) => { - setCreateTableContext({ connectionId, database }) - setShowCreateTableModal(true) + setTableDesignerContext({ connectionId, database, mode: 'create' }) + setShowTableDesigner(true) }, []) // 删除表 @@ -508,10 +568,11 @@ function App() { showNotification('success', '已刷新') }, [fetchTables, showNotification]) - // 设计表 - const handleDesignTable = useCallback(async (connectionId: string, database: string, table: string) => { - showNotification('info', '表设计器开发中...') - }, [showNotification]) + // 设计表 - 使用 TableDesigner + const handleDesignTable = useCallback((connectionId: string, database: string, table: string) => { + setTableDesignerContext({ connectionId, database, tableName: table, mode: 'edit' }) + setShowTableDesigner(true) + }, []) // 键盘快捷键 useEffect(() => { @@ -537,6 +598,7 @@ function App() { tablesMap={tablesMap} selectedDatabase={selectedDatabase} loadingDbSet={loadingDbSet} + loadingConnectionsSet={loadingConnectionsSet} onNewConnection={() => { setEditingConnection(null); setNewConnectionType(undefined); setShowConnectionModal(true) }} onSelectConnection={setActiveConnection} onConnect={handleConnect} @@ -557,10 +619,16 @@ function App() { onDuplicateTable={handleDuplicateTable} onRefreshTables={handleRefreshTables} onDesignTable={handleDesignTable} + onFetchDatabases={fetchDatabases} /> @@ -634,6 +704,74 @@ function App() { }} /> + {showTableDesigner && tableDesignerContext && ( + c.id === tableDesignerContext.connectionId)?.type || 'mysql'} + onClose={() => { setShowTableDesigner(false); setTableDesignerContext(null) }} + onSave={async (sql: string) => { + try { + await api.executeQuery(tableDesignerContext.connectionId, sql) + await fetchTables(tableDesignerContext.connectionId, tableDesignerContext.database) + showNotification('success', tableDesignerContext.mode === 'create' ? '表创建成功' : '表结构已更新') + return { success: true, message: '' } + } catch (err: any) { + return { success: false, message: err.message || '操作失败' } + } + }} + onGetTableInfo={tableDesignerContext.mode === 'edit' ? async () => { + const cols = await api.getTableColumns(tableDesignerContext.connectionId, tableDesignerContext.database, tableDesignerContext.tableName!) + return { + columns: cols.map((c, i) => ({ + id: `col-${i}`, + name: c.name, + type: c.type.split('(')[0].toUpperCase(), + length: c.type.match(/\((\d+)/)?.[1] || '', + decimals: c.type.match(/,(\d+)\)/)?.[1] || '', + nullable: c.nullable, + primaryKey: c.key === 'PRI', + autoIncrement: c.extra?.includes('auto_increment') || false, + unsigned: c.type.includes('unsigned'), + zerofill: c.type.includes('zerofill'), + defaultValue: c.default || '', + comment: c.comment || '', + isVirtual: false, + virtualExpression: '' + })), + indexes: [], + foreignKeys: [], + options: { engine: 'InnoDB', charset: 'utf8mb4', collation: 'utf8mb4_general_ci', comment: '', autoIncrement: '', rowFormat: '' } + } + } : undefined} + onGetDatabases={async () => databasesMap.get(tableDesignerContext.connectionId) || []} + onGetTables={async (db) => { + // 如果缓存中有表列表,直接返回 + const cached = tablesMap.get(db) + if (cached && cached.length > 0) { + return cached.map(t => t.name) + } + // 否则从 API 加载 + try { + const tables = await api.getTables(tableDesignerContext.connectionId, db) + // 更新缓存 + setTablesMap(prev => new Map(prev).set(db, tables)) + return tables.map((t: any) => t.name || t) + } catch (err) { + console.error('Failed to load tables:', err) + return [] + } + }} + onGetColumns={async (db, table) => { + const cols = await api.getTableColumns(tableDesignerContext.connectionId, db, table) + return cols.map(c => c.name) + }} + /> + )} + {inputDialog && ( +
e.stopPropagation()}> {/* 标题 */}
diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 95ab725..946d0ad 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1,5 +1,5 @@ -import { X, Play, Plus, Minus, Table2, ChevronLeft, ChevronRight, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, Loader2, Check, RefreshCw, Zap } from 'lucide-react' -import { QueryTab, DB_INFO, DatabaseType, TableInfo, ColumnInfo, TableTab } from '../types' +import { X, Play, Plus, Minus, Table2, ChevronLeft, ChevronRight, FolderOpen, Save, AlignLeft, Download, FileSpreadsheet, FileCode, Database, Loader2, Check, RefreshCw, Zap, Server, ChevronDown } from 'lucide-react' +import { QueryTab, DB_INFO, DatabaseType, TableInfo, ColumnInfo, TableTab, Connection } from '../types' import { useState, useEffect, useCallback, memo, Suspense, lazy } from 'react' import { format } from 'sql-formatter' import api from '../lib/electron-api' @@ -21,6 +21,11 @@ type Tab = QueryTab | TableTab interface Props { tabs: Tab[] activeTab: string + activeConnection: string | null + selectedDatabase: string | null + connections: Connection[] + connectedIds: Set + databasesMap: Map databases: string[] tables: TableInfo[] columns: Map @@ -42,12 +47,19 @@ interface Props { onAddTableRow?: (tabId: string) => void onUpdateNewRow?: (tabId: string, rowIndex: number, colName: string, value: any) => void onDeleteNewRow?: (tabId: string, rowIndex: number) => void + onSelectConnection?: (connectionId: string) => void + onSelectDatabase?: (database: string, connectionId: string) => void loadingTables?: Set } const MainContent = memo(function MainContent({ tabs, activeTab, + activeConnection, + selectedDatabase, + connections, + connectedIds, + databasesMap, databases, tables, columns, @@ -69,6 +81,8 @@ const MainContent = memo(function MainContent({ onAddTableRow, onUpdateNewRow, onDeleteNewRow, + onSelectConnection, + onSelectDatabase, loadingTables, }: Props) { useEffect(() => { @@ -175,12 +189,19 @@ const MainContent = memo(function MainContent({ ) : ( onRunQuery(currentTab.id, sql)} onUpdateSql={(sql) => onUpdateSql(currentTab.id, sql)} onUpdateTitle={(title) => onUpdateTabTitle(currentTab.id, title)} + onSelectConnection={onSelectConnection} + onSelectDatabase={onSelectDatabase} /> ) ) : null} @@ -315,7 +336,8 @@ const TableViewer = memo(function TableViewer({ }) const newRowCount = tab.newRows?.length || 0 - const existingDataCount = tab.data.filter((_, i) => !tab.deletedRows?.has(i)).length + const tabData = tab.data || [] + const existingDataCount = tabData.filter((_, i) => !tab.deletedRows?.has(i)).length if (newRowCount > 0) { for (let i = 0; i < newRowCount; i++) { @@ -326,8 +348,8 @@ const TableViewer = memo(function TableViewer({ } } - 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 visibleData = [...tabData.filter((_, i) => !tab.deletedRows?.has(i)), ...(tab.newRows || [])] + const originalIndexMap = tabData.map((_, i) => i).filter(i => !tab.deletedRows?.has(i)) const changesCount = (tab.pendingChanges?.size || 0) + (tab.deletedRows?.size || 0) + (tab.newRows?.length || 0) return ( @@ -358,17 +380,17 @@ const TableViewer = memo(function TableViewer({ - + {tab.page} / {totalPages} @@ -376,7 +398,7 @@ const TableViewer = memo(function TableViewer({ value={tab.pageSize} onChange={(e) => onChangePageSize?.(parseInt(e.target.value))} disabled={isLoading} - className="h-7 px-2 text-xs bg-white border border-border-default rounded cursor-pointer" + className="h-7 px-2 text-xs bg-white border border-border-default rounded cursor-pointer text-text-primary" > @@ -388,16 +410,31 @@ const TableViewer = memo(function TableViewer({ {/* 表格 */}
- {isLoading && ( -
-
- - 加载数据中... + {isLoading && tab.columns.length === 0 ? ( + // 初始加载时显示全屏 loading +
+
+
+ +
+
+
正在加载表数据
+
{tab.tableName}
+
- )} -
- + {isLoading && ( +
+
+ + 加载数据中... +
+
+ )} +
+ -
+
+ + )}
{/* 底部操作栏 */} @@ -483,19 +522,221 @@ const TableViewer = memo(function TableViewer({ // 查询编辑器 const QueryEditor = memo(function QueryEditor({ - tab, databases, tables, columns, onRun, onUpdateSql, onUpdateTitle + tab, connectionId, selectedDatabase, connections, connectedIds, databasesMap, databases, tables, columns, + onRun, onUpdateSql, onUpdateTitle, onSelectConnection, onSelectDatabase }: { tab: QueryTab + connectionId: string | null + selectedDatabase: string | null + connections: Connection[] + connectedIds: Set + databasesMap: Map databases: string[] tables: TableInfo[] columns: Map onRun: (sql: string) => void onUpdateSql: (sql: string) => void onUpdateTitle?: (title: string) => void + onSelectConnection?: (connectionId: string) => void + onSelectDatabase?: (database: string, connectionId: string) => void }) { + const [showConnectionMenu, setShowConnectionMenu] = useState(false) + const [showDatabaseMenu, setShowDatabaseMenu] = useState(false) + + // 获取当前连接信息 + const currentConnection = connections.find(c => c.id === connectionId) + const currentDatabases = connectionId ? (databasesMap.get(connectionId) || []) : [] const [sql, setSql] = useState(tab.sql) const [filePath, setFilePath] = useState(null) const [showExportMenu, setShowExportMenu] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + // 本地数据状态(用于编辑) + const [localData, setLocalData] = useState([]) + const [originalData, setOriginalData] = useState([]) // 保存原始数据用于对比 + const [modifiedCells, setModifiedCells] = useState>(new Set()) + const [deletedRows, setDeletedRows] = useState>(new Set()) // 待删除的行索引(原始数据的索引) + + // 当查询结果变化时,更新本地数据 + useEffect(() => { + if (tab.results) { + const data = tab.results.rows.map(row => { + const obj: Record = {} + tab.results?.columns.forEach((col, i) => { obj[col] = row[i] }) + return obj + }) + setLocalData(data) + setOriginalData(JSON.parse(JSON.stringify(data))) // 深拷贝保存原始数据 + setModifiedCells(new Set()) + setDeletedRows(new Set()) + } else { + setLocalData([]) + setOriginalData([]) + setModifiedCells(new Set()) + setDeletedRows(new Set()) + } + }, [tab.results]) + + // 从SQL中解析表名(支持简单的 SELECT ... FROM table_name 格式) + const parseTableNameFromSql = useCallback((sqlStr: string): string | null => { + // 匹配 FROM table_name 或 FROM `table_name` 或 FROM database.table_name + const match = sqlStr.match(/\bFROM\s+[`"]?(\w+)[`"]?(?:\s*\.\s*[`"]?(\w+)[`"]?)?/i) + if (match) { + // 如果有 database.table 格式,返回表名 + return match[2] || match[1] + } + return null + }, []) + + // 从SQL中解析数据库名 + const parseDatabaseFromSql = useCallback((sqlStr: string): string | null => { + const match = sqlStr.match(/\bFROM\s+[`"]?(\w+)[`"]?\s*\.\s*[`"]?(\w+)[`"]?/i) + if (match) { + return match[1] // 返回数据库名 + } + return null + }, []) + + // 保存修改到数据库(包括更新和删除) + const handleSaveChanges = useCallback(async () => { + if (!connectionId || (modifiedCells.size === 0 && deletedRows.size === 0)) return + + const tableName = parseTableNameFromSql(sql) + if (!tableName) { + alert('无法从SQL中解析表名,只支持简单的 SELECT ... FROM table_name 格式') + return + } + + const database = parseDatabaseFromSql(sql) || selectedDatabase + if (!database) { + alert('无法确定数据库,请先选择数据库') + return + } + + // 找到主键列 + const tableColumns = columns.get(`${database}.${tableName}`) || columns.get(tableName) || [] + let primaryKeyCol = tableColumns.find(c => c.key === 'PRI')?.name + + // 如果找不到主键,尝试用第一列 + if (!primaryKeyCol && tab.results?.columns.length) { + primaryKeyCol = tab.results.columns[0] + } + + if (!primaryKeyCol) { + alert('无法确定主键列,无法保存修改') + return + } + + setIsSaving(true) + + try { + let updateSuccessCount = 0 + let updateErrorCount = 0 + let deleteSuccessCount = 0 + let deleteErrorCount = 0 + + // 1. 执行删除操作 + if (deletedRows.size > 0) { + for (const rowIndex of deletedRows) { + const originalRow = originalData[rowIndex] + if (!originalRow) continue + + const primaryKeyValue = originalRow[primaryKeyCol] + if (primaryKeyValue === null || primaryKeyValue === undefined) { + deleteErrorCount++ + continue + } + + const result = await api.deleteRow( + connectionId, + database, + tableName, + { column: primaryKeyCol, value: primaryKeyValue } + ) + + if (result.success) { + deleteSuccessCount++ + } else { + deleteErrorCount++ + console.error('删除失败:', result.error) + } + } + } + + // 2. 执行更新操作(需要调整索引,因为有些行可能已删除) + if (modifiedCells.size > 0) { + // 按行分组修改 + const rowChanges = new Map>() + modifiedCells.forEach(cellKey => { + const idx = cellKey.indexOf('-') + const rowIndex = parseInt(cellKey.substring(0, idx)) + const colName = cellKey.substring(idx + 1) + + if (!rowChanges.has(rowIndex)) { + rowChanges.set(rowIndex, {}) + } + rowChanges.get(rowIndex)![colName] = localData[rowIndex]?.[colName] + }) + + for (const [localRowIndex, updates] of rowChanges) { + // 找到对应的原始行索引 + // localRowIndex 是当前 localData 中的索引,需要映射回原始数据的索引 + let originalRowIndex = localRowIndex + const sortedDeletedIndices = [...deletedRows].sort((a, b) => a - b) + for (const delIdx of sortedDeletedIndices) { + if (delIdx <= originalRowIndex) { + originalRowIndex++ + } + } + + const originalRow = originalData[originalRowIndex] + if (!originalRow) continue + + const primaryKeyValue = originalRow[primaryKeyCol] + if (primaryKeyValue === null || primaryKeyValue === undefined) { + updateErrorCount++ + continue + } + + const result = await api.updateRow( + connectionId, + database, + tableName, + { column: primaryKeyCol, value: primaryKeyValue }, + updates + ) + + if (result.success) { + updateSuccessCount++ + } else { + updateErrorCount++ + console.error('更新失败:', result.error) + } + } + } + + // 汇总结果 + const messages: string[] = [] + if (deleteSuccessCount > 0) messages.push(`删除 ${deleteSuccessCount} 行`) + if (updateSuccessCount > 0) messages.push(`更新 ${updateSuccessCount} 行`) + if (deleteErrorCount > 0) messages.push(`删除失败 ${deleteErrorCount} 行`) + if (updateErrorCount > 0) messages.push(`更新失败 ${updateErrorCount} 行`) + + if (deleteSuccessCount > 0 || updateSuccessCount > 0) { + // 更新原始数据 + setOriginalData(JSON.parse(JSON.stringify(localData))) + setModifiedCells(new Set()) + setDeletedRows(new Set()) + alert(`操作完成:${messages.join(',')}`) + } else if (deleteErrorCount > 0 || updateErrorCount > 0) { + alert(`操作失败:${messages.join(',')}`) + } + } catch (err: any) { + alert('保存失败: ' + err.message) + } finally { + setIsSaving(false) + } + }, [connectionId, selectedDatabase, sql, modifiedCells, deletedRows, localData, originalData, columns, tab.results, parseTableNameFromSql, parseDatabaseFromSql]) const handleRun = useCallback(() => { onRun(sql) @@ -539,35 +780,109 @@ const QueryEditor = memo(function QueryEditor({ }, [columns]) const handleExportCsv = useCallback(async () => { - if (!tab.results || tab.results.rows.length === 0) return + if (!tab.results || localData.length === 0) return const electronAPI = (window as any).electronAPI if (!electronAPI) return const path = await electronAPI.saveDialog({ filters: [{ name: 'CSV', extensions: ['csv'] }], defaultPath: `query_${Date.now()}.csv` }) if (!path) return const header = tab.results.columns.join(',') - const rows = tab.results.rows.map(row => row.map((v: any) => v === null ? '' : typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v)).join(',')).join('\n') + const rows = localData.map(row => + tab.results!.columns.map(col => { + const v = row[col] + return v === null ? '' : typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v) + }).join(',') + ).join('\n') await electronAPI.writeFile(path, `${header}\n${rows}`) - }, [tab.results]) + }, [tab.results, localData]) const handleExportSql = useCallback(async () => { - if (!tab.results || tab.results.rows.length === 0) return + if (!tab.results || localData.length === 0) return const electronAPI = (window as any).electronAPI if (!electronAPI) return const path = await electronAPI.saveDialog({ filters: [{ name: 'SQL', extensions: ['sql'] }], defaultPath: `query_${Date.now()}.sql` }) if (!path) return - let sqlContent = `-- ${new Date().toLocaleString()}\n-- ${tab.results.rows.length} 条\n\n` - tab.results.rows.forEach(row => { - const values = row.map((val: any) => val === null ? 'NULL' : typeof val === 'number' ? val : `'${String(val).replace(/'/g, "''")}'`).join(', ') + let sqlContent = `-- ${new Date().toLocaleString()}\n-- ${localData.length} 条\n\n` + localData.forEach(row => { + const values = tab.results!.columns.map(col => { + const val = row[col] + return val === null ? 'NULL' : typeof val === 'number' ? val : `'${String(val).replace(/'/g, "''")}'` + }).join(', ') sqlContent += `INSERT INTO table_name (\`${tab.results!.columns.join('`, `')}\`) VALUES (${values});\n` }) await electronAPI.writeFile(path, sqlContent) - }, [tab.results]) + }, [tab.results, localData]) - const resultData = tab.results?.rows.map(row => { - const obj: Record = {} - tab.results?.columns.forEach((col, i) => { obj[col] = row[i] }) - return obj - }) || [] + // 处理单元格编辑 + const handleCellChange = useCallback((rowIndex: number, colName: string, value: any) => { + setLocalData(prev => { + const newData = [...prev] + if (newData[rowIndex]) { + newData[rowIndex] = { ...newData[rowIndex], [colName]: value } + } + return newData + }) + setModifiedCells(prev => new Set(prev).add(`${rowIndex}-${colName}`)) + }, []) + + // 处理删除单行 + const handleDeleteRow = useCallback((rowIndex: number) => { + // 标记为待删除(保留原始索引用于数据库删除) + setDeletedRows(prev => new Set(prev).add(rowIndex)) + // 从本地数据中移除 + setLocalData(prev => prev.filter((_, i) => i !== rowIndex)) + // 清理相关的修改记录(需要调整索引) + setModifiedCells(prev => { + const newSet = new Set() + prev.forEach(cellKey => { + const idx = cellKey.indexOf('-') + const cellRowIndex = parseInt(cellKey.substring(0, idx)) + const colName = cellKey.substring(idx + 1) + if (cellRowIndex < rowIndex) { + newSet.add(cellKey) + } else if (cellRowIndex > rowIndex) { + newSet.add(`${cellRowIndex - 1}-${colName}`) + } + // cellRowIndex === rowIndex 的记录被删除 + }) + return newSet + }) + }, []) + + // 处理批量删除 + const handleDeleteRows = useCallback((rowIndices: number[]) => { + // 从大到小排序,确保删除时索引不会乱 + const sortedIndices = [...rowIndices].sort((a, b) => b - a) + + // 标记所有待删除行 + setDeletedRows(prev => { + const newSet = new Set(prev) + sortedIndices.forEach(idx => newSet.add(idx)) + return newSet + }) + + // 从本地数据中移除 + setLocalData(prev => prev.filter((_, i) => !rowIndices.includes(i))) + + // 清理相关的修改记录 + setModifiedCells(prev => { + const indexSet = new Set(rowIndices) + const newSet = new Set() + prev.forEach(cellKey => { + const idx = cellKey.indexOf('-') + const cellRowIndex = parseInt(cellKey.substring(0, idx)) + const colName = cellKey.substring(idx + 1) + if (!indexSet.has(cellRowIndex)) { + // 计算删除后的新索引 + let newIndex = cellRowIndex + for (const delIdx of sortedIndices) { + if (delIdx < cellRowIndex) newIndex-- + } + newSet.add(`${newIndex}-${colName}`) + } + }) + return newSet + }) + }, []) const resultColumns = tab.results?.columns.map(col => { const colInfo = findColumnInfo(col) @@ -579,6 +894,83 @@ const QueryEditor = memo(function QueryEditor({ {/* 工具栏 */}
+ {/* 连接选择器 */} +
+ + {showConnectionMenu && ( + <> +
setShowConnectionMenu(false)} /> +
+ {connections.filter(c => connectedIds.has(c.id)).length === 0 ? ( +
暂无已连接的数据库
+ ) : ( + connections.filter(c => connectedIds.has(c.id)).map(conn => ( + + )) + )} +
+ + )} +
+ + {/* 数据库选择器 */} +
+ + {showDatabaseMenu && connectionId && ( + <> +
setShowDatabaseMenu(false)} /> +
+ {currentDatabases.length === 0 ? ( +
暂无数据库
+ ) : ( + currentDatabases.map(db => ( + + )) + )} +
+ + )} +
+ +
+
@@ -612,11 +1004,11 @@ const QueryEditor = memo(function QueryEditor({
setShowExportMenu(false)} />
@@ -642,13 +1034,29 @@ const QueryEditor = memo(function QueryEditor({ 结果 - {tab.results && ({tab.results.rows.length.toLocaleString()} 行)} + {tab.results && ( + + ({localData.length.toLocaleString()} 行) + {modifiedCells.size > 0 && · {modifiedCells.size} 已修改} + {deletedRows.size > 0 && · {deletedRows.size} 待删除} + + )}
-
+
{tab.results ? ( - onRun(sql)} /> + onRun(sql)} + onCellChange={handleCellChange} + onDeleteRow={handleDeleteRow} + onDeleteRows={handleDeleteRows} + modifiedCells={modifiedCells} + /> ) : (
@@ -660,6 +1068,50 @@ const QueryEditor = memo(function QueryEditor({
)}
+ {/* 底部工具栏 */} + {tab.results && ( +
+
+ + + +
+
+ {isSaving ? '保存中...' : (modifiedCells.size > 0 || deletedRows.size > 0) + ? `${modifiedCells.size > 0 ? `${modifiedCells.size} 项修改` : ''}${modifiedCells.size > 0 && deletedRows.size > 0 ? ' · ' : ''}${deletedRows.size > 0 ? `${deletedRows.size} 行删除` : ''}` + : `共 ${localData.length} 行`} +
+
+ {sql.length > 50 ? sql.substring(0, 50) + '...' : sql} +
+
+ )}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ed622b2..ca28cc1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -69,16 +69,17 @@ const TableGroupList = memo(function TableGroupList({
{isTablesExpanded && ( -
+
{regularTables.map(table => (
onOpenTable(connectionId, db, table.name)} onContextMenu={(e) => onContextMenu(e, table.name)} + title={table.name} > - - {table.name} + + {table.name}
))}
@@ -105,16 +106,17 @@ const TableGroupList = memo(function TableGroupList({
{isViewsExpanded && ( -
+
{views.map(view => (
onOpenTable(connectionId, db, view.name)} onContextMenu={(e) => onContextMenu(e, view.name)} + title={view.name} > - - {view.name} + + {view.name}
))}
@@ -133,6 +135,7 @@ interface Props { tablesMap: Map selectedDatabase: string | null loadingDbSet: Set + loadingConnectionsSet?: Set onNewConnection: () => void onSelectConnection: (id: string) => void onConnect: (conn: Connection) => void @@ -155,6 +158,7 @@ interface Props { onDuplicateTable?: (connectionId: string, database: string, table: string) => void onRefreshTables?: (connectionId: string, database: string) => void onDesignTable?: (connectionId: string, database: string, table: string) => void + onFetchDatabases?: (connectionId: string) => void } function getMenuPosition(x: number, y: number, menuHeight: number = 200, menuWidth: number = 200) { @@ -183,6 +187,7 @@ export default function Sidebar({ tablesMap, selectedDatabase, loadingDbSet, + loadingConnectionsSet, onNewConnection, onSelectConnection, onConnect, @@ -205,6 +210,7 @@ export default function Sidebar({ onDuplicateTable, onRefreshTables, onDesignTable, + onFetchDatabases, }: Props) { const [menu, setMenu] = useState<{ x: number; y: number; conn: Connection } | null>(null) const [dbMenu, setDbMenu] = useState<{ x: number; y: number; db: string; connectionId: string } | null>(null) @@ -216,6 +222,7 @@ export default function Sidebar({ const searchInputRef = useRef(null) const sidebarRef = useRef(null) const [isFocused, setIsFocused] = useState(false) + const prevConnectedIdsRef = useRef>(new Set()) useEffect(() => { if (selectedDatabase) { @@ -223,6 +230,20 @@ export default function Sidebar({ } }, [selectedDatabase]) + // 当连接状态变化时,只展开新建立的连接(不影响其他已连接但被折叠的连接) + useEffect(() => { + const prevIds = prevConnectedIdsRef.current + // 找出新增的连接 + connectedIds.forEach(id => { + if (!prevIds.has(id)) { + // 只展开新建立的连接 + setExpandedDbs(prev => new Set(prev).add(id)) + } + }) + // 更新引用 + prevConnectedIdsRef.current = new Set(connectedIds) + }, [connectedIds]) + const handleSidebarKeyDown = useCallback((e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isFocused) { e.preventDefault() @@ -280,7 +301,7 @@ export default function Sidebar({ <>
setIsFocused(true)} onBlur={(e) => { @@ -449,18 +470,23 @@ export default function Sidebar({ } else { onSelectConnection(conn.id) if (isConnected) { + const willExpand = !expandedDbs.has(conn.id) setExpandedDbs(prev => { const next = new Set(prev) if (next.has(conn.id)) next.delete(conn.id) else next.add(conn.id) return next }) + // 如果展开但数据库列表为空,尝试获取 + if (willExpand && connDatabases.length === 0 && onFetchDatabases) { + onFetchDatabases(conn.id) + } } } }} onDoubleClick={async () => { if (!multiSelectMode && !isConnected) { - onConnect(conn) + await onConnect(conn) setExpandedDbs(prev => new Set(prev).add(conn.id)) } }} @@ -488,9 +514,18 @@ export default function Sidebar({
{/* 数据库列表 */} - {showDatabases && ( + {isExpanded && isConnected && (
- {getFilteredDatabases(connDatabases).map(db => { + {loadingConnectionsSet?.has(conn.id) ? ( +
+ + 加载数据库... +
+ ) : connDatabases.length === 0 ? ( +
+ 无数据库或无权限 +
+ ) : getFilteredDatabases(connDatabases).map(db => { const isDbSelected = selectedDatabase === db const isDbExpanded = expandedDbs.has(db) const dbTables = getFilteredTables(db) @@ -590,7 +625,11 @@ export default function Sidebar({ ) : ( + | + + + 已选 {tempValues.length} 项 + +
+ + {/* 选项列表 */} +
{filteredOptions.length === 0 ? ( -
无匹配项
+
+
🔍
+ 无匹配字段 +
) : ( filteredOptions.map(opt => (
- {}} - className="w-3 h-3 accent-accent-blue" - /> - {opt.label} +
+ {tempValues.includes(opt.value) && ( + + )} +
+ {opt.label}
)) )}
+ + {/* 底部操作按钮 */} +
+ + +
)}
@@ -603,9 +710,15 @@ export default function TableDesigner({ } } - const updateForeignKey = (id: string, field: keyof ForeignKeyDef, value: any) => { - setForeignKeys(foreignKeys.map(fk => { + const updateForeignKey = (id: string, field: keyof ForeignKeyDef | Record, value?: any) => { + setForeignKeys(prev => prev.map(fk => { if (fk.id !== id) return fk + + // 支持批量更新多个字段 + if (typeof field === 'object') { + return { ...fk, ...field } + } + const updated = { ...fk, [field]: value } // 当选择字段时,自动生成外键名(如果名称为空或以 fk_ 开头) @@ -942,24 +1055,26 @@ export default function TableDesigner({ ] as const return ( -
-
+
+
{/* 标题栏 */} -
+
- - +
+ +
+ {mode === 'create' ? '新建表' : '编辑表'} - {database} {mode === 'edit' && initialTableName && ( - ({initialTableName}) + ({initialTableName}) )}
{/* 表名 & 注释 & 标签页 */} -
+
{/* 表名和注释 */} -
+
表名: setTableName(e.target.value)} placeholder="输入表名" disabled={mode === 'edit'} - className="w-48 h-8 px-3 bg-metro-surface border border-metro-border text-sm - focus:border-accent-blue focus:outline-none transition-colors + className="w-48 h-8 px-3 bg-white border border-border-default text-sm rounded-lg text-text-primary + focus:border-primary-500 focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed" />
@@ -998,26 +1113,26 @@ export default function TableDesigner({ value={options.comment} onChange={(e) => setOptions({ ...options, comment: e.target.value })} placeholder="表注释" - className="flex-1 max-w-md h-8 px-3 bg-metro-surface border border-metro-border text-sm - focus:border-accent-blue focus:outline-none transition-colors" + className="flex-1 max-w-md h-8 px-3 bg-white border border-border-default text-sm rounded-lg text-text-primary + focus:border-primary-500 focus:outline-none transition-colors" />
{/* 标签页 */} -
+
{tabs.map(tab => ( ))} @@ -1026,13 +1141,13 @@ export default function TableDesigner({ {/* 错误提示 */} {error && ( -
+
{error}
)} {/* 内容区 */} -
+
{loading ? (
加载中... @@ -1167,10 +1282,10 @@ function ColumnsTab({ return (
{/* 工具栏 */} -
+