From e7937e5861663e456cd9ae7e78ed4f8ba683bc4f Mon Sep 17 00:00:00 2001 From: Ethanfly Date: Wed, 31 Dec 2025 22:32:07 +0800 Subject: [PATCH] Add blowfish-node dependency and implement Navicat password decryption functionality in the Electron app. Enhanced main.js and preload.js for cryptographic operations, updated UI components to support new features, and improved database connection handling. --- electron/main.js | 212 +++++ electron/preload.js | 6 +- package-lock.json | 7 + package.json | 1 + src/App.tsx | 204 ++++- src/components/ConnectionModal.tsx | 2 +- src/components/MainContent.tsx | 534 ++++++++++++- src/components/Sidebar.tsx | 65 +- src/components/SqlEditor.tsx | 2 +- src/components/TableDesigner.tsx | 459 +++++++---- src/components/VirtualDataTable.tsx | 1133 ++++++++++++++++++++++++--- src/index.css | 130 ++- src/lib/electron-api.ts | 251 +++++- src/lib/hooks.ts | 69 +- src/types.ts | 1 + 15 files changed, 2665 insertions(+), 411 deletions(-) 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 (
{/* 工具栏 */} -
+